新增:限价单接口对接

This commit is contained in:
ivan 2026-02-11 21:25:50 +08:00
parent 297d2d1c56
commit b73a910b43
9 changed files with 331 additions and 63 deletions

View File

@ -105,8 +105,13 @@ Swagger UI 页面(如 [PmEvent findPmEvent](https://api.xtrader.vip/swagger/in
### 公开事件列表 `GET /PmEvent/getPmEventPublic` ### 公开事件列表 `GET /PmEvent/getPmEventPublic`
- **Query**page、pageSize、keyword、createdAtRangearray - **Query**page、pageSize、keyword、createdAtRangearray、tokenid可选来自 market.clobTokenIds 的值,可传单个或数组)。
- **响应 200**`data``response.PageResult`list、page、pageSize、totallist 项为 `polymarket.PmEvent`,内含 markets、series、tags 等markets[].outcomePrices 为价格数组,首项对应 Yes 概率。 - **响应 200**`data``response.PageResult`list、page、pageSize、totallist 项为 `polymarket.PmEvent`,内含 markets、series、tags 等markets[].outcomePrices 为价格数组,首项对应 Yes 概率markets[].clobTokenIds 与 outcomes/outcomePrices 顺序一致。
### 订单类型与交易方向(用于交易接口,`src/api/constants.ts`
- **OrderType**GTC=0一直有效直到取消、GTD=1指定时间内有效、FOK=2全部成交或取消、FAK=3立即成交剩余取消、Market=4市价单
- **Side**Buy=1、Sell=2。
### 通用响应与鉴权 ### 通用响应与鉴权

30
src/api/constants.ts Normal file
View File

@ -0,0 +1,30 @@
/**
* OrderType
*
*/
export const OrderType = {
/** Good Till Cancelled: 一直有效直到取消 */
GTC: 0,
/** Good Till Date: 指定时间内有效 */
GTD: 1,
/** Fill Or Kill: 全部成交或立即取消(不允许部分成交) */
FOK: 2,
/** Fill And Kill (IOC): 立即成交,剩余部分取消 */
FAK: 3,
/** Market Order: 市价单,立即按最优价成交,剩余取消 (同 FAK但不限价) */
Market: 4,
} as const
export type OrderTypeValue = (typeof OrderType)[keyof typeof OrderType]
/**
* Side
*/
export const Side = {
/** 买入 */
Buy: 1,
/** 卖出 */
Sell: 2,
} as const
export type SideValue = (typeof Side)[keyof typeof Side]

View File

@ -65,8 +65,8 @@ export interface PmEventMarketItem {
outcomes?: string[] outcomes?: string[]
/** 各选项价格outcomes[0] 对应 outcomePrices[0] */ /** 各选项价格outcomes[0] 对应 outcomePrices[0] */
outcomePrices?: string[] | number[] outcomePrices?: string[] | number[]
/** 市场对应的 clob token id 与 outcomePrices 和outcomes顺序一致 */ /** 市场对应的 clob token id 与 outcomePrices、outcomes 顺序一致outcomes[0] 对应 clobTokenIds[0] */
clobTokenIds:string[] clobTokenIds?: string[]
endDate?: string endDate?: string
volume?: number volume?: number
[key: string]: unknown [key: string]: unknown
@ -79,6 +79,16 @@ export function getMarketId(m: PmEventMarketItem | null | undefined): string | u
return raw != null ? String(raw) : undefined return raw != null ? String(raw) : undefined
} }
/** 从市场项取 clobTokenIdoutcomeIndex 0=Yes/第一选项1=No/第二选项 */
export function getClobTokenId(
m: PmEventMarketItem | null | undefined,
outcomeIndex: 0 | 1 = 0
): string | undefined {
if (!m?.clobTokenIds?.length) return undefined
const id = m.clobTokenIds[outcomeIndex]
return id != null ? String(id) : undefined
}
/** 对应 definitions polymarket.PmSeries 常用字段 */ /** 对应 definitions polymarket.PmSeries 常用字段 */
export interface PmEventSeriesItem { export interface PmEventSeriesItem {
ID?: number ID?: number
@ -109,22 +119,30 @@ export interface GetPmEventListParams {
keyword?: string keyword?: string
/** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */ /** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */
createdAtRange?: string[] createdAtRange?: string[]
/** clobTokenIds 对应的值,用于按市场 token 筛选;可从 market.clobTokenIds 获取 */
tokenid?: string | string[]
} }
/** /**
* Event * Event
* GET /PmEvent/getPmEventPublic * GET /PmEvent/getPmEventPublic
*
* Query: page, pageSize, keyword, createdAtRange, tokenid
* tokenid market.clobTokenIds
*/ */
export async function getPmEventPublic( export async function getPmEventPublic(
params: GetPmEventListParams = {} params: GetPmEventListParams = {}
): Promise<PmEventListResponse> { ): Promise<PmEventListResponse> {
const { page = 1, pageSize = 10, keyword, createdAtRange } = params const { page = 1, pageSize = 10, keyword, createdAtRange, tokenid } = params
const query: Record<string, string | number | string[] | undefined> = { const query: Record<string, string | number | string[] | undefined> = {
page, page,
pageSize, pageSize,
} }
if (keyword != null && keyword !== '') query.keyword = keyword if (keyword != null && keyword !== '') query.keyword = keyword
if (createdAtRange != null && createdAtRange.length) query.createdAtRange = createdAtRange if (createdAtRange != null && createdAtRange.length) query.createdAtRange = createdAtRange
if (tokenid != null) {
query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid]
}
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query) return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
} }
@ -186,6 +204,8 @@ export interface EventCardOutcome {
noLabel?: string noLabel?: string
/** 可选,用于交易时区分 market */ /** 可选,用于交易时区分 market */
marketId?: string marketId?: string
/** 用于下单 tokenId与 outcomes 顺序一致 */
clobTokenIds?: string[]
} }
/** /**
@ -213,6 +233,8 @@ export interface EventCardItem {
isNew?: boolean isNew?: boolean
/** 当前市场 ID单 market 时为第一个 market 的 ID供交易/Split 使用) */ /** 当前市场 ID单 market 时为第一个 market 的 ID供交易/Split 使用) */
marketId?: string marketId?: string
/** 用于下单 tokenId单 market 时取自 firstMarket.clobTokenIds */
clobTokenIds?: string[]
} }
/** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */ /** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */
@ -298,6 +320,7 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
yesLabel: m.outcomes?.[0] ?? 'Yes', yesLabel: m.outcomes?.[0] ?? 'Yes',
noLabel: m.outcomes?.[1] ?? 'No', noLabel: m.outcomes?.[1] ?? 'No',
marketId: getMarketId(m), marketId: getMarketId(m),
clobTokenIds: m.clobTokenIds,
})) }))
: undefined : undefined
@ -316,5 +339,6 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
noLabel: firstMarket?.outcomes?.[1] ?? 'No', noLabel: firstMarket?.outcomes?.[1] ?? 'No',
isNew: item.new === true, isNew: item.new === true,
marketId: getMarketId(firstMarket), marketId: getMarketId(firstMarket),
clobTokenIds: firstMarket?.clobTokenIds,
} }
} }

