Merge feature/responsive-layout-refactor into modular for desktop layouts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 17:02:11 +03:30
co-authored by Cursor
235 changed files with 18805 additions and 5910 deletions
+59 -12
View File
@@ -1,13 +1,17 @@
<template>
<div
:class="y > 50 ? 'bg-white shadow opacity-95' : ''"
class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition"
:class="y > 50 ? 'bg-white shadow opacity-95' : ''"
class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition"
>
<div class="flex justify-between my-2">
<div class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })">
<div class="flex justify-between my-2 items-center">
<div v-if="isBoursecuProject()" class="flex items-center gap-2">
<img :src="projectLogo" alt="" class="h-8 w-auto object-contain" />
<div class="color-transition font-bold text-primary">{{ appName }}</div>
</div>
<div v-else class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })">
<div
class="z-100000 rounded-full border-1"
:class="y > 50 ? 'text-primary border-primary' : 'text-primary border-primary'"
class="z-100000 rounded-full border-1"
:class="y > 50 ? 'text-primary border-primary' : 'text-primary border-primary'"
>
<Icon icon="vuesax-linear:user" size="20" />
</div>
@@ -15,26 +19,69 @@
{{ userStore.userInfo?.name }}
</div>
</div>
<Icon
:color="y > 50 ? '#0A1B37' : '#0A1B37'"
class="color-transition"
icon="vuesax-linear:logout"
<div class="flex items-center gap-2">
<button
v-if="isBoursecuProject()"
type="button"
class="mhome-header-btn"
@click="router.push({ name: 'user' })"
>
<Icon icon="vuesax-linear:user" size="18" />
</button>
<button
type="button"
class="mhome-header-btn mhome-header-btn--logout"
@click="$emit('logout')"
/>
>
<Icon icon="vuesax-linear:logout" size="18" />
<span>خروج</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useWindowScroll } from '@vueuse/core'
import { useUserStore } from '@/store/userStore'
import { useConfigStore } from '@/store/configStore'
import router from '@/router'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { getProjectNavLogo, isBoursecuProject } from '@/utils/config'
const { y } = useWindowScroll()
const userStore = useUserStore()
const config = useConfigStore()
const projectLogo = computed(() => getProjectNavLogo())
const appName = computed(() => config.config?.name_fa || config.config?.name || 'بورس مس')
defineEmits<{
logout: []
}>()
</script>
</script>
<style scoped>
.mhome-header-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #0a1b37;
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.mhome-header-btn--logout {
color: #dc2626;
border-color: #fecaca;
background: #fef2f2;
}
</style>
@@ -1,26 +1,52 @@
<template>
<div class="bg-primary rounded-lg p-2 py-3 mt-2">
<div v-if="meta?.has_count_based_products" class="flex justify-center text-tertiary w-full z-20">
<div :class="plain ? 'py-2' : 'bg-primary rounded-lg p-2 py-3 mt-2'">
<div
v-if="!boursecuLayout && meta?.has_count_based_products"
:class="rowClass"
class="flex justify-center w-full z-20"
>
محصولات:
<div class="text-l mr-2 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<template v-if="meta?.single_metal && primaryMetalTotal">
<div class="flex mt-2 justify-center text-tertiary w-full z-20">
<template v-if="boursecuAssetRows.length">
<div
v-for="(row, index) in boursecuAssetRows"
:key="row.id"
:class="[rowClass, index > 0 ? 'mt-2' : '']"
class="flex justify-center w-full z-20"
>
{{ row.label }}:
<div class="text-l mr-2 font-bold">{{ row.value }}</div>
<BasicIcon
v-if="row.info !== 'none'"
icon="vuesax-linear:info-circle"
size="18"
:color="iconColor"
@click="handleRowInfo(row)"
/>
</div>
</template>
<template v-else-if="meta?.single_metal && primaryMetalTotal">
<div :class="[rowClass, meta?.has_count_based_products ? 'mt-2' : '']" class="flex justify-center w-full z-20">
{{ primaryMetalTotal.label }}:
<div class="text-l mr-2 font-bold">{{ formatMetalTotal(primaryMetalTotal) }}</div>
<BasicIcon
icon="vuesax-linear:info-circle"
size="18"
color="tertiary"
:color="iconColor"
@click="$emit('openGoldModal', primaryMetalTotal)"
/>
</div>
</template>
<template v-else>
<template v-else-if="!boursecuLayout">
<div
v-for="metal in metalTotals"
v-for="(metal, index) in metalTotals"
:key="metal.metal_id"
class="flex mt-2 justify-center text-tertiary w-full z-20"
:class="[rowClass, index > 0 || meta?.has_count_based_products ? 'mt-2' : '']"
class="flex justify-center w-full z-20"
>
{{ metal.label }}:
<div class="text-l mr-2 font-bold">{{ formatMetalTotal(metal) }}</div>
@@ -28,48 +54,79 @@
v-if="metal.metal_id === metalTotals[0]?.metal_id"
icon="vuesax-linear:info-circle"
size="18"
color="tertiary"
:color="iconColor"
@click="$emit('openGoldModal', metal)"
/>
</div>
<div :class="[rowClass, 'mt-2']" class="flex justify-center w-full z-20">
دارایی ریالی:
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(totalRialValue) }} ریال</div>
<BasicIcon
icon="vuesax-linear:info-circle"
size="18"
:color="iconColor"
@click="$emit('openRialModal')"
/>
</div>
</template>
<div class="flex mt-2 justify-center text-tertiary w-full z-20">
دارایی ریالی:
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(cashWallet) }} ریال</div>
<BasicIcon @click="$emit('openRialModal')" icon="vuesax-linear:info-circle" size="18" color="tertiary" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight } from '@/utils/productAssets'
import {
buildBoursecuHomeAssetRows,
resolveSummaryMetalWeight,
type HomeAssetRow,
} from '@/utils/productAssets'
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const props = withDefaults(
defineProps<{
plain?: boolean
boursecuLayout?: boolean
}>(),
{ plain: false, boursecuLayout: false }
)
defineEmits<{
const emit = defineEmits<{
openGoldModal: [metal: WalletMetalTotal]
openRialModal: []
}>()
const { assetsSummary } = useWalletAssets()
const rowClass = computed(() => (props.plain ? 'text-primary' : 'text-tertiary'))
const iconColor = computed(() => (props.plain ? 'primary' : 'tertiary'))
const meta = computed(() => assetsSummary.value?.meta)
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const primaryMetalTotal = computed(() => metalTotals.value[0])
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
const boursecuAssetRows = computed(() =>
props.boursecuLayout ? buildBoursecuHomeAssetRows(assetsSummary.value) : []
)
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
const inventoryCards = computed(() => assetsSummary.value?.inventory_cards ?? [])
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
)
onMounted(() => {
void loadWalletAssets()
})
function formatMetalTotal(metal: WalletMetalTotal) {
return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
}
function handleRowInfo(row: HomeAssetRow) {
if (row.info === 'rial') {
emit('openRialModal')
return
}
if (row.metal) {
emit('openGoldModal', row.metal)
}
}
</script>
@@ -50,7 +50,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { computed, watch } from 'vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import { usePrice } from '@/utils/usePrice'
@@ -84,10 +84,6 @@ const metalRialValue = computed(() =>
)
const totalRialValue = computed(() => cashWallet.value + metalRialValue.value)
onMounted(() => {
void loadWalletAssets()
})
watch(
() => props.show,
(open) => {
@@ -0,0 +1,41 @@
<script setup lang="ts">
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { isBoursecuProject } from '@/utils/config'
import type { HomeQuickActionItem } from '../layouts/types'
defineProps<{
action: HomeQuickActionItem
}>()
defineEmits<{
click: []
}>()
const isBoursecu = isBoursecuProject()
</script>
<template>
<button
type="button"
class="home-quick-action-box flex flex-col items-center justify-center gap-2 rounded-xl border-2 p-3 min-h-[96px] w-full"
:class="{ 'home-quick-action-box--boursecu': isBoursecu }"
@click="$emit('click')"
>
<div class="flex h-11 w-11 shrink-0 items-center justify-center">
<Icon class="text-primary" :icon="action.icon" size="28" />
</div>
<span class="text-primary text-xs font-bold text-center leading-5">{{ action.label }}</span>
</button>
</template>
<style scoped>
.home-quick-action-box {
appearance: none;
cursor: pointer;
}
.home-quick-action-box--boursecu {
background-color: #eff5f3;
border-color: #d4e3de;
}
</style>
+46 -55
View File
@@ -1,63 +1,54 @@
<template>
<div ref="skeletonRatio" class="h-fit">
<content-loader :width="skeletonSize.width" class="bg-white" height="100vh">
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="0"/>
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="84"/>
<rect :width="5" height="85" rx="3" ry="3" x="0" y="0"/>
<rect :width="5" :x="skeletonSize.width-5" height="85" rx="3" ry="3" y="0"/>
<rect :width="2" :x="skeletonSize.width/2" height="60" rx="3" ry="3" y="15"/>
<rect :width="skeletonSize.width/2-20" :x="10" height="20" rx="10" ry="10" y="15"/>
<rect :width="skeletonSize.width/2-20" :x="skeletonSize.width/2 + 10" height="20" rx="10" ry="10" y="15"/>
<rect :width="skeletonSize.width/2-40" :x="20" height="10" rx="5" ry="5" y="40"/>
<rect :width="skeletonSize.width/2-40" :x="skeletonSize.width/2 + 20" height="10" rx="5" ry="5" y="40"/>
<rect :width="skeletonSize.width/2-30" :x="15" height="18" rx="10" ry="10" y="60"/>
<rect :width="skeletonSize.width/2-30" :x="skeletonSize.width/2 + 15" height="18" rx="10" ry="10" y="60"/>
<circle :cx="skeletonSize.width/4-skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width/4+skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width/2+ skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width - skeletonSize.width/8" cy="145" r="20"/>
<rect :width="50" :x="skeletonSize.width/4-skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width/4+skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width/2+ skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width - skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="skeletonSize.width - 45" :x="45" height="20" rx="3" ry="3" y="230"/>
<rect :width="skeletonSize.width" :x="0" height="80" rx="3" ry="3" y="260"/>
<rect :width="skeletonSize.width/2 - 5" :x="0" height="50" rx="3" ry="3" y="360"/>
<rect :width="skeletonSize.width/2 - 5" :x="skeletonSize.width/2 + 5" height="50" rx="3" ry="3" y="360"/>
<rect :width="skeletonSize.width" :x="0" height="170" rx="3" ry="3" y="440"/>
<!-- <rect height="20" rx="3" ry="3" width="100" x="0" y="0"/>
<rect :width="skeletonSize.width/4 + 40" :x="skeletonSize.width/2" height="20" rx="3" ry="3" y="0"/>
<rect height="20" rx="3" ry="3" width="80" x="0" y="30"/>
<rect :x="skeletonSize.width - 40" height="40" rx="12" ry="12" width="40" y="0"/>
<rect :width="skeletonSize.width" :x="0" :y="skeletonSize.height-5" height="2" rx="12" ry="12"/>-->
<!-- <circle :cx="skeletonSize.width - 20" :cy="skeletonSize.height/2 - 10" r="20" />-->
<!-- <rect :x="10" y="20" rx="3" ry="3" :width="skeletonSize.width - 20" height="150"/>
<circle :cx="skeletonSize.width/2" cy="170" r="15" />
<rect :x="skeletonSize.width/2 - skeletonSize.width/3 -10" y="190" rx="3" ry="3" :width="skeletonSize.width/3 + skeletonSize.width/3 + 20" height="20"/>
<rect :x="skeletonSize.width/2 - skeletonSize.width/3 +20" y="220" rx="3" ry="3" :width="skeletonSize.width/3 + skeletonSize.width/3 - 40" height="20"/>-->
<!-- <rect x="90" y="0" rx="3" ry="3" width="80" height="20" />
<rect x="180" y="0" rx="3" ry="3" width="80" height="20" />
<rect x="270" y="0" rx="3" ry="3" width="80" height="20" ♥/>-->
<div ref="skeletonRatio" class="h-fit w-full min-h-[620px]">
<content-loader
:width="skeletonWidth"
class="bg-white"
:height="skeletonHeight"
>
<rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="0" />
<rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="84" />
<rect width="5" height="85" rx="3" ry="3" x="0" y="0" />
<rect width="5" :x="skeletonWidth - 5" height="85" rx="3" ry="3" y="0" />
<rect width="2" :x="skeletonWidth / 2" height="60" rx="3" ry="3" y="15" />
<rect :width="halfWidth(20)" x="10" height="20" rx="10" ry="10" y="15" />
<rect :width="halfWidth(20)" :x="skeletonWidth / 2 + 10" height="20" rx="10" ry="10" y="15" />
<rect :width="halfWidth(40)" x="20" height="10" rx="5" ry="5" y="40" />
<rect :width="halfWidth(40)" :x="skeletonWidth / 2 + 20" height="10" rx="5" ry="5" y="40" />
<rect :width="halfWidth(30)" x="15" height="18" rx="10" ry="10" y="60" />
<rect :width="halfWidth(30)" :x="skeletonWidth / 2 + 15" height="18" rx="10" ry="10" y="60" />
<circle :cx="skeletonWidth / 4 - skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth / 4 + skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth / 2 + skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth - skeletonWidth / 8" cy="145" r="20" />
<rect width="50" :x="skeletonWidth / 4 - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth / 4 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth / 2 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect :width="fullWidth(45)" x="45" height="20" rx="3" ry="3" y="230" />
<rect :width="skeletonWidth" x="0" height="80" rx="3" ry="3" y="260" />
<rect :width="halfWidth(10)" x="0" height="50" rx="3" ry="3" y="360" />
<rect :width="halfWidth(10)" :x="skeletonWidth / 2 + 5" height="50" rx="3" ry="3" y="360" />
<rect :width="skeletonWidth" x="0" height="170" rx="3" ry="3" y="440" />
</content-loader>
</div>
</template>
<script lang="ts" setup>
import {reactive, ref, watch} from "vue";
import {useElementSize} from "@vueuse/core";
import {ContentLoader} from "vue-content-loader";
import { computed, ref } from 'vue'
import { useElementSize } from '@vueuse/core'
import { ContentLoader } from 'vue-content-loader'
const skeletonRatio = ref(null)
const skeletonSize = reactive(
useElementSize(skeletonRatio, {width: 0, height: 0}, {box: 'border-box'})
)
/*watch(skeletonSize, () => {
console.log(skeletonSize)
})*/
const skeletonHeight = 620
const skeletonRatio = ref<HTMLElement | null>(null)
const { width } = useElementSize(skeletonRatio, { width: 0, height: 0 }, { box: 'border-box' })
const skeletonWidth = computed(() => Math.max(width.value, 320))
function halfWidth(padding: number) {
return Math.max(skeletonWidth.value / 2 - padding, 0)
}
function fullWidth(padding: number) {
return Math.max(skeletonWidth.value - padding, 0)
}
</script>
<style scoped>
</style>
+197 -170
View File
@@ -1,201 +1,228 @@
<template>
<div class="bg-gray-50 w-full flex justify-evenly rounded-lg py-4 mt-4 h-[15rem]">
<div
class="bg-gray-50 w-full rounded-lg py-4"
:class="[
chartContainerClass,
variant === 'desktop' ? 'mt-0' : 'mt-4',
layout === 'boursecu' && variant !== 'desktop' ? 'price-chart-box--boursecu' : '',
]"
>
<div class="w-full text-center pt-2">
<a-radio-group v-model:value="selectedValue" button-style="solid">
<div v-if="showMetalSelector" class="chart-metal-segment mb-3">
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'katod' }"
@click="emit('update:selectedMetal', 'katod')"
>
کاتد مس
</button>
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'maftol' }"
@click="emit('update:selectedMetal', 'maftol')"
>
مفتول مس
</button>
</div>
<a-radio-group v-model:value="selectedValue" button-style="solid" class="chart-timeline-group">
<a-radio-button value="DAILY" @click="handleUpdate('DAILY')">24 ساعت اخیر</a-radio-button>
<a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button>
<a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button>
</a-radio-group>
<div class="min-h-[200px] relative">
<div v-if="hasChartData" ref="chartEl" class="h-[200px] w-full" />
<div
v-else
class="h-[200px] flex items-center justify-center text-gray-400 text-sm"
>
داده نمودار در دسترس نیست
</div>
<div ref="chartContainerRef" id="chart-timeline" class="w-full" :class="chartInnerClass">
<apexchart
v-if="canRenderChart"
:key="`${layout}-${variant}-${selectedMetal}-${chartSelection}-${seriesLength}`"
type="area"
width="100%"
:height="chartHeight"
:options="chartOptions"
:series="props.priceChartSeries"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useConfigStore } from '@/store/configStore'
import { usePrice } from '@/utils/usePrice'
import { getProjectFontFamily } from '@/utils/config'
import { useElementSize } from '@vueuse/core'
type ChartPoint = [number, number]
import type { ChartMetalType } from '../composables/usePriceChart'
const props = defineProps<{
priceChartSeries?: Array<{ name?: string; data?: unknown[] }> | null
}>()
const props = withDefaults(
defineProps<{
priceChartSeries: any[]
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
chartSelection?: string
selectedMetal?: ChartMetalType
}>(),
{
layout: 'default',
variant: 'mobile',
chartSelection: 'DAILY',
selectedMetal: 'katod',
}
)
const chartHeight = computed(() => (props.variant === 'desktop' ? 320 : 200))
const chartContainerClass = computed(() => {
if (props.variant === 'desktop') {
return 'min-h-[22rem]'
}
return props.layout === 'default' ? 'min-h-[15rem] h-[15rem]' : 'min-h-[15rem]'
})
const chartInnerClass = computed(() =>
props.variant === 'desktop' ? 'min-h-[320px]' : 'min-h-[200px]'
)
const showMetalSelector = computed(() => props.layout === 'boursecu')
const emit = defineEmits<{
updateData: [timeline: string]
'update:selectedMetal': [metal: ChartMetalType]
}>()
const configStore = useConfigStore()
const selectedValue = ref('DAILY')
const chartEl = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
let renderToken = 0
const config = useConfigStore()
const chartContainerRef = ref<HTMLElement | null>(null)
const chartMounted = ref(false)
const selectedValue = ref(props.chartSelection)
function extractPoints(): ChartPoint[] {
const source = props.priceChartSeries
if (!Array.isArray(source) || source.length === 0) {
return []
}
const { width: chartWidth } = useElementSize(chartContainerRef)
const raw = source[0]?.data
if (!Array.isArray(raw)) {
return []
}
return raw
.map((item) => {
if (!Array.isArray(item) || item.length < 2) {
return null
}
let x = Number(item[0])
let y = Number(item[1])
if (!Number.isFinite(x) || !Number.isFinite(y) || y <= 0) {
return null
}
if (x > 0 && x < 1e11) {
x *= 1000
}
return [x, y] as ChartPoint
})
.filter((point): point is ChartPoint => point !== null)
.sort((a, b) => a[0] - b[0])
}
const chartPoints = computed(() => extractPoints())
const hasChartData = computed(() => chartPoints.value.length >= 2)
const toPersianNumber = (num: number) =>
num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d])
function buildOption(points: ChartPoint[]): EChartsOption {
const color = configStore.config?.color || '#CEA34C'
return {
animation: true,
grid: {
left: 4,
right: 4,
top: 8,
bottom: 4,
containLabel: false
},
xAxis: {
type: 'time',
show: false,
boundaryGap: false
},
yAxis: {
type: 'value',
show: false,
scale: true
},
tooltip: {
trigger: 'axis',
confine: true,
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params
if (!row || !Array.isArray(row.value)) {
return ''
}
const [timestamp, value] = row.value as [number, number]
const date = new Date(timestamp)
const dateText = `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit'
})}`
return `${dateText}<br/>${toPersianNumber(usePrice(value, true, 'ه.ریال'))}`
}
},
series: [
{
name: 'قیمت',
type: 'line',
smooth: true,
showSymbol: false,
data: points,
lineStyle: {
width: 2,
color
},
areaStyle: {
color,
opacity: 0.25
}
}
]
}
}
function destroyChart() {
if (!chart) {
return
}
chart.dispose()
chart = null
}
async function renderChart() {
const token = ++renderToken
if (!hasChartData.value) {
destroyChart()
return
}
await nextTick()
await nextTick()
if (token !== renderToken || !chartEl.value) {
return
}
const points = chartPoints.value
try {
if (!chart || chart.isDisposed()) {
chart = echarts.init(chartEl.value, undefined, { renderer: 'canvas' })
}
chart.setOption(buildOption(points), true)
chart.resize()
} catch (error) {
console.error('Price chart render failed:', error)
destroyChart()
}
}
watch(
() => [chartPoints.value.length, selectedValue.value, configStore.config?.color] as const,
() => {
void renderChart()
},
{ immediate: true, flush: 'post' }
const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0)
const canRenderChart = computed(
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
)
onBeforeUnmount(() => {
renderToken++
destroyChart()
watch(
() => props.chartSelection,
(timeline) => {
selectedValue.value = timeline
}
)
onMounted(async () => {
await nextTick()
chartMounted.value = true
})
const toPersianNumber = (num: number) => {
return num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d])
}
const chartOptions = computed(() => {
const fontFamily = getProjectFontFamily()
return {
chart: {
id: 'نوسانات قیمت',
type: 'area',
height: 200,
toolbar: { show: false },
zoom: { autoScaleYaxis: true },
redrawOnParentResize: true,
},
grid: {
padding: { top: 0, right: 0, bottom: 0, left: 0 },
yaxis: { lines: { show: false } },
},
stroke: { width: 2 },
dataLabels: {
enabled: false,
style: {
fontFamily,
},
},
yaxis: {
tickAmount: 8,
show: false,
labels: {
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontSize: '13px', fontFamily },
},
},
xaxis: {
show: false,
type: 'datetime',
axisTicks: { show: false },
tooltip: { enabled: false },
labels: { show: false },
},
tooltip: {
x: {
formatter: (value: number) => {
const date = new Date(value)
return `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
})}`
},
},
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontFamily },
},
colors: [config.config.color],
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.7,
opacityTo: 0.9,
stops: [0, 100],
},
},
}
})
const handleUpdate = (timeline: string) => {
selectedValue.value = timeline
emit('updateData', timeline)
}
</script>
<style scoped lang="less">
@boursecu-surface-border: #d4e3de;
.price-chart-box--boursecu {
border: 2px solid @boursecu-surface-border;
}
.chart-metal-segment {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: #dfe8e5;
}
.chart-metal-segment__item {
appearance: none;
cursor: pointer;
min-width: 6.5rem;
padding: 0.4rem 0.75rem;
border: none;
border-radius: 8px;
background: transparent;
color: @App-primary-color;
opacity: 0.65;
font-size: 13px;
font-weight: 600;
line-height: 1.25;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.chart-metal-segment__item--active {
background: #fff;
color: @App-primary-color;
opacity: 1;
font-weight: 700;
box-shadow: 0 1px 4px rgba(10, 27, 55, 0.12);
}
</style>
+35 -7
View File
@@ -1,5 +1,11 @@
<template>
<div class="w-full flex justify-evenly rounded-lg py-2 home_appear_fade_in mt-6 pb-45">
<div
class="w-full flex justify-evenly rounded-lg py-2 home_appear_fade_in"
:class="[
layout === 'boursecu' ? 'mt-8 pt-2' : 'mt-6',
variant === 'mobile' ? 'pb-45' : 'mt-0 pb-0',
]"
>
<div class="text-center w-full">
<div>بالاترین قیمت امروز</div>
<div v-if="price" class="flex flex-col items-center mt-3">
@@ -26,13 +32,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
import { usePriceWithoutType } from '../../../../utils/usePrice'
import type { ChartMetalType } from '../composables/usePriceChart'
import { resolveMetalDayRangePrices } from '../composables/usePriceChart'
const props = defineProps<{
price: any
}>()
const props = withDefaults(
defineProps<{
price: any
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
selectedMetal?: ChartMetalType
maftolRatio?: number
}>(),
{
layout: 'default',
variant: 'mobile',
selectedMetal: 'katod',
maftolRatio: 0,
}
)
const highPrice = computed(() => props.price?.up?.buy ?? getTradeBuyPrice(props.price))
const lowPrice = computed(() => props.price?.up?.sell ?? getTradeSellPrice(props.price))
const rangePrices = computed(() =>
resolveMetalDayRangePrices({
price: props.price,
metal: props.selectedMetal,
isBoursecuLayout: props.layout === 'boursecu',
maftolRatio: props.maftolRatio,
})
)
const highPrice = computed(() => rangePrices.value.high)
const lowPrice = computed(() => rangePrices.value.low)
</script>
@@ -1,35 +1,32 @@
<template>
<div class="bg-primary rounded-lg p-2 py-1 mt-2">
<div class="p-2">
<div
v-if="userStore.userInfo?.referral_id"
class="text-tertiary font-bold w-full text-center flex justify-between gap-2 items-center"
<div class="home-referral bg-primary rounded-lg">
<div
v-if="userStore.userInfo?.referral_id"
class="home-referral__code text-tertiary font-bold"
>
<span>کد معرف:</span>
<span class="home-referral__code-value">{{ userStore.userInfo?.referral_id }}</span>
</div>
<div class="home-referral__actions">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="home-referral__btn !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="shareCode"
>
کد معرف:
<div class="flex gap-2">
<div class="mr-2">{{ userStore.userInfo?.referral_id }}</div>
</div>
</div>
<div class="w-full flex gap-2 mt-1">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="flex-1 !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="shareCode"
>
دعوت از دوستان
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="flex-1 !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="copyCode"
>
رونوشت
</BasicButton>
</div>
دعوت از دوستان
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="home-referral__btn !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="copyCode"
>
رونوشت
</BasicButton>
</div>
</div>
</template>
@@ -58,4 +55,76 @@ const copyCode = () => {
message.info('کد رونوشت شد')
}
}
</script>
</script>
<style scoped lang="less">
.home-referral {
container-type: inline-size;
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
margin-top: 8px;
padding: 10px 12px;
overflow: hidden;
&__code {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
margin-bottom: 8px;
}
&__code-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
gap: 8px;
width: 100%;
min-width: 0;
}
&__btn {
flex: 1 1 0;
min-width: 0 !important;
max-width: 100%;
:deep(.ant-btn) {
width: 100%;
min-width: 0;
max-width: 100%;
height: auto;
padding-inline: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.ant-btn > div) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@container (max-width: 340px) {
&__actions {
flex-direction: column;
}
&__btn {
flex: none;
width: 100%;
}
}
}
</style>
@@ -42,10 +42,15 @@ import { message } from 'ant-design-vue'
import { useUserStore } from '@/store/userStore'
import { getReferralShareText, getReferralShareTitle } from '@/utils/config'
const props = defineProps<{
show: boolean
referralId: string
}>()
const props = withDefaults(
defineProps<{
show: boolean
referralId?: string
}>(),
{
referralId: '',
}
)
const emit = defineEmits<{
'update:show': [value: boolean]