From be59cca162c79d789bdfcbc400136d4314b6ff96 Mon Sep 17 00:00:00 2001 From: adonis Date: Sat, 22 Aug 2026 21:50:39 +0330 Subject: [PATCH] fix tv --- src/utils/price.ts | 8 + src/utils/priceSocket.ts | 148 ++++++++++++++----- src/views/base/tv/index.vue | 8 +- src/views/tv/composables/useTvMonitorPage.ts | 26 +++- src/views/tv/index.vue | 68 ++++++++- 5 files changed, 215 insertions(+), 43 deletions(-) diff --git a/src/utils/price.ts b/src/utils/price.ts index d521092..e2162de 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -50,10 +50,18 @@ function hasValidPricePayload(data?: Price | null): boolean { export function markPriceSourceReceived() { lastPriceSourceAt = Date.now() consecutivePriceFetchFailures = 0 + void import('@/utils/priceSocket').then(({ clearPriceNetworkAlert }) => { + clearPriceNetworkAlert() + }) } export function markPriceSourceFetchFailed() { consecutivePriceFetchFailures += 1 + if (consecutivePriceFetchFailures >= 2) { + void import('@/utils/priceSocket').then(({ notifyPriceLastFetchFailed }) => { + notifyPriceLastFetchFailed() + }) + } } export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean { diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index f22a0df..99bc306 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -24,15 +24,99 @@ export { const RECONNECT_BASE_MS = 3000 const RECONNECT_MAX_MS = 30000 -const FAILURES_BEFORE_ALERT = 4 +/** چند بار خطای پشت‌سرهم /api/price/last قبل از نمایش مدال */ +const LAST_FETCH_FAILURES_BEFORE_ALERT = 2 const ALERT_COOLDOWN_MS = 5 * 60 * 1000 const FALLBACK_POLL_INTERVAL_MS = 10000 const PRICE_SOURCE_STALE_MS_MOBILE = 50000 const PRICE_SOURCE_STALE_MS_DESKTOP = 35000 const STALE_STREAM_MS = 120000 const FALLBACK_POLL_DELAY_MS = 2500 +const STREAM_CONNECTING = 0 +const STREAM_OPEN = 1 +const STREAM_CLOSED = 2 -let eventSource: EventSource | null = null +class AuthPriceStream extends EventTarget { + readyState = STREAM_CONNECTING + onopen: (() => void) | null = null + onerror: (() => void) | null = null + onmessage: (() => void) | null = null + private abort = new AbortController() + + constructor(url: string) { + super() + void this.start(url) + } + + close() { + this.readyState = STREAM_CLOSED + this.abort.abort() + } + + private async start(url: string) { + try { + const response = await fetch(url, { + headers: { Accept: 'text/event-stream' }, + cache: 'no-store', + signal: this.abort.signal, + }) + + if (!response.ok || !response.body) { + this.readyState = STREAM_CLOSED + this.onerror?.() + return + } + + this.readyState = STREAM_OPEN + this.onopen?.() + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + buffer += decoder.decode(value, { stream: true }) + buffer = this.flushSse(buffer) + } + + this.readyState = STREAM_CLOSED + this.onerror?.() + } catch { + if (this.abort.signal.aborted) { + return + } + this.readyState = STREAM_CLOSED + this.onerror?.() + } + } + + private flushSse(buffer: string): string { + const blocks = buffer.split('\n\n') + const rest = blocks.pop() ?? '' + + for (const block of blocks) { + let event = 'message' + const data: string[] = [] + for (const line of block.split('\n')) { + if (line.startsWith('event:')) { + event = line.slice(6).trim() + } else if (line.startsWith('data:')) { + data.push(line.slice(5).trimStart()) + } + } + this.dispatchEvent(new MessageEvent(event, { data: data.join('\n') })) + this.onmessage?.() + } + + return rest + } +} + +let eventSource: AuthPriceStream | null = null let subscribers = 0 let stopped = false let getSetting: ((key: string) => unknown) | null = null @@ -131,7 +215,7 @@ function startStaleCheck() { stopped || subscribers <= 0 || !eventSource - || eventSource.readyState !== EventSource.OPEN + || eventSource.readyState !== STREAM_OPEN ) { return } @@ -184,31 +268,40 @@ function scheduleFallbackPoll() { }, FALLBACK_POLL_DELAY_MS) } +export function clearPriceNetworkAlert() { + networkAlertOpen = false + pendingPriceAlert = false +} + +/** وقتی /api/price/last خطا می‌دهد (از polling عادی یا fallback) */ +export function notifyPriceLastFetchFailed() { + void maybeShowNetworkAlert() +} + async function runFallbackPoll() { if (!priceWssEnabled.value || priceSocketConnected.value) { return } try { - const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import( - '@/utils/price' - ) + const { getLastPrices, hasRecentPriceFromSource } = await import('@/utils/price') const { refreshProductsPageData } = await import( '@/views/base/products/composables/useProductsPageData' ) - const [lastResult] = await Promise.allSettled([ + const [priceResult] = await Promise.allSettled([ getLastPrices(), refreshProductsPageData(), ]) const priceOk = - lastResult.status === 'fulfilled' - && lastResult.value === true + priceResult.status === 'fulfilled' + && priceResult.value === true && hasRecentPriceFromSource(getPriceSourceStaleMs()) - if (!priceOk) { - markPriceSourceFetchFailed() + if (priceOk) { + clearPriceNetworkAlert() + return } void maybeShowNetworkAlert() @@ -237,25 +330,7 @@ async function isRealPriceOutage(): Promise { return false } - const fetchFailures = consecutivePriceSourceFailures() - - if (fetchFailures >= 2) { - return true - } - - if (!priceSocketConnected.value && fetchFailures >= 1 && reconnectAttempt >= 2) { - return true - } - - if (!isMobileDevice() && !navigator.onLine && fetchFailures >= 1) { - return true - } - - if (!isMobileDevice() && reconnectAttempt >= FAILURES_BEFORE_ALERT) { - return true - } - - return false + return consecutivePriceSourceFailures() >= LAST_FETCH_FAILURES_BEFORE_ALERT } function showNetworkAlert() { @@ -316,7 +391,6 @@ function scheduleReconnect() { reconnectTimer = setTimeout(() => { reconnectAttempt += 1 connect() - maybeShowNetworkAlert() }, delay) } @@ -384,7 +458,7 @@ async function applyUnreadCountFromStreamPayload(payload: Record { try { handleLastPayload(JSON.parse(event.data)) @@ -475,7 +549,7 @@ function connect() { if (eventSource) { const state = eventSource.readyState - if (state === EventSource.OPEN || state === EventSource.CONNECTING) { + if (state === STREAM_OPEN || state === STREAM_CONNECTING) { return } disconnectSourceOnly() @@ -484,7 +558,7 @@ function connect() { markConnectingLifecycle() try { - eventSource = new EventSource(url) + eventSource = new AuthPriceStream(url) } catch (error) { console.warn('Price SSE connect failed', error) handleStreamLost() @@ -507,7 +581,7 @@ function connect() { eventSource.onerror = () => { const state = eventSource?.readyState - if (state === EventSource.OPEN || state === EventSource.CONNECTING) { + if (state === STREAM_OPEN || state === STREAM_CONNECTING) { return } @@ -621,7 +695,7 @@ function onVisibilityChange() { void maybeShowNetworkAlert() } - if (!eventSource || eventSource.readyState !== EventSource.OPEN) { + if (!eventSource || eventSource.readyState !== STREAM_OPEN) { forceReconnect() } } @@ -631,7 +705,7 @@ function onPageShow(event: PageTransitionEvent) { return } - if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) { + if (event.persisted || !eventSource || eventSource.readyState !== STREAM_OPEN) { reconnectAttempt = 0 networkAlertOpen = false forceReconnect() diff --git a/src/views/base/tv/index.vue b/src/views/base/tv/index.vue index c7f7aa8..042de34 100644 --- a/src/views/base/tv/index.vue +++ b/src/views/base/tv/index.vue @@ -71,12 +71,16 @@ const statusBanner = computed(() => { function isShowOnTv(product: Product): boolean { const value = product.show_on_tv as unknown - return value === true || value === 1 || value === '1' + return value === true || value === 1 || value === '1' || value === 'true' } const rows = computed(() => { void lastPrice.value - return products.value.filter(isShowOnTv).map((product) => { + // API فقط محصولات تابلو را می‌دهد؛ اگر فیلد show_on_tv نبود، همه را نشان بده + const list = products.value + const marked = list.filter(isShowOnTv) + const source = marked.length ? marked : list + return source.map((product) => { if (isProductPriceInactive(product)) { return { id: product.id, diff --git a/src/views/tv/composables/useTvMonitorPage.ts b/src/views/tv/composables/useTvMonitorPage.ts index ab6e368..7ff2183 100644 --- a/src/views/tv/composables/useTvMonitorPage.ts +++ b/src/views/tv/composables/useTvMonitorPage.ts @@ -28,7 +28,7 @@ function productText(product: Product) { function isShowOnTv(product: Product) { const value = product.show_on_tv as unknown - return value === true || value === 1 || value === '1' + return value === true || value === 1 || value === '1' || value === 'true' } function isParsianCoin(product: Product) { @@ -79,7 +79,13 @@ export function useTvMonitorPage() { ) const brandLogo = computed(() => String(getConfigApp('avatar') || logoFallback)) - const catalogProducts = computed(() => products.value.filter(isShowOnTv)) + // API فقط show_on_tv می‌دهد؛ فیلتر کلاینت برای دادهٔ ناقص است + const catalogProducts = computed(() => { + const list = products.value + if (!list.length) return list + const marked = list.filter(isShowOnTv) + return marked.length ? marked : list + }) const parsianCoins = computed(() => catalogProducts.value @@ -108,6 +114,20 @@ export function useTvMonitorPage() { .sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)), ) + /** آبشده، شمش، … — هر چیزی غیر از سکه/سیمرغ */ + const otherProducts = computed(() => + catalogProducts.value.filter( + (p) => !isParsianCoin(p) && !isBankCoin(p) && !isSimorghBar(p), + ), + ) + + const hasBoardContent = computed( + () => + hasCoinBoard.value + || otherProducts.value.length > 0 + || simorghBars.value.length > 0, + ) + const statusBanner = computed(() => { void lastPrice.value const state = getPriceDisplayState() @@ -263,7 +283,9 @@ export function useTvMonitorPage() { parsianCoins, bankCoins, hasCoinBoard, + hasBoardContent, coinRowCount, + otherProducts, simorghBars, statusBanner, liveBuy, diff --git a/src/views/tv/index.vue b/src/views/tv/index.vue index dfb675c..2faac49 100644 --- a/src/views/tv/index.vue +++ b/src/views/tv/index.vue @@ -135,7 +135,48 @@ -
+
+
+

محصولات تابلو

+ خرید و فروش +
+
+ +
+ فروش + خرید +
+
+
+
+
+ +
+
+

{{ item.title }}

+

{{ productWeightLabel(item) }}

+
+
+
+ {{ productBuyLabel(item) }} +
+
+ {{ productSellLabel(item) }} +
+
+
+
+
+ +
محصولی برای نمایش روی تابلو انتخاب نشده است
@@ -200,7 +241,9 @@ const { parsianCoins, bankCoins, hasCoinBoard, + hasBoardContent, coinRowCount, + otherProducts, simorghBars, statusBanner, liveBuy, @@ -420,24 +463,45 @@ const { } .tv-monitor__board { + display: flex; flex: 1; + flex-direction: column; min-height: 0; :deep(.tv-monitor__spin), :deep(.ant-spin-nested-loading), :deep(.ant-spin-container) { + display: flex; + flex: 1; + flex-direction: column; height: 100%; + min-height: 0; } } .tv-monitor__columns { display: grid; + flex: 1; grid-template-columns: 1fr 1fr; gap: clamp(12px, 1.6vw, 22px); - height: 100%; min-height: 0; } +.tv-col--others { + margin-top: clamp(12px, 1.6vw, 22px); + max-height: 42%; +} + +.tv-col--others-alone { + margin-top: 0; + height: 100%; + max-height: none; +} + +.tv-col__list--others { + grid-auto-rows: minmax(56px, auto); +} + .tv-col { display: flex; min-width: 0;