From 062e370bea96e341b3508797379faefbf41523f2 Mon Sep 17 00:00:00 2001 From: ivan Date: Tue, 31 Mar 2026 18:42:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=81=9A=E5=BE=97=E6=9B=B4=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=EF=BC=8C=E9=92=B1=E5=8C=85=E9=A1=B5=E9=9D=A2=E6=95=B0=E6=8D=AE?= =?UTF-8?q?bug=E5=8A=A0=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/event.ts | 54 +++++++ src/api/historyRecord.ts | 56 ++++++- src/api/mockData.ts | 148 +++++++++++++++-- src/api/order.ts | 66 +++++++- src/api/position.ts | 12 ++ src/api/vipLevel.ts | 18 +++ src/components/MarqueeTitle.vue | 103 ++++++++++++ src/components/OrderBook.vue | 245 +++++++++++++++++++++++++--- src/components/TradeComponent.vue | 56 +++++-- src/locales/en.json | 30 +++- src/locales/ja.json | 30 +++- src/locales/ko.json | 30 +++- src/locales/zh-CN.json | 30 +++- src/locales/zh-TW.json | 52 ++++-- src/router/index.ts | 6 + src/views/EventMarkets.vue | 3 +- src/views/TradeDetail.vue | 63 +++++++- src/views/Wallet.vue | 260 ++++++++++++++++++++---------- 18 files changed, 1096 insertions(+), 166 deletions(-) create mode 100644 src/api/vipLevel.ts create mode 100644 src/components/MarqueeTitle.vue diff --git a/src/api/event.ts b/src/api/event.ts index e6cd8a1..37282f3 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -215,6 +215,60 @@ export async function findPmEvent( return get('/PmEvent/findPmEvent', query, config) } +/** 从嵌套对象读取事件 ID(兼容多种后端字段名) */ +export function readTradeRouteEventId(obj: unknown): string | undefined { + if (!obj || typeof obj !== 'object') return undefined + const r = obj as Record + const keys = ['eventID', 'EventID', 'eventId', 'pmEventID', 'PmEventID', 'PMEventID', 'pmEventId'] + for (const k of keys) { + const v = r[k] + if (v != null && String(v).trim() !== '') return String(v).trim() + } + return undefined +} + +/** 从嵌套对象读取事件 slug(不含通用 slug,避免与市场 slug 混淆) */ +export function readTradeRouteEventSlug(obj: unknown): string | undefined { + if (!obj || typeof obj !== 'object') return undefined + const r = obj as Record + const v = r.eventSlug ?? r.event_slug + if (v != null && String(v).trim() !== '') return String(v).trim() + return undefined +} + +export interface TradeDetailRouteInput { + /** 事件数字 ID(与首页 MarketCard :id 一致),优先用于路径参数 */ + eventId?: string | number | null + /** 事件 slug;无 eventId 时用作路径;路径为数字 ID 时可作为 query.slug 传给 findPmEvent */ + eventSlug?: string | null + /** 市场行 ID,对应详情页 query.marketId */ + marketId?: string | null + /** 详情页标题回显 query.title */ + title?: string | null +} + +/** + * 构造与 MarketCard、EventMarkets → trade-detail 一致的 router.push 参数。 + * 路径:`/trade-detail/:id`,id 为事件数字 ID 或事件 slug。 + */ +export function buildTradeDetailPushOptions( + input: TradeDetailRouteInput, +): { name: 'trade-detail'; params: { id: string }; query: Record } | null { + const eid = + input.eventId != null && String(input.eventId).trim() !== '' ? String(input.eventId).trim() : '' + const slugOnly = input.eventSlug?.trim() || '' + if (!eid && !slugOnly) return null + const pathId = eid || slugOnly + const numId = parseInt(eid, 10) + const isNumericEventPath = + eid !== '' && Number.isFinite(numId) && String(numId) === eid && numId >= 1 + const query: Record = {} + if (input.title?.trim()) query.title = input.title.trim() + if (input.marketId?.trim()) query.marketId = input.marketId.trim() + if (isNumericEventPath && slugOnly) query.slug = slugOnly + return { name: 'trade-detail', params: { id: pathId }, query } +} + /** 多选项卡片中单个选项(用于左右滑动切换) */ export interface EventCardOutcome { title: string diff --git a/src/api/historyRecord.ts b/src/api/historyRecord.ts index 2d29c0f..c95239b 100644 --- a/src/api/historyRecord.ts +++ b/src/api/historyRecord.ts @@ -3,10 +3,22 @@ * 用于 Wallet.vue 历史 Tab 数据 */ +import { readTradeRouteEventId, readTradeRouteEventSlug } from './event' import { buildQuery, get } from './request' import { BASE_URL } from './request' import type { PageResult } from './types' +/** 历史记录 type(与后端常量一致) */ +export const HISTORY_RECORD_TYPE = { + TRADE: 'TRADE', + SPLIT: 'SPLIT', + MERGE: 'MERGE', + REDEEM: 'REDEEM', + REWARD: 'REWARD', + CONVERSION: 'CONVERSION', + MAKER_REBATE: 'MAKER_REBATE', +} as const + /** 单条历史记录(与 doc.json definitions["polymarket.HistoryRecord"] 对齐) */ export interface HistoryRecordItem { ID?: number @@ -36,6 +48,10 @@ export interface HistoryRecordItem { UpdatedAt?: string /** 金额(USDC),用于充值等类型 */ usdcSize?: number + eventID?: number + eventId?: number + marketID?: string | number + marketId?: string | number } /** GET /hr/getHistoryRecordPublic 请求参数 */ @@ -136,6 +152,16 @@ export interface HistoryDisplayItem { iconClass?: string /** 图标 URL(来自 record.icon,用于展示) */ imageUrl?: string + /** 接口 type:TRADE、SPLIT、MERGE 等 */ + recordType?: string + /** TRADE 时 API 的 side:buy / sell(小写,用于标签) */ + tradeSideRaw?: string + /** 跳转详情:事件 ID */ + tradeEventId?: string + /** 跳转详情:事件 slug */ + tradeEventSlug?: string + /** 跳转详情 query.marketId */ + detailMarketId?: string } function formatTimeAgo(dateStr: string | undefined, timestamp?: number): string { @@ -166,23 +192,38 @@ function toFullIconUrl(icon: string | undefined): string | undefined { */ export function mapHistoryRecordToDisplayItem(record: HistoryRecordItem): HistoryDisplayItem { const id = String(record.ID ?? '') - const market = record.title ?? record.name ?? record.eventSlug ?? '' - const outcome = (record.outcome ?? record.side ?? 'Yes').toString() + const market = record.name ?? record.title ?? record.eventSlug ?? '' + const outcome = (record.outcome ?? 'Yes').toString() const side = outcome === 'No' || outcome === 'Down' ? 'No' : 'Yes' - const typeLabel = record.type ?? 'Trade' - const activity = `${typeLabel} ${outcome}`.trim() + const recordType = (record.type ?? HISTORY_RECORD_TYPE.TRADE).toString().toUpperCase() + const tradeSideRaw = (record.side ?? '').toLowerCase() + const activity = (record.title ?? `${recordType} · ${outcome}`).trim() const usdcSize = record.usdcSize ?? 0 const price = record.price ?? 0 const size = record.size ?? 0 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 activityDetail = + size > 0 + ? recordType === HISTORY_RECORD_TYPE.TRADE + ? `${tradeSideRaw === 'sell' ? 'Sold' : 'Bought'} ${Math.floor(size)} ${outcome} at ${priceCents}¢` + : `Sold ${Math.floor(size)} ${outcome} at ${priceCents}¢` + : value const timeAgo = formatTimeAgo( record.UpdatedAt ?? record.updatedAt ?? record.CreatedAt ?? record.createdAt, record.timestamp, ) const imageUrl = toFullIconUrl(record.icon) + const tradeEventId = readTradeRouteEventId(record) + const tradeEventSlug = + readTradeRouteEventSlug(record) || + record.eventSlug?.trim() || + record.slug?.trim() || + undefined + const rawMk = record.marketID ?? record.marketId + const detailMarketId = + rawMk != null && String(rawMk).trim() !== '' ? String(rawMk).trim() : undefined return { id, market, @@ -196,6 +237,11 @@ export function mapHistoryRecordToDisplayItem(record: HistoryRecordItem): Histor avgPrice: priceCents ? `${priceCents}¢` : undefined, shares: size > 0 ? String(Math.floor(size)) : undefined, imageUrl, + recordType, + tradeSideRaw: recordType === HISTORY_RECORD_TYPE.TRADE ? tradeSideRaw : undefined, + tradeEventId: tradeEventId || undefined, + tradeEventSlug: tradeEventSlug || undefined, + detailMarketId, } } diff --git a/src/api/mockData.ts b/src/api/mockData.ts index 09d338f..68a5dc3 100644 --- a/src/api/mockData.ts +++ b/src/api/mockData.ts @@ -19,8 +19,68 @@ export interface MockOrderBookRow { shares: number } -/** 生成随机订单簿数据,每次进入页面不同;保证卖单最低价 > 买单最高价 */ -export function generateRandomOrderBook(): { +/** random integer in [min, max] inclusive */ +function randomInt(min: number, max: number): number { + return min + Math.floor(Math.random() * (max - min + 1)) +} + +export interface GenerateRandomOrderBookOptions { + /** 最优买价(美分);与卖盘对齐后恒满足 lowestAsk = highestBid + 1 */ + highestBid?: number + /** 最优卖价(美分);与买盘对齐后恒满足 highestBid = lowestAsk - 1 */ + lowestAsk?: number + /** + * 来自 outcomePrices[0] 的美分锚(如 probability×100),确定最优卖单价; + * 最优买单价 = 卖一 − 1(1¢ 价差)。 + */ + outcomePriceAnchorCents?: number + /** + * 生成哪一侧:`both`、仅卖单 `asks`、仅买单 `bids`。 + * 不传则每次随机(约各 25% 单边卖/单边买,50% 双边)。 + */ + sides?: 'both' | 'asks' | 'bids' +} + +function buildMockAsks(lowest: number, count: number, r: () => number): MockOrderBookRow[] { + return Array.from({ length: count }, (_, i) => ({ + price: lowest + i, + shares: Math.round((500 + r() * 3000) * 10) / 10, + })) +} + +function buildMockBids(highest: number, count: number, r: () => number): MockOrderBookRow[] { + const rows: MockOrderBookRow[] = [] + for (let i = 0; i < count; i++) { + const p = highest - i + if (p < 1) break + rows.push({ + price: p, + shares: Math.round((200 + r() * 3000) * 10) / 10, + }) + } + return rows +} + +/** 触摸价:最优卖一 la,最优买一 = la − 1(1¢ 价差) */ +function touchFromLowestAsk(lowestAsk: number): { highestBid: number; lowestAsk: number } { + let la = Math.floor(lowestAsk) + la = Math.min(99, Math.max(2, la)) + return { highestBid: la - 1, lowestAsk: la } +} + +function touchFromHighestBid(highestBid: number): { highestBid: number; lowestAsk: number } { + let hb = Math.floor(highestBid) + hb = Math.min(98, Math.max(1, hb)) + return { highestBid: hb, lowestAsk: hb + 1 } +} + +/** + * 生成随机订单簿:每次调用结果不同。 + * - **买单最高价 = 卖单最低价 − 1**(触摸价差恒 1¢),档位向两侧延伸不重叠。 + * - `outcomePriceAnchorCents`:outcomePrices[0] 换算的美分,锚定最优卖一。 + * - 可仅卖单或仅买单;条数各侧 2~10 随机。 + */ +export function generateRandomOrderBook(options?: GenerateRandomOrderBookOptions): { asks: MockOrderBookRow[] bids: MockOrderBookRow[] lastPrice: number @@ -28,24 +88,63 @@ export function generateRandomOrderBook(): { } { const r = () => Math.random() - // 买单最高价 25–35,卖单最低价 = 买单最高价 + 1 + spread,保证有价差 - const highestBid = Math.floor(25 + r() * 11) - const spread = Math.max(1, Math.floor(1 + r() * 3)) - const lowestAsk = highestBid + spread + const sideRoll = r() + const sides: 'both' | 'asks' | 'bids' = + options?.sides ?? + (sideRoll < 0.25 ? 'asks' : sideRoll < 0.5 ? 'bids' : 'both') - const askPrices = Array.from({ length: 9 }, (_, i) => lowestAsk + i) - const asks: MockOrderBookRow[] = askPrices.map((p) => ({ - price: p, - shares: Math.round((500 + r() * 3000) * 10) / 10, - })) + const nAsks = randomInt(2, 10) + const nBids = randomInt(2, 10) - const bidPrices = Array.from({ length: 12 }, (_, i) => highestBid - i) - const bids: MockOrderBookRow[] = bidPrices.map((p) => ({ - price: Math.max(1, p), - shares: Math.round((200 + r() * 3000) * 10) / 10, - })) + let highestBid = 0 + let lowestAsk = 0 + let asks: MockOrderBookRow[] = [] + let bids: MockOrderBookRow[] = [] + let lastPrice = 0 + const spread = 1 - const lastPrice = Math.floor((highestBid + lowestAsk) / 2) + if (sides === 'both') { + const anchor = options?.outcomePriceAnchorCents + const hbOpt = options?.highestBid + const laOpt = options?.lowestAsk + + if (anchor != null && Number.isFinite(anchor) && anchor > 0) { + ;({ highestBid, lowestAsk } = touchFromLowestAsk(Math.round(anchor))) + } else if (laOpt != null && Number.isFinite(laOpt)) { + ;({ highestBid, lowestAsk } = touchFromLowestAsk(laOpt)) + } else if (hbOpt != null && Number.isFinite(hbOpt)) { + ;({ highestBid, lowestAsk } = touchFromHighestBid(hbOpt)) + } else { + lowestAsk = randomInt(2, 99) + highestBid = lowestAsk - 1 + } + + asks = buildMockAsks(lowestAsk, nAsks, r) + bids = buildMockBids(highestBid, nBids, r) + lastPrice = Math.round((highestBid + lowestAsk) / 2) + } else if (sides === 'asks') { + if (options?.highestBid != null && Number.isFinite(options.highestBid)) { + ;({ highestBid, lowestAsk } = touchFromHighestBid(options.highestBid)) + } else if (options?.lowestAsk != null && Number.isFinite(options.lowestAsk)) { + ;({ highestBid, lowestAsk } = touchFromLowestAsk(options.lowestAsk)) + } else { + ;({ highestBid, lowestAsk } = touchFromLowestAsk(randomInt(2, 99))) + } + asks = buildMockAsks(lowestAsk, nAsks, r) + bids = [] + lastPrice = Math.round((highestBid + lowestAsk) / 2) + } else { + if (options?.lowestAsk != null && Number.isFinite(options.lowestAsk)) { + ;({ highestBid, lowestAsk } = touchFromLowestAsk(options.lowestAsk)) + } else if (options?.highestBid != null && Number.isFinite(options.highestBid)) { + ;({ highestBid, lowestAsk } = touchFromHighestBid(options.highestBid)) + } else { + ;({ highestBid, lowestAsk } = touchFromHighestBid(randomInt(1, 98))) + } + bids = buildMockBids(highestBid, nBids, r) + asks = [] + lastPrice = Math.round((highestBid + lowestAsk) / 2) + } return { asks, bids, lastPrice, spread } } @@ -107,6 +206,8 @@ export interface MockPosition { marketID?: string tokenID?: string needClaim?: boolean + tradeEventId?: string + tradeEventSlug?: string } export interface MockOpenOrder { @@ -124,6 +225,9 @@ export interface MockOpenOrder { iconClass?: string orderID?: number tokenID?: string + tradeEventId?: string + tradeEventSlug?: string + detailMarketId?: string } export interface MockHistoryItem { @@ -140,6 +244,9 @@ export interface MockHistoryItem { shares?: string iconChar?: string iconClass?: string + tradeEventId?: string + tradeEventSlug?: string + detailMarketId?: string } export const MOCK_WALLET_POSITIONS: MockPosition[] = [ @@ -163,6 +270,8 @@ export const MOCK_WALLET_POSITIONS: MockPosition[] = [ marketID: 'mock-market-1', tokenID: MOCK_TOKEN_ID, needClaim: true, + tradeEventId: '1', + tradeEventSlug: 'mock-wallet-event', }, { id: 'p2', @@ -218,6 +327,9 @@ export const MOCK_WALLET_ORDERS: MockOpenOrder[] = [ iconClass: 'position-icon-btc', orderID: 5, tokenID: MOCK_TOKEN_ID, + tradeEventId: '1', + tradeEventSlug: 'mock-wallet-event', + detailMarketId: 'mock-market-1', }, { id: 'o2', @@ -249,6 +361,8 @@ export const MOCK_WALLET_HISTORY: MockHistoryItem[] = [ { id: 'h1', market: 'Bitcoin Up or Down - February 9, 3:00AM-3:15AM ET', + tradeEventId: '1', + tradeEventSlug: 'mock-wallet-event', side: 'No', activity: 'Sell No', activityDetail: 'Sold 1 Down at 50¢', diff --git a/src/api/order.ts b/src/api/order.ts index 76a3b6c..561db6a 100644 --- a/src/api/order.ts +++ b/src/api/order.ts @@ -1,8 +1,20 @@ -import { buildQuery, get, post } from './request' +import { readTradeRouteEventId, readTradeRouteEventSlug } from './event' +import { BASE_URL, buildQuery, get, post } from './request' import type { ApiResponse, PageResult } from './types' export type { PageResult } +/** 订单列表嵌套的市场信息(getOrderList 响应内 pmMarket) */ +export interface PmMarketEmbed { + ID?: number + question?: string + slug?: string + image?: string + icon?: string + conditionId?: string + outcomes?: string[] +} + /** * 订单项(与 doc.json definitions["model.ClobOrder"] 对齐) * GET /clob/order/getOrderList 列表项 @@ -23,9 +35,34 @@ export interface ClobOrderItem { sizeMatched?: number status?: number userID?: number + pmMarket?: PmMarketEmbed [key: string]: unknown } +/** 展示用标题:优先 pmMarket.question,否则回退 market(多为 ID) */ +export function resolveClobOrderMarketTitle(order: ClobOrderItem): string { + const q = order.pmMarket?.question?.trim() + if (q) return q + const m = order.market?.toString().trim() + return m ?? '' +} + +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(/^\//, '')}` +} + +/** 市场图:优先 pmMarket.image,其次 pmMarket.icon */ +export function resolveClobOrderMarketImageUrl(order: ClobOrderItem): string | undefined { + const pm = order.pmMarket + if (!pm) return undefined + const raw = (pm.image ?? pm.icon ?? '').trim() + return raw ? toFullIconUrl(raw) : undefined +} + /** 订单列表响应 */ export interface OrderListResponse { code: number @@ -114,6 +151,8 @@ export interface HistoryDisplayItem { shares?: string iconChar?: string iconClass?: string + /** 来自订单 pmMarket.image / icon */ + imageUrl?: string } /** Side: Buy=1, Sell=2 */ @@ -140,7 +179,7 @@ function formatTimeAgo(createdAt: string | undefined): string { */ export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem { const id = String(order.ID ?? '') - const market = order.market ?? '' + const market = resolveClobOrderMarketTitle(order) || (order.market ?? '') const outcome = order.outcome ?? 'Yes' const sideNum = order.side ?? Side.Buy const sideLabel = sideNum === Side.Sell ? 'Sell' : 'Buy' @@ -155,6 +194,7 @@ export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem const activityDetail = `${verb} ${Math.floor(size)} ${outcome} at ${priceCents}¢` const avgPrice = `${priceCents}¢` const timeAgo = formatTimeAgo(order.createdAt) + const imageUrl = resolveClobOrderMarketImageUrl(order) return { id, market, @@ -167,6 +207,7 @@ export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem timeAgo, avgPrice, shares: String(Math.floor(size)), + imageUrl, } } @@ -186,6 +227,14 @@ export interface OpenOrderDisplayItem { tokenID?: string /** 已成交数量达到原始总数量,不可撤单 */ fullyFilled?: boolean + /** 来自 pmMarket.image / icon */ + imageUrl?: string + /** 跳转详情:事件 ID */ + tradeEventId?: string + /** 跳转详情:事件 slug */ + tradeEventSlug?: string + /** 跳转详情 query.marketId(pmMarket.ID) */ + detailMarketId?: string } /** OrderType GTC=0 表示 Until Cancelled */ @@ -197,7 +246,7 @@ const OrderType = { GTC: 0, GTD: 1 } as const */ export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayItem { const id = String(order.ID ?? '') - const market = order.market ?? '' + const market = resolveClobOrderMarketTitle(order) || (order.market ?? '') const sideNum = order.side ?? Side.Buy const side = sideNum === Side.Sell ? 'No' : 'Yes' const outcome = order.outcome || (side === 'Yes' ? 'Yes' : 'No') @@ -215,6 +264,13 @@ export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayI order.orderType === OrderType.GTC ? 'Until Cancelled' : order.expiration?.toString() ?? '' const actionLabel = sideNum === Side.Buy ? `Buy ${outcome}` : `Sell ${outcome}` const fullyFilled = originalSize <= 0 || sizeMatched >= originalSize + const imageUrl = resolveClobOrderMarketImageUrl(order) + const pm = order.pmMarket + const tradeEventId = readTradeRouteEventId(pm) ?? readTradeRouteEventId(order) + const slugExplicit = readTradeRouteEventSlug(pm) ?? readTradeRouteEventSlug(order) + const slugFromPm = pm?.slug?.trim() + const tradeEventSlug = slugExplicit || (!tradeEventId && slugFromPm ? slugFromPm : undefined) + const detailMarketId = pm?.ID != null ? String(pm.ID) : undefined return { id, market, @@ -229,5 +285,9 @@ export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayI orderID: order.ID, tokenID: order.assetID, fullyFilled, + imageUrl, + tradeEventId: tradeEventId || undefined, + tradeEventSlug: tradeEventSlug || undefined, + detailMarketId, } } diff --git a/src/api/position.ts b/src/api/position.ts index 2bb5288..2855f8c 100644 --- a/src/api/position.ts +++ b/src/api/position.ts @@ -1,3 +1,4 @@ +import { readTradeRouteEventId, readTradeRouteEventSlug } from './event' import { buildQuery, get, post } from './request' import type { ApiResponse } from './types' import type { PageResult } from './types' @@ -166,6 +167,10 @@ export interface PositionDisplayItem { tokenID?: string /** 所属市场是否已关闭,market.closed=true 表示可结算/可领取 */ marketClosed?: boolean + /** 跳转市场详情:事件 ID(路径优先) */ + tradeEventId?: string + /** 跳转市场详情:事件 slug */ + tradeEventSlug?: string } /** @@ -233,6 +238,11 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay const tokenID = pos.tokenId ?? (pos as { tokenID?: string }).tokenID ?? '' const imageUrl = (pos.market?.image ?? pos.market?.icon) as string | undefined const marketClosed = pos.market?.closed === true + const tradeEventId = readTradeRouteEventId(pos.market) ?? readTradeRouteEventId(pos) + const slugExplicit = readTradeRouteEventSlug(pos.market) ?? readTradeRouteEventSlug(pos) + const slugFromMarket = pos.market?.slug?.trim() + const tradeEventSlug = + slugExplicit || (!tradeEventId && slugFromMarket ? slugFromMarket : undefined) return { id, @@ -256,5 +266,7 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay marketID: marketID || undefined, tokenID: tokenID || undefined, marketClosed, + tradeEventId: tradeEventId || undefined, + tradeEventSlug: tradeEventSlug || undefined, } } diff --git a/src/api/vipLevel.ts b/src/api/vipLevel.ts new file mode 100644 index 0000000..e8206f0 --- /dev/null +++ b/src/api/vipLevel.ts @@ -0,0 +1,18 @@ +import { get } from './request' +import type { ApiResponse } from './types' + +/** GET /VipLevel/getPmVipLevelPublic — 手续费等级(无需鉴权) */ +export interface PmVipLevelItem { + levelName: string + takerFeeRate: number + makerFeeRate: number + needDeposit: number +} + +export interface PmVipLevelPublicData { + list: PmVipLevelItem[] +} + +export async function getPmVipLevelPublic(): Promise> { + return get>('/VipLevel/getPmVipLevelPublic') +} diff --git a/src/components/MarqueeTitle.vue b/src/components/MarqueeTitle.vue new file mode 100644 index 0000000..a5e544e --- /dev/null +++ b/src/components/MarqueeTitle.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/src/components/OrderBook.vue b/src/components/OrderBook.vue index 892fb6d..452590d 100644 --- a/src/components/OrderBook.vue +++ b/src/components/OrderBook.vue @@ -91,7 +91,7 @@ import { ref, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import HorizontalProgressBar from './HorizontalProgressBar.vue' -import { generateRandomOrderBook } from '../api/mockData' +import { generateRandomOrderBook, type MockOrderBookRow } from '../api/mockData' import { USE_MOCK_ORDER_BOOK } from '../config/mock' const { t } = useI18n() @@ -126,6 +126,15 @@ const props = withDefaults( connected?: boolean yesLabel?: string noLabel?: string + /** Yes 簿真实买单最高价(美分),单侧真实数据时用于生成另一侧模拟盘口 */ + anchorBestBidYesCents?: number + anchorLowestAskYesCents?: number + anchorBestBidNoCents?: number + anchorLowestAskNoCents?: number + /** 无真实盘口时:Yes 侧用 outcomePrices[0] 换算的美分锚定最优卖一(买一=卖一−1) */ + outcomePriceAnchorYesCents?: number + /** 无真实盘口时:No 侧锚定(常为 outcomePrices[1]),由父组件传入 */ + outcomePriceAnchorNoCents?: number }>(), { asksYes: () => [], @@ -144,17 +153,179 @@ const props = withDefaults( connected: false, yesLabel: 'Yes', noLabel: 'No', + anchorBestBidYesCents: 0, + anchorLowestAskYesCents: 0, + anchorBestBidNoCents: 0, + anchorLowestAskNoCents: 0, + outcomePriceAnchorYesCents: 0, + outcomePriceAnchorNoCents: 0, }, ) // State:up = Yes 交易,down = No 交易 const activeTrade = ref('up') -const initMock = generateRandomOrderBook() -const internalAsks = ref([...initMock.asks]) -const internalBids = ref([...initMock.bids]) -const internalLastPrice = ref(initMock.lastPrice) -const internalSpread = ref(initMock.spread) +/** 一侧真实、另一侧由 mock 填充时的模拟档位与展示的 last/spread */ +type HybridSupplement = { + mockAsks: OrderBookRow[] + mockBids: OrderBookRow[] + lastPrice: number + spread: number +} + +const hybridSupplementYes = ref(null) +const hybridSupplementNo = ref(null) + +/** 双侧皆无真实数据时的完整模拟簿(Yes / No 各自一份) */ +const fullMockYes = ref(null) +const fullMockNo = ref(null) + +function mockRowsToOrderBookRows(rows: MockOrderBookRow[]): OrderBookRow[] { + return rows.map((r) => ({ price: r.price, shares: r.shares })) +} + +function bestBidCentsFromRows(rows: OrderBookRow[] | undefined): number { + if (!rows?.length) return 0 + return Math.max(...rows.map((b) => b.price)) +} + +function bestAskCentsFromRows(rows: OrderBookRow[] | undefined): number { + if (!rows?.length) return 0 + return Math.min(...rows.map((a) => a.price)) +} + +function supplementFromGen(gen: ReturnType): HybridSupplement { + return { + mockAsks: mockRowsToOrderBookRows(gen.asks), + mockBids: mockRowsToOrderBookRows(gen.bids), + lastPrice: gen.lastPrice, + spread: gen.spread, + } +} + +function syncOrderBookMocks() { + if (!USE_MOCK_ORDER_BOOK || props.loading) { + hybridSupplementYes.value = null + hybridSupplementNo.value = null + fullMockYes.value = null + fullMockNo.value = null + return + } + + const runSide = ( + nAsks: number, + nBids: number, + anchorBidProp: number, + anchorAskProp: number, + bidsRows: OrderBookRow[] | undefined, + asksRows: OrderBookRow[] | undefined, + setRef: (v: HybridSupplement | null) => void, + ) => { + if (nAsks > 0 && nBids > 0) { + setRef(null) + return + } + if (nAsks === 0 && nBids === 0) { + setRef(null) + return + } + const hb = + anchorBidProp > 0 ? Math.floor(anchorBidProp) : bestBidCentsFromRows(bidsRows) + const la = + anchorAskProp > 0 ? Math.floor(anchorAskProp) : bestAskCentsFromRows(asksRows) + if (nAsks === 0 && nBids > 0 && hb > 0) { + const gen = generateRandomOrderBook({ highestBid: hb, sides: 'asks' }) + setRef({ + mockAsks: mockRowsToOrderBookRows(gen.asks), + mockBids: [], + lastPrice: gen.lastPrice, + spread: gen.spread, + }) + return + } + if (nBids === 0 && nAsks > 0 && la > 0) { + const gen = generateRandomOrderBook({ lowestAsk: la, sides: 'bids' }) + setRef({ + mockAsks: [], + mockBids: mockRowsToOrderBookRows(gen.bids), + lastPrice: gen.lastPrice, + spread: gen.spread, + }) + return + } + setRef(null) + } + + runSide( + props.asksYes?.length ?? 0, + props.bidsYes?.length ?? 0, + props.anchorBestBidYesCents ?? 0, + props.anchorLowestAskYesCents ?? 0, + props.bidsYes, + props.asksYes, + (v) => { + hybridSupplementYes.value = v + }, + ) + runSide( + props.asksNo?.length ?? 0, + props.bidsNo?.length ?? 0, + props.anchorBestBidNoCents ?? 0, + props.anchorLowestAskNoCents ?? 0, + props.bidsNo, + props.asksNo, + (v) => { + hybridSupplementNo.value = v + }, + ) + + const ay = props.asksYes?.length ?? 0 + const by = props.bidsYes?.length ?? 0 + if (ay === 0 && by === 0) { + const anchorY = props.outcomePriceAnchorYesCents ?? 0 + fullMockYes.value = supplementFromGen( + generateRandomOrderBook({ + sides: 'both', + outcomePriceAnchorCents: anchorY > 0 ? anchorY : undefined, + }), + ) + } else { + fullMockYes.value = null + } + + const an = props.asksNo?.length ?? 0 + const bn = props.bidsNo?.length ?? 0 + if (an === 0 && bn === 0) { + const anchorN = props.outcomePriceAnchorNoCents ?? 0 + fullMockNo.value = supplementFromGen( + generateRandomOrderBook({ + sides: 'both', + outcomePriceAnchorCents: anchorN > 0 ? anchorN : undefined, + }), + ) + } else { + fullMockNo.value = null + } +} + +watch( + () => + [ + props.loading, + props.asksYes?.length ?? 0, + props.bidsYes?.length ?? 0, + props.asksNo?.length ?? 0, + props.bidsNo?.length ?? 0, + props.anchorBestBidYesCents ?? 0, + props.anchorLowestAskYesCents ?? 0, + props.anchorBestBidNoCents ?? 0, + props.anchorLowestAskNoCents ?? 0, + props.outcomePriceAnchorYesCents ?? 0, + props.outcomePriceAnchorNoCents ?? 0, + ] as const, + () => syncOrderBookMocks(), + { immediate: true }, +) // 根据 activeTrade 选择当前 tab 对应的数据;loading 时不显示模拟数据 const asks = computed(() => { @@ -162,22 +333,34 @@ const asks = computed(() => { const isYes = activeTrade.value === 'up' const fromYes = isYes ? props.asksYes : props.asksNo const fromLegacy = props.asks + const hybrid = isYes ? hybridSupplementYes.value : hybridSupplementNo.value const hasYesNo = (fromYes?.length ?? 0) > 0 const hasLegacy = (fromLegacy?.length ?? 0) > 0 if (hasYesNo) return fromYes ?? [] if (hasLegacy) return fromLegacy ?? [] - return USE_MOCK_ORDER_BOOK ? internalAsks.value : [] + if (USE_MOCK_ORDER_BOOK && hybrid && hybrid.mockAsks.length > 0) return hybrid.mockAsks + if (USE_MOCK_ORDER_BOOK) { + const full = isYes ? fullMockYes.value : fullMockNo.value + if (full?.mockAsks.length) return full.mockAsks + } + return [] }) const bids = computed(() => { if (props.loading) return [] const isYes = activeTrade.value === 'up' const fromYes = isYes ? props.bidsYes : props.bidsNo const fromLegacy = props.bids + const hybrid = isYes ? hybridSupplementYes.value : hybridSupplementNo.value const hasYesNo = (fromYes?.length ?? 0) > 0 const hasLegacy = (fromLegacy?.length ?? 0) > 0 if (hasYesNo) return fromYes ?? [] if (hasLegacy) return fromLegacy ?? [] - return USE_MOCK_ORDER_BOOK ? internalBids.value : [] + if (USE_MOCK_ORDER_BOOK && hybrid && hybrid.mockBids.length > 0) return hybrid.mockBids + if (USE_MOCK_ORDER_BOOK) { + const full = isYes ? fullMockYes.value : fullMockNo.value + if (full?.mockBids.length) return full.mockBids + } + return [] }) const displayLastPrice = computed(() => { if (props.loading) return '—' @@ -185,7 +368,13 @@ const displayLastPrice = computed(() => { const fromYesNo = isYes ? props.lastPriceYes : props.lastPriceNo if (fromYesNo != null) return fromYesNo if (props.lastPrice != null) return props.lastPrice - return USE_MOCK_ORDER_BOOK ? internalLastPrice.value : 0 + if (USE_MOCK_ORDER_BOOK) { + const h = isYes ? hybridSupplementYes.value : hybridSupplementNo.value + if (h) return h.lastPrice + const full = isYes ? fullMockYes.value : fullMockNo.value + if (full) return full.lastPrice + } + return 0 }) const displaySpread = computed(() => { if (props.loading) return '—' @@ -193,7 +382,13 @@ const displaySpread = computed(() => { const fromYesNo = isYes ? props.spreadYes : props.spreadNo if (fromYesNo != null) return fromYesNo if (props.spread != null) return props.spread - return USE_MOCK_ORDER_BOOK ? internalSpread.value : 0 + if (USE_MOCK_ORDER_BOOK) { + const h = isYes ? hybridSupplementYes.value : hybridSupplementNo.value + if (h) return h.spread + const full = isYes ? fullMockYes.value : fullMockNo.value + if (full) return full.spread + } + return 0 }) // 仅在没有外部数据且开启 mock 时运行 mock 更新 @@ -212,24 +407,22 @@ watch( mockInterval = undefined } else if (!hasRealData && USE_MOCK_ORDER_BOOK && !mockInterval) { mockInterval = setInterval(() => { - const randomAskIndex = Math.floor(Math.random() * internalAsks.value.length) - const askItem = internalAsks.value[randomAskIndex] - if (askItem) { - internalAsks.value[randomAskIndex] = { - price: askItem.price, - shares: Math.max(0, askItem.shares + Math.floor(Math.random() * 100) - 50), + const jitterSide = (book: HybridSupplement | null) => { + if (!book) return book + if (!book.mockAsks.length && !book.mockBids.length) return book + const jitterRow = (row: OrderBookRow): OrderBookRow => ({ + ...row, + shares: Math.max(0, row.shares + Math.floor(Math.random() * 100) - 50), + }) + return { + mockAsks: book.mockAsks.map(jitterRow), + mockBids: book.mockBids.map(jitterRow), + lastPrice: Math.max(1, Math.min(99, book.lastPrice + Math.floor(Math.random() * 3) - 1)), + spread: 1, } } - const randomBidIndex = Math.floor(Math.random() * internalBids.value.length) - const bidItem = internalBids.value[randomBidIndex] - if (bidItem) { - internalBids.value[randomBidIndex] = { - price: bidItem.price, - shares: Math.max(0, bidItem.shares + Math.floor(Math.random() * 100) - 50), - } - } - internalLastPrice.value = Math.max(1, Math.min(99, internalLastPrice.value + Math.floor(Math.random() * 3) - 1)) - internalSpread.value = Math.max(1, internalSpread.value + Math.floor(Math.random() * 2) - 1) + fullMockYes.value = jitterSide(fullMockYes.value) + fullMockNo.value = jitterSide(fullMockNo.value) }, 2000) } }, diff --git a/src/components/TradeComponent.vue b/src/components/TradeComponent.vue index 09ef43e..c3d9394 100644 --- a/src/components/TradeComponent.vue +++ b/src/components/TradeComponent.vue @@ -1494,6 +1494,7 @@ function indexOfAllowedPrice(v: number): number { export interface TradeMarketPayload { marketId?: string + /** 优先来自订单簿卖一(0–1);为 0 时可配合 outcomePrices 解析 */ yesPrice: number noPrice: number title?: string @@ -1501,6 +1502,8 @@ export interface TradeMarketPayload { clobTokenIds?: string[] /** 选项展示文案,如 ["Yes","No"] 或 ["Up","Down"],用于 Buy Yes/No 按钮文字 */ outcomes?: string[] + /** yesPrice/noPrice 均为 0 时用于回退展示与限价(与 API outcomePrices 同源) */ + outcomePrices?: Array /** 订单簿 Yes 买单最高价(美分),市价卖出时用于计算将收到金额 */ bestBidYesCents?: number /** 订单簿 No 买单最高价(美分),市价卖出时用于计算将收到金额 */ @@ -1633,8 +1636,41 @@ async function submitSplit() { defineExpose({ openMergeDialog, openSplitDialog }) -const yesPriceCents = computed(() => (props.market ? Math.round(props.market.yesPrice * 100) : 19)) -const noPriceCents = computed(() => (props.market ? Math.round(props.market.noPrice * 100) : 82)) +/** outcomePrices 单项 → 美元单价 0–1(兼容概率小数或 1–100 美分写法) */ +function outcomeRawToDollars(raw: string | number | undefined): number { + if (raw == null || (typeof raw === 'string' && raw.trim() === '')) return 0 + const p = parseFloat(String(raw)) + if (!Number.isFinite(p)) return 0 + if (p > 1 && p <= 100) return Math.min(1, p / 100) + return Math.min(1, Math.max(0, p)) +} + +/** 展示/限价:优先 market.yesPrice(来自订单簿卖一),否则 outcomePrices[0] */ +const yesPriceDollarsEffective = computed(() => { + const m = props.market + if (!m) return 0.19 + if (Math.round(m.yesPrice * 100) > 0) return m.yesPrice + const d = outcomeRawToDollars(m.outcomePrices?.[0]) + return d > 0 ? d : (m.yesPrice > 0 ? m.yesPrice : 0.19) +}) + +const noPriceDollarsEffective = computed(() => { + const m = props.market + if (!m) return 0.82 + if (Math.round(m.noPrice * 100) > 0) return m.noPrice + const raw1 = m.outcomePrices?.[1] + if (raw1 != null && String(raw1).trim() !== '') { + const d = outcomeRawToDollars(raw1) + if (d > 0) return d + } + const y = yesPriceDollarsEffective.value + if (y > 0 && y <= 1) return Math.min(1, Math.max(0, 1 - y)) + const d0 = outcomeRawToDollars(m.outcomePrices?.[0]) + return d0 > 0 ? d0 : (m.noPrice > 0 ? m.noPrice : 0.82) +}) + +const yesPriceCents = computed(() => Math.round(yesPriceDollarsEffective.value * 100)) +const noPriceCents = computed(() => Math.round(noPriceDollarsEffective.value * 100)) const yesLabel = computed(() => props.market?.outcomes?.[0] ?? 'Yes') const noLabel = computed(() => props.market?.outcomes?.[1] ?? 'No') @@ -1766,9 +1802,7 @@ const avgPriceCents = computed(() => { const toWinValue = computed(() => { if (isMarketMode.value) { const price = - selectedOption.value === 'yes' - ? (props.market?.yesPrice ?? 0.5) - : (props.market?.noPrice ?? 0.5) + selectedOption.value === 'yes' ? yesPriceDollarsEffective.value : noPriceDollarsEffective.value const sharesFromAmount = price > 0 ? amount.value / price : 0 return trimTrailingZeros(sharesFromAmount.toFixed(2)) } @@ -1831,9 +1865,9 @@ function clampLimitPrice(v: number): number { /** 根据当前 props.market 与 selectedOption 同步 limitPrice(组件显示或 market 更新时调用) */ function syncLimitPriceFromMarket() { - const yesP = props.market?.yesPrice ?? 0.19 - const noP = props.market?.noPrice ?? 0.82 - limitPrice.value = clampLimitPrice(selectedOption.value === 'yes' ? yesP : noP) + limitPrice.value = clampLimitPrice( + selectedOption.value === 'yes' ? yesPriceDollarsEffective.value : noPriceDollarsEffective.value, + ) } onMounted(() => { @@ -1903,9 +1937,9 @@ watch(limitType, () => saveTradePrefs(), { flush: 'post' }) // Methods const handleOptionChange = (option: 'yes' | 'no') => { selectedOption.value = option - const yesP = props.market?.yesPrice ?? 0.19 - const noP = props.market?.noPrice ?? 0.82 - limitPrice.value = clampLimitPrice(option === 'yes' ? yesP : noP) + limitPrice.value = clampLimitPrice( + option === 'yes' ? yesPriceDollarsEffective.value : noPriceDollarsEffective.value, + ) emit('optionChange', option) // Set max shares when option changes in sell mode diff --git a/src/locales/en.json b/src/locales/en.json index 11817d6..6d2ab28 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -221,6 +221,14 @@ "noPositionsFound": "No positions found.", "noOpenOrdersFound": "No open orders found.", "noHistoryFound": "You haven't traded any polymarkets yet", + "historySideBuy": "BUY", + "historySideSell": "SELL", + "historyTypeSplit": "SPLIT", + "historyTypeMerge": "MERGE", + "historyTypeRedeem": "REDEEM", + "historyTypeReward": "REWARD", + "historyTypeConversion": "CONVERSION", + "historyTypeMakerRebate": "MAKER REBATE", "market": "Market", "avgNow": "AVG → NOW", "bet": "BET", @@ -308,7 +316,27 @@ "nameSaved": "Username updated", "changeAvatar": "Change avatar", "avatarUploadSuccess": "Avatar updated", - "avatarUploadFailed": "Failed to upload avatar" + "avatarUploadFailed": "Failed to upload avatar", + "memberCenter": "Member Center", + "depositCoin": "Deposit", + "withdrawCoin": "Withdraw" + }, + "memberCenter": { + "title": "Member Center", + "currentLevel": "Current level", + "vipLabel": "VIP {n}", + "goRecharge": "Top up", + "explainTitle": "Levels", + "hintNeedMoreDeposit": "{amount} more USDC needed to reach the next level.", + "footnote": "* Thresholds and statistics follow backend rules.", + "tier0": "Complete registration.", + "tierNeedDeposit": "Cumulative deposits ≥ {amount} USDC.", + "feesLine": "Taker {taker} · Maker {maker}", + "loadError": "Failed to load VIP tiers. Please try again.", + "tier1": "Cumulative deposits ≥ $10,000 USDC.", + "tier2": "Cumulative deposits ≥ $50,000 USDC.", + "tier3": "Cumulative deposits ≥ $200,000 USDC.", + "tier4": "Cumulative deposits ≥ $500,000 USDC;" }, "deposit": { "title": "Deposit", diff --git a/src/locales/ja.json b/src/locales/ja.json index 08edc4a..b2e6545 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -221,6 +221,14 @@ "noPositionsFound": "ポジションがありません", "noOpenOrdersFound": "未約定注文がありません", "noHistoryFound": "まだ取引履歴がありません", + "historySideBuy": "買い", + "historySideSell": "売り", + "historyTypeSplit": "スプリット", + "historyTypeMerge": "マージ", + "historyTypeRedeem": "償還", + "historyTypeReward": "リワード", + "historyTypeConversion": "コンバージョン", + "historyTypeMakerRebate": "メイカー還元", "market": "市場", "avgNow": "平均→現在", "bet": "ベット", @@ -308,7 +316,27 @@ "nameSaved": "ユーザー名を更新しました", "changeAvatar": "アバターを変更", "avatarUploadSuccess": "アバターを更新しました", - "avatarUploadFailed": "アバターのアップロードに失敗しました" + "avatarUploadFailed": "アバターのアップロードに失敗しました", + "memberCenter": "メンバー", + "depositCoin": "入金", + "withdrawCoin": "出金" + }, + "memberCenter": { + "title": "メンバーセンター", + "currentLevel": "現在のレベル", + "vipLabel": "VIP {n}", + "goRecharge": "チャージへ", + "explainTitle": "レベル説明", + "hintNeedMoreDeposit": "次のレベルまであと {amount} USDC の入金が必要です。", + "footnote": "* 数値と条件はバックエンドの規則に従います。", + "tier0": "登録で達成。", + "tierNeedDeposit": "累計入金 ≥ {amount} USDC。", + "feesLine": "テイカー {taker} · メイカー {maker}", + "loadError": "レベル設定の読み込みに失敗しました", + "tier1": "累計入金 ≥ $10,000 USDC。", + "tier2": "累計入金 ≥ $50,000 USDC。", + "tier3": "累計入金 ≥ $200,000 USDC。", + "tier4": "累計入金 ≥ $500,000 USDC。" }, "deposit": { "title": "入金", diff --git a/src/locales/ko.json b/src/locales/ko.json index 38f5fa7..f246722 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -221,6 +221,14 @@ "noPositionsFound": "포지션이 없습니다", "noOpenOrdersFound": "미체결 주문이 없습니다", "noHistoryFound": "아직 거래 내역이 없습니다", + "historySideBuy": "매수", + "historySideSell": "매도", + "historyTypeSplit": "스플릿", + "historyTypeMerge": "머지", + "historyTypeRedeem": "상환", + "historyTypeReward": "리워드", + "historyTypeConversion": "전환", + "historyTypeMakerRebate": "메이커 리베이트", "market": "시장", "avgNow": "평균→현재", "bet": "베팅", @@ -308,7 +316,27 @@ "nameSaved": "사용자 이름이 업데이트되었습니다", "changeAvatar": "아바타 변경", "avatarUploadSuccess": "아바타가 업데이트되었습니다", - "avatarUploadFailed": "아바타 업로드에 실패했습니다" + "avatarUploadFailed": "아바타 업로드에 실패했습니다", + "memberCenter": "멤버십", + "depositCoin": "입금", + "withdrawCoin": "출금" + }, + "memberCenter": { + "title": "멤버 센터", + "currentLevel": "현재 등급", + "vipLabel": "VIP {n}", + "goRecharge": "충전하기", + "explainTitle": "등급 안내", + "hintNeedMoreDeposit": "다음 등급까지 {amount} USDC를 더 입금해야 합니다.", + "footnote": "* 통계와 기준은 백엔드 규칙을 따릅니다.", + "tier0": "가입 완료 시.", + "tierNeedDeposit": "누적 입금 ≥ {amount} USDC.", + "feesLine": "테이커 {taker} · 메이커 {maker}", + "loadError": "등급 정보를 불러오지 못했습니다", + "tier1": "누적 입금 ≥ $10,000 USDC.", + "tier2": "누적 입금 ≥ $50,000 USDC.", + "tier3": "누적 입금 ≥ $200,000 USDC.", + "tier4": "누적 입금 ≥ $500,000 USDC ." }, "deposit": { "title": "입금", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 381244c..364b881 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -221,6 +221,14 @@ "noPositionsFound": "暂无持仓", "noOpenOrdersFound": "暂无未成交订单", "noHistoryFound": "您还未进行过任何交易", + "historySideBuy": "买入", + "historySideSell": "卖出", + "historyTypeSplit": "拆分", + "historyTypeMerge": "合并", + "historyTypeRedeem": "赎回", + "historyTypeReward": "奖励", + "historyTypeConversion": "转换", + "historyTypeMakerRebate": "做市返佣", "market": "市场", "avgNow": "均价→现价", "bet": "投注", @@ -308,7 +316,27 @@ "nameSaved": "用户名已更新", "changeAvatar": "更换头像", "avatarUploadSuccess": "头像已更新", - "avatarUploadFailed": "头像上传失败" + "avatarUploadFailed": "头像上传失败", + "memberCenter": "会员中心", + "depositCoin": "充币", + "withdrawCoin": "提币" + }, + "memberCenter": { + "title": "会员中心", + "currentLevel": "当前等级", + "vipLabel": "VIP {n}", + "goRecharge": "去充值", + "explainTitle": "等级说明", + "hintNeedMoreDeposit": "距离下一等级还需要充值 {amount} USDC。", + "footnote": "* 统计数据与门槛以后台规则为准。", + "tier0": "完成注册即可。", + "tierNeedDeposit": "累计充值 ≥ {amount} USDC。", + "feesLine": "吃单 {taker} · 挂单 {maker}", + "loadError": "等级配置加载失败,请稍后重试", + "tier1": "累计充值 ≥ $10,000 USDC。", + "tier2": "累计充值 ≥ $50,000 USDC。", + "tier3": "累计充值 ≥ $200,000 USDC。", + "tier4": "累计充值 ≥ $500,000 USDC;" }, "deposit": { "title": "入金", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index d1e33d7..fee6fdf 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -221,6 +221,14 @@ "noPositionsFound": "暫無持倉", "noOpenOrdersFound": "暫無未成交訂單", "noHistoryFound": "您還未進行過任何交易", + "historySideBuy": "買入", + "historySideSell": "賣出", + "historyTypeSplit": "拆分", + "historyTypeMerge": "合併", + "historyTypeRedeem": "贖回", + "historyTypeReward": "獎勵", + "historyTypeConversion": "轉換", + "historyTypeMakerRebate": "做市返佣", "market": "市場", "avgNow": "均價→現價", "bet": "投注", @@ -293,22 +301,42 @@ "defaultName": "使用者", "vipTrader": "VIP Trader", "trader": "Trader", - "editComingSoon": "編輯功能即將上線", - "editNameTitle": "修改使用者名稱", - "newUserName": "使用者名稱", - "cancel": "取消", + "editComingSoon": "編輯功能即將上線", + "editNameTitle": "修改使用者名稱", + "newUserName": "使用者名稱", + "cancel": "取消", "save": "儲存", "nameSaving": "儲存中...", "nameSaveFailed": "使用者名稱更新失敗", "nameRequired": "使用者名稱不能为空", - "nameTooLong": "使用者名稱不能超過 {max} 個字元", - "nameInvalidFormat": "使用者名稱只能包含字母、數字與底線(_)", - "nameTooShort": "使用者名稱長度至少 {min} 個字元", - "nameFormatHint": "只能包含字母、數字與底線(a-z / 0-9 / _)", - "nameSaved": "使用者名稱已更新", - "changeAvatar": "更換頭像", - "avatarUploadSuccess": "頭像已更新", - "avatarUploadFailed": "頭像上傳失敗" + "nameTooLong": "使用者名稱不能超過 {max} 個字元", + "nameInvalidFormat": "使用者名稱只能包含字母、數字與底線(_)", + "nameTooShort": "使用者名稱長度至少 {min} 個字元", + "nameFormatHint": "只能包含字母、數字與底線(a-z / 0-9 / _)", + "nameSaved": "使用者名稱已更新", + "changeAvatar": "更換頭像", + "avatarUploadSuccess": "頭像已更新", + "avatarUploadFailed": "頭像上傳失敗", + "memberCenter": "會員中心", + "depositCoin": "充幣", + "withdrawCoin": "提幣" + }, + "memberCenter": { + "title": "會員中心", + "currentLevel": "目前等級", + "vipLabel": "VIP {n}", + "goRecharge": "去充值", + "explainTitle": "等級說明", + "hintNeedMoreDeposit": "距離下一等級還需要充值 {amount} USDC。", + "footnote": "* 統計數據與門檻以後台規則為準。", + "tier0": "完成註冊即可。", + "tierNeedDeposit": "累計充值 ≥ {amount} USDC。", + "feesLine": "吃單 {taker} · 掛單 {maker}", + "loadError": "等級配置載入失敗,請稍後再試", + "tier1": "累計充值 ≥ $10,000 USDC。", + "tier2": "累計充值 ≥ $50,000 USDC。", + "tier3": "累計充值 ≥ $200,000 USDC。", + "tier4": "累計充值 ≥ $500,000 USDC;" }, "deposit": { "title": "入金", diff --git a/src/router/index.ts b/src/router/index.ts index 4cbc309..4875f10 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -7,6 +7,7 @@ import EventMarkets from '../views/EventMarkets.vue' import Wallet from '../views/Wallet.vue' import Search from '../views/Search.vue' import Profile from '../views/Profile.vue' +import MemberCenter from '../views/MemberCenter.vue' import ApiKey from '../views/ApiKey.vue' const router = createRouter({ @@ -52,6 +53,11 @@ const router = createRouter({ name: 'profile', component: Profile, }, + { + path: '/member-center', + name: 'member-center', + component: MemberCenter, + }, { path: '/api-key', name: 'api-key', diff --git a/src/views/EventMarkets.vue b/src/views/EventMarkets.vue index 5b7257a..3c4ea90 100644 --- a/src/views/EventMarkets.vue +++ b/src/views/EventMarkets.vue @@ -281,6 +281,7 @@ const tradeMarketPayload = computed(() => { title: m.question, clobTokenIds: m.clobTokenIds, outcomes: m.outcomes, + outcomePrices: m.outcomePrices, } }) @@ -415,7 +416,7 @@ function setChartSeries(seriesArr: ChartSeriesItem[]) { const series = chartInstance!.addSeries(LineSeries, { color, lineWidth: 2, - lineType: LineType.Curved, + lineType: LineType.Simple, lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate, crosshairMarkerVisible: true, lastValueVisible: true, diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue index c581de1..1942551 100644 --- a/src/views/TradeDetail.vue +++ b/src/views/TradeDetail.vue @@ -187,6 +187,12 @@ :bids-yes="orderBookBidsYes" :asks-no="orderBookAsksNo" :bids-no="orderBookBidsNo" + :anchor-best-bid-yes="orderBookBestBidYesCents" + :anchor-lowest-ask-yes="orderBookLowestAskYesCents" + :anchor-best-bid-no="orderBookBestBidNoCents" + :anchor-lowest-ask-no="orderBookLowestAskNoCents" + :outcome-price-anchor-yes-cents="orderBookOutcomeAnchorYesCents" + :outcome-price-anchor-no-cents="orderBookOutcomeAnchorNoCents" :last-price-yes="clobLastPriceYes" :last-price-no="clobLastPriceNo" :spread-yes="clobSpreadYes" @@ -615,6 +621,32 @@ const orderBookBestBidNoCents = computed(() => { if (!bids.length) return 0 return Math.max(...bids.map((b) => b.price)) }) + +/** outcomePrices 单项 → 订单簿锚价(美分):概率 0–1 或已是美分 1–100 */ +function outcomePriceToAnchorCents(raw: string | number | undefined): number { + if (raw == null || (typeof raw === 'string' && raw.trim() === '')) return 0 + const p = parseFloat(String(raw)) + if (!Number.isFinite(p)) return 0 + if (p > 1 && p <= 100) return Math.min(99, Math.max(1, Math.round(p))) + return Math.min(99, Math.max(0, Math.round(p * 100))) +} + +/** 无真实盘口时模拟 Yes 簿:锚定 outcomePrices[0] 为最优卖一 */ +const orderBookOutcomeAnchorYesCents = computed(() => + outcomePriceToAnchorCents(currentMarket.value?.outcomePrices?.[0]), +) + +/** 无真实盘口时模拟 No 簿:优先 outcomePrices[1],否则与 Yes 互补(100−Yes¢) */ +const orderBookOutcomeAnchorNoCents = computed(() => { + const m = currentMarket.value + const raw1 = m?.outcomePrices?.[1] + if (raw1 != null && String(raw1).trim() !== '') { + return outcomePriceToAnchorCents(raw1) + } + const y = orderBookOutcomeAnchorYesCents.value + if (y > 0) return Math.min(99, Math.max(2, 100 - y)) + return outcomePriceToAnchorCents(m?.outcomePrices?.[0]) +}) const clobLastPriceYes = computed(() => clobLastPriceByToken.value[0]) const clobLastPriceNo = computed(() => clobLastPriceByToken.value[1]) const clobSpreadYes = computed(() => clobSpreadByToken.value[0]) @@ -758,14 +790,36 @@ function disconnectClob() { clobLoading.value = false } -/** 传给 TradeComponent 的 market,供 Split 调用 /PmMarket/split;yesPrice/noPrice 取订单簿卖单最低价,无数据时为 0 */ +/** + * 传给 TradeComponent:yesPrice/noPrice 优先取对应 token 订单簿 **卖单最低价**(美元 0–1), + * 取不到再用当前市场的 outcomePrices。 + */ const tradeMarketPayload = computed(() => { const m = currentMarket.value - const yesPrice = orderBookLowestAskYesCents.value / 100 - const noPrice = orderBookLowestAskNoCents.value / 100 + const yC = orderBookLowestAskYesCents.value + const nC = orderBookLowestAskNoCents.value + let yesPrice = yC > 0 ? yC / 100 : 0 + let noPrice = nC > 0 ? nC / 100 : 0 const bestBidYesCents = orderBookBestBidYesCents.value const bestBidNoCents = orderBookBestBidNoCents.value + if (m) { + if (yesPrice <= 0) { + const c = outcomePriceToAnchorCents(m.outcomePrices?.[0]) + if (c > 0) yesPrice = c / 100 + } + if (noPrice <= 0) { + const raw1 = m.outcomePrices?.[1] + if (raw1 != null && String(raw1).trim() !== '') { + const c = outcomePriceToAnchorCents(raw1) + if (c > 0) noPrice = c / 100 + } else if (yesPrice > 0) { + noPrice = Math.min(1, Math.max(0, 1 - yesPrice)) + } else { + const c = outcomePriceToAnchorCents(m.outcomePrices?.[0]) + if (c > 0) noPrice = c / 100 + } + } return { marketId: getMarketId(m), yesPrice, @@ -773,6 +827,7 @@ const tradeMarketPayload = computed(() => { title: m.question, clobTokenIds: m.clobTokenIds, outcomes: m.outcomes, + outcomePrices: m.outcomePrices, bestBidYesCents, bestBidNoCents, } @@ -1152,7 +1207,7 @@ function ensureChartSeries() { chartSeries = chartInstance.addSeries(LineSeries, { color: lineColor, lineWidth: 2, - lineType: LineType.Curved, + lineType: LineType.Simple, lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate, crosshairMarkerVisible: true, lastValueVisible: true, diff --git a/src/views/Wallet.vue b/src/views/Wallet.vue index bd5dc75..5f61b3e 100644 --- a/src/views/Wallet.vue +++ b/src/views/Wallet.vue @@ -75,6 +75,12 @@ v-for="pos in paginatedPositions" :key="pos.id" class="position-mobile-card design-pos-card" + :class="{ 'wallet-row--nav': canOpenTradeDetail(pos) }" + :role="canOpenTradeDetail(pos) ? 'button' : undefined" + :tabindex="canOpenTradeDetail(pos) ? 0 : -1" + @click="onPositionRowClick(pos)" + @keydown.enter.prevent="canOpenTradeDetail(pos) && onPositionRowClick(pos)" + @keydown.space.prevent="canOpenTradeDetail(pos) && onPositionRowClick(pos)" >
@@ -91,12 +97,7 @@ {{ pos.iconChar || '•' }}
-
-
- {{ pos.market }} - -
-
+
{{ t('wallet.noOpenOrdersFound') }}
-
+
- {{ ord.iconChar || '•' }} + + {{ ord.iconChar || '•' }}
-
-
- {{ getOrderDisplayTitle(ord) }} - -
-
+
{{ getOrderActionLabel(ord) @@ -216,7 +232,15 @@ v-for="h in paginatedHistory" :key="h.id" class="history-mobile-card" - :class="isFundingHistory(h) ? 'design-funding-card' : 'design-trade-card'" + :class="[ + isFundingHistory(h) ? 'design-funding-card' : 'design-trade-card', + { 'wallet-row--nav': historyRowCanOpenDetail(h) }, + ]" + :role="historyRowCanOpenDetail(h) ? 'button' : undefined" + :tabindex="historyRowCanOpenDetail(h) ? 0 : undefined" + @click="onHistoryRowClick(h)" + @keydown.enter.prevent="onHistoryRowClick(h)" + @keydown.space.prevent="onHistoryRowClick(h)" >