chore: sync price monitor updates

This commit is contained in:
2026-09-28 18:04:49 +03:30
parent f778064aef
commit 9cb0979606
7 changed files with 88 additions and 112 deletions
-2
View File
@@ -35,8 +35,6 @@ export interface Product {
updated_at: any
deleted_at: any
price?:number | undefined
price_buy?: number | null
price_sell?: number | null
price_profit?: number | null
price_offset_percent?: number | null
sellPrice?:number | undefined
+37 -19
View File
@@ -7,6 +7,9 @@ import {
priceSocketConnected,
priceStreamConnectingSince,
priceStreamLifecycle,
priceStreamList,
priceStreamProducts,
priceStreamServerUnix,
priceWssEnabled,
} from '@/utils/priceSocketState'
import {
@@ -282,28 +285,21 @@ export function notifyPriceLastFetchFailed() {
void maybeShowNetworkAlert()
}
function onTvBoard() {
if (typeof window === 'undefined') return false
const path = window.location.pathname.replace(/\/+$/, '') || '/'
return path === '/tv' || path.endsWith('/tv') || path === '/monitor' || path.endsWith('/monitor')
}
async function runFallbackPoll() {
if (onTvBoard() || tvStreamKey || !priceWssEnabled.value || priceSocketConnected.value) {
if (tvStreamKey || !priceWssEnabled.value || priceSocketConnected.value) {
return
}
try {
const { getLastPrices, hasRecentPriceFromSource } = await import('@/utils/price')
const jobs: Promise<unknown>[] = [getLastPrices()]
if (!tvStreamKey) {
const { refreshProductsPageData } = await import(
'@/views/base/products/composables/useProductsPageData'
)
jobs.push(refreshProductsPageData())
}
const { refreshProductsPageData } = await import(
'@/views/base/products/composables/useProductsPageData'
)
const [priceResult] = await Promise.allSettled(jobs)
const [priceResult] = await Promise.allSettled([
getLastPrices(),
refreshProductsPageData(),
])
const priceOk =
priceResult.status === 'fulfilled'
@@ -508,6 +504,7 @@ function handleListPayload(payload: unknown) {
}
touchStreamActivity()
priceStreamList.value = products as Array<Record<string, unknown>>
applyProductsListFromStream(products)
}
@@ -572,6 +569,31 @@ function bindStreamEvents(source: AuthPriceStream) {
}
})
source.addEventListener('products', (event) => {
try {
const rows = JSON.parse(event.data)
if (Array.isArray(rows) && rows.length) {
touchStreamActivity()
priceStreamProducts.value = rows
}
} catch (error) {
console.warn('Price SSE products parse failed', error)
}
})
source.addEventListener('time', (event) => {
try {
const payload = JSON.parse(event.data) as { unix?: number }
const unix = Number(payload?.unix)
if (unix > 0) {
touchStreamActivity()
priceStreamServerUnix.value = unix
}
} catch (error) {
console.warn('Price SSE time parse failed', error)
}
})
source.addEventListener('app_notification', (event) => {
try {
handleAppNotificationPayload(JSON.parse(event.data))
@@ -750,10 +772,6 @@ function unbindBrowserNetworkListeners() {
window.removeEventListener('offline', onBrowserOffline)
}
export function isTvStreamActive() {
return Boolean(tvStreamKey)
}
export function startTvPriceStream(
settingGetter: (key: string) => unknown,
key: string,
+9
View File
@@ -9,6 +9,15 @@ export const priceStreamLifecycle = ref<PriceStreamLifecycle>('idle')
export const priceStreamConnectingSince = ref(0)
/** آخرین رویداد list استریم */
export const priceStreamList = ref<Array<Record<string, unknown>>>([])
/** آخرین بدنهٔ tv/products که از SSE آمده */
export const priceStreamProducts = ref<Array<Record<string, unknown>>>([])
/** unix ساعت سرور از رویداد time استریم */
export const priceStreamServerUnix = ref(0)
const CONNECTING_POLL_AFTER_MS = 8000
export function shouldUsePricePolling(): boolean {
+6 -11
View File
@@ -61,15 +61,6 @@ function sellCashFee(product: Product, base: number) {
return resolveProductCashFeeSellRial(product, base, cashFeeOptions(product, 'sell'))
}
/** سمت خرید/فروش همکار؛ اگر سرور نفرستاده باشد همان قیمت وسط قبلی */
function colleagueSideUnit(product: Product, side: 'buy' | 'sell', live: number): number {
const sided = side === 'buy' ? Number(product.price_buy ?? 0) : Number(product.price_sell ?? 0)
if (sided > 0) {
return sided
}
return getColleagueSourcedUnitPrice(product, live)
}
/** قیمت منبع همکار؛ اگر خالی بود آبشده همان مظنهٔ صفحه اصلی را می‌گیرد */
function getColleagueSourcedUnitPrice(product: Product, live: number, apiPrice = Number(product.price ?? 0)): number {
if (apiPrice > 0) {
@@ -143,7 +134,7 @@ export function getProductBaseBuyPrice(product?: Product | null): number {
}
if (isColleaguePriceProduct(product)) {
return colleagueSideUnit(product, 'buy', getTradeBuyPrice(lastPrice.value))
return getColleagueSourcedUnitPrice(product, getTradeBuyPrice(lastPrice.value))
}
if (isMeltedProductUsingAppPrice(product) || isAppPriceProduct(product)) {
@@ -220,7 +211,11 @@ export function getProductBaseSellPrice(product?: Product | null): number {
}
if (isColleaguePriceProduct(product)) {
return colleagueSideUnit(product, 'sell', getTradeSellPrice(lastPrice.value))
return getColleagueSourcedUnitPrice(
product,
getTradeSellPrice(lastPrice.value),
Number(product.sellPrice ?? product.price ?? 0),
)
}
if (isMeltedProductUsingAppPrice(product) || isAppPriceProduct(product)) {
@@ -182,13 +182,13 @@ function applyProductsList(products: Product[]) {
productsPageLoading.value = false
}
/** قیمت enrich شده فقط در HTTP کامل است؛ slim SSE برای پرچم‌هاست */
function onTvBoard() {
if (typeof window === 'undefined') return false
const path = window.location.pathname.replace(/\/+$/, '') || '/'
return path === '/tv' || path.endsWith('/tv') || path === '/monitor' || path.endsWith('/monitor')
}
/** قیمت enrich شده فقط در HTTP کامل است؛ slim SSE برای پرچم‌هاست */
function scheduleCatalogRefreshFromStream() {
if (onTvBoard()) return
if (catalogStreamRefreshTimer) {
@@ -196,10 +196,7 @@ function scheduleCatalogRefreshFromStream() {
}
catalogStreamRefreshTimer = setTimeout(() => {
catalogStreamRefreshTimer = undefined
void import('@/utils/priceSocket').then(({ isTvStreamActive }) => {
if (isTvStreamActive()) return
void refreshProductsPageData()
})
void refreshProductsPageData()
}, 150)
}
+29 -49
View File
@@ -4,11 +4,12 @@ import type { Product } from '@/models/products'
import { getTvProducts, getTvServerTime } from '@/api/products'
import { getConfigApp } from '@/utils/config'
import { useSettingsStore } from '@/store/settingsStore'
import { getTvProductBuyPrice, getTvProductSellPrice } from '@/utils/productPrice'
import { getTvProductBuyPrice, getTvProductSellPrice, isTvProductPriceInactive } from '@/utils/productPrice'
import { getLastPrices, getTradeBuyPrice, getTradeSellPrice, lastPrice, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { usePriceWithoutType } from '@/utils/usePrice'
import { getStoreClosedMessage, isStoreClosedBySchedule, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
import { isMeltedTradeGroup, isMetalCommodityGroup } from '@/utils/productCategories'
import { priceSocketConnected, priceStreamLifecycle } from '@/utils/priceSocketState'
import { priceSocketConnected, priceStreamLifecycle, priceStreamProducts, priceStreamServerUnix } from '@/utils/priceSocketState'
import { startTvPriceStream, stopTvPriceStream } from '@/utils/priceSocket'
import logoFallback from '@/assets/img/logo_full_.png'
//@ts-ignore
@@ -130,6 +131,16 @@ export function useTvMonitorPage() {
startTvPriceStream((settingKey) => settingsStore.getSettingByKey(settingKey), secret)
}
watch(priceStreamProducts, (rows) => {
if (!rows.length) return
products.value = (rows as Product[]).map(normalizeTvProduct)
})
watch(priceStreamServerUnix, (unix) => {
if (!unix) return
serverOffsetMs = unix * 1000 - Date.now()
})
const brandName = computed(
() => String(getConfigApp('name_fa') || getConfigApp('name') || 'تابلو قیمت'),
)
@@ -222,44 +233,30 @@ export function useTvMonitorPage() {
|| tvNewsItems.value.length > 0,
)
const statusBanner = computed(() => (isStoreClosedBySchedule() ? getStoreClosedMessage() : ''))
const mainTvProduct = computed(() => {
void settingsStore.settingsRevision
const raw = String(settingsStore.getSettingByKey('main_product') ?? '').trim() || '13'
const match = catalogProducts.value.find(
(product) => String(product.id) === raw || String(product.reading_method ?? '') === raw,
)
if (!match) return null
if (products.value.some(isShowOnTv) && !isShowOnTv(match)) return null
return match
const statusBanner = computed(() => {
void lastPrice.value
const state = getPriceDisplayState()
return state === 'open' ? '' : getPriceDisplayMessage(state)
})
const showMainQuote = computed(() => !!mainTvProduct.value)
const liveBuy = computed(() => Number(mainTvProduct.value?.tv_price ?? 0) || 0)
const liveSell = computed(() => Number(mainTvProduct.value?.tv_sell_price ?? 0) || 0)
const mainQuoteReady = computed(() => liveBuy.value > 0 || liveSell.value > 0)
const liveBuy = computed(() => getTradeBuyPrice(lastPrice.value))
const liveSell = computed(() => getTradeSellPrice(lastPrice.value))
const isLive = computed(
() => priceSocketConnected.value || priceStreamLifecycle.value === 'open',
)
let productsInFlight = false
async function loadProducts() {
if (productsInFlight) return
productsInFlight = true
if (priceStreamProducts.value.length) return
try {
const list = await getTvProducts()
if (priceStreamProducts.value.length) return
products.value = (Array.isArray(list) ? list : []).map(normalizeTvProduct)
} catch {
if (!products.value.length) {
products.value = []
}
} finally {
productsInFlight = false
loading.value = false
}
}
@@ -340,12 +337,8 @@ export function useTvMonitorPage() {
return simorghWeightLabel(product)
}
function tvQuoteMissing(product: Product) {
return Number(product.tv_price ?? 0) <= 0 && Number(product.tv_sell_price ?? 0) <= 0
}
function isProductTvInactive(product: Product) {
return tvQuoteMissing(product)
return isTvProductPriceInactive(product)
}
function productBuyLabel(product: Product) {
@@ -359,24 +352,9 @@ export function useTvMonitorPage() {
}
function liveQuoteLabel(value: number) {
if (!mainQuoteReady.value) return NO_QUOTE_MESSAGE
const label = formatTvPrice(value)
return label === '—' ? NO_QUOTE_MESSAGE : label
return formatTvPrice(value)
}
function streamIsUp() {
return priceSocketConnected.value || priceStreamLifecycle.value === 'open'
}
function pollTvProducts() {
if (streamIsUp()) return
void loadProducts()
}
watch(priceSocketConnected, (up) => {
if (!up) pollTvProducts()
})
onMounted(async () => {
document.documentElement.classList.add(TV_HTML_CLASS)
await syncServerClock()
@@ -387,10 +365,13 @@ export function useTvMonitorPage() {
document.addEventListener('visibilitychange', onVisibilityChange)
await requestWakeLock()
startLastPricePolling()
void getLastPrices()
void loadProducts()
listTimer = setInterval(() => {
if (isLive.value) return
void loadProducts()
void syncServerClock()
pollTvProducts()
}, REFRESH_MS)
document.title = `${brandName.value} | تابلو قیمت`
@@ -405,6 +386,7 @@ export function useTvMonitorPage() {
window.removeEventListener('mousemove', hideCursorSoon)
document.removeEventListener('visibilitychange', onVisibilityChange)
void releaseWakeLock()
stopLastPricePolling()
stopTvPriceStream()
})
@@ -426,8 +408,6 @@ export function useTvMonitorPage() {
tvNewsItems,
newsTickerTitle,
statusBanner,
showMainQuote,
mainQuoteReady,
liveBuy,
liveSell,
isLive,
+5 -26
View File
@@ -15,18 +15,18 @@
</div>
</div>
<div v-if="showMainQuote" class="tv-monitor__quote">
<div class="tv-monitor__quote">
<span class="tv-monitor__quote-label">مظنه لحظه‌ای</span>
<div class="tv-monitor__quote-prices">
<div class="tv-monitor__quote-item tv-monitor__quote-item--sell">
<span>فروش</span>
<strong :class="{ 'is-empty': !mainQuoteReady }">{{ liveQuoteLabel(liveBuy) }}</strong>
<em v-if="mainQuoteReady">تومان</em>
<strong>{{ liveQuoteLabel(liveBuy) }}</strong>
<em>تومان</em>
</div>
<div class="tv-monitor__quote-item tv-monitor__quote-item--buy">
<span>خرید</span>
<strong :class="{ 'is-empty': !mainQuoteReady }">{{ liveQuoteLabel(liveSell) }}</strong>
<em v-if="mainQuoteReady">تومان</em>
<strong>{{ liveQuoteLabel(liveSell) }}</strong>
<em>تومان</em>
</div>
</div>
</div>
@@ -267,9 +267,6 @@ const {
tvNewsItems,
newsTickerTitle,
statusBanner,
showMainQuote,
mainQuoteReady,
mainQuoteReady,
liveBuy,
liveSell,
isLive,
@@ -392,7 +389,6 @@ const coinColumns = computed(() => [
}
.tv-monitor__brand {
grid-column: 1;
display: flex;
align-items: center;
gap: 14px;
@@ -423,7 +419,6 @@ const coinColumns = computed(() => [
}
.tv-monitor__quote {
grid-column: 2;
display: flex;
flex-direction: column;
align-items: center;
@@ -481,17 +476,9 @@ const coinColumns = computed(() => [
&--buy strong {
color: var(--tv-buy);
}
strong.is-empty {
font-size: clamp(13px, 1.15vw, 18px);
font-weight: 600;
direction: rtl;
color: var(--tv-muted);
}
}
.tv-monitor__clock {
grid-column: 3;
display: flex;
flex-direction: column;
align-items: flex-end;
@@ -1105,14 +1092,6 @@ const coinColumns = computed(() => [
order: 3;
}
.tv-monitor__clock {
grid-column: 2;
}
.tv-monitor__clock {
grid-column: 2;
}
.tv-monitor__columns {
grid-template-columns: 1fr;
overflow: auto;