新增:购买成功后的提示

This commit is contained in:
ivan 2026-02-25 18:40:46 +08:00
parent 84ed7de42c
commit 359059bff6
7 changed files with 277 additions and 0 deletions

View File

@ -2,6 +2,7 @@
import { computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from './stores/user'
import Toast from './components/Toast.vue'
const route = useRoute()
const router = useRouter()
@ -92,6 +93,7 @@ watch(
</keep-alive>
</router-view>
</v-main>
<Toast />
</v-app>
</template>

143
src/components/Toast.vue Normal file
View File

@ -0,0 +1,143 @@
<template>
<Teleport to="body">
<div class="toast-container">
<TransitionGroup name="toast" tag="div" class="toast-stack">
<div
v-for="item in toastStore.displaying"
:key="item.id"
:class="['toast-item', `toast-item--${item.type}`]"
role="status"
aria-live="polite"
>
<span class="toast-message">
{{ item.count > 1 ? `${item.message} (×${item.count})` : item.message }}
</span>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useToastStore } from '@/stores/toast'
import type { ToastItem } from '@/stores/toast'
const toastStore = useToastStore()
const TIMEOUT_MS = 3000
const timers = new Map<string, ReturnType<typeof setTimeout>>()
function scheduleRemove(item: ToastItem) {
const t = setTimeout(() => {
toastStore.remove(item.id)
timers.delete(item.id)
}, TIMEOUT_MS)
timers.set(item.id, t)
}
function syncTimers() {
for (const item of toastStore.displaying) {
const existing = timers.get(item.id)
if (existing) clearTimeout(existing)
scheduleRemove(item)
}
for (const [id] of timers) {
if (!toastStore.displaying.some((d) => d.id === id)) {
clearTimeout(timers.get(id)!)
timers.delete(id)
}
}
}
onMounted(() => {
syncTimers()
const stop = toastStore.$subscribe(syncTimers)
onUnmounted(() => {
stop()
timers.forEach((t) => clearTimeout(t))
timers.clear()
})
})
</script>
<style scoped>
.toast-container {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10000;
pointer-events: none;
/* 避开顶部导航栏(约 64px+ 安全区域 */
padding: max(72px, calc(64px + env(safe-area-inset-top))) 16px 16px;
display: flex;
justify-content: center;
}
.toast-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: min(520px, calc(100vw - 32px));
}
.toast-item {
position: relative;
width: 100%;
padding: 12px 28px;
border-radius: 8px;
backdrop-filter: blur(8px);
text-align: center;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: auto;
}
.toast-item--success {
background: rgba(76, 175, 80, 0.65);
color: rgba(255, 255, 255, 0.95);
}
.toast-item--error {
background: rgba(244, 67, 54, 0.65);
color: rgba(255, 255, 255, 0.95);
}
.toast-item--warning {
background: rgba(255, 152, 0, 0.65);
color: rgba(33, 33, 33, 0.95);
}
.toast-item--info {
background: rgba(33, 150, 243, 0.65);
color: rgba(255, 255, 255, 0.95);
}
.toast-message {
display: block;
width: 100%;
}
/* Transition */
.toast-enter-active,
.toast-leave-active {
transition: all 0.25s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.toast-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* 下方 Toast 在上方消失时平缓上移 */
.toast-move {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@ -1461,6 +1461,7 @@ const orderError = ref('')
// Emits
const emit = defineEmits<{
optionChange: [option: 'yes' | 'no']
orderSuccess: []
submit: [
payload: {
side: 'buy' | 'sell'
@ -1764,6 +1765,7 @@ async function submitOrder() {
)
if (res.code === 0 || res.code === 200) {
userStore.fetchUsdcBalance()
emit('orderSuccess')
} else {
orderError.value = res.msg || '下单失败'
}

105
src/stores/toast.ts Normal file
View File

@ -0,0 +1,105 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export type ToastType = 'success' | 'error' | 'info' | 'warning'
export interface ToastItem {
id: string
message: string
type: ToastType
count: number
addedAt: number
}
const DEDUP_MS = 3000
const MAX_VISIBLE = 3
let idCounter = 0
function genId() {
return `toast-${++idCounter}`
}
function normalizeMsg(msg: string) {
return msg.trim().replace(/\s+/g, ' ')
}
export const useToastStore = defineStore('toast', () => {
/** 当前正在显示的 Toast最多 MAX_VISIBLE 条) */
const displaying = ref<ToastItem[]>([])
/** 待显示的队列 */
const queue = ref<ToastItem[]>([])
/** 每种 message+type 最后一次出现的时间(添加或合并时更新) */
const lastSeenAt = ref<Record<string, number>>({})
function mergeKey(msg: string, type: ToastType) {
return `${type}:${normalizeMsg(msg)}`
}
/** 尝试合并:同 message+type 且在 DEDUP_MS 内(以最后一次为准)合并为 count+1 */
function tryMerge(item: ToastItem): boolean {
const now = Date.now()
const key = mergeKey(item.message, item.type)
const last = lastSeenAt.value[key] ?? 0
if (now - last >= DEDUP_MS) return false
const msg = normalizeMsg(item.message)
const inDisplayingIdx = displaying.value.findLastIndex(
(d) => normalizeMsg(d.message) === msg && d.type === item.type
)
if (inDisplayingIdx >= 0) {
displaying.value = displaying.value.map((x, i) =>
i === inDisplayingIdx
? { ...x, count: x.count + 1, addedAt: now }
: x
)
lastSeenAt.value[key] = now
return true
}
const inQueueReversed = [...queue.value].reverse()
const inQueueIdx = inQueueReversed.findIndex(
(q) => normalizeMsg(q.message) === msg && q.type === item.type
)
if (inQueueIdx >= 0) {
const origIdx = queue.value.length - 1 - inQueueIdx
queue.value = queue.value.map((x, i) =>
i === origIdx ? { ...x, count: x.count + 1, addedAt: now } : x
)
lastSeenAt.value[key] = now
return true
}
return false
}
function show(msg: string, toastType: ToastType = 'success') {
const item: ToastItem = {
id: genId(),
message: msg.trim(),
type: toastType,
count: 1,
addedAt: Date.now(),
}
if (tryMerge(item)) return
const key = mergeKey(item.message, item.type)
lastSeenAt.value[key] = item.addedAt
if (displaying.value.length < MAX_VISIBLE) {
displaying.value = [...displaying.value, item]
} else {
queue.value = [...queue.value, item]
}
}
/** 某条 Toast 关闭后调用 */
function remove(id: string) {
displaying.value = displaying.value.filter((d) => d.id !== id)
if (queue.value.length > 0) {
const next = queue.value[0]
queue.value = queue.value.slice(1)
displaying.value = [...displaying.value, next]
}
}
return { displaying, queue, show, remove }
})

View File

@ -174,6 +174,7 @@
:initial-option="tradeInitialOption"
embedded-in-sheet
@submit="onTradeSubmit"
@order-success="onOrderSuccess"
/>
</v-bottom-sheet>
</template>
@ -198,6 +199,7 @@ import {
} from '../api/event'
import { MOCK_EVENT_LIST } from '../api/mockEventList'
import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast'
const route = useRoute()
const router = useRouter()
@ -559,6 +561,12 @@ function onTradeSubmit(payload: {
console.log('Trade submit', payload)
}
const toastStore = useToastStore()
function onOrderSuccess() {
tradeSheetOpen.value = false
toastStore.show('下单成功')
}
function marketChance(market: PmEventMarketItem): number {
const raw = market?.outcomePrices?.[0]
if (raw == null) return 0

View File

@ -207,6 +207,7 @@
:key="`trade-${tradeDialogMarket?.id}-${tradeDialogSide}`"
:market="homeTradeMarketPayload"
:initial-option="tradeDialogSide"
@order-success="onOrderSuccess"
/>
</v-dialog>
<v-bottom-sheet v-else v-model="tradeDialogOpen" content-class="trade-bottom-sheet">
@ -215,6 +216,7 @@
:market="homeTradeMarketPayload"
:initial-option="tradeDialogSide"
embedded-in-sheet
@order-success="onOrderSuccess"
/>
</v-bottom-sheet>
</v-container>
@ -321,6 +323,7 @@ import {
type CategoryTreeNode,
} from '../api/category'
import { useSearchHistory } from '../composables/useSearchHistory'
import { useToastStore } from '../stores/toast'
const { mobile } = useDisplay()
const searchHistory = useSearchHistory()
@ -520,6 +523,12 @@ function onCardOpenTrade(
tradeDialogOpen.value = true
}
const toastStore = useToastStore()
function onOrderSuccess() {
tradeDialogOpen.value = false
toastStore.show('下单成功')
}
/** 传给 TradeComponent 的 marketHome 弹窗/底部栏),供 Split、下单等使用 */
const homeTradeMarketPayload = computed(() => {
const m = tradeDialogMarket.value

View File

@ -181,6 +181,7 @@
:market="tradeMarketPayload"
:initial-option="tradeInitialOptionFromBar"
embedded-in-sheet
@order-success="onOrderSuccess"
/>
</v-bottom-sheet>
</template>
@ -205,6 +206,7 @@ import {
} from '../api/event'
import { getClobWsUrl } from '../api/request'
import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast'
import {
ClobSdk,
type PriceSizePolyMsg,
@ -526,6 +528,12 @@ function openSplitFromBar() {
})
}
const toastStore = useToastStore()
function onOrderSuccess() {
tradeSheetOpen.value = false
toastStore.show('下单成功')
}
// Comments / Top Holders / Activity
const detailTab = ref('activity')
const activityMinAmount = ref<string>('0')