fix tv
This commit is contained in:
@@ -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 {
|
||||
|
||||
+111
-37
@@ -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<boolean> {
|
||||
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<string, unknown
|
||||
}
|
||||
}
|
||||
|
||||
function bindStreamEvents(source: EventSource) {
|
||||
function bindStreamEvents(source: AuthPriceStream) {
|
||||
source.addEventListener('last', (event) => {
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+66
-2
@@ -135,7 +135,48 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading" class="tv-monitor__empty">
|
||||
<section
|
||||
v-if="otherProducts.length"
|
||||
class="tv-col tv-col--others"
|
||||
:class="{ 'tv-col--others-alone': !hasCoinBoard }"
|
||||
>
|
||||
<header class="tv-col__head">
|
||||
<h2>محصولات تابلو</h2>
|
||||
<span>خرید و فروش</span>
|
||||
</header>
|
||||
<div class="tv-col__labels">
|
||||
<span class="tv-col__labels-gap" />
|
||||
<div class="tv-col__price-heads">
|
||||
<span class="tv-col__label tv-col__label--sell">فروش</span>
|
||||
<span class="tv-col__label tv-col__label--buy">خرید</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tv-col__list tv-col__list--others">
|
||||
<article
|
||||
v-for="item in otherProducts"
|
||||
:key="item.id"
|
||||
class="tv-row"
|
||||
>
|
||||
<div class="tv-row__media">
|
||||
<ProductImage :product="item" img-class="tv-row__img" />
|
||||
</div>
|
||||
<div class="tv-row__meta">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ productWeightLabel(item) }}</p>
|
||||
</div>
|
||||
<div class="tv-row__prices">
|
||||
<div class="tv-row__price tv-row__price--sell">
|
||||
<strong>{{ productBuyLabel(item) }}</strong>
|
||||
</div>
|
||||
<div class="tv-row__price tv-row__price--buy">
|
||||
<strong>{{ productSellLabel(item) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="!loading && !hasBoardContent" class="tv-monitor__empty">
|
||||
محصولی برای نمایش روی تابلو انتخاب نشده است
|
||||
</div>
|
||||
</a-spin>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user