From e603e04d0f27907738fa73d9ffdae7aa3fbdab0c Mon Sep 17 00:00:00 2001 From: ivan Date: Mon, 9 Feb 2026 22:32:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/event.ts | 167 ++++++++++++++++++++++++++++++++++ src/api/request.ts | 41 +++++++++ src/components/MarketCard.vue | 122 +++++++++++++++++++------ src/views/Home.vue | 113 +++++++++++++++++++---- 4 files changed, 396 insertions(+), 47 deletions(-) create mode 100644 src/api/event.ts create mode 100644 src/api/request.ts diff --git a/src/api/event.ts b/src/api/event.ts new file mode 100644 index 0000000..2ced17a --- /dev/null +++ b/src/api/event.ts @@ -0,0 +1,167 @@ +import { get } from './request' + +/** 分页结果 */ +export interface PageResult { + 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 + 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 { + const { page = 0, pageSize = 10, keyword, createdAtRange } = params + const query: Record = { + page, + pageSize, + } + if (keyword != null && keyword !== '') query.keyword = keyword + if (createdAtRange != null && createdAtRange.length) query.createdAtRange = createdAtRange + + return get('/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, + } +} diff --git a/src/api/request.ts b/src/api/request.ts new file mode 100644 index 0000000..72df01f --- /dev/null +++ b/src/api/request.ts @@ -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 }).env?.VITE_API_BASE_URL + ? (import.meta as unknown as { env: Record }).env.VITE_API_BASE_URL + : 'https://api.xtrader.vip' + +export interface RequestConfig { + /** 请求头,如 { 'x-token': token } */ + headers?: Record +} + +/** + * 带 x-token 等自定义头的 GET 请求 + */ +export async function get( + path: string, + params?: Record, + config?: RequestConfig +): Promise { + 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 = { + '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 +} diff --git a/src/components/MarketCard.vue b/src/components/MarketCard.vue index 9fb1b59..4ed81cd 100644 --- a/src/components/MarketCard.vue +++ b/src/components/MarketCard.vue @@ -13,21 +13,35 @@ - +
- - - - chance + chance +
+ @@ -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 */ diff --git a/src/views/Home.vue b/src/views/Home.vue index fb90ed1..5ea86cc 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -14,19 +14,28 @@
+
+ 暂无数据 +
-