2917 lines
92 KiB
Vue
2917 lines
92 KiB
Vue
<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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>
|
||
{{ yesLabel }} {{ yesPriceCents }}¢
|
||
</v-btn>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@click="handleOptionChange('no')"
|
||
>
|
||
{{ noLabel }} {{ noPriceCents }}¢
|
||
</v-btn>
|
||
</div>
|
||
|
||
<!-- Buy Market: Amount 区(余额充足时也显示) -->
|
||
<template v-if="activeTab === 'buy'">
|
||
<div class="input-group shares-group">
|
||
<div class="shares-header sell-shares-header">
|
||
<span class="label">{{ t('trade.amount') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="amount"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
prefix="$"
|
||
@update:model-value="onAmountInput"
|
||
@keydown="onAmountKeydown"
|
||
@paste="onAmountPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-shares-buttons">
|
||
<v-btn class="share-btn" @click="adjustAmount(1)">+$1</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(20)">+$20</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(100)">+$100</v-btn>
|
||
<v-btn class="share-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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-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="setMaxShares">{{ 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 v-if="!isMarketMode" 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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>
|
||
{{ yesLabel }} {{ yesPriceCents }}¢
|
||
</v-btn>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@click="handleOptionChange('no')"
|
||
>
|
||
{{ noLabel }} {{ noPriceCents }}¢
|
||
</v-btn>
|
||
</div>
|
||
|
||
<!-- Buy: Amount Section -->
|
||
<template v-if="activeTab === 'buy'">
|
||
<div class="input-group shares-group">
|
||
<div class="shares-header sell-shares-header">
|
||
<span class="label">{{ t('trade.amount') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="amount"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
prefix="$"
|
||
@update:model-value="onAmountInput"
|
||
@keydown="onAmountKeydown"
|
||
@paste="onAmountPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-shares-buttons">
|
||
<v-btn class="share-btn" @click="adjustAmount(1)">+$1</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(20)">+$20</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(100)">+$100</v-btn>
|
||
<v-btn class="share-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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
variant="outlined"
|
||
class="shares-input-field"
|
||
hide-details
|
||
density="compact"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-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="setMaxShares">{{ 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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>
|
||
{{ yesLabel }} {{ yesPriceCents }}¢
|
||
</v-btn>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@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="1"
|
||
variant="outlined"
|
||
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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span v-if="activeTab === 'sell'" class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
variant="outlined"
|
||
class="shares-input-field"
|
||
hide-details
|
||
density="compact"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</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 sell-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="setMaxShares">{{ t('trade.max') }}</v-btn>
|
||
</div>
|
||
<p v-if="sellSharesExceedsMax" class="shares-exceeds-max-hint">
|
||
{{ t('trade.sharesExceedsMax', { max: maxAvailableShares }) }}
|
||
</p>
|
||
<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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||
>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@click="handleOptionChange('no')"
|
||
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
|
||
>
|
||
</div>
|
||
<!-- Buy Market: Amount 区(余额充足时也显示) -->
|
||
<template v-if="activeTab === 'buy'">
|
||
<div class="input-group shares-group">
|
||
<div class="shares-header sell-shares-header">
|
||
<span class="label">{{ t('trade.amount') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="amount"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
prefix="$"
|
||
@update:model-value="onAmountInput"
|
||
@keydown="onAmountKeydown"
|
||
@paste="onAmountPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-shares-buttons">
|
||
<v-btn class="share-btn" @click="adjustAmount(1)">+$1</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(20)">+$20</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(100)">+$100</v-btn>
|
||
<v-btn class="share-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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
density="compact"
|
||
variant="outlined"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-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="setMaxShares">{{ t('trade.max') }}</v-btn>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div class="total-section">
|
||
<template v-if="activeTab === 'buy'">
|
||
<div v-if="!isMarketMode" 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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||
>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@click="handleOptionChange('no')"
|
||
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
|
||
>
|
||
</div>
|
||
<!-- Buy: Amount Section -->
|
||
<template v-if="activeTab === 'buy'">
|
||
<div class="input-group shares-group">
|
||
<div class="shares-header sell-shares-header">
|
||
<span class="label">{{ t('trade.amount') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="amount"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
prefix="$"
|
||
@update:model-value="onAmountInput"
|
||
@keydown="onAmountKeydown"
|
||
@paste="onAmountPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-shares-buttons">
|
||
<v-btn class="share-btn" @click="adjustAmount(1)">+$1</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(20)">+$20</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(100)">+$100</v-btn>
|
||
<v-btn class="share-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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
density="compact"
|
||
variant="outlined"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-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="setMaxShares">{{ 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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||
>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@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="1"
|
||
variant="outlined"
|
||
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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span v-if="activeTab === 'sell'" class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
density="compact"
|
||
variant="outlined"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</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 sell-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="setMaxShares">{{ 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
|
||
:title="`${t('trade.buyLabel', { label: yesLabel })} ${yesPriceCents}¢`"
|
||
@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
|
||
:title="`${t('trade.buyLabel', { label: noLabel })} ${noPriceCents}¢`"
|
||
@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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||
>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@click="handleOptionChange('no')"
|
||
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
|
||
>
|
||
</div>
|
||
<!-- Buy Market: Amount 区(余额充足时也显示) -->
|
||
<template v-if="activeTab === 'buy'">
|
||
<div class="input-group shares-group">
|
||
<div class="shares-header sell-shares-header">
|
||
<span class="label">{{ t('trade.amount') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="amount"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
prefix="$"
|
||
@update:model-value="onAmountInput"
|
||
@keydown="onAmountKeydown"
|
||
@paste="onAmountPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-shares-buttons">
|
||
<v-btn class="share-btn" @click="adjustAmount(1)">+$1</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(20)">+$20</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(100)">+$100</v-btn>
|
||
<v-btn class="share-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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
density="compact"
|
||
variant="outlined"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-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="setMaxShares">{{ t('trade.max') }}</v-btn>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div class="total-section">
|
||
<template v-if="activeTab === 'buy'">
|
||
<div v-if="!isMarketMode" 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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||
>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@click="handleOptionChange('no')"
|
||
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
|
||
>
|
||
</div>
|
||
<!-- Buy: Amount Section -->
|
||
<template v-if="activeTab === 'buy'">
|
||
<div class="input-group shares-group">
|
||
<div class="shares-header sell-shares-header">
|
||
<span class="label">{{ t('trade.amount') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.balanceLabel') }} ${{ balance.toFixed(2) }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="amount"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
prefix="$"
|
||
@update:model-value="onAmountInput"
|
||
@keydown="onAmountKeydown"
|
||
@paste="onAmountPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-shares-buttons">
|
||
<v-btn class="share-btn" @click="adjustAmount(1)">+$1</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(20)">+$20</v-btn>
|
||
<v-btn class="share-btn" @click="adjustAmount(100)">+$100</v-btn>
|
||
<v-btn class="share-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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
variant="outlined"
|
||
density="compact"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</div>
|
||
<div class="shares-buttons sell-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="setMaxShares">{{ 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
|
||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||
@click="handleOptionChange('yes')"
|
||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||
>
|
||
<v-btn
|
||
class="no-btn"
|
||
:class="{ active: selectedOption === 'no' }"
|
||
text
|
||
:title="`${noLabel} ${noPriceCents}¢`"
|
||
@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="1"
|
||
class="price-input-field"
|
||
hide-details
|
||
density="compact"
|
||
variant="outlined"
|
||
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 sell-shares-header">
|
||
<span class="label">{{ t('trade.shares') }}</span>
|
||
<span v-if="activeTab === 'sell'" class="max-shares-inline"
|
||
>{{ t('trade.maxShares') }}: {{ maxAvailableShares }}</span
|
||
>
|
||
</div>
|
||
<div class="shares-input-wrapper">
|
||
<v-text-field
|
||
:model-value="shares"
|
||
type="number"
|
||
min="1"
|
||
class="shares-input-field"
|
||
hide-details
|
||
density="compact"
|
||
variant="outlined"
|
||
@update:model-value="onSharesInput"
|
||
@keydown="onSharesKeydown"
|
||
@paste="onSharesPaste"
|
||
></v-text-field>
|
||
</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 sell-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="setMaxShares">{{ 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 { useAuthError } from '../composables/useAuthError'
|
||
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()
|
||
const { formatAuthError } = useAuthError()
|
||
|
||
/** 限价单允许的 135 个价格档位(0–1 区间),规则:1–9/10–90/100–9900/9910–9990/9991–9999 */
|
||
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 顺序一致,用于下单 tokenId:0=Yes 1=No */
|
||
clobTokenIds?: string[]
|
||
/** 选项展示文案,如 ["Yes","No"] 或 ["Up","Down"],用于 Buy Yes/No 按钮文字 */
|
||
outcomes?: string[]
|
||
/** 订单簿 Yes 买单最高价(美分),市价卖出时用于计算将收到金额 */
|
||
bestBidYesCents?: number
|
||
/** 订单簿 No 买单最高价(美分),市价卖出时用于计算将收到金额 */
|
||
bestBidNoCents?: number
|
||
}
|
||
|
||
/** 持仓展示项(由父组件传入,用于计算可合并份额) */
|
||
export interface TradePositionItem {
|
||
id: string
|
||
/** 方向:Yes | No */
|
||
outcomeWord: 'Yes' | 'No'
|
||
/** 份额数(如 "5 shares") */
|
||
shares: string
|
||
/** 份数数值(纯数字) */
|
||
sharesNum?: number
|
||
/** 是否锁单,锁单不可卖出 */
|
||
locked?: boolean
|
||
}
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
initialOption?: 'yes' | 'no'
|
||
/** 初始选中的 Tab:buy / sell(如从持仓 Sell 打开时传 'sell') */
|
||
initialTab?: 'buy' | 'sell'
|
||
embeddedInSheet?: boolean
|
||
/** 从外部传入的市场数据(如 EventMarkets 点击 Yes/No 传入),yesPrice/noPrice 为 0–1 */
|
||
market?: TradeMarketPayload
|
||
/** 当前市场持仓列表,用于计算可合并份额 */
|
||
positions?: TradePositionItem[]
|
||
}>(),
|
||
{
|
||
initialOption: undefined,
|
||
initialTab: 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: (mergeAmount.value * 1000000).toFixed(0) },
|
||
{ 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 = formatAuthError(e, '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: (splitAmount.value * 1000000).toFixed(0) },
|
||
{ 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 = formatAuthError(e, '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
|
||
}
|
||
|
||
// 持久化:记住用户上次的买入/卖出、市价/限价选择
|
||
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,显示为美分与按钮一致
|
||
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('')
|
||
/** 当前 orderError 是否为「无可用份额可卖」(用于仅清除该提示而不清除其他错误) */
|
||
const isNoAvailableSharesError = ref(false)
|
||
|
||
// 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 按钮单位一致 */
|
||
/** 去掉小数点后多余的 0(如 1.00 → 1,0.10 → 0.1) */
|
||
function trimTrailingZeros(s: string): string {
|
||
return s.replace(/\.?0+$/, '')
|
||
}
|
||
|
||
const limitPriceCentsDisplay = computed(() => Math.round(limitPrice.value * 10000) / 100)
|
||
const currentPrice = computed(() => {
|
||
return `${trimTrailingZeros((limitPrice.value * 100).toFixed(0))}¢`
|
||
})
|
||
|
||
const totalPrice = computed(() => {
|
||
let raw: number
|
||
if (activeTab.value === 'sell' && limitType.value === 'Market') {
|
||
const bestBidCents =
|
||
selectedOption.value === 'yes'
|
||
? (props.market?.bestBidYesCents ?? 0)
|
||
: (props.market?.bestBidNoCents ?? 0)
|
||
raw = (bestBidCents / 100) * shares.value
|
||
} else {
|
||
raw = limitPrice.value * shares.value
|
||
}
|
||
if (!Number.isFinite(raw)) return '0'
|
||
return trimTrailingZeros(raw.toFixed(4))
|
||
})
|
||
|
||
/** Sell 模式下的平均单价(¢) */
|
||
const avgPriceCents = computed(() => {
|
||
const p = limitPrice.value
|
||
return trimTrailingZeros((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 trimTrailingZeros(sharesFromAmount.toFixed(2))
|
||
}
|
||
return trimTrailingZeros((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 })
|
||
})
|
||
|
||
/** 计算当前选中选项的持仓份额 */
|
||
const currentOptionPositionShares = computed(() => {
|
||
const positions = props.positions ?? []
|
||
const currentOutcome = selectedOption.value === 'yes' ? 'Yes' : 'No'
|
||
let totalShares = 0
|
||
|
||
for (const pos of positions) {
|
||
if (pos.outcomeWord === currentOutcome) {
|
||
const num = pos.sharesNum ?? parseFloat(pos.shares?.replace(/[^0-9.]/g, ''))
|
||
if (Number.isFinite(num) && num > 0) {
|
||
totalShares += num
|
||
}
|
||
}
|
||
}
|
||
|
||
return totalShares
|
||
})
|
||
|
||
/** 最大可卖出份额 */
|
||
const maxAvailableShares = computed(() => {
|
||
return Math.floor(currentOptionPositionShares.value)
|
||
})
|
||
|
||
/** 将 shares 限制为正整数(>= 1) */
|
||
function clampShares(v: number): number {
|
||
const n = Math.floor(Number.isFinite(v) ? v : 1)
|
||
return Math.max(1, n)
|
||
}
|
||
|
||
// 设置最大份额(基于当前选项的持仓);Sell 模式下份额默认取最大可卖数量
|
||
const setMaxShares = () => {
|
||
const maxShares = currentOptionPositionShares.value
|
||
shares.value = maxShares > 0 ? clampShares(maxShares) : 0
|
||
}
|
||
|
||
function applyInitialOption(option: 'yes' | 'no') {
|
||
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()
|
||
if (props.initialTab) activeTab.value = props.initialTab
|
||
if (activeTab.value === 'sell') setMaxShares()
|
||
})
|
||
watch(
|
||
() => props.initialOption,
|
||
(option) => {
|
||
if (option) applyInitialOption(option)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
watch(
|
||
() => props.initialTab,
|
||
(tab) => {
|
||
if (tab) activeTab.value = tab
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => props.market,
|
||
(m) => {
|
||
if (m) {
|
||
orderError.value = ''
|
||
isNoAvailableSharesError.value = false
|
||
if (props.initialOption) applyInitialOption(props.initialOption)
|
||
else syncLimitPriceFromMarket()
|
||
}
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
watch(
|
||
() => activeTab.value,
|
||
(newTab) => {
|
||
if (newTab === 'sell') {
|
||
setMaxShares()
|
||
}
|
||
saveTradePrefs()
|
||
},
|
||
)
|
||
/** 卖出时无可卖份额(available 为 0)则显示不可卖提示 */
|
||
watch(
|
||
() => [activeTab.value, maxAvailableShares.value] as const,
|
||
([tab, max]) => {
|
||
if (tab === 'sell') {
|
||
setMaxShares()
|
||
if (!Number.isFinite(max) || max <= 0) {
|
||
orderError.value = t('activity.noAvailableSharesToSell')
|
||
isNoAvailableSharesError.value = true
|
||
} else if (isNoAvailableSharesError.value) {
|
||
orderError.value = ''
|
||
isNoAvailableSharesError.value = false
|
||
}
|
||
} else if (isNoAvailableSharesError.value) {
|
||
orderError.value = ''
|
||
isNoAvailableSharesError.value = false
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
watch(limitType, () => saveTradePrefs(), { flush: 'post' })
|
||
|
||
// Methods
|
||
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)
|
||
|
||
// Set max shares when option changes in sell mode
|
||
if (activeTab.value === 'sell') {
|
||
setMaxShares()
|
||
}
|
||
}
|
||
|
||
/** 输入为美分(0–100),与 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)
|
||
}
|
||
|
||
/** 只允许数字和小数点输入(美分 0–100) */
|
||
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()
|
||
}
|
||
|
||
/** 粘贴时只接受有效美分数(0–100) */
|
||
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 */
|
||
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)
|
||
}
|
||
|
||
/** 卖出时输入份额是否超过最大可卖 */
|
||
const sellSharesExceedsMax = computed(
|
||
() =>
|
||
activeTab.value === 'sell' &&
|
||
maxAvailableShares.value >= 0 &&
|
||
shares.value > maxAvailableShares.value,
|
||
)
|
||
|
||
// 份额百分比调整方法(仅在Sell模式下使用)
|
||
const setSharesPercentage = (percentage: number) => {
|
||
const maxShares = currentOptionPositionShares.value || 100
|
||
shares.value = clampShares(Math.round((maxShares * percentage) / 100))
|
||
}
|
||
|
||
// Market mode methods
|
||
/** 将 amount 限制为非负数 */
|
||
function clampAmount(v: number): number {
|
||
const n = Number.isFinite(v) ? v : 0
|
||
return Math.max(0, n)
|
||
}
|
||
|
||
/** 处理 amount 输入(允许小数,>= 0) */
|
||
function onAmountInput(v: unknown) {
|
||
const num = v == null ? NaN : Number(v)
|
||
if (!Number.isFinite(num) || num < 0) return
|
||
amount.value = num
|
||
}
|
||
|
||
/** 只允许数字和小数点输入(Amount 非负) */
|
||
function onAmountKeydown(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()
|
||
}
|
||
|
||
/** 粘贴时只接受有效数字(>= 0) */
|
||
function onAmountPaste(e: ClipboardEvent) {
|
||
const text = e.clipboardData?.getData('text') ?? ''
|
||
const num = parseFloat(text)
|
||
if (!Number.isFinite(num) || num < 0) {
|
||
e.preventDefault()
|
||
}
|
||
}
|
||
|
||
const adjustAmount = (value: number) => {
|
||
amount.value += value
|
||
if (amount.value < 0) amount.value = 0
|
||
}
|
||
|
||
const setMaxAmount = () => {
|
||
amount.value = balance.value
|
||
}
|
||
|
||
/** Buy 模式:余额是否足够(>= 所需金额且不为 0)。cost:balance>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')
|
||
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
|
||
const userIdNum = typeof uid === 'number' ? uid : uid != null ? parseInt(String(uid), 10) : 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
|
||
: 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
|
||
|
||
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(
|
||
{
|
||
expiration,
|
||
feeRateBps: 0,
|
||
nonce: 0,
|
||
orderType: orderTypeNum,
|
||
price: limitPrice.value,
|
||
side: sideNum,
|
||
size: sizeValue,
|
||
taker: true,
|
||
tokenID: tokenId,
|
||
userID: userIdNum,
|
||
marketID: marketId || '',
|
||
},
|
||
{ headers },
|
||
)
|
||
if (res.code === 0 || res.code === 200) {
|
||
userStore.fetchUsdcBalance()
|
||
emit('orderSuccess')
|
||
} else {
|
||
orderError.value = res.msg || t('trade.orderFailed')
|
||
}
|
||
} catch (e) {
|
||
orderError.value = formatAuthError(e, 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;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 50%;
|
||
}
|
||
|
||
.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;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 50%;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* Sell模式:Max shares行内提示样式(放在Shares标签右侧) */
|
||
.max-shares-inline {
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
font-weight: 500;
|
||
margin-left: auto;
|
||
}
|
||
|
||
/* Sell模式:输入框独占一行 */
|
||
.shares-input-wrapper {
|
||
margin: 4px 0 8px;
|
||
}
|
||
|
||
.shares-input-wrapper .v-field {
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* Sell模式:shares区域布局优化 */
|
||
.sell-shares-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.sell-shares-buttons {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.shares-exceeds-max-hint {
|
||
font-size: 12px;
|
||
color: #b45309;
|
||
margin: 6px 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>
|