新增:多Market详情

This commit is contained in:
ivan 2026-02-11 12:19:40 +08:00
parent c6e896efc3
commit e14bd3bc23
11 changed files with 1175 additions and 43 deletions

3
.env Normal file
View File

@ -0,0 +1,3 @@
# API 基础地址,不设置时默认 https://api.xtrader.vip
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释:
VITE_API_BASE_URL=http://192.168.3.21:8888

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
# API 基础地址,不设置时默认 https://api.xtrader.vip
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释:
# VITE_API_BASE_URL=http://192.168.3.21:8888

View File

@ -106,7 +106,7 @@ export interface GetPmEventListParams {
export async function getPmEventPublic( export async function getPmEventPublic(
params: GetPmEventListParams = {} params: GetPmEventListParams = {}
): Promise<PmEventListResponse> { ): Promise<PmEventListResponse> {
const { page = 0, pageSize = 10, keyword, createdAtRange } = params const { page = 1, pageSize = 10, keyword, createdAtRange } = params
const query: Record<string, string | number | string[] | undefined> = { const query: Record<string, string | number | string[] | undefined> = {
page, page,
pageSize, pageSize,

31
src/api/market.ts Normal file
View File

@ -0,0 +1,31 @@
import { post } from './request'
/** 通用响应:与 doc.json response.Response 一致 */
export interface ApiResponse<T = unknown> {
code: number
data?: T
msg: string
}
/**
* Split 192.168.3.21:8888 /PmMarket/split
* USDC Yes+No 1 USDC 1 Yes + 1 No
*/
export interface PmMarketSplitRequest {
/** 市场 ID */
marketId: string
/** 要 split 的 USDC 金额 */
amount: number
}
/**
* /PmMarket/split
* - x-token
* - 使 VITE_API_BASE_URL http://192.168.3.21:8888 连接测试服
*/
export async function pmMarketSplit(
data: PmMarketSplitRequest,
config?: { headers?: Record<string, string> }
): Promise<ApiResponse> {
return post<ApiResponse>('/PmMarket/split', data, config)
}

View File

@ -112,4 +112,64 @@ export const MOCK_EVENT_LIST: PmEventListItem[] = [
series: [{ ID: 4, title: 'Sports', ticker: 'SPORT' }], series: [{ ID: 4, title: 'Sports', ticker: 'SPORT' }],
tags: [{ label: 'World Cup', slug: 'world-cup' }], tags: [{ label: 'World Cup', slug: 'world-cup' }],
}, },
// 5. 多 marketElon Musk 推文数量区间(类似 Polymarket 多档位)
{
ID: 9005,
title: 'Elon Musk # tweets February 3 - February 10, 2026?',
slug: 'elon-musk-tweets-feb-2026',
ticker: 'TWEET',
image: '',
icon: '',
volume: 22136050,
endDate: '2026-02-10T23:59:59.000Z',
new: true,
markets: [
{ ID: 90051, question: '260-279', outcomes: ['Yes', 'No'], outcomePrices: [0.01, 0.99], volume: 120000 },
{ ID: 90052, question: '280-299', outcomes: ['Yes', 'No'], outcomePrices: [0.42, 0.58], volume: 939379 },
{ ID: 90053, question: '300-319', outcomes: ['Yes', 'No'], outcomePrices: [0.45, 0.55], volume: 850000 },
{ ID: 90054, question: '320-339', outcomes: ['Yes', 'No'], outcomePrices: [0.16, 0.84], volume: 320000 },
{ ID: 90055, question: '340-359', outcomes: ['Yes', 'No'], outcomePrices: [0.08, 0.92], volume: 180000 },
],
series: [{ ID: 5, title: 'Culture', ticker: 'CULTURE' }],
tags: [{ label: 'Tech', slug: 'tech' }, { label: 'Twitter', slug: 'twitter' }],
},
// 6. 多 market总统候选人胜选概率多候选人
{
ID: 9006,
title: 'Who wins the 2028 U.S. Presidential Election?',
slug: 'us-president-2028',
ticker: 'POL28',
image: '',
icon: '',
volume: 12500000,
endDate: '2028-11-07T23:59:59.000Z',
new: true,
markets: [
{ ID: 90061, question: 'Democrat nominee', outcomes: ['Yes', 'No'], outcomePrices: [0.48, 0.52], volume: 3200000 },
{ ID: 90062, question: 'Republican nominee', outcomes: ['Yes', 'No'], outcomePrices: [0.45, 0.55], volume: 3100000 },
{ ID: 90063, question: 'Third party / Independent', outcomes: ['Yes', 'No'], outcomePrices: [0.07, 0.93], volume: 800000 },
],
series: [{ ID: 6, title: 'Politics', ticker: 'POL' }],
tags: [{ label: 'Election', slug: 'election' }],
},
// 7. 多 marketNBA 分区冠军4 个选项)
{
ID: 9007,
title: 'NBA Eastern Conference Champion 2025-26?',
slug: 'nba-east-champion-2026',
ticker: 'NBA26',
image: '',
icon: '',
volume: 890000,
endDate: '2026-05-31T23:59:59.000Z',
new: false,
markets: [
{ ID: 90071, question: 'Boston Celtics', outcomes: ['Yes', 'No'], outcomePrices: [0.35, 0.65], volume: 280000 },
{ ID: 90072, question: 'Milwaukee Bucks', outcomes: ['Yes', 'No'], outcomePrices: [0.28, 0.72], volume: 220000 },
{ ID: 90073, question: 'Philadelphia 76ers', outcomes: ['Yes', 'No'], outcomePrices: [0.18, 0.82], volume: 150000 },
{ ID: 90074, question: 'New York Knicks', outcomes: ['Yes', 'No'], outcomePrices: [0.12, 0.88], volume: 120000 },
],
series: [{ ID: 7, title: 'Sports', ticker: 'SPORT' }],
tags: [{ label: 'NBA', slug: 'nba' }],
},
] ]

View File

@ -39,3 +39,27 @@ export async function get<T = unknown>(
} }
return res.json() as Promise<T> return res.json() as Promise<T>
} }
/**
* x-token POST
*/
export async function post<T = unknown>(
path: string,
body?: unknown,
config?: RequestConfig
): Promise<T> {
const url = new URL(path, BASE_URL || window.location.origin)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...config?.headers,
}
const res = await fetch(url.toString(), {
method: 'POST',
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json() as Promise<T>
}

View File

@ -261,6 +261,10 @@ const semiProgressColor = computed(() => {
}) })
const navigateToDetail = () => { const navigateToDetail = () => {
if (props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1) {
router.push({ path: `/event/${props.id}/markets` })
return
}
router.push({ router.push({
path: `/trade-detail/${props.id}`, path: `/trade-detail/${props.id}`,
query: { query: {

View File

@ -25,7 +25,7 @@
<v-list-item @click="openMergeDialog"> <v-list-item @click="openMergeDialog">
<v-list-item-title>Merge</v-list-item-title> <v-list-item-title>Merge</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item> <v-list-item @click="openSplitDialog">
<v-list-item-title>Split</v-list-item-title> <v-list-item-title>Split</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
@ -44,7 +44,7 @@
text text
@click="handleOptionChange('yes')" @click="handleOptionChange('yes')"
> >
Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }} Yes {{ yesPriceCents }}¢
</v-btn> </v-btn>
<v-btn <v-btn
class="no-btn" class="no-btn"
@ -52,7 +52,7 @@
text text
@click="handleOptionChange('no')" @click="handleOptionChange('no')"
> >
No {{ selectedOption === 'no' ? '82¢' : '81¢' }} No {{ noPriceCents }}¢
</v-btn> </v-btn>
</div> </div>
@ -100,7 +100,7 @@
text text
@click="handleOptionChange('yes')" @click="handleOptionChange('yes')"
> >
Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }} Yes {{ yesPriceCents }}¢
</v-btn> </v-btn>
<v-btn <v-btn
class="no-btn" class="no-btn"
@ -108,7 +108,7 @@
text text
@click="handleOptionChange('no')" @click="handleOptionChange('no')"
> >
No {{ selectedOption === 'no' ? '82¢' : '81¢' }} No {{ noPriceCents }}¢
</v-btn> </v-btn>
</div> </div>
@ -151,7 +151,7 @@
text text
@click="handleOptionChange('yes')" @click="handleOptionChange('yes')"
> >
Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }} Yes {{ yesPriceCents }}¢
</v-btn> </v-btn>
<v-btn <v-btn
class="no-btn" class="no-btn"
@ -159,7 +159,7 @@
text text
@click="handleOptionChange('no')" @click="handleOptionChange('no')"
> >
No {{ selectedOption === 'no' ? '82¢' : '81¢' }} No {{ noPriceCents }}¢
</v-btn> </v-btn>
</div> </div>
@ -295,15 +295,15 @@
<v-list-item @click="limitType = 'Limit'"><v-list-item-title>Limit</v-list-item-title></v-list-item> <v-list-item @click="limitType = 'Limit'"><v-list-item-title>Limit</v-list-item-title></v-list-item>
<v-divider></v-divider> <v-divider></v-divider>
<v-list-item @click="openMergeDialog"><v-list-item-title>Merge</v-list-item-title></v-list-item> <v-list-item @click="openMergeDialog"><v-list-item-title>Merge</v-list-item-title></v-list-item>
<v-list-item><v-list-item-title>Split</v-list-item-title></v-list-item> <v-list-item @click="openSplitDialog"><v-list-item-title>Split</v-list-item-title></v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</div> </div>
<template v-if="isMarketMode"> <template v-if="isMarketMode">
<template v-if="balance > 0"> <template v-if="balance > 0">
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="total-section"> <div class="total-section">
<template v-if="activeTab === 'buy'"> <template v-if="activeTab === 'buy'">
@ -318,8 +318,8 @@
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="input-group"> <div class="input-group">
<div class="amount-header"> <div class="amount-header">
@ -338,8 +338,8 @@
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="input-group limit-price-group"> <div class="input-group limit-price-group">
<div class="limit-price-header"> <div class="limit-price-header">
@ -445,15 +445,15 @@
<v-list-item @click="limitType = 'Limit'"><v-list-item-title>Limit</v-list-item-title></v-list-item> <v-list-item @click="limitType = 'Limit'"><v-list-item-title>Limit</v-list-item-title></v-list-item>
<v-divider></v-divider> <v-divider></v-divider>
<v-list-item @click="openMergeDialog"><v-list-item-title>Merge</v-list-item-title></v-list-item> <v-list-item @click="openMergeDialog"><v-list-item-title>Merge</v-list-item-title></v-list-item>
<v-list-item><v-list-item-title>Split</v-list-item-title></v-list-item> <v-list-item @click="openSplitDialog"><v-list-item-title>Split</v-list-item-title></v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</div> </div>
<template v-if="isMarketMode"> <template v-if="isMarketMode">
<template v-if="balance > 0"> <template v-if="balance > 0">
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="total-section"> <div class="total-section">
<template v-if="activeTab === 'buy'"> <template v-if="activeTab === 'buy'">
@ -468,8 +468,8 @@
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="input-group"> <div class="input-group">
<div class="amount-header"> <div class="amount-header">
@ -488,8 +488,8 @@
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ selectedOption === 'yes' ? '19¢' : '18¢' }}</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ selectedOption === 'no' ? '82¢' : '81¢' }}</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="input-group limit-price-group"> <div class="input-group limit-price-group">
<div class="limit-price-header"> <div class="limit-price-header">
@ -587,17 +587,77 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- Split dialog对接 /PmMarket/split -->
<v-dialog v-model="splitDialogOpen" max-width="440" persistent content-class="split-dialog" transition="dialog-transition">
<v-card class="split-dialog-card" rounded="lg">
<div class="split-dialog-header">
<h3 class="split-dialog-title">Split</h3>
<v-btn icon variant="text" size="small" class="split-dialog-close" @click="splitDialogOpen = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text class="split-dialog-body">
<p class="split-dialog-desc">
Use USDC to get one share of Yes and one share of No for this market. 1 USDC 1 complete set.
</p>
<div class="split-amount-row">
<label class="split-amount-label">Amount (USDC)</label>
<v-text-field
v-model.number="splitAmount"
type="number"
min="0"
step="1"
hide-details
density="compact"
variant="outlined"
class="split-amount-input"
/>
</div>
<p v-if="!props.market?.marketId" class="split-no-market">Please select a market first (e.g. click Buy Yes/No on a market).</p>
<p v-if="splitError" class="split-error">{{ splitError }}</p>
</v-card-text>
<v-card-actions class="split-dialog-actions">
<v-btn
color="primary"
variant="flat"
block
class="split-submit-btn"
:loading="splitLoading"
:disabled="splitLoading || !props.market?.marketId || splitAmount <= 0"
@click="submitSplit"
>
Split
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useUserStore } from '../stores/user'
import { pmMarketSplit } from '../api/market'
const { mobile } = useDisplay() const { mobile } = useDisplay()
const userStore = useUserStore()
export interface TradeMarketPayload {
marketId?: string
yesPrice: number
noPrice: number
title?: string
}
const props = withDefaults( const props = withDefaults(
defineProps<{ initialOption?: 'yes' | 'no'; embeddedInSheet?: boolean }>(), defineProps<{
{ initialOption: undefined, embeddedInSheet: false } initialOption?: 'yes' | 'no'
embeddedInSheet?: boolean
/** 从外部传入的市场数据(如 EventMarkets 点击 Yes/No 传入yesPrice/noPrice 为 01 */
market?: TradeMarketPayload
}>(),
{ initialOption: undefined, embeddedInSheet: false, market: undefined }
) )
// //
@ -618,8 +678,44 @@ function submitMerge() {
mergeDialogOpen.value = false mergeDialogOpen.value = false
} }
const yesPriceCents = computed(() => 19) // Split dialog /PmMarket/split
const noPriceCents = computed(() => 82) const splitDialogOpen = ref(false)
const splitAmount = ref(0)
const splitLoading = ref(false)
const splitError = ref('')
function openSplitDialog() {
splitAmount.value = 0
splitError.value = ''
splitDialogOpen.value = true
}
async function submitSplit() {
const marketId = props.market?.marketId
if (!marketId || splitAmount.value <= 0) return
splitLoading.value = true
splitError.value = ''
try {
const res = await pmMarketSplit(
{ marketId, amount: splitAmount.value },
{ headers: userStore.token ? { 'x-token': userStore.token } : undefined }
)
if (res.code === 0 || res.code === 200) {
splitDialogOpen.value = false
} else {
splitError.value = res.msg || 'Split failed'
}
} catch (e) {
splitError.value = e instanceof Error ? e.message : 'Request failed'
} finally {
splitLoading.value = false
}
}
const yesPriceCents = computed(() =>
props.market ? Math.round(props.market.yesPrice * 100) : 19
)
const noPriceCents = computed(() =>
props.market ? Math.round(props.market.noPrice * 100) : 82
)
function openSheet(option: 'yes' | 'no') { function openSheet(option: 'yes' | 'no') {
handleOptionChange(option) handleOptionChange(option)
@ -651,6 +747,7 @@ const emit = defineEmits<{
shares: number shares: number
expirationEnabled: boolean expirationEnabled: boolean
expirationTime: string expirationTime: string
marketId?: string
}] }]
}>() }>()
@ -669,11 +766,9 @@ const actionButtonText = computed(() => {
function applyInitialOption(option: 'yes' | 'no') { function applyInitialOption(option: 'yes' | 'no') {
selectedOption.value = option selectedOption.value = option
if (option === 'yes') { const yesP = props.market?.yesPrice ?? 0.19
limitPrice.value = 0.19 const noP = props.market?.noPrice ?? 0.82
} else { limitPrice.value = option === 'yes' ? yesP : noP
limitPrice.value = 0.82
}
} }
onMounted(() => { onMounted(() => {
@ -683,15 +778,16 @@ watch(() => props.initialOption, (option) => {
if (option) applyInitialOption(option) if (option) applyInitialOption(option)
}, { immediate: true }) }, { immediate: true })
watch(() => props.market, (m) => {
if (m && props.initialOption) applyInitialOption(props.initialOption)
}, { deep: true })
// Methods // Methods
const handleOptionChange = (option: 'yes' | 'no') => { const handleOptionChange = (option: 'yes' | 'no') => {
selectedOption.value = option selectedOption.value = option
// const yesP = props.market?.yesPrice ?? 0.19
if (option === 'yes') { const noP = props.market?.noPrice ?? 0.82
limitPrice.value = 0.19 limitPrice.value = option === 'yes' ? yesP : noP
} else {
limitPrice.value = 0.82
}
emit('optionChange', option) emit('optionChange', option)
} }
@ -740,6 +836,7 @@ function submitOrder() {
shares: shares.value, shares: shares.value,
expirationEnabled: expirationEnabled.value, expirationEnabled: expirationEnabled.value,
expirationTime: expirationTime.value, expirationTime: expirationTime.value,
...(props.market?.marketId != null && { marketId: props.market.marketId }),
}) })
} }
</script> </script>
@ -1278,4 +1375,72 @@ function submitOrder() {
text-transform: none; text-transform: none;
font-weight: 600; font-weight: 600;
} }
/* Split dialog */
.split-dialog-card {
padding: 0;
overflow: hidden;
}
.split-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 0;
}
.split-dialog-title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0;
}
.split-dialog-close {
color: #6b7280;
}
.split-dialog-body {
padding: 16px 20px 8px;
}
.split-dialog-desc {
font-size: 14px;
color: #374151;
line-height: 1.5;
margin: 0 0 16px 0;
}
.split-amount-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.split-amount-label {
font-size: 14px;
font-weight: 600;
color: #111827;
flex-shrink: 0;
}
.split-amount-input {
flex: 1;
max-width: 160px;
}
.split-amount-input :deep(.v-field) {
font-size: 14px;
}
.split-no-market,
.split-error {
font-size: 13px;
margin: 8px 0 0;
}
.split-no-market {
color: #6b7280;
}
.split-error {
color: #dc2626;
}
.split-dialog-actions {
padding: 16px 20px 20px;
padding-top: 8px;
}
.split-submit-btn {
text-transform: none;
font-weight: 600;
}
</style> </style>

