1405/05/29-add tv page

This commit is contained in:
2026-08-20 10:06:55 +03:30
parent 866ffbf774
commit d2737a4998
17 changed files with 1759 additions and 20 deletions
+1 -5
View File
@@ -13,20 +13,17 @@ declare module 'vue' {
ADivider: typeof import('ant-design-vue/es')['Divider']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AModal: typeof import('ant-design-vue/es')['Modal']
ARadio: typeof import('ant-design-vue/es')['Radio']
AppCreditLink: typeof import('./src/components/AppCreditLink.vue')['default']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASlider: typeof import('ant-design-vue/es')['Slider']
ASpin: typeof import('ant-design-vue/es')['Spin']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
BaseDatePicker: typeof import('./src/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue')['default']
Basic: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/basic.vue')['default']
@@ -78,6 +75,5 @@ declare module 'vue' {
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet']
VanDialog: typeof import('vant/es')['Dialog']
VanField: typeof import('vant/es')['Field']
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+139 -3
View File
@@ -46,7 +46,7 @@
</transition>
<div
v-if="showSupportButton && !showSplash && route.name !== 'support'"
v-if="showSupportButton && !showSplash && route.name !== 'support' && !hideAppChrome"
id="support-button"
ref="supportButtonEl"
class="draggable-support-button"
@@ -80,10 +80,24 @@
<VersionUpdateModal />
<PwaInstallGuideModal
v-if="!hideAppChrome"
v-model:open="guideOpen"
:platform="platform"
@complete="completeGuide()"
/>
<div
v-if="!showSplash && !hideAppChrome && !isDesktop"
class="app-credit-footer app-credit-footer--mobile"
>
<AppCreditLink variant="mobile" />
</div>
<div
v-if="!showSplash && !hideAppChrome && showDesktopFloatingCredit"
class="app-credit-footer app-credit-footer--desktop-float"
>
<AppCreditLink variant="float" />
</div>
</div>
</template>
@@ -103,7 +117,8 @@ import { useSupportMessenger } from '@/composables/useSupportMessenger'
import { loadSupportMessengerFromSettings } from '@/utils/supportMessenger'
import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue'
import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
import { useApplyDesktopLayoutClass } from '@/layouts/composables/useAppLayout'
import { useApplyDesktopLayoutClass, useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import AppCreditLink from '@/components/AppCreditLink.vue'
import { usePwaInstall } from '@/composables/usePwaInstall'
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
import { useUserStore } from '@/store/userStore'
@@ -125,11 +140,23 @@ const indicator = h(LoadingOutlined, {
spin: true
})
const displayVersion = getDisplayAppVersion(pkg.version)
const showSplash = ref(true)
const showSplash = ref(!isTvBoardPath())
const route = useRoute()
const hideAppChrome = computed(() => route.meta.hideChrome === true || route.name === 'tvMonitor')
function isTvBoardPath() {
const path = window.location.pathname.replace(/\/+$/, '')
return path.endsWith('/tv') || path.endsWith('/monitor')
}
useApplyDesktopLayoutClass()
const isDesktop = useDesktopBreakpoint()
const showDesktopFloatingCredit = computed(() => {
if (!isDesktop.value) return false
return route.name === 'login' || !!route.meta.hideMenu
})
const {
guideOpen,
platform,
@@ -185,11 +212,19 @@ function handleSupportPointerUp(event: PointerEvent) {
onMounted(async () => {
await loadSupportMessengerFromSettings()
if (hideAppChrome.value) {
showSplash.value = false
return
}
setTimeout(() => {
showSplash.value = false
}, 2000)
})
watch(hideAppChrome, (hidden) => {
if (hidden) showSplash.value = false
})
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const { latestIncomingNotification } = useChatService()
@@ -252,6 +287,107 @@ watch(latestIncomingNotification, (payload) => {
</script>
<style lang="less">
.app-credit-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000000;
box-sizing: content-box;
}
.app-credit-footer--mobile {
width: 100%;
max-width: 426px;
margin-left: auto;
margin-right: auto;
min-height: var(--app-credit-bar-inner);
padding-bottom: var(--app-safe-bottom);
background: #fff;
box-shadow: 0 -2px 4px -2px #0000001a;
display: flex;
justify-content: center;
font-size: 9px;
}
.app-credit-footer--desktop-float {
left: auto;
right: 20px;
bottom: 16px;
width: auto;
}
.app-credit-link {
display: inline-flex;
align-items: center;
text-decoration: none;
color: inherit;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.85;
}
&__text {
font-weight: 500;
white-space: nowrap;
}
&__logo {
width: auto;
object-fit: contain;
}
&--mobile {
gap: 4px;
padding: 4px 0;
color: inherit;
.app-credit-link__logo {
height: 12px;
}
}
&--sidebar {
flex-direction: column;
gap: 6px;
padding: 4px;
color: #9ca3af;
font-size: 10px;
.app-credit-link__logo {
height: 15px;
opacity: 0.85;
}
}
&--float {
gap: 6px;
padding: 7px 14px;
font-size: 10px;
color: #6b7280;
background: #fff;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
.app-credit-link__logo {
height: 13px;
}
}
&--tv {
gap: 8px;
padding: 6px 0;
font-size: 12px;
color: #6b7280;
.app-credit-link__logo {
height: 16px;
}
}
}
.updateButton .van-dialog__confirm .van-button__text {
color: @primary-color !important;
font-weight: 600;
+15 -4
View File
@@ -33,8 +33,17 @@ export function setupRouterGuards() {
}
}
function isGuestAllowed(to: RouteLocationNormalized) {
if (to.meta?.public) {
return true
}
return ['login', '404', 'buyProduct', 'productDetail', 'about', 'cart', 'tvMonitor'].includes(
to.name as string,
)
}
function goToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
if (to.name === 'buyProduct' || to.name === 'productDetail' || to.name === 'about' || to.name === 'cart' || to.name === 'tv') {
if (isGuestAllowed(to)) {
return next()
}
if (to.name !== 'login') {
@@ -53,7 +62,9 @@ export function setupRouterGuards() {
router.beforeEach(async (to, from, next) => {
authStore.setLoading(true)
clearFingerTimeout()
setFingerTimeOut(to)
if (to.name !== 'tvMonitor' && !to.meta?.hideChrome) {
setFingerTimeOut(to)
}
if (to.name === 'notifyTrigger' && getToken()) {
const { useSettingsStore } = await import('@/store/settingsStore')
@@ -112,7 +123,7 @@ export function setupRouterGuards() {
return next()
}
} else {
if (['login', '404', 'buyProduct', 'productDetail', 'about', 'cart', 'tv'].includes(to.name as string)) {
if (isGuestAllowed(to)) {
return next()
}
goToLogin(to, next)
@@ -126,7 +137,7 @@ export function setupRouterGuards() {
return next()
}
if (['login', '404', 'buyProduct', 'productDetail', 'about', 'cart', 'tv'].includes(to.name as string)) {
if (isGuestAllowed(to)) {
return next()
}
return next({ name: 'login' })
+3 -3
View File
@@ -66,11 +66,11 @@ body {
color-scheme: only light;
--app-safe-top: env(safe-area-inset-top, 0px);
--app-safe-bottom: env(safe-area-inset-bottom, 0px);
--app-credit-bar-inner: 0px;
--app-credit-bar-inner: 28px;
--app-home-header-offset: calc(4.25rem + var(--app-safe-top));
--app-credit-footer-offset: var(--app-safe-bottom);
--app-credit-footer-offset: calc(var(--app-credit-bar-inner) + var(--app-safe-bottom));
--app-nav-height: 80px;
--app-bottom-chrome-height: calc(var(--app-nav-height) + var(--app-safe-bottom));
--app-bottom-chrome-height: calc(var(--app-nav-height) + var(--app-credit-footer-offset));
--app-add-card-height: 88px;
--wallet-card-dock-overlap: 52px;
--app-z-nav: 5000;
+21
View File
@@ -0,0 +1,21 @@
<template>
<a
href="https://montzar.ir"
target="_blank"
rel="noopener noreferrer"
class="app-credit-link"
:class="`app-credit-link--${variant}`"
>
<span class="app-credit-link__text">توسعه و طراحی توسط</span>
<img src="/logo-montzar.png" alt="montzar" class="app-credit-link__logo" />
</a>
</template>
<script lang="ts" setup>
withDefaults(
defineProps<{
variant?: 'mobile' | 'sidebar' | 'float' | 'tv'
}>(),
{ variant: 'mobile' },
)
</script>
+5
View File
@@ -34,6 +34,10 @@
@click="navigateTo(item.routeName)"
/>
</nav>
<div class="desktop-sidebar__credit">
<AppCreditLink variant="sidebar" />
</div>
</aside>
</template>
@@ -47,6 +51,7 @@ import { useSettingsStore } from '@/store/settingsStore'
import { getPrimaryNavItems, getSecondaryNavItems } from '../config/navigation'
import { useAppNavigation } from '../composables/useAppNavigation'
import DesktopNavItem from './DesktopNavItem.vue'
import AppCreditLink from '@/components/AppCreditLink.vue'
const cartStore = useCartStore()
const authenticated = computed(() => isToken())
+7
View File
@@ -85,6 +85,13 @@
overscroll-behavior: contain;
}
.desktop-sidebar__credit {
margin-top: 12px;
padding: 12px 8px 4px;
display: flex;
justify-content: center;
}
.desktop-sidebar__divider {
height: 1px;
background: rgba(0, 0, 0, 0.08);
+2 -3
View File
@@ -1,13 +1,12 @@
.bottom-appbar {
z-index: var(--app-z-nav, 5000);
position: fixed;
bottom: 0;
bottom: var(--app-credit-footer-offset, 28px);
right: 0;
left: 0;
width: 100%;
max-width: 100vw;
height: calc(var(--app-nav-height, 80px) + var(--app-safe-bottom, env(safe-area-inset-bottom, 0px)));
padding-bottom: var(--app-safe-bottom, env(safe-area-inset-bottom, 0px));
height: var(--app-nav-height, 80px);
box-sizing: border-box;
display: flex;
justify-content: space-evenly;
+5
View File
@@ -39,6 +39,11 @@
.bottom-appbar {
display: none !important;
}
:root {
--app-credit-bar-inner: 0px;
--app-credit-footer-offset: 0px;
}
}
html.app-layout--desktop {
+10
View File
@@ -9,6 +9,16 @@ const router = createRouter({
name: 'login',
component: () => import('../views/login/index.vue')
},
{
path: `/tv`,
name: 'tvMonitor',
alias: ['/monitor'],
meta: {
public: true,
hideChrome: true,
},
component: () => import('../views/tv/index.vue')
},
/* {
path: '/',
name: 'null',
+7 -1
View File
@@ -60,7 +60,13 @@ _axios.interceptors.response.use(
if (response?.status === 401) {
removeToken()
if (router.currentRoute.value.name !== 'buyProduct') {
const routeName = router.currentRoute.value.name
const isPublicRoute =
router.currentRoute.value.meta?.public === true ||
['buyProduct', 'productDetail', 'about', 'cart', 'tvMonitor', 'login', '404'].includes(
routeName as string,
)
if (!isPublicRoute) {
await router.push({name: 'login'})
}
return Promise.reject(error)
@@ -0,0 +1,282 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useFullscreen } from '@vueuse/core'
import type { Product } from '@/models/products'
import { getTvProducts } from '@/api/products'
import { getConfigApp } from '@/utils/config'
import { getProductBuyPrice, getProductSellPrice, isProductPriceInactive } from '@/utils/productPrice'
import { getLastPrices, getTradeBuyPrice, getTradeSellPrice, lastPrice, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
import { isMeltedTradeGroup } from '@/utils/productCategories'
import { priceSocketConnected, priceStreamLifecycle } from '@/utils/priceSocketState'
import { usePrice } from '@/utils/usePrice'
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
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'
}
function isParsianCoin(product: Product) {
return productText(product).includes('پارسیان')
}
function isBankCoin(product: Product) {
if (isParsianCoin(product)) return false
const text = productText(product)
const group = (product.group_type ?? '').trim()
if (group === 'سکه' || group === 'سکه بانکی' || group.includes('بانکی')) return true
return text.includes('سکه') && /تمام|نیم|ربع/.test(text)
}
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 isBankCoinDenomination(product: Product) {
return bankCoinRank(product) <= 3
}
function isSimorghBar(product: Product) {
return productText(product).includes('سیمرغ')
}
export function useTvMonitorPage() {
const rootEl = ref<HTMLElement | null>(null)
const { isFullscreen, enter: enterFullscreen } = 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
const brandName = computed(
() => String(getConfigApp('name_fa') || getConfigApp('name') || 'تابلو قیمت'),
)
const brandLogo = computed(() => String(getConfigApp('avatar') || logoFallback))
const catalogProducts = computed(() => products.value.filter(isShowOnTv))
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)
.filter(isBankCoinDenomination)
.sort((a, b) => bankCoinRank(a) - bankCoinRank(b) || Number(b.amount ?? 0) - Number(a.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 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 : []
} catch {
if (!products.value.length) {
products.value = []
}
} finally {
loading.value = false
}
}
function updateClock() {
const now = moment()
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 handleBoardClick() {
hideCursorSoon()
if (!isFullscreen.value) {
try {
await enterFullscreen()
} catch {
/* browser may require a later 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 productBuyLabel(product: Product) {
if (isProductPriceInactive(product)) return NO_QUOTE_MESSAGE
const value = getProductBuyPrice(product)
return value > 0 ? usePrice(value) : '—'
}
function productSellLabel(product: Product) {
if (isProductPriceInactive(product)) return NO_QUOTE_MESSAGE
const value = getProductSellPrice(product)
return value > 0 ? usePrice(value) : '—'
}
function liveQuoteLabel(value: number) {
return value > 0 ? usePrice(value) : '—'
}
onMounted(async () => {
document.documentElement.classList.add(TV_HTML_CLASS)
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()
}, REFRESH_MS)
document.title = `${brandName.value} | تابلو قیمت`
try {
await enterFullscreen()
} catch {
/* ignored until user clicks */
}
})
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,
coinRowCount,
simorghBars,
statusBanner,
liveBuy,
liveSell,
isLive,
isFullscreen,
cursorHidden,
loading,
productWeightLabel,
simorghWeightLabel,
productBuyLabel,
productSellLabel,
liveQuoteLabel,
handleBoardClick,
}
}
+201
View File
@@ -0,0 +1,201 @@
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { colors } from '@/themeConfig'
export type TvThemeMode = 'light' | 'dark'
const STORAGE_KEY = 'ghatreh_tv_theme'
type Rgb = { r: number; g: number; b: number }
/** رنگ بنفش اپ کاربر — همان primary در UnoCSS و @App-primary-color */
const appPrimary = '#a72b77'
const appSecondary = String(colors.secondary)
const appTertiary = String(colors.tertiary)
const appWhite = String(colors.white)
const appGray50 = String(colors.gray[50])
const appGray100 = String(colors.gray[100])
const appGray500 = String(colors.gray[500])
const appGray700 = String(colors.gray[700])
function normalizeHex(hex: unknown): string | null {
if (!hex || typeof hex !== 'string') return null
let value = hex.trim()
if (!value) return null
if (value[0] !== '#') value = `#${value}`
if (/^#[0-9a-fA-F]{3}$/.test(value)) {
value = `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
}
if (!/^#[0-9a-fA-F]{6}$/.test(value)) return null
return value.toLowerCase()
}
function hexToRgb(hex: string): Rgb | null {
const normalized = normalizeHex(hex)
if (!normalized) return null
const num = parseInt(normalized.slice(1), 16)
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
}
}
function rgbToHex(r: number, g: number, b: number) {
const toHex = (value: number) =>
Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function mix(hexA: string, hexB: string, weight: number) {
const a = hexToRgb(hexA)
const b = hexToRgb(hexB)
if (!a || !b) return hexA
return rgbToHex(
a.r * (1 - weight) + b.r * weight,
a.g * (1 - weight) + b.g * weight,
a.b * (1 - weight) + b.b * weight,
)
}
function brightness(hex: string) {
const rgb = hexToRgb(hex)
if (!rgb) return 0
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
}
function hexToRgba(hex: string, alpha: number) {
const rgb = hexToRgb(hex)
if (!rgb) return `rgba(15, 23, 42, ${alpha})`
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`
}
function toneForSurface(hex: string, surface: 'light' | 'dark') {
const value = brightness(hex)
if (surface === 'light') {
return value > 165 ? mix(hex, '#1e2229', 0.42) : hex
}
return value < 95 ? mix(hex, appWhite, 0.42) : hex
}
function readStoredTheme(): TvThemeMode | null {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') return stored
} catch {
/* ignore */
}
return null
}
function buildThemeVars(mode: TvThemeMode) {
const isDark = mode === 'dark'
const pageBg = isDark ? mix('#160910', appPrimary, 0.22) : appWhite
const pageBgSoft = isDark
? mix(pageBg, appPrimary, 0.18)
: mix(appGray50, appPrimary, 0.08)
const text = isDark ? appWhite : '#1e2229'
const muted = isDark ? hexToRgba(appWhite, 0.72) : appGray500
const card = isDark ? mix(pageBg, appWhite, 0.08) : appWhite
const cardText = isDark ? appWhite : '#1e2229'
const cardMuted = isDark ? hexToRgba(appWhite, 0.68) : appGray500
const border = isDark ? hexToRgba(appWhite, 0.12) : hexToRgba(appPrimary, 0.14)
const mediaBg = isDark ? hexToRgba(appWhite, 0.08) : mix(appGray100, appPrimary, 0.06)
const shadow = isDark
? `0 16px 40px ${hexToRgba(appPrimary, 0.28)}`
: `0 14px 32px ${hexToRgba(appPrimary, 0.14)}`
const sell = toneForSurface(appSecondary, isDark ? 'dark' : 'light')
const buy = toneForSurface(appPrimary, isDark ? 'dark' : 'light')
const sellBg = isDark ? hexToRgba(appSecondary, 0.2) : mix(appWhite, appSecondary, 0.14)
const buyBg = isDark ? hexToRgba(appPrimary, 0.22) : mix(appWhite, appPrimary, 0.12)
return {
'--tv-bg': pageBg,
'--tv-bg-soft': pageBgSoft,
'--tv-text': text,
'--tv-muted': muted,
'--tv-card': card,
'--tv-card-text': cardText,
'--tv-card-muted': cardMuted,
'--tv-border': border,
'--tv-primary': appPrimary,
'--tv-secondary': appSecondary,
'--tv-tertiary': appTertiary,
'--tv-sell': sell,
'--tv-buy': buy,
'--tv-sell-bg': sellBg,
'--tv-buy-bg': buyBg,
'--tv-media': mediaBg,
'--tv-logo-bg': appWhite,
'--tv-shadow': shadow,
'--tv-orb-1': hexToRgba(appPrimary, isDark ? 0.28 : 0.14),
'--tv-orb-2': hexToRgba(appTertiary, isDark ? 0.16 : 0.1),
'--tv-quote-bg': isDark ? hexToRgba(appWhite, 0.07) : hexToRgba(appWhite, 0.86),
'--tv-dot': isDark ? hexToRgba(appWhite, 0.28) : hexToRgba(appPrimary, 0.22),
'--tv-badge': isDark ? mix(appPrimary, '#1e2229', 0.35) : mix(appPrimary, '#1e2229', 0.2),
}
}
export function useTvTheme() {
const route = useRoute()
const router = useRouter()
function resolveTheme(): TvThemeMode {
const query = String(route.query.theme || '').toLowerCase()
if (query === 'light' || query === 'dark') return query
return readStoredTheme() ?? 'dark'
}
const theme = ref<TvThemeMode>(resolveTheme())
const themeVars = computed(() => buildThemeVars(theme.value))
watch(
theme,
(mode) => {
document.documentElement.classList.toggle('app-layout--tv-dark', mode === 'dark')
document.documentElement.classList.toggle('app-layout--tv-light', mode === 'light')
document.documentElement.style.setProperty('--tv-page-bg', themeVars.value['--tv-bg'])
},
{ immediate: true },
)
watch(
() => route.query.theme,
(value) => {
if (value === 'light' || value === 'dark') {
theme.value = value
}
},
)
onUnmounted(() => {
document.documentElement.classList.remove('app-layout--tv-dark', 'app-layout--tv-light')
document.documentElement.style.removeProperty('--tv-page-bg')
})
function persistTheme(next: TvThemeMode) {
theme.value = next
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {
/* ignore */
}
const nextQuery = { ...route.query, theme: next }
void router.replace({ query: nextQuery })
}
function toggleTheme(event?: Event) {
event?.stopPropagation()
persistTheme(theme.value === 'dark' ? 'light' : 'dark')
}
return {
theme,
themeVars,
toggleTheme,
}
}
+127
View File
@@ -0,0 +1,127 @@
import type { Product } from '@/models/products'
import type { Wallet } from '@/models/wallet'
/** وقتی true باشد تابلو از داده تستی استفاده می‌کند؛ بعداً false شود تا از سرور بخواند. */
export const TV_USE_STATIC_PRODUCTS = true
function mockWallet(): Wallet {
return {
owner: 0,
treasure: 0,
treasure_sum: 0,
id: 0,
gold: 0,
gold_block: 0,
cash: 0,
gold_sum: 0,
product: {} as Product,
cash_block: 0,
count_cash: 0,
count_cash_wallet: 0,
count_cash_wallets: 0,
count_gold: 0,
count_gold_wallet: 0,
count_gold_wallets: 0,
count_product: 0,
}
}
function mockCoin(input: {
id: number
title: string
group_type: string
amount: number
price: number
sellPrice: number
}): Product {
return {
id: input.id,
img: undefined,
title: input.title,
amount: input.amount,
metal_id: 1,
file_id: null,
unit: 'گرم',
active: true,
show: true,
open_sell: true,
open_buy: true,
group_type: input.group_type,
percent_fee_buy: 0,
percent_fee_sell: 0,
cash_fee_buy: 0,
cash_fee_sell: 0,
reading_method: null,
created_at: null,
updated_at: null,
deleted_at: null,
price: input.price,
sellPrice: input.sellPrice,
description: '',
wallet: mockWallet(),
inventory: 1,
trade_by_quantity: true,
}
}
const PARSIAN_WEIGHTS = [
{ amount: 0.05, title: 'سکه پارسیان ۵۰ سوت', price: 48_500_000, sellPrice: 46_800_000 },
{ amount: 0.1, title: 'سکه پارسیان ۱۰۰ سوت', price: 96_200_000, sellPrice: 93_400_000 },
{ amount: 0.15, title: 'سکه پارسیان ۱۵۰ سوت', price: 143_800_000, sellPrice: 139_900_000 },
{ amount: 0.2, title: 'سکه پارسیان ۲۰۰ سوت', price: 191_500_000, sellPrice: 186_200_000 },
{ amount: 0.25, title: 'سکه پارسیان ۲۵۰ سوت', price: 239_000_000, sellPrice: 232_400_000 },
{ amount: 0.3, title: 'سکه پارسیان ۳۰۰ سوت', price: 286_700_000, sellPrice: 278_800_000 },
{ amount: 0.4, title: 'سکه پارسیان ۴۰۰ سوت', price: 382_000_000, sellPrice: 371_500_000 },
{ amount: 0.5, title: 'سکه پارسیان ۵۰۰ سوت', price: 477_200_000, sellPrice: 464_000_000 },
{ amount: 1, title: 'سکه پارسیان ۱ گرم', price: 952_000_000, sellPrice: 926_000_000 },
] as const
const BANK_COINS = [
{ amount: 8.13, title: 'تمام سکه بانکی', price: 8_150_000_000, sellPrice: 7_980_000_000 },
{ amount: 4.06, title: 'نیم سکه بانکی', price: 4_120_000_000, sellPrice: 4_020_000_000 },
{ amount: 2.03, title: 'ربع سکه بانکی', price: 2_180_000_000, sellPrice: 2_110_000_000 },
] as const
const SIMORGH_BARS = [
{ amount: 0.5, title: 'شمش سیمرغ ۰٫۵ گرم', price: 478_000_000, sellPrice: 465_000_000 },
{ amount: 1, title: 'شمش سیمرغ ۱ گرم', price: 952_000_000, sellPrice: 926_000_000 },
{ amount: 2.5, title: 'شمش سیمرغ ۲٫۵ گرم', price: 2_380_000_000, sellPrice: 2_315_000_000 },
{ amount: 5, title: 'شمش سیمرغ ۵ گرم', price: 4_760_000_000, sellPrice: 4_630_000_000 },
{ amount: 10, title: 'شمش سیمرغ ۱۰ گرم', price: 9_520_000_000, sellPrice: 9_260_000_000 },
{ amount: 20, title: 'شمش سیمرغ ۲۰ گرم', price: 19_040_000_000, sellPrice: 18_520_000_000 },
{ amount: 50, title: 'شمش سیمرغ ۵۰ گرم', price: 47_600_000_000, sellPrice: 46_300_000_000 },
{ amount: 100, title: 'شمش سیمرغ ۱۰۰ گرم', price: 95_200_000_000, sellPrice: 92_600_000_000 },
] as const
export const STATIC_TV_PRODUCTS: Product[] = [
...PARSIAN_WEIGHTS.map((item, index) =>
mockCoin({
id: 100 + index,
title: item.title,
group_type: 'سکه پارسیان',
amount: item.amount,
price: item.price,
sellPrice: item.sellPrice,
}),
),
...BANK_COINS.map((item, index) =>
mockCoin({
id: 200 + index,
title: item.title,
group_type: 'سکه',
amount: item.amount,
price: item.price,
sellPrice: item.sellPrice,
}),
),
...SIMORGH_BARS.map((item, index) =>
mockCoin({
id: 300 + index,
title: item.title,
group_type: 'شمش سیمرغ',
amount: item.amount,
price: item.price,
sellPrice: item.sellPrice,
}),
),
]
+923
View File
@@ -0,0 +1,923 @@
<template>
<div
ref="rootEl"
class="tv-monitor"
:class="[`tv-monitor--${theme}`, { 'tv-monitor--idle': cursorHidden }]"
:style="themeVars"
dir="rtl"
@click="handleBoardClick"
>
<header class="tv-monitor__header">
<div class="tv-monitor__brand">
<img :src="brandLogo" :alt="brandName" class="tv-monitor__logo" />
<div>
<h1 class="tv-monitor__title">{{ brandName }}</h1>
<p class="tv-monitor__subtitle">تابلو قیمت محصولات</p>
</div>
</div>
<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>{{ liveQuoteLabel(liveBuy) }}</strong>
</div>
<div class="tv-monitor__quote-item tv-monitor__quote-item--buy">
<span>خرید</span>
<strong>{{ liveQuoteLabel(liveSell) }}</strong>
</div>
</div>
</div>
<div class="tv-monitor__clock">
<button
type="button"
class="tv-monitor__theme-toggle"
:title="theme === 'dark' ? 'تم روشن' : 'تم تیره'"
@click="toggleTheme"
>
<BasicIcon :icon="theme === 'dark' ? 'vuesax-linear:sun-1' : 'vuesax-linear:moon'" size="18" />
<span>{{ theme === 'dark' ? 'تم روشن' : 'تم تیره' }}</span>
</button>
<div class="tv-monitor__time">{{ nowLabel }}</div>
<div class="tv-monitor__date">{{ dateLabel }}</div>
<div class="tv-monitor__live" :class="{ 'is-on': isLive }">
<span class="tv-monitor__live-dot" />
{{ isLive ? 'زنده' : 'به‌روزرسانی خودکار' }}
</div>
</div>
</header>
<p v-if="statusBanner" class="tv-monitor__banner">{{ statusBanner }}</p>
<main class="tv-monitor__board">
<a-spin :spinning="loading" class="tv-monitor__spin">
<div
v-if="hasCoinBoard"
class="tv-monitor__columns"
:style="{ '--tv-row-count': coinRowCount }"
>
<section class="tv-col">
<header class="tv-col__head">
<h2>سکه پارسیان</h2>
<span>تمام اوزان</span>
</header>
<div class="tv-col__labels">
<span class="tv-col__labels-gap" />
<div class="tv-col__price-heads">
<span class="tv-col__label tv-col__label--sell">فروش</span>
<span class="tv-col__label tv-col__label--buy">خرید</span>
</div>
</div>
<div class="tv-col__list">
<article
v-for="item in parsianCoins"
:key="item.id"
class="tv-row"
>
<div class="tv-row__media">
<ProductImage :product="item" img-class="tv-row__img" />
</div>
<div class="tv-row__meta">
<h3>{{ item.title }}</h3>
<p>{{ productWeightLabel(item) }}</p>
</div>
<div class="tv-row__prices">
<div class="tv-row__price tv-row__price--sell">
<strong>{{ productBuyLabel(item) }}</strong>
</div>
<div class="tv-row__price tv-row__price--buy">
<strong>{{ productSellLabel(item) }}</strong>
</div>
</div>
</article>
<div v-if="!parsianCoins.length" class="tv-col__empty">سکه‌ای برای نمایش نیست</div>
</div>
</section>
<section class="tv-col">
<header class="tv-col__head">
<h2>سکه بانکی</h2>
<span>تمام · نیم · ربع</span>
</header>
<div class="tv-col__labels">
<span class="tv-col__labels-gap" />
<div class="tv-col__price-heads">
<span class="tv-col__label tv-col__label--sell">فروش</span>
<span class="tv-col__label tv-col__label--buy">خرید</span>
</div>
</div>
<div class="tv-col__list">
<article
v-for="item in bankCoins"
:key="item.id"
class="tv-row"
>
<div class="tv-row__media">
<ProductImage :product="item" img-class="tv-row__img" />
</div>
<div class="tv-row__meta">
<h3>{{ item.title }}</h3>
<p>{{ productWeightLabel(item) }}</p>
</div>
<div class="tv-row__prices">
<div class="tv-row__price tv-row__price--sell">
<strong>{{ productBuyLabel(item) }}</strong>
</div>
<div class="tv-row__price tv-row__price--buy">
<strong>{{ productSellLabel(item) }}</strong>
</div>
</div>
</article>
<div v-if="!bankCoins.length" class="tv-col__empty">سکه‌ای برای نمایش نیست</div>
</div>
</section>
</div>
<div v-else-if="!loading" class="tv-monitor__empty">
محصولی برای نمایش روی تابلو انتخاب نشده است
</div>
</a-spin>
</main>
<p v-if="!isFullscreen" class="tv-monitor__hint">
برای نمایش تمام‌صفحه روی صفحه کلیک کنید
</p>
<footer class="tv-monitor__footer">
<div
v-if="simorghBars.length"
class="tv-ticker"
:style="{ '--tv-ticker-duration': `${Math.max(simorghBars.length * 7, 36)}s` }"
>
<div class="tv-ticker__label">
<strong>شمش سیمرغ</strong>
<span>خرید و فروش</span>
</div>
<div class="tv-ticker__viewport">
<div class="tv-ticker__track">
<article
v-for="item in simorghBars"
:key="item.id"
class="tv-ticker__item"
>
<span class="tv-ticker__weight">{{ simorghWeightLabel(item) }}</span>
<span class="tv-ticker__price tv-ticker__price--sell">
فروش
<b>{{ productBuyLabel(item) }}</b>
</span>
<span class="tv-ticker__price tv-ticker__price--buy">
خرید
<b>{{ productSellLabel(item) }}</b>
</span>
</article>
</div>
</div>
</div>
<div class="tv-monitor__credit" @click.stop>
<AppCreditLink variant="tv" />
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import ProductImage from '@/components/ProductImage.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import AppCreditLink from '@/components/AppCreditLink.vue'
import { useTvMonitorPage } from '@/views/tv/composables/useTvMonitorPage'
import { useTvTheme } from '@/views/tv/composables/useTvTheme'
const { theme, themeVars, toggleTheme } = useTvTheme()
const {
rootEl,
brandName,
brandLogo,
nowLabel,
dateLabel,
parsianCoins,
bankCoins,
hasCoinBoard,
coinRowCount,
simorghBars,
statusBanner,
liveBuy,
liveSell,
isLive,
isFullscreen,
cursorHidden,
loading,
productWeightLabel,
simorghWeightLabel,
productBuyLabel,
productSellLabel,
liveQuoteLabel,
handleBoardClick,
} = useTvMonitorPage()
</script>
<style scoped lang="less">
.tv-monitor {
box-sizing: border-box;
display: flex;
position: relative;
flex-direction: column;
width: 100%;
height: 100%;
padding: clamp(12px, 1.6vh, 24px) clamp(16px, 2vw, 32px);
background:
radial-gradient(1200px 500px at 100% -10%, var(--tv-orb-1), transparent 55%),
radial-gradient(900px 420px at -10% 110%, var(--tv-orb-2), transparent 50%),
linear-gradient(160deg, var(--tv-bg) 0%, var(--tv-bg-soft) 48%, var(--tv-bg) 100%);
color: var(--tv-text);
font-family: iran-sans, sans-serif;
user-select: none;
overflow: hidden;
h1,
h2,
p,
span,
strong,
div,
article {
font-family: inherit;
}
&--idle {
cursor: none;
}
}
.tv-monitor__header {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.9fr;
align-items: center;
gap: 16px;
margin-bottom: clamp(10px, 1.4vh, 20px);
}
.tv-monitor__banner {
margin: 0 0 clamp(10px, 1.2vh, 16px);
padding: 10px 16px;
border-radius: 14px;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
color: var(--tv-muted);
font-size: clamp(12px, 1.05vw, 15px);
text-align: center;
}
.tv-monitor__brand {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.tv-monitor__logo {
width: clamp(52px, 6.5vh, 76px);
height: clamp(52px, 6.5vh, 76px);
object-fit: contain;
background: var(--tv-logo-bg);
border-radius: 16px;
padding: 6px;
}
.tv-monitor__title {
margin: 0;
font-size: clamp(20px, 2.4vw, 36px);
font-weight: 700;
line-height: 1.3;
color: var(--tv-text);
}
.tv-monitor__subtitle {
margin: 2px 0 0;
color: var(--tv-muted);
font-size: clamp(12px, 1.1vw, 16px);
}
.tv-monitor__quote {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.tv-monitor__quote-label {
font-size: 13px;
color: var(--tv-muted);
}
.tv-monitor__quote-prices {
display: flex;
gap: 10px;
}
.tv-monitor__quote-item {
min-width: 180px;
padding: 8px 14px;
border-radius: 14px;
text-align: center;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
span {
display: block;
font-size: 12px;
color: var(--tv-muted);
}
strong {
display: block;
margin-top: 2px;
font-size: clamp(16px, 1.5vw, 22px);
font-weight: 700;
font-family: iran-sans, sans-serif;
direction: ltr;
unicode-bidi: isolate;
}
&--sell strong {
color: var(--tv-sell);
}
&--buy strong {
color: var(--tv-buy);
}
}
.tv-monitor__clock {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: left;
}
.tv-monitor__theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 6px 12px;
border: 1px solid var(--tv-border);
border-radius: 999px;
background: var(--tv-quote-bg);
color: var(--tv-text);
font-family: iran-sans, sans-serif;
font-size: 12px;
cursor: pointer;
:deep(svg path) {
stroke: currentColor;
}
}
.tv-monitor__time {
font-size: clamp(28px, 3.4vw, 48px);
font-weight: 700;
letter-spacing: 0.04em;
font-family: iran-sans, sans-serif;
direction: ltr;
unicode-bidi: isolate;
line-height: 1;
color: var(--tv-text);
}
.tv-monitor__date {
margin-top: 6px;
color: var(--tv-muted);
font-size: clamp(12px, 1.15vw, 16px);
}
.tv-monitor__live {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
color: var(--tv-muted);
font-size: 12px;
&.is-on {
color: var(--tv-sell);
}
}
.tv-monitor__live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tv-muted);
.is-on & {
background: var(--tv-sell);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--tv-sell) 70%, transparent);
animation: tv-pulse 1.6s infinite;
}
}
.tv-monitor__board {
flex: 1;
min-height: 0;
:deep(.tv-monitor__spin),
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
}
}
.tv-monitor__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(12px, 1.6vw, 22px);
height: 100%;
min-height: 0;
}
.tv-col {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
padding: 12px;
border-radius: 22px;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
}
.tv-col__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding: 0 4px 10px;
border-bottom: 1px solid var(--tv-border);
h2 {
margin: 0;
font-size: clamp(18px, 1.7vw, 28px);
font-weight: 700;
color: var(--tv-text);
}
span {
color: var(--tv-muted);
font-size: clamp(12px, 1vw, 15px);
}
}
.tv-col__labels {
display: grid;
grid-template-columns: 56px minmax(0, 0.9fr) minmax(0, 2fr);
gap: 10px;
align-items: center;
margin-bottom: 8px;
padding: 0 10px;
}
.tv-col__labels-gap {
grid-column: 1 / 3;
}
.tv-col__price-heads {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.tv-col__label {
display: block;
text-align: center;
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 700;
}
.tv-col__label--sell {
color: var(--tv-sell);
}
.tv-col__label--buy {
color: var(--tv-buy);
}
.tv-col__list {
display: grid;
flex: 1;
min-height: 0;
grid-auto-rows: minmax(
52px,
calc((100% - (var(--tv-row-count) - 1) * 8px) / var(--tv-row-count))
);
align-content: start;
gap: 8px;
overflow: auto;
}
.tv-col__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
color: var(--tv-muted);
font-size: 16px;
}
.tv-row {
position: relative;
display: grid;
grid-template-columns: minmax(44px, 56px) minmax(0, 0.9fr) minmax(0, 2fr);
align-items: center;
gap: 10px;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
padding: 6px 10px;
border-radius: 16px;
background: var(--tv-card);
color: var(--tv-card-text);
border: 1px solid var(--tv-border);
box-shadow: var(--tv-shadow);
container-type: size;
&--off {
opacity: 0.72;
}
}
.tv-row__badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 7px;
border-radius: 8px;
background: var(--tv-badge);
color: #fff;
font-size: 11px;
}
.tv-row__media {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
height: 100%;
min-height: 0;
border-radius: 12px;
background: var(--tv-media);
}
.tv-row__img {
max-width: 86%;
max-height: 86%;
object-fit: contain;
}
.tv-row__meta {
min-width: 0;
overflow: hidden;
h3 {
margin: 0;
overflow: hidden;
font-size: clamp(13px, 1.2vw, 20px);
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--tv-card-text);
}
p {
margin: 4px 0 0;
overflow: hidden;
color: var(--tv-card-muted);
font-size: clamp(11px, 1vw, 15px);
text-overflow: ellipsis;
white-space: nowrap;
}
}
@container (max-height: 64px) {
.tv-row__meta p {
display: none;
}
}
.tv-row__prices {
display: grid;
align-self: stretch;
grid-template-columns: 1fr 1fr;
gap: 8px;
height: 100%;
min-height: 0;
}
.tv-row__price {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
padding: 6px 8px;
border-radius: 12px;
text-align: center;
container-type: inline-size;
span {
display: block;
font-size: 12px;
color: var(--tv-card-muted);
}
strong {
display: block;
max-width: 100%;
margin-top: 0;
overflow: hidden;
font-size: clamp(10px, 12cqi, 16px);
font-weight: 700;
font-family: iran-sans, sans-serif;
white-space: nowrap;
direction: ltr;
unicode-bidi: isolate;
}
&--sell {
background: var(--tv-sell-bg);
strong {
color: var(--tv-sell);
}
}
&--buy {
background: var(--tv-buy-bg);
strong {
color: var(--tv-buy);
}
}
}
.tv-monitor__empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--tv-muted);
font-size: 22px;
}
.tv-monitor__footer {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 12px;
margin-inline: calc(-1 * clamp(16px, 2vw, 32px));
margin-bottom: calc(-1 * clamp(12px, 1.6vh, 24px));
}
.tv-monitor__hint {
position: absolute;
top: clamp(12px, 1.6vh, 24px);
left: 50%;
z-index: 4;
margin: 0;
padding: 6px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--tv-card) 88%, transparent);
border: 1px solid var(--tv-border);
color: var(--tv-muted);
font-size: 12px;
text-align: center;
white-space: nowrap;
pointer-events: none;
transform: translateX(-50%);
}
.tv-monitor__credit {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
background: #fff;
box-shadow: 0 -2px 4px -2px #0000001a;
}
.tv-ticker {
display: flex;
overflow: hidden;
min-height: 52px;
background: var(--tv-primary);
color: #fff;
box-shadow: 0 -8px 24px color-mix(in srgb, var(--tv-primary) 28%, transparent);
}
.tv-ticker__label {
display: flex;
position: relative;
z-index: 2;
flex-shrink: 0;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 132px;
padding: 8px 18px 8px 22px;
background: color-mix(in srgb, #1e2229 42%, var(--tv-primary));
strong {
font-size: clamp(14px, 1.2vw, 18px);
font-weight: 700;
line-height: 1.3;
}
span {
font-size: 11px;
opacity: 0.82;
}
&::before {
position: absolute;
top: 0;
bottom: 0;
left: -18px;
width: 18px;
background: inherit;
clip-path: polygon(100% 0, 100% 100%, 0 50%);
content: '';
}
}
.tv-ticker__viewport {
display: flex;
overflow: hidden;
flex: 1;
align-items: center;
justify-content: flex-start;
min-width: 0;
direction: ltr;
container-type: inline-size;
mask-image: linear-gradient(to left, transparent 0, #000 36px);
}
.tv-ticker__track {
display: flex;
flex-shrink: 0;
align-items: center;
width: max-content;
direction: rtl;
will-change: transform;
animation: tv-ticker var(--tv-ticker-duration, 40s) linear infinite;
}
.tv-ticker__item {
display: flex;
align-items: center;
gap: 14px;
padding: 0 28px;
white-space: nowrap;
border-left: 1px solid color-mix(in srgb, #fff 28%, transparent);
}
.tv-ticker__weight {
font-size: clamp(14px, 1.15vw, 18px);
font-weight: 700;
}
.tv-ticker__price {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
b {
font-size: clamp(13px, 1.05vw, 16px);
font-weight: 700;
font-family: iran-sans, sans-serif;
white-space: nowrap;
direction: ltr;
unicode-bidi: isolate;
}
&--sell b {
color: #ffe08a;
}
&--buy b {
color: #fff;
}
}
@keyframes tv-ticker {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100cqw);
}
}
@keyframes tv-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--tv-sell) 55%, transparent);
}
70% {
box-shadow: 0 0 0 8px transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@media (max-width: 1100px) {
.tv-monitor__header {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.2fr) auto;
gap: 10px;
}
.tv-monitor__logo {
width: 48px;
height: 48px;
}
.tv-monitor__quote,
.tv-monitor__quote-item {
min-width: 0;
}
.tv-col__labels,
.tv-row {
grid-template-columns: minmax(40px, 48px) minmax(0, 0.85fr) minmax(0, 2.1fr);
}
}
@media (max-width: 720px) {
.tv-monitor__header {
grid-template-columns: 1fr auto;
}
.tv-monitor__quote {
grid-column: 1 / -1;
order: 3;
}
.tv-monitor__columns {
grid-template-columns: 1fr;
overflow: auto;
}
.tv-col {
min-height: 240px;
}
}
</style>
<style lang="less">
html.app-layout--tv,
html.app-layout--tv body,
html.app-layout--tv #app,
html.app-layout--tv .desktopConfig,
html.app-layout--tv .ant-config-provider,
html.app-layout--tv .app-main,
html.app-layout--tv .tv-monitor {
font-family: iran-sans, sans-serif !important;
}
html.app-layout--tv,
html.app-layout--tv body,
html.app-layout--tv #app,
html.app-layout--tv .desktopConfig,
html.app-layout--tv .ant-config-provider,
html.app-layout--tv .app-main {
width: 100% !important;
max-width: none !important;
height: 100dvh !important;
min-height: 100dvh !important;
margin: 0 !important;
overflow: hidden !important;
}
html.app-layout--tv .tv-monitor {
width: 100% !important;
height: 100% !important;
}
html.app-layout--tv body {
display: block !important;
background: var(--tv-page-bg, #0b1220) !important;
}
html.app-layout--tv .app-splash-screen,
html.app-layout--tv #support-button,
html.app-layout--tv #goftino_w {
display: none !important;
}
</style>
+11 -1
View File
@@ -10,4 +10,14 @@ declare global {
}
}
export {};
export {}
declare module 'vue-router' {
interface RouteMeta {
public?: boolean
hideChrome?: boolean
hideMenu?: boolean
hideBottomNav?: boolean
transition?: string
}
}