added sse and description for withdraw
This commit is contained in:
@@ -9,6 +9,7 @@ import { applyProjectTheme } from '@/utils/config'
|
|||||||
import { useSettingsStore } from '@/store/settingsStore'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import { getToken } from '@/utils'
|
import { getToken } from '@/utils'
|
||||||
|
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
|
||||||
|
|
||||||
type BootWindow = Window & {
|
type BootWindow = Window & {
|
||||||
__BOOT_TIMEOUT__?: number
|
__BOOT_TIMEOUT__?: number
|
||||||
@@ -98,6 +99,8 @@ export async function bootstrapApp() {
|
|||||||
|
|
||||||
await withTimeout(settingsStore.fetchSettings(), 45000, 'Settings loading timeout')
|
await withTimeout(settingsStore.fetchSettings(), 45000, 'Settings loading timeout')
|
||||||
|
|
||||||
|
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
|
||||||
|
|
||||||
if (getToken()) {
|
if (getToken()) {
|
||||||
await withTimeout(userStore.getMyInfo(), 45000, 'User session loading timeout')
|
await withTimeout(userStore.getMyInfo(), 45000, 'User session loading timeout')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface Transaction {
|
|||||||
percentWallet?: number
|
percentWallet?: number
|
||||||
wallet?: Wallet
|
wallet?: Wallet
|
||||||
otp?: string
|
otp?: string
|
||||||
|
extra_note?: string
|
||||||
tracked_number: number
|
tracked_number: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+286
-36
@@ -1,9 +1,36 @@
|
|||||||
type ChartPoint = [number, number]
|
type ChartPoint = [number, number]
|
||||||
|
|
||||||
/** ضریب بزرگنمایی بصری نوسان — فقط مختصات نمودار؛ tooltip قیمت واقعی را نشان میدهد */
|
export type DayPriceRange = { high: number; low: number }
|
||||||
const KATOD_CHART_DEVIATION_FACTOR = 3
|
|
||||||
|
|
||||||
/** نمودار 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: {
|
export function shouldAmplifyKatodMesChart(options: {
|
||||||
isBoursecu: boolean
|
isBoursecu: boolean
|
||||||
metalTypeSetting?: string | number | null
|
metalTypeSetting?: string | number | null
|
||||||
@@ -16,46 +43,269 @@ export function shouldAmplifyKatodMesChart(options: {
|
|||||||
return metal === 'کاتد مس'
|
return metal === 'کاتد مس'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeTightChartYBounds(prices: number[]): { min?: number; max?: number } {
|
/** ۲۴ ساعت: داده خام API — بدون تغییر بصری */
|
||||||
const values = prices.filter((value) => Number.isFinite(value) && value > 0)
|
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<string, DayBucket> {
|
||||||
|
const buckets = new Map<string, DayBucket>()
|
||||||
|
|
||||||
|
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) {
|
if (values.length < 2) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataMin = Math.min(...values)
|
const dataMin = Math.min(...values)
|
||||||
const dataMax = Math.max(...values)
|
const dataMax = Math.max(...values)
|
||||||
const mid = (dataMin + dataMax) / 2
|
const padding = Math.max((dataMax - dataMin) * 0.06, 500_000)
|
||||||
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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
min: Math.floor(center - span / 2 - padding),
|
min: Math.floor(dataMin - padding),
|
||||||
max: Math.ceil(center + span / 2 + 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),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|||||||
+65
-19
@@ -1,6 +1,11 @@
|
|||||||
import { getLastPrice, getPrice } from '@/api/home'
|
import { getLastPrice, getPrice } from '@/api/home'
|
||||||
import type { Price, PriceUp } from '@/models/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
|
const LAST_INTERVAL_MS = 10000
|
||||||
|
|
||||||
@@ -26,17 +31,55 @@ export function normalizePriceResponse(data: Price): Price {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/** polling /api/price/last — فقط صفحه اصلی */
|
function startPollingInterval() {
|
||||||
export function startLastPricePolling() {
|
if (lastInterval || !shouldUsePricePolling()) {
|
||||||
lastSubscribers++
|
|
||||||
if (lastInterval) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lastInterval = setInterval(() => {
|
lastInterval = setInterval(() => {
|
||||||
void getLastPrices()
|
if (shouldUsePricePolling()) {
|
||||||
|
void getLastPrices()
|
||||||
|
}
|
||||||
}, LAST_INTERVAL_MS)
|
}, 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 استفاده کنید */
|
/** @deprecated از startLastPricePolling استفاده کنید */
|
||||||
export async function setIntervalGetAllPrice() {
|
export async function setIntervalGetAllPrice() {
|
||||||
startLastPricePolling()
|
startLastPricePolling()
|
||||||
@@ -49,9 +92,8 @@ export async function setIntervalGetLastPrice() {
|
|||||||
|
|
||||||
export function stopLastPricePolling() {
|
export function stopLastPricePolling() {
|
||||||
lastSubscribers = Math.max(0, lastSubscribers - 1)
|
lastSubscribers = Math.max(0, lastSubscribers - 1)
|
||||||
if (lastSubscribers === 0 && lastInterval) {
|
if (lastSubscribers === 0) {
|
||||||
clearInterval(lastInterval)
|
stopPollingInterval()
|
||||||
lastInterval = undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,21 +129,25 @@ export async function getLastPrices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizePriceResponse(dataLast)
|
applyLastPricePayload(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),
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(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() {
|
export function percentPriceChanged() {
|
||||||
if (!olderPrice.value) {
|
if (!olderPrice.value) {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -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<typeof setTimeout> | undefined
|
||||||
|
let staleCheckTimer: ReturnType<typeof setInterval> | 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -51,11 +51,18 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|||||||
import { useConfigStore } from '@/store/configStore'
|
import { useConfigStore } from '@/store/configStore'
|
||||||
import { usePrice } from '@/utils/usePrice'
|
import { usePrice } from '@/utils/usePrice'
|
||||||
import { getProjectFontFamily } from '@/utils/config'
|
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 { useSettingsStore } from '@/store/settingsStore'
|
||||||
import { useElementSize } from '@vueuse/core'
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import { price } from '@/utils/price'
|
||||||
|
|
||||||
import type { ChartMetalType } from '../composables/usePriceChart'
|
import type { ChartMetalType } from '../composables/usePriceChart'
|
||||||
|
import { resolveMetalDayRangePrices, usePriceChart } from '../composables/usePriceChart'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -93,6 +100,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const { maftolChartRatio } = usePriceChart()
|
||||||
const chartContainerRef = ref<HTMLElement | null>(null)
|
const chartContainerRef = ref<HTMLElement | null>(null)
|
||||||
const chartMounted = ref(false)
|
const chartMounted = ref(false)
|
||||||
const selectedValue = ref(props.chartSelection)
|
const selectedValue = ref(props.chartSelection)
|
||||||
@@ -106,21 +114,43 @@ const amplifyKatodChart = computed(() =>
|
|||||||
metalTypeSetting: settingsStore.metalTypeSettingValue,
|
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 displayChartSeries = computed(() => {
|
||||||
const baseSeries = props.priceChartSeries?.[0]
|
const baseSeries = props.priceChartSeries?.[0]
|
||||||
if (!baseSeries?.data?.length || !amplifyKatodChart.value) {
|
if (!baseSeries?.data?.length || !amplifyKatodChart.value) {
|
||||||
return props.priceChartSeries
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
...baseSeries,
|
...baseSeries,
|
||||||
data: amplifyChartDeviation(baseSeries.data as [number, number][]),
|
data: displayData,
|
||||||
|
meta: { tooltipPrices, dayMeta, pointMeta },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
const chartYBounds = computed(() => {
|
const chartYBounds = computed(() => {
|
||||||
if (!amplifyKatodChart.value) {
|
if (!katodUsesVisualTransform.value) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +159,7 @@ const chartYBounds = computed(() => {
|
|||||||
?.map((point: [number, number]) => Number(point?.[1]))
|
?.map((point: [number, number]) => Number(point?.[1]))
|
||||||
.filter((value: number) => Number.isFinite(value)) ?? []
|
.filter((value: number) => Number.isFinite(value)) ?? []
|
||||||
|
|
||||||
return computeTightChartYBounds(prices)
|
return computeKatodMesVisualYBounds(prices)
|
||||||
})
|
})
|
||||||
const canRenderChart = computed(
|
const canRenderChart = computed(
|
||||||
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
|
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
|
||||||
@@ -157,7 +187,7 @@ const chartOptions = computed(() => {
|
|||||||
type: 'area',
|
type: 'area',
|
||||||
height: 200,
|
height: 200,
|
||||||
toolbar: { show: false },
|
toolbar: { show: false },
|
||||||
zoom: { autoScaleYaxis: !amplifyKatodChart.value },
|
zoom: { autoScaleYaxis: !katodUsesVisualTransform.value },
|
||||||
redrawOnParentResize: true,
|
redrawOnParentResize: true,
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -165,8 +195,8 @@ const chartOptions = computed(() => {
|
|||||||
yaxis: { lines: { show: false } },
|
yaxis: { lines: { show: false } },
|
||||||
},
|
},
|
||||||
stroke: {
|
stroke: {
|
||||||
width: amplifyKatodChart.value ? 2.5 : 2,
|
width: katodUsesVisualTransform.value ? 2.5 : 2,
|
||||||
curve: amplifyKatodChart.value ? 'smooth' : 'straight',
|
curve: 'straight',
|
||||||
},
|
},
|
||||||
dataLabels: {
|
dataLabels: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -201,11 +231,20 @@ const chartOptions = computed(() => {
|
|||||||
})}`
|
})}`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(amplifyKatodChart.value
|
...(katodUsesVisualTransform.value
|
||||||
? {
|
? {
|
||||||
custom: ({ dataPointIndex, w }: { dataPointIndex: number; w: { globals: { seriesX: number[][] } } }) => {
|
custom: ({ dataPointIndex, w }: { dataPointIndex: number; w: { globals: { seriesX: number[][] } } }) => {
|
||||||
const timestamp = w.globals.seriesX[0]?.[dataPointIndex]
|
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) {
|
if (timestamp == null || realPrice == null) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -216,12 +255,31 @@ const chartOptions = computed(() => {
|
|||||||
minute: '2-digit',
|
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
|
||||||
|
? `<div style="font-family:${fontFamily};font-size:12px;opacity:0.85;margin-top:4px">
|
||||||
|
بازه روز: ${usePrice(rangeHigh, true, 'ه.ریال')} — ${usePrice(rangeLow, true, 'ه.ریال')}
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
return `<div class="apexcharts-tooltip-title" style="font-family:${fontFamily}">${timeLabel}</div>
|
return `<div class="apexcharts-tooltip-title" style="font-family:${fontFamily}">${timeLabel}</div>
|
||||||
<div class="apexcharts-tooltip-series-group apexcharts-active" style="order:1;display:flex">
|
<div class="apexcharts-tooltip-series-group apexcharts-active" style="order:1;display:flex">
|
||||||
<span class="apexcharts-tooltip-text" style="font-family:${fontFamily}">
|
<span class="apexcharts-tooltip-text" style="font-family:${fontFamily}">
|
||||||
<span class="apexcharts-tooltip-text-y-value">${usePrice(realPrice, true, 'ه.ریال')}</span>
|
<span class="apexcharts-tooltip-text-y-value">${valueLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>`
|
</div>${rangeLine}`
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { getProductsCatalog } from '@/composables/useProductsCatalog'
|
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
|
||||||
import { loading } from '@/utils'
|
import { loading } from '@/utils'
|
||||||
import type { Product } from '@/models/products'
|
import type { Product } from '@/models/products'
|
||||||
import { loadServiceFeeFromSettings } from '@/utils/serviceFee'
|
import { loadServiceFeeFromSettings } from '@/utils/serviceFee'
|
||||||
import { loadProductCategoryTabNames } from '@/utils/categoryTabs'
|
import { loadProductCategoryTabNames } from '@/utils/categoryTabs'
|
||||||
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
|
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. */
|
/** Shared products page datasets — mobile/desktop views read this module state only. */
|
||||||
let initPromise: Promise<void> | null = null
|
let initPromise: Promise<void> | null = null
|
||||||
@@ -99,12 +104,49 @@ export async function refreshProductsPageData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startProductListPolling() {
|
function startProductListPolling() {
|
||||||
if (productListInterval) return
|
if (productListInterval || !shouldUsePricePolling()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
productListInterval = setInterval(() => {
|
productListInterval = setInterval(() => {
|
||||||
void refreshProductsPageData()
|
if (shouldUsePricePolling()) {
|
||||||
|
void refreshProductsPageData()
|
||||||
|
}
|
||||||
}, 10000)
|
}, 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() {
|
function stopProductListPolling() {
|
||||||
if (productListInterval) {
|
if (productListInterval) {
|
||||||
clearInterval(productListInterval)
|
clearInterval(productListInterval)
|
||||||
@@ -134,7 +176,7 @@ export function onProductsPageProviderUnmounted() {
|
|||||||
|
|
||||||
export async function initProductsPageData(force = false) {
|
export async function initProductsPageData(force = false) {
|
||||||
if (!force && initialized.value) {
|
if (!force && initialized.value) {
|
||||||
startProductListPolling()
|
syncProductListPolling()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +194,7 @@ export async function initProductsPageData(force = false) {
|
|||||||
loadCategoryTabs(),
|
loadCategoryTabs(),
|
||||||
])
|
])
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
startProductListPolling()
|
syncProductListPolling()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Products page initialization error:', error)
|
console.error('Products page initialization error:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface WalletPageContext {
|
|||||||
infoVisible: Ref<boolean>
|
infoVisible: Ref<boolean>
|
||||||
refOtpInput: Ref<InstanceType<typeof OtpInput> | null>
|
refOtpInput: Ref<InstanceType<typeof OtpInput> | null>
|
||||||
form: Ref<Transaction>
|
form: Ref<Transaction>
|
||||||
|
extraNote: Ref<string>
|
||||||
cardForm: Ref<Card>
|
cardForm: Ref<Card>
|
||||||
disableButton: ReturnType<typeof computed<boolean>>
|
disableButton: ReturnType<typeof computed<boolean>>
|
||||||
cardItems: typeof walletCardItems
|
cardItems: typeof walletCardItems
|
||||||
@@ -106,6 +107,8 @@ export function useWalletPage() {
|
|||||||
buildDepositSmsUrl(supportMessengerSettings.value.depositSmsSupportNumber),
|
buildDepositSmsUrl(supportMessengerSettings.value.depositSmsSupportNumber),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const extraNote = ref('')
|
||||||
|
|
||||||
const form = ref<Transaction>({
|
const form = ref<Transaction>({
|
||||||
value: undefined,
|
value: undefined,
|
||||||
percentWallet: 0,
|
percentWallet: 0,
|
||||||
@@ -146,6 +149,7 @@ export function useWalletPage() {
|
|||||||
function resetData() {
|
function resetData() {
|
||||||
form.value.value = undefined
|
form.value.value = undefined
|
||||||
form.value.percentWallet = 0
|
form.value.percentWallet = 0
|
||||||
|
extraNote.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addCard() {
|
async function addCard() {
|
||||||
@@ -217,7 +221,11 @@ export function useWalletPage() {
|
|||||||
try {
|
try {
|
||||||
infoVisible.value = true
|
infoVisible.value = true
|
||||||
showConfirmCode.value = false
|
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 walletStore.getMyWallet()
|
||||||
await message.success('عملیات با موفقیت انجام شد')
|
await message.success('عملیات با موفقیت انجام شد')
|
||||||
withdrawInfModal.value = true
|
withdrawInfModal.value = true
|
||||||
@@ -264,6 +272,7 @@ export function useWalletPage() {
|
|||||||
infoVisible,
|
infoVisible,
|
||||||
refOtpInput,
|
refOtpInput,
|
||||||
form,
|
form,
|
||||||
|
extraNote,
|
||||||
cardForm,
|
cardForm,
|
||||||
disableButton,
|
disableButton,
|
||||||
cardItems: walletCardItems,
|
cardItems: walletCardItems,
|
||||||
|
|||||||
@@ -55,6 +55,15 @@
|
|||||||
@on-complete="handleOnComplete"
|
@on-complete="handleOnComplete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="px-5 pb-2 mt-4">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="extraNote"
|
||||||
|
:maxlength="500"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="توضیحات اضافی"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #button>
|
<template #button>
|
||||||
@@ -136,6 +145,7 @@ const {
|
|||||||
withdrawInfModal,
|
withdrawInfModal,
|
||||||
infoVisible,
|
infoVisible,
|
||||||
refOtpInput,
|
refOtpInput,
|
||||||
|
extraNote,
|
||||||
supportMessengerText,
|
supportMessengerText,
|
||||||
depositGuideSupportLink,
|
depositGuideSupportLink,
|
||||||
depositSmsSupportLink,
|
depositSmsSupportLink,
|
||||||
|
|||||||
Reference in New Issue
Block a user