新增:多Market卡片UI
This commit is contained in:
parent
f0c1be71cb
commit
a572fdfa99
@ -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>
|
||||
|
||||
@ -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 返回结构一致,用于缓存)
|
||||
* displayType:single = 单一 Yes/No,multi = 多个选项左右滑动
|
||||
*/
|
||||
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 所需字段
|
||||
* 字段对应:ID→id, title→marketTitle, image→imageUrl, volume→marketInfo,
|
||||
* outcomePrices[0]→chanceValue, series[0].title→category, endDate→expiresAt
|
||||
* - 单一 market 或无 market:displayType single,当前逻辑
|
||||
* - 多个 markets:displayType multi,outcomes 为每项标题+概率,卡片内左右滑动切换
|
||||
*/
|
||||
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
115
src/api/mockEventList.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import type { PmEventListItem } from './event'
|
||||
|
||||
/**
|
||||
* 模拟事件列表,用于本地测试:
|
||||
* - 动态按钮文案(Up/Down、Yes/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. 单一 market,Yes/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' }],
|
||||
},
|
||||
]
|
||||
@ -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/No,multi 多选项左右滑动 */
|
||||
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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 一致 310px;auto-fill 按可用宽度自动 1~4 列,避免第三列被裁 */
|
||||
/* 列数由 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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user