Compare commits

...

2 Commits

Author SHA1 Message Date
ivan
e7a33c9638 配置:配置更新 2026-03-16 15:30:19 +08:00
ivan
acd3e41f36 优化:历史数据更新,折线图接口对接更新(参数变更) 2026-03-16 15:29:28 +08:00
11 changed files with 170 additions and 164 deletions

3
.env
View File

@ -1,7 +1,8 @@
# API 基础地址,不设置时默认 https://api.xtrader.vip
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释:
VITE_API_BASE_URL=http://localhost:8888
# VITE_API_BASE_URL=http://192.168.3.14:8888
# VITE_USE_MOCK_DATA=false # 全部关闭 mock
# SSH 部署npm run deploy可选覆盖
# DEPLOY_HOST=38.246.250.238
# DEPLOY_USER=root

View File

@ -59,17 +59,16 @@
| 字段 | 类型 | 说明 |
|------|------|------|
| ID | number | 主键 |
| title | string | 标题 |
| name | string | 名称 |
| eventSlug | string | 事件标识 |
| outcome | string | 结果Yes/No 等) |
| side | string | 方向 |
| type | string | 类型 |
| title | string | 标题(如「充值资金」) |
| type | string | 类型(如 recharge |
| **usdcSize** | number | **金额USDC**,用于充值等 |
| **icon** | string | **图标路径**(如 uploads/file/btc.png会转为完整 URL 展示 |
| **UpdatedAt** | string | **更新时间**,用于 timeAgo 展示 |
| price | number | 价格 |
| size | number | 大小 |
| createdAt | string | 创建时间 |
| timestamp | number | 时间戳(秒) |
| 其他 | - | asset, bio, conditionId, icon, slug, transactionHash 等 |
| outcome | string | 结果 |
| timestamp | number | 时间戳(秒或毫秒 |
| 其他 | - | asset, bio, conditionId, slug, transactionHash 等 |
## 使用方式

View File

@ -9,6 +9,7 @@
## 核心能力
- `getPmPriceHistoryPublic`:按市场 ID 分页获取价格历史
- `getTimeRangeSeconds`:根据分时范围计算 `startTs``endTs`Unix 秒);`endTs` 始终为当前时间1H/6H/1D/1W/1M 的 `startTs` 为当前时间往前对应时长ALL 的 `startTs` 为事件开始时间、`endTs` 为当前时间
- `priceHistoryToChartData`:将接口返回的 `list` 转为 ECharts 使用的 `[timestamp_ms, value_0_100][]`
## GET /pmPriceHistory/getPmPriceHistoryPublic
@ -20,6 +21,8 @@
| market | string | 是 | 传 YES 对应的 clobTokenId即当前市场 clobTokenIds[0] |
| page | number | 否 | 页码,默认 1 |
| pageSize | number | 否 | 每页条数,默认 500 |
| startTs | number | 否 | 时间范围起始Unix 秒) |
| endTs | number | 否 | 时间范围结束Unix 秒) |
| interval | string | 否 | 数据间隔 |
| time | number | 否 | 时间筛选 |
| createdAtRange | string[] | 否 | 创建时间范围 |
@ -52,18 +55,22 @@
import {
getPmPriceHistoryPublic,
priceHistoryToChartData,
getTimeRangeSeconds,
type PmPriceHistoryItem,
} from '@/api/priceHistory'
const timeRange = getTimeRangeSeconds('1D') // { startTs, endTs }
// ALL 时传 eventDatesgetTimeRangeSeconds('ALL', { startDate: ev.startDate, endDate: ev.endDate })
const res = await getPmPriceHistoryPublic({
market: marketId,
page: 1,
pageSize: 500,
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
})
const chartData = priceHistoryToChartData(res.data?.list ?? [])
```
## 扩展方式
- 按时间范围1H/6H/1D 等)传 `interval``createdAtRange` 需与后端约定取值
- 时间戳规则:`endTs` 始终为当前时间1H/6H/1D/1W/1M 的 `startTs` 为当前时间往前对应时长ALL 的 `startTs` 为事件 `startDate``endTs` 为当前时间
- 若后端返回的 `price` 固定为 0100`priceHistoryToChartData` 已兼容≤1 时乘 100

View File

@ -7,12 +7,17 @@
事件下的市场列表页,展示某个 Event 的多个 Market如 NFL 多支队伍),支持选择并跳转交易详情。
- **多市场折线图**:按市场数量依次调用 `getPmPriceHistoryPublic`,每个市场使用 `clobTokenIds[0]`YES token作为 `market` 参数,展示多条分时曲线
- **时间范围**1H / 6H / 1D / 1W / 1M / ALL与 TradeDetail 一致
## 使用方式
- 从首页或详情进入,路由 `/event/123/markets`
- 路由参数 `id` 为 Event ID
- 分时图数据来源:`src/api/priceHistory.ts``getPmPriceHistoryPublic``priceHistoryToChartData`
## 扩展方式
- 增加市场筛选、排序
- 与 TradeDetail 联动,支持从市场列表直接进入指定 market 的交易
- 可抽取 `getTimeRangeMs``filterChartDataByRange` 为共享 util与 TradeDetail 复用

View File

@ -4,6 +4,7 @@
*/
import { buildQuery, get } from './request'
import { BASE_URL } from './request'
import type { PageResult } from './types'
/** 单条历史记录(与 doc.json definitions["polymarket.HistoryRecord"] 对齐) */
@ -13,6 +14,7 @@ export interface HistoryRecordItem {
bio?: string
conditionId?: string
createdAt?: string
CreatedAt?: string
eventSlug?: string
icon?: string
name?: string
@ -31,6 +33,9 @@ export interface HistoryRecordItem {
transactionHash?: string
type?: string
updatedAt?: string
UpdatedAt?: string
/** 金额USDC用于充值等类型 */
usdcSize?: number
}
/** GET /hr/getHistoryRecordPublic 请求参数 */
@ -129,11 +134,15 @@ export interface HistoryDisplayItem {
shares?: string
iconChar?: string
iconClass?: string
/** 图标 URL来自 record.icon用于展示 */
imageUrl?: string
}
function formatTimeAgo(createdAt: string | undefined, timestamp?: number): string {
const ms = createdAt ? new Date(createdAt).getTime() : (timestamp != null ? timestamp * 1000 : 0)
if (!ms) return ''
function formatTimeAgo(dateStr: string | undefined, timestamp?: number): string {
let ms = 0
if (dateStr) ms = new Date(dateStr).getTime()
else if (timestamp != null) ms = timestamp < 1e12 ? timestamp * 1000 : timestamp
if (!ms || !Number.isFinite(ms)) return ''
const diff = Date.now() - ms
if (diff < 60000) return 'Just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`
@ -142,8 +151,18 @@ function formatTimeAgo(createdAt: string | undefined, timestamp?: number): strin
return new Date(ms).toLocaleDateString()
}
/** 将相对路径转为完整 URL */
function toFullIconUrl(icon: string | undefined): string | undefined {
if (!icon?.trim()) return undefined
const s = icon.trim()
if (s.startsWith('http://') || s.startsWith('https://')) return s
const base = BASE_URL?.replace(/\/$/, '') ?? ''
return `${base}/${s.replace(/^\//, '')}`
}
/**
* HistoryRecordItem History
* usdcSize icon UpdatedAt
*/
export function mapHistoryRecordToDisplayItem(record: HistoryRecordItem): HistoryDisplayItem {
const id = String(record.ID ?? '')
@ -152,13 +171,18 @@ export function mapHistoryRecordToDisplayItem(record: HistoryRecordItem): Histor
const side = outcome === 'No' || outcome === 'Down' ? 'No' : 'Yes'
const typeLabel = record.type ?? 'Trade'
const activity = `${typeLabel} ${outcome}`.trim()
const usdcSize = record.usdcSize ?? 0
const price = record.price ?? 0
const size = record.size ?? 0
const valueUsd = price * size
const valueUsd = usdcSize !== 0 ? usdcSize : price * size
const value = `$${Math.abs(valueUsd).toFixed(2)}`
const priceCents = Math.round(price * 100)
const activityDetail = size > 0 ? `Sold ${Math.floor(size)} ${outcome} at ${priceCents}¢` : value
const timeAgo = formatTimeAgo(record.createdAt, record.timestamp)
const timeAgo = formatTimeAgo(
record.UpdatedAt ?? record.updatedAt ?? record.CreatedAt ?? record.createdAt,
record.timestamp,
)
const imageUrl = toFullIconUrl(record.icon)
return {
id,
market,
@ -171,6 +195,7 @@ export function mapHistoryRecordToDisplayItem(record: HistoryRecordItem): Histor
timeAgo,
avgPrice: priceCents ? `${priceCents}¢` : undefined,
shares: size > 0 ? String(Math.floor(size)) : undefined,
imageUrl,
}
}

