From 1ac53ffb79060b91c0f16ef9bcce3de10666054e Mon Sep 17 00:00:00 2001 From: ivan Date: Sat, 28 Feb 2026 23:08:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E4=B8=8B=E5=8D=95?= =?UTF-8?q?=E9=94=81=E5=8D=95=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/components/TradeComponent.md | 1 + src/api/position.ts | 16 ++++ src/components/TradeComponent.vue | 147 ++++++++++++++++++++++++------ src/locales/en.json | 5 + src/locales/ja.json | 5 + src/locales/ko.json | 5 + src/locales/zh-CN.json | 5 + src/locales/zh-TW.json | 5 + src/views/TradeDetail.vue | 45 ++++++++- 9 files changed, 204 insertions(+), 30 deletions(-) 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') }} +

{{ t('trade.sharesExceedsMax', { max: maxAvailableShares }) }}

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;