Compare commits

...

2 Commits

Author SHA1 Message Date
ivan
c976333b72 新增:market关闭时候的页面样式,优化传参 2026-04-07 10:58:13 +08:00
ivan
fc480dc4a8 优化:VIP显示优化 2026-04-07 10:08:39 +08:00
17 changed files with 2185 additions and 152 deletions

File diff suppressed because it is too large Load Diff

View File

@ -64,9 +64,49 @@ export interface PmEventMarketItem {
clobTokenIds?: string[]
endDate?: string
volume?: number
/** 为 true 时表示市场已关闭/已结算,不再交易 */
closed?: boolean
[key: string]: unknown
}
/** 市场是否已关闭(不再接受下单) */
export function isPmMarketClosed(m: PmEventMarketItem | null | undefined): boolean {
return m?.closed === true
}
/**
* outcomePrices outcomes 01 1100
*/
export function getPmMarketSettledWinner(m: PmEventMarketItem): {
label: string
winnerIndex: number
} | null {
if (!isPmMarketClosed(m)) return null
const prices = m.outcomePrices
const outcomes = m.outcomes
if (!prices?.length) {
return { label: outcomes?.[0] ?? '—', winnerIndex: 0 }
}
let bestIdx = 0
let bestProb = -1
for (let i = 0; i < prices.length; i++) {
const p = parseFloat(String(prices[i]))
if (!Number.isFinite(p)) continue
const prob = p > 1 ? Math.min(1, p / 100) : p
if (prob > bestProb) {
bestProb = prob
bestIdx = i
}
}
if (bestProb < 0) {
return { label: outcomes?.[0] ?? '—', winnerIndex: 0 }
}
const label =
outcomes?.[bestIdx] ??
(bestIdx === 0 ? 'Yes' : bestIdx === 1 ? 'No' : `#${bestIdx + 1}`)
return { label, winnerIndex: bestIdx }
}
/** 从市场项取 marketId兼容 ID / id */
export function getMarketId(m: PmEventMarketItem | null | undefined): string | undefined {
if (!m) return undefined
@ -237,36 +277,24 @@ export function readTradeRouteEventSlug(obj: unknown): string | undefined {
}
export interface TradeDetailRouteInput {
/** 事件数字 ID(与首页 MarketCard :id 一致),优先用于路径参数 */
/** 事件数字 ID;无 slug 时用作路径段 */
eventId?: string | number | null
/** 事件 slug;无 eventId 时用作路径;路径为数字 ID 时可作为 query.slug 传给 findPmEvent */
/** 事件 slug,优先作为路径参数(详情页仅用其请求 findPmEvent */
eventSlug?: string | null
/** 市场行 ID对应详情页 query.marketId */
marketId?: string | null
/** 详情页标题回显 query.title */
title?: string | null
}
/**
* MarketCardEventMarkets trade-detail router.push
* `/trade-detail/:id`id ID slug
* `/trade-detail/:id` query slug ID
*/
export function buildTradeDetailPushOptions(
input: TradeDetailRouteInput,
): { name: 'trade-detail'; params: { id: string }; query: Record<string, string> } | null {
): { name: 'trade-detail'; params: { id: string } } | null {
const slug = input.eventSlug?.trim() || ''
const eid =
input.eventId != null && String(input.eventId).trim() !== '' ? String(input.eventId).trim() : ''
const slugOnly = input.eventSlug?.trim() || ''
if (!eid && !slugOnly) return null
const pathId = eid || slugOnly
const numId = parseInt(eid, 10)
const isNumericEventPath =
eid !== '' && Number.isFinite(numId) && String(numId) === eid && numId >= 1
const query: Record<string, string> = {}
if (input.title?.trim()) query.title = input.title.trim()
if (input.marketId?.trim()) query.marketId = input.marketId.trim()
if (isNumericEventPath && slugOnly) query.slug = slugOnly
return { name: 'trade-detail', params: { id: pathId }, query }
const pathId = slug || eid
if (!pathId) return null
return { name: 'trade-detail', params: { id: pathId } }
}
/** 多选项卡片中单个选项(用于左右滑动切换) */

View File

@ -68,10 +68,12 @@ export function usdcToAmount(displayAmount: number): number {
return Math.round(displayAmount * USDC_SCALE)
}
/** 提现状态:审核中、提现成功、审核不通过、提现失败 */
/** 提现状态:审核中、提现成功(含 processed、审核不通过、提现失败 */
export const WITHDRAW_STATUS = {
PENDING: 'pending',
SUCCESS: 'success',
/** 与 success 同义,后端已处理完成 */
PROCESSED: 'processed',
REJECTED: 'rejected',
FAILED: 'failed',
} as const

View File

@ -232,26 +232,13 @@ const semiProgressColor = computed(() => {
})
const navigateToDetail = () => {
const segment = (props.slug?.trim() || props.id || '').trim()
if (!segment) return
if (props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1) {
router.push({
path: `/event/${props.id}/markets`,
query: { ...(props.slug && { slug: props.slug }) },
})
router.push({ name: 'event-markets', params: { id: segment } })
return
}
router.push({
path: `/trade-detail/${props.id}`,
query: {
title: props.marketTitle,
imageUrl: props.imageUrl || undefined,
category: props.category || undefined,
marketInfo: props.marketInfo || undefined,
expiresAt: props.expiresAt || undefined,
chance: String(props.chanceValue),
...(props.marketId && { marketId: props.marketId }),
...(props.slug && { slug: props.slug }),
},
})
router.push({ name: 'trade-detail', params: { id: segment } })
}
function openTradeSingle(side: 'yes' | 'no') {

View File

@ -77,6 +77,10 @@
"pleaseSelectMarket": "Please select a market (with clobTokenIds)",
"userError": "User info error",
"orderFailed": "Order failed",
"marketClosedTitle": "Market is closed",
"marketClosedDesc": "This market has settled. Trading and new orders are not available.",
"marketClosedOutcome": "Outcome: {outcome}",
"marketClosedBanner": "This market is closed and has settled.",
"expiration": {
"5m": "5m",
"15m": "15m",
@ -106,7 +110,8 @@
"yes": "Yes",
"no": "No",
"priceZero": "0¢",
"moreActions": "More actions"
"moreActions": "More actions",
"settledSubMarketsTitle": "Sub-markets (settled)"
},
"searchPage": {
"title": "Search",

View File

@ -77,6 +77,10 @@
"pleaseSelectMarket": "市場を選択してくださいclobTokenIds が必要)",
"userError": "ユーザー情報エラー",
"orderFailed": "注文に失敗しました",
"marketClosedTitle": "マーケットは閉鎖されています",
"marketClosedDesc": "このマーケットは決済済みです。取引や新規注文はできません。",
"marketClosedOutcome": "結果:{outcome}",
"marketClosedBanner": "このマーケットは閉鎖され決済されています。",
"expiration": {
"5m": "5分",
"15m": "15分",
@ -106,7 +110,8 @@
"yes": "Yes",
"no": "No",
"priceZero": "0¢",
"moreActions": "その他の操作"
"moreActions": "その他の操作",
"settledSubMarketsTitle": "サブマーケット(決済済み)"
},
"searchPage": {
"title": "検索",

View File

@ -77,6 +77,10 @@
"pleaseSelectMarket": "시장을 선택하세요 (clobTokenIds 필요)",
"userError": "사용자 정보 오류",
"orderFailed": "주문 실패",
"marketClosedTitle": "마켓이 종료되었습니다",
"marketClosedDesc": "이 마켓은 정산되었으며 거래와 신규 주문을 받지 않습니다.",
"marketClosedOutcome": "결과: {outcome}",
"marketClosedBanner": "이 마켓은 종료되어 정산되었습니다.",
"expiration": {
"5m": "5분",
"15m": "15분",
@ -106,7 +110,8 @@
"yes": "Yes",
"no": "No",
"priceZero": "0¢",
"moreActions": "더보기"
"moreActions": "더보기",
"settledSubMarketsTitle": "하위 마켓(정산 완료)"
},
"searchPage": {
"title": "검색",

View File

@ -77,6 +77,10 @@
"pleaseSelectMarket": "请先选择市场(需包含 clobTokenIds",
"userError": "用户信息异常",
"orderFailed": "下单失败",
"marketClosedTitle": "市场已关闭",
"marketClosedDesc": "本市场已结算,不再接受交易与下单。",
"marketClosedOutcome": "结果:{outcome}",
"marketClosedBanner": "该市场已关闭并完成结算。",
"expiration": {
"5m": "5分钟",
"15m": "15分钟",
@ -106,7 +110,8 @@
"yes": "是",
"no": "否",
"priceZero": "0¢",
"moreActions": "更多操作"
"moreActions": "更多操作",
"settledSubMarketsTitle": "子市场(已结算)"
},
"searchPage": {
"title": "搜索",

View File

@ -77,6 +77,10 @@
"pleaseSelectMarket": "請先選擇市場(需包含 clobTokenIds",
"userError": "用戶資訊異常",
"orderFailed": "下單失敗",
"marketClosedTitle": "市場已關閉",
"marketClosedDesc": "本市場已結算,不再接受交易與下單。",
"marketClosedOutcome": "結果:{outcome}",
"marketClosedBanner": "該市場已關閉並完成結算。",
"expiration": {
"5m": "5分鐘",
"15m": "15分鐘",
@ -106,7 +110,8 @@
"yes": "是",
"no": "否",
"priceZero": "0¢",
"moreActions": "更多操作"
"moreActions": "更多操作",
"settledSubMarketsTitle": "子市場(已結算)"
},
"searchPage": {
"title": "搜尋",

View File

@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { HOME_MAIN_SCROLL_STORAGE_KEY } from './scrollKeys'
import Home from '../views/Home.vue'
import Trade from '../views/Trade.vue'
import Login from '../views/Login.vue'
@ -67,6 +68,24 @@ const router = createRouter({
scrollBehavior(to, from, savedPosition) {
const el = document.querySelector('[data-main-scroll]')
if (el) {
const shouldRestoreHomeScroll =
to.name === 'home' &&
from?.name != null &&
(from.name === 'trade-detail' || from.name === 'event-markets')
if (shouldRestoreHomeScroll) {
const raw = sessionStorage.getItem(HOME_MAIN_SCROLL_STORAGE_KEY)
const top = raw != null ? parseInt(raw, 10) : NaN
if (Number.isFinite(top) && top >= 0) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.scrollTo({ top, left: 0 })
})
})
return
}
}
if (savedPosition && from?.name) {
el.scrollTo({ top: savedPosition.top, left: savedPosition.left ?? 0 })
return

2
src/router/scrollKeys.ts Normal file
View File

@ -0,0 +1,2 @@
/** 离开首页时写入 [data-main-scroll].scrollTop从详情/多市场返回首页时恢复 */
export const HOME_MAIN_SCROLL_STORAGE_KEY = 'pcv-home-main-scroll-top'

View File

@ -94,12 +94,17 @@
</div>
</v-card>
<!-- 市场列表 -->
<v-card class="markets-list-card" elevation="0" rounded="lg">
<!-- 可交易市场列表 -->
<v-card
v-if="activeMarkets.length > 0"
class="markets-list-card"
elevation="0"
rounded="lg"
>
<div class="markets-list">
<div
v-for="(market, index) in markets"
:key="market.ID ?? index"
v-for="(market, index) in activeMarkets"
:key="market.ID ?? market.id ?? index"
class="market-row"
:class="{ selected: selectedMarketIndex === index }"
@click="goToTradeDetail(market)"
@ -134,11 +139,46 @@
</div>
</div>
</v-card>
<!-- 已结算子市场 design vb1xr 一致标题 + 行内结果角标 -->
<v-card
v-if="closedMarkets.length > 0"
class="markets-settled-card"
elevation="0"
rounded="lg"
>
<div class="markets-settled-head">
{{ t('eventMarkets.settledSubMarketsTitle') }}
</div>
<div class="markets-settled-list">
<div
v-for="(market, index) in closedMarkets"
:key="market.ID ?? market.id ?? `c-${index}`"
class="markets-settled-row"
@click="goToTradeDetail(market)"
>
<div class="markets-settled-row-text">
<span class="markets-settled-question">{{
market.question || t('eventMarkets.marketPlaceholder')
}}</span>
<span class="markets-settled-vol">{{ formatVolumeLine(market.volume) }}</span>
</div>
<span
v-if="settledWinnerPill(market)"
class="markets-settled-pill"
:class="settledPillClass(market)"
>
{{ settledWinnerPill(market)!.label }}
</span>
<v-icon class="markets-settled-chevron" size="18">mdi-chevron-right</v-icon>
</div>
</div>
</v-card>
</v-col>
<!-- 右侧购买组件桌面端显示移动端用底部栏+弹窗 -->
<v-col v-if="!isMobile" cols="12" class="trade-col">
<div v-if="markets.length > 0" class="trade-sidebar">
<div v-if="activeMarkets.length > 0" class="trade-sidebar">
<TradeComponent
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
@ -149,7 +189,7 @@
</v-row>
<!-- 移动端 market 时显示固定底部 Yes/No + 三点菜单Merge/Split -->
<template v-if="isMobile && markets.length === 1">
<template v-if="isMobile && activeMarkets.length === 1">
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
<div class="mobile-trade-bar">
<v-btn
@ -200,7 +240,7 @@
</div>
</template>
<!-- 移动端交易弹窗 market 与多 market 均需 market 时通过列表 Buy Yes/No 打开 -->
<template v-if="isMobile && markets.length > 0">
<template v-if="isMobile && activeMarkets.length > 0">
<v-bottom-sheet v-model="tradeSheetOpen" content-class="event-markets-trade-sheet">
<TradeComponent
v-if="tradeSheetRenderContent"
@ -237,6 +277,8 @@ import {
findPmEvent,
getMarketId,
getClobTokenId,
getPmMarketSettledWinner,
isPmMarketClosed,
type FindPmEventParams,
type PmEventListItem,
type PmEventMarketItem,
@ -282,9 +324,13 @@ const markets = computed(() => {
const list = eventDetail.value?.markets ?? []
return list.length > 0 ? list : []
})
const selectedMarket = computed(() => markets.value[selectedMarketIndex.value] ?? null)
/** 移动端底部栏显示的市场(选中项或首个),仅在 markets.length > 0 时使用 */
const barMarket = computed(() => selectedMarket.value ?? markets.value[0])
/** 仍可交易的市场closed !== true */
const activeMarkets = computed(() => markets.value.filter((m) => !isPmMarketClosed(m)))
/** 已结算/已关闭的子市场 */
const closedMarkets = computed(() => markets.value.filter((m) => isPmMarketClosed(m)))
const selectedMarket = computed(() => activeMarkets.value[selectedMarketIndex.value] ?? null)
/** 移动端底部栏显示的市场(选中项或首个),仅在有可交易市场时使用 */
const barMarket = computed(() => selectedMarket.value ?? activeMarkets.value[0])
/** 传给购买组件的市场数据(当前选中的市场) */
const tradeMarketPayload = computed(() => {
const m = selectedMarket.value
@ -316,6 +362,26 @@ function formatVolume(volume: number | undefined): string {
return `$${Math.round(volume)} Vol.`
}
/** 已结算列表 secondary 行Vol. $12.4M / k与设计稿一致 */
function formatVolumeLine(volume: number | undefined): string {
if (volume == null || !Number.isFinite(volume)) return 'Vol. —'
if (volume >= 1_000_000) return `Vol. $${(volume / 1_000_000).toFixed(1)}M`
if (volume >= 1000) return `Vol. $${(volume / 1000).toFixed(1)}k`
return `Vol. $${Math.round(volume)}`
}
function settledWinnerPill(market: PmEventMarketItem) {
return getPmMarketSettledWinner(market)
}
function settledPillClass(market: PmEventMarketItem): string {
const w = getPmMarketSettledWinner(market)
if (!w) return 'markets-settled-pill--neutral'
if (w.winnerIndex === 0) return 'markets-settled-pill--yes'
if (w.winnerIndex === 1) return 'markets-settled-pill--no'
return 'markets-settled-pill--neutral'
}
function formatExpiresAt(endDate: string | undefined): string {
if (!endDate) return ''
try {
@ -581,20 +647,13 @@ function noPrice(market: PmEventMarketItem): string {
return `${Math.round(p * 100)}¢`
}
function goToTradeDetail(market: PmEventMarketItem, side?: 'yes' | 'no') {
const eventId = route.params.id
const marketId = market.ID != null ? String(market.ID) : undefined
router.push({
path: `/trade-detail/${eventId}`,
query: {
title: market.question ?? eventDetail.value?.title,
marketId,
marketInfo: formatVolume(market.volume),
chance: String(marketChance(market)),
...(side && { side }),
...(eventDetail.value?.slug && { slug: eventDetail.value.slug }),
},
})
function goToTradeDetail(_market: PmEventMarketItem, _side?: 'yes' | 'no') {
void _market
void _side
const slug = eventDetail.value?.slug?.trim()
const eventKey = slug || String(route.params.id ?? '').trim()
if (!eventKey) return
router.push({ name: 'trade-detail', params: { id: eventKey } })
}
async function loadEventDetail() {
@ -607,10 +666,9 @@ async function loadEventDetail() {
}
const numId = parseInt(idStr, 10)
const isNumericId = Number.isFinite(numId) && String(numId) === idStr && numId >= 1
const slugFromQuery = (route.query.slug as string)?.trim()
const params: FindPmEventParams = {
id: isNumericId ? numId : undefined,
slug: isNumericId ? slugFromQuery || undefined : idStr,
slug: isNumericId ? undefined : idStr,
}
detailError.value = null
@ -646,6 +704,16 @@ async function loadEventDetail() {
}
}
watch(
activeMarkets,
(list) => {
if (selectedMarketIndex.value >= list.length) {
selectedMarketIndex.value = Math.max(0, list.length - 1)
}
},
{ immediate: true },
)
onMounted(() => {
loadEventDetail()
window.addEventListener('resize', handleResize)
@ -1082,6 +1150,96 @@ watch(
color: #cc0000 !important;
}
/* 已结算子市场列表Pencil vb1xr */
.markets-settled-card {
margin-top: 16px;
border: 1px solid #e7e7e7;
border-radius: 12px;
overflow: hidden;
box-shadow: none;
background: #fff;
}
.markets-settled-head {
padding: 14px 16px 10px;
font-size: 14px;
font-weight: 600;
color: #111827;
border-bottom: 1px solid #e5e7eb;
}
.markets-settled-list {
display: flex;
flex-direction: column;
}
.markets-settled-row {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background-color 0.15s ease;
}
.markets-settled-row:last-child {
border-bottom: none;
}
.markets-settled-row:hover {
background-color: #f9fafb;
}
.markets-settled-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.markets-settled-question {
font-size: 15px;
font-weight: 500;
color: #111827;
line-height: 1.35;
word-break: break-word;
}
.markets-settled-vol {
font-size: 13px;
color: #6b7280;
}
.markets-settled-pill {
flex-shrink: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
}
.markets-settled-pill--yes {
background: #d1fae5;
color: #065f46;
}
.markets-settled-pill--no {
background: #fee2e2;
color: #991b1b;
}
.markets-settled-pill--neutral {
background: #f3f4f6;
color: #374151;
}
.markets-settled-chevron {
flex-shrink: 0;
color: #d1d5db;
}
/* 移动端底部交易栏 */
.mobile-trade-bar-spacer {
height: 72px;

View File

@ -227,6 +227,8 @@ import {
computed,
watch,
} from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { HOME_MAIN_SCROLL_STORAGE_KEY } from '../router/scrollKeys'
import { useDisplay } from 'vuetify'
import MarketCard from '../components/MarketCard.vue'
import TradeComponent from '../components/TradeComponent.vue'
@ -644,6 +646,13 @@ function checkScrollLoad() {
if (scrollHeight - scrollTop - clientHeight < SCROLL_LOAD_THRESHOLD) loadMore()
}
onBeforeRouteLeave(() => {
const el = getMainScrollEl()
if (el) {
sessionStorage.setItem(HOME_MAIN_SCROLL_STORAGE_KEY, String((el as HTMLElement).scrollTop))
}
})
onMounted(() => {
if (props.initialSearchExpanded) expandSearch()
loadCategory()

View File

@ -111,9 +111,8 @@ function formatTradingFee(rate: number): string {
/** 与接口 needDeposit / accumulated 同一套口径:大额按 USDC 6 位小数,否则按美元数值 */
function depositRawToUsd(raw: number): number {
if (!Number.isFinite(raw) || raw <= 0) return 0
return raw >= 1_000_000_000 ? raw / 1_000_000 : raw
return raw / 1_000_000
}
function parseUserAccumulatedUsd(user: Record<string, unknown>): number {
const v = user.accumulated ?? user.Accumulated
if (typeof v === 'number' && Number.isFinite(v)) return depositRawToUsd(v)

View File

@ -302,27 +302,15 @@ function openResult(item: SearchResultItem) {
const card = mapEventItemToCard(item.raw)
if (!card.id) return
const segment = (card.slug?.trim() || card.id || '').trim()
if (!segment) return
if (card.displayType === 'multi' && (card.outcomes?.length ?? 0) > 1) {
router.push({
path: `/event/${card.id}/markets`,
query: { ...(card.slug && { slug: card.slug }) },
})
router.push({ name: 'event-markets', params: { id: segment } })
return
}
router.push({
path: `/trade-detail/${card.id}`,
query: {
title: card.marketTitle,
imageUrl: card.imageUrl || undefined,
category: card.category || undefined,
marketInfo: card.marketInfo || undefined,
expiresAt: card.expiresAt || undefined,
chance: String(card.chanceValue),
...(card.marketId && { marketId: card.marketId }),
...(card.slug && { slug: card.slug }),
},
})
router.push({ name: 'trade-detail', params: { id: segment } })
}
</script>

View File

@ -61,6 +61,9 @@
</template>
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
</div>
<p v-if="currentMarketClosed" class="chart-closed-banner">
{{ t('trade.marketClosedBanner') }}
</p>
</div>
<!-- 图表区域 -->
@ -92,6 +95,23 @@
</div>
</v-card>
<!-- 移动端无右侧交易栏时展示已关闭说明 -->
<v-card
v-if="isMobile && currentMarketClosed"
class="market-closed-mobile-card"
elevation="0"
rounded="lg"
>
<div class="market-closed-pane market-closed-pane--mobile">
<v-icon size="40" color="grey-darken-1">mdi-lock-outline</v-icon>
<h2 class="market-closed-pane-title">{{ t('trade.marketClosedTitle') }}</h2>
<p class="market-closed-pane-desc">{{ t('trade.marketClosedDesc') }}</p>
<p v-if="settledOutcomeLabel" class="market-closed-pane-outcome">
{{ t('trade.marketClosedOutcome', { outcome: settledOutcomeLabel }) }}
</p>
</div>
</v-card>
<!-- 持仓 / 限价订单簿上方 -->
<v-card class="positions-orders-card" elevation="0" rounded="lg">
<v-tabs
@ -150,6 +170,7 @@
color="primary"
class="position-sell-btn"
:disabled="
currentMarketClosed ||
!(pos.availableSharesNum != null && pos.availableSharesNum > 0)
"
@click="openSellFromPosition(pos)"
@ -199,7 +220,7 @@
</v-card>
<!-- Order Book Section -->
<v-card class="order-book-card" elevation="0" rounded="lg">
<v-card v-if="!currentMarketClosed" class="order-book-card" elevation="0" rounded="lg">
<OrderBook
:asks-yes="orderBookAsksYes"
:bids-yes="orderBookBidsYes"
@ -257,11 +278,18 @@
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口移动端隐藏改用底部栏+弹窗 -->
<v-col v-if="!isMobile" cols="12" class="trade-col">
<div class="trade-sidebar">
<div v-if="currentMarketClosed" class="trade-sidebar market-closed-pane">
<v-icon size="44" color="grey-darken-1">mdi-lock-outline</v-icon>
<h2 class="market-closed-pane-title">{{ t('trade.marketClosedTitle') }}</h2>
<p class="market-closed-pane-desc">{{ t('trade.marketClosedDesc') }}</p>
<p v-if="settledOutcomeLabel" class="market-closed-pane-outcome">
{{ t('trade.marketClosedOutcome', { outcome: settledOutcomeLabel }) }}
</p>
</div>
<div v-else-if="tradeMarketPayload" class="trade-sidebar">
<TradeComponent
ref="tradeComponentRef"
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
:positions="tradePositionsForComponent"
@merge-success="onMergeSuccess"
@split-success="onSplitSuccess"
@ -270,7 +298,7 @@
</v-col>
<!-- 移动端固定底部 Yes/No + 三点菜单Merge/Split -->
<template v-if="isMobile && tradeMarketPayload">
<template v-if="isMobile && tradeMarketPayload && !currentMarketClosed">
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
<div class="mobile-trade-bar">
<v-btn
@ -381,6 +409,8 @@ import {
findPmEvent,
getMarketId,
getClobTokenId,
getPmMarketSettledWinner,
isPmMarketClosed,
type FindPmEventParams,
type PmEventListItem,
} from '../api/event'
@ -486,10 +516,9 @@ async function loadEventDetail() {
}
const numId = parseInt(idStr, 10)
const isNumericId = Number.isFinite(numId) && String(numId) === idStr && numId >= 1
const slugFromQuery = (route.query.slug as string)?.trim()
const params: FindPmEventParams = {
id: isNumericId ? numId : undefined,
slug: isNumericId ? slugFromQuery || undefined : idStr,
slug: isNumericId ? undefined : idStr,
}
detailError.value = null
@ -535,18 +564,15 @@ function onRefresh({ done }: { done: () => void }) {
)
}
// query
const marketTitle = computed(() => {
if (eventDetail.value?.title) return eventDetail.value.title
return (route.query.title as string) || 'U.S. anti-cartel ground operation in Mexico by March 31?'
})
// 使 slug/id query
const marketTitle = computed(() => eventDetail.value?.title ?? '')
const marketVolume = computed(() => {
if (eventDetail.value?.volume != null) return formatVolume(eventDetail.value.volume)
return (route.query.marketInfo as string) || '$398,719'
return formatVolume(undefined)
})
const marketExpiresAt = computed(() => {
if (eventDetail.value?.endDate) return formatExpiresAt(eventDetail.value.endDate)
return (route.query.expiresAt as string) || 'Mar 31, 2026'
return ''
})
const resolutionDate = computed(() => {
const s = marketExpiresAt.value
@ -593,17 +619,19 @@ function setChartMode(mode: 'yesno' | 'crypto') {
updateChartData()
}
/** 当前市场(用于交易组件与 Split 拆单):query.marketId 匹配或取第一个 */
/** 当前市场(用于交易组件与 Split 拆单):接口返回的第一个市场 */
const currentMarket = computed(() => {
const list = eventDetail.value?.markets ?? []
if (list.length === 0) return null
const qId = route.query.marketId
if (qId != null && String(qId).trim() !== '') {
const qStr = String(qId).trim()
const found = list.find((m) => getMarketId(m) === qStr)
if (found) return found
}
return list[0]
return list[0] ?? null
})
const currentMarketClosed = computed(() => isPmMarketClosed(currentMarket.value))
const settledOutcomeLabel = computed(() => {
const m = currentMarket.value
if (!m) return ''
const w = getPmMarketSettledWinner(m)
return w?.label ?? ''
})
// --- CLOB WebSocket 簿 ---
@ -861,23 +889,6 @@ const tradeMarketPayload = computed(() => {
bestBidNoCents,
}
}
const qId = route.query.marketId
if (qId != null && String(qId).trim() !== '') {
return {
marketId: String(qId).trim(),
yesPrice,
noPrice,
title: (route.query.title as string) || undefined,
bestBidYesCents,
bestBidNoCents,
}
}
return undefined
})
const tradeInitialOption = computed(() => {
const side = route.query.side
if (side === 'yes' || side === 'no') return side
return undefined
})
@ -1216,12 +1227,7 @@ const currentChance = computed(() => {
const d = data.value
const last = d.length > 0 ? d[d.length - 1] : undefined
if (last != null) return last[1]
const q = route.query.chance
if (q != null) {
const n = Number(q)
if (Number.isFinite(n)) return Math.min(100, Math.max(0, Math.round(n)))
}
return 20
return 0
})
const lineColor = '#2563eb'
@ -1966,6 +1972,59 @@ onUnmounted(() => {
flex-shrink: 0;
}
.chart-closed-banner {
margin: 8px 0 0;
font-size: 13px;
color: #92400e;
background: #fffbeb;
border-radius: 8px;
padding: 8px 12px;
line-height: 1.4;
}
.market-closed-pane {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 28px 20px;
border: 1px solid #e7e7e7;
border-radius: 12px;
background: #fafafa;
}
.market-closed-pane--mobile {
padding: 20px 16px;
}
.market-closed-pane-title {
margin: 12px 0 8px;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.market-closed-pane-desc {
margin: 0;
font-size: 14px;
color: #6b7280;
line-height: 1.5;
}
.market-closed-pane-outcome {
margin: 14px 0 0;
font-size: 15px;
font-weight: 600;
color: #111827;
}
.market-closed-mobile-card {
margin-bottom: 16px;
border: 1px solid #e7e7e7;
box-shadow: none !important;
overflow: hidden;
}
@media (min-width: 960px) {
.trade-detail-row {
flex-wrap: nowrap;

View File

@ -767,17 +767,10 @@ function canOpenTradeDetail(opts: { tradeEventId?: string; tradeEventSlug?: stri
return !!(opts.tradeEventId?.trim() || opts.tradeEventSlug?.trim())
}
function openTradeDetailFromWallet(input: {
tradeEventId?: string
tradeEventSlug?: string
marketId?: string
title?: string
}) {
function openTradeDetailFromWallet(input: { tradeEventId?: string; tradeEventSlug?: string }) {
const loc = buildTradeDetailPushOptions({
eventId: input.tradeEventId,
eventSlug: input.tradeEventSlug,
marketId: input.marketId,
title: input.title,
})
if (loc) router.push(loc)
}
@ -787,8 +780,6 @@ function onPositionRowClick(pos: Position) {
openTradeDetailFromWallet({
tradeEventId: pos.tradeEventId,
tradeEventSlug: pos.tradeEventSlug,
marketId: pos.marketID,
title: pos.market,
})
}
@ -797,8 +788,6 @@ function onOpenOrderRowClick(ord: OpenOrder) {
openTradeDetailFromWallet({
tradeEventId: ord.tradeEventId,
tradeEventSlug: ord.tradeEventSlug,
marketId: ord.detailMarketId,
title: ord.market,
})
}
@ -813,8 +802,6 @@ function onHistoryRowClick(h: HistoryItem) {
openTradeDetailFromWallet({
tradeEventId: h.tradeEventId,
tradeEventSlug: h.tradeEventSlug,
marketId: h.detailMarketId,
title: h.market,
})
}
@ -1174,7 +1161,12 @@ function getWithdrawStatusLabel(status: string | undefined): string {
const s = (status ?? '').toLowerCase()
if (s === WITHDRAW_STATUS.PENDING || s === '0' || s === 'pending')
return t('wallet.withdrawStatusPending')
if (s === WITHDRAW_STATUS.SUCCESS || s === '1' || s === 'success')
if (
s === WITHDRAW_STATUS.SUCCESS ||
s === WITHDRAW_STATUS.PROCESSED ||
s === '1' ||
s === 'success'
)
return t('wallet.withdrawStatusSuccess')
if (s === WITHDRAW_STATUS.REJECTED || s === '2' || s === 'rejected')
return t('wallet.withdrawStatusRejected')
@ -1186,7 +1178,13 @@ function getWithdrawStatusLabel(status: string | undefined): string {
function getWithdrawStatusClass(status: string | undefined): string {
const s = (status ?? '').toLowerCase()
if (s === WITHDRAW_STATUS.PENDING || s === '0' || s === 'pending') return 'status-pending'
if (s === WITHDRAW_STATUS.SUCCESS || s === '1' || s === 'success') return 'status-success'
if (
s === WITHDRAW_STATUS.SUCCESS ||
s === WITHDRAW_STATUS.PROCESSED ||
s === '1' ||
s === 'success'
)
return 'status-success'
if (s === WITHDRAW_STATUS.REJECTED || s === '2' || s === 'rejected') return 'status-rejected'
if (s === WITHDRAW_STATUS.FAILED || s === '3' || s === 'failed') return 'status-failed'
return ''
@ -2731,12 +2729,17 @@ async function submitAuthorize() {
flex-shrink: 0;
}
.withdrawal-status-pill.status-pending,
.withdrawal-status-pill.status-success,
.withdrawal-status-pill.status-failed {
background: #f3f4f6;
color: #374151;
}
.withdrawal-status-pill.status-success {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.withdrawal-status-pill.status-rejected {
background: #fef2f2;
color: #b91c1c;