View File

@ -28,6 +28,10 @@ export interface GetPmPriceHistoryPublicParams {
market: string
page?: number
pageSize?: number
/** 时间范围起始时间戳Unix 秒) */
startTs?: number
/** 时间范围结束时间戳Unix 秒) */
endTs?: number
/** 数据间隔 */
interval?: string
/** 时间筛选 */
@ -56,11 +60,13 @@ export async function getPmPriceHistoryPublic(
params: GetPmPriceHistoryPublicParams,
config?: { headers?: Record<string, string> },
): Promise<PmPriceHistoryPublicResponse> {
const { market, page = 1, pageSize = 500, interval, time, createdAtRange, fidelity, keyword, order, sort, price } = params
const { market, page = 1, pageSize = 500, startTs, endTs, interval, time, createdAtRange, fidelity, keyword, order, sort, price } = params
const query = buildQuery({
market,
page,
pageSize,
startTs,
endTs,
interval,
time,
createdAtRange,
@ -76,6 +82,34 @@ export async function getPmPriceHistoryPublic(
/** 图表单点格式 [timestamp_ms, value_0_100] */
export type ChartDataPoint = [number, number]
/**
* Unix
* endTs 1H/6H/1D/1W/1M startTs ALL startTs endTs
*/
export function getTimeRangeSeconds(
range: string,
eventDates?: { startDate?: string; endDate?: string },
): { startTs: number; endTs: number } | null {
const nowSec = Math.floor(Date.now() / 1000)
const H = 60 * 60
const D = 24 * H
switch (range) {
case '1H': return { startTs: nowSec - 1 * H, endTs: nowSec }
case '6H': return { startTs: nowSec - 6 * H, endTs: nowSec }
case '1D': return { startTs: nowSec - 1 * D, endTs: nowSec }
case '1W': return { startTs: nowSec - 7 * D, endTs: nowSec }
case '1M': return { startTs: nowSec - 30 * D, endTs: nowSec }
case 'ALL':
if (eventDates?.startDate) {
const startTs = Math.floor(new Date(eventDates.startDate).getTime() / 1000)
if (Number.isFinite(startTs))
return { startTs, endTs: nowSec }
}
return null
default: return null
}
}
/**
* list ECharts 线
* - time

View File

@ -296,7 +296,7 @@ async function checkBalance(isFirst = false) {
for (const addr of contractsToCheck) {
try {
const contract = new ethers.Contract(addr, ['function balanceOf(address) view returns (uint256)'], provider)
const bal = await contract.balanceOf(targetAddr)
const bal = contract.balanceOf ? await contract.balanceOf(targetAddr) : BigInt(0)
totalBalance += bal
} catch (err) {
console.warn(`Failed to check balance for token ${addr}`, err)

View File

@ -1579,7 +1579,7 @@ async function submitMerge() {
mergeError.value = ''
try {
const res = await pmMarketMerge(
{ marketID: marketId, amount: String(mergeAmount.value) },
{ marketID: marketId, amount: (mergeAmount.value * 1000000).toFixed(0) },
{ headers: userStore.getAuthHeaders() },
)
if (res.code === 0 || res.code === 200) {
@ -1614,7 +1614,7 @@ async function submitSplit() {
splitError.value = ''
try {
const res = await pmMarketSplit(
{ marketID: marketId, usdcAmount: String(splitAmount.value * 1000000) },
{ marketID: marketId, usdcAmount: (splitAmount.value * 1000000).toFixed(0) },
{ headers: userStore.getAuthHeaders() },
)
if (res.code === 0 || res.code === 200) {

View File

@ -36,6 +36,9 @@
<p class="chart-legend-hint">{{ markets.length }} 个市场</p>
</div>
<div class="chart-wrapper">
<div v-if="chartLoading" class="chart-loading-overlay">
<v-progress-circular indeterminate color="primary" size="32" />
</div>
<div ref="chartContainerRef" class="chart-container"></div>
</div>
<div class="chart-footer">
@ -218,10 +221,12 @@ import TradeComponent from '../components/TradeComponent.vue'
import {
findPmEvent,
getMarketId,
getClobTokenId,
type FindPmEventParams,
type PmEventListItem,
type PmEventMarketItem,
} from '../api/event'
import { getPmPriceHistoryPublic, priceHistoryToChartData, getTimeRangeSeconds } from '../api/priceHistory'
import { getMockEventById } from '../api/mockData'
import { USE_MOCK_EVENT } from '../config/mock'
import { useI18n } from 'vue-i18n'
@ -336,13 +341,13 @@ const timeRanges = [
{ label: '1M', value: '1M' },
{ label: 'ALL', value: 'ALL' },
]
const selectedTimeRange = ref('ALL')
const selectedTimeRange = ref('1D')
const chartContainerRef = ref<HTMLElement | null>(null)
type ChartSeriesItem = { name: string; data: [number, number][] }
const chartData = ref<ChartSeriesItem[]>([])
const chartLoading = ref(false)
let chartInstance: ECharts | null = null
let dynamicInterval: number | undefined
const LINE_COLORS = [
'#2563eb',
@ -356,51 +361,38 @@ const LINE_COLORS = [
]
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
/** 按市场依次请求 getPmPriceHistoryPublicmarket 传 clobTokenIds[0]YES token */
async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
const list = markets.value
return list.map((market, i) => {
const chance = marketChance(market)
const range = selectedTimeRange.value
const results: ChartSeriesItem[] = []
for (let i = 0; i < list.length; i++) {
const market = list[i]
if (!market) continue
const yesTokenId = getClobTokenId(market, 0)
const base = (market.question || 'Market').slice(0, 32)
const baseName = base + (base.length >= 32 ? '…' : '')
// name ECharts
const name = list.length > 1 ? `${baseName} (${i + 1}/${list.length})` : baseName
return {
name,
data: generateDataForMarket(chance || 20, range),
if (!yesTokenId) {
results.push({ name, data: [] })
continue
}
})
try {
const ev = eventDetail.value
const timeRange = getTimeRangeSeconds(range, ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined)
const res = await getPmPriceHistoryPublic({
market: yesTokenId,
page: 1,
pageSize: 500,
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
})
const points = priceHistoryToChartData(res.data?.list ?? [])
results.push({ name, data: points })
} catch {
results.push({ name, data: [] })
}
}
return results
}
function buildOption(seriesArr: ChartSeriesItem[], containerWidth?: number) {
@ -497,28 +489,29 @@ function buildOption(seriesArr: ChartSeriesItem[], containerWidth?: number) {
}
}
function initChart() {
async 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))
// 便
console.log('[EventMarkets] 数据分析:', {
marketsCount: markets.value.length,
chartSeriesCount: chartData.value.length,
containerWidth: w,
legendData: chartData.value.map((s) => ({ name: s.name, nameLen: s.name.length, points: s.data.length })),
eventDetail: eventDetail.value ? { title: eventDetail.value.title, id: eventDetail.value.ID } : null,
})
chartLoading.value = true
try {
chartData.value = await loadChartFromApi()
chartInstance = echarts.init(chartContainerRef.value)
const w = chartContainerRef.value.clientWidth
chartInstance.setOption(buildOption(chartData.value, w))
} finally {
chartLoading.value = false
}
}
function updateChartData() {
chartData.value = generateAllData()
const w = chartContainerRef.value?.clientWidth
if (chartInstance)
chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
async function updateChartData() {
chartLoading.value = true
try {
chartData.value = await loadChartFromApi()
const w = chartContainerRef.value?.clientWidth
if (chartInstance)
chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
} finally {
chartLoading.value = false
}
}
function selectTimeRange(range: string) {
@ -526,35 +519,6 @@ function selectTimeRange(range: string) {
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()
@ -752,7 +716,6 @@ watch(tradeSheetOpen, (open) => {
}, { immediate: true })
onUnmounted(() => {
stopDynamicUpdate()
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
chartInstance = null
@ -764,10 +727,7 @@ watch(
() => markets.value.length,
(len) => {
if (len > 0) {
nextTick(() => {
initChart()
if (dynamicInterval == null) startDynamicUpdate()
})
nextTick(() => initChart())
}
},
)
@ -915,10 +875,21 @@ watch(
}
.chart-wrapper {
position: relative;
width: 100%;
margin-bottom: 12px;
}
.chart-loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
z-index: 1;
}
.chart-container {
width: 100%;
height: 320px;

View File

@ -401,6 +401,7 @@ import { cancelOrder as apiCancelOrder } from '../api/order'
import type { ChartDataPoint, ChartTimeRange } from '../api/chart'
import {
getPmPriceHistoryPublic,
getTimeRangeSeconds,
priceHistoryToChartData,
} from '../api/priceHistory'
import {
@ -1231,7 +1232,7 @@ function formatTimeAgo(ts: number): string {
}
//
const selectedTimeRange = ref('ALL')
const selectedTimeRange = ref('1D')
const timeRanges = [
{ label: '1H', value: '1H' },
{ label: '6H', value: '6H' },
@ -1249,40 +1250,6 @@ const cryptoChartLoading = ref(false)
const chartYesNoLoading = ref(false)
let chartInstance: ECharts | null = null
/** 分时范围对应的毫秒数ALL 返回 null 表示不截断 */
function getTimeRangeMs(range: string): number | null {
const H = 60 * 60 * 1000
const D = 24 * H
switch (range) {
case '1H':
return 1 * H
case '6H':
return 6 * H
case '1D':
return 1 * D
case '1W':
return 7 * D
case '1M':
return 30 * D
case 'ALL':
default:
return null
}
}
/** 按分时范围过滤 [timestamp_ms, value][],保留区间 [now - rangeMs, now] 内的点 */
function filterChartDataByRange(
points: ChartDataPoint[],
range: string,
): ChartDataPoint[] {
if (!points.length) return []
const rangeMs = getTimeRangeMs(range)
if (rangeMs == null) return points
const nowMs = Date.now()
const cutoffMs = nowMs - rangeMs
return points.filter(([ts]) => ts >= cutoffMs)
}
const currentChance = computed(() => {
const ev = eventDetail.value
const market = ev?.markets?.[0]
@ -1528,11 +1495,14 @@ function initChart() {
}
/** 从 GET /pmPriceHistory/getPmPriceHistoryPublic 拉取价格历史market 传 YES 对应的 clobTokenId */
async function loadChartFromApi(marketParam: string): Promise<ChartDataPoint[]> {
async function loadChartFromApi(marketParam: string, range: string): Promise<ChartDataPoint[]> {
const ev = eventDetail.value
const timeRange = getTimeRangeSeconds(range, ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined)
const res = await getPmPriceHistoryPublic({
market: marketParam,
page: 1,
pageSize: 500,
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
})
const list = res.data?.list ?? []
return priceHistoryToChartData(list)
@ -1592,9 +1562,9 @@ async function updateChartData() {
try {
// market clobTokenIds[0]YES token ID
const yesTokenId = clobTokenIds.value[0]
const points = yesTokenId ? await loadChartFromApi(yesTokenId) : []
const points = yesTokenId ? await loadChartFromApi(yesTokenId, selectedTimeRange.value) : []
rawChartData.value = points
data.value = filterChartDataByRange(points, selectedTimeRange.value)
data.value = points
if (chartInstance)
chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] })
} finally {
@ -1607,16 +1577,7 @@ function selectTimeRange(range: string) {
selectedTimeRange.value = range
}
watch(selectedTimeRange, (range) => {
if (chartMode.value === 'yesno') {
data.value = filterChartDataByRange(rawChartData.value, range)
const w = chartContainerRef.value?.clientWidth
if (chartInstance)
chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] })
} else {
updateChartData()
}
})
watch(selectedTimeRange, () => updateChartData())
// CLOB market clobTokenIds 使 Yes/No token ID
const clobTokenIds = computed(() => {

View File

@ -471,7 +471,8 @@
>
<div class="history-mobile-row">
<div class="history-mobile-icon" :class="h.iconClass">
<span class="position-icon-char">{{ h.iconChar }}</span>
<img v-if="h.imageUrl" :src="h.imageUrl" alt="" class="position-icon-img" />
<span v-else class="position-icon-char">{{ h.iconChar || '•' }}</span>
</div>
<div class="history-mobile-main">
<div class="history-mobile-title">{{ h.market }}</div>
@ -920,6 +921,8 @@ interface HistoryItem {
shares?: string
iconChar?: string
iconClass?: string
/** 图标 URL来自 record.icon */
imageUrl?: string
}
const positions = ref<Position[]>(