Reuse the live price stream for the product catalog and physical delivery list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-10 22:11:55 +03:30
co-authored by Cursor
parent 6facf5d72d
commit 751d9a34e3
23 changed files with 13037 additions and 556 deletions
+7 -3
View File
@@ -1,10 +1,14 @@
import useAxios from "@/utils/useAxios";
import type {PInventory, Product} from "@/models/products";
import type { PWallet, Wallet } from '@/models/wallet'
import { priceStreamList } from "@/utils/priceSocketState";
export const getProducts = async (): Promise<any> => {
return await useAxios.get<Product[]>(`/customer/product/list`, { timeout: 15000 })
export const getProducts = async (force = false): Promise<Product[]> => {
if (!force && Array.isArray(priceStreamList.value) && priceStreamList.value.length > 0) {
return priceStreamList.value as unknown as Product[]
}
const response = await useAxios.get<Product[]>(`/customer/product/list`, { timeout: 15000 })
return Array.isArray(response) ? response : []
}
export const getTvProducts = async (): Promise<Product[]> => {
const response = await useAxios.get<unknown>(`/customer/tv/products`, { timeout: 15000 })
+861
View File
@@ -0,0 +1,861 @@
<template>
<div
ref="containerRef"
class="product-picker-root"
:class="{
'product-picker-root--compact': compact,
'product-picker-root--open': isOpen,
}"
>
<label v-if="label" class="product-picker__label">{{ label }}</label>
<!-- سلکتور جمع‌وجور -->
<div
class="product-picker__control"
:class="{
'product-picker__control--clickable': !disabled && !isEdit,
'product-picker__control--disabled': disabled,
'product-picker__control--locked': isEdit,
'product-picker__control--active': isOpen,
}"
role="button"
:tabindex="disabled ? -1 : 0"
:aria-label="label || placeholder"
@click="toggleOpen"
@keydown.enter.prevent="toggleOpen"
>
<div class="product-picker__value">
<span class="product-picker__icon" aria-hidden="true">
<BasicIcon
:icon="isEdit ? 'vuesax-linear:lock' : 'vuesax-linear:box-1'"
:size="compact ? 16 : 18"
:color="selectedItem ? 'var(--App-secondary-color, #e0be7f)' : 'var(--app-text-soft, #9ca3af)'"
/>
</span>
<span
class="product-picker__text"
:class="{ 'product-picker__text--placeholder': !selectedItem && !lockedTitle }"
>
{{ displayTitle }}
</span>
<span v-if="displayUnit" class="product-picker__unit">({{ displayUnit }})</span>
<span v-if="displaySubtitle" class="product-picker__subtitle">{{ displaySubtitle }}</span>
</div>
<div class="product-picker__trailing">
<button
v-if="allowClear && selectedItem && !disabled && !isEdit"
type="button"
class="product-picker__clear-btn"
aria-label="پاک کردن"
@click.stop="handleClear"
>
<BasicIcon icon="vuesax-linear:close-circle" size="16" color="var(--app-text-soft, #9ca3af)" />
</button>
<span class="product-picker__arrow" aria-hidden="true">
<BasicIcon
v-if="!isEdit"
:icon="isOpen ? 'vuesax-linear:arrow-up-1' : 'vuesax-linear:arrow-down-1'"
size="16"
color="var(--app-text-soft, #9ca3af)"
/>
<BasicIcon v-else icon="vuesax-linear:lock" size="14" color="var(--app-text-soft, #9ca3af)" />
</span>
</div>
</div>
<!-- دراپ‌داون دسکتاپ -->
<div
v-if="isDesktop && isOpen && !disabled && !isEdit"
class="product-picker__dropdown"
:style="dropdownStyle"
>
<div class="product-picker__search">
<span class="product-picker__search-icon" aria-hidden="true">
<BasicIcon icon="vuesax-linear:search-normal" size="16" color="var(--app-text-soft, #9ca3af)" />
</span>
<input
ref="desktopSearchInputRef"
v-model="searchQuery"
type="text"
class="product-picker__search-input"
placeholder="جستجوی محصول..."
autocomplete="off"
/>
<button
v-if="searchQuery"
type="button"
class="product-picker__clear-query-btn"
aria-label="پاک کردن جستجو"
@click="searchQuery = ''"
>
<BasicIcon icon="vuesax-linear:close-circle" size="15" color="var(--app-text-soft, #9ca3af)" />
</button>
</div>
<div class="product-picker__list" role="listbox">
<button
v-for="p in filteredList"
:key="getItemKey(p)"
type="button"
role="option"
class="product-picker__item"
:class="{ 'product-picker__item--selected': isSelected(p) }"
:aria-selected="isSelected(p)"
@click="selectItem(p)"
>
<div class="product-picker__item-info">
<span class="product-picker__item-icon">
<BasicIcon
icon="vuesax-linear:box-1"
size="16"
:color="isSelected(p) ? 'var(--App-secondary-color, #e0be7f)' : 'inherit'"
/>
</span>
<span class="product-picker__item-title">{{ getItemTitle(p) }}</span>
<span v-if="getItemUnit(p)" class="product-picker__item-unit">({{ getItemUnit(p) }})</span>
<span v-if="getItemSubtitle(p)" class="product-picker__item-subtitle">{{ getItemSubtitle(p) }}</span>
</div>
<span v-if="isSelected(p)" class="product-picker__item-check">
<BasicIcon icon="vuesax-linear:tick-circle" size="18" color="var(--App-secondary-color, #e0be7f)" />
</span>
</button>
<div v-if="loading" class="product-picker__loading">
<BasicIcon icon="vuesax-linear:box-time" size="20" class="animate-spin" color="var(--App-secondary-color, #e0be7f)" />
<span>در حال دریافت محصولات...</span>
</div>
<div v-else-if="filteredList.length === 0" class="product-picker__empty">
<BasicIcon icon="vuesax-linear:info-circle" size="20" color="var(--app-text-soft, #9ca3af)" />
<span>{{ emptyText }}</span>
</div>
</div>
</div>
<!-- اکشن‌شیت موبایل -->
<BasicActionSheet
v-if="!isDesktop"
v-model:show="isSheetOpen"
:title="sheetTitle"
:closable="true"
@closed="onSheetClosed"
>
<div class="product-picker-sheet">
<div class="product-picker-sheet__search">
<span class="product-picker-sheet__search-icon" aria-hidden="true">
<BasicIcon icon="vuesax-linear:search-normal" size="18" color="var(--app-text-soft, #9ca3af)" />
</span>
<input
ref="mobileSearchInputRef"
v-model="searchQuery"
type="text"
class="product-picker-sheet__search-input"
placeholder="جستجوی نام یا کد محصول..."
autocomplete="off"
/>
<button
v-if="searchQuery"
type="button"
class="product-picker-sheet__clear-btn"
aria-label="پاک کردن جستجو"
@click="searchQuery = ''"
>
<BasicIcon icon="vuesax-linear:close-circle" size="18" color="var(--app-text-soft, #9ca3af)" />
</button>
</div>
<div class="product-picker-sheet__list" role="listbox">
<button
v-for="p in filteredList"
:key="getItemKey(p)"
type="button"
role="option"
class="product-picker-sheet__item"
:class="{ 'product-picker-sheet__item--selected': isSelected(p) }"
:aria-selected="isSelected(p)"
@click="selectItem(p)"
>
<div class="product-picker-sheet__item-info">
<span class="product-picker-sheet__item-icon">
<BasicIcon
icon="vuesax-linear:box-1"
size="18"
:color="isSelected(p) ? 'var(--App-secondary-color, #e0be7f)' : 'inherit'"
/>
</span>
<div class="product-picker-sheet__item-texts">
<span class="product-picker-sheet__item-title">{{ getItemTitle(p) }}</span>
<span
v-if="getItemSubtitle(p) || getItemUnit(p)"
class="product-picker-sheet__item-meta"
>
{{ getItemSubtitle(p) || `واحد: ${getItemUnit(p)}` }}
</span>
</div>
</div>
<span v-if="isSelected(p)" class="product-picker-sheet__item-check">
<BasicIcon icon="vuesax-linear:tick-circle" size="20" color="var(--App-secondary-color, #e0be7f)" />
</span>
</button>
<div v-if="loading" class="product-picker-sheet__loading">
<BasicIcon icon="vuesax-linear:box-time" size="26" class="animate-spin" color="var(--App-secondary-color, #e0be7f)" />
<span class="mt-2 text-sm text-gray-400">در حال دریافت محصولات...</span>
</div>
<div v-else-if="filteredList.length === 0" class="product-picker-sheet__empty">
<BasicIcon icon="vuesax-linear:info-circle" size="24" color="var(--app-text-soft, #9ca3af)" />
<span>{{ emptyText }}</span>
</div>
</div>
</div>
</BasicActionSheet>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
interface Props {
modelValue?: number | string | null
value?: number | string | null
items?: any[]
products?: any[]
keyName?: string
valueName?: string
unitName?: string
subtitleName?: string
placeholder?: string
sheetTitle?: string
label?: string
disabled?: boolean
loading?: boolean
isEdit?: boolean
lockedTitle?: string
allowClear?: boolean
compact?: boolean
emptyText?: string
dropdownStyle?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
modelValue: undefined,
value: undefined,
items: undefined,
products: undefined,
keyName: 'id',
valueName: 'title',
unitName: 'unit',
subtitleName: 'subtitle',
placeholder: 'انتخاب محصول...',
sheetTitle: 'انتخاب محصول',
label: '',
disabled: false,
loading: false,
isEdit: false,
lockedTitle: '',
allowClear: false,
compact: false,
emptyText: 'محصولی یافت نشد',
dropdownStyle: undefined,
})
const emit = defineEmits<{
'update:modelValue': [val: any]
'update:value': [val: any]
'change': [item: any]
'clear': []
}>()
const isDesktop = useDesktopBreakpoint()
const containerRef = ref<HTMLElement>()
const desktopSearchInputRef = ref<HTMLInputElement>()
const mobileSearchInputRef = ref<HTMLInputElement>()
const isOpen = ref(false)
const isSheetOpen = ref(false)
const searchQuery = ref('')
const effectiveList = computed(() => {
if (Array.isArray(props.items)) return props.items
if (Array.isArray(props.products)) return props.products
return []
})
const currentValue = computed(() => {
if (props.modelValue !== undefined) return props.modelValue
return props.value
})
function getItemKey(item: any): any {
if (!item) return ''
return item[props.keyName] !== undefined ? item[props.keyName] : item.id
}
function getItemTitle(item: any): string {
if (!item) return ''
return String(item[props.valueName] || item.title || item.name || `محصول ${getItemKey(item)}`)
}
function getItemUnit(item: any): string {
if (!item) return ''
return item[props.unitName] || item.unit || ''
}
function getItemSubtitle(item: any): string {
if (!item) return ''
return item[props.subtitleName] || item.subtitle || ''
}
const selectedItem = computed(() => {
const val = currentValue.value
if (val === undefined || val === null || val === '') return null
return effectiveList.value.find((p) => String(getItemKey(p)) === String(val)) ?? null
})
const displayTitle = computed(() => {
if (props.isEdit && props.lockedTitle) return props.lockedTitle
if (selectedItem.value) return getItemTitle(selectedItem.value)
const val = currentValue.value
if (val !== undefined && val !== null && val !== '') return `محصول ${val}`
return props.placeholder
})
const displayUnit = computed(() => {
if (props.isEdit) return ''
return selectedItem.value ? getItemUnit(selectedItem.value) : ''
})
const displaySubtitle = computed(() => {
if (props.isEdit) return ''
return selectedItem.value ? getItemSubtitle(selectedItem.value) : ''
})
function isSelected(item: any): boolean {
const val = currentValue.value
if (val === undefined || val === null || val === '') return false
return String(getItemKey(item)) === String(val)
}
function normalizeText(text: string): string {
return (text || '')
.toLowerCase()
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim()
}
const filteredList = computed(() => {
const q = normalizeText(searchQuery.value)
if (!q) return effectiveList.value
return effectiveList.value.filter((p) => {
const title = normalizeText(getItemTitle(p))
const unit = normalizeText(getItemUnit(p))
const subtitle = normalizeText(getItemSubtitle(p))
const key = String(getItemKey(p))
return title.includes(q) || unit.includes(q) || subtitle.includes(q) || key.includes(q)
})
})
function toggleOpen() {
if (props.disabled || props.isEdit) return
if (isDesktop.value) {
isOpen.value = !isOpen.value
if (isOpen.value) {
nextTick(() => {
desktopSearchInputRef.value?.focus()
})
} else {
searchQuery.value = ''
}
} else {
isSheetOpen.value = true
nextTick(() => {
mobileSearchInputRef.value?.focus()
})
}
}
function selectItem(item: any) {
const key = getItemKey(item)
emit('update:modelValue', key)
emit('update:value', key)
emit('change', item)
isOpen.value = false
isSheetOpen.value = false
searchQuery.value = ''
}
function handleClear() {
emit('update:modelValue', undefined)
emit('update:value', undefined)
emit('change', undefined)
emit('clear')
}
function onSheetClosed() {
searchQuery.value = ''
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
isOpen.value = false
searchQuery.value = ''
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped lang="less">
.product-picker-root {
position: relative;
display: flex;
flex-direction: column;
gap: 0.375rem;
width: 100%;
direction: rtl;
text-align: right;
z-index: 1;
&--open {
z-index: 50;
}
--picker-bg: var(--app-surface, var(--admin-bg-surface, #ffffff));
--picker-border: var(--app-border, var(--admin-border, #e2e8f0));
--picker-border-focus: var(--app-border-strong, var(--App-secondary-color, #e0be7f));
--picker-text: var(--app-text-primary, var(--admin-text, #0f172a));
--picker-text-soft: var(--app-text-muted, var(--admin-text-secondary, #64748b));
--picker-inset: var(--app-surface-inset, rgba(15, 23, 42, 0.03));
}
:global(html[data-theme='dark']) .product-picker-root,
:global(html.dark) .product-picker-root {
--picker-bg: var(--app-surface, #1e2026);
--picker-border: var(--app-border, rgba(255, 255, 255, 0.12));
--picker-border-focus: var(--app-border-strong, rgba(255, 255, 255, 0.28));
--picker-text: var(--app-text-primary, #ffffff);
--picker-text-soft: var(--app-text-muted, rgba(255, 255, 255, 0.55));
--picker-inset: var(--app-surface-inset, rgba(255, 255, 255, 0.04));
}
.product-picker__label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--picker-text);
}
.product-picker__control {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 2.65rem;
padding: 0 0.75rem;
border-radius: 0.65rem;
border: 1px solid var(--picker-border);
background: var(--picker-bg);
color: var(--picker-text);
direction: rtl;
transition: all 0.18s ease;
user-select: none;
.product-picker-root--compact & {
min-height: 2.35rem;
padding: 0 0.6rem;
border-radius: 0.5rem;
}
&--clickable {
cursor: pointer;
&:hover {
border-color: var(--picker-border-focus);
}
}
&--active {
border-color: var(--picker-border-focus);
box-shadow: 0 0 0 2px rgba(227, 176, 86, 0.15);
}
&--disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--picker-inset);
}
&--locked {
background: var(--picker-inset);
cursor: default;
}
}
.product-picker__value {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
.product-picker__icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.product-picker__text {
font-size: 0.875rem;
font-weight: 600;
color: var(--picker-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.product-picker-root--compact & {
font-size: 0.8125rem;
}
&--placeholder {
color: var(--picker-text-soft);
font-weight: 400;
}
}
.product-picker__unit {
font-size: 0.75rem;
color: var(--picker-text-soft);
flex-shrink: 0;
}
.product-picker__subtitle {
font-size: 0.72rem;
color: var(--App-secondary-color, #e0be7f);
font-weight: 600;
flex-shrink: 0;
}
.product-picker__trailing {
display: flex;
align-items: center;
gap: 0.35rem;
flex-shrink: 0;
}
.product-picker__clear-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
padding: 2px;
cursor: pointer;
border-radius: 9999px;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
}
.product-picker__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* دراپ‌داون دسکتاپ */
.product-picker__dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: 0;
z-index: 1050;
padding: 0.5rem;
border-radius: 0.75rem;
border: 1px solid var(--picker-border);
background: var(--picker-bg);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.product-picker__search {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.25rem;
padding: 0 0.6rem;
border-radius: 0.5rem;
background: var(--picker-inset);
border: 1px solid var(--picker-border);
}
.product-picker__search-icon {
display: inline-flex;
flex-shrink: 0;
}
.product-picker__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--picker-text);
font-size: 0.8125rem;
text-align: right;
direction: rtl;
&::placeholder {
color: var(--picker-text-soft);
}
}
.product-picker__clear-query-btn {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
display: inline-flex;
}
.product-picker__list {
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 14rem;
overflow-y: auto;
}
.product-picker__item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.65rem;
border-radius: 0.5rem;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
text-align: right;
&:hover {
background: var(--picker-inset);
border-color: var(--picker-border);
}
&--selected {
background: rgba(224, 190, 127, 0.12);
border-color: var(--App-secondary-color, #e0be7f);
}
}
.product-picker__item-info {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.product-picker__item-icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.product-picker__item-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--picker-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-picker__item-unit {
font-size: 0.72rem;
color: var(--picker-text-soft);
}
.product-picker__item-subtitle {
font-size: 0.72rem;
color: var(--App-secondary-color, #e0be7f);
font-weight: 600;
}
.product-picker__item-check {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.product-picker__empty {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 1.25rem 0.5rem;
color: var(--picker-text-soft);
font-size: 0.8125rem;
}
/* شیت موبایل */
.product-picker-sheet {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-bottom: 1.5rem;
direction: rtl;
text-align: right;
}
.product-picker-sheet__search {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.75rem;
padding: 0 0.85rem;
border-radius: 0.75rem;
background: var(--picker-inset);
border: 1px solid var(--picker-border);
}
.product-picker-sheet__search-icon {
display: inline-flex;
flex-shrink: 0;
}
.product-picker-sheet__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--picker-text);
font-size: 0.875rem;
font-weight: 500;
text-align: right;
direction: rtl;
&::placeholder {
color: var(--picker-text-soft);
}
}
.product-picker-sheet__clear-btn {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
display: inline-flex;
}
.product-picker-sheet__list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 55vh;
overflow-y: auto;
}
.product-picker-sheet__item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 0.65rem;
border: 1px solid var(--picker-border);
background: var(--picker-bg);
cursor: pointer;
transition: all 0.15s ease;
outline: none;
text-align: right;
&:hover {
border-color: var(--App-secondary-color, #e0be7f);
}
&--selected {
border-color: var(--App-secondary-color, #e0be7f);
background: rgba(224, 190, 127, 0.1);
}
}
.product-picker-sheet__item-info {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
.product-picker-sheet__item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.5rem;
background: var(--picker-inset);
flex-shrink: 0;
.product-picker-sheet__item--selected & {
background: var(--App-secondary-color, #e0be7f);
color: #fff;
}
}
.product-picker-sheet__item-texts {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.product-picker-sheet__item-title {
font-size: 0.85rem;
font-weight: 700;
color: var(--picker-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-picker-sheet__item-meta {
font-size: 0.7rem;
color: var(--picker-text-soft);
font-weight: 500;
}
.product-picker-sheet__item-check {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.product-picker-sheet__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 2rem 1rem;
color: var(--picker-text-soft);
font-size: 0.8125rem;
text-align: center;
}
</style>
+42 -2
View File
@@ -1,15 +1,38 @@
import { ref } from 'vue'
import { getProducts } from '@/api/products'
import type { Product } from '@/models/products'
import { priceStreamList } from '@/utils/priceSocketState'
/** کش مشترک لیست محصولات — dedupe درخواست‌های polling */
const catalogProducts = ref<Product[]>([])
let catalogPromise: Promise<Product[]> | null = null
export function getProductsCatalogRef() {
if (catalogProducts.value.length === 0 && Array.isArray(priceStreamList.value) && priceStreamList.value.length > 0) {
catalogProducts.value = priceStreamList.value as unknown as Product[]
}
return catalogProducts
}
export function syncCatalogFromStream(streamProducts: Product[]) {
if (!Array.isArray(streamProducts) || streamProducts.length === 0) return
if (catalogProducts.value.length === 0) {
catalogProducts.value = streamProducts
} else {
const map = new Map(streamProducts.map((p) => [p.id, p]))
catalogProducts.value.forEach((p) => {
const incoming = map.get(p.id)
if (incoming) {
if (incoming.price !== undefined) p.price = incoming.price
if (incoming.price_buy !== undefined) p.price_buy = incoming.price_buy
if (incoming.price_sell !== undefined) p.price_sell = incoming.price_sell
if (incoming.trade_open !== undefined) p.trade_open = incoming.trade_open
if (incoming.show !== undefined) p.show = incoming.show
}
})
}
}
export function findProductInCatalog(id: number): Product | undefined {
return catalogProducts.value.find((p) => p.id === id)
}
@@ -19,17 +42,34 @@ export function getProductsCatalog(force = false): Promise<Product[]> {
if (catalogProducts.value.length > 0) {
return Promise.resolve(catalogProducts.value)
}
if (Array.isArray(priceStreamList.value) && priceStreamList.value.length > 0) {
catalogProducts.value = priceStreamList.value as unknown as Product[]
return Promise.resolve(catalogProducts.value)
}
}
if (!force && catalogPromise) {
return catalogPromise
}
catalogPromise = getProducts()
catalogPromise = getProducts(force)
.then((products) => {
catalogProducts.value = products ?? []
const list = Array.isArray(products) ? products : []
if (list.length > 0) {
catalogProducts.value = list
}
return catalogProducts.value
})
.catch((err) => {
if (catalogProducts.value.length > 0) {
return catalogProducts.value
}
if (Array.isArray(priceStreamList.value) && priceStreamList.value.length > 0) {
catalogProducts.value = priceStreamList.value as unknown as Product[]
return catalogProducts.value
}
throw err
})
.finally(() => {
catalogPromise = null
})
+2
View File
@@ -3,6 +3,7 @@ import type { Product } from '@/models/products'
import { Modal } from 'ant-design-vue'
import { getConfigApp } from '@/utils/config'
import { applyProductsListFromStream } from '@/utils/productStreamBridge'
import { syncCatalogFromStream } from '@/composables/useProductsCatalog'
import {
priceSocketConnected,
priceStreamConnectingSince,
@@ -505,6 +506,7 @@ function handleListPayload(payload: unknown) {
touchStreamActivity()
priceStreamList.value = products as Array<Record<string, unknown>>
syncCatalogFromStream(products)
applyProductsListFromStream(products)
}
+4 -19
View File
@@ -307,43 +307,28 @@ function tvBuyUnitPrice(product: Product): number {
}
function tvSellUnitPrice(product: Product): number {
const sell = Number(product.tv_sell_price ?? 0)
if (sell > 0) {
return sell
}
return tvBuyUnitPrice(product)
return Number(product.tv_sell_price ?? 0)
}
export function getTvProductBuyPrice(product?: Product | null): number {
if (!product) {
return 0
}
const unit = tvBuyUnitPrice(product)
if (unit > 0) {
return unit
}
return getProductBuyPrice(product)
return tvBuyUnitPrice(product)
}
export function getTvProductSellPrice(product?: Product | null): number {
if (!product) {
return 0
}
const unit = tvSellUnitPrice(product)
if (unit > 0) {
return unit
}
return getProductSellPrice(product)
return tvSellUnitPrice(product)
}
export function isTvProductPriceInactive(product?: Product | null): boolean {
if (!product) {
return true
}
if (tvBuyUnitPrice(product) > 0 || tvSellUnitPrice(product) > 0) {
return false
}
return isProductPriceInactive(product)
return tvBuyUnitPrice(product) <= 0 && tvSellUnitPrice(product) <= 0
}
/**
@@ -1,13 +1,14 @@
import { inject, nextTick, onMounted, provide, ref, type InjectionKey, type Ref } from 'vue'
import { inject, nextTick, onMounted, provide, ref, watch, type InjectionKey, type Ref } from 'vue'
import { cloneDeep } from 'lodash'
import { message } from 'ant-design-vue'
import router from '@/router'
import { getProductInventory } from '@/api/products'
import { physicalReceiveApi } from '@/api/physicalreceive'
import { tableAddress } from '@/api/address'
import type { CountPInventory, PInventory } from '@/models/products'
import type { CountPInventory, PInventory, Product } from '@/models/products'
import type { Address } from '@/models/address'
import { loading } from '@/utils'
import { priceStreamList } from '@/utils/priceSocketState'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { usePhysicalReceiveShellContext } from './usePhysicalReceiveShell'
@@ -73,18 +74,63 @@ export function useNewPhysicalRequestPage() {
)
}
let cachedInventoryRows: PInventory[] = []
function buildPhysicalDeliveryItems(inventoryRows: PInventory[] = []): PInventory[] {
const streamList = Array.isArray(priceStreamList.value)
? (priceStreamList.value as unknown as Product[])
: []
const inventoryMap = new Map((inventoryRows || []).map((r) => [r.id, r]))
const physicalStream = streamList.filter(
(p) => Boolean(p.has_physical_delivery) && p.title !== 'آبشده نقدی'
)
const list: PInventory[] = physicalStream.map((p) => {
const inv = inventoryMap.get(p.id)
return {
...p,
...(inv || {}),
id: p.id,
title: p.title,
unit: p.unit || inv?.unit || 'عدد',
has_physical_delivery: true,
min_physical_delivery_quantity: Number(
p.min_physical_delivery_quantity ?? inv?.min_physical_delivery_quantity ?? 0
),
wallet: inv?.wallet ?? {
id: p.id,
gold: 0,
},
} as PInventory
})
return list
}
function syncItemsFromStream() {
const selectedIds = new Set(selectedProducts.value.map((sp) => sp.product.id))
const fresh = buildPhysicalDeliveryItems(cachedInventoryRows)
baseItems.value = cloneDeep(fresh)
items.value = fresh.filter((item) => !selectedIds.has(item.id))
}
watch(
() => priceStreamList.value,
() => {
syncItemsFromStream()
},
{ deep: true }
)
async function initProductWallet() {
syncItemsFromStream()
try {
loading.value = true
const inventory = (await getProductInventory()).rows.filter(
(x) => x.title !== 'آبشده نقدی' && x.wallet?.gold && x.wallet.gold > 0,
)
items.value = inventory
baseItems.value = cloneDeep(inventory)
const invRes = await getProductInventory()
cachedInventoryRows = invRes?.rows || []
syncItemsFromStream()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
@@ -140,7 +186,8 @@ export function useNewPhysicalRequestPage() {
}
onMounted(async () => {
await initProductWallet()
syncItemsFromStream()
void initProductWallet()
await getAddress()
})
@@ -6,15 +6,12 @@
<div class="dinner-surface p-5 space-y-4">
<div class="physical-new-request__picker-row">
<div class="physical-new-request__select">
<BasicSelect
<ProductPicker
v-model:value="selectedProduct"
:items="items"
key-name="id"
:show-search="false"
value-name="title"
size="large"
placeholder="لطفا محصول را انتخاب کنید"
:has-tooltip="false"
placeholder="لطفاً محصول را انتخاب کنید"
sheet-title="انتخاب محصول تحویل فیزیکی"
allow-clear
/>
</div>
<BasicButton
@@ -85,7 +82,7 @@
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
import ProductPicker from '@/components/BasicPacks/ProductPicker.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import New_EditAddress from '@/views/base/address/components/New_EditAddress.vue'
@@ -3,15 +3,12 @@
<div class="physical-new-request__toolbar mpage-content">
<div class="physical-new-request__picker-row px-3">
<div class="physical-new-request__select">
<BasicSelect
<ProductPicker
v-model:value="selectedProduct"
:items="items"
key-name="id"
:show-search="false"
value-name="title"
size="large"
placeholder="لطفا محصول را انتخاب کنید"
:has-tooltip="false"
placeholder="لطفاً محصول را انتخاب کنید"
sheet-title="انتخاب محصول تحویل فیزیکی"
allow-clear
/>
</div>
<BasicButton
@@ -64,7 +61,7 @@
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
import ProductPicker from '@/components/BasicPacks/ProductPicker.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import New_EditAddress from '@/views/base/address/components/New_EditAddress.vue'
import PhysicalReceiveCartRow from '../components/PhysicalReceiveCartRow.vue'
+15 -17
View File
@@ -24,18 +24,13 @@
<BasicIcon icon="vuesax-linear:box-1" size="18" />
<span class="truncate">{{ lockedProductTitle || 'محصول ثابت' }}</span>
</div>
<div v-else class="piggy-create__products">
<button
v-for="p in products"
:key="p.id"
type="button"
class="piggy-create__product"
:class="{ 'piggy-create__product--on': productId === p.id }"
@click="productId = p.id"
>
<BasicIcon icon="vuesax-linear:box-1" size="18" />
<span class="min-w-0 truncate">{{ p.title || `محصول ${p.id}` }}</span>
</button>
<div v-else>
<ProductPicker
v-model:value="productId"
:items="products"
placeholder="انتخاب محصول قلک..."
sheet-title="انتخاب محصول قلک"
/>
</div>
</section>
@@ -82,7 +77,8 @@
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import { createFund, editFund, getFundById } from '@/api/fund'
import { getProducts } from '@/api/products'
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
import ProductPicker from '@/components/BasicPacks/ProductPicker.vue'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { message } from 'ant-design-vue'
@@ -94,7 +90,7 @@ const settingsStore = useSettingsStore()
const busy = ref(false)
const title = ref('پس انداز')
const productId = ref<number | undefined>()
const products = ref<Product[]>([])
const products = ref<Product[]>(getProductsCatalogRef().value || [])
const lockedProductTitle = ref('')
const presets = [...PIGGY_PRESETS]
@@ -142,15 +138,17 @@ onMounted(async () => {
return
}
try {
busy.value = true
const list = await getProducts()
if (!products.value.length || editId.value) {
busy.value = true
}
const list = await getProductsCatalog()
products.value = Array.isArray(list) ? list : ((list as any)?.rows ?? (list as any)?.data ?? [])
if (editId.value) {
const fund = await getFundById(editId.value)
title.value = fund?.title ?? ''
productId.value = fund?.product_id
lockedProductTitle.value = fund?.product?.title || ''
} else if (products.value.length === 1) {
} else if (!productId.value && products.value.length > 0) {
productId.value = products.value[0]?.id
}
} catch (e) {
@@ -8,13 +8,12 @@
>
<div class="flex flex-col gap-4 w-full">
<a-form-item :rules="[required()]" label="محصول" name="product_id">
<BasicSelect
<ProductPicker
v-model:value="form.product_id"
:items="products"
key-name="id"
placeholder="آبشده، سکه، نقره و ..."
size="large"
value-name="title"
sheet-title="انتخاب محصول هشدار"
allow-clear
/>
</a-form-item>
<a-form-item :rules="[required()]" label="جهت قیمت" name="price_condition">
@@ -53,6 +52,7 @@
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
import ProductPicker from '@/components/BasicPacks/ProductPicker.vue'
import { required } from '@/utils/inputRules'
import { usePriceTriggerPageContext } from '../composables/usePriceTriggerPage'
@@ -341,13 +341,23 @@ export function useTvMonitorPage() {
return isTvProductPriceInactive(product)
}
function isProductTvBuyInactive(product: Product) {
return Number(product.tv_price ?? 0) <= 0
}
function isProductTvSellInactive(product: Product) {
return Number(product.tv_sell_price ?? 0) <= 0
}
function productBuyLabel(product: Product) {
if (isProductTvInactive(product)) return NO_QUOTE_MESSAGE
if (isProductTvBuyInactive(product)) return '—'
return formatTvPrice(getTvProductBuyPrice(product))
}
function productSellLabel(product: Product) {
if (isProductTvInactive(product)) return NO_QUOTE_MESSAGE
if (isProductTvSellInactive(product)) return '—'
return formatTvPrice(getTvProductSellPrice(product))
}
@@ -417,6 +427,8 @@ export function useTvMonitorPage() {
productWeightLabel,
tickerItemLabel,
isProductTvInactive,
isProductTvBuyInactive,
isProductTvSellInactive,
productBuyLabel,
productSellLabel,
liveQuoteLabel,
+6 -4
View File
@@ -118,17 +118,17 @@
<div class="tv-row__prices">
<div
class="tv-row__price tv-row__price--sell"
:class="{ 'tv-row__price--empty': isProductTvInactive(item) }"
:class="{ 'tv-row__price--empty': isProductTvBuyInactive(item) }"
>
<strong>{{ productBuyLabel(item) }}</strong>
<em v-if="!isProductTvInactive(item) && productBuyLabel(item) !== '—'">تومان</em>
<em v-if="!isProductTvBuyInactive(item) && productBuyLabel(item) !== '—'">تومان</em>
</div>
<div
class="tv-row__price tv-row__price--buy"
:class="{ 'tv-row__price--empty': isProductTvInactive(item) }"
:class="{ 'tv-row__price--empty': isProductTvSellInactive(item) }"
>
<strong>{{ productSellLabel(item) }}</strong>
<em v-if="!isProductTvInactive(item) && productSellLabel(item) !== '—'">تومان</em>
<em v-if="!isProductTvSellInactive(item) && productSellLabel(item) !== '—'">تومان</em>
</div>
</div>
</article>
@@ -276,6 +276,8 @@ const {
productWeightLabel,
tickerItemLabel,
isProductTvInactive,
isProductTvBuyInactive,
isProductTvSellInactive,
productBuyLabel,
productSellLabel,
liveQuoteLabel,