View File

@ -3,6 +3,7 @@ import Home from '../views/Home.vue'
import Trade from '../views/Trade.vue' import Trade from '../views/Trade.vue'
import Login from '../views/Login.vue' import Login from '../views/Login.vue'
import TradeDetail from '../views/TradeDetail.vue' import TradeDetail from '../views/TradeDetail.vue'
import EventMarkets from '../views/EventMarkets.vue'
import Wallet from '../views/Wallet.vue' import Wallet from '../views/Wallet.vue'
const router = createRouter({ const router = createRouter({
@ -28,6 +29,11 @@ const router = createRouter({
name: 'trade-detail', name: 'trade-detail',
component: TradeDetail component: TradeDetail
}, },
{
path: '/event/:id/markets',
name: 'event-markets',
component: EventMarkets
},
{ {
path: '/wallet', path: '/wallet',
name: 'wallet', name: 'wallet',

836
src/views/EventMarkets.vue Normal file
View File

@ -0,0 +1,836 @@
<template>
<v-container class="event-markets-container">
<v-card v-if="detailLoading && !eventDetail" class="loading-card" elevation="0" rounded="lg">
<div class="loading-placeholder">
<v-progress-circular indeterminate color="primary" size="48" />
<p>加载中...</p>
</div>
</v-card>
<template v-else-if="detailError">
<v-card class="error-card" elevation="0" rounded="lg">
<p class="error-text">{{ detailError }}</p>
</v-card>
</template>
<template v-else-if="eventDetail">
<div class="event-header">
<h1 class="event-title">{{ eventDetail.title }}</h1>
<p v-if="eventDetail.series?.length || eventDetail.tags?.length" class="event-meta">
{{ categoryText }}
</p>
<p v-if="eventVolume" class="event-volume">{{ eventVolume }}</p>
</div>
<v-row align="stretch" class="event-markets-row">
<!-- 左侧分时图 + 市场列表 -->
<v-col cols="12" class="chart-col">
<!-- 分时图卡片多市场多条线 -->
<v-card
v-if="markets.length > 0"
class="chart-card polymarket-chart"
elevation="0"
rounded="lg"
>
<div class="chart-header">
<h1 class="chart-title">{{ eventDetail?.title || 'All markets' }}</h1>
<div class="chart-controls-row">
<v-btn variant="text" size="small" class="past-btn">Past </v-btn>
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
</div>
<p class="chart-legend-hint">{{ markets.length }} 个市场</p>
</div>
<div class="chart-wrapper">
<div ref="chartContainerRef" class="chart-container"></div>
</div>
<div class="chart-footer">
<div class="chart-footer-left">
<span class="chart-volume">{{ eventVolume || '$0 Vol.' }}</span>
<span v-if="marketExpiresAt" class="chart-expires">| {{ marketExpiresAt }}</span>
</div>
<div class="chart-time-ranges">
<v-btn
v-for="r in timeRanges"
:key="r.value"
:class="['time-range-btn', { active: selectedTimeRange === r.value }]"
variant="text"
size="small"
@click="selectTimeRange(r.value)"
>
{{ r.label }}
</v-btn>
</div>
</div>
</v-card>
<!-- 市场列表 -->
<v-card class="markets-list-card" elevation="0" rounded="lg">
<div class="markets-list">
<div
v-for="(market, index) in markets"
:key="market.ID ?? index"
class="market-row"
:class="{ selected: selectedMarketIndex === index }"
@click="goToTradeDetail(market)"
>
<div class="market-row-main">
<span class="market-question">{{ market.question || 'Market' }}</span>
<span class="market-chance">{{ marketChance(market) }}%</span>
</div>
<div class="market-row-vol">{{ formatVolume(market.volume) }}</div>
<div class="market-row-actions" @click.stop>
<v-btn
class="buy-yes-btn"
variant="flat"
size="small"
@click="openTrade(market, index, 'yes')"
>
Buy Yes {{ yesPrice(market) }}
</v-btn>
<v-btn
class="buy-no-btn"
variant="flat"
size="small"
@click="openTrade(market, index, 'no')"
>
Buy No {{ noPrice(market) }}
</v-btn>
</div>
</div>
</div>
</v-card>
</v-col>
<!-- 右侧购买组件点击 Yes/No 时传入当前市场数据 -->
<v-col cols="12" class="trade-col">
<div v-if="markets.length > 0" class="trade-sidebar">
<TradeComponent
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
@submit="onTradeSubmit"
/>
</div>
</v-col>
</v-row>
</template>
</v-container>
</template>
<script setup lang="ts">
defineOptions({ name: 'EventMarkets' })
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import TradeComponent from '../components/TradeComponent.vue'
import { findPmEvent, type PmEventListItem, type PmEventMarketItem } from '../api/event'
import { MOCK_EVENT_LIST } from '../api/mockEventList'
import { useUserStore } from '../stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const eventDetail = ref<PmEventListItem | null>(null)
const detailLoading = ref(false)
const detailError = ref<string | null>(null)
const selectedMarketIndex = ref(0)
/** 点击 Buy Yes/No 时传给购买组件的初始方向,不点击则为 undefined 使用组件默认 */
const tradeInitialOption = ref<'yes' | 'no' | undefined>(undefined)
const markets = computed(() => {
const list = eventDetail.value?.markets ?? []
return list.length > 0 ? list : []
})
const selectedMarket = computed(() => markets.value[selectedMarketIndex.value] ?? null)
/** 传给购买组件的市场数据(当前选中的市场) */
const tradeMarketPayload = computed(() => {
const m = selectedMarket.value
if (!m) return undefined
const yesRaw = m.outcomePrices?.[0]
const noRaw = m.outcomePrices?.[1]
const yesPrice = yesRaw != null && Number.isFinite(Number(yesRaw)) ? Number(yesRaw) : 0.5
const noPrice = noRaw != null && Number.isFinite(Number(noRaw)) ? Number(noRaw) : 0.5
return {
marketId: m.ID != null ? String(m.ID) : undefined,
yesPrice,
noPrice,
title: m.question,
}
})
const categoryText = computed(() => {
const s = eventDetail.value?.series?.[0]?.title
const t = eventDetail.value?.tags?.[0]?.label
return [s, t].filter(Boolean).join(' · ') || ''
})
function formatVolume(volume: number | undefined): string {
if (volume == null || !Number.isFinite(volume)) return '$0 Vol.'
if (volume >= 1000) return `$${(volume / 1000).toFixed(1)}k Vol.`
return `$${Math.round(volume)} Vol.`
}
function formatExpiresAt(endDate: string | undefined): string {
if (!endDate) return ''
try {
const d = new Date(endDate)
if (Number.isNaN(d.getTime())) return endDate
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return endDate
}
}
const eventVolume = computed(() => {
const v = eventDetail.value?.volume
return v != null ? formatVolume(v) : ''
})
const currentChance = computed(() => {
const m = selectedMarket.value
if (!m) return 0
return marketChance(m)
})
const selectedMarketVolume = computed(() => formatVolume(selectedMarket.value?.volume))
const marketExpiresAt = computed(() => {
const endDate = eventDetail.value?.endDate
return endDate ? formatExpiresAt(endDate) : ''
})
const resolutionDate = computed(() => {
const s = marketExpiresAt.value
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || '' : ''
})
const timeRanges = [
{ label: '1H', value: '1H' },
{ label: '6H', value: '6H' },
{ label: '1D', value: '1D' },
{ label: '1W', value: '1W' },
{ label: '1M', value: '1M' },
{ label: 'ALL', value: 'ALL' },
]
const selectedTimeRange = ref('ALL')
const chartContainerRef = ref<HTMLElement | null>(null)
type ChartSeriesItem = { name: string; data: [number, number][] }
const chartData = ref<ChartSeriesItem[]>([])
let chartInstance: ECharts | null = null
let dynamicInterval: number | undefined
const LINE_COLORS = ['#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea', '#0891b2', '#ea580c', '#4f46e5']
const MOBILE_BREAKPOINT = 600
function getStepAndCount(range: string): { stepMs: number; count: number } {
switch (range) {
case '1H':
return { stepMs: 60 * 1000, count: 60 }
case '6H':
return { stepMs: 10 * 60 * 1000, count: 36 }
case '1D':
return { stepMs: 60 * 60 * 1000, count: 24 }
case '1W':
return { stepMs: 24 * 60 * 60 * 1000, count: 7 }
case '1M':
case 'ALL':
return { stepMs: 24 * 60 * 60 * 1000, count: 30 }
default:
return { stepMs: 60 * 60 * 1000, count: 24 }
}
}
function generateDataForMarket(baseChance: number, range: string): [number, number][] {
const now = Date.now()
const data: [number, number][] = []
const { stepMs, count } = getStepAndCount(range)
let value = baseChance + (Math.random() - 0.5) * 10
for (let i = count; i >= 0; i--) {
const t = now - i * stepMs
value = Math.max(10, Math.min(90, value + (Math.random() - 0.5) * 6))
data.push([t, Math.round(value * 10) / 10])
}
return data
}
function generateAllData(): ChartSeriesItem[] {
const range = selectedTimeRange.value
return markets.value.map((market) => {
const chance = marketChance(market)
const name = (market.question || 'Market').slice(0, 32)
return {
name: name + (name.length >= 32 ? '…' : ''),
data: generateDataForMarket(chance || 20, range),
}
})
}
function buildOption(seriesArr: ChartSeriesItem[], containerWidth?: number) {
const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400
const isMobile = width < MOBILE_BREAKPOINT
const hasData = seriesArr.some((s) => s.data.length >= 2)
const xAxisConfig: Record<string, unknown> = {
type: 'time',
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280', fontSize: isMobile ? 10 : 11 },
axisTick: { show: false },
splitLine: { show: false },
}
if (isMobile && hasData) {
const times = seriesArr.flatMap((s) => s.data.map((d) => d[0]))
const span = times.length ? Math.max(...times) - Math.min(...times) : 0
xAxisConfig.axisLabel = {
...(xAxisConfig.axisLabel as object),
interval: Math.max(span / 4, 60 * 1000),
formatter: (value: number) => {
const d = new Date(value)
return `${d.getMonth() + 1}/${d.getDate()}`
},
rotate: -25,
}
}
const series = seriesArr.map((s, i) => {
const color = LINE_COLORS[i % LINE_COLORS.length]
const lastIndex = s.data.length - 1
return {
name: s.name,
type: 'line' as const,
showSymbol: true,
symbol: 'circle',
symbolSize: (_: unknown, params: { dataIndex?: number }) =>
params?.dataIndex === lastIndex ? 8 : 0,
data: s.data,
smooth: true,
lineStyle: { width: 2, color },
itemStyle: { color, borderColor: '#fff', borderWidth: 2 },
}
})
return {
animation: false,
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const arr = Array.isArray(params) ? params : [params]
if (arr.length === 0) return ''
const p0 = arr[0] as { name: string | number; value: unknown }
const date = new Date(p0.name as number)
const dateStr = `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`
const lines = arr
.filter((x) => x != null && (x as { value?: unknown }).value != null)
.map((x) => {
const v = (x as { seriesName?: string; value: unknown }).value
const val = Array.isArray(v) ? v[1] : v
return `${(x as { seriesName?: string }).seriesName}: ${val}%`
})
return [dateStr, ...lines].join('<br/>')
},
axisPointer: { animation: false },
},
legend: {
type: 'scroll',
top: 0,
right: 0,
data: seriesArr.map((s) => s.name),
textStyle: { fontSize: 11, color: '#6b7280' },
itemWidth: 14,
itemHeight: 8,
itemGap: 12,
},
grid: {
left: 16,
right: 48,
top: 40,
bottom: isMobile ? 44 : 28,
containLabel: false,
},
xAxis: xAxisConfig,
yAxis: {
type: 'value',
position: 'right',
boundaryGap: [0, '100%'],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#6b7280', fontSize: 11, formatter: '{value}%' },
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5e7eb' } },
},
series,
}
}
function initChart() {
if (!chartContainerRef.value || markets.value.length === 0) return
chartData.value = generateAllData()
chartInstance = echarts.init(chartContainerRef.value)
const w = chartContainerRef.value.clientWidth
chartInstance.setOption(buildOption(chartData.value, w))
}
function updateChartData() {
chartData.value = generateAllData()
const w = chartContainerRef.value?.clientWidth
if (chartInstance) chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
}
function selectTimeRange(range: string) {
selectedTimeRange.value = range
updateChartData()
}
function getMaxPoints(range: string): number {
return getStepAndCount(range).count + 1
}
function startDynamicUpdate() {
dynamicInterval = window.setInterval(() => {
const nextData = chartData.value.map((s) => {
const list = [...s.data]
const last = list[list.length - 1]
if (!last) return s
const nextVal = Math.max(10, Math.min(90, last[1] + (Math.random() - 0.5) * 4))
list.push([Date.now(), Math.round(nextVal * 10) / 10])
const max = getMaxPoints(selectedTimeRange.value)
return { name: s.name, data: list.slice(-max) }
})
chartData.value = nextData
const w = chartContainerRef.value?.clientWidth
if (chartInstance) chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
}, 3000)
}
function stopDynamicUpdate() {
if (dynamicInterval) {
clearInterval(dynamicInterval)
dynamicInterval = undefined
}
}
const handleResize = () => {
if (!chartInstance || !chartContainerRef.value) return
chartInstance.resize()
chartInstance.setOption(buildOption(chartData.value, chartContainerRef.value.clientWidth), {
replaceMerge: ['series'],
})
}
function selectMarket(index: number) {
selectedMarketIndex.value = index
}
/** 点击 Buy Yes/No选中该市场并把数据和方向传给购买组件不跳转 */
function openTrade(market: PmEventMarketItem, index: number, side: 'yes' | 'no') {
selectedMarketIndex.value = index
tradeInitialOption.value = side
}
function onTradeSubmit(payload: {
side: 'buy' | 'sell'
option: 'yes' | 'no'
limitPrice: number
shares: number
expirationEnabled: boolean
expirationTime: string
marketId?: string
}) {
// APIpayload marketId
console.log('Trade submit', payload)
}
function marketChance(market: PmEventMarketItem): number {
const raw = market?.outcomePrices?.[0]
if (raw == null) return 0
const yesPrice = parseFloat(String(raw))
if (!Number.isFinite(yesPrice)) return 0
return Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
}
function yesPrice(market: PmEventMarketItem): string {
const raw = market?.outcomePrices?.[0]
if (raw == null) return '0¢'
const p = parseFloat(String(raw))
if (!Number.isFinite(p)) return '0¢'
return `${Math.round(p * 100)}¢`
}
function noPrice(market: PmEventMarketItem): string {
const raw = market?.outcomePrices?.[1]
if (raw == null) return '0¢'
const p = parseFloat(String(raw))
if (!Number.isFinite(p)) return '0¢'
return `${Math.round(p * 100)}¢`
}
function goToTradeDetail(market: PmEventMarketItem, side?: 'yes' | 'no') {
const eventId = route.params.id
const marketId = market.ID != null ? String(market.ID) : undefined
router.push({
path: `/trade-detail/${eventId}`,
query: {
title: market.question ?? eventDetail.value?.title,
marketId,
marketInfo: formatVolume(market.volume),
chance: String(marketChance(market)),
...(side && { side }),
},
})
}
async function loadEventDetail() {
const idRaw = route.params.id
const id = typeof idRaw === 'string' ? parseInt(idRaw, 10) : Number(idRaw)
if (!Number.isFinite(id) || id < 1) {
detailError.value = '无效的 ID'
eventDetail.value = null
return
}
detailError.value = null
detailLoading.value = true
try {
const res = await findPmEvent(id, {
headers: userStore.token ? { 'x-token': userStore.token } : undefined,
})
if (res.code === 0 || res.code === 200) {
eventDetail.value = res.data ?? null
detailError.value = null
} else {
const fallback = getMockEventById(id)
if (fallback) {
eventDetail.value = fallback
detailError.value = null
} else {
detailError.value = res.msg || '加载失败'
eventDetail.value = null
}
}
} catch (e) {
const fallback = getMockEventById(id)
if (fallback) {
eventDetail.value = fallback
detailError.value = null
} else {
detailError.value = e instanceof Error ? e.message : '加载失败'
eventDetail.value = null
}
} finally {
detailLoading.value = false
}
}
function getMockEventById(id: number): PmEventListItem | null {
const item = MOCK_EVENT_LIST.find((e) => e.ID === id)
return item && (item.markets?.length ?? 0) > 1 ? item : null
}
onMounted(() => {
loadEventDetail()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
stopDynamicUpdate()
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
chartInstance = null
})
watch(
() => markets.value.length,
(len) => {
if (len > 0) {
nextTick(() => {
initChart()
if (dynamicInterval == null) startDynamicUpdate()
})
}
}
)
watch(
() => route.params.id,
() => loadEventDetail()
)
</script>
<style scoped>
.event-markets-container {
padding: 24px;
min-height: 100vh;
}
.event-markets-row {
display: flex;
flex-wrap: wrap;
}
.chart-col {
flex: 1 1 0%;
min-width: 0;
}
.trade-col {
margin-top: 32px;
}
.trade-sidebar {
position: sticky;
top: 24px;
width: 400px;
max-width: 100%;
flex-shrink: 0;
}
@media (min-width: 960px) {
.event-markets-row {
flex-wrap: nowrap;
}
.chart-col {
flex: 1 1 0% !important;
min-width: 0;
max-width: none !important;
}
.trade-col {
flex: 0 0 400px !important;
max-width: 400px !important;
margin-top: 32px;
}
.trade-sidebar {
width: 400px;
}
}
@media (max-width: 959px) {
.trade-sidebar {
position: static;
width: 100%;
}
}
/* 分时图卡片 */
.chart-card.polymarket-chart {
margin-bottom: 24px;
padding: 20px 24px 16px;
background-color: #ffffff;
border: 1px solid #e7e7e7;
border-radius: 12px;
}
.chart-header {
margin-bottom: 16px;
}
.chart-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0 0 12px 0;
line-height: 1.3;
}
.chart-controls-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.past-btn {
font-size: 13px;
color: #6b7280;
text-transform: none;
}
.date-pill {
background-color: #111827 !important;
color: #fff !important;
font-size: 12px;
font-weight: 500;
text-transform: none;
}
.chart-chance {
font-size: 1.5rem;
font-weight: 700;
color: #2563eb;
}
.chart-legend-hint {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 8px 0;
}
.chart-wrapper {
width: 100%;
margin-bottom: 12px;
}
.chart-container {
width: 100%;
height: 320px;
min-height: 260px;
}
.chart-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.chart-footer-left {
font-size: 12px;
color: #6b7280;
}
.chart-expires {
margin-left: 4px;
}
.chart-time-ranges {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.time-range-btn {
font-size: 12px;
text-transform: none;
min-width: 36px;
color: #6b7280;
}
.time-range-btn.active {
color: #111827;
font-weight: 600;
}
.time-range-btn:hover {
color: #111827;
}
.loading-card,
.error-card {
padding: 48px;
text-align: center;
}
.loading-placeholder p,
.error-text {
margin-top: 16px;
color: #6b7280;
}
.error-text {
color: #dc2626;
}
.event-header {
margin-bottom: 24px;
}
.event-title {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
margin: 0 0 8px 0;
line-height: 1.3;
}
.event-meta {
font-size: 14px;
color: #6b7280;
margin: 0 0 4px 0;
}
.event-volume {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.markets-list-card {
border: 1px solid #e7e7e7;
border-radius: 12px;
overflow: hidden;
}
.markets-list {
display: flex;
flex-direction: column;
}
.market-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.15s ease;
}
.market-row:last-child {
border-bottom: none;
}
.market-row:hover {
background-color: #f9fafb;
}
.market-row.selected {
background-color: #eff6ff;
}
.market-row-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.market-question {
font-size: 15px;
font-weight: 500;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.market-chance {
font-size: 15px;
font-weight: 700;
color: #111827;
flex-shrink: 0;
}
.market-row-vol {
font-size: 14px;
color: #6b7280;
flex-shrink: 0;
}
.market-row-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.buy-yes-btn {
background-color: #b8e0b8 !important;
color: #006600 !important;
text-transform: none;
font-weight: 500;
}
.buy-no-btn {
background-color: #f0b8b8 !important;
color: #cc0000 !important;
text-transform: none;
font-weight: 500;
}
</style>

View File

@ -183,8 +183,8 @@ const PAGE_SIZE = 10
/** 接口返回的列表(已映射为卡片所需结构) */ /** 接口返回的列表(已映射为卡片所需结构) */
const eventList = ref<EventCardItem[]>([]) const eventList = ref<EventCardItem[]>([])
/** 当前页码(0-based */ /** 当前页码(从 1 开始 */
const eventPage = ref(0) const eventPage = ref(1)
/** 接口返回的 total */ /** 接口返回的 total */
const eventTotal = ref(0) const eventTotal = ref(0)
const eventPageSize = ref(PAGE_SIZE) const eventPageSize = ref(PAGE_SIZE)
@ -192,7 +192,7 @@ const loadingMore = ref(false)
const noMoreEvents = computed(() => { const noMoreEvents = computed(() => {
if (eventList.value.length === 0) return false if (eventList.value.length === 0) return false
return eventList.value.length >= eventTotal.value || (eventPage.value + 1) * eventPageSize.value >= eventTotal.value return eventList.value.length >= eventTotal.value || eventPage.value * eventPageSize.value >= eventTotal.value
}) })
const footerLang = ref('English') const footerLang = ref('English')
@ -268,8 +268,8 @@ async function loadEvents(page: number, append: boolean) {
function onRefresh({ done }: { done: () => void }) { function onRefresh({ done }: { done: () => void }) {
clearEventListCache() clearEventListCache()
eventPage.value = 0 eventPage.value = 1
loadEvents(0, false).finally(() => done()) loadEvents(1, false).finally(() => done())
} }
function loadMore() { function loadMore() {
@ -296,7 +296,7 @@ onMounted(() => {
eventTotal.value = cached.total eventTotal.value = cached.total
eventPageSize.value = cached.pageSize eventPageSize.value = cached.pageSize
} else { } else {
loadEvents(0, false) loadEvents(1, false)
} }
nextTick(() => { nextTick(() => {
const sentinel = sentinelRef.value const sentinel = sentinelRef.value