Compare commits

...

6 Commits

Author SHA1 Message Date
ivan
ff7bc3b685 优化:订单簿显示优化 2026-03-02 09:41:11 +08:00
ivan
0069ae8357 优化:限价单已经完成的不可以撤消 2026-03-01 00:46:06 +08:00
ivan
fffd7461df 优化:限价单份额显示单位更改 2026-02-28 23:59:50 +08:00
ivan
df82732fad 优化:卖出订单份额默认取最大有效份额 2026-02-28 23:31:33 +08:00
ivan
ff6c7a1877 优化:订单份额单位统一优化 2026-02-28 23:11:54 +08:00
ivan
1ac53ffb79 优化:下单锁单优化 2026-02-28 23:08:48 +08:00
16 changed files with 465 additions and 98 deletions

23
docs/api/order.md Normal file
View File

@ -0,0 +1,23 @@
# order.ts
**路径**`src/api/order.ts`
## 功能用途
订单相关 API获取订单列表、取消订单以及将 `ClobOrderItem` 映射为展示项History、Open Orders
## 使用方式
```typescript
import { getOrderList, mapOrderToOpenOrderItem, mapOrderToHistoryItem, cancelOrder } from '@/api/order'
```
## 数据单位约定
- **price**:整数,已乘 10000bps`priceCents = price / 100`
- **originalSize / sizeMatched**:按 6 位小数传1_000_000 = 1 share展示时除以 `ORDER_SIZE_SCALE` 转为实际份额
## 扩展方式
1. 新增订单状态或筛选参数时,更新 `GetOrderListParams``getOrderList`
2. 展示格式变更时,调整 `mapOrderToOpenOrderItem``mapOrderToHistoryItem` 的格式化逻辑

View File

