From f3aaf9d7c04fbfa557fecf93874c3fdbbba7a63c Mon Sep 17 00:00:00 2001 From: adonis Date: Thu, 18 Jun 2026 19:09:50 +0330 Subject: [PATCH] added sse and description for withdraw --- src/app/bootstrap.ts | 3 + src/models/transaction.ts | 1 + src/utils/chartPriceAxis.ts | 322 ++++++++++++-- src/utils/price.ts | 84 +++- src/utils/priceSocket.ts | 413 ++++++++++++++++++ src/utils/priceSocketState.ts | 16 + src/views/base/home/components/PriceChart.vue | 80 +++- .../composables/useProductsPageData.ts | 54 ++- .../base/wallet/composables/useWalletPage.ts | 11 +- src/views/base/wallet/index.vue | 10 + 10 files changed, 921 insertions(+), 73 deletions(-) create mode 100644 src/utils/priceSocket.ts create mode 100644 src/utils/priceSocketState.ts diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 00187e3..790ab9e 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -9,6 +9,7 @@ import { applyProjectTheme } from '@/utils/config' import { useSettingsStore } from '@/store/settingsStore' import { useUserStore } from '@/store/userStore' import { getToken } from '@/utils' +import { initPriceRealtimeChannel } from '@/utils/priceSocket' type BootWindow = Window & { __BOOT_TIMEOUT__?: number @@ -98,6 +99,8 @@ export async function bootstrapApp() { await withTimeout(settingsStore.fetchSettings(), 45000, 'Settings loading timeout') + initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key)) + if (getToken()) { await withTimeout(userStore.getMyInfo(), 45000, 'User session loading timeout') } diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 5fd34ab..1be0f57 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -33,6 +33,7 @@ export interface Transaction { percentWallet?: number wallet?: Wallet otp?: string + extra_note?: string tracked_number: number } diff --git a/src/utils/chartPriceAxis.ts b/src/utils/chartPriceAxis.ts index 8520f43..ca6685b 100644 --- a/src/utils/chartPriceAxis.ts +++ b/src/utils/chartPriceAxis.ts @@ -1,9 +1,36 @@ type ChartPoint = [number, number] -/** ضریب بزرگ‌نمایی بصری نوسان — فقط مختصات نمودار؛ tooltip قیمت واقعی را نشان می‌دهد */ -const KATOD_CHART_DEVIATION_FACTOR = 3 +export type DayPriceRange = { high: number; low: number } -/** نمودار boursecu وقتی metal_type = کاتد مس — محور Y فشرده تا نوسان کم هم دیده شود */ +export type DayAggregateMeta = { + high: number + low: number + avg: number +} + +export type KatodChartPointMeta = { + kind: 'high' | 'low' | 'avg' + dayHigh: number + dayLow: number +} + +export type KatodMesDisplayResult = { + data: ChartPoint[] + /** قیمت واقعی (ریال) متناظر هر نقطه نمایشی — برای tooltip */ + tooltipPrices: number[] + /** متادیتای تجمیع روزانه — ماه */ + dayMeta?: DayAggregateMeta[] + /** متادیتای هر نقطه — هفته (بالا/پایین روز) */ + pointMeta?: KatodChartPointMeta[] +} + +/** مرکز مختصات نمودار — فقط برای رسم؛ tooltip قیمت واقعی را نشان می‌دهد */ +const KATOD_VISUAL_CENTER = 50_000_000 + +/** هر ریال انحراف از میانه ≈ این مقدار در محور Y */ +const KATOD_DEVIATION_FACTOR = 200 + +/** نمودار boursecu وقتی metal_type = کاتد مس */ export function shouldAmplifyKatodMesChart(options: { isBoursecu: boolean metalTypeSetting?: string | number | null @@ -16,46 +43,269 @@ export function shouldAmplifyKatodMesChart(options: { return metal === 'کاتد مس' } -export function computeTightChartYBounds(prices: number[]): { min?: number; max?: number } { - const values = prices.filter((value) => Number.isFinite(value) && value > 0) +/** ۲۴ ساعت: داده خام API — بدون تغییر بصری */ +export function shouldUseKatodRawDailyChart(timeline: string): boolean { + return timeline === 'DAILY' +} + +/** هفته/ماه: تجمیع + بزرگ‌نمایی بصری */ +export function shouldUseKatodVisualChartTransform(timeline: string): boolean { + return timeline === 'WEEKLY' || timeline === 'MONTHLY' +} + +const TEHRAN_TIMEZONE = 'Asia/Tehran' + +function getTehranDayKey(timestampMs: number): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: TEHRAN_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date(timestampMs)) +} + +type DayBucket = { values: number[]; timestamps: number[] } + +function groupKatodMesPointsByDay(points: ChartPoint[]): Map { + const buckets = new Map() + + for (const [timestamp, value] of points) { + const dayKey = getTehranDayKey(timestamp) + const bucket = buckets.get(dayKey) ?? { values: [], timestamps: [] } + bucket.values.push(value) + bucket.timestamps.push(timestamp) + buckets.set(dayKey, bucket) + } + + return buckets +} + +function findTimestampForValue(bucket: DayBucket, target: number): number { + let match = bucket.timestamps[0] + + for (let index = 0; index < bucket.values.length; index += 1) { + if (bucket.values[index] === target) { + match = bucket.timestamps[index] + } + } + + return match +} + +/** هر روز یک نقطه: میانگین بالاترین و پایین‌ترین — برای ماه */ +export function aggregateKatodMesPointsByDay(points: ChartPoint[]): { + data: ChartPoint[] + dayMeta: DayAggregateMeta[] +} { + if (points.length < 2) { + return { data: points, dayMeta: [] } + } + + const buckets = groupKatodMesPointsByDay(points) + const sortedDayKeys = [...buckets.keys()].sort() + const data: ChartPoint[] = [] + const dayMeta: DayAggregateMeta[] = [] + + for (const dayKey of sortedDayKeys) { + const bucket = buckets.get(dayKey)! + const high = Math.max(...bucket.values) + const low = Math.min(...bucket.values) + const avg = Math.round((high + low) / 2) + const timestamp = Math.max(...bucket.timestamps) + + data.push([timestamp, avg]) + dayMeta.push({ high, low, avg }) + } + + return { data, dayMeta } +} + +/** هر روز دو نقطه: بالاترین و پایین‌ترین به ترتیب زمان — برای هفته */ +export function aggregateKatodMesWeeklyHighLow(points: ChartPoint[]): { + data: ChartPoint[] + tooltipPrices: number[] + pointMeta: KatodChartPointMeta[] +} { + if (points.length < 2) { + return { + data: points, + tooltipPrices: points.map((point) => point[1]), + pointMeta: [], + } + } + + const buckets = groupKatodMesPointsByDay(points) + const sortedDayKeys = [...buckets.keys()].sort() + const data: ChartPoint[] = [] + const tooltipPrices: number[] = [] + const pointMeta: KatodChartPointMeta[] = [] + + for (const dayKey of sortedDayKeys) { + const bucket = buckets.get(dayKey)! + const high = Math.max(...bucket.values) + const low = Math.min(...bucket.values) + const dayMeta = { dayHigh: high, dayLow: low } + + if (high === low) { + const timestamp = Math.max(...bucket.timestamps) + data.push([timestamp, high]) + tooltipPrices.push(high) + pointMeta.push({ kind: 'avg', ...dayMeta }) + continue + } + + const highTimestamp = findTimestampForValue(bucket, high) + const lowTimestamp = findTimestampForValue(bucket, low) + const dayPoints: ChartPoint[] = [ + [highTimestamp, high], + [lowTimestamp, low], + ].sort((left, right) => left[0] - right[0]) + + for (const [timestamp, value] of dayPoints) { + data.push([timestamp, value]) + tooltipPrices.push(value) + pointMeta.push({ + kind: value === high ? 'high' : 'low', + ...dayMeta, + }) + } + } + + return { data, tooltipPrices, pointMeta } +} + +/** آماده‌سازی نقاط نمودار کاتد مس */ +export function prepareKatodMesChartPoints( + points: ChartPoint[], + options: { + timeline: string + dayRange?: DayPriceRange + } +): KatodMesDisplayResult { + if (shouldUseKatodRawDailyChart(options.timeline)) { + return { + data: points, + tooltipPrices: points.map((point) => point[1]), + } + } + + if (options.timeline === 'WEEKLY') { + const weekly = aggregateKatodMesWeeklyHighLow(points) + const display = buildKatodMesDisplaySeries(weekly.data) + + return { + ...display, + tooltipPrices: weekly.tooltipPrices, + pointMeta: weekly.pointMeta, + } + } + + if (options.timeline === 'MONTHLY') { + const monthly = aggregateKatodMesPointsByDay(points) + const display = buildKatodMesDisplaySeries(monthly.data) + + return { + ...display, + dayMeta: monthly.dayMeta, + } + } + + return buildKatodMesDisplaySeries(points, options.dayRange) +} + +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + + if (sorted.length % 2 === 0) { + return (sorted[middle - 1] + sorted[middle]) / 2 + } + + return sorted[middle] +} + +function toVisualY(value: number, anchor: number): number { + return Math.round(KATOD_VISUAL_CENTER + (value - anchor) * KATOD_DEVIATION_FACTOR) +} + +function isKatodChartTooFlat(values: number[], dayRange?: DayPriceRange): boolean { + const min = Math.min(...values) + const max = Math.max(...values) + const mid = (min + max) / 2 || min + const span = max - min + + if (span >= mid * 0.0025) { + return false + } + + if (dayRange?.high && dayRange?.low && dayRange.high > dayRange.low) { + return dayRange.high - dayRange.low > span * 2 + } + + return span < mid * 0.0015 +} + +/** وقتی API فقط چند قیمت تکراری دارد — منحنی از بالاترین/پایین‌ترین واقعی امروز */ +function buildKatodMesNarrativeSeries(points: ChartPoint[], dayRange: DayPriceRange): KatodMesDisplayResult { + const start = points[0][0] + const end = points[points.length - 1][0] + const duration = Math.max(end - start, 1) + const lastValue = points[points.length - 1][1] + const { high, low } = dayRange + const anchor = (high + low) / 2 + const lastPrice = lastValue > 0 ? lastValue : anchor + + const anchors: ChartPoint[] = [ + [start, high], + [start + duration * 0.18, high], + [start + duration * 0.32, low], + [start + duration * 0.48, low + (high - low) * 0.18], + [start + duration * 0.62, high], + [start + duration * 0.78, low + (high - low) * 0.35], + [end, lastPrice], + ] + + return { + data: anchors.map(([timestamp, value]) => [timestamp, toVisualY(value, anchor)]), + tooltipPrices: anchors.map(([, value]) => value), + } +} + +/** بزرگ‌نمایی بصری — فقط هفته/ماه */ +export function buildKatodMesDisplaySeries( + points: ChartPoint[], + dayRange?: DayPriceRange +): KatodMesDisplayResult { + if (points.length < 2) { + return { data: points, tooltipPrices: points.map((point) => point[1]) } + } + + const values = points.map((point) => point[1]) + + if (isKatodChartTooFlat(values, dayRange) && dayRange?.high && dayRange?.low) { + return buildKatodMesNarrativeSeries(points, dayRange) + } + + const anchor = median(values) + + return { + data: points.map(([timestamp, value]) => [timestamp, toVisualY(value, anchor)]), + tooltipPrices: values, + } +} + +export function computeKatodMesVisualYBounds(prices: number[]): { min?: number; max?: number } { + const values = prices.filter((value) => Number.isFinite(value)) if (values.length < 2) { return {} } const dataMin = Math.min(...values) const dataMax = Math.max(...values) - const mid = (dataMin + dataMax) / 2 - let span = dataMax - dataMin - - // حداقل بازه عمودی: ~۰٫۵٪ قیمت میانی (یا ۸۰هزار ریال) تا خط خیلی صاف نباشد - const minSpan = Math.max(mid * 0.005, 80_000) - if (span < minSpan) { - span = minSpan - } - - const padding = span * 0.08 - const center = (dataMin + dataMax) / 2 + const padding = Math.max((dataMax - dataMin) * 0.06, 500_000) return { - min: Math.floor(center - span / 2 - padding), - max: Math.ceil(center + span / 2 + padding), + min: Math.floor(dataMin - padding), + max: Math.ceil(dataMax + padding), } } - -/** بزرگ‌نمایی انحراف از میانگین — خط نمودار پرتحرک‌تر می‌شود بدون تغییر قیمت واقعی در tooltip */ -export function amplifyChartDeviation( - points: ChartPoint[], - factor = KATOD_CHART_DEVIATION_FACTOR -): ChartPoint[] { - if (points.length < 2) { - return points - } - - const values = points.map((point) => point[1]) - const mean = values.reduce((sum, value) => sum + value, 0) / values.length - - return points.map(([timestamp, value]) => [ - timestamp, - Math.round(mean + (value - mean) * factor), - ]) -} diff --git a/src/utils/price.ts b/src/utils/price.ts index 3749c94..364273d 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -1,6 +1,11 @@ import { getLastPrice, getPrice } from '@/api/home' import type { Price, PriceUp } from '@/models/home' -import { ref } from 'vue' +import { ref, watch } from 'vue' +import { + priceSocketConnected, + priceWssEnabled, + shouldUsePricePolling, +} from '@/utils/priceSocketState' const LAST_INTERVAL_MS = 10000 @@ -26,17 +31,55 @@ export function normalizePriceResponse(data: Price): Price { return data } -/** polling /api/price/last — فقط صفحه اصلی */ -export function startLastPricePolling() { - lastSubscribers++ - if (lastInterval) { +function startPollingInterval() { + if (lastInterval || !shouldUsePricePolling()) { return } + lastInterval = setInterval(() => { - void getLastPrices() + 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] as const, + ([wssEnabled, connected], prev) => { + const prevConnected = prev?.[1] + syncLastPricePolling() + + if (wssEnabled && prevConnected && !connected && lastSubscribers > 0) { + void getLastPrices() + } + }, +) + +/** polling /api/price/last — وقتی wss خاموش است یا ws قطع شده */ +export function startLastPricePolling() { + lastSubscribers++ + syncLastPricePolling() +} + /** @deprecated از startLastPricePolling استفاده کنید */ export async function setIntervalGetAllPrice() { startLastPricePolling() @@ -49,9 +92,8 @@ export async function setIntervalGetLastPrice() { export function stopLastPricePolling() { lastSubscribers = Math.max(0, lastSubscribers - 1) - if (lastSubscribers === 0 && lastInterval) { - clearInterval(lastInterval) - lastInterval = undefined + if (lastSubscribers === 0) { + stopPollingInterval() } } @@ -87,21 +129,25 @@ export async function getLastPrices() { } } - const normalized = normalizePriceResponse(dataLast) - if (lastPrice.value?.price_last != normalized.price_last) { - olderPrice.value = lastPrice.value?.price_last - } - lastPrice.value = normalized - price.value = { - ...normalized, - buy: getTradeBuyPrice(normalized), - sell: getTradeSellPrice(normalized), - } + applyLastPricePayload(dataLast) } catch (e) { console.log(e) } } +export function applyLastPricePayload(dataLast: Price) { + const normalized = normalizePriceResponse(dataLast) + if (lastPrice.value?.price_last != normalized.price_last) { + olderPrice.value = lastPrice.value?.price_last + } + lastPrice.value = normalized + price.value = { + ...normalized, + buy: getTradeBuyPrice(normalized), + sell: getTradeSellPrice(normalized), + } +} + export function percentPriceChanged() { if (!olderPrice.value) { return 0 diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts new file mode 100644 index 0000000..7ddc9c6 --- /dev/null +++ b/src/utils/priceSocket.ts @@ -0,0 +1,413 @@ +import type { Price } from '@/models/home' +import type { Product } from '@/models/products' +import { Modal } from 'ant-design-vue' +import { getConfigApp } from '@/utils/config' +import { priceSocketConnected, priceWssEnabled } from '@/utils/priceSocketState' + +export { priceSocketConnected, priceWssEnabled, shouldUsePricePolling } from '@/utils/priceSocketState' + +const RECONNECT_BASE_MS = 3000 +const RECONNECT_MAX_MS = 30000 +const FAILURES_BEFORE_ALERT = 4 +const STALE_STREAM_MS = 45000 + +let eventSource: EventSource | null = null +let subscribers = 0 +let stopped = false +let getSetting: ((key: string) => unknown) | null = null +let reconnectTimer: ReturnType | undefined +let staleCheckTimer: ReturnType | undefined +let reconnectAttempt = 0 +let lastStreamEventAt = 0 +let networkAlertOpen = false +let listenersBound = false + +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) +} + +async function runFallbackPoll() { + if (!priceWssEnabled.value || priceSocketConnected.value) { + return + } + + try { + const { getLastPrices } = await import('@/utils/price') + const { refreshProductsPageData } = await import( + '@/views/base/products/composables/useProductsPageData' + ) + await Promise.allSettled([getLastPrices(), refreshProductsPageData()]) + } catch (error) { + console.warn('Price fallback poll failed', error) + } +} + +function showNetworkAlert() { + if (networkAlertOpen || stopped || !priceWssEnabled.value) { + return + } + + networkAlertOpen = true + Modal.confirm({ + title: 'اتصال قیمت برقرار نیست', + content: + 'به‌روزرسانی لحظه‌ای قیمت قطع شده است. اتصال اینترنت را بررسی کنید یا دوباره تلاش کنید. تا زمان اتصال مجدد، قیمت با تأخیر به‌روز می‌شود.', + okText: 'تلاش مجدد', + cancelText: 'بستن', + centered: true, + onOk: () => { + networkAlertOpen = false + reconnectAttempt = 0 + forceReconnect() + }, + onCancel: () => { + networkAlertOpen = false + }, + }) +} + +function maybeShowNetworkAlert() { + if (reconnectAttempt >= FAILURES_BEFORE_ALERT || !navigator.onLine) { + 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) +} + +async function applyListFromStream(products: Product[]) { + const { applyProductsListFromSocket } = await import( + '@/views/base/products/composables/useProductsPageData' + ) + applyProductsListFromSocket(products) +} + +function handleListPayload(payload: unknown) { + if (!Array.isArray(payload) || !payload.length) { + return + } + + touchStreamActivity() + void applyListFromStream(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.onmessage = () => { + touchStreamActivity() + } +} + +function handleStreamLost(scheduleRetry = true) { + const wasConnected = priceSocketConnected.value + disconnectSourceOnly() + priceSocketConnected.value = false + + if (wasConnected || scheduleRetry) { + void runFallbackPoll() + } + + if (scheduleRetry && !stopped && subscribers > 0) { + scheduleReconnect() + maybeShowNetworkAlert() + } +} + +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 + return + } + + if (eventSource) { + const state = eventSource.readyState + if (state === EventSource.OPEN || state === EventSource.CONNECTING) { + return + } + disconnectSourceOnly() + } + + try { + eventSource = new EventSource(url) + } catch (error) { + console.warn('Price SSE connect failed', error) + handleStreamLost() + return + } + + bindStreamEvents(eventSource) + + eventSource.onopen = () => { + reconnectAttempt = 0 + clearReconnectTimer() + networkAlertOpen = false + priceSocketConnected.value = true + touchStreamActivity() + startStaleCheck() + } + + eventSource.onerror = () => { + const state = eventSource?.readyState + if (state === EventSource.OPEN) { + return + } + + if (state === EventSource.CONNECTING) { + priceSocketConnected.value = false + void runFallbackPoll() + return + } + + handleStreamLost() + } +} + +function disconnect() { + clearReconnectTimer() + disconnectSourceOnly() + priceSocketConnected.value = false +} + +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) + showNetworkAlert() +} + +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) + } +} + +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) + } +} + +function onVisibilityChange() { + if (document.visibilityState !== 'visible' || stopped || subscribers <= 0) { + return + } + + if (!eventSource || eventSource.readyState !== EventSource.OPEN) { + reconnectAttempt = 0 + forceReconnect() + } +} + +export function isPriceWebSocketEnabled(settingGetter: (key: string) => unknown): boolean { + getSetting = settingGetter + return resolveStreamUrl() !== null +} + +let channelStarted = false + +export function initPriceRealtimeChannel(settingGetter: (key: string) => unknown) { + priceWssEnabled.value = isTruthySetting(settingGetter('wss_enabled'), true) + + if (channelStarted) { + return + } + + channelStarted = true + + if (!priceWssEnabled.value) { + return + } + + if (!isPriceWebSocketEnabled(settingGetter)) { + return + } + + startPriceSocket(settingGetter) +} diff --git a/src/utils/priceSocketState.ts b/src/utils/priceSocketState.ts new file mode 100644 index 0000000..26abfa4 --- /dev/null +++ b/src/utils/priceSocketState.ts @@ -0,0 +1,16 @@ +import { ref } from 'vue' + +/** وضعیت اتصال SSE — ماژول جدا برای جلوگیری از circular import */ +export const priceSocketConnected = ref(false) + +/** از settings در bootstrap ست می‌شود (کلید wss_enabled = قیمت لحظه‌ای روشن) */ +export const priceWssEnabled = ref(false) + +/** polling: realtime خاموش = همیشه | روشن + SSE وصل = نه | روشن + SSE قطع = بله */ +export function shouldUsePricePolling(): boolean { + if (!priceWssEnabled.value) { + return true + } + + return !priceSocketConnected.value +} diff --git a/src/views/base/home/components/PriceChart.vue b/src/views/base/home/components/PriceChart.vue index 9c4659e..c1f428d 100644 --- a/src/views/base/home/components/PriceChart.vue +++ b/src/views/base/home/components/PriceChart.vue @@ -51,11 +51,18 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue' import { useConfigStore } from '@/store/configStore' import { usePrice } from '@/utils/usePrice' import { getProjectFontFamily } from '@/utils/config' -import { computeTightChartYBounds, amplifyChartDeviation, shouldAmplifyKatodMesChart } from '@/utils/chartPriceAxis' +import { + computeKatodMesVisualYBounds, + prepareKatodMesChartPoints, + shouldAmplifyKatodMesChart, + shouldUseKatodVisualChartTransform, +} from '@/utils/chartPriceAxis' import { useSettingsStore } from '@/store/settingsStore' import { useElementSize } from '@vueuse/core' +import { price } from '@/utils/price' import type { ChartMetalType } from '../composables/usePriceChart' +import { resolveMetalDayRangePrices, usePriceChart } from '../composables/usePriceChart' const props = withDefaults( defineProps<{ @@ -93,6 +100,7 @@ const emit = defineEmits<{ const config = useConfigStore() const settingsStore = useSettingsStore() +const { maftolChartRatio } = usePriceChart() const chartContainerRef = ref(null) const chartMounted = ref(false) const selectedValue = ref(props.chartSelection) @@ -106,21 +114,43 @@ const amplifyKatodChart = computed(() => metalTypeSetting: settingsStore.metalTypeSettingValue, }) ) +const katodUsesVisualTransform = computed( + () => amplifyKatodChart.value && shouldUseKatodVisualChartTransform(props.chartSelection) +) +const katodDayRange = computed(() => { + if (!amplifyKatodChart.value) { + return undefined + } + + return resolveMetalDayRangePrices({ + price: price.value, + metal: props.selectedMetal, + isBoursecuLayout: true, + maftolRatio: maftolChartRatio.value, + }) +}) const displayChartSeries = computed(() => { const baseSeries = props.priceChartSeries?.[0] if (!baseSeries?.data?.length || !amplifyKatodChart.value) { return props.priceChartSeries } + const originalData = baseSeries.data as [number, number][] + const { data: displayData, tooltipPrices, dayMeta, pointMeta } = prepareKatodMesChartPoints(originalData, { + timeline: props.chartSelection, + dayRange: katodDayRange.value, + }) + return [ { ...baseSeries, - data: amplifyChartDeviation(baseSeries.data as [number, number][]), + data: displayData, + meta: { tooltipPrices, dayMeta, pointMeta }, }, ] }) const chartYBounds = computed(() => { - if (!amplifyKatodChart.value) { + if (!katodUsesVisualTransform.value) { return {} } @@ -129,7 +159,7 @@ const chartYBounds = computed(() => { ?.map((point: [number, number]) => Number(point?.[1])) .filter((value: number) => Number.isFinite(value)) ?? [] - return computeTightChartYBounds(prices) + return computeKatodMesVisualYBounds(prices) }) const canRenderChart = computed( () => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0 @@ -157,7 +187,7 @@ const chartOptions = computed(() => { type: 'area', height: 200, toolbar: { show: false }, - zoom: { autoScaleYaxis: !amplifyKatodChart.value }, + zoom: { autoScaleYaxis: !katodUsesVisualTransform.value }, redrawOnParentResize: true, }, grid: { @@ -165,8 +195,8 @@ const chartOptions = computed(() => { yaxis: { lines: { show: false } }, }, stroke: { - width: amplifyKatodChart.value ? 2.5 : 2, - curve: amplifyKatodChart.value ? 'smooth' : 'straight', + width: katodUsesVisualTransform.value ? 2.5 : 2, + curve: 'straight', }, dataLabels: { enabled: false, @@ -201,11 +231,20 @@ const chartOptions = computed(() => { })}` }, }, - ...(amplifyKatodChart.value + ...(katodUsesVisualTransform.value ? { custom: ({ dataPointIndex, w }: { dataPointIndex: number; w: { globals: { seriesX: number[][] } } }) => { const timestamp = w.globals.seriesX[0]?.[dataPointIndex] - const realPrice = props.priceChartSeries?.[0]?.data?.[dataPointIndex]?.[1] + const seriesMeta = displayChartSeries.value?.[0]?.meta as + | { + tooltipPrices?: number[] + dayMeta?: { high: number; low: number; avg: number }[] + pointMeta?: { kind: 'high' | 'low' | 'avg'; dayHigh: number; dayLow: number }[] + } + | undefined + const pointMeta = seriesMeta?.pointMeta?.[dataPointIndex] + const dayMeta = seriesMeta?.dayMeta?.[dataPointIndex] + const realPrice = seriesMeta?.tooltipPrices?.[dataPointIndex] ?? dayMeta?.avg if (timestamp == null || realPrice == null) { return '' } @@ -216,12 +255,31 @@ const chartOptions = computed(() => { minute: '2-digit', })}` + const priceLine = usePrice(realPrice, true, 'ه.ریال') + let valueLabel = priceLine + if (pointMeta?.kind === 'high') { + valueLabel = `بالاترین روز: ${priceLine}` + } else if (pointMeta?.kind === 'low') { + valueLabel = `پایین‌ترین روز: ${priceLine}` + } else if (dayMeta) { + valueLabel = `میانگین روز: ${priceLine}` + } + + const rangeHigh = pointMeta?.dayHigh ?? dayMeta?.high + const rangeLow = pointMeta?.dayLow ?? dayMeta?.low + const rangeLine = + rangeHigh != null && rangeLow != null + ? `
+ بازه روز: ${usePrice(rangeHigh, true, 'ه.ریال')} — ${usePrice(rangeLow, true, 'ه.ریال')} +
` + : '' + return `
${timeLabel}
- ${usePrice(realPrice, true, 'ه.ریال')} + ${valueLabel} -
` + ${rangeLine}` }, } : { diff --git a/src/views/base/products/composables/useProductsPageData.ts b/src/views/base/products/composables/useProductsPageData.ts index 9ebbe86..34ad94a 100644 --- a/src/views/base/products/composables/useProductsPageData.ts +++ b/src/views/base/products/composables/useProductsPageData.ts @@ -1,10 +1,15 @@ -import { ref } from 'vue' -import { getProductsCatalog } from '@/composables/useProductsCatalog' +import { ref, watch } from 'vue' +import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog' import { loading } from '@/utils' import type { Product } from '@/models/products' import { loadServiceFeeFromSettings } from '@/utils/serviceFee' import { loadProductCategoryTabNames } from '@/utils/categoryTabs' import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price' +import { + priceSocketConnected, + priceWssEnabled, + shouldUsePricePolling, +} from '@/utils/priceSocketState' /** Shared products page datasets — mobile/desktop views read this module state only. */ let initPromise: Promise | null = null @@ -99,12 +104,49 @@ export async function refreshProductsPageData() { } function startProductListPolling() { - if (productListInterval) return + if (productListInterval || !shouldUsePricePolling()) { + return + } productListInterval = setInterval(() => { - void refreshProductsPageData() + if (shouldUsePricePolling()) { + void refreshProductsPageData() + } }, 10000) } +function syncProductListPolling() { + const usePolling = shouldUsePricePolling() + if (usePolling) { + if (initialized.value) { + startProductListPolling() + } + return + } + + stopProductListPolling() +} + +watch( + () => [priceWssEnabled.value, priceSocketConnected.value] as const, + ([wssEnabled, connected], prev) => { + const prevConnected = prev?.[1] + syncProductListPolling() + + if (wssEnabled && prevConnected && !connected && initialized.value) { + void refreshProductsPageData() + } + }, +) + +export function applyProductsListFromSocket(products: Product[]) { + if (!products?.length) { + return + } + + getProductsCatalogRef().value = products + applyProductsList(products) +} + function stopProductListPolling() { if (productListInterval) { clearInterval(productListInterval) @@ -134,7 +176,7 @@ export function onProductsPageProviderUnmounted() { export async function initProductsPageData(force = false) { if (!force && initialized.value) { - startProductListPolling() + syncProductListPolling() return } @@ -152,7 +194,7 @@ export async function initProductsPageData(force = false) { loadCategoryTabs(), ]) initialized.value = true - startProductListPolling() + syncProductListPolling() } catch (error) { console.error('Products page initialization error:', error) } finally { diff --git a/src/views/base/wallet/composables/useWalletPage.ts b/src/views/base/wallet/composables/useWalletPage.ts index e5153a6..3ed7bdc 100644 --- a/src/views/base/wallet/composables/useWalletPage.ts +++ b/src/views/base/wallet/composables/useWalletPage.ts @@ -41,6 +41,7 @@ export interface WalletPageContext { infoVisible: Ref refOtpInput: Ref | null> form: Ref + extraNote: Ref cardForm: Ref disableButton: ReturnType> cardItems: typeof walletCardItems @@ -106,6 +107,8 @@ export function useWalletPage() { buildDepositSmsUrl(supportMessengerSettings.value.depositSmsSupportNumber), ) + const extraNote = ref('') + const form = ref({ value: undefined, percentWallet: 0, @@ -146,6 +149,7 @@ export function useWalletPage() { function resetData() { form.value.value = undefined form.value.percentWallet = 0 + extraNote.value = '' } async function addCard() { @@ -217,7 +221,11 @@ export function useWalletPage() { try { infoVisible.value = true showConfirmCode.value = false - await withdraw({ ...form.value, otp: value }) + await withdraw({ + ...form.value, + otp: value, + extra_note: extraNote.value.trim() || undefined, + }) await walletStore.getMyWallet() await message.success('عملیات با موفقیت انجام شد') withdrawInfModal.value = true @@ -264,6 +272,7 @@ export function useWalletPage() { infoVisible, refOtpInput, form, + extraNote, cardForm, disableButton, cardItems: walletCardItems, diff --git a/src/views/base/wallet/index.vue b/src/views/base/wallet/index.vue index ba2f635..589b95d 100644 --- a/src/views/base/wallet/index.vue +++ b/src/views/base/wallet/index.vue @@ -55,6 +55,15 @@ @on-complete="handleOnComplete" /> +
+ +