Files
ghatreh_user/src/views/tv/composables/useTvMonitorPage.ts
T
2026-09-14 12:57:53 +03:30

402 lines
12 KiB
TypeScript

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<HTMLElement | null>(null)
const { isFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(rootEl)
const products = ref<Product[]>([])
const loading = ref(true)
const nowLabel = ref('')
const dateLabel = ref('')
const cursorHidden = ref(false)
const wakeLockSentinel = ref<{ release: () => Promise<void> } | null>(null)
let clockTimer: ReturnType<typeof setInterval> | undefined
let cursorTimer: ReturnType<typeof setTimeout> | undefined
let listTimer: ReturnType<typeof setInterval> | 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<void> }> }
}
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,
}
}