新增:列表数据接口
This commit is contained in:
parent
08ae68b767
commit
e603e04d0f
167
src/api/event.ts
Normal file
167
src/api/event.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { get } from './request'
|
||||
|
||||
/** 分页结果 */
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 接口 list 单项结构(与 /PmEvent/getPmEventPublic 返回一致) */
|
||||
export interface PmEventListItem {
|
||||
ID: number
|
||||
CreatedAt?: string
|
||||
UpdatedAt?: string
|
||||
ticker?: string
|
||||
slug?: string
|
||||
title: string
|
||||
description?: string
|
||||
resolutionSource?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
image?: string
|
||||
icon?: string
|
||||
active?: boolean
|
||||
closed?: boolean
|
||||
volume?: number
|
||||
markets?: Array<{
|
||||
ID?: number
|
||||
question?: string
|
||||
outcomePrices?: string[]
|
||||
endDate?: string
|
||||
volume?: number
|
||||
[key: string]: unknown
|
||||
}>
|
||||
series?: Array<{
|
||||
ID?: number
|
||||
ticker?: string
|
||||
title?: string
|
||||
[key: string]: unknown
|
||||
}>
|
||||
tags?: Array<{ label?: string; slug?: string; [key: string]: unknown }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 接口统一响应 */
|
||||
export interface PmEventListResponse {
|
||||
code: number
|
||||
data: PageResult<PmEventListItem>
|
||||
msg: string
|
||||
}
|
||||
|
||||
export interface GetPmEventListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
/** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */
|
||||
createdAtRange?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取 Event 列表(公开接口,不需要鉴权)
|
||||
* GET /PmEvent/getPmEventPublic
|
||||
*/
|
||||
export async function getPmEventPublic(
|
||||
params: GetPmEventListParams = {}
|
||||
): Promise<PmEventListResponse> {
|
||||
const { page = 0, pageSize = 10, keyword, createdAtRange } = params
|
||||
const query: Record<string, string | number | string[] | undefined> = {
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
if (keyword != null && keyword !== '') query.keyword = keyword
|
||||
if (createdAtRange != null && createdAtRange.length) query.createdAtRange = createdAtRange
|
||||
|
||||
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
|
||||
}
|
||||
|
||||
/** 首页卡片项(与 mapEventItemToCard 返回结构一致,用于缓存) */
|
||||
export interface EventCardItem {
|
||||
id: string
|
||||
marketTitle: string
|
||||
chanceValue: number
|
||||
marketInfo: string
|
||||
imageUrl: string
|
||||
category: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
/** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */
|
||||
let eventListCache: {
|
||||
list: EventCardItem[]
|
||||
page: number
|
||||
total: number
|
||||
pageSize: number
|
||||
} | null = null
|
||||
|
||||
export function getEventListCache(): typeof eventListCache {
|
||||
return eventListCache
|
||||
}
|
||||
|
||||
export function setEventListCache(data: {
|
||||
list: EventCardItem[]
|
||||
page: number
|
||||
total: number
|
||||
pageSize: number
|
||||
}) {
|
||||
eventListCache = data
|
||||
}
|
||||
|
||||
export function clearEventListCache(): void {
|
||||
eventListCache = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 list 单项映射为首页 MarketCard 所需字段
|
||||
* 字段对应:ID→id, title→marketTitle, image→imageUrl, volume→marketInfo,
|
||||
* outcomePrices[0]→chanceValue, series[0].title→category, endDate→expiresAt
|
||||
*/
|
||||
export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
|
||||
const id = String(item.ID ?? '')
|
||||
const marketTitle = item.title ?? ''
|
||||
const imageUrl = item.image ?? item.icon ?? ''
|
||||
|
||||
let chanceValue = 17
|
||||
const market = item.markets?.[0]
|
||||
if (market?.outcomePrices?.[0] != null) {
|
||||
const yesPrice = parseFloat(String(market.outcomePrices[0]))
|
||||
if (Number.isFinite(yesPrice)) {
|
||||
chanceValue = Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
|
||||
}
|
||||
}
|
||||
|
||||
let marketInfo = '$0 Vol.'
|
||||
if (item.volume != null && Number.isFinite(item.volume)) {
|
||||
const v = item.volume
|
||||
if (v >= 1000) {
|
||||
marketInfo = `$${(v / 1000).toFixed(1)}k Vol.`
|
||||
} else {
|
||||
marketInfo = `$${Math.round(v)} Vol.`
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt = ''
|
||||
if (item.endDate) {
|
||||
try {
|
||||
const d = new Date(item.endDate)
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
expiresAt = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
} catch {
|
||||
expiresAt = item.endDate
|
||||
}
|
||||
}
|
||||
|
||||
const category = item.series?.[0]?.title ?? item.tags?.[0]?.label ?? ''
|
||||
|
||||
return {
|
||||
id,
|
||||
marketTitle,
|
||||
chanceValue,
|
||||
marketInfo,
|
||||
imageUrl,
|
||||
category,
|
||||
expiresAt,
|
||||
}
|
||||
}
|
||||
41
src/api/request.ts
Normal file
41
src/api/request.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 请求基础 URL,默认 https://api.xtrader.vip,可通过环境变量 VITE_API_BASE_URL 覆盖
|
||||
*/
|
||||
const BASE_URL = typeof import.meta !== 'undefined' && (import.meta as unknown as { env?: Record<string, string> }).env?.VITE_API_BASE_URL
|
||||
? (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL
|
||||
: 'https://api.xtrader.vip'
|
||||
|
||||
export interface RequestConfig {
|
||||
/** 请求头,如 { 'x-token': token } */
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 带 x-token 等自定义头的 GET 请求
|
||||
*/
|
||||
export async function get<T = unknown>(
|
||||
path: string,
|
||||
params?: Record<string, string | number | string[] | undefined>,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
const url = new URL(path, BASE_URL || window.location.origin)
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined) return
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => url.searchParams.append(key, String(v)))
|
||||
} else {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...config?.headers,
|
||||
}
|
||||
const res = await fetch(url.toString(), { method: 'GET', headers })
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
@ -13,23 +13,37 @@
|
||||
</v-card-title>
|
||||
</div>
|
||||
|
||||
<!-- Chance Container -->
|
||||
<!-- Chance Container:半圆进度条 -->
|
||||
<div class="chance-container">
|
||||
<v-progress-circular
|
||||
class="progress-bar"
|
||||
:size="60"
|
||||
:width="4"
|
||||
:value="chanceValue"
|
||||
:color="progressColor"
|
||||
:background="'#e0e0e0'"
|
||||
>
|
||||
<template v-slot:default>
|
||||
<div class="semi-progress-wrap">
|
||||
<svg class="semi-progress-svg" viewBox="0 0 60 36" preserveAspectRatio="xMidYMin meet">
|
||||
<!-- 半圆弧开口向下:M 左上 → A 弧至 右上(弧朝下) -->
|
||||
<path
|
||||
class="semi-progress-track"
|
||||
d="M 4 30 A 26 26 0 0 1 56 30"
|
||||
fill="none"
|
||||
stroke="#e8e8e8"
|
||||
stroke-width="5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
class="semi-progress-fill"
|
||||
d="M 4 30 A 26 26 0 0 1 56 30"
|
||||
fill="none"
|
||||
:stroke="semiProgressColor"
|
||||
stroke-width="5"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="81.68"
|
||||
:stroke-dashoffset="semiProgressOffset"
|
||||
/>
|
||||
</svg>
|
||||
<div class="semi-progress-inner">
|
||||
<span class="chance-value">{{ chanceValue }}%</span>
|
||||
</template>
|
||||
</v-progress-circular>
|
||||
<span class="chance-label">chance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options Section:点击 Yes/No 弹出交易框,阻止冒泡不触发卡片跳转 -->
|
||||
<div class="options-section">
|
||||
@ -108,11 +122,37 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 计算进度条颜色,从红色(0%)到绿色(100%)
|
||||
const progressColor = computed(() => {
|
||||
// 红色在HSL中是0度,绿色是120度
|
||||
const hue = (props.chanceValue / 100) * 120
|
||||
return `hsl(${hue}, 100%, 50%)`
|
||||
// 半圆进度条:stroke-dashoffset,半圆弧长 ≈ π*26(左底 0° 顺时针至右底 180°)
|
||||
const SEMI_CIRCLE_LENGTH = Math.PI * 26
|
||||
const semiProgressOffset = computed(() => {
|
||||
const p = Math.min(100, Math.max(0, props.chanceValue)) / 100
|
||||
return SEMI_CIRCLE_LENGTH * (1 - p)
|
||||
})
|
||||
|
||||
// 进度条纯色:0% 红 → 50% 图片橙黄 #FFBB5C → 100% 绿(两段 RGB 插值)
|
||||
const COLOR_RED = { r: 239, g: 68, b: 68 }
|
||||
const COLOR_ORANGE_YELLOW = { r: 255, g: 187, b: 92 }
|
||||
const COLOR_GREEN = { r: 34, g: 197, b: 94 }
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const toByte = (v: number) => Math.min(255, Math.max(0, Math.round(v)))
|
||||
return `#${toByte(r).toString(16).padStart(2, '0')}${toByte(g).toString(16).padStart(2, '0')}${toByte(b).toString(16).padStart(2, '0')}`
|
||||
}
|
||||
const semiProgressColor = computed(() => {
|
||||
const t = Math.min(1, Math.max(0, props.chanceValue / 100))
|
||||
if (t <= 0.5) {
|
||||
const u = t * 2
|
||||
return rgbToHex(
|
||||
COLOR_RED.r + (COLOR_ORANGE_YELLOW.r - COLOR_RED.r) * u,
|
||||
COLOR_RED.g + (COLOR_ORANGE_YELLOW.g - COLOR_RED.g) * u,
|
||||
COLOR_RED.b + (COLOR_ORANGE_YELLOW.b - COLOR_RED.b) * u
|
||||
)
|
||||
}
|
||||
const u = (t - 0.5) * 2
|
||||
return rgbToHex(
|
||||
COLOR_ORANGE_YELLOW.r + (COLOR_GREEN.r - COLOR_ORANGE_YELLOW.r) * u,
|
||||
COLOR_ORANGE_YELLOW.g + (COLOR_GREEN.g - COLOR_ORANGE_YELLOW.g) * u,
|
||||
COLOR_ORANGE_YELLOW.b + (COLOR_GREEN.b - COLOR_ORANGE_YELLOW.b) * u
|
||||
)
|
||||
})
|
||||
|
||||
// 跳转到交易详情页面,将标题、图片等通过 query 传入
|
||||
@ -196,31 +236,55 @@ function openTrade(side: 'yes' | 'no') {
|
||||
}
|
||||
|
||||
.chance-container {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.semi-progress-wrap {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.semi-progress-svg {
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 36px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.semi-progress-track,
|
||||
.semi-progress-fill {
|
||||
transition: stroke-dashoffset 0.25s ease;
|
||||
}
|
||||
|
||||
/* 半圆弧 viewBox 0 0 60 36:弧的视觉中心约 y=17,百分比文字的 top 对齐该中心 */
|
||||
.semi-progress-inner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 17px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
margin-bottom: 2px;
|
||||
justify-content: flex-start;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chance-value {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chance-label {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
color: #808080;
|
||||
margin-top: 4px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Options Section */
|
||||
|
||||
@ -14,19 +14,28 @@
|
||||
<div class="pull-to-refresh-inner">
|
||||
<div class="home-list">
|
||||
<MarketCard
|
||||
v-for="id in listLength"
|
||||
:key="id"
|
||||
:id="String(id)"
|
||||
v-for="card in eventList"
|
||||
:key="card.id"
|
||||
:id="card.id"
|
||||
:market-title="card.marketTitle"
|
||||
:chance-value="card.chanceValue"
|
||||
:market-info="card.marketInfo"
|
||||
:image-url="card.imageUrl"
|
||||
:category="card.category"
|
||||
:expires-at="card.expiresAt"
|
||||
@open-trade="onCardOpenTrade"
|
||||
/>
|
||||
<div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
|
||||
暂无数据
|
||||
</div>
|
||||
<div class="load-more-footer">
|
||||
</div>
|
||||
<div v-if="eventList.length > 0" class="load-more-footer">
|
||||
<div ref="sentinelRef" class="load-more-sentinel" aria-hidden="true" />
|
||||
<div v-if="loadingMore" class="load-more-indicator">
|
||||
<v-progress-circular indeterminate size="24" width="2" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="listLength >= maxItems" class="no-more-tip">没有更多了</div>
|
||||
<div v-else-if="noMoreEvents" class="no-more-tip">没有更多了</div>
|
||||
<v-btn
|
||||
v-else
|
||||
class="load-more-btn"
|
||||
@ -150,18 +159,36 @@ import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import MarketCard from '../components/MarketCard.vue'
|
||||
import TradeComponent from '../components/TradeComponent.vue'
|
||||
import {
|
||||
getPmEventPublic,
|
||||
mapEventItemToCard,
|
||||
getEventListCache,
|
||||
setEventListCache,
|
||||
clearEventListCache,
|
||||
type EventCardItem,
|
||||
} from '../api/event'
|
||||
|
||||
const { mobile } = useDisplay()
|
||||
const isMobile = computed(() => mobile.value)
|
||||
|
||||
const activeTab = ref('overview')
|
||||
|
||||
const INITIAL_COUNT = 10
|
||||
const PAGE_SIZE = 10
|
||||
const maxItems = 50
|
||||
|
||||
const listLength = ref(INITIAL_COUNT)
|
||||
/** 接口返回的列表(已映射为卡片所需结构) */
|
||||
const eventList = ref<EventCardItem[]>([])
|
||||
/** 当前页码(0-based) */
|
||||
const eventPage = ref(0)
|
||||
/** 接口返回的 total */
|
||||
const eventTotal = ref(0)
|
||||
const eventPageSize = ref(PAGE_SIZE)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const noMoreEvents = computed(() => {
|
||||
if (eventList.value.length === 0) return false
|
||||
return eventList.value.length >= eventTotal.value || (eventPage.value + 1) * eventPageSize.value >= eventTotal.value
|
||||
})
|
||||
|
||||
const footerLang = ref('English')
|
||||
const tradeDialogOpen = ref(false)
|
||||
const tradeDialogSide = ref<'yes' | 'no'>('yes')
|
||||
@ -178,25 +205,59 @@ let observer: IntersectionObserver | null = null
|
||||
|
||||
const SCROLL_LOAD_THRESHOLD = 280
|
||||
|
||||
/** 请求事件列表并追加或覆盖到 eventList(公开接口,无需鉴权);成功后会更新内存缓存 */
|
||||
async function loadEvents(page: number, append: boolean) {
|
||||
try {
|
||||
const res = await getPmEventPublic(
|
||||
{ page, pageSize: PAGE_SIZE }
|
||||
)
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
throw new Error(res.msg || '请求失败')
|
||||
}
|
||||
const data = res.data
|
||||
if (!data?.list || !Array.isArray(data.list)) {
|
||||
if (!append) eventList.value = []
|
||||
return
|
||||
}
|
||||
const mapped = data.list.map((item) => mapEventItemToCard(item))
|
||||
eventTotal.value = data.total ?? 0
|
||||
eventPageSize.value = data.pageSize && data.pageSize > 0 ? data.pageSize : PAGE_SIZE
|
||||
eventPage.value = data.page ?? page
|
||||
if (append) {
|
||||
eventList.value = [...eventList.value, ...mapped]
|
||||
} else {
|
||||
eventList.value = mapped
|
||||
}
|
||||
setEventListCache({
|
||||
list: eventList.value,
|
||||
page: eventPage.value,
|
||||
total: eventTotal.value,
|
||||
pageSize: eventPageSize.value,
|
||||
})
|
||||
} catch (e) {
|
||||
if (!append) eventList.value = []
|
||||
console.warn('getPmEventList failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function onRefresh({ done }: { done: () => void }) {
|
||||
setTimeout(() => {
|
||||
listLength.value = INITIAL_COUNT
|
||||
done()
|
||||
}, 600)
|
||||
clearEventListCache()
|
||||
eventPage.value = 0
|
||||
loadEvents(0, false).finally(() => done())
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loadingMore.value || listLength.value >= maxItems) return
|
||||
if (loadingMore.value || noMoreEvents.value || eventList.value.length === 0) return
|
||||
loadingMore.value = true
|
||||
setTimeout(() => {
|
||||
listLength.value = Math.min(listLength.value + PAGE_SIZE, maxItems)
|
||||
const nextPage = eventPage.value + 1
|
||||
loadEvents(nextPage, true).finally(() => {
|
||||
loadingMore.value = false
|
||||
}, 400)
|
||||
})
|
||||
}
|
||||
|
||||
function checkScrollLoad() {
|
||||
const el = scrollRef.value
|
||||
if (!el || loadingMore.value || listLength.value >= maxItems) return
|
||||
if (!el || loadingMore.value || eventList.value.length === 0 || noMoreEvents.value) return
|
||||
const { scrollTop, clientHeight, scrollHeight } = el
|
||||
if (scrollHeight - scrollTop - clientHeight < SCROLL_LOAD_THRESHOLD) {
|
||||
loadMore()
|
||||
@ -204,6 +265,15 @@ function checkScrollLoad() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const cached = getEventListCache()
|
||||
if (cached && cached.list.length > 0) {
|
||||
eventList.value = cached.list
|
||||
eventPage.value = cached.page
|
||||
eventTotal.value = cached.total
|
||||
eventPageSize.value = cached.pageSize
|
||||
} else {
|
||||
loadEvents(0, false)
|
||||
}
|
||||
nextTick(() => {
|
||||
const scrollEl = scrollRef.value
|
||||
const sentinel = sentinelRef.value
|
||||
@ -271,10 +341,17 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding-bottom: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
|
||||
}
|
||||
|
||||
.home-list-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
|
||||
.home-list > * {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user