import { computed, onMounted, onUnmounted, ref } from 'vue' import { useFullscreen } from '@vueuse/core' 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, isTvProductPriceInactive } from '@/utils/productPrice' import { getLastPrices, getTradeBuyPrice, getTradeSellPrice, lastPrice, startLastPricePolling, stopLastPricePolling } from '@/utils/price' import { usePriceWithoutType } from '@/utils/usePrice' import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours' import { isMeltedTradeGroup, isMetalCommodityGroup } from '@/utils/productCategories' import { priceSocketConnected, priceStreamLifecycle } from '@/utils/priceSocketState' import logoFallback from '@/assets/img/logo_full_.png' //@ts-ignore import moment from 'moment-jalaali' //@ts-ignore import fa from 'moment/src/locale/fa' moment.locale('fa', fa) moment.loadPersian({ usePersianDigits: false }) const CURSOR_HIDE_MS = 2500 const TV_HTML_CLASS = 'app-layout--tv' const REFRESH_MS = 15000 /** دقت تابلو: هزار تومان = ۱۰٬۰۰۰ ریال — باقی‌مانده به پایین حذف می‌شود */ const TV_TOMAN_STEP = 1000 function parseTvNews(raw: unknown): string[] { const text = String(raw ?? '').trim() if (!text) return [] try { const decoded = JSON.parse(text) if (Array.isArray(decoded)) { return decoded.map((item) => String(item ?? '').trim()).filter(Boolean) } } catch { // هر خط یک خبر } return text .split(/\r?\n/) .map((line) => line.replace(/^[-•*]\s+/, '').trim()) .filter(Boolean) } function floorTvToman(rial: number): number { if (!Number.isFinite(rial) || rial <= 0) return 0 return Math.floor(rial / 10 / TV_TOMAN_STEP) * TV_TOMAN_STEP } function formatTvPrice(rial: number): string { const toman = floorTvToman(rial) return toman > 0 ? usePriceWithoutType(toman) : '—' } function productText(product: Product) { return `${product.group_type ?? ''} ${product.title ?? ''}` } function isShowOnTv(product: Product) { const value = product.show_on_tv as unknown return value === true || value === 1 || value === '1' || value === 'true' } function isParsianCoin(product: Product) { return productText(product).includes('پارسیان') } function isBankCoin(product: Product) { if (isParsianCoin(product)) return false const group = (product.group_type ?? '').trim() const title = (product.title ?? '').trim() if (group === 'سکه' || group === 'سکه بانکی') return true if (group.includes('سکه') && !group.includes('پارسیان')) return true if (title.includes('بانکی')) return true // API سبک TV ممکن است group_type نداشته باشد if (title.includes('سکه') && !title.includes('پارسیان')) return true return false } function bankCoinRank(product: Product) { const text = productText(product) if (text.includes('نیم')) return 2 if (text.includes('ربع')) return 3 if (text.includes('تمام') || text.includes('امامی') || text.includes('بهار')) return 1 return 99 } function isSimorghBar(product: Product) { return productText(product).includes('سیمرغ') } function isGoldBar(product: Product) { if (isMeltedTradeGroup(product.group_type) || isMetalCommodityGroup(product.group_type)) { return false } const group = (product.group_type ?? '').trim() if (group.includes('شمش') || isSimorghBar(product)) { return true } return (product.title ?? '').includes('شمش') } export function useTvMonitorPage() { const settingsStore = useSettingsStore() const rootEl = ref(null) const { isFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(rootEl) const products = ref([]) const loading = ref(true) const nowLabel = ref('') const dateLabel = ref('') const cursorHidden = ref(false) const wakeLockSentinel = ref<{ release: () => Promise } | null>(null) let clockTimer: ReturnType | undefined let cursorTimer: ReturnType | undefined let listTimer: ReturnType | undefined /** اختلاف میلی‌ثانیهٔ ساعت سرور (Asia/Tehran) با Date.now محلی */ let serverOffsetMs = 0 const brandName = computed( () => String(getConfigApp('name_fa') || getConfigApp('name') || 'تابلو قیمت'), ) const brandLogo = computed(() => String(getConfigApp('avatar') || logoFallback)) // 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 }) function normalizeTvProduct(raw: Product): Product { const image = (raw as Product & { image?: string | null }).image if (image && !raw.img) { return { ...raw, img: image } } return raw } async function syncServerClock() { const t0 = Date.now() const payload = await getTvServerTime() const t1 = Date.now() if (!payload?.unix) return const rttHalf = (t1 - t0) / 2 serverOffsetMs = payload.unix * 1000 + rttHalf - t1 } const parsianCoins = computed(() => catalogProducts.value .filter(isParsianCoin) .sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)), ) const bankCoins = computed(() => catalogProducts.value .filter(isBankCoin) .sort((a, b) => bankCoinRank(a) - bankCoinRank(b) || Number(a.amount ?? 0) - Number(b.amount ?? 0)), ) const hasCoinBoard = computed( () => parsianCoins.value.length > 0 || bankCoins.value.length > 0, ) const coinRowCount = computed(() => Math.max(parsianCoins.value.length, bankCoins.value.length, 1), ) const simorghBars = computed(() => catalogProducts.value .filter(isSimorghBar) .sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)), ) /** تیکر بالا: فقط شمش‌های تابلو */ const tickerProducts = computed(() => catalogProducts.value .filter(isGoldBar) .sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)), ) const tickerTitle = computed(() => (tickerProducts.value.length ? 'مظنه شمش‌ها' : '')) /** تیکر پایین: بقیه — نه سکه، نه پارسیان، نه شمش */ const extraTickerProducts = computed(() => catalogProducts.value .filter((product) => !isParsianCoin(product) && !isBankCoin(product) && !isGoldBar(product)) .sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '', 'fa')), ) const extraTickerTitle = computed(() => extraTickerProducts.value.length ? 'سایر مظنه‌ها' : '', ) const tvNewsItems = computed(() => { void settingsStore.settingsRevision return parseTvNews(settingsStore.getSettingByKey('tv_news') ?? getConfigApp('tv_news')) }) const newsTickerTitle = computed(() => (tvNewsItems.value.length ? 'اخبار' : '')) const hasBoardContent = computed( () => hasCoinBoard.value || tickerProducts.value.length > 0 || extraTickerProducts.value.length > 0 || tvNewsItems.value.length > 0, ) const statusBanner = computed(() => { void lastPrice.value const state = getPriceDisplayState() return state === 'open' ? '' : getPriceDisplayMessage(state) }) const liveBuy = computed(() => getTradeBuyPrice(lastPrice.value)) const liveSell = computed(() => getTradeSellPrice(lastPrice.value)) const isLive = computed( () => priceSocketConnected.value || priceStreamLifecycle.value === 'open', ) async function loadProducts() { try { const list = await getTvProducts() products.value = (Array.isArray(list) ? list : []).map(normalizeTvProduct) } catch { if (!products.value.length) { products.value = [] } } finally { loading.value = false } } function updateClock() { const now = moment(Date.now() + serverOffsetMs).utcOffset(210) nowLabel.value = now.format('HH:mm:ss') dateLabel.value = now.format('dddd jD jMMMM jYYYY') } function hideCursorSoon() { cursorHidden.value = false if (cursorTimer) clearTimeout(cursorTimer) cursorTimer = setTimeout(() => { cursorHidden.value = true }, CURSOR_HIDE_MS) } async function requestWakeLock() { try { const nav = navigator as Navigator & { wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise }> } } wakeLockSentinel.value = (await nav.wakeLock?.request('screen')) ?? null } catch { wakeLockSentinel.value = null } } async function releaseWakeLock() { try { await wakeLockSentinel.value?.release() } catch { /* ignore */ } wakeLockSentinel.value = null } async function onVisibilityChange() { if (document.visibilityState === 'visible') { await requestWakeLock() } } async function toggleFullscreen(event?: Event) { event?.stopPropagation() hideCursorSoon() try { if (isFullscreen.value) { await exitFullscreen() } else { await enterFullscreen() } } catch { /* browser may block without gesture */ } } function productWeightLabel(product: Product) { if (isMeltedTradeGroup(product.group_type)) { return 'قیمت هر گرم' } const amount = Number(product.amount ?? 0) if (!amount) return '' if (amount < 1) return `${amount * 1000} سوت` return `${amount} گرم` } function simorghWeightLabel(product: Product) { const amount = Number(product.amount ?? 0) if (!amount) return product.title return `${amount} گرم` } function tickerItemLabel(product: Product) { const title = (product.title ?? '').trim() if (title) return title return simorghWeightLabel(product) } function isProductTvInactive(product: Product) { return isTvProductPriceInactive(product) } function productBuyLabel(product: Product) { if (isProductTvInactive(product)) return NO_QUOTE_MESSAGE return formatTvPrice(getTvProductBuyPrice(product)) } function productSellLabel(product: Product) { if (isProductTvInactive(product)) return NO_QUOTE_MESSAGE return formatTvPrice(getTvProductSellPrice(product)) } function liveQuoteLabel(value: number) { return formatTvPrice(value) } onMounted(async () => { document.documentElement.classList.add(TV_HTML_CLASS) await syncServerClock() updateClock() hideCursorSoon() clockTimer = setInterval(updateClock, 1000) window.addEventListener('mousemove', hideCursorSoon) document.addEventListener('visibilitychange', onVisibilityChange) await requestWakeLock() startLastPricePolling() void getLastPrices() void loadProducts() listTimer = setInterval(() => { void loadProducts() void syncServerClock() }, REFRESH_MS) document.title = `${brandName.value} | تابلو قیمت` }) onUnmounted(() => { document.documentElement.classList.remove(TV_HTML_CLASS) document.title = String(getConfigApp('name_fa') || getConfigApp('name') || '') if (clockTimer) clearInterval(clockTimer) if (cursorTimer) clearTimeout(cursorTimer) if (listTimer) clearInterval(listTimer) window.removeEventListener('mousemove', hideCursorSoon) document.removeEventListener('visibilitychange', onVisibilityChange) void releaseWakeLock() stopLastPricePolling() }) return { rootEl, brandName, brandLogo, nowLabel, dateLabel, parsianCoins, bankCoins, hasCoinBoard, hasBoardContent, coinRowCount, tickerProducts, tickerTitle, extraTickerProducts, extraTickerTitle, tvNewsItems, newsTickerTitle, statusBanner, liveBuy, liveSell, isLive, isFullscreen, cursorHidden, loading, productWeightLabel, tickerItemLabel, isProductTvInactive, productBuyLabel, productSellLabel, liveQuoteLabel, toggleFullscreen, } }