211 lines
5.7 KiB
Vue
211 lines
5.7 KiB
Vue
<template>
|
|
<div class="tv-board">
|
|
<header class="tv-board__header">
|
|
<h1 class="tv-board__title">{{ shopName }}</h1>
|
|
<time class="tv-board__clock" dir="ltr">{{ clockText }}</time>
|
|
</header>
|
|
|
|
<p v-if="statusBanner" class="tv-board__banner">{{ statusBanner }}</p>
|
|
|
|
<div v-if="loading" class="tv-board__state">
|
|
<a-spin />
|
|
</div>
|
|
<div v-else-if="!rows.length" class="tv-board__state">
|
|
<BasicIcon icon="vuesax-linear:monitor" size="42" />
|
|
<span>محصولی برای نمایش روی تابلو انتخاب نشده است</span>
|
|
</div>
|
|
<div v-else class="tv-board__table">
|
|
<div class="tv-board__row tv-board__row--head">
|
|
<span>محصول</span>
|
|
<span>خرید</span>
|
|
<span>فروش</span>
|
|
</div>
|
|
<div v-for="row in rows" :key="row.id" class="tv-board__row">
|
|
<span class="tv-board__name">{{ row.title }}</span>
|
|
<span class="tv-board__buy" dir="ltr">{{ row.buyText }}</span>
|
|
<span class="tv-board__sell" dir="ltr">{{ row.sellText }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
|
import { getTvProducts, getTvServerTime } from '@/api/products'
|
|
import type { Product } from '@/models/products'
|
|
import { getConfigApp } from '@/utils/config'
|
|
import { getTvProductBuyPrice, getTvProductSellPrice, isTvProductPriceInactive } from '@/utils/productPrice'
|
|
import { lastPrice, getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
|
|
import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
|
|
import { usePriceWithoutType } from '@/utils/usePrice'
|
|
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
|
|
import { useSettingsStore } from '@/store/settingsStore'
|
|
|
|
const REFRESH_MS = 15000
|
|
|
|
const loading = ref(true)
|
|
const products = ref<Product[]>([])
|
|
const now = ref(Date.now())
|
|
let serverOffsetMs = 0
|
|
|
|
let clockTimer: ReturnType<typeof setInterval> | undefined
|
|
let listTimer: ReturnType<typeof setInterval> | undefined
|
|
|
|
const shopName = computed(
|
|
() => String(getConfigApp('name_fa') || getConfigApp('name') || 'تابلو قیمت'),
|
|
)
|
|
|
|
const clockText = computed(() =>
|
|
new Date(now.value).toLocaleTimeString('fa-IR', {
|
|
timeZone: 'Asia/Tehran',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
}),
|
|
)
|
|
|
|
const statusBanner = computed(() => {
|
|
void lastPrice.value
|
|
const state = getPriceDisplayState()
|
|
return state === 'open' ? '' : getPriceDisplayMessage(state)
|
|
})
|
|
|
|
function isShowOnTv(product: Product): boolean {
|
|
const value = product.show_on_tv as unknown
|
|
return value === true || value === 1 || value === '1' || value === 'true'
|
|
}
|
|
|
|
const rows = computed(() => {
|
|
void lastPrice.value
|
|
// API فقط محصولات تابلو را میدهد؛ اگر فیلد show_on_tv نبود، همه را نشان بده
|
|
const list = products.value
|
|
const marked = list.filter(isShowOnTv)
|
|
const source = marked.length ? marked : list
|
|
return source.map((product) => {
|
|
if (isTvProductPriceInactive(product)) {
|
|
return {
|
|
id: product.id,
|
|
title: product.title,
|
|
buyText: NO_QUOTE_MESSAGE,
|
|
sellText: NO_QUOTE_MESSAGE,
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: product.id,
|
|
title: product.title,
|
|
buyText: usePriceWithoutType(getTvProductBuyPrice(product)),
|
|
sellText: usePriceWithoutType(getTvProductSellPrice(product)),
|
|
}
|
|
})
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const settingsStore = useSettingsStore()
|
|
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
|
|
startLastPricePolling()
|
|
void getLastPrices()
|
|
await syncServerClock()
|
|
now.value = Date.now() + serverOffsetMs
|
|
void loadProducts()
|
|
clockTimer = setInterval(() => {
|
|
now.value = Date.now() + serverOffsetMs
|
|
}, 1000)
|
|
listTimer = setInterval(() => {
|
|
void loadProducts()
|
|
void syncServerClock()
|
|
}, REFRESH_MS)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopLastPricePolling()
|
|
if (clockTimer) {
|
|
clearInterval(clockTimer)
|
|
}
|
|
if (listTimer) {
|
|
clearInterval(listTimer)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.tv-board {
|
|
@apply min-h-screen w-full flex flex-col gap-5 p-6 text-white bg-gray-950;
|
|
}
|
|
|
|
.tv-board__header {
|
|
@apply flex items-center justify-between gap-4;
|
|
}
|
|
|
|
.tv-board__title {
|
|
@apply m-0 text-2xl font-bold;
|
|
}
|
|
|
|
.tv-board__clock {
|
|
@apply text-xl font-bold text-gray-300;
|
|
}
|
|
|
|
.tv-board__banner {
|
|
@apply m-0 rounded-xl bg-gray-800 px-4 py-3 text-center text-sm text-gray-200;
|
|
}
|
|
|
|
.tv-board__state {
|
|
@apply flex flex-1 flex-col items-center justify-center gap-3 text-gray-400;
|
|
}
|
|
|
|
.tv-board__table {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.tv-board__row {
|
|
@apply grid grid-cols-3 items-center gap-4 rounded-xl bg-gray-900 px-4 py-3;
|
|
}
|
|
|
|
.tv-board__row--head {
|
|
@apply bg-gray-800 text-sm text-gray-400;
|
|
}
|
|
|
|
.tv-board__name {
|
|
@apply text-lg font-bold;
|
|
}
|
|
|
|
.tv-board__buy {
|
|
@apply text-xl font-bold text-green-400;
|
|
}
|
|
|
|
.tv-board__sell {
|
|
@apply text-xl font-bold text-red-400;
|
|
}
|
|
</style>
|