xtraderClient/src/components/TradeComponent.vue
2026-02-27 22:25:35 +08:00

2517 lines
78 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- 桌面端完整交易卡片扁平化 -->
<v-card v-if="!mobile" class="trade-component" elevation="0">
<!-- Header -->
<div class="header">
<v-tabs v-model="activeTab" class="buy-sell-tabs minimal-tabs" density="compact">
<v-tab value="buy" class="minimal-tab">{{ t('trade.buy') }}</v-tab>
<v-tab value="sell" class="minimal-tab">{{ t('trade.sell') }}</v-tab>
</v-tabs>
<v-menu>
<template v-slot:activator="{ props, isActive }">
<v-btn v-bind="props" class="limit-btn" text end>
{{ limitTypeDisplay }}
<v-icon right>{{ isActive ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="limitType = 'Market'">
<v-list-item-title>{{ t('trade.market') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="limitType = 'Limit'">
<v-list-item-title>{{ t('trade.limit') }}</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-list-item @click="openMergeDialog">
<v-list-item-title>{{ t('trade.merge') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="openSplitDialog">
<v-list-item-title>{{ t('trade.split') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<!-- Market Mode View -->
<template v-if="isMarketMode">
<!-- Balance > 0: Show Trade Interface -->
<template v-if="balance > 0">
<!-- Price Options -->
<div class="price-options">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>
{{ yesLabel }} {{ yesPriceCents }}¢
</v-btn>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>
{{ noLabel }} {{ noPriceCents }}¢
</v-btn>
</div>
<!-- Buy Market: Amount 余额充足时也显示 -->
<template v-if="activeTab === 'buy'">
<div class="input-group">
<div class="amount-header">
<div>
<span class="label amount-label">{{ t('trade.amount') }}</span>
<span class="balance-label">{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span>
</div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">{{ t('trade.max') }}</v-btn>
</div>
</div>
</template>
<!-- Sell Market: Shares input + 25%/50%/Max -->
<template v-if="activeTab === 'sell'">
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<div class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
</div>
</template>
<!-- Total and To Win (Buy模式) You'll receive (Sell模式) -->
<div class="total-section">
<!-- Buy模式 -->
<template v-if="activeTab === 'buy'">
<div class="total-row">
<span class="label">{{ t('trade.total') }}</span>
<span class="total-value">${{ totalPrice }}</span>
</div>
<div class="total-row">
<span class="label">{{ t('trade.toWin') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ toWinValue }}
</span>
</div>
</template>
<!-- Sell模式 -->
<template v-else>
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ totalPrice }}
</span>
</div>
<div class="total-row avg-price-row">
<span class="label">
{{ t('trade.avgPrice') }} {{ avgPriceCents }}¢
<v-icon size="14" class="info-icon">mdi-information-outline</v-icon>
</span>
</div>
</template>
</div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<!-- Action Button: Buy 余额足够显示 Buy Yes/No不足显示 {{ t('trade.deposit') }}Sell 只显示 Sell Yes/No -->
<v-btn
v-if="activeTab === 'buy' && showDepositForBuy"
class="deposit-btn"
@click="deposit"
>
{{ t('trade.deposit') }}
</v-btn>
<v-btn
v-else
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>
{{ actionButtonText }}
</v-btn>
</template>
<!-- Balance <= 0: Show Deposit Interface (Buy) Sell UI (Sell) -->
<template v-else>
<!-- Price Options -->
<div class="price-options">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>
{{ yesLabel }} {{ yesPriceCents }}¢
</v-btn>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>
{{ noLabel }} {{ noPriceCents }}¢
</v-btn>
</div>
<!-- Buy: Amount Section -->
<template v-if="activeTab === 'buy'">
<div class="input-group">
<div class="amount-header">
<div>
<span class="label amount-label">{{ t('trade.amount') }}</span>
<span class="balance-label">{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span>
</div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<!-- Amount Buttons -->
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">{{ t('trade.max') }}</v-btn>
</div>
<!-- To win份数 × 1U -->
<div v-if="amount > 0" class="total-row amount-to-win-row">
<span class="label">{{ t('trade.toWin') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ toWinValue }}
</span>
</div>
</div>
<!-- Buy 余额不足时显示 {{ t('trade.deposit') }} -->
<v-btn class="deposit-btn" @click="deposit">{{ t('trade.deposit') }}</v-btn>
</template>
<!-- Sell: Shares + To receive + Avg. Price只显示 Sell Yes/No {{ t('trade.deposit') }} -->
<template v-else>
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<div class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
</div>
<div class="total-section">
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ totalPrice }}
</span>
</div>
<div class="total-row avg-price-row">
<span class="label">
{{ t('trade.avgPrice') }} {{ avgPriceCents }}¢
<v-icon size="14" class="info-icon">mdi-information-outline</v-icon>
</span>
</div>
</div>
<!-- Sell 只显示 Sell Yes/No -->
<v-btn
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>
{{ actionButtonText }}
</v-btn>
</template>
</template>
</template>
<!-- Limit Mode View -->
<template v-else>
<!-- Price Options -->
<div class="price-options">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>
{{ yesLabel }} {{ yesPriceCents }}¢
</v-btn>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>
{{ noLabel }} {{ noPriceCents }}¢
</v-btn>
</div>
<!-- Limit Price -->
<div class="input-group limit-price-group">
<div class="limit-price-header">
<span class="label">{{ t('trade.limitPrice') }}</span>
<div class="price-input">
<v-btn class="adjust-btn" icon @click="decreasePrice">
<v-icon>mdi-minus</v-icon>
</v-btn>
<v-text-field
:model-value="limitPriceCentsDisplay"
type="number"
min="0"
max="100"
step="0.01"
class="price-input-field"
hide-details
density="compact"
suffix="¢"
@update:model-value="onLimitPriceInput"
@blur="onLimitPriceBlur"
@keydown="onLimitPriceKeydown"
@paste="onLimitPricePaste"
></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice">
<v-icon>mdi-plus</v-icon>
</v-btn>
</div>
</div>
</div>
<!-- Shares -->
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<!-- Buy模式的份额调整按钮 -->
<div v-if="activeTab === 'buy'" class="shares-buttons">
<v-btn class="share-btn" @click="adjustShares(-100)">-100</v-btn>
<v-btn class="share-btn" @click="adjustShares(-10)">-10</v-btn>
<v-btn class="share-btn" @click="adjustShares(10)">+10</v-btn>
<v-btn class="share-btn" @click="adjustShares(100)">+100</v-btn>
<v-btn class="share-btn" @click="adjustShares(200)">+200</v-btn>
</div>
<!-- Sell模式的份额调整按钮 -->
<div v-else class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
<div v-if="activeTab === 'buy'" class="matching-info">
<v-icon size="14">mdi-information</v-icon>
<span>20.00 matching</span>
</div>
</div>
<!-- Set Expiration -->
<div class="input-group expiration-group">
<div class="expiration-header">
<span class="label">{{ t('trade.setExpiration') }}</span>
<v-switch v-model="expirationEnabled" class="expiration-switch" hide-details></v-switch>
</div>
<!-- Expiration Time Dropdown -->
<v-select
v-if="expirationEnabled"
v-model="expirationTime"
:items="expirationOptions"
item-title="title"
item-value="value"
class="expiration-select"
hide-details
density="compact"
></v-select>
</div>
<!-- Total and To Win (Buy模式) You'll receive (Sell模式) -->
<div class="total-section">
<!-- Buy模式 -->
<template v-if="activeTab === 'buy'">
<div class="total-row">
<span class="label">{{ t('trade.total') }}</span>
<span class="total-value">${{ totalPrice }}</span>
</div>
<div class="total-row">
<span class="label">{{ t('trade.toWin') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ toWinValue }}
</span>
</div>
</template>
<!-- Sell模式 -->
<template v-else>
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ totalPrice }}
</span>
</div>
</template>
</div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>
{{ actionButtonText }}
</v-btn>
</template>
</v-card>
<!-- 移动端且由首页卡片嵌入:只渲染交易表单,无底部栏、无内部 sheet扁平化 -->
<template v-else-if="embeddedInSheet">
<v-sheet class="trade-sheet-paper trade-sheet-paper--embedded" rounded="lg" elevation="0">
<div class="trade-component trade-sheet-inner">
<div class="header">
<v-tabs v-model="activeTab" class="buy-sell-tabs minimal-tabs" density="compact">
<v-tab value="buy" class="minimal-tab">{{ t('trade.buy') }}</v-tab>
<v-tab value="sell" class="minimal-tab">{{ t('trade.sell') }}</v-tab>
</v-tabs>
<v-menu class="limit-dropdown hide-in-mobile-sheet">
<template v-slot:activator="{ props: limitProps, isActive }">
<v-btn v-bind="limitProps" class="limit-btn" text end>
{{ limitTypeDisplay }}
<v-icon right>{{ isActive ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="limitType = 'Market'"
><v-list-item-title>{{ t('trade.market') }}</v-list-item-title></v-list-item
>
<v-list-item @click="limitType = 'Limit'"
><v-list-item-title>{{ t('trade.limit') }}</v-list-item-title></v-list-item
>
<v-divider></v-divider>
<v-list-item @click="openMergeDialog"
><v-list-item-title>{{ t('trade.merge') }}</v-list-item-title></v-list-item
>
<v-list-item @click="openSplitDialog"
><v-list-item-title>{{ t('trade.split') }}</v-list-item-title></v-list-item
>
</v-list>
</v-menu>
</div>
<template v-if="isMarketMode">
<template v-if="balance > 0">
<div class="price-options hide-in-mobile-sheet">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
>
</div>
<!-- Buy Market: Amount 区(余额充足时也显示) -->
<template v-if="activeTab === 'buy'">
<div class="input-group">
<div class="amount-header">
<div>
<span class="label amount-label">{{ t('trade.amount') }}</span>
<span class="balance-label">{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span>
</div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">{{ t('trade.max') }}</v-btn>
</div>
</div>
</template>
<!-- Sell Market: Shares input + 25%/50%/Max -->
<template v-if="activeTab === 'sell'">
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<div class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
</div>
</template>
<div class="total-section">
<template v-if="activeTab === 'buy'">
<div class="total-row">
<span class="label">{{ t('trade.total') }}</span><span class="total-value">${{ totalPrice }}</span>
</div>
<div class="total-row">
<span class="label">{{ t('trade.toWin') }}</span
><span class="to-win-value"
><v-icon size="16" color="green">mdi-currency-usd</v-icon> {{ toWinValue }}</span
>
</div>
</template>
<template v-else>
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span
><span class="to-win-value"
><v-icon size="16" color="green">mdi-currency-usd</v-icon> {{
totalPrice
}}</span
>
</div>
<div class="total-row avg-price-row">
<span class="label">
{{ t('trade.avgPrice') }} {{ avgPriceCents }}¢
<v-icon size="14" class="info-icon">mdi-information-outline</v-icon>
</span>
</div>
</template>
</div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn
v-if="activeTab === 'buy' && showDepositForBuy"
class="deposit-btn"
@click="deposit"
>
{{ t('trade.deposit') }}
</v-btn>
<v-btn
v-else
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>{{ actionButtonText }}</v-btn
>
</template>
<template v-else>
<div class="price-options hide-in-mobile-sheet">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
>
</div>
<!-- Buy: Amount Section -->
<template v-if="activeTab === 'buy'">
<div class="input-group">
<div class="amount-header">
<div>
<span class="label amount-label">{{ t('trade.amount') }}</span
><span class="balance-label">{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span>
</div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">{{ t('trade.max') }}</v-btn>
</div>
<div v-if="amount > 0" class="total-row amount-to-win-row">
<span class="label">{{ t('trade.toWin') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ toWinValue }}
</span>
</div>
</div>
<v-btn class="deposit-btn" @click="deposit">{{ t('trade.deposit') }}</v-btn>
</template>
<!-- Sell: Shares + To receive + Avg. Price -->
<template v-else>
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<div class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
</div>
<div class="total-section">
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ totalPrice }}
</span>
</div>
<div class="total-row avg-price-row">
<span class="label">
{{ t('trade.avgPrice') }} {{ avgPriceCents }}¢
<v-icon size="14" class="info-icon">mdi-information-outline</v-icon>
</span>
</div>
</div>
<v-btn
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>
{{ actionButtonText }}
</v-btn>
</template>
</template>
</template>
<template v-else>
<div class="price-options hide-in-mobile-sheet">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
>
</div>
<div class="input-group limit-price-group">
<div class="limit-price-header">
<span class="label">{{ t('trade.limitPrice') }}</span>
<div class="price-input">
<v-btn class="adjust-btn" icon @click="decreasePrice"
><v-icon>mdi-minus</v-icon></v-btn
>
<v-text-field
:model-value="limitPriceCentsDisplay"
type="number"
min="0"
max="100"
step="0.01"
class="price-input-field"
hide-details
density="compact"
suffix="¢"
@update:model-value="onLimitPriceInput"
@blur="onLimitPriceBlur"
@keydown="onLimitPriceKeydown"
@paste="onLimitPricePaste"
></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice"
><v-icon>mdi-plus</v-icon></v-btn
>
</div>
</div>
</div>
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<div v-if="activeTab === 'buy'" class="shares-buttons">
<v-btn class="share-btn" @click="adjustShares(-100)">-100</v-btn>
<v-btn class="share-btn" @click="adjustShares(-10)">-10</v-btn>
<v-btn class="share-btn" @click="adjustShares(10)">+10</v-btn>
<v-btn class="share-btn" @click="adjustShares(100)">+100</v-btn>
</div>
<div v-else class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
</div>
<div class="input-group expiration-group">
<div class="expiration-header">
<span class="label">{{ t('trade.setExpiration') }}</span>
<v-switch
v-model="expirationEnabled"
class="expiration-switch"
hide-details
color="primary"
></v-switch>
</div>
<v-select
v-if="expirationEnabled"
v-model="expirationTime"
:items="expirationOptions"
item-title="title"
item-value="value"
class="expiration-select"
hide-details
density="compact"
></v-select>
</div>
<div class="total-section">
<template v-if="activeTab === 'buy'">
<div class="total-row">
<span class="label">{{ t('trade.total') }}</span><span class="total-value">${{ totalPrice }}</span>
</div>
<div class="total-row">
<span class="label">{{ t('trade.toWin') }}</span
><span class="to-win-value"
><v-icon size="16" color="green">mdi-currency-usd</v-icon> {{ toWinValue }}</span
>
</div>
</template>
<template v-else>
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span
><span class="to-win-value"
><v-icon size="16" color="green">mdi-currency-usd</v-icon> {{ totalPrice }}</span
>
</div>
</template>
</div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>{{ actionButtonText }}</v-btn
>
</template>
</div>
</v-sheet>
</template>
<!-- 移动端:底部紧凑栏 + 底部弹出层 -->
<template v-else>
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
<div class="mobile-trade-bar">
<v-btn
class="mobile-bar-btn mobile-bar-yes"
variant="flat"
color="success"
rounded="pill"
block
@click="openSheet('yes')"
>
{{ t('trade.buyLabel', { label: yesLabel }) }} {{ yesPriceCents }}¢
</v-btn>
<v-btn
class="mobile-bar-btn mobile-bar-no"
variant="flat"
color="error"
rounded="pill"
block
@click="openSheet('no')"
>
{{ t('trade.buyLabel', { label: noLabel }) }} {{ noPriceCents }}¢
</v-btn>
</div>
<v-bottom-sheet v-model="sheetOpen" class="trade-sheet">
<v-sheet class="trade-sheet-paper" rounded="lg">
<div class="trade-component trade-sheet-inner">
<div class="header">
<v-tabs v-model="activeTab" class="buy-sell-tabs minimal-tabs" density="compact">
<v-tab value="buy" class="minimal-tab">{{ t('trade.buy') }}</v-tab>
<v-tab value="sell" class="minimal-tab">{{ t('trade.sell') }}</v-tab>
</v-tabs>
<v-menu class="limit-dropdown hide-in-mobile-sheet">
<template v-slot:activator="{ props: limitProps, isActive }">
<v-btn v-bind="limitProps" class="limit-btn" text end>
{{ limitType }}
<v-icon right>{{ isActive ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="limitType = 'Market'"
><v-list-item-title>Market</v-list-item-title></v-list-item
>
<v-list-item @click="limitType = 'Limit'"
><v-list-item-title>Limit</v-list-item-title></v-list-item
>
<v-divider></v-divider>
<v-list-item @click="openMergeDialog"
><v-list-item-title>{{ t('trade.merge') }}</v-list-item-title></v-list-item
>
<v-list-item @click="openSplitDialog"
><v-list-item-title>{{ t('trade.split') }}</v-list-item-title></v-list-item
>
</v-list>
</v-menu>
</div>
<template v-if="isMarketMode">
<template v-if="balance > 0">
<div class="price-options hide-in-mobile-sheet">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
>
</div>
<!-- Buy Market: Amount 区(余额充足时也显示) -->
<template v-if="activeTab === 'buy'">
<div class="input-group">
<div class="amount-header">
<div>
<span class="label amount-label">{{ t('trade.amount') }}</span>
<span class="balance-label">{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span>
</div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">{{ t('trade.max') }}</v-btn>
</div>
</div>
</template>
<!-- Sell Market: Shares input + 25%/50%/Max -->
<template v-if="activeTab === 'sell'">
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<div class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
</div>
</template>
<div class="total-section">
<template v-if="activeTab === 'buy'">
<div class="total-row">
<span class="label">Total</span
><span class="total-value">${{ totalPrice }}</span>
</div>
<div class="total-row">
<span class="label">{{ t('trade.toWin') }}</span
><span class="to-win-value"
><v-icon size="16" color="green">mdi-currency-usd</v-icon> {{ toWinValue }}</span
>
</div>
</template>
<template v-else>
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span
><span class="to-win-value"
><v-icon size="16" color="green">mdi-currency-usd</v-icon> {{
totalPrice
}}</span
>
</div>
<div class="total-row avg-price-row">
<span class="label">
{{ t('trade.avgPrice') }} {{ avgPriceCents }}¢
<v-icon size="14" class="info-icon">mdi-information-outline</v-icon>
</span>
</div>
</template>
</div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn
v-if="activeTab === 'buy' && showDepositForBuy"
class="deposit-btn"
@click="deposit"
>
{{ t('trade.deposit') }}
</v-btn>
<v-btn
v-else
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>{{ actionButtonText }}</v-btn
>
</template>
<template v-else>
<div class="price-options hide-in-mobile-sheet">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
>
</div>
<!-- Buy: Amount Section -->
<template v-if="activeTab === 'buy'">
<div class="input-group">
<div class="amount-header">
<div>
<span class="label amount-label">{{ t('trade.amount') }}</span
><span class="balance-label">{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span>
</div>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
</div>
<div class="amount-buttons">
<v-btn class="amount-btn" @click="adjustAmount(1)">+$1</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(20)">+$20</v-btn>
<v-btn class="amount-btn" @click="adjustAmount(100)">+$100</v-btn>
<v-btn class="amount-btn" @click="setMaxAmount">{{ t('trade.max') }}</v-btn>
</div>
<div v-if="amount > 0" class="total-row amount-to-win-row">
<span class="label">{{ t('trade.toWin') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ toWinValue }}
</span>
</div>
</div>
<v-btn class="deposit-btn" @click="deposit">{{ t('trade.deposit') }}</v-btn>
</template>
<!-- Sell: Shares + To receive + Avg. Price -->
<template v-else>
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<div class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
</div>
<div class="total-section">
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span>
<span class="to-win-value">
<v-icon size="16" color="green">mdi-currency-usd</v-icon>
{{ totalPrice }}
</span>
</div>
<div class="total-row avg-price-row">
<span class="label">
{{ t('trade.avgPrice') }} {{ avgPriceCents }}¢
<v-icon size="14" class="info-icon">mdi-information-outline</v-icon>
</span>
</div>
</div>
<v-btn
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>
{{ actionButtonText }}
</v-btn>
</template>
</template>
</template>
<template v-else>
<div class="price-options hide-in-mobile-sheet">
<v-btn
class="yes-btn"
:class="{ active: selectedOption === 'yes' }"
text
@click="handleOptionChange('yes')"
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
>
<v-btn
class="no-btn"
:class="{ active: selectedOption === 'no' }"
text
@click="handleOptionChange('no')"
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
>
</div>
<div class="input-group limit-price-group">
<div class="limit-price-header">
<span class="label">{{ t('trade.limitPrice') }}</span>
<div class="price-input">
<v-btn class="adjust-btn" icon @click="decreasePrice"
><v-icon>mdi-minus</v-icon></v-btn
>
<v-text-field
:model-value="limitPriceCentsDisplay"
type="number"
min="0"
max="100"
step="0.01"
class="price-input-field"
hide-details
density="compact"
suffix="¢"
@update:model-value="onLimitPriceInput"
@blur="onLimitPriceBlur"
@keydown="onLimitPriceKeydown"
@paste="onLimitPricePaste"
></v-text-field>
<v-btn class="adjust-btn" icon @click="increasePrice"
><v-icon>mdi-plus</v-icon></v-btn
>
</div>
</div>
</div>
<div class="input-group shares-group">
<div class="shares-header">
<span class="label">{{ t('trade.shares') }}</span>
<div class="shares-input">
<v-text-field
:model-value="shares"
type="number"
min="1"
class="shares-input-field"
hide-details
density="compact"
@update:model-value="onSharesInput"
@keydown="onSharesKeydown"
@paste="onSharesPaste"
></v-text-field>
</div>
</div>
<div v-if="activeTab === 'buy'" class="shares-buttons">
<v-btn class="share-btn" @click="adjustShares(-100)">-100</v-btn>
<v-btn class="share-btn" @click="adjustShares(-10)">-10</v-btn>
<v-btn class="share-btn" @click="adjustShares(10)">+10</v-btn>
<v-btn class="share-btn" @click="adjustShares(100)">+100</v-btn>
</div>
<div v-else class="shares-buttons">
<v-btn class="share-btn" @click="setSharesPercentage(25)">25%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(50)">50%</v-btn>
<v-btn class="share-btn" @click="setSharesPercentage(100)">{{ t('trade.max') }}</v-btn>
</div>
</div>
<div class="input-group expiration-group">
<div class="expiration-header">
<span class="label">{{ t('trade.setExpiration') }}</span>
<v-switch
v-model="expirationEnabled"
class="expiration-switch"
hide-details
color="primary"
></v-switch>
</div>
<v-select
v-if="expirationEnabled"
v-model="expirationTime"
:items="expirationOptions"
item-title="title"
item-value="value"
class="expiration-select"
hide-details
density="compact"
></v-select>
</div>
<div class="total-section">
<template v-if="activeTab === 'buy'">
<div class="total-row">
<span class="label">{{ t('trade.total') }}</span><span class="total-value">${{ totalPrice }}</span>
</div>
<div class="total-row">
<span class="label">{{ t('trade.toWin') }}</span
><span class="to-win-value"
><v-icon size="16" color="green">mdi-currency-usd</v-icon> {{ toWinValue }}</span
>
</div>
</template>
<template v-else>
<div class="total-row">
<span class="label">{{ t('trade.youllReceive') }}</span
><span class="to-win-value"
><v-icon size="16" color="green">mdi-currency-usd</v-icon> {{
totalPrice
}}</span
>
</div>
</template>
</div>
<p v-if="orderError" class="order-error">{{ orderError }}</p>
<v-btn
class="action-btn"
:loading="orderLoading"
:disabled="orderLoading"
@click="submitOrder"
>{{ actionButtonText }}</v-btn
>
</template>
</div>
</v-sheet>
</v-bottom-sheet>
</template>
<!-- Merge shares dialog与桌面/移动端分支并列,始终挂载才能响应 openMergeDialog -->
<v-dialog
v-model="mergeDialogOpen"
max-width="440"
persistent
content-class="merge-dialog"
transition="dialog-transition"
>
<v-card class="merge-dialog-card" rounded="lg" elevation="0">
<div class="merge-dialog-header">
<h3 class="merge-dialog-title">{{ t('trade.mergeDialogTitle') }}</h3>
<v-btn
icon
variant="text"
size="small"
class="merge-dialog-close"
@click="mergeDialogOpen = false"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text class="merge-dialog-body">
<p class="merge-dialog-desc">
{{ t('trade.mergeDialogDesc', { yesLabel, noLabel }) }}
</p>
<div class="merge-amount-row">
<label class="merge-amount-label">{{ t('trade.amount') }}</label>
<v-text-field
v-model.number="mergeAmount"
type="number"
min="0"
hide-details
density="compact"
variant="outlined"
class="merge-amount-input"
/>
</div>
<p class="merge-available">
{{ t('trade.mergeAvailableShares') }} {{ availableMergeShares }}
<button type="button" class="merge-max-link" @click="setMergeMax">{{ t('trade.max') }}</button>
</p>
<p v-if="!props.market?.marketId" class="merge-no-market">
{{ t('trade.mergeNoMarket', { yesLabel, noLabel }) }}
</p>
<p v-if="mergeError" class="merge-error">{{ mergeError }}</p>
</v-card-text>
<v-card-actions class="merge-dialog-actions">
<v-btn
color="primary"
variant="flat"
block
class="merge-submit-btn"
:loading="mergeLoading"
:disabled="mergeLoading || mergeAmount <= 0"
@click="submitMerge"
>
{{ t('trade.mergeSubmitBtn') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Split dialog对接 /PmMarket/split -->
<v-dialog
v-model="splitDialogOpen"
max-width="440"
persistent
content-class="split-dialog"
transition="dialog-transition"
>
<v-card class="split-dialog-card" rounded="lg" elevation="0">
<div class="split-dialog-header">
<h3 class="split-dialog-title">{{ t('trade.splitDialogTitle') }}</h3>
<v-btn
icon
variant="text"
size="small"
class="split-dialog-close"
@click="splitDialogOpen = false"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text class="split-dialog-body">
<p class="split-dialog-desc">
{{ t('trade.splitDialogDesc', { yesLabel, noLabel }) }}
</p>
<div class="split-amount-row">
<label class="split-amount-label">{{ t('trade.splitAmountLabel') }}</label>
<v-text-field
v-model.number="splitAmount"
type="number"
min="0"
step="1"
hide-details
density="compact"
variant="outlined"
class="split-amount-input"
/>
</div>
<p v-if="!props.market?.marketId" class="split-no-market">
{{ t('trade.splitNoMarket', { yesLabel, noLabel }) }}
</p>
<p v-if="splitError" class="split-error">{{ splitError }}</p>
</v-card-text>
<v-card-actions class="split-dialog-actions">
<v-btn
color="primary"
variant="flat"
block
class="split-submit-btn"
:loading="splitLoading"
:disabled="splitLoading || splitAmount <= 0"
@click="submitSplit"
>
{{ t('trade.splitSubmitBtn') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast'
import { pmMarketMerge, pmMarketSplit, pmOrderPlace } from '../api/market'
import { OrderType, Side } from '../api/constants'
const { mobile } = useDisplay()
const { t } = useI18n()
const userStore = useUserStore()
const toastStore = useToastStore()
/** 限价单允许的 135 个价格档位01 区间规则19/1090/1009900/99109990/99919999 */
function buildAllowedLimitPrices(): number[] {
const list: number[] = []
for (let i = 1; i <= 9; i++) list.push(i / 10000)
for (let i = 10; i <= 90; i += 10) list.push(i / 10000)
for (let i = 100; i <= 9900; i += 100) list.push(i / 10000)
for (let i = 9910; i <= 9990; i += 10) list.push(i / 10000)
for (let i = 9991; i <= 9999; i++) list.push(i / 10000)
return list
}
const ALLOWED_LIMIT_PRICES = buildAllowedLimitPrices()
/** 将限价吸附到最近的允许档位 */
function snapToAllowedPrice(v: number): number {
const clamped = Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0))
const list = ALLOWED_LIMIT_PRICES
if (list.length === 0) return clamped
let nearest = list[0] as number
let minDiff = Math.abs(clamped - nearest)
for (const p of list) {
const d = Math.abs(clamped - p)
if (d < minDiff) {
minDiff = d
nearest = p
}
}
return nearest
}
/** 获取当前价格在允许列表中的索引,-1 表示不在列表中 */
function indexOfAllowedPrice(v: number): number {
const snapped = snapToAllowedPrice(v)
const idx = ALLOWED_LIMIT_PRICES.findIndex((p) => Math.abs(p - snapped) < 1e-9)
return idx >= 0 ? idx : 0
}
export interface TradeMarketPayload {
marketId?: string
yesPrice: number
noPrice: number
title?: string
/** 与 outcomes/outcomePrices 顺序一致,用于下单 tokenId0=Yes 1=No */
clobTokenIds?: string[]
/** 选项展示文案,如 ["Yes","No"] 或 ["Up","Down"],用于 Buy Yes/No 按钮文字 */
outcomes?: string[]
}
/** 持仓展示项(由父组件传入,用于计算可合并份额) */
export interface TradePositionItem {
id: string
/** 方向Yes | No */
outcomeWord: 'Yes' | 'No'
/** 份额数(如 "5 shares" */
shares: string
/** 份数数值(纯数字) */
sharesNum?: number
}
const props = withDefaults(
defineProps<{
initialOption?: 'yes' | 'no'
embeddedInSheet?: boolean
/** 从外部传入的市场数据(如 EventMarkets 点击 Yes/No 传入yesPrice/noPrice 为 01 */
market?: TradeMarketPayload
/** 当前市场持仓列表,用于计算可合并份额 */
positions?: TradePositionItem[]
}>(),
{ initialOption: undefined, embeddedInSheet: false, market: undefined, positions: () => [] },
)
// 移动端:底部栏与弹出层
const sheetOpen = ref(false)
// Merge shares dialog对接 /PmMarket/merge
const mergeDialogOpen = ref(false)
const mergeAmount = ref(0)
const mergeLoading = ref(false)
const mergeError = ref('')
/** 计算可合并份额Yes 和 No 持仓的最小值(成对数量) */
const availableMergeShares = computed(() => {
const positions = props.positions ?? []
let yesShares = 0
let noShares = 0
for (const pos of positions) {
const num = pos.sharesNum ?? parseFloat(pos.shares?.replace(/[^0-9.]/g, ''))
if (!Number.isFinite(num) || num <= 0) continue
if (pos.outcomeWord === 'Yes') yesShares += num
else if (pos.outcomeWord === 'No') noShares += num
}
return Math.floor(Math.min(yesShares, noShares))
})
function openMergeDialog() {
mergeAmount.value = 0
mergeError.value = ''
mergeDialogOpen.value = true
}
function setMergeMax() {
mergeAmount.value = availableMergeShares.value
}
async function submitMerge() {
const marketId = props.market?.marketId
if (mergeAmount.value <= 0) return
if (!marketId) return
mergeLoading.value = true
mergeError.value = ''
try {
const res = await pmMarketMerge(
{ marketID: marketId, amount: String(mergeAmount.value) },
{ headers: userStore.getAuthHeaders() },
)
if (res.code === 0 || res.code === 200) {
mergeDialogOpen.value = false
userStore.fetchUsdcBalance()
emit('mergeSuccess')
} else {
mergeError.value = res.msg || 'Merge failed'
}
} catch (e) {
mergeError.value = e instanceof Error ? e.message : 'Request failed'
} finally {
mergeLoading.value = false
}
}
// Split dialog对接测试服务器 /PmMarket/split
const splitDialogOpen = ref(false)
const splitAmount = ref(0)
const splitLoading = ref(false)
const splitError = ref('')
function openSplitDialog() {
splitAmount.value = splitAmount.value > 0 ? splitAmount.value : 1
splitError.value = ''
splitDialogOpen.value = true
}
async function submitSplit() {
const marketId = props.market?.marketId
if (splitAmount.value <= 0) return
if (!marketId) return // 无市场时仅依赖模板中的 split-no-market 提示,不重复设置 splitError
splitLoading.value = true
splitError.value = ''
try {
const res = await pmMarketSplit(
{ marketID: marketId, usdcAmount: String(splitAmount.value) },
{ headers: userStore.getAuthHeaders() },
)
if (res.code === 0 || res.code === 200) {
splitDialogOpen.value = false
toastStore.show(t('toast.splitSuccess'))
emit('splitSuccess')
} else {
splitError.value = res.msg || 'Split failed'
}
} catch (e) {
splitError.value = e instanceof Error ? e.message : 'Request failed'
} finally {
splitLoading.value = false
}
}
defineExpose({ openMergeDialog, openSplitDialog })
const yesPriceCents = computed(() => (props.market ? Math.round(props.market.yesPrice * 100) : 19))
const noPriceCents = computed(() => (props.market ? Math.round(props.market.noPrice * 100) : 82))
const yesLabel = computed(() => props.market?.outcomes?.[0] ?? 'Yes')
const noLabel = computed(() => props.market?.outcomes?.[1] ?? 'No')
function openSheet(option: 'yes' | 'no') {
handleOptionChange(option)
sheetOpen.value = true
}
// State
const activeTab = ref('buy')
const limitType = ref('Limit')
const expirationEnabled = ref(false)
const selectedOption = ref<'yes' | 'no'>(props.initialOption ?? 'no')
const limitPrice = ref(0.82) // 内部存储 01显示为美分与按钮一致
const shares = ref(20) // 初始份额(正整数)
const expirationTime = ref('5m') // 初始过期时间
const EXPIRATION_VALUES = ['5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d'] as const
const expirationOptions = computed(() =>
EXPIRATION_VALUES.map((v) => ({ title: t(`trade.expiration.${v}`), value: v })),
)
// Market mode state
const isMarketMode = computed(() => limitType.value === 'Market')
const amount = ref(0) // Market mode amount
/** 余额(从 userStore 同步) */
const balance = computed(() => {
const s = userStore.balance
const n = parseFloat(String(s ?? '0'))
return Number.isFinite(n) ? n : 0
})
const orderLoading = ref(false)
const orderError = ref('')
// Emits
const emit = defineEmits<{
optionChange: [option: 'yes' | 'no']
orderSuccess: []
mergeSuccess: []
splitSuccess: []
submit: [
payload: {
side: 'buy' | 'sell'
option: 'yes' | 'no'
limitPrice: number
shares: number
expirationEnabled: boolean
expirationTime: string
marketId?: string
},
]
}>()
// Computed properties
/** Limit Price 显示值(美分),与 Yes/No 按钮单位一致 */
const limitPriceCentsDisplay = computed(() => Math.round(limitPrice.value * 10000) / 100)
const currentPrice = computed(() => {
return `${(limitPrice.value * 100).toFixed(0)}¢`
})
const totalPrice = computed(() => {
return (limitPrice.value * shares.value).toFixed(2)
})
/** Sell 模式下的平均单价(¢) */
const avgPriceCents = computed(() => {
const p = limitPrice.value
return (p * 100).toFixed(1)
})
/** To win = 份数 × 1U = shares × 1 USDC */
const toWinValue = computed(() => {
if (isMarketMode.value) {
const price =
selectedOption.value === 'yes'
? (props.market?.yesPrice ?? 0.5)
: (props.market?.noPrice ?? 0.5)
const sharesFromAmount = price > 0 ? amount.value / price : 0
return sharesFromAmount.toFixed(2)
}
return (shares.value * 1).toFixed(2)
})
const limitTypeDisplay = computed(() =>
limitType.value === 'Market' ? t('trade.market') : t('trade.limit'),
)
const actionButtonText = computed(() => {
const label = selectedOption.value === 'yes' ? yesLabel.value : noLabel.value
const tab = activeTab.value
return tab === 'buy' ? t('trade.buyLabel', { label }) : t('trade.sellLabel', { label })
})
function applyInitialOption(option: 'yes' | 'no') {
selectedOption.value = option
syncLimitPriceFromMarket()
}
/** 限价限制到 [0,1] 并吸附到 135 个允许档位之一 */
function clampLimitPrice(v: number): number {
return snapToAllowedPrice(Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0)))
}
/** 根据当前 props.market 与 selectedOption 同步 limitPrice组件显示或 market 更新时调用) */
function syncLimitPriceFromMarket() {
const yesP = props.market?.yesPrice ?? 0.19
const noP = props.market?.noPrice ?? 0.82
limitPrice.value = clampLimitPrice(selectedOption.value === 'yes' ? yesP : noP)
}
onMounted(() => {
if (props.initialOption) applyInitialOption(props.initialOption)
else if (props.market) syncLimitPriceFromMarket()
})
watch(
() => props.initialOption,
(option) => {
if (option) applyInitialOption(option)
},
{ immediate: true },
)
watch(
() => props.market,
(m) => {
if (m) {
orderError.value = ''
if (props.initialOption) applyInitialOption(props.initialOption)
else syncLimitPriceFromMarket()
}
},
{ deep: true },
)
// Methods
const handleOptionChange = (option: 'yes' | 'no') => {
selectedOption.value = option
const yesP = props.market?.yesPrice ?? 0.19
const noP = props.market?.noPrice ?? 0.82
limitPrice.value = clampLimitPrice(option === 'yes' ? yesP : noP)
emit('optionChange', option)
}
/** 输入为美分0100与 Yes/No 按钮单位一致 */
function onLimitPriceInput(v: unknown) {
const num = v == null ? NaN : Number(v)
if (!Number.isFinite(num) || num < 0 || num > 100) return
limitPrice.value = snapToAllowedPrice(num / 100)
}
/** 失焦时吸附到允许档位 */
function onLimitPriceBlur() {
limitPrice.value = snapToAllowedPrice(limitPrice.value)
}
/** 只允许数字和小数点输入(美分 0100 */
function onLimitPriceKeydown(e: KeyboardEvent) {
const key = e.key
const allowed = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
if (allowed.includes(key)) return
if (e.ctrlKey || e.metaKey) {
if (['a', 'c', 'v', 'x'].includes(key.toLowerCase())) return
}
if (key >= '0' && key <= '9') return
if (key === '.' && !String((e.target as HTMLInputElement)?.value ?? '').includes('.')) return
e.preventDefault()
}
/** 粘贴时只接受有效美分数0100 */
function onLimitPricePaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text') ?? ''
const num = parseFloat(text)
if (!Number.isFinite(num) || num < 0 || num > 100) {
e.preventDefault()
}
}
// 限价调整方法:在 135 个允许档位间上下切换
const decreasePrice = () => {
const idx = indexOfAllowedPrice(limitPrice.value)
const nextIdx = Math.max(0, idx - 1)
limitPrice.value = ALLOWED_LIMIT_PRICES[nextIdx] ?? limitPrice.value
}
const increasePrice = () => {
const idx = indexOfAllowedPrice(limitPrice.value)
const nextIdx = Math.min(ALLOWED_LIMIT_PRICES.length - 1, idx + 1)
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 */
function onSharesInput(v: unknown) {
const num = v == null ? NaN : Number(v)
const n = Math.floor(num)
if (!Number.isFinite(num) || n < 1 || num !== n) return
shares.value = n
}
/** 只允许数字输入Shares 为正整数) */
function onSharesKeydown(e: KeyboardEvent) {
const key = e.key
const allowed = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End']
if (allowed.includes(key)) return
if (e.ctrlKey || e.metaKey) {
if (['a', 'c', 'v', 'x'].includes(key.toLowerCase())) return
}
if (key >= '0' && key <= '9') return
e.preventDefault()
}
/** 粘贴时只接受正整数 */
function onSharesPaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text') ?? ''
const num = parseInt(text, 10)
if (!Number.isFinite(num) || num < 1) e.preventDefault()
}
// 份额调整方法(保证正整数)
const adjustShares = (amount: number) => {
shares.value = clampShares(shares.value + amount)
}
// 份额百分比调整方法仅在Sell模式下使用
const setSharesPercentage = (percentage: number) => {
const maxShares = 100
shares.value = clampShares(Math.round((maxShares * percentage) / 100))
}
// Market mode methods
const adjustAmount = (value: number) => {
amount.value += value
if (amount.value < 0) amount.value = 0
}
const setMaxAmount = () => {
amount.value = balance.value
}
/** Buy 模式:余额是否足够(>= 所需金额且不为 0。costbalance>0 时用 totalPrice否则用 amount */
const canAffordBuy = computed(() => {
const bal = balance.value
if (bal <= 0) return false
const cost = bal > 0 ? parseFloat(totalPrice.value) || 0 : amount.value
return bal >= cost
})
const showDepositForBuy = computed(() => !canAffordBuy.value)
/** Buy 模式且余额不足时显示 Deposit否则显示 Buy Yes/No */
const deposit = () => {
// 实际应用中这里应该调用存款API
}
/** 将 expirationTime 如 "5m" 转为 Unix 秒级时间戳GTD 用0 表示无过期 */
function parseExpirationTimestamp(expTime: string): number {
const m = /^(\d+)(m|h|d)$/i.exec(expTime)
if (!m) return 0
const [, num, unit] = m
const n = parseInt(num ?? '0', 10)
if (!Number.isFinite(n) || n <= 0) return 0
const now = Date.now()
let ms = n
if (unit?.toLowerCase() === 'm') ms = n * 60 * 1000
else if (unit?.toLowerCase() === 'h') ms = n * 3600 * 1000
else if (unit?.toLowerCase() === 'd') ms = n * 86400 * 1000
return Math.floor((now + ms) / 1000)
}
// 提交订单(含 Set expiration对接 /clob/gateway/submitOrder
async function submitOrder() {
const marketId = props.market?.marketId
const clobIds = props.market?.clobTokenIds
const outcomeIndex = selectedOption.value === 'yes' ? 0 : 1
const tokenId = clobIds?.[outcomeIndex]
const payload = {
side: activeTab.value as 'buy' | 'sell',
option: selectedOption.value,
limitPrice: limitPrice.value,
shares: shares.value,
expirationEnabled: expirationEnabled.value,
expirationTime: expirationTime.value,
...(marketId != null && { marketId }),
}
emit('submit', payload)
if (!tokenId) {
orderError.value = t('trade.pleaseSelectMarket')
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
orderError.value = t('trade.pleaseLogin')
return
}
const uid = userStore?.user?.ID ?? 0
const userIdNum =
typeof uid === 'number'
? uid
: uid != null
? parseInt(String(uid), 10)
: 0
if (!Number.isFinite(userIdNum) || userIdNum <= 0) {
orderError.value = t('trade.userError')
return
}
const isMarket = limitType.value === 'Market'
const orderTypeNum = isMarket
? OrderType.Market
: expirationEnabled.value
? OrderType.GTD
: OrderType.GTC
const sideNum = activeTab.value === 'buy' ? Side.Buy : Side.Sell
const expiration =
orderTypeNum === OrderType.GTD && expirationEnabled.value
? parseExpirationTimestamp(expirationTime.value)
: 0
orderLoading.value = true
orderError.value = ''
try {
const res = await pmOrderPlace(
{
expiration,
feeRateBps: 0,
nonce: 0,
orderType: orderTypeNum,
price: limitPrice.value,
side: sideNum,
size: clampShares(shares.value),
taker: true,
tokenID: tokenId,
userID: userIdNum,
},
{ headers },
)
if (res.code === 0 || res.code === 200) {
userStore.fetchUsdcBalance()
emit('orderSuccess')
} else {
orderError.value = res.msg || t('trade.orderFailed')
}
} catch (e) {
orderError.value = e instanceof Error ? e.message : t('error.requestFailed')
} finally {
orderLoading.value = false
}
}
</script>
<style scoped>
/* 扁平化:移除所有阴影 */
.trade-component,
.trade-sheet-paper,
.merge-dialog-card,
.split-dialog-card {
box-shadow: none !important;
}
.trade-component :deep(.v-btn),
.trade-component :deep(.v-btn::before),
.trade-sheet-inner :deep(.v-btn),
.trade-sheet-inner :deep(.v-btn::before),
.merge-dialog-card .v-btn,
.merge-dialog-card .v-btn::before,
.split-dialog-card .v-btn,
.split-dialog-card .v-btn::before {
box-shadow: none !important;
}
.trade-component :deep(.v-field),
.trade-sheet-inner :deep(.v-field),
.merge-dialog-card :deep(.v-field),
.split-dialog-card :deep(.v-field) {
box-shadow: none !important;
}
.trade-component {
width: 100%;
max-width: 400px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e7e7e7;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid #e0e0e0;
}
.buy-sell-tabs {
flex: 1;
}
.minimal-tabs {
--tabs-height: 32px !important;
margin: 0 !important;
}
.minimal-tab {
height: 32px !important;
min-height: 32px !important;
padding: 4px 12px !important;
font-size: 14px !important;
}
/* 移除了.tab-btn样式使用v-tabs的默认样式 */
.limit-btn {
font-size: 14px;
text-transform: none;
color: #666666;
box-shadow: none !important;
}
.more-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.price-options {
display: flex;
gap: 10px;
padding: 12px;
}
.yes-btn {
flex: 1;
background-color: #f0f0f0;
border-radius: 4px;
text-transform: none;
font-size: 14px;
box-shadow: none !important;
}
.yes-btn.active {
background-color: #b8e0b8;
color: #006600;
}
.no-btn {
flex: 1;
background-color: #f0f0f0;
color: #000000;
border-radius: 4px;
text-transform: none;
font-size: 14px;
box-shadow: none !important;
}
.no-btn.active {
background-color: #f0b8b8;
color: #cc0000;
}
.input-group {
padding: 12px;
border-top: 1px solid #e0e0e0;
}
/* Reduce height for Set Expiration section */
.expiration-group {
padding: 8px 12px !important;
margin: 0 !important;
}
/* Limit Price header flex layout */
.limit-price-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
/* Adjust label margin for flex layout */
.limit-price-header .label {
margin-bottom: 0;
flex-shrink: 0;
}
/* Adjust price input width for flex layout */
.limit-price-header .price-input {
flex: 1;
max-width: 200px;
margin-left: 12px;
}
.label {
font-size: 14px;
color: #666666;
margin-bottom: 6px;
display: block;
}
.price-input {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #f8f8f8;
border-radius: 4px;
padding: 6px 10px;
}
.adjust-btn {
color: #666666;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
}
.price-value {
font-size: 15px;
font-weight: 500;
}
.price-input-field {
flex: 1;
text-align: center;
font-size: 15px;
font-weight: 500;
background-color: transparent;
border: none;
box-shadow: none;
max-width: 100px;
}
/* Shares header flex layout */
.shares-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 8px;
}
/* Adjust label margin for flex layout */
.shares-header .label {
margin-bottom: 0;
flex-shrink: 0;
}
/* Adjust shares input width for flex layout */
.shares-header .shares-input {
flex: 1;
max-width: 200px;
margin-left: 12px;
text-align: right;
}
.shares-input {
background-color: #f8f8f8;
border-radius: 4px;
margin-bottom: 8px;
}
.shares-value {
font-size: 15px;
font-weight: 500;
text-align: right;
}
.shares-input-field {
width: 100%;
text-align: right;
font-size: 15px;
font-weight: 500;
background-color: transparent;
border: none;
box-shadow: none;
}
.shares-buttons {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.share-btn {
flex: 1;
background-color: #f0f0f0;
border-radius: 4px;
text-transform: none;
font-size: 12px;
padding: 4px;
box-shadow: none !important;
}
.matching-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #3d8b40;
}
.avg-price-row {
font-size: 12px;
color: #666;
}
.avg-price-row .info-icon {
margin-left: 4px;
vertical-align: middle;
opacity: 0.7;
}
.expiration-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.expiration-switch {
margin: 0;
}
.expiration-select {
margin-top: 8px;
width: 100%;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.expiration-select :deep(.v-field) {
box-shadow: none !important;
}
.total-section {
padding: 12px;
border-top: 1px solid #e0e0e0;
border-bottom: 1px solid #e0e0e0;
}
.total-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.total-value {
font-size: 15px;
font-weight: 500;
color: #0066cc;
}
.to-win-value {
font-size: 15px;
font-weight: 500;
color: #3d8b40;
}
.action-btn {
width: 100%;
background-color: #0066cc;
color: #ffffff;
border-radius: 6px;
padding: 16px;
font-size: 15px;
font-weight: 500;
text-transform: none;
box-shadow: none !important;
}
/* Market Mode Styles */
.amount-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 12px;
}
.amount-label {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.balance-label {
font-size: 12px;
color: #999999;
display: block;
}
.amount-value {
font-size: 24px;
font-weight: 500;
color: #000000;
}
.amount-buttons {
display: flex;
gap: 8px;
justify-content: space-between;
}
.amount-btn {
flex: 1;
background-color: #f0f0f0;
border-radius: 4px;
text-transform: none;
font-size: 14px;
padding: 8px;
box-shadow: none !important;
}
.amount-to-win-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eee;
}
.deposit-btn {
width: 100%;
background-color: #0066cc;
color: #ffffff;
border-radius: 6px;
padding: 16px;
font-size: 15px;
font-weight: 500;
text-transform: none;
box-shadow: none !important;
}
.deposit-btn--secondary {
margin-top: 8px;
background-color: transparent;
color: #0066cc;
border: 1px solid #0066cc;
}
/* 移动端底部交易栏(红框样式) */
.mobile-trade-bar-spacer {
height: 72px;
}
.mobile-trade-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: stretch;
gap: 8px;
width: 100%;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #eee;
}
.mobile-bar-btn {
border: none;
border-radius: 9999px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
}
.mobile-bar-yes {
flex: 1;
min-width: 0;
background: #16a34a;
color: #fff;
padding: 14px 16px;
}
.mobile-bar-no {
flex: 1;
min-width: 0;
background: #dc2626;
color: #fff;
padding: 14px 16px;
}
.trade-sheet-paper {
padding: 0;
max-height: 85vh;
overflow-y: auto;
}
.trade-sheet-inner {
box-shadow: none;
max-width: 100%;
}
/* 手机端底部弹层内不显示Limit 下拉、Yes/No 按钮 */
.trade-sheet-inner .hide-in-mobile-sheet {
display: none !important;
}
/* 响应式调整 - 小屏幕设备 */
@media (max-width: 600px) {
.header {
padding: 10px;
}
.price-options {
padding: 10px;
}
.input-group {
padding: 10px;
}
.total-section {
padding: 10px;
}
.label {
font-size: 13px;
margin-bottom: 5px;
}
.price-value,
.shares-value,
.total-value,
.to-win-value {
font-size: 14px;
}
.action-btn {
padding: 15px;
font-size: 14px;
}
.share-btn {
font-size: 11px;
padding: 3px;
}
.amount-label {
font-size: 14px;
}
.amount-value {
font-size: 20px;
}
.amount-btn {
font-size: 13px;
padding: 6px;
}
.deposit-btn {
padding: 15px;
font-size: 14px;
}
}
/* Merge shares dialog */
.merge-dialog-card {
padding: 0;
overflow: hidden;
box-shadow: none !important;
}
.merge-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 20px 0;
}
.merge-dialog-title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0;
}
.merge-dialog-close {
color: #6b7280;
}
.merge-dialog-body {
padding: 16px 20px 8px;
}
.merge-dialog-desc {
font-size: 14px;
color: #374151;
line-height: 1.5;
margin: 0 0 20px;
}
.merge-amount-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.merge-amount-label {
font-size: 14px;
font-weight: 600;
color: #111827;
min-width: 60px;
}
.merge-amount-input {
flex: 1;
max-width: 160px;
}
.merge-amount-input :deep(.v-field) {
font-size: 14px;
}
.merge-available {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.merge-max-link {
color: #2563eb;
background: none;
border: none;
cursor: pointer;
padding: 0;
margin-left: 4px;
font-size: 13px;
}
.merge-max-link:hover {
text-decoration: underline;
}
.merge-no-market,
.merge-error {
font-size: 13px;
margin: 8px 0 0;
}
.merge-no-market {
color: #6b7280;
}
.merge-error {
color: #dc2626;
}
.order-error {
font-size: 13px;
color: #dc2626;
margin: 8px 0 0;
}
.merge-dialog-actions {
padding: 16px 20px 20px;
padding-top: 8px;
}
.merge-submit-btn {
text-transform: none;
font-weight: 600;
box-shadow: none !important;
}
/* Split dialog */
.split-dialog-card {
padding: 0;
overflow: hidden;
box-shadow: none !important;
}
.split-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 0;
}
.split-dialog-title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0;
}
.split-dialog-close {
color: #6b7280;
}
.split-dialog-body {
padding: 16px 20px 8px;
}
.split-dialog-desc {
font-size: 14px;
color: #374151;
line-height: 1.5;
margin: 0 0 16px 0;
}
.split-amount-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.split-amount-label {
font-size: 14px;
font-weight: 600;
color: #111827;
flex-shrink: 0;
}
.split-amount-input {
flex: 1;
max-width: 160px;
}
.split-amount-input :deep(.v-field) {
font-size: 14px;
}
.split-no-market,
.split-error {
font-size: 13px;
margin: 8px 0 0;
}
.split-no-market {
color: #6b7280;
}
.split-error {
color: #dc2626;
}
.split-dialog-actions {
padding: 16px 20px 20px;
padding-top: 8px;
}
.split-submit-btn {
text-transform: none;
font-weight: 600;
box-shadow: none !important;
}
</style>