402 lines
12 KiB
TypeScript
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,
|
|
}
|
|
}
|