diff --git a/docs/components/TradeComponent.md b/docs/components/TradeComponent.md
index 87bdc4f..a5039d9 100644
--- a/docs/components/TradeComponent.md
+++ b/docs/components/TradeComponent.md
@@ -43,6 +43,7 @@ interface TradePositionItem {
- 25%/50%/Max 按钮独立一行(`sell-shares-buttons`)
- 整体布局更清晰:`Shares Max: 2` → `[输入框]` → `[25%][50%][Max]`
- 调用 market API 下单、Split、Merge
+- **submitOrder size 统一乘 1_000_000**:无论 Market Buy(amount)、Market Sell、Limit Buy、Limit Sell(shares),传入 `pmOrderPlace` 的 `size` 均先取原始值再 `Math.round(rawSize * 1_000_000)`
- **合并/拆分成功后触发事件**:`mergeSuccess`、`splitSuccess`,父组件监听后可刷新持仓列表
- **401 权限错误提示**:通过 `useAuthError().formatAuthError` 统一处理,未登录显示「请先登录」,已登录显示「权限不足」
diff --git a/src/api/position.ts b/src/api/position.ts
index ee5ff7a..21116b4 100644
--- a/src/api/position.ts
+++ b/src/api/position.ts
@@ -21,6 +21,8 @@ export interface ClobPositionItem {
size?: string
/** 可用份额,字符串,6 位小数 */
available?: string
+ /** 锁单数量,字符串,6 位小数;>0 表示锁单中不可卖出 */
+ lock?: string
/** 成本,字符串,6 位小数 */
cost?: string
/** 方向:Yes | No */
@@ -97,6 +99,12 @@ export interface PositionDisplayItem {
iconClass?: string
outcomeTag?: string
outcomePillClass?: string
+ /** 是否锁单(lock > 0),锁单不可卖出 */
+ locked?: boolean
+ /** 锁单数量(来自 lock 字段),用于展示「锁单 X shares」 */
+ lockedSharesNum?: number
+ /** 可卖份额数值(来自 available),available > 0 表示有订单可卖,此为最大可卖份额 */
+ availableSharesNum?: number
}
/**
@@ -107,8 +115,11 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
const id = String(pos.ID ?? '')
const market = pos.marketID ?? ''
const sizeRaw = parsePosNum(pos.size ?? pos.available)
+ const availableRaw = parsePosNum(pos.available)
const costRaw = parsePosNum(pos.cost)
+ const lockRaw = parsePosNum(pos.lock)
const size = sizeRaw / SCALE
+ const availableNum = availableRaw / SCALE
const costUsd = costRaw / SCALE
const shares = `${size} shares`
const outcomeWord = pos.outcome === 'No' ? 'No' : 'Yes'
@@ -117,6 +128,8 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
const bet = value
const toWin = `$${size.toFixed(2)}`
const outcomeTag = `${outcomeWord} —`
+ const locked = lockRaw > 0
+ const lockedSharesNum = lockRaw > 0 ? lockRaw / SCALE : undefined
return {
id,
market,
@@ -129,5 +142,8 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
outcomeWord,
outcomeTag,
outcomePillClass: pillClass,
+ locked,
+ lockedSharesNum,
+ availableSharesNum: availableNum >= 0 ? availableNum : undefined,
}
}
diff --git a/src/components/TradeComponent.vue b/src/components/TradeComponent.vue
index f6c86c3..9657884 100644
--- a/src/components/TradeComponent.vue
+++ b/src/components/TradeComponent.vue
@@ -382,6 +382,7 @@
50%
{{ t('trade.max') }}
+
mdi-information
20.00 matching
@@ -1467,6 +1468,10 @@ export interface TradeMarketPayload {
clobTokenIds?: string[]
/** 选项展示文案,如 ["Yes","No"] 或 ["Up","Down"],用于 Buy Yes/No 按钮文字 */
outcomes?: string[]
+ /** 订单簿 Yes 买单最高价(美分),市价卖出时用于计算将收到金额 */
+ bestBidYesCents?: number
+ /** 订单簿 No 买单最高价(美分),市价卖出时用于计算将收到金额 */
+ bestBidNoCents?: number
}
/** 持仓展示项(由父组件传入,用于计算可合并份额) */
@@ -1478,6 +1483,8 @@ export interface TradePositionItem {
shares: string
/** 份数数值(纯数字) */
sharesNum?: number
+ /** 是否锁单,锁单不可卖出 */
+ locked?: boolean
}
const props = withDefaults(
@@ -1597,9 +1604,36 @@ function openSheet(option: 'yes' | 'no') {
sheetOpen.value = true
}
-// State
-const activeTab = ref('buy')
-const limitType = ref('Limit')
+// 持久化:记住用户上次的买入/卖出、市价/限价选择
+const TRADE_PREFS_KEY = 'poly-trade-prefs'
+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 selectedOption = ref<'yes' | 'no'>(props.initialOption ?? 'no')
const limitPrice = ref(0.82) // 内部存储 0–1,显示为美分与按钮一致
@@ -1622,6 +1656,8 @@ const balance = computed(() => {
const orderLoading = ref(false)
const orderError = ref('')
+/** 当前 orderError 是否为「无可用份额可卖」(用于仅清除该提示而不清除其他错误) */
+const isNoAvailableSharesError = ref(false)
// Emits
const emit = defineEmits<{
@@ -1650,6 +1686,14 @@ const currentPrice = computed(() => {
})
const totalPrice = computed(() => {
+ if (activeTab.value === 'sell' && limitType.value === 'Market') {
+ const bestBidCents =
+ selectedOption.value === 'yes'
+ ? (props.market?.bestBidYesCents ?? 0)
+ : (props.market?.bestBidNoCents ?? 0)
+ const price = bestBidCents / 100
+ return (price * shares.value).toFixed(2)
+ }
return (limitPrice.value * shares.value).toFixed(2)
})
@@ -1681,6 +1725,29 @@ const actionButtonText = computed(() => {
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)
+})
+
function applyInitialOption(option: 'yes' | 'no') {
selectedOption.value = option
syncLimitPriceFromMarket()
@@ -1722,6 +1789,7 @@ watch(
(m) => {
if (m) {
orderError.value = ''
+ isNoAvailableSharesError.value = false
if (props.initialOption) applyInitialOption(props.initialOption)
else syncLimitPriceFromMarket()
}
@@ -1735,8 +1803,26 @@ watch(
if (newTab === 'sell') {
setMaxShares()
}
+ saveTradePrefs()
},
)
+/** 卖出时无可卖份额(available 为 0)则显示不可卖提示 */
+watch(
+ () => [activeTab.value, maxAvailableShares.value] as const,
+ ([tab, max]) => {
+ if (tab === 'sell' && (!Number.isFinite(max) || max <= 0)) {
+ orderError.value = t('activity.noAvailableSharesToSell')
+ isNoAvailableSharesError.value = true
+ } else if (tab !== 'sell' || (Number.isFinite(max) && max > 0)) {
+ if (isNoAvailableSharesError.value) {
+ orderError.value = ''
+ isNoAvailableSharesError.value = false
+ }
+ }
+ },
+ { immediate: true },
+)
+watch(limitType, () => saveTradePrefs(), { flush: 'post' })
// Methods
const handleOptionChange = (option: 'yes' | 'no') => {
@@ -1837,28 +1923,10 @@ const adjustShares = (amount: number) => {
shares.value = clampShares(shares.value + amount)
}
-/** 计算当前选中选项的持仓份额 */
-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)
-})
+/** 卖出时输入份额是否超过最大可卖 */
+const sellSharesExceedsMax = computed(
+ () => activeTab.value === 'sell' && maxAvailableShares.value >= 0 && shares.value > maxAvailableShares.value,
+)
// 份额百分比调整方法(仅在Sell模式下使用)
const setSharesPercentage = (percentage: number) => {
@@ -1969,11 +2037,13 @@ async function submitOrder() {
if (!tokenId) {
orderError.value = t('trade.pleaseSelectMarket')
+ isNoAvailableSharesError.value = false
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
orderError.value = t('trade.pleaseLogin')
+ isNoAvailableSharesError.value = false
return
}
const uid = userStore?.user?.ID ?? 0
@@ -1985,9 +2055,24 @@ async function submitOrder() {
: 0
if (!Number.isFinite(userIdNum) || userIdNum <= 0) {
orderError.value = t('trade.userError')
+ isNoAvailableSharesError.value = false
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 orderTypeNum = isMarket
? OrderType.Market
@@ -2000,12 +2085,14 @@ async function submitOrder() {
? parseExpirationTimestamp(expirationTime.value)
: 0
- const sizeValue = isMarket && activeTab.value === 'buy'
- ? Math.round(amount.value * 1_000_000)
+ const rawSize = isMarket && activeTab.value === 'buy'
+ ? amount.value
: clampShares(shares.value)
+ const sizeValue = Math.round(rawSize * 1_000_000)
orderLoading.value = true
orderError.value = ''
+ isNoAvailableSharesError.value = false
try {
const res = await pmOrderPlace(
{
@@ -2682,6 +2769,12 @@ async function submitOrder() {
margin: 8px 0 0;
}
+.shares-exceeds-max-hint {
+ font-size: 12px;
+ color: #b45309;
+ margin: 6px 0 0;
+}
+
.merge-dialog-actions {
padding: 16px 20px 20px;
padding-top: 8px;
diff --git a/src/locales/en.json b/src/locales/en.json
index 72dafa3..e7f28e5 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -59,6 +59,7 @@
"max": "Max",
"balanceLabel": "Balance",
"maxShares": "Max shares",
+ "sharesExceedsMax": "Shares cannot exceed maximum available ({max})",
"pleaseLogin": "Please log in first",
"pleaseSelectMarket": "Please select a market (with clobTokenIds)",
"userError": "User info error",
@@ -102,6 +103,10 @@
"openOrders": "Orders",
"noPositionsInMarket": "No positions 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",
"noCommentsYet": "No comments yet.",
"topHoldersPlaceholder": "Top holders will appear here.",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 400ab97..d04b66f 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -59,6 +59,7 @@
"max": "最大",
"balanceLabel": "残高",
"maxShares": "最大シェア",
+ "sharesExceedsMax": "入力したシェアは最大売却可能数(最大 {max})を超えられません",
"pleaseLogin": "先にログインしてください",
"pleaseSelectMarket": "市場を選択してください(clobTokenIds が必要)",
"userError": "ユーザー情報エラー",
@@ -102,6 +103,10 @@
"openOrders": "注文",
"noPositionsInMarket": "この市場にポジションはありません",
"noOpenOrdersInMarket": "この市場に未約定注文はありません",
+ "positionLocked": "ロック",
+ "positionLockedWithAmount": "ロック {n} シェア",
+ "positionLockedCannotSell": "このポジションはロック中で売却できません",
+ "noAvailableSharesToSell": "売却可能なシェアがありません",
"cancelOrder": "キャンセル",
"noCommentsYet": "コメントはまだありません",
"topHoldersPlaceholder": "持倉トップがここに表示されます",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index ecb93df..e16dc59 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -59,6 +59,7 @@
"max": "최대",
"balanceLabel": "잔액",
"maxShares": "최대 주식",
+ "sharesExceedsMax": "입력한 수량은 최대 매도 가능 수량({max})을 초과할 수 없습니다",
"pleaseLogin": "먼저 로그인하세요",
"pleaseSelectMarket": "시장을 선택하세요 (clobTokenIds 필요)",
"userError": "사용자 정보 오류",
@@ -102,6 +103,10 @@
"openOrders": "주문",
"noPositionsInMarket": "이 시장에 포지션이 없습니다",
"noOpenOrdersInMarket": "이 시장에 미체결 주문이 없습니다",
+ "positionLocked": "잠금",
+ "positionLockedWithAmount": "잠금 {n} 수량",
+ "positionLockedCannotSell": "이 포지션은 잠금 상태로 매도할 수 없습니다",
+ "noAvailableSharesToSell": "매도 가능한 수량이 없습니다",
"cancelOrder": "취소",
"noCommentsYet": "아직 댓글이 없습니다",
"topHoldersPlaceholder": "보유자 순위가 여기에 표시됩니다",
diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json
index 99c6620..bd5e24e 100644
--- a/src/locales/zh-CN.json
+++ b/src/locales/zh-CN.json
@@ -59,6 +59,7 @@
"max": "最大",
"balanceLabel": "余额",
"maxShares": "最大份额",
+ "sharesExceedsMax": "输入份额不能大于最大可卖份额(最大 {max})",
"pleaseLogin": "请先登录",
"pleaseSelectMarket": "请先选择市场(需包含 clobTokenIds)",
"userError": "用户信息异常",
@@ -102,6 +103,10 @@
"openOrders": "限价",
"noPositionsInMarket": "本市场暂无持仓",
"noOpenOrdersInMarket": "本市场暂无未成交订单",
+ "positionLocked": "锁单",
+ "positionLockedWithAmount": "锁单 {n} 份",
+ "positionLockedCannotSell": "该持仓为锁单状态,不可卖出",
+ "noAvailableSharesToSell": "当前无可卖份额",
"cancelOrder": "撤单",
"noCommentsYet": "暂无评论",
"topHoldersPlaceholder": "持仓大户将在此显示",
diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json
index 25c57b1..f060014 100644
--- a/src/locales/zh-TW.json
+++ b/src/locales/zh-TW.json
@@ -59,6 +59,7 @@
"max": "最大",
"balanceLabel": "餘額",
"maxShares": "最大份額",
+ "sharesExceedsMax": "輸入份額不能大於最大可賣份額(最大 {max})",
"pleaseLogin": "請先登入",
"pleaseSelectMarket": "請先選擇市場(需包含 clobTokenIds)",
"userError": "用戶資訊異常",
@@ -102,6 +103,10 @@
"openOrders": "限價",
"noPositionsInMarket": "本市場暫無持倉",
"noOpenOrdersInMarket": "本市場暫無未成交訂單",
+ "positionLocked": "鎖單",
+ "positionLockedWithAmount": "鎖單 {n} 份",
+ "positionLockedCannotSell": "該持倉為鎖單狀態,不可賣出",
+ "noAvailableSharesToSell": "目前無可賣份額",
"cancelOrder": "撤單",
"noCommentsYet": "暫無評論",
"topHoldersPlaceholder": "持倉大戶將在此顯示",
diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue
index f0d8c84..d4ac9a3 100644
--- a/src/views/TradeDetail.vue
+++ b/src/views/TradeDetail.vue
@@ -64,6 +64,9 @@
>
{{ pos.outcomeTag }}
+
+ {{ pos.lockedSharesNum != null && Number.isFinite(pos.lockedSharesNum) ? t('activity.positionLockedWithAmount', { n: pos.lockedSharesNum }) : t('activity.positionLocked') }}
+
{{ pos.shares }}
{{ pos.value }}
{{ t('trade.sell') }}
@@ -535,6 +539,18 @@ const orderBookLowestAskNoCents = computed(() => {
if (!asks.length) return 0
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 clobLastPriceNo = computed(() => clobLastPriceByToken.value[1])
const clobSpreadYes = computed(() => clobSpreadByToken.value[0])
@@ -680,6 +696,8 @@ const tradeMarketPayload = computed(() => {
const m = currentMarket.value
const yesPrice = orderBookLowestAskYesCents.value / 100
const noPrice = orderBookLowestAskNoCents.value / 100
+ const bestBidYesCents = orderBookBestBidYesCents.value
+ const bestBidNoCents = orderBookBestBidNoCents.value
if (m) {
return {
marketId: getMarketId(m),
@@ -688,6 +706,8 @@ const tradeMarketPayload = computed(() => {
title: m.question,
clobTokenIds: m.clobTokenIds,
outcomes: m.outcomes,
+ bestBidYesCents,
+ bestBidNoCents,
}
}
const qId = route.query.marketId
@@ -697,6 +717,8 @@ const tradeMarketPayload = computed(() => {
yesPrice,
noPrice,
title: (route.query.title as string) || undefined,
+ bestBidYesCents,
+ bestBidNoCents,
}
}
return undefined
@@ -775,8 +797,12 @@ function onSplitSuccess() {
loadMarketPositions()
}
-/** 从持仓项点击 Sell:弹出交易组件并切到 Sell、对应 Yes/No。移动端直接开底部弹窗,桌面端开 Dialog */
+/** 从持仓项点击 Sell:弹出交易组件并切到 Sell、对应 Yes/No。是否可卖由 available 判断,available > 0 才可卖。 */
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'
if (isMobile.value) {
tradeInitialOptionFromBar.value = option
@@ -808,13 +834,17 @@ const marketPositionsFiltered = computed(() =>
}),
)
-/** 转为 TradeComponent 所需的 TradePositionItem[],保证 outcomeWord 为 'Yes' | 'No'(仅含份额>0) */
+/** 转为 TradeComponent 所需的 TradePositionItem[];sharesNum 用 available(最大可卖份额),available > 0 表示有订单可卖 */
const tradePositionsForComponent = computed(() =>
marketPositionsFiltered.value.map((p) => ({
id: p.id,
outcomeWord: (p.outcomeWord === 'No' ? 'No' : 'Yes') as 'Yes' | 'No',
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,
}))
)
@@ -1867,6 +1897,15 @@ onUnmounted(() => {
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,
.order-side-pill {
font-size: 12px;