新增:列表数据接口

This commit is contained in:
ivan 2026-02-09 22:32:11 +08:00
parent 08ae68b767
commit e603e04d0f
4 changed files with 396 additions and 47 deletions

167
src/api/event.ts Normal file
View 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
* IDid, titlemarketTitle, imageimageUrl, volumemarketInfo,
* outcomePrices[0]chanceValue, series[0].titlecategory, endDateexpiresAt
*/
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
View 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>
}

View File

@ -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(() => {
// HSL0绿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 */

View File

@ -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%;