优化:UI优化,接口对接优化

This commit is contained in:
ivan 2026-02-28 20:17:59 +08:00
parent 73cf147348
commit a084780180
17 changed files with 492 additions and 109 deletions

View File

@ -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` 统一处理,未登录显示「请先登录」,已登录显示「权限不足」
## 使用方式

View File

@ -0,0 +1,36 @@
# useAuthError.ts
**路径**`src/composables/useAuthError.ts`
## 功能用途
统一处理 HTTP 401Unauthorized等权限相关错误根据登录状态返回用户友好的提示文案未登录时提示「请先登录」已登录时提示「权限不足」。
## 核心能力
- `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

View File

@ -16,6 +16,7 @@
- 限价订单:通过 `getOrderList` 获取当前市场未成交限价单,支持撤单
- 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent`
- Merge/Split通过 `TradeComponent` 或底部菜单触发,成功后监听 `mergeSuccess`/`splitSuccess` 事件刷新持仓
- **401 权限错误**:加载详情失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
## 使用方式

View File

@ -13,6 +13,7 @@
- Profit/Loss 卡片时间范围切换、ECharts 图表
- TabPositions、Open orders、History
- DepositDialog、WithdrawDialog 组件
- **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
## 使用方式

View File

@ -126,7 +126,10 @@ export class ClobSdk {
console.log(`[ClobSdk] 已连接到 ${this.url}`);
this.reconnectAttempts = 0;
this.notifyConnect(event);
this.subscribe();
setTimeout(() => {
this.subscribe();
}, 1000);
// this.subscribe();
};
this.ws.onmessage = (event: any) => {

View File

@ -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>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
<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-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-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="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>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
<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>
<!-- 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-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="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>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
<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-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-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="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>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
<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-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-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="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>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
<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-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-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="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>
<div class="amount-value">${{ amount.toFixed(2) }}</div>
<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-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-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="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 个价格档位01 区间规则19/1090/1009900/99109990/99919999 */
function buildAllowedLimitPrices(): number[] {
@ -1386,13 +1483,15 @@ export interface TradePositionItem {
const props = withDefaults(
defineProps<{
initialOption?: 'yes' | 'no'
/** 初始选中的 Tabbuy / sell如从持仓 Sell 打开时传 'sell' */
initialTab?: 'buy' | 'sell'
embeddedInSheet?: boolean
/** 从外部传入的市场数据(如 EventMarkets 点击 Yes/No 传入yesPrice/noPrice 为 01 */
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 {

View 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 }
}

View File

@ -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",

View File

@ -85,7 +85,9 @@
"error": {
"requestFailed": "リクエストに失敗しました",
"loadFailed": "読み込みに失敗しました",
"invalidId": "無効な ID または slug"
"invalidId": "無効な ID または slug",
"pleaseLogin": "先にログインしてください",
"insufficientPermission": "権限がありません"
},
"activity": {
"comments": "コメント",

View File

@ -85,7 +85,9 @@
"error": {
"requestFailed": "요청 실패",
"loadFailed": "로드 실패",
"invalidId": "잘못된 ID 또는 slug"
"invalidId": "잘못된 ID 또는 slug",
"pleaseLogin": "먼저 로그인하세요",
"insufficientPermission": "권한이 없습니다"
},
"activity": {
"comments": "댓글",

View File

@ -85,7 +85,9 @@
"error": {
"requestFailed": "请求失败",
"loadFailed": "加载失败",
"invalidId": "无效的 ID 或 slug"
"invalidId": "无效的 ID 或 slug",
"pleaseLogin": "请先登录",
"insufficientPermission": "权限不足"
},
"activity": {
"comments": "评论",

View File

@ -85,7 +85,9 @@
"error": {
"requestFailed": "請求失敗",
"loadFailed": "載入失敗",
"invalidId": "無效的 ID 或 slug"
"invalidId": "無效的 ID 或 slug",
"pleaseLogin": "請先登入",
"insufficientPermission": "權限不足"
},
"activity": {
"comments": "評論",

View File

@ -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 {
/** 用户 IDAPI 可能返回 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,
}
})

View File

@ -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>

View File

@ -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'))

View File

@ -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/splityesPrice/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;

View File

@ -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(() => {