新增:多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> </template>
</v-app-bar> </v-app-bar>
<v-main> <v-main>
<router-view /> <router-view v-slot="{ Component }">
<keep-alive :include="['Home']">
<component :is="Component" />
</keep-alive>
</router-view>
</v-main> </v-main>
</v-app> </v-app>
</template> </template>

View File

@ -49,11 +49,18 @@ export interface PmEventListItem {
[key: string]: unknown [key: string]: unknown
} }
/** 对应 definitions polymarket.PmMarket 常用字段outcomePrices 首项为 Yes 价格 */ /**
* definitions polymarket.PmMarket
* - outcomes: 选项展示文案 ["Yes", "No"] ["Up", "Down"] outcomePrices
* - outcomePrices: 各选项价格 Yes/Up
*/
export interface PmEventMarketItem { export interface PmEventMarketItem {
ID?: number ID?: number
question?: string question?: string
slug?: string slug?: string
/** 选项展示文案,与 outcomePrices 顺序一致 */
outcomes?: string[]
/** 各选项价格outcomes[0] 对应 outcomePrices[0] */
outcomePrices?: string[] | number[] outcomePrices?: string[] | number[]
endDate?: string endDate?: string
volume?: number volume?: number
@ -137,7 +144,23 @@ export async function findPmEvent(
return get<PmEventDetailResponse>('/PmEvent/findPmEvent', { ID: id }, config) 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 { export interface EventCardItem {
id: string id: string
marketTitle: string marketTitle: string
@ -146,6 +169,15 @@ export interface EventCardItem {
imageUrl: string imageUrl: string
category: string category: string
expiresAt: 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 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 * list MarketCard
* IDid, titlemarketTitle, imageimageUrl, volumemarketInfo, * - market marketdisplayType single
* outcomePrices[0]chanceValue, series[0].titlecategory, endDateexpiresAt * - marketsdisplayType multioutcomes +
*/ */
export function mapEventItemToCard(item: PmEventListItem): EventCardItem { export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
const id = String(item.ID ?? '') const id = String(item.ID ?? '')
const marketTitle = item.title ?? '' const marketTitle = item.title ?? ''
const imageUrl = item.image ?? item.icon ?? '' const imageUrl = item.image ?? item.icon ?? ''
const markets = item.markets ?? []
const multi = markets.length > 1
let chanceValue = 17 let chanceValue = 17
const market = item.markets?.[0] const firstMarket = markets[0]
if (market?.outcomePrices?.[0] != null) { if (firstMarket?.outcomePrices?.[0] != null) {
const yesPrice = parseFloat(String(market.outcomePrices[0])) chanceValue = marketChance(firstMarket)
if (Number.isFinite(yesPrice)) {
chanceValue = Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
}
} }
let marketInfo = '$0 Vol.' 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 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 { return {
id, id,
marketTitle, marketTitle,
@ -224,5 +274,10 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
imageUrl, imageUrl,
category, category,
expiresAt, 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> <template>
<v-card class="market-card" elevation="0" :rounded="'lg'" @click="navigateToDetail"> <v-card class="market-card" elevation="0" :rounded="'lg'" @click="navigateToDetail">
<div class="market-card-content"> <div class="market-card-content">
<!-- Top Section --> <!-- Top Section头像 + 标题 + 半圆概率 / 多选项时仅标题 -->
<div class="top-section"> <div class="top-section">
<!-- Market Image and Title -->
<div class="image-title-container"> <div class="image-title-container">
<v-avatar class="market-image" :size="40" color="#f0f0f0" rounded="sm"> <v-avatar class="market-image" :size="40" color="#f0f0f0" rounded="sm">
<v-img v-if="props.imageUrl" :src="props.imageUrl" cover /> <v-img v-if="props.imageUrl" :src="props.imageUrl" cover />
@ -12,12 +11,10 @@
{{ marketTitle }} {{ marketTitle }}
</v-card-title> </v-card-title>
</div> </div>
<!-- 单一类型右侧半圆进度条 -->
<!-- Chance Container半圆进度条 --> <div v-if="!isMulti" class="chance-container">
<div class="chance-container">
<div class="semi-progress-wrap"> <div class="semi-progress-wrap">
<svg class="semi-progress-svg" viewBox="0 0 60 36" preserveAspectRatio="xMidYMin meet"> <svg class="semi-progress-svg" viewBox="0 0 60 36" preserveAspectRatio="xMidYMin meet">
<!-- 半圆弧开口向下M 左上 A 弧至 右上弧朝下 -->
<path <path
class="semi-progress-track" class="semi-progress-track"
d="M 4 30 A 26 26 0 0 1 56 30" d="M 4 30 A 26 26 0 0 1 56 30"
@ -45,31 +42,117 @@
</div> </div>
</div> </div>
<!-- Options Section点击 Yes/No 弹出交易框阻止冒泡不触发卡片跳转 --> <!-- 单一类型中间 Yes/No Up/Down -->
<template v-if="!isMulti">
<div class="options-section"> <div class="options-section">
<v-btn <v-btn
class="option-yes" class="option-yes"
:color="'#e6f9e6'" :color="'#e6f9e6'"
:rounded="'sm'" :rounded="'sm'"
:text="true" :text="true"
@click.stop="openTrade('yes')" elevation="0"
@click.stop="openTradeSingle('yes')"
> >
<span class="option-text-yes">Yes</span> <span class="option-text-yes">{{ yesLabel }}</span>
</v-btn> </v-btn>
<v-btn <v-btn
class="option-no" class="option-no"
:color="'#ffe6e6'" :color="'#ffe6e6'"
:rounded="'sm'" :rounded="'sm'"
:text="true" :text="true"
@click.stop="openTrade('no')" elevation="0"
@click.stop="openTradeSingle('no')"
> >
<span class="option-text-no">No</span> <span class="option-text-no">{{ noLabel }}</span>
</v-btn> </v-btn>
</div> </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"
>
<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 --> <!-- Bottom Section -->
<div class="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"> <div class="icons-container">
<v-icon class="gift-icon" size="16">mdi-gift</v-icon> <v-icon class="gift-icon" size="16">mdi-gift</v-icon>
<v-icon class="bookmark-icon" size="16">mdi-bookmark</v-icon> <v-icon class="bookmark-icon" size="16">mdi-bookmark</v-icon>
@ -80,47 +163,69 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { EventCardOutcome } from '../api/event'
const router = useRouter() const router = useRouter()
const emit = defineEmits<{ 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({ const props = withDefaults(
marketTitle: { defineProps<{
type: String, marketTitle: string
default: 'Mamdan opens city-owned grocery store b...', chanceValue: number
}, marketInfo: string
chanceValue: { id: string
type: Number, imageUrl?: string
default: 17, category?: string
}, expiresAt?: string
marketInfo: { /** 展示类型single 单一 Yes/Nomulti 多选项左右滑动 */
type: String, displayType?: 'single' | 'multi'
default: '$155k Vol.', /** 多选项时的选项列表(用于滑动) */
}, outcomes?: EventCardOutcome[]
id: { /** 单一类型时左按钮文案,如 Up */
type: String, yesLabel?: string
default: '1', /** 单一类型时右按钮文案,如 Down */
}, noLabel?: string
/** 市场图片 URL由卡片传入供详情页展示 */ /** 是否显示 + NEW */
imageUrl: { isNew?: boolean
type: String, }>(),
default: '', {
}, marketTitle: 'Mamdan opens city-owned grocery store b...',
/** 分类标签,如 "Economy · World" */ chanceValue: 17,
category: { marketInfo: '$155k Vol.',
type: String, id: '1',
default: '', imageUrl: '',
}, category: '',
/** 结算/到期日期,如 "Mar 31, 2026" */ expiresAt: '',
expiresAt: { displayType: 'single',
type: String, outcomes: () => [],
default: '', 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° // stroke-dashoffset π*26 0° 180°
const SEMI_CIRCLE_LENGTH = Math.PI * 26 const SEMI_CIRCLE_LENGTH = Math.PI * 26
@ -155,7 +260,6 @@ const semiProgressColor = computed(() => {
) )
}) })
// query
const navigateToDetail = () => { const navigateToDetail = () => {
router.push({ router.push({
path: `/trade-detail/${props.id}`, path: `/trade-detail/${props.id}`,
@ -170,17 +274,26 @@ const navigateToDetail = () => {
}) })
} }
// Yes/No openTrade function openTradeSingle(side: 'yes' | 'no') {
function openTrade(side: 'yes' | 'no') {
emit('openTrade', side, { id: props.id, title: props.marketTitle }) 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> </script>
<style scoped> <style scoped>
/* 单 market 与多 market 统一高度 */
.market-card { .market-card {
position: relative; position: relative;
width: 310px; width: 310px;
height: 160px; height: 176px;
background-color: #ffffff; background-color: #ffffff;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e7e7e7; border: 1px solid #e7e7e7;
@ -190,8 +303,6 @@ function openTrade(side: 'yes' | 'no') {
} }
.market-card:hover { .market-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
border-color: #d0d0d0; border-color: #d0d0d0;
} }
@ -199,7 +310,16 @@ function openTrade(side: 'yes' | 'no') {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; 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 */ /* Top Section */
@ -221,18 +341,25 @@ function openTrade(side: 'yes' | 'no') {
flex-shrink: 0; flex-shrink: 0;
} }
/* 覆盖 Vuetify v-card-title 的 padding、white-space保证两行完整显示且不露第三行 */
.market-title { .market-title {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: 0;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
line-height: 1.4;
color: #000000; color: #000000;
white-space: normal;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
/* 仅文字区域两行高,无 padding 占用 */
max-height: 2.8em;
} }
.chance-container { .chance-container {
@ -287,10 +414,11 @@ function openTrade(side: 'yes' | 'no') {
margin-top: 2px; margin-top: 2px;
} }
/* Options Section */ /* Options Section:单 market 时靠底对齐 */
.options-section { .options-section {
display: flex; display: flex;
gap: 12px; gap: 12px;
margin-top: auto;
} }
.option-yes { .option-yes {
@ -319,12 +447,13 @@ function openTrade(side: 'yes' | 'no') {
color: #ff0000; color: #ff0000;
} }
/* Bottom Section */ /* Bottom Section:禁止被挤压,避免与 multi-section 重叠 */
.bottom-section { .bottom-section {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: auto; margin-top: auto;
flex-shrink: 0;
} }
.market-info { .market-info {
@ -346,4 +475,170 @@ function openTrade(side: 'yes' | 'no') {
.bookmark-icon { .bookmark-icon {
color: #808080; 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> </style>

View File

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

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="home-page"> <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-row justify="center" align="center" class="home-tabs">
<v-tabs v-model="activeTab" class="home-tab-bar"> <v-tabs v-model="activeTab" class="home-tab-bar">
<v-tab value="overview">Market Overview</v-tab> <v-tab value="overview">Market Overview</v-tab>
@ -12,7 +12,7 @@
<div ref="scrollRef" class="home-list-scroll"> <div ref="scrollRef" class="home-list-scroll">
<v-pull-to-refresh class="pull-to-refresh" @load="onRefresh"> <v-pull-to-refresh class="pull-to-refresh" @load="onRefresh">
<div class="pull-to-refresh-inner"> <div class="pull-to-refresh-inner">
<div class="home-list"> <div ref="listRef" class="home-list" :style="gridListStyle">
<MarketCard <MarketCard
v-for="card in eventList" v-for="card in eventList"
:key="card.id" :key="card.id"
@ -23,6 +23,11 @@
:image-url="card.imageUrl" :image-url="card.imageUrl"
:category="card.category" :category="card.category"
:expires-at="card.expiresAt" :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" @open-trade="onCardOpenTrade"
/> />
<div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty"> <div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
@ -155,6 +160,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'Home' })
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue' import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import MarketCard from '../components/MarketCard.vue' 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) const sentinelRef = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null let observer: IntersectionObserver | null = null
let resizeObserver: ResizeObserver | null = null
const SCROLL_LOAD_THRESHOLD = 280 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公开接口无需鉴权成功后会更新内存缓存 */ /** 请求事件列表并追加或覆盖到 eventList公开接口无需鉴权成功后会更新内存缓存 */
async function loadEvents(page: number, append: boolean) { async function loadEvents(page: number, append: boolean) {
try { try {
@ -256,12 +282,10 @@ function loadMore() {
} }
function checkScrollLoad() { function checkScrollLoad() {
const el = scrollRef.value if (loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return
if (!el || loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return const { scrollY, innerHeight } = window
const { scrollTop, clientHeight, scrollHeight } = el const scrollHeight = document.documentElement.scrollHeight
if (scrollHeight - scrollTop - clientHeight < SCROLL_LOAD_THRESHOLD) { if (scrollHeight - scrollY - innerHeight < SCROLL_LOAD_THRESHOLD) loadMore()
loadMore()
}
} }
onMounted(() => { onMounted(() => {
@ -275,20 +299,24 @@ onMounted(() => {
loadEvents(0, false) loadEvents(0, false)
} }
nextTick(() => { nextTick(() => {
const scrollEl = scrollRef.value
const sentinel = sentinelRef.value const sentinel = sentinelRef.value
if (!sentinel || !scrollEl) return if (sentinel) {
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
if (!entries[0]?.isIntersecting) return if (!entries[0]?.isIntersecting) return
loadMore() loadMore()
}, },
{ root: scrollEl, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 } { root: null, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 }
) )
observer.observe(sentinel) observer.observe(sentinel)
}
scrollEl.addEventListener('scroll', checkScrollLoad, { passive: true }) 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 const sentinel = sentinelRef.value
if (observer && sentinel) observer.unobserve(sentinel) if (observer && sentinel) observer.unobserve(sentinel)
observer = null observer = null
scrollRef.value?.removeEventListener('scroll', checkScrollLoad) if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
window.removeEventListener('scroll', checkScrollLoad)
}) })
</script> </script>
<style scoped> <style scoped>
/* fluid 后无断点 max-width用自定义 max-width 让列表在 2137px 等宽屏下能算到 6 列 */
.home-container { .home-container {
min-height: 100vh; min-height: 100vh;
max-width: 2560px;
margin-left: auto;
margin-right: auto;
} }
.home-header { .home-header {
@ -326,22 +362,17 @@ onUnmounted(() => {
min-height: 100%; min-height: 100%;
} }
/* 不设固定高度与 overflow列表随页面窗口滚动便于 Vue Router scrollBehavior 自动恢复位置 */
.home-list-scroll { .home-list-scroll {
width: 100%; width: 100%;
overflow-y: auto;
overflow-x: hidden; 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 { .home-list {
width: 100%; width: 100%;
display: grid; display: grid;
gap: 20px; gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
} }
.home-list-empty { .home-list-empty {
@ -404,12 +435,6 @@ onUnmounted(() => {
padding: 0; padding: 0;
} }
/* 大屏最多 4 列,避免过宽时出现 5 列以上 */
@media (min-width: 1320px) {
.home-list {
grid-template-columns: repeat(4, minmax(310px, 1fr));
}
}
.home-subtitle { .home-subtitle {
margin-bottom: 40px; margin-bottom: 40px;