Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-03-04 17:59:14 +03:00
parent b1150c5ef7
commit 94244b194a
7 changed files with 506 additions and 46 deletions

View File

@@ -90,6 +90,7 @@
<div class="total-row">
<div class="total-cell">ADET</div>
<div class="total-cell">FOTO</div>
</div>
</div>
</div>
@@ -176,6 +177,24 @@
<q-icon :name="isOpen(grp2.key) ? 'expand_less' : 'expand_more'" size="18px" />
</div>
</div>
<div class="sub-image level2-image">
<q-card flat bordered class="product-image-card">
<q-card-section class="q-pa-xs product-image-wrap">
<q-img
v-if="getProductImageUrl(grp1.productCode, grp2.colorCode)"
:src="getProductImageUrl(grp1.productCode, grp2.colorCode)"
fit="cover"
class="product-image"
loading="lazy"
@error="onProductImageError(grp1.productCode, grp2.colorCode)"
/>
<div v-else class="product-image-placeholder">
<q-icon name="image_not_supported" size="22px" color="grey-6" />
</div>
</q-card-section>
</q-card>
</div>
</div>
<template v-if="isOpen(grp2.key)">
@@ -200,6 +219,7 @@
</div>
<div class="cell adet">{{ formatNumber(row.adet) }}</div>
<div class="cell img-placeholder"></div>
</div>
</div>
</template>
@@ -217,7 +237,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useQuasar } from 'quasar'
import api from 'src/services/api'
import { usePermission } from 'src/composables/usePermission'
@@ -260,6 +280,17 @@ const filters = ref({
const attributeOptions = ref({})
const filteredAttrOptions = ref({})
const rawRows = ref([])
const productImageCache = ref({})
const productImageLoading = ref({})
const productImageListByCode = ref({})
const productImageListLoading = ref({})
const productImageFallbackByKey = ref({})
const productImageContentLoading = ref({})
const productImageBlobUrls = ref([])
const productImageListBlockedUntil = ref(0)
const IMAGE_LIST_CONCURRENCY = 8
let imageListActiveRequests = 0
const imageListWaitQueue = []
const activeSchema = ref(storeSchemaByKey.tak)
const activeGrpKey = ref('tak')
const openState = ref({})
@@ -303,6 +334,172 @@ function parseNumber(value) {
return Number.isFinite(n) ? n : 0
}
function buildImageKey(code, color) {
return `${String(code || '').trim().toUpperCase()}::${String(color || '').trim().toUpperCase()}`
}
function normalizeUploadsPath(storagePath) {
const raw = String(storagePath || '').trim()
if (!raw) return ''
const normalized = raw.replace(/\\/g, '/')
const idx = normalized.toLowerCase().indexOf('/uploads/')
if (idx >= 0) {
return normalized.slice(idx)
}
if (normalized.toLowerCase().startsWith('uploads/')) {
return `/${normalized}`
}
return ''
}
function resolveProductImageUrl(item) {
if (!item || typeof item !== 'object') {
return { contentUrl: '', publicUrl: '' }
}
let contentUrl = ''
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) {
contentUrl = `/api/product-images/${imageId}/content`
} else {
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) contentUrl = contentURL
else if (contentURL.startsWith('/')) contentUrl = `/api${contentURL}`
}
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
let publicUrl = ''
const thumbFolder = (typeof window !== 'undefined' && window.devicePixelRatio > 1.5)
? 't600'
: 't300'
if (uploadsPath) {
// Thumbnail tercih et
if (uploadsPath.includes('/uploads/image/') && !uploadsPath.includes('/uploads/image/t300/') && !uploadsPath.includes('/uploads/image/t600/')) {
publicUrl = uploadsPath.replace('/uploads/image/', `/uploads/image/${thumbFolder}/`)
} else {
publicUrl = uploadsPath
}
} else {
const fileName = String(item.file_name || item.FileName || '').trim()
if (fileName) {
// b2b benzeri yapi: /uploads/image/t300|t600/<uuid>.jpg
publicUrl = `/uploads/image/${thumbFolder}/${fileName}`
}
}
return { contentUrl, publicUrl }
}
function getProductImageUrl(code, color) {
const key = buildImageKey(code, color)
const existing = productImageCache.value[key]
if (existing !== undefined) return existing || ''
void ensureProductImage(code, color)
return ''
}
async function onProductImageError(code, color) {
const key = buildImageKey(code, color)
const current = String(productImageCache.value[key] || '')
const fallback = String(productImageFallbackByKey.value[key] || '')
if (fallback && current !== fallback && !productImageContentLoading.value[key]) {
productImageContentLoading.value[key] = true
try {
const blobRes = await api.get(fallback, {
baseURL: '',
responseType: 'blob'
})
const blob = blobRes?.data
if (blob instanceof Blob) {
const objectUrl = URL.createObjectURL(blob)
productImageBlobUrls.value.push(objectUrl)
productImageCache.value[key] = objectUrl
return
}
} catch {
// no-op
} finally {
delete productImageContentLoading.value[key]
}
}
productImageCache.value[key] = ''
}
async function ensureProductImage(code, color) {
const key = buildImageKey(code, color)
const codeTrim = String(code || '').trim().toUpperCase()
const colorTrim = String(color || '').trim().toUpperCase()
if (!codeTrim) {
productImageCache.value[key] = ''
return ''
}
if (Date.now() < Number(productImageListBlockedUntil.value || 0)) {
productImageCache.value[key] = ''
return ''
}
if (productImageCache.value[key] !== undefined) return productImageCache.value[key] || ''
if (productImageLoading.value[key]) return ''
productImageLoading.value[key] = true
try {
if (!productImageListByCode.value[codeTrim]) {
if (!productImageListLoading.value[codeTrim]) {
productImageListLoading.value[codeTrim] = true
try {
if (imageListActiveRequests >= IMAGE_LIST_CONCURRENCY) {
await new Promise((resolve) => imageListWaitQueue.push(resolve))
}
imageListActiveRequests++
const res = await api.get('/product-images', { params: { code: codeTrim } })
productImageListByCode.value[codeTrim] = Array.isArray(res?.data) ? res.data : []
} catch (err) {
productImageListByCode.value[codeTrim] = []
const status = Number(err?.response?.status || 0)
if (status >= 500 || status === 403 || status === 0) {
// Backend dengesizken istek firtinasini kisaca kes.
productImageListBlockedUntil.value = Date.now() + 30 * 1000
}
console.warn('[ProductStockByAttributes] product image list fetch failed', { code: codeTrim, err })
} finally {
imageListActiveRequests = Math.max(0, imageListActiveRequests - 1)
const nextInQueue = imageListWaitQueue.shift()
if (nextInQueue) nextInQueue()
delete productImageListLoading.value[codeTrim]
}
} else {
// Ayni code icin baska bir istek zaten calisiyorsa tamamlanmasini bekle.
while (productImageListLoading.value[codeTrim]) {
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
}
const list = productImageListByCode.value[codeTrim] || []
let first = null
if (colorTrim) {
const needle = `-${colorTrim.toLowerCase()}-`
first = list.find((item) =>
String(item?.file_name || item?.FileName || '').toLowerCase().includes(needle)
) || null
}
if (!first) first = list[0] || null
const resolved = resolveProductImageUrl(first)
productImageCache.value[key] = resolved.publicUrl || resolved.contentUrl || ''
productImageFallbackByKey.value[key] = resolved.contentUrl || ''
} catch (err) {
console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err })
productImageCache.value[key] = ''
productImageFallbackByKey.value[key] = ''
} finally {
delete productImageLoading.value[key]
}
return productImageCache.value[key] || ''
}
function formatNumber(v) {
return parseNumber(v).toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
}
@@ -574,6 +771,13 @@ async function fetchStockByAttributes() {
activeSchema.value = schemaMap?.[grpKey] || storeSchemaByKey.tak
rawRows.value = list
productImageCache.value = {}
productImageLoading.value = {}
productImageListByCode.value = {}
productImageListLoading.value = {}
productImageFallbackByKey.value = {}
productImageContentLoading.value = {}
productImageListBlockedUntil.value = 0
initOpenState(level1Groups.value)
} catch (err) {
console.error('fetchStockByAttributes error:', err)
@@ -590,8 +794,11 @@ async function fetchStockByAttributes() {
}
}
function onLevel2Click(_productCode, grp2) {
function onLevel2Click(productCode, grp2) {
toggleOpen(grp2.key)
if (isOpen(grp2.key)) {
void ensureProductImage(productCode, grp2.colorCode)
}
}
function resetForm() {
@@ -609,11 +816,26 @@ function resetForm() {
errorMessage.value = ''
openState.value = {}
activeSchema.value = storeSchemaByKey.tak
productImageCache.value = {}
productImageLoading.value = {}
productImageListByCode.value = {}
productImageListLoading.value = {}
productImageFallbackByKey.value = {}
productImageContentLoading.value = {}
productImageListBlockedUntil.value = 0
}
onMounted(() => {
loadAttributeOptions()
})
onUnmounted(() => {
for (const url of productImageBlobUrls.value) {
try { URL.revokeObjectURL(url) } catch {}
}
productImageBlobUrls.value = []
})
</script>
<style scoped>
@@ -622,6 +844,7 @@ onMounted(() => {
--grp-title-w: 44px;
--psq-header-h: 56px;
--psq-col-adet: calc(var(--col-adet) + var(--beden-w));
--psq-col-img: 126px;
--psq-l1-lift: 42px;
}
@@ -634,7 +857,8 @@ onMounted(() => {
var(--col-alt)
var(--col-aciklama)
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w)*var(--beden-count)))
var(--psq-col-adet) !important;
var(--psq-col-adet)
var(--psq-col-img) !important;
}
.order-grid-header .col-fixed,
@@ -672,7 +896,7 @@ onMounted(() => {
}
.order-grid-header .total-row {
grid-column: 7 / -1 !important;
grid-column: 7 / 9 !important;
}
.order-grid-header .total-cell {
@@ -680,7 +904,7 @@ onMounted(() => {
}
.order-grid-header .total-cell:last-child {
width: var(--psq-col-adet) !important;
width: var(--psq-col-img) !important;
}
.order-sub-header {
@@ -691,7 +915,8 @@ onMounted(() => {
var(--col-alt)
var(--col-aciklama)
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w)*var(--beden-count)))
var(--psq-col-adet) !important;
var(--psq-col-adet)
var(--psq-col-img) !important;
top: calc(
var(--header-h)
+ var(--filter-h)
@@ -946,6 +1171,43 @@ onMounted(() => {
border-left: 1px solid #d4c79f;
}
.order-sub-header.level-2 .sub-image.level2-image {
grid-column: 8 / 9;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid #d4c79f;
padding: 0 6px;
background: #fffef7;
}
.product-image-card {
width: 108px;
height: 66px;
border-radius: 8px;
}
.product-image-wrap {
width: 100%;
height: 100%;
}
.product-image {
width: 100%;
height: 100%;
border-radius: 6px;
}
.product-image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 6px;
}
.order-sub-header.level-2 .sub-right .top-total {
grid-column: 1 / 2;
grid-row: 1;
@@ -980,7 +1242,8 @@ onMounted(() => {
var(--col-alt)
var(--col-aciklama)
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w) * var(--beden-count)))
var(--psq-col-adet) !important;
var(--psq-col-adet)
var(--psq-col-img) !important;
min-height: 56px;
height: 56px;
background: #fff;
@@ -1097,6 +1360,12 @@ onMounted(() => {
text-overflow: ellipsis;
}
.order-grid-body .summary-row .cell.img-placeholder {
grid-column: 8 / 9;
border-right: none !important;
background: #fffef7;
}
.order-grid-body {
margin-top: 0 !important;
padding-top: 0 !important;