@ -37,12 +37,14 @@ interface TradePositionItem {
- 事件处理:`onAmountInput``onAmountKeydown``onAmountPaste` - 事件处理:`onAmountInput``onAmountKeydown``onAmountPaste`
- 余额不足时 Buy 显示 Deposit 按钮 - 余额不足时 Buy 显示 Deposit 按钮
- 25%/50%/Max 快捷份额 - 25%/50%/Max 快捷份额
- **Sell 模式份额默认取最大可卖**:切换至 Sell 或 maxAvailableShares 变化时自动调用 `setMaxShares()`,将 shares 设为最大可卖数量(无可卖时为 0
- **Sell 模式 UI 优化** - **Sell 模式 UI 优化**
- Shares 标签与 Max shares 提示同行显示(`max-shares-inline` - Shares 标签与 Max shares 提示同行显示(`max-shares-inline`
- 输入框独占一行(`shares-input-wrapper` - 输入框独占一行(`shares-input-wrapper`
- 25%/50%/Max 按钮独立一行(`sell-shares-buttons` - 25%/50%/Max 按钮独立一行(`sell-shares-buttons`
- 整体布局更清晰:`Shares Max: 2``[输入框]``[25%][50%][Max]` - 整体布局更清晰:`Shares Max: 2``[输入框]``[25%][50%][Max]`
- 调用 market API 下单、Split、Merge - 调用 market API 下单、Split、Merge
- **submitOrder size 统一乘 1_000_000**:无论 Market Buyamount、Market Sell、Limit Buy、Limit Sellshares传入 `pmOrderPlace``size` 均先取原始值再 `Math.round(rawSize * 1_000_000)`
- **合并/拆分成功后触发事件**`mergeSuccess``splitSuccess`,父组件监听后可刷新持仓列表 - **合并/拆分成功后触发事件**`mergeSuccess``splitSuccess`,父组件监听后可刷新持仓列表
- **401 权限错误提示**:通过 `useAuthError().formatAuthError` 统一处理,未登录显示「请先登录」,已登录显示「权限不足」 - **401 权限错误提示**:通过 `useAuthError().formatAuthError` 统一处理,未登录显示「请先登录」,已登录显示「权限不足」

View File

@ -10,13 +10,15 @@
## 核心能力 ## 核心能力
- 分时图ECharts 渲染,支持 Past、时间粒度切换 - 分时图ECharts 渲染,支持 Past、时间粒度切换
- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送) - 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送);份额接口按 6 位小数传1_000_000 = 1 share`priceSizeToRows``mergeDelta` 会将 raw 值除以 `ORDER_BOOK_SIZE_SCALE` 转为展示值
- 交易:`TradeComponent`,传入 `market``initialOption``positions`(持仓数据) - 交易:`TradeComponent`,传入 `market``initialOption``positions`(持仓数据)
- 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额 - 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额
- 限价订单:通过 `getOrderList` 获取当前市场未成交限价单,支持撤单 - 限价订单:通过 `getOrderList` 获取当前市场未成交限价单,支持撤单
- 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent` - 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent`
- Merge/Split通过 `TradeComponent` 或底部菜单触发,成功后监听 `mergeSuccess`/`splitSuccess` 事件刷新持仓 - Merge/Split通过 `TradeComponent` 或底部菜单触发,成功后监听 `mergeSuccess`/`splitSuccess` 事件刷新持仓
- **401 权限错误**:加载详情失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」 - **401 权限错误**:加载详情失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
- **Sell 弹窗 emitsOptions 竞态**`sellDialogRenderContent` 延迟 350ms 卸载 TradeComponent等 v-dialog transition 完成,避免 Vue patch 时组件实例为 null 的 `emitsOptions` 错误
- **底部栏 slot 竞态**`tradeSheetRenderContent` 延迟 50ms 挂载、350ms 卸载 TradeComponent避免 v-bottom-sheet transition 期间 VTextField 触发「Slot default invoked outside of the render function」警告
## 使用方式 ## 使用方式

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>TestMarket</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -126,6 +126,9 @@ export interface HistoryDisplayItem {
/** Side: Buy=1, Sell=2 */ /** Side: Buy=1, Sell=2 */
const Side = { Buy: 1, Sell: 2 } as const const Side = { Buy: 1, Sell: 2 } as const
/** 订单份额接口按 6 位小数传1_000_000 = 1 share需除以该系数转为展示值 */
const ORDER_SIZE_SCALE = 1_000_000
function formatTimeAgo(createdAt: string | undefined): string { function formatTimeAgo(createdAt: string | undefined): string {
if (!createdAt) return '' if (!createdAt) return ''
const d = new Date(createdAt) const d = new Date(createdAt)
@ -140,7 +143,7 @@ function formatTimeAgo(createdAt: string | undefined): string {
/** /**
* ClobOrderItem History * ClobOrderItem History
* price 10000sizeMatched * price 10000size 6 1_000_000 = 1 share
*/ */
export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem { export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem {
const id = String(order.ID ?? '') const id = String(order.ID ?? '')
@ -151,11 +154,12 @@ export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem
const activity = `${sideLabel} ${outcome}` const activity = `${sideLabel} ${outcome}`
const priceBps = order.price ?? 0 const priceBps = order.price ?? 0
const priceCents = Math.round(priceBps / 100) const priceCents = Math.round(priceBps / 100)
const size = order.sizeMatched ?? order.originalSize ?? 0 const sizeRaw = order.sizeMatched ?? order.originalSize ?? 0
const size = sizeRaw / ORDER_SIZE_SCALE
const valueUsd = (priceBps / 10000) * size const valueUsd = (priceBps / 10000) * size
const value = `$${valueUsd.toFixed(2)}` const value = `$${valueUsd.toFixed(2)}`
const verb = sideNum === Side.Sell ? 'Sold' : 'Bought' const verb = sideNum === Side.Sell ? 'Sold' : 'Bought'
const activityDetail = `${verb} ${size} ${outcome} at ${priceCents}¢` const activityDetail = `${verb} ${Math.floor(size)} ${outcome} at ${priceCents}¢`
const avgPrice = `${priceCents}¢` const avgPrice = `${priceCents}¢`
const timeAgo = formatTimeAgo(order.createdAt) const timeAgo = formatTimeAgo(order.createdAt)
return { return {
@ -169,7 +173,7 @@ export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem
profitLossNegative: false, profitLossNegative: false,
timeAgo, timeAgo,
avgPrice, avgPrice,
shares: String(size), shares: String(Math.floor(size)),
} }
} }
@ -187,6 +191,8 @@ export interface OpenOrderDisplayItem {
filledDisplay?: string filledDisplay?: string
orderID?: number orderID?: number
tokenID?: string tokenID?: string
/** 已成交数量达到原始总数量,不可撤单 */
fullyFilled?: boolean
} }
/** OrderType GTC=0 表示 Until Cancelled */ /** OrderType GTC=0 表示 Until Cancelled */
@ -194,7 +200,7 @@ const OrderType = { GTC: 0, GTD: 1 } as const
/** /**
* ClobOrderItem Open Orders * ClobOrderItem Open Orders
* price 10000 * price 10000originalSize/sizeMatched 6 1_000_000 = 1 share
*/ */
export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayItem { export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayItem {
const id = String(order.ID ?? '') const id = String(order.ID ?? '')
@ -205,14 +211,17 @@ export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayI
const priceBps = order.price ?? 0 const priceBps = order.price ?? 0
const priceCents = Math.round(priceBps / 100) const priceCents = Math.round(priceBps / 100)
const price = `${priceCents}¢` const price = `${priceCents}¢`
const originalSize = order.originalSize ?? 0 const originalSizeRaw = order.originalSize ?? 0
const sizeMatched = order.sizeMatched ?? 0 const sizeMatchedRaw = order.sizeMatched ?? 0
const filled = `${sizeMatched}/${originalSize}` const originalSize = originalSizeRaw / ORDER_SIZE_SCALE
const sizeMatched = sizeMatchedRaw / ORDER_SIZE_SCALE
const filled = `${Math.floor(sizeMatched)}/${Math.floor(originalSize)}`
const totalUsd = (priceBps / 10000) * originalSize const totalUsd = (priceBps / 10000) * originalSize
const total = `$${totalUsd.toFixed(2)}` const total = `$${totalUsd.toFixed(2)}`
const expiration = const expiration =
order.orderType === OrderType.GTC ? 'Until Cancelled' : order.expiration?.toString() ?? '' order.orderType === OrderType.GTC ? 'Until Cancelled' : order.expiration?.toString() ?? ''
const actionLabel = sideNum === Side.Buy ? `Buy ${outcome}` : `Sell ${outcome}` const actionLabel = sideNum === Side.Buy ? `Buy ${outcome}` : `Sell ${outcome}`
const fullyFilled = originalSize <= 0 || sizeMatched >= originalSize
return { return {
id, id,
market, market,
@ -226,5 +235,6 @@ export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayI
filledDisplay: filled, filledDisplay: filled,
orderID: order.ID, orderID: order.ID,
tokenID: order.assetID, tokenID: order.assetID,
fullyFilled,
} }
} }

View File

@ -21,6 +21,8 @@ export interface ClobPositionItem {
size?: string size?: string
/** 可用份额字符串6 位小数 */ /** 可用份额字符串6 位小数 */
available?: string available?: string
/** 锁单数量字符串6 位小数;>0 表示锁单中不可卖出 */
lock?: string
/** 成本字符串6 位小数 */ /** 成本字符串6 位小数 */
cost?: string cost?: string
/** 方向Yes | No */ /** 方向Yes | No */
@ -97,6 +99,12 @@ export interface PositionDisplayItem {
iconClass?: string iconClass?: string
outcomeTag?: string outcomeTag?: string
outcomePillClass?: string outcomePillClass?: string
/** 是否锁单lock > 0锁单不可卖出 */
locked?: boolean
/** 锁单数量(来自 lock 字段),用于展示「锁单 X shares」 */
lockedSharesNum?: number
/** 可卖份额数值(来自 availableavailable > 0 表示有订单可卖,此为最大可卖份额 */
availableSharesNum?: number
} }
/** /**
@ -107,8 +115,11 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
const id = String(pos.ID ?? '') const id = String(pos.ID ?? '')
const market = pos.marketID ?? '' const market = pos.marketID ?? ''
const sizeRaw = parsePosNum(pos.size ?? pos.available) const sizeRaw = parsePosNum(pos.size ?? pos.available)
const availableRaw = parsePosNum(pos.available)
const costRaw = parsePosNum(pos.cost) const costRaw = parsePosNum(pos.cost)
const lockRaw = parsePosNum(pos.lock)
const size = sizeRaw / SCALE const size = sizeRaw / SCALE
const availableNum = availableRaw / SCALE
const costUsd = costRaw / SCALE const costUsd = costRaw / SCALE
const shares = `${size} shares` const shares = `${size} shares`
const outcomeWord = pos.outcome === 'No' ? 'No' : 'Yes' const outcomeWord = pos.outcome === 'No' ? 'No' : 'Yes'
@ -117,6 +128,8 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
const bet = value const bet = value
const toWin = `$${size.toFixed(2)}` const toWin = `$${size.toFixed(2)}`
const outcomeTag = `${outcomeWord}` const outcomeTag = `${outcomeWord}`
const locked = lockRaw > 0
const lockedSharesNum = lockRaw > 0 ? lockRaw / SCALE : undefined
return { return {
id, id,
market, market,
@ -129,5 +142,8 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
outcomeWord, outcomeWord,
outcomeTag, outcomeTag,
outcomePillClass: pillClass, outcomePillClass: pillClass,
locked,
lockedSharesNum,
availableSharesNum: availableNum >= 0 ? availableNum : undefined,
} }
} }

View File

@ -43,9 +43,9 @@
:trackStyle="{ backgroundColor: 'transparent' }" :trackStyle="{ backgroundColor: 'transparent' }"
/> />
</div> </div>
<div class="order-price asks-price">{{ ask.price }}¢</div> <div class="order-price asks-price">{{ formatOrderBookPrice(ask) }}¢</div>
<div class="order-shares">{{ ask.shares.toFixed(2) }}</div> <div class="order-shares">{{ trimTrailingZeros(ask.shares.toFixed(2)) }}</div>
<div class="order-total">{{ ask.cumulativeTotal.toFixed(2) }}</div> <div class="order-total">{{ trimTrailingZeros(ask.cumulativeTotal.toFixed(4)) }}</div>
</div> </div>
<!-- Bids Orders --> <!-- Bids Orders -->
<div class="asks-label">{{ t('trade.orderBookAsks') }}</div> <div class="asks-label">{{ t('trade.orderBookAsks') }}</div>
@ -63,9 +63,9 @@
:trackStyle="{ backgroundColor: 'transparent' }" :trackStyle="{ backgroundColor: 'transparent' }"
/> />
</div> </div>
<div class="order-price bids-price">{{ bid.price }}¢</div> <div class="order-price bids-price">{{ formatOrderBookPrice(bid) }}¢</div>
<div class="order-shares">{{ bid.shares.toFixed(2) }}</div> <div class="order-shares">{{ trimTrailingZeros(bid.shares.toFixed(2)) }}</div>
<div class="order-total">{{ bid.cumulativeTotal.toFixed(2) }}</div> <div class="order-total">{{ trimTrailingZeros(bid.cumulativeTotal.toFixed(4)) }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -93,8 +93,12 @@ import { USE_MOCK_ORDER_BOOK } from '../config/mock'
const { t } = useI18n() const { t } = useI18n()
export interface OrderBookRow { export interface OrderBookRow {
/** 价格(美分),用于展示 */
price: number price: number
/** 份额数量 */
shares: number shares: number
/** 原始精度单价(美元/份),用于计算总价,避免舍入误差 */
priceRaw?: number
} }
const props = withDefaults( const props = withDefaults(
@ -223,6 +227,21 @@ watch(
{ immediate: true }, { immediate: true },
) )
/** 去掉小数点后多余的 0如 1.00 → 10.10 → 0.1 */
function trimTrailingZeros(s: string): string {
return s.replace(/\.?0+$/, '')
}
/** 订单簿价格展示:小于 1 美分时最多显示两位小数,使用原始精度 priceRaw小数点后全为 0 则不显示 */
function formatOrderBookPrice(row: OrderBookRow): string {
const cents =
row.priceRaw != null && Number.isFinite(row.priceRaw) ? row.priceRaw * 100 : row.price
if (!Number.isFinite(cents)) return '0'
if (cents < 1) return trimTrailingZeros(cents.toFixed(2))
if (cents < 100) return trimTrailingZeros(cents.toFixed(1))
return String(Math.round(cents))
}
// Calculate cumulative total for asks // Calculate cumulative total for asks
const asksWithCumulativeTotal = computed(() => { const asksWithCumulativeTotal = computed(() => {
let cumulativeTotal = 0 let cumulativeTotal = 0
@ -231,8 +250,10 @@ const asksWithCumulativeTotal = computed(() => {
return sortedAsks return sortedAsks
.map((ask) => { .map((ask) => {
// Calculate current ask's value // 退 price/100
const askValue = (ask.price * ask.shares) / 100 // Convert cents to dollars const priceDollars =
ask.priceRaw != null && Number.isFinite(ask.priceRaw) ? ask.priceRaw : ask.price / 100
const askValue = priceDollars * ask.shares
cumulativeTotal += askValue cumulativeTotal += askValue
return { return {
...ask, ...ask,
@ -249,8 +270,10 @@ const bidsWithCumulativeTotal = computed(() => {
const sortedBids = [...bids.value].sort((a, b) => b.price - a.price) const sortedBids = [...bids.value].sort((a, b) => b.price - a.price)
return sortedBids.map((bid) => { return sortedBids.map((bid) => {
// Calculate current bid's value // 退 price/100
const bidValue = (bid.price * bid.shares) / 100 // Convert cents to dollars const priceDollars =
bid.priceRaw != null && Number.isFinite(bid.priceRaw) ? bid.priceRaw : bid.price / 100
const bidValue = priceDollars * bid.shares
cumulativeTotal += bidValue cumulativeTotal += bidValue
return { return {
...bid, ...bid,

View File

@ -382,6 +382,7 @@
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn> <v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setMaxShares">{{ t('trade.max') }}</v-btn> <v-btn class="share-btn" @click="setMaxShares">{{ t('trade.max') }}</v-btn>
</div> </div>
<p v-if="sellSharesExceedsMax" class="shares-exceeds-max-hint">{{ t('trade.sharesExceedsMax', { max: maxAvailableShares }) }}</p>
<div v-if="activeTab === 'buy'" class="matching-info"> <div v-if="activeTab === 'buy'" class="matching-info">
<v-icon size="14">mdi-information</v-icon> <v-icon size="14">mdi-information</v-icon>
<span>20.00 matching</span> <span>20.00 matching</span>
@ -1467,6 +1468,10 @@ export interface TradeMarketPayload {
clobTokenIds?: string[] clobTokenIds?: string[]
/** 选项展示文案,如 ["Yes","No"] 或 ["Up","Down"],用于 Buy Yes/No 按钮文字 */ /** 选项展示文案,如 ["Yes","No"] 或 ["Up","Down"],用于 Buy Yes/No 按钮文字 */
outcomes?: string[] outcomes?: string[]
/** 订单簿 Yes 买单最高价(美分),市价卖出时用于计算将收到金额 */
bestBidYesCents?: number
/** 订单簿 No 买单最高价(美分),市价卖出时用于计算将收到金额 */
bestBidNoCents?: number
} }
/** 持仓展示项(由父组件传入,用于计算可合并份额) */ /** 持仓展示项(由父组件传入,用于计算可合并份额) */
@ -1478,6 +1483,8 @@ export interface TradePositionItem {
shares: string shares: string
/** 份数数值(纯数字) */ /** 份数数值(纯数字) */
sharesNum?: number sharesNum?: number
/** 是否锁单,锁单不可卖出 */
locked?: boolean
} }
const props = withDefaults( const props = withDefaults(
@ -1597,9 +1604,36 @@ function openSheet(option: 'yes' | 'no') {
sheetOpen.value = true sheetOpen.value = true
} }
// State // //
const activeTab = ref('buy') const TRADE_PREFS_KEY = 'poly-trade-prefs'
const limitType = ref('Limit') function loadTradePrefs(): { activeTab: 'buy' | 'sell'; limitType: 'Market' | 'Limit' } {
try {
const raw = localStorage.getItem(TRADE_PREFS_KEY)
if (!raw) return { activeTab: 'buy', limitType: 'Limit' }
const data = JSON.parse(raw) as { activeTab?: string; limitType?: string }
return {
activeTab: data.activeTab === 'sell' ? 'sell' : 'buy',
limitType: data.limitType === 'Market' ? 'Market' : 'Limit',
}
} catch {
return { activeTab: 'buy', limitType: 'Limit' }
}
}
function saveTradePrefs() {
try {
localStorage.setItem(
TRADE_PREFS_KEY,
JSON.stringify({ activeTab: activeTab.value, limitType: limitType.value }),
)
} catch {
//
}
}
// State props.initialTab
const prefs = loadTradePrefs()
const activeTab = ref<'buy' | 'sell'>(prefs.activeTab)
const limitType = ref<'Market' | 'Limit'>(prefs.limitType)
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) // 01 const limitPrice = ref(0.82) // 01
@ -1622,6 +1656,8 @@ const balance = computed(() => {
const orderLoading = ref(false) const orderLoading = ref(false)
const orderError = ref('') const orderError = ref('')
/** 当前 orderError 是否为「无可用份额可卖」(用于仅清除该提示而不清除其他错误) */
const isNoAvailableSharesError = ref(false)
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
@ -1644,19 +1680,35 @@ const emit = defineEmits<{
// Computed properties // Computed properties
/** Limit Price 显示值(美分),与 Yes/No 按钮单位一致 */ /** Limit Price 显示值(美分),与 Yes/No 按钮单位一致 */
/** 去掉小数点后多余的 0如 1.00 → 10.10 → 0.1 */
function trimTrailingZeros(s: string): string {
return s.replace(/\.?0+$/, '')
}
const limitPriceCentsDisplay = computed(() => Math.round(limitPrice.value * 10000) / 100) const limitPriceCentsDisplay = computed(() => Math.round(limitPrice.value * 10000) / 100)
const currentPrice = computed(() => { const currentPrice = computed(() => {
return `${(limitPrice.value * 100).toFixed(0)}¢` return `${trimTrailingZeros((limitPrice.value * 100).toFixed(0))}¢`
}) })
const totalPrice = computed(() => { const totalPrice = computed(() => {
return (limitPrice.value * shares.value).toFixed(2) let raw: number
if (activeTab.value === 'sell' && limitType.value === 'Market') {
const bestBidCents =
selectedOption.value === 'yes'
? (props.market?.bestBidYesCents ?? 0)
: (props.market?.bestBidNoCents ?? 0)
raw = (bestBidCents / 100) * shares.value
} else {
raw = limitPrice.value * shares.value
}
if (!Number.isFinite(raw)) return '0'
return trimTrailingZeros(raw.toFixed(4))
}) })
/** Sell 模式下的平均单价(¢) */ /** Sell 模式下的平均单价(¢) */
const avgPriceCents = computed(() => { const avgPriceCents = computed(() => {
const p = limitPrice.value const p = limitPrice.value
return (p * 100).toFixed(1) return trimTrailingZeros((p * 100).toFixed(1))
}) })
/** To win = 份数 × 1U = shares × 1 USDC */ /** To win = 份数 × 1U = shares × 1 USDC */
@ -1667,9 +1719,9 @@ const toWinValue = computed(() => {
? (props.market?.yesPrice ?? 0.5) ? (props.market?.yesPrice ?? 0.5)
: (props.market?.noPrice ?? 0.5) : (props.market?.noPrice ?? 0.5)
const sharesFromAmount = price > 0 ? amount.value / price : 0 const sharesFromAmount = price > 0 ? amount.value / price : 0
return sharesFromAmount.toFixed(2) return trimTrailingZeros(sharesFromAmount.toFixed(2))
} }
return (shares.value * 1).toFixed(2) return trimTrailingZeros((shares.value * 1).toFixed(2))
}) })
const limitTypeDisplay = computed(() => const limitTypeDisplay = computed(() =>
@ -1681,6 +1733,41 @@ const actionButtonText = computed(() => {
return tab === 'buy' ? t('trade.buyLabel', { label }) : t('trade.sellLabel', { label }) return tab === 'buy' ? t('trade.buyLabel', { label }) : t('trade.sellLabel', { label })
}) })
/** 计算当前选中选项的持仓份额 */
const currentOptionPositionShares = computed(() => {
const positions = props.positions ?? []
const currentOutcome = selectedOption.value === 'yes' ? 'Yes' : 'No'
let totalShares = 0
for (const pos of positions) {
if (pos.outcomeWord === currentOutcome) {
const num = pos.sharesNum ?? parseFloat(pos.shares?.replace(/[^0-9.]/g, ''))
if (Number.isFinite(num) && num > 0) {
totalShares += num
}
}
}
return totalShares
})
/** 最大可卖出份额 */
const maxAvailableShares = computed(() => {
return Math.floor(currentOptionPositionShares.value)
})
/** 将 shares 限制为正整数(>= 1 */
function clampShares(v: number): number {
const n = Math.floor(Number.isFinite(v) ? v : 1)
return Math.max(1, n)
}
// Sell
const setMaxShares = () => {
const maxShares = currentOptionPositionShares.value
shares.value = maxShares > 0 ? clampShares(maxShares) : 0
}
function applyInitialOption(option: 'yes' | 'no') { function applyInitialOption(option: 'yes' | 'no') {
selectedOption.value = option selectedOption.value = option
syncLimitPriceFromMarket() syncLimitPriceFromMarket()
@ -1702,6 +1789,7 @@ onMounted(() => {
if (props.initialOption) applyInitialOption(props.initialOption) if (props.initialOption) applyInitialOption(props.initialOption)
else if (props.market) syncLimitPriceFromMarket() else if (props.market) syncLimitPriceFromMarket()
if (props.initialTab) activeTab.value = props.initialTab if (props.initialTab) activeTab.value = props.initialTab
if (activeTab.value === 'sell') setMaxShares()
}) })
watch( watch(
() => props.initialOption, () => props.initialOption,
@ -1722,6 +1810,7 @@ watch(
(m) => { (m) => {
if (m) { if (m) {
orderError.value = '' orderError.value = ''
isNoAvailableSharesError.value = false
if (props.initialOption) applyInitialOption(props.initialOption) if (props.initialOption) applyInitialOption(props.initialOption)
else syncLimitPriceFromMarket() else syncLimitPriceFromMarket()
} }
@ -1735,8 +1824,30 @@ watch(
if (newTab === 'sell') { if (newTab === 'sell') {
setMaxShares() setMaxShares()
} }
saveTradePrefs()
}, },
) )
/** 卖出时无可卖份额available 为 0则显示不可卖提示 */
watch(
() => [activeTab.value, maxAvailableShares.value] as const,
([tab, max]) => {
if (tab === 'sell') {
setMaxShares()
if (!Number.isFinite(max) || max <= 0) {
orderError.value = t('activity.noAvailableSharesToSell')
isNoAvailableSharesError.value = true
} else if (isNoAvailableSharesError.value) {
orderError.value = ''
isNoAvailableSharesError.value = false
}
} else if (isNoAvailableSharesError.value) {
orderError.value = ''
isNoAvailableSharesError.value = false
}
},
{ immediate: true },
)
watch(limitType, () => saveTradePrefs(), { flush: 'post' })
// Methods // Methods
const handleOptionChange = (option: 'yes' | 'no') => { const handleOptionChange = (option: 'yes' | 'no') => {
@ -1799,12 +1910,6 @@ const increasePrice = () => {
limitPrice.value = ALLOWED_LIMIT_PRICES[nextIdx] ?? limitPrice.value limitPrice.value = ALLOWED_LIMIT_PRICES[nextIdx] ?? limitPrice.value
} }
/** 将 shares 限制为正整数(>= 1 */
function clampShares(v: number): number {
const n = Math.floor(Number.isFinite(v) ? v : 1)
return Math.max(1, n)
}
/** 仅在值为正整数时更新 shares */ /** 仅在值为正整数时更新 shares */
function onSharesInput(v: unknown) { function onSharesInput(v: unknown) {
const num = v == null ? NaN : Number(v) const num = v == null ? NaN : Number(v)
@ -1837,28 +1942,10 @@ const adjustShares = (amount: number) => {
shares.value = clampShares(shares.value + amount) shares.value = clampShares(shares.value + amount)
} }
/** 计算当前选中选项的持仓份额 */ /** 卖出时输入份额是否超过最大可卖 */
const currentOptionPositionShares = computed(() => { const sellSharesExceedsMax = computed(
const positions = props.positions ?? [] () => activeTab.value === 'sell' && maxAvailableShares.value >= 0 && shares.value > maxAvailableShares.value,
const currentOutcome = selectedOption.value === 'yes' ? 'Yes' : 'No' )
let totalShares = 0
for (const pos of positions) {
if (pos.outcomeWord === currentOutcome) {
const num = pos.sharesNum ?? parseFloat(pos.shares?.replace(/[^0-9.]/g, ''))
if (Number.isFinite(num) && num > 0) {
totalShares += num
}
}
}
return totalShares
})
/** 最大可卖出份额 */
const maxAvailableShares = computed(() => {
return Math.floor(currentOptionPositionShares.value)
})
// Sell使 // Sell使
const setSharesPercentage = (percentage: number) => { const setSharesPercentage = (percentage: number) => {
@ -1911,14 +1998,6 @@ const setMaxAmount = () => {
amount.value = balance.value amount.value = balance.value
} }
//
const setMaxShares = () => {
const maxShares = currentOptionPositionShares.value
if (maxShares > 0) {
shares.value = clampShares(maxShares)
}
}
/** Buy 模式:余额是否足够(>= 所需金额且不为 0。costbalance>0 时用 totalPrice否则用 amount */ /** Buy 模式:余额是否足够(>= 所需金额且不为 0。costbalance>0 时用 totalPrice否则用 amount */
const canAffordBuy = computed(() => { const canAffordBuy = computed(() => {
const bal = balance.value const bal = balance.value
@ -1969,11 +2048,13 @@ async function submitOrder() {
if (!tokenId) { if (!tokenId) {
orderError.value = t('trade.pleaseSelectMarket') orderError.value = t('trade.pleaseSelectMarket')
isNoAvailableSharesError.value = false
return return
} }
const headers = userStore.getAuthHeaders() const headers = userStore.getAuthHeaders()
if (!headers) { if (!headers) {
orderError.value = t('trade.pleaseLogin') orderError.value = t('trade.pleaseLogin')
isNoAvailableSharesError.value = false
return return
} }
const uid = userStore?.user?.ID ?? 0 const uid = userStore?.user?.ID ?? 0
@ -1985,9 +2066,24 @@ async function submitOrder() {
: 0 : 0
if (!Number.isFinite(userIdNum) || userIdNum <= 0) { if (!Number.isFinite(userIdNum) || userIdNum <= 0) {
orderError.value = t('trade.userError') orderError.value = t('trade.userError')
isNoAvailableSharesError.value = false
return return
} }
if (activeTab.value === 'sell') {
const maxShares = maxAvailableShares.value
if (!Number.isFinite(maxShares) || maxShares <= 0) {
orderError.value = t('activity.noAvailableSharesToSell')
isNoAvailableSharesError.value = true
return
}
if (shares.value > maxShares) {
orderError.value = t('trade.sharesExceedsMax', { max: maxShares })
isNoAvailableSharesError.value = false
return
}
}
const isMarket = limitType.value === 'Market' const isMarket = limitType.value === 'Market'
const orderTypeNum = isMarket const orderTypeNum = isMarket
? OrderType.Market ? OrderType.Market
@ -2000,12 +2096,14 @@ async function submitOrder() {
? parseExpirationTimestamp(expirationTime.value) ? parseExpirationTimestamp(expirationTime.value)
: 0 : 0
const sizeValue = isMarket && activeTab.value === 'buy' const rawSize = isMarket && activeTab.value === 'buy'
? Math.round(amount.value * 1_000_000) ? amount.value
: clampShares(shares.value) : clampShares(shares.value)
const sizeValue = Math.round(rawSize * 1_000_000)
orderLoading.value = true orderLoading.value = true
orderError.value = '' orderError.value = ''
isNoAvailableSharesError.value = false
try { try {
const res = await pmOrderPlace( const res = await pmOrderPlace(
{ {
@ -2682,6 +2780,12 @@ async function submitOrder() {
margin: 8px 0 0; margin: 8px 0 0;
} }
.shares-exceeds-max-hint {
font-size: 12px;
color: #b45309;
margin: 6px 0 0;
}
.merge-dialog-actions { .merge-dialog-actions {
padding: 16px 20px 20px; padding: 16px 20px 20px;
padding-top: 8px; padding-top: 8px;

View File

@ -59,6 +59,7 @@
"max": "Max", "max": "Max",
"balanceLabel": "Balance", "balanceLabel": "Balance",
"maxShares": "Max shares", "maxShares": "Max shares",
"sharesExceedsMax": "Shares cannot exceed maximum available ({max})",
"pleaseLogin": "Please log in first", "pleaseLogin": "Please log in first",
"pleaseSelectMarket": "Please select a market (with clobTokenIds)", "pleaseSelectMarket": "Please select a market (with clobTokenIds)",
"userError": "User info error", "userError": "User info error",
@ -102,6 +103,10 @@
"openOrders": "Orders", "openOrders": "Orders",
"noPositionsInMarket": "No positions in this market.", "noPositionsInMarket": "No positions in this market.",
"noOpenOrdersInMarket": "No open orders in this market.", "noOpenOrdersInMarket": "No open orders in this market.",
"positionLocked": "Locked",
"positionLockedWithAmount": "Locked {n} shares",
"positionLockedCannotSell": "This position is locked and cannot be sold.",
"noAvailableSharesToSell": "No shares available to sell.",
"cancelOrder": "Cancel", "cancelOrder": "Cancel",
"noCommentsYet": "No comments yet.", "noCommentsYet": "No comments yet.",
"topHoldersPlaceholder": "Top holders will appear here.", "topHoldersPlaceholder": "Top holders will appear here.",

View File

@ -59,6 +59,7 @@
"max": "最大", "max": "最大",
"balanceLabel": "残高", "balanceLabel": "残高",
"maxShares": "最大シェア", "maxShares": "最大シェア",
"sharesExceedsMax": "入力したシェアは最大売却可能数(最大 {max})を超えられません",
"pleaseLogin": "先にログインしてください", "pleaseLogin": "先にログインしてください",
"pleaseSelectMarket": "市場を選択してくださいclobTokenIds が必要)", "pleaseSelectMarket": "市場を選択してくださいclobTokenIds が必要)",
"userError": "ユーザー情報エラー", "userError": "ユーザー情報エラー",
@ -102,6 +103,10 @@
"openOrders": "注文", "openOrders": "注文",
"noPositionsInMarket": "この市場にポジションはありません", "noPositionsInMarket": "この市場にポジションはありません",
"noOpenOrdersInMarket": "この市場に未約定注文はありません", "noOpenOrdersInMarket": "この市場に未約定注文はありません",
"positionLocked": "ロック",
"positionLockedWithAmount": "ロック {n} シェア",
"positionLockedCannotSell": "このポジションはロック中で売却できません",
"noAvailableSharesToSell": "売却可能なシェアがありません",
"cancelOrder": "キャンセル", "cancelOrder": "キャンセル",
"noCommentsYet": "コメントはまだありません", "noCommentsYet": "コメントはまだありません",
"topHoldersPlaceholder": "持倉トップがここに表示されます", "topHoldersPlaceholder": "持倉トップがここに表示されます",

View File

@ -59,6 +59,7 @@
"max": "최대", "max": "최대",
"balanceLabel": "잔액", "balanceLabel": "잔액",
"maxShares": "최대 주식", "maxShares": "최대 주식",
"sharesExceedsMax": "입력한 수량은 최대 매도 가능 수량({max})을 초과할 수 없습니다",
"pleaseLogin": "먼저 로그인하세요", "pleaseLogin": "먼저 로그인하세요",
"pleaseSelectMarket": "시장을 선택하세요 (clobTokenIds 필요)", "pleaseSelectMarket": "시장을 선택하세요 (clobTokenIds 필요)",
"userError": "사용자 정보 오류", "userError": "사용자 정보 오류",
@ -102,6 +103,10 @@
"openOrders": "주문", "openOrders": "주문",
"noPositionsInMarket": "이 시장에 포지션이 없습니다", "noPositionsInMarket": "이 시장에 포지션이 없습니다",
"noOpenOrdersInMarket": "이 시장에 미체결 주문이 없습니다", "noOpenOrdersInMarket": "이 시장에 미체결 주문이 없습니다",
"positionLocked": "잠금",
"positionLockedWithAmount": "잠금 {n} 수량",
"positionLockedCannotSell": "이 포지션은 잠금 상태로 매도할 수 없습니다",
"noAvailableSharesToSell": "매도 가능한 수량이 없습니다",
"cancelOrder": "취소", "cancelOrder": "취소",
"noCommentsYet": "아직 댓글이 없습니다", "noCommentsYet": "아직 댓글이 없습니다",
"topHoldersPlaceholder": "보유자 순위가 여기에 표시됩니다", "topHoldersPlaceholder": "보유자 순위가 여기에 표시됩니다",

View File

@ -59,6 +59,7 @@
"max": "最大", "max": "最大",
"balanceLabel": "余额", "balanceLabel": "余额",
"maxShares": "最大份额", "maxShares": "最大份额",
"sharesExceedsMax": "输入份额不能大于最大可卖份额(最大 {max}",
"pleaseLogin": "请先登录", "pleaseLogin": "请先登录",
"pleaseSelectMarket": "请先选择市场(需包含 clobTokenIds", "pleaseSelectMarket": "请先选择市场(需包含 clobTokenIds",
"userError": "用户信息异常", "userError": "用户信息异常",
@ -102,6 +103,10 @@
"openOrders": "限价", "openOrders": "限价",
"noPositionsInMarket": "本市场暂无持仓", "noPositionsInMarket": "本市场暂无持仓",
"noOpenOrdersInMarket": "本市场暂无未成交订单", "noOpenOrdersInMarket": "本市场暂无未成交订单",
"positionLocked": "锁单",
"positionLockedWithAmount": "锁单 {n} 份",
"positionLockedCannotSell": "该持仓为锁单状态,不可卖出",
"noAvailableSharesToSell": "当前无可卖份额",
"cancelOrder": "撤单", "cancelOrder": "撤单",
"noCommentsYet": "暂无评论", "noCommentsYet": "暂无评论",
"topHoldersPlaceholder": "持仓大户将在此显示", "topHoldersPlaceholder": "持仓大户将在此显示",

View File

@ -59,6 +59,7 @@
"max": "最大", "max": "最大",
"balanceLabel": "餘額", "balanceLabel": "餘額",
"maxShares": "最大份額", "maxShares": "最大份額",
"sharesExceedsMax": "輸入份額不能大於最大可賣份額(最大 {max}",
"pleaseLogin": "請先登入", "pleaseLogin": "請先登入",
"pleaseSelectMarket": "請先選擇市場(需包含 clobTokenIds", "pleaseSelectMarket": "請先選擇市場(需包含 clobTokenIds",
"userError": "用戶資訊異常", "userError": "用戶資訊異常",
@ -102,6 +103,10 @@
"openOrders": "限價", "openOrders": "限價",
"noPositionsInMarket": "本市場暫無持倉", "noPositionsInMarket": "本市場暫無持倉",
"noOpenOrdersInMarket": "本市場暫無未成交訂單", "noOpenOrdersInMarket": "本市場暫無未成交訂單",
"positionLocked": "鎖單",
"positionLockedWithAmount": "鎖單 {n} 份",
"positionLockedCannotSell": "該持倉為鎖單狀態,不可賣出",
"noAvailableSharesToSell": "目前無可賣份額",
"cancelOrder": "撤單", "cancelOrder": "撤單",
"noCommentsYet": "暫無評論", "noCommentsYet": "暫無評論",
"topHoldersPlaceholder": "持倉大戶將在此顯示", "topHoldersPlaceholder": "持倉大戶將在此顯示",

View File

@ -192,6 +192,7 @@
<template v-if="isMobile && markets.length > 0"> <template v-if="isMobile && markets.length > 0">
<v-bottom-sheet v-model="tradeSheetOpen" content-class="event-markets-trade-sheet"> <v-bottom-sheet v-model="tradeSheetOpen" content-class="event-markets-trade-sheet">
<TradeComponent <TradeComponent
v-if="tradeSheetRenderContent"
ref="tradeComponentRef" ref="tradeComponentRef"
:key="`trade-${selectedMarketIndex}-${tradeInitialOption}`" :key="`trade-${selectedMarketIndex}-${tradeInitialOption}`"
:market="tradeMarketPayload" :market="tradeMarketPayload"
@ -245,6 +246,10 @@ const selectedMarketIndex = ref(0)
const tradeInitialOption = ref<'yes' | 'no' | undefined>(undefined) const tradeInitialOption = ref<'yes' | 'no' | undefined>(undefined)
/** 移动端交易弹窗开关 */ /** 移动端交易弹窗开关 */
const tradeSheetOpen = ref(false) const tradeSheetOpen = ref(false)
/** 控制底部栏内 TradeComponent 的渲染,延迟挂载以避免 slot 竞态警告 */
const tradeSheetRenderContent = ref(false)
/** 从三点菜单点击 Merge/Split 时待打开的弹窗,等 TradeComponent 挂载后执行 */
const pendingMergeSplitDialog = ref<'merge' | 'split' | null>(null)
/** 移动端底部栏三点菜单开关 */ /** 移动端底部栏三点菜单开关 */
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
/** TradeComponent 引用,用于从底部栏触发 Merge/Split */ /** TradeComponent 引用,用于从底部栏触发 Merge/Split */
@ -580,19 +585,15 @@ function openSheetWithOption(side: 'yes' | 'no') {
/** 从底部栏三点菜单打开 Merge 弹窗 */ /** 从底部栏三点菜单打开 Merge 弹窗 */
function openMergeFromBar() { function openMergeFromBar() {
mobileMenuOpen.value = false mobileMenuOpen.value = false
pendingMergeSplitDialog.value = 'merge'
tradeSheetOpen.value = true tradeSheetOpen.value = true
nextTick(() => {
tradeComponentRef.value?.openMergeDialog?.()
})
} }
/** 从底部栏三点菜单打开 Split 弹窗 */ /** 从底部栏三点菜单打开 Split 弹窗 */
function openSplitFromBar() { function openSplitFromBar() {
mobileMenuOpen.value = false mobileMenuOpen.value = false
pendingMergeSplitDialog.value = 'split'
tradeSheetOpen.value = true tradeSheetOpen.value = true
nextTick(() => {
tradeComponentRef.value?.openSplitDialog?.()
})
} }
function onTradeSubmit(payload: { function onTradeSubmit(payload: {
@ -714,11 +715,49 @@ onMounted(() => {
loadEventDetail() loadEventDetail()
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
}) })
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
watch(tradeSheetOpen, (open) => {
if (open) {
if (tradeSheetUnmountTimer) {
clearTimeout(tradeSheetUnmountTimer)
tradeSheetUnmountTimer = undefined
}
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
tradeSheetMountTimer = setTimeout(() => {
tradeSheetRenderContent.value = true
tradeSheetMountTimer = undefined
nextTick(() => {
const pending = pendingMergeSplitDialog.value
if (pending === 'merge') {
pendingMergeSplitDialog.value = null
tradeComponentRef.value?.openMergeDialog?.()
} else if (pending === 'split') {
pendingMergeSplitDialog.value = null
tradeComponentRef.value?.openSplitDialog?.()
}
})
}, 50)
} else {
pendingMergeSplitDialog.value = null
if (tradeSheetMountTimer) {
clearTimeout(tradeSheetMountTimer)
tradeSheetMountTimer = undefined
}
tradeSheetUnmountTimer = setTimeout(() => {
tradeSheetRenderContent.value = false
tradeSheetUnmountTimer = undefined
}, 350)
}
}, { immediate: true })
onUnmounted(() => { onUnmounted(() => {
stopDynamicUpdate() stopDynamicUpdate()
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
chartInstance?.dispose() chartInstance?.dispose()
chartInstance = null chartInstance = null
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
}) })
watch( watch(

View File

@ -64,6 +64,9 @@
> >
<div class="position-row-main"> <div class="position-row-main">
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{ pos.outcomeTag }}</span> <span :class="['position-outcome-pill', pos.outcomePillClass]">{{ pos.outcomeTag }}</span>
<span v-if="pos.locked" class="position-lock-badge">
{{ pos.lockedSharesNum != null && Number.isFinite(pos.lockedSharesNum) ? t('activity.positionLockedWithAmount', { n: pos.lockedSharesNum }) : t('activity.positionLocked') }}
</span>
<span class="position-shares">{{ pos.shares }}</span> <span class="position-shares">{{ pos.shares }}</span>
<span class="position-value">{{ pos.value }}</span> <span class="position-value">{{ pos.value }}</span>
<v-btn <v-btn
@ -71,6 +74,7 @@
size="small" size="small"
color="primary" color="primary"
class="position-sell-btn" class="position-sell-btn"
:disabled="!(pos.availableSharesNum != null && pos.availableSharesNum > 0)"
@click="openSellFromPosition(pos)" @click="openSellFromPosition(pos)"
> >
{{ t('trade.sell') }} {{ t('trade.sell') }}
@ -104,7 +108,7 @@
variant="text" variant="text"
size="small" size="small"
color="error" color="error"
:disabled="cancelOrderLoading" :disabled="cancelOrderLoading || ord.fullyFilled"
@click="cancelMarketOrder(ord)" @click="cancelMarketOrder(ord)"
> >
{{ t('activity.cancelOrder') }} {{ t('activity.cancelOrder') }}
@ -286,6 +290,7 @@
</div> </div>
<v-bottom-sheet v-model="tradeSheetOpen" content-class="trade-detail-trade-sheet"> <v-bottom-sheet v-model="tradeSheetOpen" content-class="trade-detail-trade-sheet">
<TradeComponent <TradeComponent
v-if="tradeSheetRenderContent"
ref="mobileTradeComponentRef" ref="mobileTradeComponentRef"
:market="tradeMarketPayload" :market="tradeMarketPayload"
:initial-option="tradeInitialOptionFromBar" :initial-option="tradeInitialOptionFromBar"
@ -307,7 +312,7 @@
transition="dialog-transition" transition="dialog-transition"
> >
<TradeComponent <TradeComponent
v-if="sellDialogOpen" v-if="sellDialogRenderContent"
:market="tradeMarketPayload" :market="tradeMarketPayload"
:initial-option="sellInitialOption" :initial-option="sellInitialOption"
:initial-tab="'sell'" :initial-tab="'sell'"
@ -505,7 +510,7 @@ const currentMarket = computed(() => {
// --- CLOB WebSocket 簿 --- // --- CLOB WebSocket 簿 ---
// token 0 = Yes1 = No // token 0 = Yes1 = No
type OrderBookRows = { price: number; shares: number }[] type OrderBookRows = { price: number; shares: number; priceRaw?: number }[]
const clobSdkRef = ref<ClobSdk | null>(null) const clobSdkRef = ref<ClobSdk | null>(null)
const orderBookByToken = ref<Record<number, { asks: OrderBookRows; bids: OrderBookRows }>>({ const orderBookByToken = ref<Record<number, { asks: OrderBookRows; bids: OrderBookRows }>>({
0: { asks: [], bids: [] }, 0: { asks: [], bids: [] },
@ -535,19 +540,41 @@ const orderBookLowestAskNoCents = computed(() => {
if (!asks.length) return 0 if (!asks.length) return 0
return Math.min(...asks.map((a) => a.price)) return Math.min(...asks.map((a) => a.price))
}) })
/** 订单簿 Yes 买单最高价(分),无数据时为 0市价卖出时用于计算将收到金额 */
const orderBookBestBidYesCents = computed(() => {
const bids = orderBookBidsYes.value
if (!bids.length) return 0
return Math.max(...bids.map((b) => b.price))
})
/** 订单簿 No 买单最高价(分),无数据时为 0市价卖出时用于计算将收到金额 */
const orderBookBestBidNoCents = computed(() => {
const bids = orderBookBidsNo.value
if (!bids.length) return 0
return Math.max(...bids.map((b) => b.price))
})
const clobLastPriceYes = computed(() => clobLastPriceByToken.value[0]) const clobLastPriceYes = computed(() => clobLastPriceByToken.value[0])
const clobLastPriceNo = computed(() => clobLastPriceByToken.value[1]) const clobLastPriceNo = computed(() => clobLastPriceByToken.value[1])
const clobSpreadYes = computed(() => clobSpreadByToken.value[0]) const clobSpreadYes = computed(() => clobSpreadByToken.value[0])
const clobSpreadNo = computed(() => clobSpreadByToken.value[1]) const clobSpreadNo = computed(() => clobSpreadByToken.value[1])
/** 订单簿份额接口按 6 位小数传1_000_000 = 1 share需除以该系数转为展示值 */
const ORDER_BOOK_SIZE_SCALE = 1_000_000
/** 接口 key p 为价格(与 price 美分同源,除以 100 为美分priceRaw 为美元单价保留原始精度供订单簿算总价 */
function priceSizeToRows(record: Record<string, number> | undefined): OrderBookRows { function priceSizeToRows(record: Record<string, number> | undefined): OrderBookRows {
if (!record) return [] if (!record) return []
return Object.entries(record) return Object.entries(record)
.filter(([, shares]) => shares > 0) .filter(([, rawShares]) => rawShares > 0)
.map(([p, shares]) => ({ .map(([p, rawShares]) => {
price: Math.round(parseFloat(p) / 100), const numP = parseFloat(p)
shares, const priceCents = Math.round(numP / 100)
})) const priceRawDollars = Number.isFinite(numP) ? numP / 10000 : priceCents / 100
return {
price: priceCents,
shares: rawShares / ORDER_BOOK_SIZE_SCALE,
priceRaw: priceRawDollars,
}
})
} }
function getTokenIndex(msg: PriceSizePolyMsg): number { function getTokenIndex(msg: PriceSizePolyMsg): number {
@ -579,16 +606,24 @@ function applyPriceSizeDelta(msg: PriceSizePolyMsg) {
delta: Record<string, number> | undefined, delta: Record<string, number> | undefined,
asc: boolean, asc: boolean,
) => { ) => {
const map = new Map(current.map((r) => [r.price, r.shares])) const map = new Map<string, { price: number; shares: number; priceRaw: number }>()
const keyOf = (price: number, priceRaw: number) => priceRaw.toFixed(8)
current.forEach((r) => {
const pr = r.priceRaw ?? r.price / 100
map.set(keyOf(r.price, pr), { price: r.price, shares: r.shares, priceRaw: pr })
})
if (delta) { if (delta) {
Object.entries(delta).forEach(([p, shares]) => { Object.entries(delta).forEach(([p, rawShares]) => {
const price = Math.round(parseFloat(p) / 100) const numP = parseFloat(p)
if (shares <= 0) map.delete(price) const price = Math.round(numP / 100)
else map.set(price, shares) const priceRaw = Number.isFinite(numP) ? numP / 10000 : price / 100
const shares = rawShares / ORDER_BOOK_SIZE_SCALE
const key = keyOf(price, priceRaw)
if (shares <= 0) map.delete(key)
else map.set(key, { price, shares, priceRaw })
}) })
} }
return Array.from(map.entries()) return Array.from(map.values())
.map(([price, shares]) => ({ price, shares }))
.sort((a, b) => (asc ? a.price - b.price : b.price - a.price)) .sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
} }
const prev = orderBookByToken.value[idx] ?? { asks: [], bids: [] } const prev = orderBookByToken.value[idx] ?? { asks: [], bids: [] }
@ -680,6 +715,8 @@ const tradeMarketPayload = computed(() => {
const m = currentMarket.value const m = currentMarket.value
const yesPrice = orderBookLowestAskYesCents.value / 100 const yesPrice = orderBookLowestAskYesCents.value / 100
const noPrice = orderBookLowestAskNoCents.value / 100 const noPrice = orderBookLowestAskNoCents.value / 100
const bestBidYesCents = orderBookBestBidYesCents.value
const bestBidNoCents = orderBookBestBidNoCents.value
if (m) { if (m) {
return { return {
marketId: getMarketId(m), marketId: getMarketId(m),
@ -688,6 +725,8 @@ const tradeMarketPayload = computed(() => {
title: m.question, title: m.question,
clobTokenIds: m.clobTokenIds, clobTokenIds: m.clobTokenIds,
outcomes: m.outcomes, outcomes: m.outcomes,
bestBidYesCents,
bestBidNoCents,
} }
} }
const qId = route.query.marketId const qId = route.query.marketId
@ -697,6 +736,8 @@ const tradeMarketPayload = computed(() => {
yesPrice, yesPrice,
noPrice, noPrice,
title: (route.query.title as string) || undefined, title: (route.query.title as string) || undefined,
bestBidYesCents,
bestBidNoCents,
} }
} }
return undefined return undefined
@ -714,8 +755,14 @@ const tradeInitialOptionFromBar = ref<'yes' | 'no' | undefined>(undefined)
const tradeInitialTabFromBar = ref<'buy' | 'sell' | undefined>(undefined) const tradeInitialTabFromBar = ref<'buy' | 'sell' | undefined>(undefined)
/** 移动端交易弹窗开关 */ /** 移动端交易弹窗开关 */
const tradeSheetOpen = ref(false) const tradeSheetOpen = ref(false)
/** 控制底部栏内 TradeComponent 的渲染,延迟挂载以避免 slot 竞态警告 */
const tradeSheetRenderContent = ref(false)
/** 从三点菜单点击 Merge/Split 时待打开的弹窗,等 TradeComponent 挂载后执行 */
const pendingMergeSplitDialog = ref<'merge' | 'split' | null>(null)
/** 从持仓 Sell 打开的弹窗 */ /** 从持仓 Sell 打开的弹窗 */
const sellDialogOpen = ref(false) const sellDialogOpen = ref(false)
/** 控制 Sell 弹窗内 TradeComponent 的渲染,延迟卸载以避免 emitsOptions 竞态 */
const sellDialogRenderContent = ref(false)
/** 从持仓 Sell 时预选的 Yes/No */ /** 从持仓 Sell 时预选的 Yes/No */
const sellInitialOption = ref<'yes' | 'no'>('yes') const sellInitialOption = ref<'yes' | 'no'>('yes')
/** 移动端三点菜单开关 */ /** 移动端三点菜单开关 */
@ -742,18 +789,14 @@ function openSheetWithOption(side: 'yes' | 'no') {
function openMergeFromBar() { function openMergeFromBar() {
mobileMenuOpen.value = false mobileMenuOpen.value = false
pendingMergeSplitDialog.value = 'merge'
tradeSheetOpen.value = true tradeSheetOpen.value = true
nextTick(() => {
mobileTradeComponentRef.value?.openMergeDialog?.()
})
} }
function openSplitFromBar() { function openSplitFromBar() {
mobileMenuOpen.value = false mobileMenuOpen.value = false
pendingMergeSplitDialog.value = 'split'
tradeSheetOpen.value = true tradeSheetOpen.value = true
nextTick(() => {
mobileTradeComponentRef.value?.openSplitDialog?.()
})
} }
const toastStore = useToastStore() const toastStore = useToastStore()
@ -775,8 +818,12 @@ function onSplitSuccess() {
loadMarketPositions() loadMarketPositions()
} }
/** 从持仓项点击 Sell弹出交易组件并切到 Sell、对应 Yes/No。移动端直接开底部弹窗,桌面端开 Dialog */ /** 从持仓项点击 Sell弹出交易组件并切到 Sell、对应 Yes/No。是否可卖由 available 判断available > 0 才可卖。 */
function openSellFromPosition(pos: PositionDisplayItem) { function openSellFromPosition(pos: PositionDisplayItem) {
if (pos.availableSharesNum == null || pos.availableSharesNum <= 0) {
toastStore.show(t('activity.noAvailableSharesToSell'), 'warning')
return
}
const option = pos.outcomeWord === 'No' ? 'no' : 'yes' const option = pos.outcomeWord === 'No' ? 'no' : 'yes'
if (isMobile.value) { if (isMobile.value) {
tradeInitialOptionFromBar.value = option tradeInitialOptionFromBar.value = option
@ -793,6 +840,65 @@ function onSellOrderSuccess() {
onOrderSuccess() onOrderSuccess()
} }
// Sell dialog transition emitsOptions
let sellDialogUnmountTimer: ReturnType<typeof setTimeout> | undefined
watch(sellDialogOpen, (open) => {
if (open) {
if (sellDialogUnmountTimer) {
clearTimeout(sellDialogUnmountTimer)
sellDialogUnmountTimer = undefined
}
sellDialogRenderContent.value = true
} else {
sellDialogUnmountTimer = setTimeout(() => {
sellDialogRenderContent.value = false
sellDialogUnmountTimer = undefined
}, 350)
}
}, { immediate: true })
onUnmounted(() => {
if (sellDialogUnmountTimer) clearTimeout(sellDialogUnmountTimer)
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
})
// TradeComponent / transition slot
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
watch(tradeSheetOpen, (open) => {
if (open) {
if (tradeSheetUnmountTimer) {
clearTimeout(tradeSheetUnmountTimer)
tradeSheetUnmountTimer = undefined
}
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
tradeSheetMountTimer = setTimeout(() => {
tradeSheetRenderContent.value = true
tradeSheetMountTimer = undefined
nextTick(() => {
const pending = pendingMergeSplitDialog.value
if (pending === 'merge') {
pendingMergeSplitDialog.value = null
mobileTradeComponentRef.value?.openMergeDialog?.()
} else if (pending === 'split') {
pendingMergeSplitDialog.value = null
mobileTradeComponentRef.value?.openSplitDialog?.()
}
})
}, 50)
} else {
pendingMergeSplitDialog.value = null
if (tradeSheetMountTimer) {
clearTimeout(tradeSheetMountTimer)
tradeSheetMountTimer = undefined
}
tradeSheetUnmountTimer = setTimeout(() => {
tradeSheetRenderContent.value = false
tradeSheetUnmountTimer = undefined
}, 350)
}
}, { immediate: true })
// marketID // marketID
const currentMarketId = computed(() => getMarketId(currentMarket.value)) const currentMarketId = computed(() => getMarketId(currentMarket.value))
@ -808,13 +914,17 @@ const marketPositionsFiltered = computed(() =>
}), }),
) )
/** 转为 TradeComponent 所需的 TradePositionItem[],保证 outcomeWord 为 'Yes' | 'No'(仅含份额>0 */ /** 转为 TradeComponent 所需的 TradePositionItem[]sharesNum 用 available最大可卖份额available > 0 表示有订单可卖 */
const tradePositionsForComponent = computed<TradePositionItem[]>(() => const tradePositionsForComponent = computed<TradePositionItem[]>(() =>
marketPositionsFiltered.value.map((p) => ({ marketPositionsFiltered.value.map((p) => ({
id: p.id, id: p.id,
outcomeWord: (p.outcomeWord === 'No' ? 'No' : 'Yes') as 'Yes' | 'No', outcomeWord: (p.outcomeWord === 'No' ? 'No' : 'Yes') as 'Yes' | 'No',
shares: p.shares, shares: p.shares,
sharesNum: parseFloat(p.shares?.replace(/[^0-9.]/g, '')) || undefined, sharesNum:
p.availableSharesNum != null && Number.isFinite(p.availableSharesNum)
? p.availableSharesNum
: parseFloat(p.shares?.replace(/[^0-9.]/g, '')) || undefined,
locked: p.locked,
})) }))
) )
@ -897,6 +1007,7 @@ async function loadMarketOpenOrders() {
const cancelOrderLoading = ref(false) const cancelOrderLoading = ref(false)
async function cancelMarketOrder(ord: OpenOrderDisplayItem) { async function cancelMarketOrder(ord: OpenOrderDisplayItem) {
if (ord.fullyFilled) return
const orderID = ord.orderID ?? 0 const orderID = ord.orderID ?? 0
const tokenID = ord.tokenID ?? '' const tokenID = ord.tokenID ?? ''
const uid = userStore.user?.id ?? userStore.user?.ID const uid = userStore.user?.id ?? userStore.user?.ID
@ -1867,6 +1978,15 @@ onUnmounted(() => {
margin-left: auto; margin-left: auto;
} }
.position-lock-badge {
font-size: 11px;
color: #b45309;
background: #fef3c7;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.position-outcome-pill, .position-outcome-pill,
.order-side-pill { .order-side-pill {
font-size: 12px; font-size: 12px;

View File

@ -358,7 +358,7 @@
size="small" size="small"
class="order-cancel-icon" class="order-cancel-icon"
color="error" color="error"
:disabled="cancelOrderLoading" :disabled="cancelOrderLoading || ord.fullyFilled"
@click.stop="cancelOrder(ord)" @click.stop="cancelOrder(ord)"
> >
<v-icon size="20">mdi-close</v-icon> <v-icon size="20">mdi-close</v-icon>
@ -404,7 +404,7 @@
variant="text" variant="text"
size="small" size="small"
color="error" color="error"
:disabled="cancelOrderLoading" :disabled="cancelOrderLoading || ord.fullyFilled"
@click="cancelOrder(ord)" @click="cancelOrder(ord)"
>Cancel</v-btn >Cancel</v-btn
> >
@ -718,6 +718,8 @@ interface OpenOrder {
/** 取消订单 API 用 */ /** 取消订单 API 用 */
orderID?: number orderID?: number
tokenID?: string tokenID?: string
/** 已成交数量达到原始总数量,不可撤单 */
fullyFilled?: boolean
} }
interface HistoryItem { interface HistoryItem {
id: string id: string
@ -977,6 +979,7 @@ const cancelOrderLoading = ref(false)
const cancelOrderError = ref('') const cancelOrderError = ref('')
const showCancelError = ref(false) const showCancelError = ref(false)
async function cancelOrder(ord: OpenOrder) { async function cancelOrder(ord: OpenOrder) {
if (ord.fullyFilled) return
const orderID = ord.orderID ?? 5 const orderID = ord.orderID ?? 5
const tokenID = ord.tokenID ?? MOCK_TOKEN_ID const tokenID = ord.tokenID ?? MOCK_TOKEN_ID
const uid = userStore.user?.id ?? userStore.user?.ID const uid = userStore.user?.id ?? userStore.user?.ID