View File

@ -7,6 +7,36 @@ export interface ApiResponse<T = unknown> {
msg: string msg: string
} }
/**
* /clob/gateway/submitOrder
* tokenID market.clobTokenIdsoutcomeIndex 0=Yes 1=No
*/
export interface ClobSubmitOrderRequest {
expiration: number
feeRateBps: number
nonce: number
orderType: number
/** 价格(整数,已乘 10000 */
price: number
side: number
/** 数量(份额) */
size: number
taker: boolean
tokenID: string
userID: number
}
/**
* POST /clob/gateway/submitOrder
* / Yes No
*/
export async function pmOrderPlace(
data: ClobSubmitOrderRequest,
config?: { headers?: Record<string, string> }
): Promise<ApiResponse> {
return post<ApiResponse>('/clob/gateway/submitOrder', data, config)
}
/** /**
* Split /PmMarket/split * Split /PmMarket/split
* USDC Yes+No 1 USDC 1 Yes + 1 No * USDC Yes+No 1 USDC 1 Yes + 1 No

View File

@ -171,7 +171,7 @@ const router = useRouter()
const emit = defineEmits<{ const emit = defineEmits<{
openTrade: [ openTrade: [
side: 'yes' | 'no', side: 'yes' | 'no',
market?: { id: string; title: string; marketId?: string; outcomeTitle?: string } market?: { id: string; title: string; marketId?: string; outcomeTitle?: string; clobTokenIds?: string[] }
] ]
}>() }>()
@ -198,6 +198,8 @@ const props = withDefaults(
isNew?: boolean isNew?: boolean
/** 当前市场 ID单 market 时供交易/Split 使用) */ /** 当前市场 ID单 market 时供交易/Split 使用) */
marketId?: string marketId?: string
/** 用于下单 tokenId单 market 时 */
clobTokenIds?: string[]
}>(), }>(),
{ {
marketTitle: 'Mamdan opens city-owned grocery store b...', marketTitle: 'Mamdan opens city-owned grocery store b...',
@ -289,7 +291,12 @@ const navigateToDetail = () => {
} }
function openTradeSingle(side: 'yes' | 'no') { function openTradeSingle(side: 'yes' | 'no') {
emit('openTrade', side, { id: props.id, title: props.marketTitle, marketId: props.marketId }) emit('openTrade', side, {
id: props.id,
title: props.marketTitle,
marketId: props.marketId,
clobTokenIds: props.clobTokenIds,
})
} }
function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) { function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) {
@ -298,6 +305,7 @@ function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) {
title: outcome.title, title: outcome.title,
marketId: outcome.marketId, marketId: outcome.marketId,
outcomeTitle: outcome.title, outcomeTitle: outcome.title,
clobTokenIds: outcome.clobTokenIds,
}) })
} }
</script> </script>

