This commit is contained in:
2026-08-22 21:50:39 +03:30
parent 444178df05
commit be59cca162
5 changed files with 215 additions and 43 deletions
+8
View File
@@ -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
View File
@@ -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()
+6 -2
View File
@@ -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,
+24 -2
View File
@@ -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
View File
@@ -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;