Fix product performance grouped JSON build

This commit is contained in:
M_Kececi
2026-07-04 23:31:45 +03:00
parent 25bc9b2d02
commit d0717900a6
3 changed files with 272 additions and 99 deletions
+30 -16
View File
@@ -25,6 +25,7 @@ type ProductImageItem struct {
UUID string `json:"uuid,omitempty"` UUID string `json:"uuid,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"` ThumbURL string `json:"thumb_url,omitempty"`
FullURL string `json:"full_url,omitempty"` FullURL string `json:"full_url,omitempty"`
StoredInDB bool `json:"stored_in_db,omitempty"`
} }
type ProductImageBatchItem struct { type ProductImageBatchItem struct {
@@ -136,6 +137,24 @@ func extractImageUUID(storagePath, fileName string) string {
return "" return ""
} }
func enrichProductImageItem(it *ProductImageItem) {
if it == nil {
return
}
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
}
if it.StoredInDB {
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
return
}
if resolved, _ := resolveStoragePath(it.Storage); resolved != "" {
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
}
}
// POST /api/product-images/batch // POST /api/product-images/batch
func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc { func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -208,6 +227,7 @@ ranked AS (
COALESCE(b.file_name,'') AS file_name, COALESCE(b.file_name,'') AS file_name,
COALESCE(b.file_size,0) AS file_size, COALESCE(b.file_size,0) AS file_size,
COALESCE(b.storage_path,'') AS storage_path, COALESCE(b.storage_path,'') AS storage_path,
COALESCE(b.stored_in_db,false) AS stored_in_db,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY mi.key PARTITION BY mi.key
ORDER BY ORDER BY
@@ -225,7 +245,7 @@ ranked AS (
AND b.typ='img' AND b.typ='img'
AND b.src_id=mi.mmitem_id AND b.src_id=mi.mmitem_id
) )
SELECT key, id, file_name, file_size, storage_path SELECT key, id, file_name, file_size, storage_path, stored_in_db
FROM ranked FROM ranked
WHERE rn <= 1 WHERE rn <= 1
ORDER BY key, rn ORDER BY key, rn
@@ -241,15 +261,10 @@ ORDER BY key, rn
for rows.Next() { for rows.Next() {
var key string var key string
var it ProductImageItem var it ProductImageItem
if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil { if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage, &it.StoredInDB); err != nil {
continue continue
} }
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) enrichProductImageItem(&it)
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
}
grouped[key] = append(grouped[key], it) grouped[key] = append(grouped[key], it)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -339,7 +354,8 @@ SELECT
id, id,
COALESCE(file_name,'') AS file_name, COALESCE(file_name,'') AS file_name,
COALESCE(file_size,0) AS file_size, COALESCE(file_size,0) AS file_size,
COALESCE(storage_path,'') AS storage_path COALESCE(storage_path,'') AS storage_path,
COALESCE(stored_in_db,false) AS stored_in_db
FROM dfblob FROM dfblob
WHERE typ='img' WHERE typ='img'
AND src_table='mmitem' AND src_table='mmitem'
@@ -370,15 +386,10 @@ ORDER BY
items := make([]ProductImageItem, 0, 16) items := make([]ProductImageItem, 0, 16)
for rows.Next() { for rows.Next() {
var it ProductImageItem var it ProductImageItem
if err := rows.Scan(&it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil { if err := rows.Scan(&it.ID, &it.FileName, &it.FileSize, &it.Storage, &it.StoredInDB); err != nil {
continue continue
} }
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID) enrichProductImageItem(&it)
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
it.UUID = u
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
it.FullURL = "/uploads/image/" + u + ".jpg"
}
items = append(items, it) items = append(items, it)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -529,6 +540,9 @@ WHERE id = $1
} }
resolved, _ := resolveStoragePath(storagePath) resolved, _ := resolveStoragePath(storagePath)
if resolved == "" {
resolved, _ = resolveStoragePath(fileName)
}
if resolved == "" { if resolved == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return
+232 -74
View File
@@ -100,6 +100,10 @@
</button> </button>
</div> </div>
<div ref="topScrollbarRef" class="performance-top-scrollbar q-mb-xs">
<div ref="topScrollbarInnerRef" class="performance-top-scrollbar-inner"></div>
</div>
<q-table <q-table
v-if="activeTab === 'general'" v-if="activeTab === 'general'"
flat flat
@@ -111,7 +115,7 @@
:loading="loading" :loading="loading"
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }" :pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize" :virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
@@ -224,7 +228,7 @@
:pagination="{ rowsPerPage: 0, sortBy: 'order_usd', descending: true }" :pagination="{ rowsPerPage: 0, sortBy: 'order_usd', descending: true }"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize" :virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
@@ -558,7 +562,7 @@
:pagination="pagination" :pagination="pagination"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize" :virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
@@ -714,7 +718,7 @@
:pagination="{ rowsPerPage: 0 }" :pagination="{ rowsPerPage: 0 }"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize" :virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
@@ -1008,7 +1012,7 @@
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }" :pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }"
:sort-method="sortProductGroupedTableRows" :sort-method="sortProductGroupedTableRows"
virtual-scroll virtual-scroll
:virtual-scroll-item-size="64" :virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize" :virtual-scroll-slice-size="tableVirtualSliceSize"
> >
<template #header-cell="props"> <template #header-cell="props">
@@ -1506,7 +1510,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import api from 'src/services/api' import api from 'src/services/api'
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore' import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
@@ -1582,8 +1586,13 @@ const filterBusy = ref(false)
const bulkExpandLoading = ref(false) const bulkExpandLoading = ref(false)
const generalRowsLoaded = ref(false) const generalRowsLoaded = ref(false)
const detailLevelMenuOpen = ref(false) const detailLevelMenuOpen = ref(false)
const topScrollbarRef = ref(null)
const topScrollbarInnerRef = ref(null)
let backendGroupedTimer = null let backendGroupedTimer = null
let imagePrimeActiveRequests = 0 let imagePrimeActiveRequests = 0
let activeTableMiddleEl = null
let topScrollbarResizeObserver = null
let topScrollbarSyncing = false
const columnFilters = reactive({}) const columnFilters = reactive({})
const columnFilterSearch = reactive({}) const columnFilterSearch = reactive({})
@@ -1602,9 +1611,10 @@ const selectedExpandLevelKeysByTab = reactive({
}) })
const productKpiFetchLimit = 50000 const productKpiFetchLimit = 50000
const reportFetchLimit = 50000 const reportFetchLimit = 50000
const maxBulkExpandKeys = 500 const maxBulkExpandKeys = 1000
const maxBackendExpandedKeys = 120 const maxBackendExpandedKeys = 1000
const tableVirtualSliceSize = 160 const tableVirtualSliceSize = 160
const productThumbVirtualItemSize = 236
const maxAutoExpandLevelByTab = { const maxAutoExpandLevelByTab = {
products: 8, products: 8,
product_detail: 4, product_detail: 4,
@@ -2579,10 +2589,10 @@ function maxAutoExpandThroughLevelForTab (tabKey = activeTab.value) {
} }
function maxBackendExpandedKeysForTab (tabKey = activeTab.value) { function maxBackendExpandedKeysForTab (tabKey = activeTab.value) {
if (tabKey === 'product_detail') return 260 if (tabKey === 'product_detail') return 1000
if (tabKey === 'products' || tabKey === 'idle') return 260 if (tabKey === 'products' || tabKey === 'idle') return 1000
if (salesBreakdownTabKeys.includes(tabKey)) return 180 if (salesBreakdownTabKeys.includes(tabKey)) return 1000
return 100 return 1000
} }
const autoExpandThroughLevel = computed(() => { const autoExpandThroughLevel = computed(() => {
@@ -2776,12 +2786,14 @@ function buildGroupedTableRows (tableKey, sourceRows) {
function backendRowsForTab (tabKey, fallbackRows) { function backendRowsForTab (tabKey, fallbackRows) {
const rows = backendGroupedRows.value[tabKey] const rows = backendGroupedRows.value[tabKey]
const backendRows = Array.isArray(rows) && rows.length ? rows : [] const backendRows = Array.isArray(rows) && rows.length ? rows : []
if (hasActiveTableFilters(tabKey)) {
return tabKey === 'product_detail'
? filterRowsBySelectedDetailMainGroup(fallbackRows)
: fallbackRows
}
const sourceRows = tabKey === 'product_detail' const sourceRows = tabKey === 'product_detail'
? filterRowsBySelectedDetailMainGroup(backendRows.length ? backendRows : fallbackRows) ? filterRowsBySelectedDetailMainGroup(backendRows.length ? backendRows : fallbackRows)
: (backendRows.length ? backendRows : fallbackRows) : (backendRows.length ? backendRows : fallbackRows)
if (hasActiveTableFilters(tabKey)) {
return filterRowsForTable(tabKey, sourceRows, columnsForTableKey(tabKey))
}
return sourceRows return sourceRows
} }
@@ -3378,23 +3390,53 @@ function isGroupExpanded (key) {
return expandedGroups.value[key] === true return expandedGroups.value[key] === true
} }
function toggleGroup (key) { function groupKeyDepth (key) {
lastManualExpandedGroupKey.value = key const parts = String(key || '').split('|').filter(Boolean)
expandedGroups.value = { return Math.max(-1, parts.length - 2)
...expandedGroups.value,
[key]: !isGroupExpanded(key)
} }
function activeTabPrefix () {
return `tab:${activeTab.value}|`
}
function expandedLevelKeysThrough (level) {
if (level < 0) return []
const keys = []
collectGroupKeys(keys, activeGroupSourceRows.value, 0, [`tab:${activeTab.value}`], activeGroupLevels.value, level)
return keys
}
function replaceActiveTabExpandedGroups (keys) {
const tabPrefix = activeTabPrefix()
const next = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
)
for (const key of keys) next[key] = true
expandedGroups.value = next
}
function setSelectedExpandLevelsThrough (level) {
activeSelectedExpandLevelKeys.value = level < 0
? []
: activeGroupLevels.value.slice(0, level + 1).map(item => item.key)
}
function applyActiveExpansionThroughLevel (level) {
setSelectedExpandLevelsThrough(level)
replaceActiveTabExpandedGroups(expandedLevelKeysThrough(level))
lastManualExpandedGroupKey.value = ''
}
function toggleGroup (key) {
const level = groupKeyDepth(key)
applyActiveExpansionThroughLevel(isGroupExpanded(key) ? level - 1 : level)
scheduleLoadBackendGroupedRows() scheduleLoadBackendGroupedRows()
} }
async function collapseAllProductGroups () { async function collapseAllProductGroups () {
if (bulkExpandLoading.value) return if (bulkExpandLoading.value) return
detailLevelMenuOpen.value = false detailLevelMenuOpen.value = false
const tabPrefix = `tab:${activeTab.value}|` applyActiveExpansionThroughLevel(-1)
expandedGroups.value = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
)
lastManualExpandedGroupKey.value = ''
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer) if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
bulkExpandLoading.value = true bulkExpandLoading.value = true
try { try {
@@ -3410,12 +3452,7 @@ async function expandSelectedProductGroups () {
const keys = productAutoExpandKeys.value const keys = productAutoExpandKeys.value
const maxKeys = Math.min(maxBulkExpandKeys, maxBackendExpandedKeysForTab(activeTab.value)) const maxKeys = Math.min(maxBulkExpandKeys, maxBackendExpandedKeysForTab(activeTab.value))
const expandableKeys = keys.slice(0, maxKeys) const expandableKeys = keys.slice(0, maxKeys)
const tabPrefix = `tab:${activeTab.value}|` replaceActiveTabExpandedGroups(expandableKeys)
const next = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
)
for (const key of expandableKeys) next[key] = true
expandedGroups.value = next
lastManualExpandedGroupKey.value = '' lastManualExpandedGroupKey.value = ''
if (keys.length > expandableKeys.length) { if (keys.length > expandableKeys.length) {
Notify.create({ Notify.create({
@@ -3590,6 +3627,7 @@ function runWithFilterBusy (apply) {
filterBusy.value = true filterBusy.value = true
const done = () => { const done = () => {
filterBusy.value = false filterBusy.value = false
syncExpansionAfterFilterChange()
} }
const execute = () => { const execute = () => {
apply() apply()
@@ -3608,6 +3646,17 @@ function runWithFilterBusy (apply) {
window.requestAnimationFrame(execute) window.requestAnimationFrame(execute)
} }
function syncExpansionAfterFilterChange () {
if (!backendGroupedSupportedTab(activeTab.value)) return
const level = selectedExpandThroughLevel.value
if (level < 0) {
scheduleLoadBackendGroupedRows()
return
}
applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
scheduleLoadBackendGroupedRows()
}
function filterRowsForTable (tableKey, sourceRows, sourceColumns) { function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
return sourceRows.filter(row => { return sourceRows.filter(row => {
return sourceColumns.every(col => { return sourceColumns.every(col => {
@@ -4044,13 +4093,6 @@ function normalizeUploadsPath (storagePath) {
function resolveProductImageUrl (item) { function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return '' if (!item || typeof item !== 'object') return ''
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim() const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
if (thumbURL) return thumbURL if (thumbURL) return thumbURL
@@ -4061,7 +4103,15 @@ function resolveProductImageUrl (item) {
if (uploadsPath) return uploadsPath if (uploadsPath) return uploadsPath
const fileName = String(item.file_name || item.FileName || '').trim() const fileName = String(item.file_name || item.FileName || '').trim()
return fileName ? `/uploads/image/${fileName}` : '' if (fileName) return `/uploads/image/${fileName}`
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
return ''
} }
async function fetchProductImagesForRow (row) { async function fetchProductImagesForRow (row) {
@@ -4122,6 +4172,15 @@ function getCachedProductImageUrl (row) {
function markProductImageFailed (row) { function markProductImageFailed (row) {
const key = productImageKey(row) const key = productImageKey(row)
if (!key) return if (!key) return
const currentUrl = imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
const list = imageListByKey.value[key] || performanceStore.imageList(key) || []
const nextList = Array.isArray(list) ? list.filter(url => url && url !== currentUrl) : []
if (nextList.length) {
performanceStore.setImageCache(key, nextList)
imageListByKey.value = { ...imageListByKey.value, [key]: nextList }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: nextList[0] || '' }
return
}
imageFailedKeys.value = new Set([...imageFailedKeys.value, key]) imageFailedKeys.value = new Set([...imageFailedKeys.value, key])
performanceStore.setImageCache(key, []) performanceStore.setImageCache(key, [])
imageListByKey.value = { ...imageListByKey.value, [key]: [] } imageListByKey.value = { ...imageListByKey.value, [key]: [] }
@@ -4282,6 +4341,63 @@ function scheduleLoadBackendGroupedRows () {
}, 260) }, 260)
} }
function cleanupTopScrollbarSync () {
if (activeTableMiddleEl) {
activeTableMiddleEl.removeEventListener('scroll', syncTopScrollbarFromTable)
}
if (topScrollbarRef.value) {
topScrollbarRef.value.removeEventListener('scroll', syncTableFromTopScrollbar)
}
if (topScrollbarResizeObserver) {
topScrollbarResizeObserver.disconnect()
topScrollbarResizeObserver = null
}
activeTableMiddleEl = null
}
function updateTopScrollbarSize () {
const outer = topScrollbarRef.value
const inner = topScrollbarInnerRef.value
const tableMiddle = activeTableMiddleEl
if (!outer || !inner || !tableMiddle) return
inner.style.width = `${tableMiddle.scrollWidth}px`
outer.scrollLeft = tableMiddle.scrollLeft
outer.style.display = tableMiddle.scrollWidth > tableMiddle.clientWidth + 2 ? 'block' : 'none'
}
function syncTopScrollbarFromTable () {
if (topScrollbarSyncing || !topScrollbarRef.value || !activeTableMiddleEl) return
topScrollbarSyncing = true
topScrollbarRef.value.scrollLeft = activeTableMiddleEl.scrollLeft
topScrollbarSyncing = false
}
function syncTableFromTopScrollbar () {
if (topScrollbarSyncing || !topScrollbarRef.value || !activeTableMiddleEl) return
topScrollbarSyncing = true
activeTableMiddleEl.scrollLeft = topScrollbarRef.value.scrollLeft
topScrollbarSyncing = false
}
function setupTopScrollbarSync () {
if (typeof document === 'undefined') return
void nextTick(() => {
cleanupTopScrollbarSync()
activeTableMiddleEl = document.querySelector('.product-performance-page .performance-table .q-table__middle')
if (!activeTableMiddleEl || !topScrollbarRef.value) return
activeTableMiddleEl.addEventListener('scroll', syncTopScrollbarFromTable, { passive: true })
topScrollbarRef.value.addEventListener('scroll', syncTableFromTopScrollbar, { passive: true })
if (typeof ResizeObserver !== 'undefined') {
topScrollbarResizeObserver = new ResizeObserver(updateTopScrollbarSize)
topScrollbarResizeObserver.observe(activeTableMiddleEl)
const table = activeTableMiddleEl.querySelector('table')
if (table) topScrollbarResizeObserver.observe(table)
}
updateTopScrollbarSize()
window.setTimeout(updateTopScrollbarSize, 120)
})
}
function switchPerformanceTab (tabName) { function switchPerformanceTab (tabName) {
if (!tabName || tabName === activeTab.value || loading.value) return if (!tabName || tabName === activeTab.value || loading.value) return
backendGroupedLoading.value = true backendGroupedLoading.value = true
@@ -4565,22 +4681,38 @@ function bucketColor (value) {
watch(activeTab, () => { watch(activeTab, () => {
void ensureActiveTabData(false) void ensureActiveTabData(false)
scheduleLoadBackendGroupedRows() scheduleLoadBackendGroupedRows()
setupTopScrollbarSync()
}) })
watch([
displayProductKpiTableRows,
displayIdleTableRows,
displayOrderProductCustomerTableRows,
displayOrderMarketDetailTableRows,
displayActiveSalesBreakdownTableRows,
filteredGeneralRows,
filteredMarketRows,
filteredCountryRows,
filteredCustomerRows
], () => {
setupTopScrollbarSync()
}, { flush: 'post' })
watch(detailMainGroupOptions, () => { watch(detailMainGroupOptions, () => {
ensureDetailMainGroupSelection() ensureDetailMainGroupSelection()
}, { immediate: true }) }, { immediate: true })
watch(selectedDetailMainGroup, () => { watch(selectedDetailMainGroup, () => {
if (activeTab.value === 'product_detail') { if (activeTab.value === 'product_detail') {
expandedGroups.value = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith('tab:product_detail|'))
)
backendGroupedRows.value = { backendGroupedRows.value = {
...backendGroupedRows.value, ...backendGroupedRows.value,
product_detail: [] product_detail: []
} }
void nextTick(() => {
const level = selectedExpandThroughLevel.value
applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1)
scheduleLoadBackendGroupedRows() scheduleLoadBackendGroupedRows()
})
} }
}) })
@@ -4593,7 +4725,15 @@ watch(productTableRows, tableRows => {
void primeProductImages(imageRows) void primeProductImages(imageRows)
}, { immediate: true }) }, { immediate: true })
onMounted(reload) onMounted(() => {
void reload()
setupTopScrollbarSync()
})
onBeforeUnmount(() => {
cleanupTopScrollbarSync()
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
})
</script> </script>
<style scoped> <style scoped>
@@ -4793,11 +4933,29 @@ onMounted(reload)
font-size: 11px; font-size: 11px;
} }
.performance-top-scrollbar {
display: none;
height: 14px;
overflow-x: auto;
overflow-y: hidden;
background: #fff;
border: 1px solid #d5dce5;
border-radius: 4px;
}
.performance-top-scrollbar-inner {
height: 1px;
}
.performance-table :deep(.q-table__middle) { .performance-table :deep(.q-table__middle) {
max-height: calc(100vh - 220px); max-height: calc(100vh - 220px);
overflow: auto; overflow: auto;
} }
.performance-table :deep(.q-table__middle::-webkit-scrollbar:horizontal) {
height: 0;
}
.performance-table :deep(.q-table__bottom) { .performance-table :deep(.q-table__bottom) {
display: none; display: none;
} }
@@ -4916,7 +5074,7 @@ onMounted(reload)
} }
.product-breakdown-table :deep(.q-table) { .product-breakdown-table :deep(.q-table) {
min-width: 3400px; min-width: 3170px;
table-layout: fixed; table-layout: fixed;
} }
@@ -4938,14 +5096,14 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(1)), .product-breakdown-table :deep(.q-table th:nth-child(1)),
.product-breakdown-table :deep(.q-table td:nth-child(1)) { .product-breakdown-table :deep(.q-table td:nth-child(1)) {
left: 0; left: 0;
width: 420px; width: 190px;
min-width: 420px; min-width: 190px;
max-width: 420px; max-width: 190px;
} }
.product-breakdown-table :deep(.q-table th:nth-child(2)), .product-breakdown-table :deep(.q-table th:nth-child(2)),
.product-breakdown-table :deep(.q-table td:nth-child(2)) { .product-breakdown-table :deep(.q-table td:nth-child(2)) {
left: 420px; left: 190px;
width: 140px; width: 140px;
min-width: 140px; min-width: 140px;
max-width: 140px; max-width: 140px;
@@ -4953,7 +5111,7 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(3)), .product-breakdown-table :deep(.q-table th:nth-child(3)),
.product-breakdown-table :deep(.q-table td:nth-child(3)) { .product-breakdown-table :deep(.q-table td:nth-child(3)) {
left: 560px; left: 330px;
width: 110px; width: 110px;
min-width: 110px; min-width: 110px;
max-width: 110px; max-width: 110px;
@@ -4961,7 +5119,7 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(4)), .product-breakdown-table :deep(.q-table th:nth-child(4)),
.product-breakdown-table :deep(.q-table td:nth-child(4)) { .product-breakdown-table :deep(.q-table td:nth-child(4)) {
left: 670px; left: 440px;
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
max-width: 150px; max-width: 150px;
@@ -4969,7 +5127,7 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(5)), .product-breakdown-table :deep(.q-table th:nth-child(5)),
.product-breakdown-table :deep(.q-table td:nth-child(5)) { .product-breakdown-table :deep(.q-table td:nth-child(5)) {
left: 820px; left: 590px;
width: 170px; width: 170px;
min-width: 170px; min-width: 170px;
max-width: 170px; max-width: 170px;
@@ -4977,7 +5135,7 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(6)), .product-breakdown-table :deep(.q-table th:nth-child(6)),
.product-breakdown-table :deep(.q-table td:nth-child(6)) { .product-breakdown-table :deep(.q-table td:nth-child(6)) {
left: 990px; left: 760px;
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
max-width: 150px; max-width: 150px;
@@ -4985,7 +5143,7 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(7)), .product-breakdown-table :deep(.q-table th:nth-child(7)),
.product-breakdown-table :deep(.q-table td:nth-child(7)) { .product-breakdown-table :deep(.q-table td:nth-child(7)) {
left: 1140px; left: 910px;
width: 130px; width: 130px;
min-width: 130px; min-width: 130px;
max-width: 130px; max-width: 130px;
@@ -4993,7 +5151,7 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(8)), .product-breakdown-table :deep(.q-table th:nth-child(8)),
.product-breakdown-table :deep(.q-table td:nth-child(8)) { .product-breakdown-table :deep(.q-table td:nth-child(8)) {
left: 1270px; left: 1040px;
width: 90px; width: 90px;
min-width: 90px; min-width: 90px;
max-width: 90px; max-width: 90px;
@@ -5001,7 +5159,7 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(9)), .product-breakdown-table :deep(.q-table th:nth-child(9)),
.product-breakdown-table :deep(.q-table td:nth-child(9)) { .product-breakdown-table :deep(.q-table td:nth-child(9)) {
left: 1360px; left: 1130px;
width: 80px; width: 80px;
min-width: 80px; min-width: 80px;
max-width: 80px; max-width: 80px;
@@ -5009,7 +5167,7 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(10)), .product-breakdown-table :deep(.q-table th:nth-child(10)),
.product-breakdown-table :deep(.q-table td:nth-child(10)) { .product-breakdown-table :deep(.q-table td:nth-child(10)) {
left: 1440px; left: 1210px;
width: 130px; width: 130px;
min-width: 130px; min-width: 130px;
max-width: 130px; max-width: 130px;
@@ -5017,7 +5175,7 @@ onMounted(reload)
} }
.product-breakdown-table.product-detail-table :deep(.q-table) { .product-breakdown-table.product-detail-table :deep(.q-table) {
min-width: 2850px; min-width: 2620px;
} }
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(n+8)) { .product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(n+8)) {
@@ -5051,14 +5209,14 @@ onMounted(reload)
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(1)), .product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(1)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(1)) { .product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(1)) {
left: 0; left: 0;
width: 420px; width: 190px;
min-width: 420px; min-width: 190px;
max-width: 420px; max-width: 190px;
} }
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(2)), .product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(2)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(2)) { .product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(2)) {
left: 420px; left: 190px;
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
max-width: 150px; max-width: 150px;
@@ -5066,7 +5224,7 @@ onMounted(reload)
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(3)), .product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(3)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(3)) { .product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(3)) {
left: 570px; left: 340px;
width: 150px; width: 150px;
min-width: 150px; min-width: 150px;
max-width: 150px; max-width: 150px;
@@ -5074,7 +5232,7 @@ onMounted(reload)
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(4)), .product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(4)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(4)) { .product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(4)) {
left: 720px; left: 490px;
width: 130px; width: 130px;
min-width: 130px; min-width: 130px;
max-width: 130px; max-width: 130px;
@@ -5082,7 +5240,7 @@ onMounted(reload)
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(5)), .product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(5)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(5)) { .product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(5)) {
left: 850px; left: 620px;
width: 170px; width: 170px;
min-width: 170px; min-width: 170px;
max-width: 170px; max-width: 170px;
@@ -5090,7 +5248,7 @@ onMounted(reload)
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(6)), .product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(6)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(6)) { .product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(6)) {
left: 1020px; left: 790px;
width: 170px; width: 170px;
min-width: 170px; min-width: 170px;
max-width: 190px; max-width: 190px;
@@ -5098,7 +5256,7 @@ onMounted(reload)
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(7)), .product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(7)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(7)) { .product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(7)) {
left: 1190px; left: 960px;
width: 120px; width: 120px;
min-width: 120px; min-width: 120px;
max-width: 120px; max-width: 120px;
@@ -5494,21 +5652,21 @@ onMounted(reload)
} }
.product-image-cell { .product-image-cell {
width: 420px; width: 190px;
min-width: 420px; min-width: 190px;
max-width: 420px; max-width: 190px;
padding: 10px !important; padding: 10px !important;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
} }
.product-thumb-button { .product-thumb-button {
width: 400px; width: 162px;
height: 400px; height: 216px;
border: 1px solid #d9dde3; border: 1px solid #d9dde3;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
padding: 8px; padding: 0;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5518,8 +5676,8 @@ onMounted(reload)
.product-thumb, .product-thumb,
.product-thumb-placeholder { .product-thumb-placeholder {
width: 384px; width: 100%;
height: 384px; height: 100%;
} }
.product-thumb { .product-thumb {
+9 -8
View File
@@ -17,13 +17,6 @@ function normalizeUploadsPath (storagePath) {
function resolveProductImageUrl (item) { function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return '' if (!item || typeof item !== 'object') return ''
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim() const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
if (thumbURL) return thumbURL if (thumbURL) return thumbURL
@@ -34,7 +27,15 @@ function resolveProductImageUrl (item) {
if (uploadsPath) return uploadsPath if (uploadsPath) return uploadsPath
const fileName = String(item.file_name || item.FileName || '').trim() const fileName = String(item.file_name || item.FileName || '').trim()
return fileName ? `/uploads/image/${fileName}` : '' if (fileName) return `/uploads/image/${fileName}`
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
return ''
} }
function normalizedList (value, sort = false) { function normalizedList (value, sort = false) {