diff --git a/.cursor/skills/abshode-domain-lookup/learned-notes.md b/.cursor/skills/abshode-domain-lookup/learned-notes.md index 92a437a..42a8fbf 100644 --- a/.cursor/skills/abshode-domain-lookup/learned-notes.md +++ b/.cursor/skills/abshode-domain-lookup/learned-notes.md @@ -66,3 +66,6 @@ - 2026-11-03 | ویرایش/حذف تبدیل در انتظار | همان تاریخچه + `CopperConvertRequestService::update/remove` | فقط status=pending؛ ویرایش فرم را با همان حلقه‌ها پر می‌کند و PUT می‌زند - 2026-11-03 | چیدمان دسکتاپ تبدیل | `convert/desktop/DesktopConvertView.vue` | مثل کیف پول: `dwallet-grid`؛ فرم راست، تاریخچه چپ با `variant=side` - 2026-11-03 | مداد ویرایش تبدیل | `convert/components/CopperConvertHistory.vue` | فقط pending؛ آیکون `vuesax-linear:edit` کنار وضعیت، همان `beginEdit` +- 2026-11-03 | چهار قیمت هیرو خانه | `home/desktop/components/DesktopLivePrice.vue` + سرور `ZaarboxPriceService::copperDollarReferences` | مس جهانی=کلید `basemetal_london_copper`، دلار آزاد=`tgju_usd`؛ تن=ضرب، کیلو=تقسیم بر ۱۰۰۰ +- 2026-11-03 | چهار قیمت موبایل خانه | `home/components/CurrentPrice.vue` + `composables/useHomeQuoteRows.ts` | همان چهار ردیف دسکتاپ؛ منطق مشترک در useHomeQuoteRows +- 2026-11-03 | هاور نمودار دارایی | `assets/index.vue` + `useAssetsPortfolioChart.ts` + `chartAreaPaths.ts` | نزدیک‌ترین نقطهٔ رسم‌شده؛ مقدار همان نمونهٔ دارایی، نه قیمت مس diff --git a/src/models/home.ts b/src/models/home.ts index 65eba6a..231d2a8 100644 --- a/src/models/home.ts +++ b/src/models/home.ts @@ -35,4 +35,8 @@ export interface Price { /** montzar | zaarbox | main_product | store = قیمت زنده، database_last = آخرین قیمت ذخیره‌شده */ price_source?: string | null last_historical?: number + copper_world?: number + dollar_free?: number + copper_ton?: string + copper_kilo?: string } \ No newline at end of file diff --git a/src/utils/chartAreaPaths.ts b/src/utils/chartAreaPaths.ts index ebfd027..2448885 100644 --- a/src/utils/chartAreaPaths.ts +++ b/src/utils/chartAreaPaths.ts @@ -3,6 +3,13 @@ export type ChartSeriesPoint = { value: number } +export type ChartPlotSample = { + x: number + y: number + value: number + timeMs: number +} + const CHART_PAD_Y = 14 const DEFAULT_MAX_POINTS = 22 @@ -67,16 +74,17 @@ export function buildSeriesChartPaths( linePath: '', areaPath: '', endDot: null as { x: number; y: number } | null, + samples: [] as ChartPlotSample[], + width, + height, } if (points.length < 2) return empty const maxPoints = options?.maxPoints ?? DEFAULT_MAX_POINTS const useSmooth = options?.smooth ?? true - let series = downsampleSeries(points, maxPoints) - if (useSmooth) { - series = smoothSeries(series, 3) - } + const sampled = downsampleSeries(points, maxPoints) + const series = useSmooth ? smoothSeries(sampled, 3) : sampled const values = series.map((point) => point.value) let min = Math.min(...values) @@ -97,11 +105,20 @@ export function buildSeriesChartPaths( const linePath = buildSmoothLinePath(plot) const areaPath = `${linePath} L${width} ${height} L0 ${height} Z` const last = plot[plot.length - 1] + const samples = plot.map((point, index) => ({ + x: point.x, + y: point.y, + value: sampled[index]?.value ?? series[index].value, + timeMs: sampled[index]?.timeMs ?? series[index].timeMs, + })) return { hasData: true, linePath, areaPath, endDot: last ? { x: last.x, y: last.y } : null, + samples, + width, + height, } } diff --git a/src/views/base/assets/composables/useAssetsPortfolioChart.ts b/src/views/base/assets/composables/useAssetsPortfolioChart.ts index 32b58a8..789b1ef 100644 --- a/src/views/base/assets/composables/useAssetsPortfolioChart.ts +++ b/src/views/base/assets/composables/useAssetsPortfolioChart.ts @@ -4,6 +4,7 @@ import { type LandingHistoryRange, } from '@/api/zaarboxPriceHistory' import { buildSeriesChartPaths } from '@/utils/chartAreaPaths' +import { usePrice } from '@/utils/usePrice' const CHART_RANGE_OPTIONS = [ { value: 'DAILY' as const, shortLabel: '۲۴ ساعت', label: 'ارزش دارایی — ۲۴ ساعت گذشته' }, { value: 'WEEKLY' as const, shortLabel: 'هفته', label: 'ارزش دارایی — هفته گذشته' }, @@ -32,6 +33,7 @@ export function useAssetsPortfolioChart( } function setChartRange(range: LandingHistoryRange) { + chartHover.value = null chartRange.value = range void loadChartHistory(range) } @@ -61,6 +63,53 @@ export function useAssetsPortfolioChart( const portfolioChart = computed(() => buildSeriesChartPaths(portfolioSeries.value)) + const chartHover = ref<{ + xPercent: number + yPercent: number + valueLabel: string + timeLabel: string + } | null>(null) + + function formatChartTimeLabel(timeMs: number) { + try { + return new Intl.DateTimeFormat('fa-IR', { + timeZone: 'Asia/Tehran', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timeMs)) + } catch { + return '' + } + } + + function onChartPointerMove(event: PointerEvent) { + const chart = portfolioChart.value + if (!chart.hasData || chart.samples.length < 2) { + chartHover.value = null + return + } + const target = event.currentTarget as HTMLElement | null + if (!target) return + const rect = target.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)) + const index = Math.round(ratio * (chart.samples.length - 1)) + const sample = chart.samples[index] + if (!sample) return + chartHover.value = { + xPercent: (sample.x / chart.width) * 100, + yPercent: (sample.y / chart.height) * 100, + valueLabel: usePrice(sample.value), + timeLabel: formatChartTimeLabel(sample.timeMs), + } + } + + function onChartPointerLeave() { + chartHover.value = null + } + watch( () => cashRial.value + metalRial.value, (total) => { @@ -81,6 +130,9 @@ export function useAssetsPortfolioChart( chartDrawn, chartLoading, portfolioChart, + chartHover, + onChartPointerMove, + onChartPointerLeave, setChartRange, initAssetsPortfolioChart, } diff --git a/src/views/base/assets/index.vue b/src/views/base/assets/index.vue index 1cea440..6be79c1 100644 --- a/src/views/base/assets/index.vue +++ b/src/views/base/assets/index.vue @@ -18,6 +18,8 @@

نمودار ارزش دارایی در دسترس نیست

+
@@ -62,7 +46,7 @@ import { computed } from 'vue' import Icon from '@/components/BasicPacks/BasicIcon.vue' import { useUserStore } from '@/store/userStore' -import { getTradeBuyPrice, lastPrice, percentPriceChanged, price } from '@/utils/price' +import { useHomeQuoteRows } from '@/views/base/home/composables/useHomeQuoteRows' import { NO_QUOTE_MESSAGE, STORE_CLOSED_TITLE, @@ -70,7 +54,6 @@ import { getStoreClosedHint, isHomeQuotePending, } from '@/utils/storeHours' -import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice' const userStore = useUserStore() @@ -98,17 +81,7 @@ const containerClass = computed(() => { return 'boursecu-price-box boursecu-price-box--open' }) -const percentChange = computed(() => { - if (!getTradeBuyPrice(lastPrice.value)) return 0 - return percentPriceChanged() -}) - -const displayGoldVal = computed(() => { - if (displayState.value !== 'open') return 0 - return getTradeBuyPrice(lastPrice.value) -}) - -void price +const { quoteRows } = useHomeQuoteRows() diff --git a/src/views/base/home/composables/useHomeQuoteRows.ts b/src/views/base/home/composables/useHomeQuoteRows.ts new file mode 100644 index 0000000..eefa58c --- /dev/null +++ b/src/views/base/home/composables/useHomeQuoteRows.ts @@ -0,0 +1,119 @@ +import { computed, ref, watch } from 'vue' +import { lastPrice } from '@/utils/price' +import { usePriceWithoutType } from '@/utils/usePrice' + +const BOARD_FEED_URL = 'https://price.zaarbox.ir/api/free' + +type BoardRefs = { copper: number; dollar: number } + +const boardCache = ((globalThis as typeof globalThis & { + __boursecuBoardRefs?: { started: boolean; refs: BoardRefs | null } +}).__boursecuBoardRefs ??= { started: false, refs: null }) + +function boardRowTotal(row: Record): number { + const price = Math.round(Number(row.price) || 0) + if (price <= 0) return 0 + return price + Math.round(Number(row.profit) || 0) +} + +function worldCopperScore(row: Record): number | null { + const key = String(row.key ?? '').trim().toLowerCase() + const name = String(row.name ?? '').trim() + if (key.includes('basemetal_london_copper')) return 0 + if (name === 'TGJU فلز مس') return 1 + if (name.includes('مس جهانی')) return 2 + return null +} + +function freeDollarScore(row: Record): number | null { + const key = String(row.key ?? '').trim().toLowerCase() + const name = String(row.name ?? '').trim() + if (key.includes('copper') || name.includes('کاتد') || name.includes('مس')) return null + if (key === 'tgju_usd') return 0 + if (name === 'TGJU دلار' || name === 'tgju دلار آمریکا') return 1 + if (name.includes('دلار') && name.includes('آزاد')) return 2 + return null +} + +function pickBoardRefs(rows: unknown[]) { + let copper = 0 + let dollar = 0 + let copperScore = Number.POSITIVE_INFINITY + let dollarScore = Number.POSITIVE_INFINITY + for (const item of rows) { + if (!item || typeof item !== 'object') continue + const row = item as Record + const total = boardRowTotal(row) + if (total <= 0) continue + const copperRank = worldCopperScore(row) + if (copperRank !== null && copperRank < copperScore) { + copperScore = copperRank + copper = total + } + const dollarRank = freeDollarScore(row) + if (dollarRank !== null && dollarRank < dollarScore) { + dollarScore = dollarRank + dollar = total + } + } + return copper > 0 && dollar > 0 ? { copper, dollar } : null +} + +const sharedBoardRefs = ref(boardCache.refs) + +export function useHomeQuoteRows() { + function ensureBoardRefs() { + const liveCopper = Number(lastPrice.value?.copper_world ?? 0) + const liveDollar = Number(lastPrice.value?.dollar_free ?? 0) + if (liveCopper > 0 && liveDollar > 0) return + if (boardCache.refs) { + sharedBoardRefs.value = boardCache.refs + return + } + if (boardCache.started) return + boardCache.started = true + void fetch(BOARD_FEED_URL) + .then((res) => res.json()) + .then((rows: unknown) => { + if (!Array.isArray(rows)) return + const picked = pickBoardRefs(rows) + if (!picked) return + boardCache.refs = picked + sharedBoardRefs.value = picked + }) + .catch(() => { + boardCache.started = false + }) + } + + watch(lastPrice, () => ensureBoardRefs(), { immediate: true }) + + const quoteRows = computed(() => { + const fromServer = Number(lastPrice.value?.copper_world ?? 0) > 0 + && Number(lastPrice.value?.dollar_free ?? 0) > 0 + const copper = fromServer + ? Number(lastPrice.value?.copper_world) + : (sharedBoardRefs.value?.copper ?? 0) + const dollar = fromServer + ? Number(lastPrice.value?.dollar_free) + : (sharedBoardRefs.value?.dollar ?? 0) + const tonText = String(lastPrice.value?.copper_ton ?? '') + const kiloText = String(lastPrice.value?.copper_kilo ?? '') + const ton = fromServer && tonText !== '' && tonText !== '0' + ? Number(tonText) + : (copper > 0 && dollar > 0 ? copper * dollar : 0) + const kilo = fromServer && kiloText !== '' && kiloText !== '0' + ? Number(kiloText) + : (ton > 0 ? ton / 1000 : 0) + const text = (value: number) => (value > 0 ? usePriceWithoutType(value) : '—') + + return [ + { label: 'قیمت مس جهانی', value: text(copper), unit: copper > 0 ? 'دلار' : '' }, + { label: 'قیمت دلار بازار آزاد', value: text(dollar), unit: dollar > 0 ? 'ریال' : '' }, + { label: 'قیمت تن', value: text(ton), unit: ton > 0 ? 'ریال' : '' }, + { label: 'قیمت کیلوگرم', value: text(kilo), unit: kilo > 0 ? 'ریال' : '' }, + ] + }) + + return { quoteRows } +} diff --git a/src/views/base/home/desktop/components/DesktopLivePrice.vue b/src/views/base/home/desktop/components/DesktopLivePrice.vue index 9a34968..845bf8f 100644 --- a/src/views/base/home/desktop/components/DesktopLivePrice.vue +++ b/src/views/base/home/desktop/components/DesktopLivePrice.vue @@ -32,27 +32,15 @@ @@ -61,7 +49,7 @@ import { computed } from 'vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import { useUserStore } from '@/store/userStore' -import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price' +import { useHomeQuoteRows } from '@/views/base/home/composables/useHomeQuoteRows' import { NO_QUOTE_MESSAGE, STORE_CLOSED_TITLE, @@ -69,7 +57,6 @@ import { getStoreClosedHint, isHomeQuotePending, } from '@/utils/storeHours' -import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice' const userStore = useUserStore() @@ -87,15 +74,7 @@ const statusHint = computed(() => { return 'در حال حاضر قیمتی برای نمایش وجود ندارد.' }) -const displayGoldVal = computed(() => { - if (displayState.value !== 'open') return 0 - return getTradeBuyPrice(lastPrice.value) -}) - -const percentChange = computed(() => { - if (!getTradeBuyPrice(lastPrice.value)) return 0 - return percentPriceChanged() -}) +const { quoteRows } = useHomeQuoteRows()