View File

@ -84,8 +84,9 @@
</template> </template>
</div> </div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<!-- Action Button --> <!-- Action Button -->
<v-btn class="action-btn" @click="submitOrder"> <v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">
{{ actionButtonText }} {{ actionButtonText }}
</v-btn> </v-btn>
</template> </template>
@ -172,13 +173,17 @@
<v-icon>mdi-minus</v-icon> <v-icon>mdi-minus</v-icon>
</v-btn> </v-btn>
<v-text-field <v-text-field
v-model.number="limitPrice" :model-value="limitPrice"
type="number" type="number"
min="0.01" min="0"
max="1"
step="0.01" step="0.01"
class="price-input-field" class="price-input-field"
hide-details hide-details
density="compact" density="compact"
@update:model-value="onLimitPriceInput"
@keydown="onLimitPriceKeydown"
@paste="onLimitPricePaste"
></v-text-field> ></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice"> <v-btn class="adjust-btn" icon @click="increasePrice">
<v-icon>mdi-plus</v-icon> <v-icon>mdi-plus</v-icon>
@ -193,12 +198,15 @@
<span class="label">Shares</span> <span class="label">Shares</span>
<div class="shares-input"> <div class="shares-input">
<v-text-field <v-text-field
v-model.number="shares" :model-value="shares"
type="number" type="number"
min="0" min="1"
class="shares-input-field" class="shares-input-field"
hide-details hide-details
density="compact" density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field> ></v-text-field>
</div> </div>
</div> </div>
@ -267,8 +275,8 @@
</template> </template>
</div> </div>
<!-- Action Button --> <p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" @click="submitOrder"> <v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">
{{ actionButtonText }} {{ actionButtonText }}
</v-btn> </v-btn>
</template> </template>
@ -314,7 +322,8 @@
<div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div> <div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div>
</template> </template>
</div> </div>
<v-btn class="action-btn" @click="submitOrder">{{ actionButtonText }}</v-btn> <p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
@ -346,7 +355,7 @@
<span class="label">Limit Price</span> <span class="label">Limit Price</span>
<div class="price-input"> <div class="price-input">
<v-btn class="adjust-btn" icon @click="decreasePrice"><v-icon>mdi-minus</v-icon></v-btn> <v-btn class="adjust-btn" icon @click="decreasePrice"><v-icon>mdi-minus</v-icon></v-btn>
<v-text-field v-model.number="limitPrice" type="number" min="0.01" step="0.01" class="price-input-field" hide-details density="compact"></v-text-field> <v-text-field :model-value="limitPrice" type="number" min="0" max="1" step="0.01" class="price-input-field" hide-details density="compact" @update:model-value="onLimitPriceInput" @keydown="onLimitPriceKeydown" @paste="onLimitPricePaste"></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice"><v-icon>mdi-plus</v-icon></v-btn> <v-btn class="adjust-btn" icon @click="increasePrice"><v-icon>mdi-plus</v-icon></v-btn>
</div> </div>
</div> </div>
@ -355,7 +364,7 @@
<div class="shares-header"> <div class="shares-header">
<span class="label">Shares</span> <span class="label">Shares</span>
<div class="shares-input"> <div class="shares-input">
<v-text-field v-model.number="shares" type="number" min="0" class="shares-input-field" hide-details density="compact"></v-text-field> <v-text-field :model-value="shares" type="number" min="1" class="shares-input-field" hide-details density="compact" @update:model-value="onSharesInput" @keydown="onSharesKeydown" @paste="onSharesPaste"></v-text-field>
</div> </div>
</div> </div>
<div v-if="activeTab === 'buy'" class="shares-buttons"> <div v-if="activeTab === 'buy'" class="shares-buttons">
@ -393,7 +402,8 @@
<div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div> <div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div>
</template> </template>
</div> </div>
<v-btn class="action-btn" @click="submitOrder">{{ actionButtonText }}</v-btn> <p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template> </template>
</div> </div>
</v-sheet> </v-sheet>
@ -462,41 +472,42 @@
</template> </template>
<template v-else> <template v-else>
<div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div> <div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div>
</template>
</div>
<v-btn class="action-btn" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template>
<template v-else>
<div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div>
<div class="input-group">
<div class="amount-header">
<div><span class="label amount-label">Amount</span><span class="balance-label">Balance ${{ balance.toFixed(2) }}</span></div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">Max</v-btn>
</div>
</div>
<v-btn class="deposit-btn" @click="deposit">Deposit</v-btn>
</template> </template>
</div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template> </template>
<template v-else> <template v-else>
<div class="price-options hide-in-mobile-sheet"> <div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn> <v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn> <v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div> </div>
<div class="input-group limit-price-group"> <div class="input-group">
<div class="amount-header">
<div><span class="label amount-label">Amount</span><span class="balance-label">Balance ${{ balance.toFixed(2) }}</span></div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">Max</v-btn>
</div>
</div>
<v-btn class="deposit-btn" @click="deposit">Deposit</v-btn>
</template>
</template>
<template v-else>
<div class="price-options hide-in-mobile-sheet">
<v-btn class="yes-btn" :class="{ active: selectedOption === 'yes' }" text @click="handleOptionChange('yes')">Yes {{ yesPriceCents }}¢</v-btn>
<v-btn class="no-btn" :class="{ active: selectedOption === 'no' }" text @click="handleOptionChange('no')">No {{ noPriceCents }}¢</v-btn>
</div>
<div class="input-group limit-price-group">
<div class="limit-price-header"> <div class="limit-price-header">
<span class="label">Limit Price</span> <span class="label">Limit Price</span>
<div class="price-input"> <div class="price-input">
<v-btn class="adjust-btn" icon @click="decreasePrice"><v-icon>mdi-minus</v-icon></v-btn> <v-btn class="adjust-btn" icon @click="decreasePrice"><v-icon>mdi-minus</v-icon></v-btn>
<v-text-field v-model.number="limitPrice" type="number" min="0.01" step="0.01" class="price-input-field" hide-details density="compact"></v-text-field> <v-text-field :model-value="limitPrice" type="number" min="0" max="1" step="0.01" class="price-input-field" hide-details density="compact" @update:model-value="onLimitPriceInput" @keydown="onLimitPriceKeydown" @paste="onLimitPricePaste"></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice"><v-icon>mdi-plus</v-icon></v-btn> <v-btn class="adjust-btn" icon @click="increasePrice"><v-icon>mdi-plus</v-icon></v-btn>
</div> </div>
</div> </div>
@ -505,7 +516,7 @@
<div class="shares-header"> <div class="shares-header">
<span class="label">Shares</span> <span class="label">Shares</span>
<div class="shares-input"> <div class="shares-input">
<v-text-field v-model.number="shares" type="number" min="0" class="shares-input-field" hide-details density="compact"></v-text-field> <v-text-field :model-value="shares" type="number" min="1" class="shares-input-field" hide-details density="compact" @update:model-value="onSharesInput" @keydown="onSharesKeydown" @paste="onSharesPaste"></v-text-field>
</div> </div>
</div> </div>
<div v-if="activeTab === 'buy'" class="shares-buttons"> <div v-if="activeTab === 'buy'" class="shares-buttons">
@ -543,7 +554,8 @@
<div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div> <div class="total-row"><span class="label">You'll receive</span><span class="to-win-value"><v-icon size="16" color="green">mdi-currency-usd</v-icon> ${{ totalPrice }}</span></div>
</template> </template>
</div> </div>
<v-btn class="action-btn" @click="submitOrder">{{ actionButtonText }}</v-btn> <p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn class="action-btn" :loading="orderLoading" :disabled="orderLoading" @click="submitOrder">{{ actionButtonText }}</v-btn>
</template> </template>
</div> </div>
</v-sheet> </v-sheet>
@ -648,7 +660,8 @@
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useUserStore } from '../stores/user' import { useUserStore } from '../stores/user'
import { pmMarketMerge, pmMarketSplit } from '../api/market' import { pmMarketMerge, pmMarketSplit, pmOrderPlace } from '../api/market'
import { OrderType, Side } from '../api/constants'
const { mobile } = useDisplay() const { mobile } = useDisplay()
const userStore = useUserStore() const userStore = useUserStore()
@ -658,6 +671,8 @@ export interface TradeMarketPayload {
yesPrice: number yesPrice: number
noPrice: number noPrice: number
title?: string title?: string
/** 与 outcomes/outcomePrices 顺序一致,用于下单 tokenId0=Yes 1=No */
clobTokenIds?: string[]
} }
const props = withDefaults( const props = withDefaults(
@ -762,7 +777,7 @@ const limitType = ref('Limit')
const expirationEnabled = ref(false) const expirationEnabled = ref(false)
const selectedOption = ref<'yes' | 'no'>(props.initialOption ?? 'no') const selectedOption = ref<'yes' | 'no'>(props.initialOption ?? 'no')
const limitPrice = ref(0.82) // const limitPrice = ref(0.82) //
const shares = ref(20) // const shares = ref(20) //
const expirationTime = ref('5m') // const expirationTime = ref('5m') //
const expirationOptions = ref(['5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d']) // const expirationOptions = ref(['5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d']) //
@ -771,6 +786,9 @@ const isMarketMode = computed(() => limitType.value === 'Market')
const amount = ref(0) // Market mode amount const amount = ref(0) // Market mode amount
const balance = ref(0) // Market mode balance const balance = ref(0) // Market mode balance
const orderLoading = ref(false)
const orderError = ref('')
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
optionChange: [option: 'yes' | 'no'] optionChange: [option: 'yes' | 'no']
@ -803,11 +821,15 @@ function applyInitialOption(option: 'yes' | 'no') {
syncLimitPriceFromMarket() syncLimitPriceFromMarket()
} }
function clampLimitPrice(v: number): number {
return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0))
}
/** 根据当前 props.market 与 selectedOption 同步 limitPrice组件显示或 market 更新时调用) */ /** 根据当前 props.market 与 selectedOption 同步 limitPrice组件显示或 market 更新时调用) */
function syncLimitPriceFromMarket() { function syncLimitPriceFromMarket() {
const yesP = props.market?.yesPrice ?? 0.19 const yesP = props.market?.yesPrice ?? 0.19
const noP = props.market?.noPrice ?? 0.82 const noP = props.market?.noPrice ?? 0.82
limitPrice.value = selectedOption.value === 'yes' ? yesP : noP limitPrice.value = clampLimitPrice(selectedOption.value === 'yes' ? yesP : noP)
} }
onMounted(() => { onMounted(() => {
@ -820,6 +842,7 @@ watch(() => props.initialOption, (option) => {
watch(() => props.market, (m) => { watch(() => props.market, (m) => {
if (m) { if (m) {
orderError.value = ''
if (props.initialOption) applyInitialOption(props.initialOption) if (props.initialOption) applyInitialOption(props.initialOption)
else syncLimitPriceFromMarket() else syncLimitPriceFromMarket()
} }
@ -830,29 +853,90 @@ const handleOptionChange = (option: 'yes' | 'no') => {
selectedOption.value = option selectedOption.value = option
const yesP = props.market?.yesPrice ?? 0.19 const yesP = props.market?.yesPrice ?? 0.19
const noP = props.market?.noPrice ?? 0.82 const noP = props.market?.noPrice ?? 0.82
limitPrice.value = option === 'yes' ? yesP : noP limitPrice.value = clampLimitPrice(option === 'yes' ? yesP : noP)
emit('optionChange', option) emit('optionChange', option)
} }
// /** 仅在值在 [0,1] 且为有效数字时更新,否则保持原值不变 */
function onLimitPriceInput(v: unknown) {
const num = v == null ? NaN : Number(v)
if (!Number.isFinite(num) || num < 0 || num > 1) return
limitPrice.value = num
}
/** 只允许数字和小数点输入 */
function onLimitPriceKeydown(e: KeyboardEvent) {
const key = e.key
const allowed = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
if (allowed.includes(key)) return
if (e.ctrlKey || e.metaKey) {
if (['a', 'c', 'v', 'x'].includes(key.toLowerCase())) return
}
if (key >= '0' && key <= '9') return
if (key === '.' && !String((e.target as HTMLInputElement)?.value ?? '').includes('.')) return
e.preventDefault()
}
/** 粘贴时只接受有效数字 */
function onLimitPricePaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text') ?? ''
const num = parseFloat(text)
if (!Number.isFinite(num) || num < 0 || num > 1) {
e.preventDefault()
}
}
// 01
const decreasePrice = () => { const decreasePrice = () => {
limitPrice.value = Math.max(0.01, limitPrice.value - 0.01) limitPrice.value = clampLimitPrice(limitPrice.value - 0.01)
} }
const increasePrice = () => { const increasePrice = () => {
limitPrice.value += 0.01 limitPrice.value = clampLimitPrice(limitPrice.value + 0.01)
} }
// /** 将 shares 限制为正整数(>= 1 */
function clampShares(v: number): number {
const n = Math.floor(Number.isFinite(v) ? v : 1)
return Math.max(1, n)
}
/** 仅在值为正整数时更新 shares */
function onSharesInput(v: unknown) {
const num = v == null ? NaN : Number(v)
const n = Math.floor(num)
if (!Number.isFinite(num) || n < 1 || num !== n) return
shares.value = n
}
/** 只允许数字输入Shares 为正整数) */
function onSharesKeydown(e: KeyboardEvent) {
const key = e.key
const allowed = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
if (allowed.includes(key)) return
if (e.ctrlKey || e.metaKey) {
if (['a', 'c', 'v', 'x'].includes(key.toLowerCase())) return
}
if (key >= '0' && key <= '9') return
e.preventDefault()
}
/** 粘贴时只接受正整数 */
function onSharesPaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text') ?? ''
const num = parseInt(text, 10)
if (!Number.isFinite(num) || num < 1) e.preventDefault()
}
//
const adjustShares = (amount: number) => { const adjustShares = (amount: number) => {
shares.value = Math.max(0, shares.value + amount) shares.value = clampShares(shares.value + amount)
} }
// Sell使 // Sell使
const setSharesPercentage = (percentage: number) => { const setSharesPercentage = (percentage: number) => {
// 100
const maxShares = 100 const maxShares = 100
shares.value = Math.round((maxShares * percentage) / 100) shares.value = clampShares(Math.round((maxShares * percentage) / 100))
} }
// Market mode methods // Market mode methods
@ -870,17 +954,95 @@ const deposit = () => {
// API // API
} }
// Set expiration /** 将 expirationTime 如 "5m" 转为 Unix 秒级时间戳GTD 用0 表示无过期 */
function submitOrder() { function parseExpirationTimestamp(expTime: string): number {
emit('submit', { const m = /^(\d+)(m|h|d)$/i.exec(expTime)
if (!m) return 0
const [, num, unit] = m
const n = parseInt(num ?? '0', 10)
if (!Number.isFinite(n) || n <= 0) return 0
const now = Date.now()
let ms = n
if (unit?.toLowerCase() === 'm') ms = n * 60 * 1000
else if (unit?.toLowerCase() === 'h') ms = n * 3600 * 1000
else if (unit?.toLowerCase() === 'd') ms = n * 86400 * 1000
return Math.floor((now + ms) / 1000)
}
// Set expiration /clob/gateway/submitOrder
async function submitOrder() {
const marketId = props.market?.marketId
const clobIds = props.market?.clobTokenIds
const outcomeIndex = selectedOption.value === 'yes' ? 0 : 1
const tokenId = clobIds?.[outcomeIndex]
const payload = {
side: activeTab.value as 'buy' | 'sell', side: activeTab.value as 'buy' | 'sell',
option: selectedOption.value, option: selectedOption.value,
limitPrice: limitPrice.value, limitPrice: limitPrice.value,
shares: shares.value, shares: shares.value,
expirationEnabled: expirationEnabled.value, expirationEnabled: expirationEnabled.value,
expirationTime: expirationTime.value, expirationTime: expirationTime.value,
...(props.market?.marketId != null && { marketId: props.market.marketId }), ...(marketId != null && { marketId }),
}) }
emit('submit', payload)
if (!tokenId) {
orderError.value = '请先选择市场(需包含 clobTokenIds'
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
orderError.value = '请先登录'
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userIdNum = uid != null ? Number(uid) : 0
if (!Number.isFinite(userIdNum) || userIdNum <= 0) {
orderError.value = '用户信息异常'
return
}
const isMarket = limitType.value === 'Market'
const orderTypeNum = isMarket
? OrderType.Market
: expirationEnabled.value
? OrderType.GTD
: OrderType.GTC
const sideNum = activeTab.value === 'buy' ? Side.Buy : Side.Sell
const expiration =
orderTypeNum === OrderType.GTD && expirationEnabled.value
? parseExpirationTimestamp(expirationTime.value)
: 0
orderLoading.value = true
orderError.value = ''
try {
const res = await pmOrderPlace(
{
expiration,
feeRateBps: 0,
nonce: 0,
orderType: orderTypeNum,
price: limitPrice.value,
side: sideNum,
size: clampShares(shares.value),
taker: true,
tokenID: tokenId,
userID: userIdNum,
},
{ headers }
)
if (res.code === 0 || res.code === 200) {
userStore.fetchUsdcBalance()
} else {
orderError.value = res.msg || '下单失败'
}
} catch (e) {
orderError.value = e instanceof Error ? e.message : 'Request failed'
} finally {
orderLoading.value = false
}
} }
</script> </script>
@ -1423,6 +1585,12 @@ function submitOrder() {
color: #dc2626; color: #dc2626;
} }
.order-error {
font-size: 13px;
color: #dc2626;
margin: 8px 0 0;
}
.merge-dialog-actions { .merge-dialog-actions {
padding: 16px 20px 20px; padding: 16px 20px 20px;
padding-top: 8px; padding-top: 8px;

View File

@ -156,6 +156,7 @@ const tradeMarketPayload = computed(() => {
yesPrice, yesPrice,
noPrice, noPrice,
title: m.question, title: m.question,
clobTokenIds: m.clobTokenIds,
} }
}) })

View File

@ -30,6 +30,7 @@
:no-label="card.noLabel" :no-label="card.noLabel"
:is-new="card.isNew" :is-new="card.isNew"
:market-id="card.marketId" :market-id="card.marketId"
:clob-token-ids="card.clobTokenIds"
@open-trade="onCardOpenTrade" @open-trade="onCardOpenTrade"
/> />
<div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty"> <div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
@ -202,7 +203,7 @@ const noMoreEvents = computed(() => {
const footerLang = ref('English') const footerLang = ref('English')
const tradeDialogOpen = ref(false) const tradeDialogOpen = ref(false)
const tradeDialogSide = ref<'yes' | 'no'>('yes') const tradeDialogSide = ref<'yes' | 'no'>('yes')
const tradeDialogMarket = ref<{ id: string; title: string; marketId?: string } | null>(null) const tradeDialogMarket = ref<{ id: string; title: string; marketId?: string; clobTokenIds?: string[] } | null>(null)
const scrollRef = ref<HTMLElement | null>(null) const scrollRef = ref<HTMLElement | null>(null)
function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: string; marketId?: string }) { function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: string; marketId?: string }) {
@ -211,7 +212,7 @@ function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: strin
tradeDialogOpen.value = true tradeDialogOpen.value = true
} }
/** 传给 TradeComponent 的 marketHome 弹窗/底部栏),供 Split 等使用;优先用 marketId无则用事件 id */ /** 传给 TradeComponent 的 marketHome 弹窗/底部栏),供 Split、下单等使用 */
const homeTradeMarketPayload = computed(() => { const homeTradeMarketPayload = computed(() => {
const m = tradeDialogMarket.value const m = tradeDialogMarket.value
if (!m) return undefined if (!m) return undefined
@ -219,7 +220,7 @@ const homeTradeMarketPayload = computed(() => {
const chance = 50 const chance = 50
const yesPrice = Math.min(1, Math.max(0, chance / 100)) const yesPrice = Math.min(1, Math.max(0, chance / 100))
const noPrice = 1 - yesPrice const noPrice = 1 - yesPrice
return { marketId, yesPrice, noPrice, title: m.title } return { marketId, yesPrice, noPrice, title: m.title, clobTokenIds: m.clobTokenIds }
}) })
const sentinelRef = ref<HTMLElement | null>(null) const sentinelRef = ref<HTMLElement | null>(null)

View File

@ -265,6 +265,7 @@ const tradeMarketPayload = computed(() => {
yesPrice, yesPrice,
noPrice, noPrice,
title: m.question, title: m.question,
clobTokenIds: m.clobTokenIds,
} }
} }
const qId = route.query.marketId const qId = route.query.marketId