diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index 2b00e71..ad2c88f 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -467,13 +467,38 @@ function handleSettingsPayload(payload: unknown) { void applySettingsFromStream(payload) } +function coerceStreamProductList(payload: unknown): Product[] { + if (Array.isArray(payload)) { + return payload as Product[] + } + if (!payload || typeof payload !== 'object') { + return [] + } + const root = payload as Record + if (Array.isArray(root.data)) return root.data as Product[] + if (Array.isArray(root.products)) return root.products as Product[] + if (Array.isArray(root.items)) return root.items as Product[] + if (root.id != null && ('price' in root || 'title' in root || 'group_type' in root)) { + return [payload as Product] + } + const values = Object.values(root) + if ( + values.length > 0 && + values.every((item) => item && typeof item === 'object' && 'id' in (item as object)) + ) { + return values as Product[] + } + return [] +} + function handleListPayload(payload: unknown) { - if (!Array.isArray(payload) || !payload.length) { + const products = coerceStreamProductList(payload) + if (!products.length) { return } touchStreamActivity() - applyProductsListFromStream(payload as Product[]) + applyProductsListFromStream(products) } function handleAppNotificationPayload(payload: unknown) { diff --git a/src/utils/productPrice.ts b/src/utils/productPrice.ts index 3941e53..9db490f 100644 --- a/src/utils/productPrice.ts +++ b/src/utils/productPrice.ts @@ -101,26 +101,13 @@ export function getProductBaseBuyPrice(product?: Product | null): number { } if (isMeltedProductUsingAppPrice(product) || isAppPriceProduct(product)) { - const apiPrice = Number(product.price ?? 0) - const useLast = productUsesLastPriceIfZero(product) - - if (!useLast) { - const liveBuy = getTradeBuyPrice(lastPrice.value) - if (liveBuy > 0) { - return applyPriceOffsetPercent(liveBuy, product.price_offset_percent) - } - return 0 - } - - if (apiPrice > 0) { - return apiPrice - } - const liveBuy = getTradeBuyPrice(lastPrice.value) if (liveBuy > 0) { return applyPriceOffsetPercent(liveBuy, product.price_offset_percent) } - + if (productUsesLastPriceIfZero(product) && Number(product.price ?? 0) > 0) { + return Number(product.price ?? 0) + } return 0 } @@ -195,26 +182,13 @@ export function getProductBaseSellPrice(product?: Product | null): number { } if (isMeltedProductUsingAppPrice(product) || isAppPriceProduct(product)) { - const apiPrice = Number(product.price ?? 0) - const useLast = productUsesLastPriceIfZero(product) - - if (!useLast) { - const liveSell = getTradeSellPrice(lastPrice.value) - if (liveSell > 0) { - return applyPriceOffsetPercent(liveSell, product.price_offset_percent) - } - return 0 - } - - if (apiPrice > 0) { - return apiPrice - } - const liveSell = getTradeSellPrice(lastPrice.value) if (liveSell > 0) { return applyPriceOffsetPercent(liveSell, product.price_offset_percent) } - + if (productUsesLastPriceIfZero(product) && Number(product.price ?? 0) > 0) { + return Number(product.price ?? 0) + } return 0 } diff --git a/src/views/base/gold/index.vue b/src/views/base/gold/index.vue index 9f8562b..5fa1b77 100644 --- a/src/views/base/gold/index.vue +++ b/src/views/base/gold/index.vue @@ -319,7 +319,6 @@ import { getProductsCatalogRef, } from '@/composables/useProductsCatalog' import { subscribeProductListStream } from '@/utils/productStreamBridge' -import { usesQuantityTrade } from '@/utils/productCategories' import { useSettingsStore } from '@/store/settingsStore' import { storeToRefs } from 'pinia' import { @@ -456,8 +455,8 @@ const sellPagination = ref({ const unitPrice = computed(() => { const product = tradeProduct.value - if (usesQuantityTrade(product)) { - return isBuy.value ? getProductBuyPrice(product!) : getProductSellPrice(product!) + if (product) { + return isBuy.value ? getProductBuyPrice(product) : getProductSellPrice(product) } return isBuy.value ? getTradeBuyPrice(lastPrice.value) : getTradeSellPrice(lastPrice.value) }) diff --git a/src/views/base/products/composables/useProductsPageData.ts b/src/views/base/products/composables/useProductsPageData.ts index 42a4359..9001769 100644 --- a/src/views/base/products/composables/useProductsPageData.ts +++ b/src/views/base/products/composables/useProductsPageData.ts @@ -14,6 +14,7 @@ import { getLastStreamProductsList, subscribeProductListStream } from '@/utils/p let initPromise: Promise | null = null let productListInterval: ReturnType | undefined +let catalogStreamRefreshTimer: ReturnType | undefined let providerMountCount = 0 let pricePollingMountCount = 0 let unsubscribeProductStream: (() => void) | undefined @@ -72,6 +73,10 @@ function pruneEmptyTypeItems() { productsTypeItems.value = productsTypeItems.value.filter((key) => present.has(key)) } +function productRowId(product: Product): number { + return Number(product.id) +} + function productFileId(product: Product): number { return Number(product.file_id ?? (product.file as { id?: number } | undefined)?.id ?? 0) } @@ -85,43 +90,47 @@ function productHasDisplayImage(product: Product): boolean { } /** استریم گاهی file خالی با همان file_id می‌فرستد — عکس خوب HTTP را پاک نکن. */ +function mergeLiveProduct(prev: Product, incoming: Product): Product { + const next: Product = { + ...prev, + ...incoming, + open_buy: 'open_buy' in incoming ? incoming.open_buy : prev.open_buy, + open_sell: 'open_sell' in incoming ? incoming.open_sell : prev.open_sell, + } + + if (!productHasDisplayImage(incoming) && productHasDisplayImage(prev)) { + next.file = prev.file + next.file_id = prev.file_id ?? (prev.file as { id?: number } | undefined)?.id ?? incoming.file_id + next.img = prev.img + } else if (productFileId(prev) > productFileId(incoming)) { + next.file = prev.file + next.file_id = prev.file_id + next.img = prev.img + } + + return next +} + function mergeLiveProductList(incoming: Product[], current?: Product[]): Product[] { if (!current?.length) return incoming - const currentById = new Map(current.map((item) => [item.id, item])) + const currentById = new Map(current.map((item) => [productRowId(item), item])) + const incomingById = new Map(incoming.map((item) => [productRowId(item), item])) + const incomingIds = incoming.map(productRowId) + const isPatch = + incoming.length < current.length && + incomingIds.every((id) => currentById.has(id)) + + if (isPatch) { + return current.map((item) => { + const updated = incomingById.get(productRowId(item)) + return updated ? mergeLiveProduct(item, updated) : item + }) + } + return incoming.map((item) => { - const prev = currentById.get(item.id) - if (!prev) return item - - const prevId = productFileId(prev) - const nextId = productFileId(item) - - const keepTradeFlags = { - open_buy: 'open_buy' in item ? item.open_buy : prev.open_buy, - open_sell: 'open_sell' in item ? item.open_sell : prev.open_sell, - } - - if (!productHasDisplayImage(item) && productHasDisplayImage(prev)) { - return { - ...item, - ...keepTradeFlags, - file: prev.file, - file_id: prev.file_id ?? (prev.file as { id?: number } | undefined)?.id ?? item.file_id, - img: prev.img, - } - } - - if (prevId > nextId) { - return { - ...item, - ...keepTradeFlags, - file: prev.file, - file_id: prev.file_id, - img: prev.img, - } - } - - return { ...item, ...keepTradeFlags } + const prev = currentById.get(productRowId(item)) + return prev ? mergeLiveProduct(prev, item) : item }) } @@ -141,16 +150,18 @@ function isListedProduct(product: Product): boolean { return flagOn(product.active) } -/** لیست SSE فروشگاه دیگر را روی این صفحه ننشان */ +/** لیست SSE فروشگاه دیگر را روی این صفحه ننشان؛ پچ یک‌محصوله همین فروشگاه را بپذیر */ function isSameShopCatalog(incoming: Product[], current?: Product[]): boolean { if (!current?.length) return true - const incomingIds = new Set(incoming.map((item) => item.id)) - const currentIds = new Set(current.map((item) => item.id)) + const incomingIds = incoming.map((item) => productRowId(item)) + const currentIds = new Set(current.map((item) => productRowId(item))) let overlap = 0 incomingIds.forEach((id) => { if (currentIds.has(id)) overlap += 1 }) - return overlap / Math.max(incomingIds.size, currentIds.size) >= 0.5 + if (overlap === 0) return false + if (overlap === incomingIds.length) return true + return overlap / Math.max(incomingIds.length, currentIds.size) >= 0.5 } function applyProductsList(products: Product[]) { @@ -171,10 +182,20 @@ function applyProductsList(products: Product[]) { productsPageLoading.value = false } +/** قیمت enrich شده فقط در HTTP کامل است؛ slim SSE برای پرچم‌هاست */ +function scheduleCatalogRefreshFromStream() { + if (catalogStreamRefreshTimer) { + clearTimeout(catalogStreamRefreshTimer) + } + catalogStreamRefreshTimer = setTimeout(() => { + catalogStreamRefreshTimer = undefined + void refreshProductsPageData() + }, 150) +} + /** - * SSE همیشه لیست کامل محصولات فعال را می‌فرستد. - * عضو جدید/حذف‌شده را از همان لیست بگیر؛ فقط عکس را از نسخهٔ قبلی نگه دار. - * آپدیت قیمت روی همان آبجکت‌ها تا گرید نپرد. + * SSE لیست کامل یا پچ می‌فرستد. + * slim فوری برای open_buy/show؛ قیمت کارت مثل رفرش از /customer/product/list. */ function applySocketProductList(incoming: Product[], current?: Product[]) { if (!isSameShopCatalog(incoming, current)) { @@ -184,35 +205,13 @@ function applySocketProductList(incoming: Product[], current?: Product[]) { const next = sortProductsByInventory( mergeLiveProductList(incoming, current).filter(isListedProduct), ) - - if (!current?.length) { + if (next.length) { applyProductsList(next) - return + } else { + productsPageLoading.value = false } - const nextById = new Map(next.map((item) => [item.id, item])) - const currentIds = new Set(current.map((item) => item.id)) - const nextIds = new Set(next.map((item) => item.id)) - const membershipChanged = - next.length !== current.length || - next.some((item) => !currentIds.has(item.id)) || - current.some((item) => !nextIds.has(item.id)) - - if (membershipChanged) { - applyProductsList(next) - return - } - - current.forEach((item) => { - const updated = nextById.get(item.id) - if (updated) { - Object.assign(item, updated) - } - }) - - getProductsCatalogRef().value = current - changeProductsTab() - productsPageLoading.value = false + scheduleCatalogRefreshFromStream() } export async function refreshProductsPageData() { @@ -305,6 +304,11 @@ export function onProductsPageProviderUnmounted() { if (providerMountCount === 0) { stopProductListPolling() + initialized.value = false + if (catalogStreamRefreshTimer) { + clearTimeout(catalogStreamRefreshTimer) + catalogStreamRefreshTimer = undefined + } } }