新增:多Market详情
This commit is contained in:
parent
c6e896efc3
commit
e14bd3bc23
3
.env
Normal file
3
.env
Normal 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
3
.env.example
Normal 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
|
||||||
@ -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
31
src/api/market.ts
Normal 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)
|
||||||
|
}
|
||||||
@ -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. 多 market:Elon 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. 多 market:NBA 分区冠军(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' }],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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 为 0–1 */
|
||||||
|
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>
|
||||||
@ -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
836
src/views/EventMarkets.vue
Normal 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
|
||||||
|
}) {
|
||||||
|
// 可在此调用下单 API,payload 含 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>
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user