From 449fad5ed1c04779b15ee886ce08b1c53a3e50bc Mon Sep 17 00:00:00 2001 From: adonis Date: Thu, 27 Aug 2026 18:47:42 +0330 Subject: [PATCH] fix(price): delay empty-quote UI until settle window Avoid flashing no-quote too early while waiting for live/SSE price after first empty response. Co-authored-by: Cursor --- src/utils/price.ts | 64 ++++++++++++++++++++++++++++++++++++++-- src/utils/priceSocket.ts | 25 ++++++++++++++-- src/utils/storeHours.ts | 19 +++++++++--- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/src/utils/price.ts b/src/utils/price.ts index 78a757f..cf23cc3 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -19,10 +19,19 @@ export const price = ref() export const lastPrice = ref() /** حداقل یک بار تلاش برای گرفتن مظنه تمام شده (موفق یا ناموفق) */ export const lastPriceReady = ref(false) +/** + * بعد از اولین پاسخ خالی/no_quote، چند لحظه صبر می‌کنیم تا تیک SSE/زنده برسد + * و «مظنه در دسترس نیست» زودهنگام نشان داده نشود. + */ +export const lastPriceSettleGeneration = ref(0) export const olderPrice = ref(null) +const QUOTE_ABSENCE_SETTLE_MS = 2500 + let lastPriceSourceAt = 0 let consecutivePriceFetchFailures = 0 +let lastPriceReadyAt = 0 +let quoteSettleTimer: ReturnType | undefined /** buy/sell نمایش و معامله — همان buy/sell/price_last از /api/price/last */ export function getTradeBuyPrice(p?: Price | null) { @@ -50,6 +59,49 @@ function hasValidPricePayload(data?: Price | null): boolean { ) } +function clearQuoteSettleTimer() { + if (quoteSettleTimer) { + clearTimeout(quoteSettleTimer) + quoteSettleTimer = undefined + } +} + +function bumpQuoteSettle() { + lastPriceSettleGeneration.value += 1 +} + +function scheduleQuoteAbsenceSettle() { + clearQuoteSettleTimer() + if (hasValidPricePayload(lastPrice.value) || lastPrice.value?.status === 'closed') { + bumpQuoteSettle() + return + } + const elapsed = lastPriceReadyAt > 0 ? Date.now() - lastPriceReadyAt : 0 + const wait = Math.max(0, QUOTE_ABSENCE_SETTLE_MS - elapsed) + quoteSettleTimer = setTimeout(() => { + quoteSettleTimer = undefined + bumpQuoteSettle() + }, wait) +} + +function markLastPriceReady() { + if (!lastPriceReady.value) { + lastPriceReadyAt = Date.now() + } + lastPriceReady.value = true + scheduleQuoteAbsenceSettle() +} + +/** قیمت آمده، بسته قطعی است، یا پنجرهٔ انتظارِ مظنه خالی تمام شده */ +export function isLastPriceAbsenceSettled(): boolean { + void lastPriceSettleGeneration.value + if (!lastPriceReady.value) return false + if (hasValidPricePayload(lastPrice.value)) return true + if (lastPrice.value?.status === 'closed') return true + if (lastPriceReadyAt <= 0) return false + return Date.now() - lastPriceReadyAt >= QUOTE_ABSENCE_SETTLE_MS +} + function applyTradingHoursSettingsPatch(data: unknown) { if (!data || typeof data !== 'object') { return @@ -183,7 +235,7 @@ export async function getLastPrices(): Promise { markPriceSourceFetchFailed() return false } finally { - lastPriceReady.value = true + markLastPriceReady() } } @@ -192,13 +244,21 @@ export function applyLastPricePayload(dataLast: Price) { const normalized = normalizePriceResponse(applyBoardSpecialToLastPayload(dataLast as any) as Price) const nextBuy = getTradeBuyPrice(normalized) const prevBuy = getTradeBuyPrice(lastPrice.value) + const nextValid = hasValidPricePayload(normalized) + const prevValid = hasValidPricePayload(lastPrice.value) + + // قطع فید / no_quote خالی — قیمت فعلی را پاک نکن؛ فقط closed قطعی اجازهٔ صفر شدن دارد + if (!nextValid && prevValid && normalized.status !== 'closed') { + markLastPriceReady() + return + } if (prevBuy > 0 && nextBuy > 0 && prevBuy !== nextBuy) { olderPrice.value = prevBuy } lastPrice.value = normalized - lastPriceReady.value = true + markLastPriceReady() price.value = { ...normalized, buy: nextBuy, diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index 8c88318..5f9e5a2 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -426,9 +426,14 @@ function handleLastPayload(payload: unknown) { const raw = payload as Price & { settings_patch?: unknown } const last = applyBoardSpecialToLastPayload(raw) - const hasQuote = last.price_last != null || last.price != null || last.buy != null + const hasDisplayable = + Number(last.buy ?? 0) > 0 + || Number(last.sell ?? 0) > 0 + || Number(last.price_last ?? 0) > 0 + || Number(last.price ?? 0) > 0 const hasStatus = last.status != null || typeof last.closed === 'boolean' - if (!hasQuote && !hasStatus) { + + if (!hasDisplayable && !hasStatus) { if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) { touchStreamActivity() void applySettingsFromStream(raw.settings_patch) @@ -436,6 +441,22 @@ function handleLastPayload(payload: unknown) { return } + // SSE گاهی last خالی/no_quote می‌فرستد؛ قیمت معتبر فعلی را پاک نکن + if (!hasDisplayable) { + if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) { + touchStreamActivity() + void applySettingsFromStream(raw.settings_patch) + } + void import('@/utils/price').then(({ getTradeBuyPrice, lastPrice }) => { + const keepCurrent = + getTradeBuyPrice(lastPrice.value) > 0 && last.status !== 'closed' + touchStreamActivity() + if (keepCurrent) return + void applyLastFromStream(last) + }) + return + } + touchStreamActivity() void applyLastFromStream(last) } diff --git a/src/utils/storeHours.ts b/src/utils/storeHours.ts index 7f69b4e..1e8a2c6 100644 --- a/src/utils/storeHours.ts +++ b/src/utils/storeHours.ts @@ -1,6 +1,6 @@ import { ref } from 'vue' import { useSettingsStore } from '@/store/settingsStore' -import { lastPrice, lastPriceReady } from '@/utils/price' +import { isLastPriceAbsenceSettled, lastPrice, lastPriceReady } from '@/utils/price' export interface StoreTradingHours { start: string @@ -299,7 +299,10 @@ export function getPriceDisplayState(): PriceDisplayState { const p = lastPrice.value const serverStatus = p?.status - if (serverStatus === 'open' || serverStatus === 'closed' || serverStatus === 'no_quote') { + // اگر عدد قیمت هست، no_quote لحظه‌ای سرور را جدی نگیر (race با تیک بعدی) + if (serverStatus === 'no_quote' && hasShowableHomePrice(p)) { + // fall through به منطق کلاینت + } else if (serverStatus === 'open' || serverStatus === 'closed' || serverStatus === 'no_quote') { return serverStatus } @@ -310,19 +313,27 @@ export function getPriceDisplayState(): PriceDisplayState { if (hasLiveQuote(p)) return 'open' if (shouldShowLastSavedPrice() && hasShowableHomePrice(p)) return 'open' + if (hasShowableHomePrice(p)) return 'open' return 'no_quote' } -/** اولین دریافت مظنه / تنظیمات هنوز تمام نشده — اسکلتون، نه «مظنه در دسترس نیست» */ +/** + * اسکلتون تا وقتی قیمت قابل‌نمایش برسد، یا وضعیت بسته قطعی شود، + * یا بعد از اولین پاسخ خالی پنجرهٔ settle تمام شود (مظنه واقعاً نیست). + */ export function isHomeQuotePending(): boolean { const settings = useSettingsStore() void lastPriceReady.value void lastPrice.value + void isLastPriceAbsenceSettled() void settings.loaded void settings.settingsRevision - return !settings.loaded || !lastPriceReady.value + if (!settings.loaded || !lastPriceReady.value) return true + if (hasShowableHomePrice(lastPrice.value)) return false + if (lastPrice.value?.status === 'closed') return false + return !isLastPriceAbsenceSettled() } export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {