new update
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export function getConfigApp(key: string) {
|
||||
const config = window.__APP_CONFIG__ ? window.__APP_CONFIG__.user : {}
|
||||
|
||||
if (config) {
|
||||
return config[key]
|
||||
}
|
||||
return null
|
||||
}
|
||||
+150
-70
@@ -1,86 +1,166 @@
|
||||
import {loading} from '@/utils/index'
|
||||
import {getLastPrice, getPrice} from '@/api/home'
|
||||
import {ref} from 'vue'
|
||||
import type {Price} from '@/models/home'
|
||||
import { getLastPrice, getPrice } from '@/api/home'
|
||||
import type { Price, PriceUp } from '@/models/home'
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
priceSocketConnected,
|
||||
priceStreamLifecycle,
|
||||
priceWssEnabled,
|
||||
shouldUsePricePolling,
|
||||
} from '@/utils/priceSocketState'
|
||||
|
||||
const LAST_INTERVAL_MS = 10000
|
||||
|
||||
let lastInterval: ReturnType<typeof setInterval> | undefined
|
||||
let lastSubscribers = 0
|
||||
|
||||
let interval: any
|
||||
export const price = ref<Price>()
|
||||
export const lastPrice = ref<Price>()
|
||||
export const olderPrice = ref<number| undefined>(0)
|
||||
export const olderPrice = ref<number | undefined>(0)
|
||||
|
||||
let lastPriceSourceAt = 0
|
||||
let consecutivePriceFetchFailures = 0
|
||||
|
||||
function hasValidPricePayload(data?: Price | null): boolean {
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
Number(data.buy ?? data.up?.buy ?? 0) > 0
|
||||
|| Number(data.sell ?? data.up?.sell ?? 0) > 0
|
||||
|| Number(data.price_last ?? data.price ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function markPriceSourceReceived() {
|
||||
lastPriceSourceAt = Date.now()
|
||||
consecutivePriceFetchFailures = 0
|
||||
}
|
||||
|
||||
export function markPriceSourceFetchFailed() {
|
||||
consecutivePriceFetchFailures += 1
|
||||
}
|
||||
|
||||
export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean {
|
||||
return lastPriceSourceAt > 0 && Date.now() - lastPriceSourceAt < maxAgeMs
|
||||
}
|
||||
|
||||
export function consecutivePriceSourceFailures(): number {
|
||||
return consecutivePriceFetchFailures
|
||||
}
|
||||
|
||||
function startPollingInterval() {
|
||||
if (lastInterval || !shouldUsePricePolling()) {
|
||||
return
|
||||
}
|
||||
|
||||
lastInterval = setInterval(() => {
|
||||
if (shouldUsePricePolling()) {
|
||||
void getLastPrices()
|
||||
}
|
||||
}, LAST_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopPollingInterval() {
|
||||
if (lastInterval) {
|
||||
clearInterval(lastInterval)
|
||||
lastInterval = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function syncLastPricePolling() {
|
||||
const usePolling = shouldUsePricePolling()
|
||||
if (usePolling) {
|
||||
if (lastSubscribers > 0) {
|
||||
startPollingInterval()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
stopPollingInterval()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const,
|
||||
() => {
|
||||
syncLastPricePolling()
|
||||
},
|
||||
)
|
||||
|
||||
export function startLastPricePolling() {
|
||||
lastSubscribers++
|
||||
syncLastPricePolling()
|
||||
}
|
||||
|
||||
export async function setIntervalGetAllPrice() {
|
||||
// console.log('salam')
|
||||
try {
|
||||
interval = setInterval(async () => {
|
||||
await getLastPrices()
|
||||
await getUpPrice()
|
||||
}, 10000)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
startLastPricePolling()
|
||||
}
|
||||
|
||||
export async function setIntervalGetLastPrice() {
|
||||
// console.log('salam')
|
||||
try {
|
||||
interval = setInterval(async () => {
|
||||
await getLastPrices()
|
||||
// await getUpPrice()
|
||||
}, 10000)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
startLastPricePolling()
|
||||
}
|
||||
|
||||
export async function getUpPrice() {
|
||||
try {
|
||||
// loading.value = true
|
||||
const data = await getPrice()
|
||||
// const dataLast = await getLastPrice()
|
||||
// if (lastPrice.value?.price_last != dataLast.price_last) {
|
||||
// olderPrice.value = lastPrice.value?.price_last
|
||||
// }
|
||||
price.value = data
|
||||
// lastPrice.value = dataLast
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
// loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getLastPrices() {
|
||||
try {
|
||||
// loading.value = true
|
||||
// const data = await getPrice()
|
||||
const dataLast = await getLastPrice()
|
||||
if (lastPrice.value?.price_last != dataLast.price_last) {
|
||||
olderPrice.value = lastPrice.value?.price_last
|
||||
}
|
||||
// price.value = data
|
||||
lastPrice.value = dataLast
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
// loading.value = false
|
||||
}
|
||||
export function stopLastPricePolling() {
|
||||
lastSubscribers = Math.max(0, lastSubscribers - 1)
|
||||
if (lastSubscribers === 0) {
|
||||
stopPollingInterval()
|
||||
}
|
||||
}
|
||||
|
||||
export function stopPriceInterval() {
|
||||
// console.log('stop')
|
||||
clearInterval(interval)
|
||||
stopLastPricePolling()
|
||||
}
|
||||
|
||||
async function fetchUpPrices(): Promise<PriceUp | undefined> {
|
||||
try {
|
||||
const up = await getPrice()
|
||||
if (up?.buy != null && up?.sell != null) {
|
||||
return { buy: up.buy, sell: up.sell }
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUpPrice() {
|
||||
await getLastPrices()
|
||||
}
|
||||
|
||||
export async function getLastPrices(): Promise<boolean> {
|
||||
try {
|
||||
let dataLast = await getLastPrice()
|
||||
|
||||
if (!dataLast?.up) {
|
||||
const up = await fetchUpPrices()
|
||||
if (up) {
|
||||
dataLast = { ...dataLast, up }
|
||||
}
|
||||
}
|
||||
|
||||
applyLastPricePayload(dataLast)
|
||||
return hasValidPricePayload(dataLast)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
markPriceSourceFetchFailed()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function applyLastPricePayload(dataLast: Price) {
|
||||
if (lastPrice.value?.price_last != dataLast.price_last) {
|
||||
olderPrice.value = lastPrice.value?.price_last
|
||||
}
|
||||
lastPrice.value = dataLast
|
||||
price.value = dataLast
|
||||
|
||||
if (hasValidPricePayload(dataLast)) {
|
||||
markPriceSourceReceived()
|
||||
}
|
||||
}
|
||||
|
||||
export function percentPriceChanged() {
|
||||
// console.log(olderPrice.value)
|
||||
// console.log(lastPrice.value?.price_last)
|
||||
if (!olderPrice.value)
|
||||
return 0
|
||||
return ((lastPrice.value?.price_last-olderPrice.value) *100)/olderPrice.value
|
||||
}
|
||||
if (!olderPrice.value) {
|
||||
return 0
|
||||
}
|
||||
return ((lastPrice.value?.price_last! - olderPrice.value) * 100) / olderPrice.value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
import type { Price } from '@/models/home'
|
||||
import type { Product } from '@/models/products'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import { getConfigApp } from '@/utils/config'
|
||||
import { applyProductsListFromStream } from '@/utils/productStreamBridge'
|
||||
import {
|
||||
priceSocketConnected,
|
||||
priceStreamConnectingSince,
|
||||
priceStreamLifecycle,
|
||||
priceWssEnabled,
|
||||
} from '@/utils/priceSocketState'
|
||||
|
||||
export {
|
||||
priceSocketConnected,
|
||||
priceStreamConnectingSince,
|
||||
priceStreamLifecycle,
|
||||
priceWssEnabled,
|
||||
shouldUsePricePolling,
|
||||
} from '@/utils/priceSocketState'
|
||||
|
||||
const RECONNECT_BASE_MS = 3000
|
||||
const RECONNECT_MAX_MS = 30000
|
||||
const FAILURES_BEFORE_ALERT = 4
|
||||
const ALERT_COOLDOWN_MS = 5 * 60 * 1000
|
||||
const FALLBACK_POLL_INTERVAL_MS = 10000
|
||||
const PRICE_SOURCE_STALE_MS_MOBILE = 50000
|
||||
const PRICE_SOURCE_STALE_MS_DESKTOP = 35000
|
||||
const STALE_STREAM_MS = 120000
|
||||
const FALLBACK_POLL_DELAY_MS = 2500
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let subscribers = 0
|
||||
let stopped = false
|
||||
let getSetting: ((key: string) => unknown) | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let staleCheckTimer: ReturnType<typeof setInterval> | undefined
|
||||
let reconnectAttempt = 0
|
||||
let lastStreamEventAt = 0
|
||||
let networkAlertOpen = false
|
||||
let lastNetworkAlertAt = 0
|
||||
let listenersBound = false
|
||||
let fallbackPollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let fallbackPollInterval: ReturnType<typeof setInterval> | undefined
|
||||
let pendingPriceAlert = false
|
||||
|
||||
function isMobileDevice(): boolean {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
return /iPhone|iPad|iPod|Android|Mobile/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
}
|
||||
|
||||
function markConnectingLifecycle() {
|
||||
priceStreamLifecycle.value = 'connecting'
|
||||
if (priceStreamConnectingSince.value <= 0) {
|
||||
priceStreamConnectingSince.value = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
function clearConnectingLifecycle() {
|
||||
priceStreamConnectingSince.value = 0
|
||||
}
|
||||
|
||||
function isTruthySetting(value: unknown, defaultValue = true): boolean {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return ['true', '1', 'yes', 'on'].includes(String(value).toLowerCase())
|
||||
}
|
||||
|
||||
function resolveStreamPath(): string {
|
||||
const ssePath = String(getSetting?.('sse_path') ?? '').trim()
|
||||
if (ssePath && !ssePath.includes('/prc/')) {
|
||||
return ssePath.startsWith('/') ? ssePath : `/${ssePath}`
|
||||
}
|
||||
|
||||
const legacyPath = String(getSetting?.('wss_path') ?? '').trim()
|
||||
if (legacyPath && !legacyPath.includes('/prc/')) {
|
||||
return legacyPath.startsWith('/') ? legacyPath : `/${legacyPath}`
|
||||
}
|
||||
|
||||
return '/api/price/stream'
|
||||
}
|
||||
|
||||
function resolveStreamUrl(): string | null {
|
||||
if (!getSetting) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isTruthySetting(getSetting('wss_enabled'), true)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '')
|
||||
return `${base}${resolveStreamPath()}`
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function stopStaleCheck() {
|
||||
if (staleCheckTimer) {
|
||||
clearInterval(staleCheckTimer)
|
||||
staleCheckTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function touchStreamActivity() {
|
||||
lastStreamEventAt = Date.now()
|
||||
}
|
||||
|
||||
function startStaleCheck() {
|
||||
stopStaleCheck()
|
||||
touchStreamActivity()
|
||||
staleCheckTimer = setInterval(() => {
|
||||
if (
|
||||
stopped
|
||||
|| subscribers <= 0
|
||||
|| !eventSource
|
||||
|| eventSource.readyState !== EventSource.OPEN
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Date.now() - lastStreamEventAt > STALE_STREAM_MS) {
|
||||
console.warn('Price SSE stale — reconnecting')
|
||||
handleStreamLost(true)
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
function clearFallbackPollTimer() {
|
||||
if (fallbackPollTimer) {
|
||||
clearTimeout(fallbackPollTimer)
|
||||
fallbackPollTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function stopFallbackPolling() {
|
||||
clearFallbackPollTimer()
|
||||
if (fallbackPollInterval) {
|
||||
clearInterval(fallbackPollInterval)
|
||||
fallbackPollInterval = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function startFallbackPolling() {
|
||||
if (fallbackPollInterval) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleFallbackPoll()
|
||||
fallbackPollInterval = setInterval(() => {
|
||||
if (stopped || subscribers <= 0 || priceSocketConnected.value) {
|
||||
stopFallbackPolling()
|
||||
return
|
||||
}
|
||||
|
||||
void runFallbackPoll()
|
||||
}, FALLBACK_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function scheduleFallbackPoll() {
|
||||
clearFallbackPollTimer()
|
||||
fallbackPollTimer = setTimeout(() => {
|
||||
fallbackPollTimer = undefined
|
||||
if (!stopped && subscribers > 0 && !priceSocketConnected.value) {
|
||||
void runFallbackPoll()
|
||||
}
|
||||
}, FALLBACK_POLL_DELAY_MS)
|
||||
}
|
||||
|
||||
async function runFallbackPoll() {
|
||||
if (!priceWssEnabled.value || priceSocketConnected.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import(
|
||||
'@/utils/price'
|
||||
)
|
||||
const { getProducts } = await import('@/api/products')
|
||||
const { applyProductsListFromStream: applyList } = await import('@/utils/productStreamBridge')
|
||||
const [lastResult, products] = await Promise.allSettled([
|
||||
getLastPrices(),
|
||||
getProducts(),
|
||||
])
|
||||
|
||||
if (products.status === 'fulfilled' && Array.isArray(products.value) && products.value.length) {
|
||||
applyList(products.value)
|
||||
}
|
||||
|
||||
const priceOk =
|
||||
lastResult.status === 'fulfilled'
|
||||
&& lastResult.value === true
|
||||
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
|
||||
|
||||
if (!priceOk) {
|
||||
markPriceSourceFetchFailed()
|
||||
}
|
||||
|
||||
void maybeShowNetworkAlert()
|
||||
} catch (error) {
|
||||
console.warn('Price fallback poll failed', error)
|
||||
const { markPriceSourceFetchFailed } = await import('@/utils/price')
|
||||
markPriceSourceFetchFailed()
|
||||
void maybeShowNetworkAlert()
|
||||
}
|
||||
}
|
||||
|
||||
function getPriceSourceStaleMs(): number {
|
||||
return isMobileDevice() ? PRICE_SOURCE_STALE_MS_MOBILE : PRICE_SOURCE_STALE_MS_DESKTOP
|
||||
}
|
||||
|
||||
async function isRealPriceOutage(): Promise<boolean> {
|
||||
const {
|
||||
hasRecentPriceFromSource,
|
||||
consecutivePriceSourceFailures,
|
||||
} = await import('@/utils/price')
|
||||
|
||||
const staleMs = getPriceSourceStaleMs()
|
||||
|
||||
if (hasRecentPriceFromSource(staleMs)) {
|
||||
pendingPriceAlert = false
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchFailures = consecutivePriceSourceFailures()
|
||||
|
||||
if (fetchFailures >= 2) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!priceSocketConnected.value && fetchFailures >= 1 && reconnectAttempt >= 2) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isMobileDevice() && !navigator.onLine && fetchFailures >= 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isMobileDevice() && reconnectAttempt >= FAILURES_BEFORE_ALERT) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function showNetworkAlert() {
|
||||
if (networkAlertOpen || stopped || !priceWssEnabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPageVisible()) {
|
||||
pendingPriceAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastNetworkAlertAt < ALERT_COOLDOWN_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
lastNetworkAlertAt = now
|
||||
pendingPriceAlert = false
|
||||
networkAlertOpen = true
|
||||
Modal.confirm({
|
||||
title: 'اتصال قیمت برقرار نیست',
|
||||
content:
|
||||
'بهروزرسانی لحظهای قیمت قطع شده است. اتصال اینترنت را بررسی کنید یا دوباره تلاش کنید. تا زمان اتصال مجدد، قیمت با تأخیر بهروز میشود.',
|
||||
okText: 'تلاش مجدد',
|
||||
cancelText: 'بستن',
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
networkAlertOpen = false
|
||||
reconnectAttempt = 0
|
||||
forceReconnect()
|
||||
},
|
||||
onCancel: () => {
|
||||
networkAlertOpen = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function maybeShowNetworkAlert() {
|
||||
if (!(await isRealPriceOutage())) {
|
||||
return
|
||||
}
|
||||
|
||||
showNetworkAlert()
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (stopped || subscribers <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
clearReconnectTimer()
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt),
|
||||
RECONNECT_MAX_MS,
|
||||
)
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectAttempt += 1
|
||||
connect()
|
||||
maybeShowNetworkAlert()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
async function applyLastFromStream(last: Price) {
|
||||
const { applyLastPricePayload } = await import('@/utils/price')
|
||||
applyLastPricePayload(last)
|
||||
}
|
||||
|
||||
function handleLastPayload(payload: unknown) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const last = payload as Price
|
||||
if (last.price_last == null && last.price == null && last.buy == null) {
|
||||
return
|
||||
}
|
||||
|
||||
touchStreamActivity()
|
||||
void applyLastFromStream(last)
|
||||
}
|
||||
|
||||
function handleListPayload(payload: unknown) {
|
||||
if (!Array.isArray(payload) || !payload.length) {
|
||||
return
|
||||
}
|
||||
|
||||
touchStreamActivity()
|
||||
applyProductsListFromStream(payload as Product[])
|
||||
}
|
||||
|
||||
function bindStreamEvents(source: EventSource) {
|
||||
source.addEventListener('last', (event) => {
|
||||
try {
|
||||
handleLastPayload(JSON.parse(event.data))
|
||||
} catch (error) {
|
||||
console.warn('Price SSE last parse failed', error)
|
||||
}
|
||||
})
|
||||
|
||||
source.addEventListener('list', (event) => {
|
||||
try {
|
||||
handleListPayload(JSON.parse(event.data))
|
||||
} catch (error) {
|
||||
console.warn('Price SSE list parse failed', error)
|
||||
}
|
||||
})
|
||||
|
||||
source.addEventListener('ping', () => {
|
||||
touchStreamActivity()
|
||||
})
|
||||
|
||||
source.onmessage = () => {
|
||||
touchStreamActivity()
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamLost(scheduleRetry = true) {
|
||||
const wasConnected = priceSocketConnected.value
|
||||
disconnectSourceOnly()
|
||||
priceSocketConnected.value = false
|
||||
priceStreamLifecycle.value = scheduleRetry ? 'connecting' : 'closed'
|
||||
|
||||
if ((wasConnected || scheduleRetry) && scheduleRetry) {
|
||||
startFallbackPolling()
|
||||
}
|
||||
|
||||
if (scheduleRetry && !stopped && subscribers > 0) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectSourceOnly() {
|
||||
stopStaleCheck()
|
||||
if (eventSource) {
|
||||
eventSource.onopen = null
|
||||
eventSource.onerror = null
|
||||
eventSource.onmessage = null
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (stopped || subscribers <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = resolveStreamUrl()
|
||||
if (!url) {
|
||||
priceSocketConnected.value = false
|
||||
priceStreamLifecycle.value = 'closed'
|
||||
clearConnectingLifecycle()
|
||||
return
|
||||
}
|
||||
|
||||
if (eventSource) {
|
||||
const state = eventSource.readyState
|
||||
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
|
||||
return
|
||||
}
|
||||
disconnectSourceOnly()
|
||||
}
|
||||
|
||||
markConnectingLifecycle()
|
||||
|
||||
try {
|
||||
eventSource = new EventSource(url)
|
||||
} catch (error) {
|
||||
console.warn('Price SSE connect failed', error)
|
||||
handleStreamLost()
|
||||
return
|
||||
}
|
||||
|
||||
bindStreamEvents(eventSource)
|
||||
|
||||
eventSource.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
clearReconnectTimer()
|
||||
stopFallbackPolling()
|
||||
networkAlertOpen = false
|
||||
priceStreamLifecycle.value = 'open'
|
||||
clearConnectingLifecycle()
|
||||
priceSocketConnected.value = true
|
||||
touchStreamActivity()
|
||||
startStaleCheck()
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
const state = eventSource?.readyState
|
||||
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
|
||||
return
|
||||
}
|
||||
|
||||
handleStreamLost()
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
clearReconnectTimer()
|
||||
stopFallbackPolling()
|
||||
disconnectSourceOnly()
|
||||
priceSocketConnected.value = false
|
||||
priceStreamLifecycle.value = 'idle'
|
||||
clearConnectingLifecycle()
|
||||
}
|
||||
|
||||
export function forceReconnect() {
|
||||
if (stopped || subscribers <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
clearReconnectTimer()
|
||||
disconnect()
|
||||
connect()
|
||||
}
|
||||
|
||||
function onBrowserOnline() {
|
||||
if (stopped || subscribers <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttempt = 0
|
||||
networkAlertOpen = false
|
||||
forceReconnect()
|
||||
}
|
||||
|
||||
function onBrowserOffline() {
|
||||
handleStreamLost(true)
|
||||
void maybeShowNetworkAlert()
|
||||
}
|
||||
|
||||
function bindBrowserNetworkListeners() {
|
||||
if (listenersBound || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
listenersBound = true
|
||||
window.addEventListener('online', onBrowserOnline)
|
||||
window.addEventListener('offline', onBrowserOffline)
|
||||
}
|
||||
|
||||
function unbindBrowserNetworkListeners() {
|
||||
if (!listenersBound || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
listenersBound = false
|
||||
window.removeEventListener('online', onBrowserOnline)
|
||||
window.removeEventListener('offline', onBrowserOffline)
|
||||
}
|
||||
|
||||
export function startPriceSocket(settingGetter: (key: string) => unknown) {
|
||||
getSetting = settingGetter
|
||||
subscribers += 1
|
||||
stopped = false
|
||||
connect()
|
||||
bindBrowserNetworkListeners()
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pageshow', onPageShow)
|
||||
}
|
||||
}
|
||||
|
||||
export function stopPriceSocket() {
|
||||
subscribers = Math.max(0, subscribers - 1)
|
||||
if (subscribers > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
stopped = true
|
||||
disconnect()
|
||||
unbindBrowserNetworkListeners()
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pageshow', onPageShow)
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (stopped || subscribers <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.visibilityState === 'hidden') {
|
||||
reconnectAttempt = 0
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttempt = 0
|
||||
networkAlertOpen = false
|
||||
|
||||
if (pendingPriceAlert) {
|
||||
void maybeShowNetworkAlert()
|
||||
}
|
||||
|
||||
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
|
||||
forceReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
function onPageShow(event: PageTransitionEvent) {
|
||||
if (stopped || subscribers <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) {
|
||||
reconnectAttempt = 0
|
||||
networkAlertOpen = false
|
||||
forceReconnect()
|
||||
if (pendingPriceAlert) {
|
||||
void maybeShowNetworkAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isPriceWebSocketEnabled(settingGetter: (key: string) => unknown): boolean {
|
||||
getSetting = settingGetter
|
||||
if (!isTruthySetting(settingGetter('wss_enabled'), true)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return resolveStreamUrl() !== null
|
||||
}
|
||||
|
||||
let channelStarted = false
|
||||
|
||||
function shutdownPriceRealtimeChannel() {
|
||||
if (!channelStarted && subscribers <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
channelStarted = false
|
||||
stopped = true
|
||||
subscribers = 0
|
||||
disconnect()
|
||||
unbindBrowserNetworkListeners()
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pageshow', onPageShow)
|
||||
}
|
||||
}
|
||||
|
||||
export function initPriceRealtimeChannel(settingGetter: (key: string) => unknown) {
|
||||
getSetting = settingGetter
|
||||
priceWssEnabled.value = isTruthySetting(settingGetter('wss_enabled'), true)
|
||||
|
||||
if (!priceWssEnabled.value || !isPriceWebSocketEnabled(settingGetter)) {
|
||||
shutdownPriceRealtimeChannel()
|
||||
return
|
||||
}
|
||||
|
||||
if (channelStarted && subscribers > 0) {
|
||||
forceReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
channelStarted = true
|
||||
stopped = false
|
||||
startPriceSocket(settingGetter)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const priceSocketConnected = ref(false)
|
||||
export const priceWssEnabled = ref(false)
|
||||
|
||||
export type PriceStreamLifecycle = 'idle' | 'connecting' | 'open' | 'closed'
|
||||
|
||||
export const priceStreamLifecycle = ref<PriceStreamLifecycle>('idle')
|
||||
|
||||
export const priceStreamConnectingSince = ref(0)
|
||||
|
||||
const CONNECTING_POLL_AFTER_MS = 8000
|
||||
|
||||
export function shouldUsePricePolling(): boolean {
|
||||
if (!priceWssEnabled.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (priceStreamLifecycle.value === 'open') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (priceStreamLifecycle.value === 'connecting') {
|
||||
const since = priceStreamConnectingSince.value
|
||||
if (since > 0 && Date.now() - since >= CONNECTING_POLL_AFTER_MS) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Product } from '@/models/products'
|
||||
|
||||
type ProductListHandler = (products: Product[]) => void
|
||||
|
||||
const handlers = new Set<ProductListHandler>()
|
||||
|
||||
export function subscribeProductListStream(handler: ProductListHandler) {
|
||||
handlers.add(handler)
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyProductsListFromStream(products: Product[]) {
|
||||
if (!Array.isArray(products) || !products.length) {
|
||||
return
|
||||
}
|
||||
|
||||
handlers.forEach((handler) => handler(products))
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ref } from 'vue'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { lastPrice } from '@/utils/price'
|
||||
|
||||
export interface StoreTradingHours {
|
||||
start: string
|
||||
end: string
|
||||
closePriceEnabled: boolean
|
||||
}
|
||||
|
||||
export interface EffectiveTradingHours {
|
||||
start: string
|
||||
end: string
|
||||
enforced: boolean
|
||||
}
|
||||
|
||||
const productTradingHours = ref<{ start: string; end: string } | null>(null)
|
||||
const productTradingHoursVersion = ref(0)
|
||||
|
||||
function normalizeTradingTimeSetting(value: unknown, fallback: string): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
return raw || fallback
|
||||
}
|
||||
|
||||
function parseTimeToSeconds(time: string): number {
|
||||
const parts = time.trim().split(':').map(Number)
|
||||
const [hours = 0, minutes = 0, seconds = 0] = parts
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
export function formatTradingTimeDisplay(time: string): string {
|
||||
const normalized = normalizeTradingTimeSetting(time, '')
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parts = normalized.split(':')
|
||||
if (parts.length >= 3) {
|
||||
return `${parts[0]}:${parts[1]}:${parts[2]}`
|
||||
}
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}:${parts[1]}:00`
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function hasProductTradingWindow(start?: string | null, end?: string | null): boolean {
|
||||
const open = formatTradingTimeDisplay(normalizeTradingTimeSetting(start, '00:00:00'))
|
||||
const close = formatTradingTimeDisplay(normalizeTradingTimeSetting(end, '00:00:00'))
|
||||
if (!open || !close) {
|
||||
return false
|
||||
}
|
||||
|
||||
return open !== close
|
||||
}
|
||||
|
||||
export function setProductTradingHours(start?: string | null, end?: string | null) {
|
||||
if (!hasProductTradingWindow(start, end)) {
|
||||
productTradingHours.value = null
|
||||
productTradingHoursVersion.value += 1
|
||||
return
|
||||
}
|
||||
|
||||
productTradingHours.value = {
|
||||
start: normalizeTradingTimeSetting(start, '00:00'),
|
||||
end: normalizeTradingTimeSetting(end, '23:59'),
|
||||
}
|
||||
productTradingHoursVersion.value += 1
|
||||
}
|
||||
|
||||
export function syncProductTradingHoursFromProducts(
|
||||
products: Array<{ auto_open_at?: string | null; auto_close_at?: string | null }>,
|
||||
) {
|
||||
for (const product of products) {
|
||||
if (hasProductTradingWindow(product.auto_open_at, product.auto_close_at)) {
|
||||
setProductTradingHours(product.auto_open_at, product.auto_close_at)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setProductTradingHours(null, null)
|
||||
}
|
||||
|
||||
export function getStoreTradingHours(): StoreTradingHours {
|
||||
const settings = useSettingsStore()
|
||||
const closeRaw = settings.getSettingByKey('close_price')
|
||||
const closePriceEnabled =
|
||||
closeRaw === true ||
|
||||
closeRaw === 1 ||
|
||||
String(closeRaw ?? 'false').trim().toLowerCase() === 'true'
|
||||
|
||||
return {
|
||||
start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'),
|
||||
end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'),
|
||||
closePriceEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEffectiveTradingHours(): EffectiveTradingHours {
|
||||
void productTradingHoursVersion.value
|
||||
const settings = getStoreTradingHours()
|
||||
if (settings.closePriceEnabled) {
|
||||
return {
|
||||
start: settings.start,
|
||||
end: settings.end,
|
||||
enforced: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (productTradingHours.value?.start && productTradingHours.value?.end) {
|
||||
return {
|
||||
start: productTradingHours.value.start,
|
||||
end: productTradingHours.value.end,
|
||||
enforced: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: settings.start,
|
||||
end: settings.end,
|
||||
enforced: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function isStoreClosedBySchedule(now = new Date()): boolean {
|
||||
const { start, end, enforced } = getEffectiveTradingHours()
|
||||
if (!enforced) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
|
||||
const openSeconds = parseTimeToSeconds(start)
|
||||
const closeSeconds = parseTimeToSeconds(end)
|
||||
|
||||
return currentSeconds < openSeconds || currentSeconds > closeSeconds
|
||||
}
|
||||
|
||||
export function isStoreClosed(): boolean {
|
||||
void productTradingHoursVersion.value
|
||||
const { closePriceEnabled } = getStoreTradingHours()
|
||||
|
||||
if (closePriceEnabled) {
|
||||
if (lastPrice.value?.closed === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isStoreClosedBySchedule()
|
||||
}
|
||||
|
||||
// وقتی close_price خاموش است، صفر بودن قیمت مرجع به معنی بسته بودن فروشگاه نیست.
|
||||
return isStoreClosedBySchedule()
|
||||
}
|
||||
|
||||
export function getStoreClosedMessage(): string {
|
||||
const { start, end } = getEffectiveTradingHours()
|
||||
const open = formatTradingTimeDisplay(start)
|
||||
const close = formatTradingTimeDisplay(end)
|
||||
|
||||
if (!open || !close) {
|
||||
return 'فروشگاه در حال حاضر بسته است.'
|
||||
}
|
||||
|
||||
return `فروشگاه در حال حاضر بسته است. ساعت کاری از ${open} تا ${close} میباشد.`
|
||||
}
|
||||
@@ -17,10 +17,16 @@ export interface SupportMessengerSettings {
|
||||
|
||||
export const supportMessengerSettings = ref<SupportMessengerSettings>({})
|
||||
|
||||
function getActiveSettingValue(settings: Array<{ key?: string; is_active?: boolean; value?: unknown }>, key: string) {
|
||||
const setting = settings.find((item) => item?.key === key && item?.is_active === true)
|
||||
const value = setting?.value
|
||||
function getSupportSettingValue(
|
||||
settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>,
|
||||
key: string
|
||||
) {
|
||||
const setting = settings.find((item) => item?.key === key)
|
||||
if (!setting) return ''
|
||||
|
||||
const value = setting.value
|
||||
if (value === undefined || value === null) return ''
|
||||
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
@@ -33,6 +39,8 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin
|
||||
const type = (messenger ?? '').trim().toLowerCase()
|
||||
const username = id.replace(/^@/, '')
|
||||
|
||||
if (!type) return ''
|
||||
|
||||
switch (type) {
|
||||
case 'bale':
|
||||
if (id.includes('ble.ir')) {
|
||||
@@ -60,9 +68,9 @@ export async function loadSupportMessengerFromSettings() {
|
||||
if (!settings || !Array.isArray(settings)) return
|
||||
|
||||
supportMessengerSettings.value = {
|
||||
messenger: getActiveSettingValue(settings, 'support_messenger'),
|
||||
messengerId: getActiveSettingValue(settings, 'support_messenger_id'),
|
||||
messengerText: getActiveSettingValue(settings, 'support_messenger_text'),
|
||||
messenger: getSupportSettingValue(settings, 'support_messenger'),
|
||||
messengerId: getSupportSettingValue(settings, 'support_messenger_id'),
|
||||
messengerText: getSupportSettingValue(settings, 'support_messenger_text'),
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {baseURL} from '@/themeConfig'
|
||||
import { getConfigApp } from '@/utils/config'
|
||||
import { baseURL as fallbackBaseURL } from '@/themeConfig'
|
||||
import type {AxiosRequestConfig, AxiosResponse} from 'axios'
|
||||
import axios from 'axios'
|
||||
import {getToken, removeToken} from "@/utils";
|
||||
@@ -20,12 +21,16 @@ const headers = {
|
||||
}
|
||||
|
||||
const _axios = axios.create({
|
||||
baseURL,
|
||||
headers,
|
||||
baseURL: fallbackBaseURL,
|
||||
headers,
|
||||
})
|
||||
|
||||
_axios.interceptors.request.use(
|
||||
(config) => {
|
||||
const runtimeBaseURL = getConfigApp('node_server')
|
||||
if (runtimeBaseURL) {
|
||||
config.baseURL = runtimeBaseURL
|
||||
}
|
||||
//console.log('request config', config)
|
||||
if (config.headers) {
|
||||
const token = getToken()
|
||||
|
||||
@@ -24,3 +24,58 @@ export function useTowDecimal(num: string | number): string | number {
|
||||
export function useFormat(num: string | number, unit = '') {
|
||||
return usePrice(Math.abs(+useTowDecimal(num)), true, unit)
|
||||
}
|
||||
|
||||
type TransactionPriceSource = {
|
||||
cash?: number | null
|
||||
gold?: number | null
|
||||
order?: {
|
||||
price?: number | null
|
||||
total_price?: number | null
|
||||
amount?: number | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function getTransactionTradePrice(item: TransactionPriceSource): number {
|
||||
const orderPrice = Number(item.order?.price)
|
||||
if (Number.isFinite(orderPrice) && orderPrice > 0) {
|
||||
return Math.round(orderPrice)
|
||||
}
|
||||
|
||||
const cashAmount = Math.abs(Number(item.cash) || 0)
|
||||
const goldAmount =
|
||||
Math.abs(Number(item.gold) || 0) || Math.abs(Number(item.order?.amount) || 0)
|
||||
|
||||
if (goldAmount > 0 && cashAmount > 0) {
|
||||
return Math.round(cashAmount / goldAmount)
|
||||
}
|
||||
|
||||
const totalPrice = Number(item.order?.total_price)
|
||||
if (Number.isFinite(totalPrice) && totalPrice > 0) {
|
||||
return Math.round(totalPrice)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function getTransactionGoldAmount(item: TransactionPriceSource): number {
|
||||
const gold = Number(item.gold) || 0
|
||||
if (Math.abs(gold) > 0) {
|
||||
return gold
|
||||
}
|
||||
|
||||
const orderAmount = Number(item.order?.amount) || 0
|
||||
if (orderAmount > 0) {
|
||||
const cash = Number(item.cash) || 0
|
||||
if (cash < 0) return orderAmount
|
||||
if (cash > 0) return -orderAmount
|
||||
return orderAmount
|
||||
}
|
||||
|
||||
return gold
|
||||
}
|
||||
|
||||
export function formatGoldAmount(amount?: number | null): string {
|
||||
const num = Number(amount)
|
||||
if (!Number.isFinite(num)) return '0'
|
||||
return String(Number(num.toFixed(3)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user