优化:UI优化,接口对接优化
This commit is contained in:
parent
73cf147348
commit
a084780180
@ -32,7 +32,9 @@ interface TradePositionItem {
|
||||
|
||||
- Buy/Sell Tab 切换
|
||||
- Market/Limit 类型、Merge/Split 菜单
|
||||
- **Buy 模式 Amount 区**:无论余额是否充足,均显示 Amount 标签、Balance、金额输入、+$1/+$20/+$100/Max 快捷按钮(桌面端、嵌入弹窗、移动端弹窗一致)
|
||||
- **Buy 模式 Amount 区**:无论余额是否充足,均显示 Amount 标签、Balance、**可编辑金额输入框**(v-text-field,带 $ 前缀,variant="outlined")、+$1/+$20/+$100/Max 快捷按钮(桌面端、嵌入弹窗、移动端弹窗一致)
|
||||
- 输入框支持直接输入金额(>= 0,支持小数)
|
||||
- 事件处理:`onAmountInput`、`onAmountKeydown`、`onAmountPaste`
|
||||
- 余额不足时 Buy 显示 Deposit 按钮
|
||||
- 25%/50%/Max 快捷份额
|
||||
- **Sell 模式 UI 优化**:
|
||||
@ -42,6 +44,7 @@ interface TradePositionItem {
|
||||
- 整体布局更清晰:`Shares Max: 2` → `[输入框]` → `[25%][50%][Max]`
|
||||
- 调用 market API 下单、Split、Merge
|
||||
- **合并/拆分成功后触发事件**:`mergeSuccess`、`splitSuccess`,父组件监听后可刷新持仓列表
|
||||
- **401 权限错误提示**:通过 `useAuthError().formatAuthError` 统一处理,未登录显示「请先登录」,已登录显示「权限不足」
|
||||
|
||||
## 使用方式
|
||||
|
||||
|
||||
36
docs/composables/useAuthError.md
Normal file
36
docs/composables/useAuthError.md
Normal file
@ -0,0 +1,36 @@
|
||||
# useAuthError.ts
|
||||
|
||||
**路径**:`src/composables/useAuthError.ts`
|
||||
|
||||
## 功能用途
|
||||
|
||||
统一处理 HTTP 401(Unauthorized)等权限相关错误,根据登录状态返回用户友好的提示文案:未登录时提示「请先登录」,已登录时提示「权限不足」。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- `formatAuthError(err, fallback)`:将异常转换为展示文案
|
||||
- 若错误信息包含 `401` 或 `Unauthorized`:根据 `userStore.isLoggedIn` 返回 `error.pleaseLogin` 或 `error.insufficientPermission`
|
||||
- 否则返回原始错误信息或 `fallback`
|
||||
|
||||
## 使用方式
|
||||
|
||||
```typescript
|
||||
import { useAuthError } from '@/composables/useAuthError'
|
||||
|
||||
const { formatAuthError } = useAuthError()
|
||||
|
||||
try {
|
||||
await someApiCall()
|
||||
} catch (e) {
|
||||
errorMessage.value = formatAuthError(e, t('error.requestFailed'))
|
||||
}
|
||||
```
|
||||
|
||||
## 国际化
|
||||
|
||||
依赖 `error.pleaseLogin`、`error.insufficientPermission`,在各 `locales/*.json` 的 `error` 下配置。
|
||||
|
||||
## 扩展方式
|
||||
|
||||
1. **扩展状态码**:在 `formatAuthError` 中增加对 403 等的判断
|
||||
2. **统一拦截**:在 `request.ts` 层统一处理 401,自动跳转登录或弹 toast
|
||||
@ -16,6 +16,7 @@
|
||||
- 限价订单:通过 `getOrderList` 获取当前市场未成交限价单,支持撤单
|
||||
- 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent`
|
||||
- Merge/Split:通过 `TradeComponent` 或底部菜单触发,成功后监听 `mergeSuccess`/`splitSuccess` 事件刷新持仓
|
||||
- **401 权限错误**:加载详情失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
|
||||
|
||||
## 使用方式
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
- Profit/Loss 卡片:时间范围切换、ECharts 图表
|
||||
- Tab:Positions、Open orders、History
|
||||
- DepositDialog、WithdrawDialog 组件
|
||||
- **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
|
||||
|
||||
## 使用方式
|
||||
|
||||
|
||||
@ -126,7 +126,10 @@ export class ClobSdk {
|
||||
console.log(`[ClobSdk] 已连接到 ${this.url}`);
|
||||
this.reconnectAttempts = 0;
|
||||
this.notifyConnect(event);
|
||||
setTimeout(() => {
|
||||
this.subscribe();
|
||||
}, 1000);
|
||||
// this.subscribe();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: any) => {
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>
|
||||
{{ yesLabel }} {{ yesPriceCents }}¢
|
||||
@ -50,6 +51,7 @@
|
||||
class="no-btn"
|
||||
:class="{ active: selectedOption === 'no' }"
|
||||
text
|
||||
:title="`${noLabel} ${noPriceCents}¢`"
|
||||
@click="handleOptionChange('no')"
|
||||
>
|
||||
{{ noLabel }} {{ noPriceCents }}¢
|
||||
@ -58,19 +60,32 @@
|
||||
|
||||
<!-- 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 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="amount-value">${{ amount.toFixed(2) }}</div>
|
||||
<div class="shares-input-wrapper">
|
||||
<v-text-field
|
||||
:model-value="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
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="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 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>
|
||||
@ -107,7 +122,7 @@
|
||||
<div class="total-section">
|
||||
<!-- Buy模式 -->
|
||||
<template v-if="activeTab === 'buy'">
|
||||
<div class="total-row">
|
||||
<div v-if="!isMarketMode" class="total-row">
|
||||
<span class="label">{{ t('trade.total') }}</span>
|
||||
<span class="total-value">${{ totalPrice }}</span>
|
||||
</div>
|
||||
@ -165,6 +180,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>
|
||||
{{ yesLabel }} {{ yesPriceCents }}¢
|
||||
@ -173,6 +189,7 @@
|
||||
class="no-btn"
|
||||
:class="{ active: selectedOption === 'no' }"
|
||||
text
|
||||
:title="`${noLabel} ${noPriceCents}¢`"
|
||||
@click="handleOptionChange('no')"
|
||||
>
|
||||
{{ noLabel }} {{ noPriceCents }}¢
|
||||
@ -181,23 +198,33 @@
|
||||
|
||||
<!-- 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 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="amount-value">${{ amount.toFixed(2) }}</div>
|
||||
<div class="shares-input-wrapper">
|
||||
<v-text-field
|
||||
:model-value="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="shares-input-field"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prefix="$"
|
||||
@update:model-value="onAmountInput"
|
||||
@keydown="onAmountKeydown"
|
||||
@paste="onAmountPaste"
|
||||
></v-text-field>
|
||||
</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 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>
|
||||
@ -274,6 +301,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>
|
||||
{{ yesLabel }} {{ yesPriceCents }}¢
|
||||
@ -282,6 +310,7 @@
|
||||
class="no-btn"
|
||||
:class="{ active: selectedOption === 'no' }"
|
||||
text
|
||||
:title="`${noLabel} ${noPriceCents}¢`"
|
||||
@click="handleOptionChange('no')"
|
||||
>
|
||||
{{ noLabel }} {{ noPriceCents }}¢
|
||||
@ -458,6 +487,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||||
>
|
||||
@ -465,25 +495,39 @@
|
||||
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">
|
||||
<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 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="amount-value">${{ amount.toFixed(2) }}</div>
|
||||
<div class="shares-input-wrapper">
|
||||
<v-text-field
|
||||
:model-value="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
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="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 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>
|
||||
@ -517,7 +561,7 @@
|
||||
</template>
|
||||
<div class="total-section">
|
||||
<template v-if="activeTab === 'buy'">
|
||||
<div class="total-row">
|
||||
<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">
|
||||
@ -567,6 +611,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||||
>
|
||||
@ -574,25 +619,39 @@
|
||||
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">
|
||||
<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 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="amount-value">${{ amount.toFixed(2) }}</div>
|
||||
<div class="shares-input-wrapper">
|
||||
<v-text-field
|
||||
:model-value="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
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="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 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>
|
||||
@ -663,6 +722,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||||
>
|
||||
@ -670,6 +730,7 @@
|
||||
class="no-btn"
|
||||
:class="{ active: selectedOption === 'no' }"
|
||||
text
|
||||
:title="`${noLabel} ${noPriceCents}¢`"
|
||||
@click="handleOptionChange('no')"
|
||||
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
|
||||
>
|
||||
@ -799,6 +860,7 @@
|
||||
color="success"
|
||||
rounded="pill"
|
||||
block
|
||||
:title="`${t('trade.buyLabel', { label: yesLabel })} ${yesPriceCents}¢`"
|
||||
@click="openSheet('yes')"
|
||||
>
|
||||
{{ t('trade.buyLabel', { label: yesLabel }) }} {{ yesPriceCents }}¢
|
||||
@ -809,6 +871,7 @@
|
||||
color="error"
|
||||
rounded="pill"
|
||||
block
|
||||
:title="`${t('trade.buyLabel', { label: noLabel })} ${noPriceCents}¢`"
|
||||
@click="openSheet('no')"
|
||||
>
|
||||
{{ t('trade.buyLabel', { label: noLabel }) }} {{ noPriceCents }}¢
|
||||
@ -854,6 +917,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||||
>
|
||||
@ -861,25 +925,39 @@
|
||||
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">
|
||||
<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 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="amount-value">${{ amount.toFixed(2) }}</div>
|
||||
<div class="shares-input-wrapper">
|
||||
<v-text-field
|
||||
:model-value="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
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="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 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>
|
||||
@ -913,7 +991,7 @@
|
||||
</template>
|
||||
<div class="total-section">
|
||||
<template v-if="activeTab === 'buy'">
|
||||
<div class="total-row">
|
||||
<div v-if="!isMarketMode" class="total-row">
|
||||
<span class="label">Total</span
|
||||
><span class="total-value">${{ totalPrice }}</span>
|
||||
</div>
|
||||
@ -964,6 +1042,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||||
>
|
||||
@ -971,25 +1050,39 @@
|
||||
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">
|
||||
<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 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="amount-value">${{ amount.toFixed(2) }}</div>
|
||||
<div class="shares-input-wrapper">
|
||||
<v-text-field
|
||||
:model-value="amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
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="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 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>
|
||||
@ -1060,6 +1153,7 @@
|
||||
class="yes-btn"
|
||||
:class="{ active: selectedOption === 'yes' }"
|
||||
text
|
||||
:title="`${yesLabel} ${yesPriceCents}¢`"
|
||||
@click="handleOptionChange('yes')"
|
||||
>{{ yesLabel }} {{ yesPriceCents }}¢</v-btn
|
||||
>
|
||||
@ -1067,6 +1161,7 @@
|
||||
class="no-btn"
|
||||
:class="{ active: selectedOption === 'no' }"
|
||||
text
|
||||
:title="`${noLabel} ${noPriceCents}¢`"
|
||||
@click="handleOptionChange('no')"
|
||||
>{{ noLabel }} {{ noPriceCents }}¢</v-btn
|
||||
>
|
||||
@ -1317,6 +1412,7 @@ 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'
|
||||
|
||||
@ -1324,6 +1420,7 @@ 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[] {
|
||||
@ -1386,13 +1483,15 @@ export interface TradePositionItem {
|
||||
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, embeddedInSheet: false, market: undefined, positions: () => [] },
|
||||
{ initialOption: undefined, initialTab: undefined, embeddedInSheet: false, market: undefined, positions: () => [] },
|
||||
)
|
||||
|
||||
// 移动端:底部栏与弹出层
|
||||
@ -1445,7 +1544,7 @@ async function submitMerge() {
|
||||
mergeError.value = res.msg || 'Merge failed'
|
||||
}
|
||||
} catch (e) {
|
||||
mergeError.value = e instanceof Error ? e.message : 'Request failed'
|
||||
mergeError.value = formatAuthError(e, 'Request failed')
|
||||
} finally {
|
||||
mergeLoading.value = false
|
||||
}
|
||||
@ -1480,7 +1579,7 @@ async function submitSplit() {
|
||||
splitError.value = res.msg || 'Split failed'
|
||||
}
|
||||
} catch (e) {
|
||||
splitError.value = e instanceof Error ? e.message : 'Request failed'
|
||||
splitError.value = formatAuthError(e, 'Request failed')
|
||||
} finally {
|
||||
splitLoading.value = false
|
||||
}
|
||||
@ -1602,6 +1701,7 @@ function syncLimitPriceFromMarket() {
|
||||
onMounted(() => {
|
||||
if (props.initialOption) applyInitialOption(props.initialOption)
|
||||
else if (props.market) syncLimitPriceFromMarket()
|
||||
if (props.initialTab) activeTab.value = props.initialTab
|
||||
})
|
||||
watch(
|
||||
() => props.initialOption,
|
||||
@ -1610,6 +1710,12 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(
|
||||
() => props.initialTab,
|
||||
(tab) => {
|
||||
if (tab) activeTab.value = tab
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.market,
|
||||
@ -1761,6 +1867,41 @@ const setSharesPercentage = (percentage: number) => {
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -1859,6 +2000,10 @@ async function submitOrder() {
|
||||
? parseExpirationTimestamp(expirationTime.value)
|
||||
: 0
|
||||
|
||||
const sizeValue = isMarket && activeTab.value === 'buy'
|
||||
? Math.round(amount.value * 1_000_000)
|
||||
: clampShares(shares.value)
|
||||
|
||||
orderLoading.value = true
|
||||
orderError.value = ''
|
||||
try {
|
||||
@ -1870,7 +2015,7 @@ async function submitOrder() {
|
||||
orderType: orderTypeNum,
|
||||
price: limitPrice.value,
|
||||
side: sideNum,
|
||||
size: clampShares(shares.value),
|
||||
size: sizeValue,
|
||||
taker: true,
|
||||
tokenID: tokenId,
|
||||
userID: userIdNum,
|
||||
@ -1885,7 +2030,7 @@ async function submitOrder() {
|
||||
orderError.value = res.msg || t('trade.orderFailed')
|
||||
}
|
||||
} catch (e) {
|
||||
orderError.value = e instanceof Error ? e.message : t('error.requestFailed')
|
||||
orderError.value = formatAuthError(e, t('error.requestFailed'))
|
||||
} finally {
|
||||
orderLoading.value = false
|
||||
}
|
||||
@ -1980,6 +2125,10 @@ async function submitOrder() {
|
||||
text-transform: none;
|
||||
font-size: 14px;
|
||||
box-shadow: none !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.yes-btn.active {
|
||||
@ -1995,6 +2144,10 @@ async function submitOrder() {
|
||||
text-transform: none;
|
||||
font-size: 14px;
|
||||
box-shadow: none !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.no-btn.active {
|
||||
|
||||
20
src/composables/useAuthError.ts
Normal file
20
src/composables/useAuthError.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
/**
|
||||
* 401 等权限错误时:未登录提示请先登录,已登录提示权限不足
|
||||
*/
|
||||
export function useAuthError() {
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
function formatAuthError(err: unknown, fallback: string): string {
|
||||
const msg = err instanceof Error ? err.message : String(err ?? fallback)
|
||||
if (/401|Unauthorized/i.test(msg)) {
|
||||
return userStore.isLoggedIn ? t('error.insufficientPermission') : t('error.pleaseLogin')
|
||||
}
|
||||
return msg || fallback
|
||||
}
|
||||
|
||||
return { formatAuthError }
|
||||
}
|
||||
@ -85,7 +85,9 @@
|
||||
"error": {
|
||||
"requestFailed": "Request failed",
|
||||
"loadFailed": "Load failed",
|
||||
"invalidId": "Invalid ID or slug"
|
||||
"invalidId": "Invalid ID or slug",
|
||||
"pleaseLogin": "Please log in first",
|
||||
"insufficientPermission": "Insufficient permission"
|
||||
},
|
||||
"activity": {
|
||||
"comments": "Comments",
|
||||
|
||||
@ -85,7 +85,9 @@
|
||||
"error": {
|
||||
"requestFailed": "リクエストに失敗しました",
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"invalidId": "無効な ID または slug"
|
||||
"invalidId": "無効な ID または slug",
|
||||
"pleaseLogin": "先にログインしてください",
|
||||
"insufficientPermission": "権限がありません"
|
||||
},
|
||||
"activity": {
|
||||
"comments": "コメント",
|
||||
|
||||
@ -85,7 +85,9 @@
|
||||
"error": {
|
||||
"requestFailed": "요청 실패",
|
||||
"loadFailed": "로드 실패",
|
||||
"invalidId": "잘못된 ID 또는 slug"
|
||||
"invalidId": "잘못된 ID 또는 slug",
|
||||
"pleaseLogin": "먼저 로그인하세요",
|
||||
"insufficientPermission": "권한이 없습니다"
|
||||
},
|
||||
"activity": {
|
||||
"comments": "댓글",
|
||||
|
||||
@ -85,7 +85,9 @@
|
||||
"error": {
|
||||
"requestFailed": "请求失败",
|
||||
"loadFailed": "加载失败",
|
||||
"invalidId": "无效的 ID 或 slug"
|
||||
"invalidId": "无效的 ID 或 slug",
|
||||
"pleaseLogin": "请先登录",
|
||||
"insufficientPermission": "权限不足"
|
||||
},
|
||||
"activity": {
|
||||
"comments": "评论",
|
||||
|
||||
@ -85,7 +85,9 @@
|
||||
"error": {
|
||||
"requestFailed": "請求失敗",
|
||||
"loadFailed": "載入失敗",
|
||||
"invalidId": "無效的 ID 或 slug"
|
||||
"invalidId": "無效的 ID 或 slug",
|
||||
"pleaseLogin": "請先登入",
|
||||
"insufficientPermission": "權限不足"
|
||||
},
|
||||
"activity": {
|
||||
"comments": "評論",
|
||||
|
||||
@ -2,7 +2,7 @@ import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getUsdcBalance, formatUsdcBalance, getUserInfo } from '@/api/user'
|
||||
import { getUserWsUrl } from '@/api/request'
|
||||
import { UserSdk, type BalanceData } from '../../sdk/userSocket'
|
||||
import { UserSdk, type BalanceData, type PositionData } from '../../sdk/userSocket'
|
||||
|
||||
export interface UserInfo {
|
||||
/** 用户 ID(API 可能返回 id 或 ID) */
|
||||
@ -53,6 +53,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const balance = ref<string>('0.00')
|
||||
|
||||
let userSdkRef: UserSdk | null = null
|
||||
const positionUpdateCallbacks: ((data: PositionData & Record<string, unknown>) => void)[] = []
|
||||
|
||||
// 若从 storage 恢复登录态,自动连接 UserSocket
|
||||
if (stored?.token && stored?.user) {
|
||||
@ -76,6 +77,9 @@ export const useUserStore = defineStore('user', () => {
|
||||
balance.value = formatUsdcBalance(String(avail))
|
||||
}
|
||||
})
|
||||
sdk.onPositionUpdate((data) => {
|
||||
positionUpdateCallbacks.forEach((cb) => cb(data as PositionData & Record<string, unknown>))
|
||||
})
|
||||
sdk.onConnect(() => {})
|
||||
sdk.onDisconnect(() => {})
|
||||
sdk.onError((e) => {
|
||||
@ -92,6 +96,15 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 订阅 position_update 推送,返回取消订阅函数 */
|
||||
function onPositionUpdate(cb: (data: PositionData & Record<string, unknown>) => void): () => void {
|
||||
positionUpdateCallbacks.push(cb)
|
||||
return () => {
|
||||
const i = positionUpdateCallbacks.indexOf(cb)
|
||||
if (i >= 0) positionUpdateCallbacks.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function setUser(loginData: { token?: string; user?: UserInfo }) {
|
||||
const t = loginData.token ?? ''
|
||||
const raw = loginData.user ?? null
|
||||
@ -202,5 +215,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
fetchUserInfo,
|
||||
connectUserSocket,
|
||||
disconnectUserSocket,
|
||||
onPositionUpdate,
|
||||
}
|
||||
})
|
||||
|
||||
@ -226,11 +226,13 @@ import { USE_MOCK_EVENT } from '../config/mock'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { useLocaleStore } from '../stores/locale'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const localeStore = useLocaleStore()
|
||||
const { mobile } = useDisplay()
|
||||
const isMobile = computed(() => mobile.value)
|
||||
|
||||
@ -734,6 +736,14 @@ watch(
|
||||
() => route.params.id,
|
||||
() => loadEventDetail(),
|
||||
)
|
||||
|
||||
// 监听语言切换,语言变化时重新加载数据
|
||||
watch(
|
||||
() => localeStore.currentLocale,
|
||||
() => {
|
||||
loadEventDetail()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -304,7 +304,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'Home' })
|
||||
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed, watch } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import MarketCard from '../components/MarketCard.vue'
|
||||
import TradeComponent from '../components/TradeComponent.vue'
|
||||
@ -328,6 +328,7 @@ import { USE_MOCK_CATEGORY } from '../config/mock'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSearchHistory } from '../composables/useSearchHistory'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { useLocaleStore } from '../stores/locale'
|
||||
|
||||
const { mobile } = useDisplay()
|
||||
const { t } = useI18n()
|
||||
@ -552,6 +553,18 @@ function onCardOpenTrade(
|
||||
}
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const localeStore = useLocaleStore()
|
||||
|
||||
// 监听语言切换,语言变化时重新加载事件列表
|
||||
watch(
|
||||
() => localeStore.currentLocale,
|
||||
() => {
|
||||
clearEventListCache()
|
||||
eventPage.value = 1
|
||||
loadEvents(1, false, activeSearchKeyword.value)
|
||||
},
|
||||
)
|
||||
|
||||
function onOrderSuccess() {
|
||||
tradeDialogOpen.value = false
|
||||
toastStore.show(t('toast.orderSuccess'))
|
||||
|
||||
@ -53,12 +53,12 @@
|
||||
<v-window v-model="positionsOrdersTab" class="positions-orders-window">
|
||||
<v-window-item value="positions" class="detail-pane">
|
||||
<div v-if="positionLoading" class="placeholder-pane">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="marketPositions.length === 0" class="placeholder-pane">
|
||||
<div v-else-if="marketPositionsFiltered.length === 0" class="placeholder-pane">
|
||||
{{ t('activity.noPositionsInMarket') }}
|
||||
</div>
|
||||
<div v-else class="positions-list">
|
||||
<div
|
||||
v-for="pos in marketPositions"
|
||||
v-for="pos in marketPositionsFiltered"
|
||||
:key="pos.id"
|
||||
class="position-row-item"
|
||||
>
|
||||
@ -66,6 +66,15 @@
|
||||
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{ pos.outcomeTag }}</span>
|
||||
<span class="position-shares">{{ pos.shares }}</span>
|
||||
<span class="position-value">{{ pos.value }}</span>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="position-sell-btn"
|
||||
@click="openSellFromPosition(pos)"
|
||||
>
|
||||
{{ t('trade.sell') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="position-row-meta">{{ pos.bet }} → {{ pos.toWin }}</div>
|
||||
</div>
|
||||
@ -220,7 +229,7 @@
|
||||
ref="tradeComponentRef"
|
||||
:market="tradeMarketPayload"
|
||||
:initial-option="tradeInitialOption"
|
||||
:positions="marketPositions"
|
||||
:positions="tradePositionsForComponent"
|
||||
@merge-success="onMergeSuccess"
|
||||
@split-success="onSplitSuccess"
|
||||
/>
|
||||
@ -280,7 +289,8 @@
|
||||
ref="mobileTradeComponentRef"
|
||||
:market="tradeMarketPayload"
|
||||
:initial-option="tradeInitialOptionFromBar"
|
||||
:positions="marketPositions"
|
||||
:initial-tab="tradeInitialTabFromBar"
|
||||
:positions="tradePositionsForComponent"
|
||||
embedded-in-sheet
|
||||
@order-success="onOrderSuccess"
|
||||
@merge-success="onMergeSuccess"
|
||||
@ -288,6 +298,25 @@
|
||||
/>
|
||||
</v-bottom-sheet>
|
||||
</template>
|
||||
|
||||
<!-- 从持仓点击 Sell 弹出的交易组件(桌面/移动端通用) -->
|
||||
<v-dialog
|
||||
v-model="sellDialogOpen"
|
||||
max-width="420"
|
||||
content-class="trade-detail-sell-dialog"
|
||||
transition="dialog-transition"
|
||||
>
|
||||
<TradeComponent
|
||||
v-if="sellDialogOpen"
|
||||
:market="tradeMarketPayload"
|
||||
:initial-option="sellInitialOption"
|
||||
:initial-tab="'sell'"
|
||||
:positions="tradePositionsForComponent"
|
||||
@order-success="onSellOrderSuccess"
|
||||
@merge-success="onMergeSuccess"
|
||||
@split-success="onSplitSuccess"
|
||||
/>
|
||||
</v-dialog>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
@ -300,7 +329,7 @@ import { useDisplay } from 'vuetify'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import OrderBook from '../components/OrderBook.vue'
|
||||
import TradeComponent from '../components/TradeComponent.vue'
|
||||
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
|
||||
import {
|
||||
findPmEvent,
|
||||
getMarketId,
|
||||
@ -311,6 +340,8 @@ import {
|
||||
import { getClobWsUrl } from '../api/request'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { useLocaleStore } from '../stores/locale'
|
||||
import { useAuthError } from '../composables/useAuthError'
|
||||
import { getPositionList, mapPositionToDisplayItem, type PositionDisplayItem } from '../api/position'
|
||||
import {
|
||||
getOrderList,
|
||||
@ -356,6 +387,8 @@ export type ChartIncrement = { point: ChartPoint }
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const { formatAuthError } = useAuthError()
|
||||
const localeStore = useLocaleStore()
|
||||
const { mobile } = useDisplay()
|
||||
const isMobile = computed(() => mobile.value)
|
||||
|
||||
@ -427,7 +460,7 @@ async function loadEventDetail() {
|
||||
eventDetail.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
detailError.value = e instanceof Error ? e.message : t('error.loadFailed')
|
||||
detailError.value = formatAuthError(e, t('error.loadFailed'))
|
||||
eventDetail.value = null
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
@ -489,6 +522,19 @@ const orderBookAsksYes = computed(() => orderBookByToken.value[0]?.asks ?? [])
|
||||
const orderBookBidsYes = computed(() => orderBookByToken.value[0]?.bids ?? [])
|
||||
const orderBookAsksNo = computed(() => orderBookByToken.value[1]?.asks ?? [])
|
||||
const orderBookBidsNo = computed(() => orderBookByToken.value[1]?.bids ?? [])
|
||||
|
||||
/** 订单簿 Yes 卖单最低价(分),无数据时为 0 */
|
||||
const orderBookLowestAskYesCents = computed(() => {
|
||||
const asks = orderBookAsksYes.value
|
||||
if (!asks.length) return 0
|
||||
return Math.min(...asks.map((a) => a.price))
|
||||
})
|
||||
/** 订单簿 No 卖单最低价(分),无数据时为 0 */
|
||||
const orderBookLowestAskNoCents = computed(() => {
|
||||
const asks = orderBookAsksNo.value
|
||||
if (!asks.length) return 0
|
||||
return Math.min(...asks.map((a) => a.price))
|
||||
})
|
||||
const clobLastPriceYes = computed(() => clobLastPriceByToken.value[0])
|
||||
const clobLastPriceNo = computed(() => clobLastPriceByToken.value[1])
|
||||
const clobSpreadYes = computed(() => clobSpreadByToken.value[0])
|
||||
@ -629,14 +675,12 @@ function disconnectClob() {
|
||||
clobLoading.value = false
|
||||
}
|
||||
|
||||
/** 传给 TradeComponent 的 market,供 Split 调用 /PmMarket/split;接口未返回时用 query 兜底 */
|
||||
/** 传给 TradeComponent 的 market,供 Split 调用 /PmMarket/split;yesPrice/noPrice 取订单簿卖单最低价,无数据时为 0 */
|
||||
const tradeMarketPayload = computed(() => {
|
||||
const m = currentMarket.value
|
||||
const yesPrice = orderBookLowestAskYesCents.value / 100
|
||||
const noPrice = orderBookLowestAskNoCents.value / 100
|
||||
if (m) {
|
||||
const yesRaw = m.outcomePrices?.[0]
|
||||
const noRaw = m.outcomePrices?.[1]
|
||||
const yesPrice = yesRaw != null && Number.isFinite(Number(yesRaw)) ? Number(yesRaw) : 0.5
|
||||
const noPrice = noRaw != null && Number.isFinite(Number(noRaw)) ? Number(noRaw) : 0.5
|
||||
return {
|
||||
marketId: getMarketId(m),
|
||||
yesPrice,
|
||||
@ -648,9 +692,6 @@ const tradeMarketPayload = computed(() => {
|
||||
}
|
||||
const qId = route.query.marketId
|
||||
if (qId != null && String(qId).trim() !== '') {
|
||||
const chance = route.query.chance != null ? Number(route.query.chance) : NaN
|
||||
const yesPrice = Number.isFinite(chance) ? Math.min(1, Math.max(0, chance / 100)) : 0.5
|
||||
const noPrice = Number.isFinite(chance) ? 1 - yesPrice : 0.5
|
||||
return {
|
||||
marketId: String(qId).trim(),
|
||||
yesPrice,
|
||||
@ -669,8 +710,14 @@ const tradeInitialOption = computed(() => {
|
||||
|
||||
/** 移动端底部栏点击 Yes/No 时传给弹窗内 TradeComponent 的初始选项 */
|
||||
const tradeInitialOptionFromBar = ref<'yes' | 'no' | undefined>(undefined)
|
||||
/** 移动端弹窗初始 Tab:从持仓 Sell 打开时为 'sell',从底部栏 Yes/No 打开时为 undefined(默认 Buy) */
|
||||
const tradeInitialTabFromBar = ref<'buy' | 'sell' | undefined>(undefined)
|
||||
/** 移动端交易弹窗开关 */
|
||||
const tradeSheetOpen = ref(false)
|
||||
/** 从持仓 Sell 打开的弹窗 */
|
||||
const sellDialogOpen = ref(false)
|
||||
/** 从持仓 Sell 时预选的 Yes/No */
|
||||
const sellInitialOption = ref<'yes' | 'no'>('yes')
|
||||
/** 移动端三点菜单开关 */
|
||||
const mobileMenuOpen = ref(false)
|
||||
/** 桌面端 TradeComponent 引用(Merge/Split) */
|
||||
@ -689,6 +736,7 @@ const noLabel = computed(() => currentMarket.value?.outcomes?.[1] ?? 'No')
|
||||
|
||||
function openSheetWithOption(side: 'yes' | 'no') {
|
||||
tradeInitialOptionFromBar.value = side
|
||||
tradeInitialTabFromBar.value = undefined
|
||||
tradeSheetOpen.value = true
|
||||
}
|
||||
|
||||
@ -727,6 +775,24 @@ function onSplitSuccess() {
|
||||
loadMarketPositions()
|
||||
}
|
||||
|
||||
/** 从持仓项点击 Sell:弹出交易组件并切到 Sell、对应 Yes/No。移动端直接开底部弹窗,桌面端开 Dialog */
|
||||
function openSellFromPosition(pos: PositionDisplayItem) {
|
||||
const option = pos.outcomeWord === 'No' ? 'no' : 'yes'
|
||||
if (isMobile.value) {
|
||||
tradeInitialOptionFromBar.value = option
|
||||
tradeInitialTabFromBar.value = 'sell'
|
||||
tradeSheetOpen.value = true
|
||||
} else {
|
||||
sellInitialOption.value = option
|
||||
sellDialogOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onSellOrderSuccess() {
|
||||
sellDialogOpen.value = false
|
||||
onOrderSuccess()
|
||||
}
|
||||
|
||||
// 当前市场的 marketID,用于筛选持仓和订单
|
||||
const currentMarketId = computed(() => getMarketId(currentMarket.value))
|
||||
|
||||
@ -734,6 +800,24 @@ const currentMarketId = computed(() => getMarketId(currentMarket.value))
|
||||
const marketPositions = ref<PositionDisplayItem[]>([])
|
||||
const positionLoading = ref(false)
|
||||
|
||||
/** 过滤掉份额为 0 的持仓项 */
|
||||
const marketPositionsFiltered = computed(() =>
|
||||
marketPositions.value.filter((p) => {
|
||||
const n = parseFloat(p.shares?.replace(/[^0-9.]/g, '') ?? '')
|
||||
return Number.isFinite(n) && n > 0
|
||||
}),
|
||||
)
|
||||
|
||||
/** 转为 TradeComponent 所需的 TradePositionItem[],保证 outcomeWord 为 'Yes' | 'No'(仅含份额>0) */
|
||||
const tradePositionsForComponent = computed<TradePositionItem[]>(() =>
|
||||
marketPositionsFiltered.value.map((p) => ({
|
||||
id: p.id,
|
||||
outcomeWord: (p.outcomeWord === 'No' ? 'No' : 'Yes') as 'Yes' | 'No',
|
||||
shares: p.shares,
|
||||
sharesNum: parseFloat(p.shares?.replace(/[^0-9.]/g, '')) || undefined,
|
||||
}))
|
||||
)
|
||||
|
||||
async function loadMarketPositions() {
|
||||
const marketID = currentMarketId.value
|
||||
if (!marketID) {
|
||||
@ -1238,6 +1322,23 @@ watch(
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// 监听语言切换,语言变化时重新加载数据
|
||||
watch(
|
||||
() => localeStore.currentLocale,
|
||||
() => {
|
||||
loadEventDetail()
|
||||
loadMarketPositions()
|
||||
},
|
||||
)
|
||||
|
||||
// 订阅 position_update,收到当前市场的推送时刷新持仓
|
||||
const unsubscribePositionUpdate = userStore.onPositionUpdate((data) => {
|
||||
const marketID = data.marketID ?? (data as Record<string, unknown>).market_id
|
||||
if (marketID && String(marketID) === String(currentMarketId.value)) {
|
||||
loadMarketPositions()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadEventDetail()
|
||||
initChart()
|
||||
@ -1246,6 +1347,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribePositionUpdate()
|
||||
stopDynamicUpdate()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chartInstance?.dispose()
|
||||
@ -1761,6 +1863,10 @@ onUnmounted(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-sell-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.position-outcome-pill,
|
||||
.order-side-pill {
|
||||
font-size: 12px;
|
||||
|
||||
@ -626,6 +626,8 @@ import type { ECharts } from 'echarts'
|
||||
import DepositDialog from '../components/DepositDialog.vue'
|
||||
import WithdrawDialog from '../components/WithdrawDialog.vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useLocaleStore } from '../stores/locale'
|
||||
import { useAuthError } from '../composables/useAuthError'
|
||||
import { cancelOrder as apiCancelOrder } from '../api/order'
|
||||
import { getOrderList, mapOrderToHistoryItem, mapOrderToOpenOrderItem, OrderStatus } from '../api/order'
|
||||
import { getPositionList, mapPositionToDisplayItem } from '../api/position'
|
||||
@ -640,6 +642,8 @@ import { CrossChainUSDTAuth } from '../../sdk/approve'
|
||||
|
||||
const { mobile } = useDisplay()
|
||||
const userStore = useUserStore()
|
||||
const { formatAuthError } = useAuthError()
|
||||
const localeStore = useLocaleStore()
|
||||
const portfolioBalance = computed(() => userStore.balance)
|
||||
const profitLoss = ref('0.00')
|
||||
const plRange = ref('ALL')
|
||||
@ -978,13 +982,13 @@ async function cancelOrder(ord: OpenOrder) {
|
||||
const uid = userStore.user?.id ?? userStore.user?.ID
|
||||
const userID = uid != null ? Number(uid) : 0
|
||||
if (!Number.isFinite(userID) || userID <= 0) {
|
||||
cancelOrderError.value = '请先登录'
|
||||
cancelOrderError.value = t('error.pleaseLogin')
|
||||
showCancelError.value = true
|
||||
return
|
||||
}
|
||||
const headers = userStore.getAuthHeaders()
|
||||
if (!headers) {
|
||||
cancelOrderError.value = '请先登录'
|
||||
cancelOrderError.value = t('error.pleaseLogin')
|
||||
showCancelError.value = true
|
||||
return
|
||||
}
|
||||
@ -1005,7 +1009,7 @@ async function cancelOrder(ord: OpenOrder) {
|
||||
showCancelError.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
cancelOrderError.value = e instanceof Error ? e.message : 'Request failed'
|
||||
cancelOrderError.value = formatAuthError(e, t('error.requestFailed'))
|
||||
showCancelError.value = true
|
||||
} finally {
|
||||
cancelOrderLoading.value = false
|
||||
@ -1184,6 +1188,15 @@ const handleResize = () => plChartInstance?.resize()
|
||||
|
||||
watch(plRange, () => updatePlChart())
|
||||
|
||||
// 监听语言切换,语言变化时重新加载数据
|
||||
watch(
|
||||
() => localeStore.currentLocale,
|
||||
() => {
|
||||
loadPositionList()
|
||||
loadOpenOrders()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!USE_MOCK_WALLET && activeTab.value === 'positions') loadPositionList()
|
||||
nextTick(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user