1. 新增模块品类穿透

This commit is contained in:
2026-05-08 08:27:39 +08:00
parent 9d7ac679a0
commit 1967e97074
7 changed files with 352 additions and 322 deletions

View File

@@ -21,6 +21,8 @@ export interface ExecuteProcedureParams {
* 注意与 axios 的 `params` 配置重名,此处为查询参数字段名
*/
params?: string
/** 存储过程入参 @ztj如 YDY_AI_GET_SPTPQ 正特价维度) */
ztj?: string
}
/** 日报表-当日总销售数据 */

View File

@@ -56,7 +56,9 @@ const styles = computed(() => {
return {
width,
height
height,
/** 嵌套 flex 時避免高度鏈斷裂導致圖表區變矮、模組下半截空白 */
minHeight: height === '100%' ? '100%' : undefined
}
})

View File

@@ -775,7 +775,10 @@ const remainingRouter: AppRouteRecordRaw[] = [
hidden: true,
canTo: true
},
component: () => import('@/views/ydoyun/report/lijun/reportpage6/components/categoryCardListComponents.vue')
component: () =>
import(
'@/views/ydoyun/report/lijun/reportpage6/components/categoryCardListComponents.vue'
)
},
{
path: 'lijun/supplier-ranking',
@@ -786,7 +789,8 @@ const remainingRouter: AppRouteRecordRaw[] = [
hidden: true,
canTo: true
},
component: () => import('@/views/ydoyun/report/lijun/reportpage6/components/SupplierRanking.vue')
component: () =>
import('@/views/ydoyun/report/lijun/reportpage6/components/SupplierRanking.vue')
},
{
path: 'lijun/supplier-performance',
@@ -796,7 +800,8 @@ const remainingRouter: AppRouteRecordRaw[] = [
hidden: true,
canTo: true
},
component: () => import('@/views/ydoyun/report/lijun/reportpage6/components/SupplierPerformancePage.vue')
component: () =>
import('@/views/ydoyun/report/lijun/reportpage6/components/SupplierPerformancePage.vue')
},
{
path: 'lijun/middle-class-ranking',
@@ -807,7 +812,8 @@ const remainingRouter: AppRouteRecordRaw[] = [
hidden: true,
canTo: true
},
component: () => import('@/views/ydoyun/report/lijun/reportpage6/components/MiddleClassRanking.vue')
component: () =>
import('@/views/ydoyun/report/lijun/reportpage6/components/MiddleClassRanking.vue')
},
{
path: 'lijun/product-cards',
@@ -818,7 +824,8 @@ const remainingRouter: AppRouteRecordRaw[] = [
hidden: true,
canTo: true
},
component: () => import('@/views/ydoyun/report/lijun/reportpage6/components/ProductCardsPage.vue')
component: () =>
import('@/views/ydoyun/report/lijun/reportpage6/components/ProductCardsPage.vue')
},
{
path: 'lijun/reportpage6/detail',
@@ -841,6 +848,28 @@ const remainingRouter: AppRouteRecordRaw[] = [
canTo: true
},
component: () => import('@/views/ydoyun/report/wangbuliao/productSplb/index.vue')
},
{
path: 'product-daily/top-wall-more',
name: 'ProductDailyTopWallMore',
meta: {
title: 'TOP图片墙 - 更多',
noCache: true,
hidden: true,
canTo: true
},
component: () => import('@/views/ydoyun/report/productDaily/TopWallMore.vue')
},
{
path: 'sales-daily/category-penetration',
name: 'SalesDailyCategoryPenetration',
meta: {
title: '品类穿透看板',
noCache: true,
hidden: true,
canTo: true
},
component: () => import('@/views/ydoyun/report/salesdaily/CategoryPenetrationBoard.vue')
}
]
},
@@ -860,7 +889,8 @@ const remainingRouter: AppRouteRecordRaw[] = [
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "custom-tag" */ '@/views/ydoyun/customtag/index.vue')
component: () =>
import(/* webpackChunkName: "custom-tag" */ '@/views/ydoyun/customtag/index.vue')
},
{
path: 'product-custom-tag',
@@ -870,7 +900,10 @@ const remainingRouter: AppRouteRecordRaw[] = [
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "product-custom-tag" */ '@/views/ydoyun/productcustomtag/index.vue')
component: () =>
import(
/* webpackChunkName: "product-custom-tag" */ '@/views/ydoyun/productcustomtag/index.vue'
)
},
{
path: 'store-custom-tag',
@@ -880,7 +913,10 @@ const remainingRouter: AppRouteRecordRaw[] = [
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "store-custom-tag" */ '@/views/ydoyun/storecustomtag/index.vue')
component: () =>
import(
/* webpackChunkName: "store-custom-tag" */ '@/views/ydoyun/storecustomtag/index.vue'
)
},
{
path: 'supplier-custom-tag',
@@ -890,7 +926,10 @@ const remainingRouter: AppRouteRecordRaw[] = [
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "supplier-custom-tag" */ '@/views/ydoyun/suppliercustomtag/index.vue')
component: () =>
import(
/* webpackChunkName: "supplier-custom-tag" */ '@/views/ydoyun/suppliercustomtag/index.vue'
)
}
]
}

View File

@@ -207,7 +207,7 @@
<div class="value">{{ rawCellValue(summary.ZTJ ?? summary.ztj ?? summary.zjzb) }}</div>
</div>
<!-- 3 同比业绩同比毛利率库存量 -->
<!-- 3 同比业绩同比毛利率库存量周转天 -->
<div class="stat-box">
<span class="label">同比业绩</span>
<div class="value">{{ formatMoney(summary.TBYJ) }}</div>
@@ -220,6 +220,10 @@
<span class="label">库存量</span>
<div class="value">{{ formatNumber(summary.kcl) }}</div>
</div>
<div class="stat-box">
<span class="label">周转天</span>
<div class="value">{{ formatNumber(summary.zzt ?? summary.ZZT) }}</div>
</div>
</div>
</div>
@@ -230,7 +234,7 @@
>
<div class="card-title trend-title">
<span class="trend-title-main">TOP图片墙</span>
<div class="trend-title-sub trend-inline-filters">
<div class="trend-title-sub trend-inline-filters trend-image-wall-filters">
<el-select
v-model="imageFilter.storeValues"
multiple
@@ -293,6 +297,16 @@
/>
</el-select>
</div>
<el-button
v-if="isProductDailyReport"
link
type="primary"
size="small"
class="trend-more-link"
@click="goTopWallMorePage"
>
更多
</el-button>
</div>
<div
class="trend-chart-wrap trend-product-wrap"
@@ -442,12 +456,33 @@
<el-dialog
v-model="imagePreviewVisible"
title="图片详情"
width="680px"
width="720px"
append-to-body
destroy-on-close
class="image-detail-dialog"
@closed="resetImagePreviewState"
>
<div class="image-preview-wrap">
<img v-if="currentPreviewImage" :src="currentPreviewImage" class="image-preview-main" alt="图片详情" />
<div class="image-preview-body">
<div class="image-preview-wrap">
<img
v-if="currentPreviewImage"
:src="currentPreviewImage"
class="image-preview-main"
alt="商品图片"
/>
</div>
<div v-if="previewImageDetailEntries.length" class="image-preview-detail">
<div class="image-preview-detail-title">全部信息</div>
<el-descriptions :column="2" border size="small" class="image-preview-descriptions">
<el-descriptions-item
v-for="ent in previewImageDetailEntries"
:key="ent.key"
:label="ent.label"
>
<span class="image-preview-detail-value">{{ ent.value }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-dialog>
@@ -457,7 +492,7 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ReportApi, type ExecuteProcedureParams } from '@/api/ydoyun/report/reportpage'
import { useUserStore } from '@/store/modules/user'
import { Icon } from '@/components/Icon'
@@ -470,6 +505,7 @@ import AiPromptEditDialog from '../lijun/reportpage6/components/AiPromptEditDial
defineOptions({ name: 'ProductDaily' })
const route = useRoute()
const router = useRouter()
const reportScene = computed(() => String((route.meta as any)?.query?.scene || '').trim().toLowerCase())
const isProductDailyReport = computed(() => reportScene.value === 'product')
/** 与 setPageModuleCode、AiPromptEditDialog 保存逻辑一致component = moduleKey 冒号前一段 */
@@ -593,6 +629,31 @@ const daleiOptions = ref<Array<{ label: string; value: string }>>([])
const ztjOptions = ref<Array<{ label: string; value: string }>>([])
const zhongleiOptions = ref<Array<{ label: string; value: string }>>([])
/** TOP 明细页 ckdm与图片墙「门店」大图筛选一致cangku 模式用选中门店,否则主表门店) */
const topWallMoreCkdmParam = computed(() => {
if (imageFilter.activeType === 'cangku' && imageFilter.storeValues.length) {
return imageFilter.storeValues.map((v) => String(v).trim()).filter(Boolean).join(',')
}
return ckdmQueryParam.value
})
/** 跳转 TOP图片墙明细携带门店 / 日期 / 品牌 / 正特价 */
function goTopWallMorePage() {
const rqStr = String(queryParams.rq || '').trim()
router.push({
path: '/reports/product-daily/top-wall-more',
query: {
fromProductDaily: '1',
pp: String(queryParams.brandCode || ''),
ckdm: topWallMoreCkdmParam.value,
rq: rqStr,
rq_s: rqStr,
rq_e: rqStr,
ztj: String(imageFilter.ztjValue || '').trim()
}
})
}
const routeSelectOptions = computed(() => {
const seen = new Set<string>()
return userStoreRows.value
@@ -895,11 +956,20 @@ const summary = reactive<any>({})
const categoryRowsRaw = ref<any[]>([])
/** 仓库节点是否展开(默认折叠,点击箭头展开子行) */
const warehouseExpanded = ref<Record<string, boolean>>({})
const productImageRows = ref<Array<{ spdm: string; pic: string }>>([])
/** TOP 图片墙行:含接口完整 raw便于详情弹窗展示全部字段 */
interface ProductImageWallItem {
spdm: string
pic: string
raw: Record<string, any>
}
const productImageRows = ref<ProductImageWallItem[]>([])
const topProductImageRows = computed(() => productImageRows.value.slice(0, 10))
const imageWallLoading = ref(false)
const imagePreviewVisible = ref(false)
const currentPreviewImage = ref('')
/** 图片详情:与 SPRB2 返回行一致,用于列出全部键值 */
const currentPreviewRow = ref<Record<string, any> | null>(null)
function clearReactiveObject(obj: Record<string, any>) {
Object.keys(obj).forEach((k) => delete obj[k])
@@ -997,6 +1067,7 @@ async function loadSummary() {
if (anySum.PJZ == null && anySum.pjz != null) anySum.PJZ = anySum.pjz
if (anySum.zjzb == null && anySum.ZJZB != null) anySum.zjzb = anySum.ZJZB
if (anySum.kcl == null && anySum.KCL != null) anySum.kcl = anySum.KCL
if (anySum.zzt == null && anySum.ZZT != null) anySum.zzt = anySum.ZZT
// 毛利率YDY_AI_GET_SXRB 可能返回 mll / MLL / ML
if (anySum.mll == null && anySum.MLL != null) anySum.mll = anySum.MLL
if (anySum.mll == null && anySum.ML != null) anySum.mll = anySum.ML
@@ -1178,10 +1249,14 @@ async function loadProductImageList() {
p: PROCEDURE_SECRET,
username: username.value
} satisfies ExecuteProcedureParams)
productImageRows.value = extractProcedureList(res).map((row: any) => ({
spdm: String(row.spdm ?? row.SPDM ?? '').trim(),
pic: normalizeWeatherIconUrl(String(row.pic ?? row.PIC ?? '').trim())
}))
productImageRows.value = extractProcedureList(res).map((row: any) => {
const r = row && typeof row === 'object' ? row : {}
return {
spdm: String(r.spdm ?? r.SPDM ?? '').trim(),
pic: normalizeWeatherIconUrl(String(r.pic ?? r.PIC ?? '').trim()),
raw: r as Record<string, any>
}
})
} catch (e) {
console.warn('商品图片查询加载失败:', e)
productImageRows.value = []
@@ -1207,12 +1282,58 @@ function handleImageScopeChange(type: 'cangku' | 'dalei' | 'fjsx4' | 'fjsx1') {
handleImageFilterChange()
}
function openImagePreview(item: { pic: string }) {
function openImagePreview(item: ProductImageWallItem) {
if (!item?.pic) return
currentPreviewImage.value = item.pic
currentPreviewRow.value = item.raw && typeof item.raw === 'object' ? item.raw : {}
imagePreviewVisible.value = true
}
function resetImagePreviewState() {
currentPreviewImage.value = ''
currentPreviewRow.value = null
}
/** 图片详情弹窗:字段中文标签(未知字段仍显示键名) */
function imageDetailFieldLabel(key: string): string {
const k = key.toLowerCase()
const map: Record<string, string> = {
pic: '图片地址',
spdmt: '商品代码(拓展)',
spdm: '商品代码',
spmc: '商品名称',
ppmc: '品牌名称',
sl: '数量',
ckdm: '仓库/门店代码',
ckmc: '仓库/门店名称'
}
return map[k] ?? key
}
function formatImageDetailValue(v: any): string {
if (v == null || v === '') return '—'
if (typeof v === 'object') {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
const s = String(v).trim()
return s === '' ? '—' : s
}
/** 弹窗中按接口原字段顺序展示全部信息 */
const previewImageDetailEntries = computed(() => {
const row = currentPreviewRow.value
if (!row || typeof row !== 'object') return [] as { key: string; label: string; value: string }[]
return Object.keys(row).map((key) => ({
key,
label: imageDetailFieldLabel(key),
value: formatImageDetailValue(row[key])
}))
})
// ========= 字段归一/展示 =========
function toFiniteNumber(v: any): number {
if (v == null || v === '') return NaN
@@ -1930,8 +2051,22 @@ function formatAmountSlashQuantity(v: any): string {
.trend-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
align-items: center;
gap: 6px 10px;
}
/* 中部筛选项占满中间区域,「更多」自然靠最右 */
.trend-image-wall-filters {
flex: 1 1 auto;
min-width: 0;
}
.trend-more-link {
flex-shrink: 0;
margin-left: auto;
padding: 0 2px;
font-size: 13px;
font-weight: 500;
}
.trend-title-main {
@@ -2087,19 +2222,52 @@ function formatAmountSlashQuantity(v: any): string {
font-size: 13px;
}
.image-preview-body {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 85vh;
}
.image-preview-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 260px;
min-height: 200px;
flex-shrink: 0;
}
.image-preview-main {
max-width: 100%;
max-height: 70vh;
max-height: 46vh;
object-fit: contain;
}
.image-preview-detail {
min-height: 0;
max-height: 38vh;
overflow: auto;
}
.image-preview-detail-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.image-preview-descriptions {
:deep(.el-descriptions__label) {
width: 120px;
font-weight: 500;
}
}
.image-preview-detail-value {
word-break: break-all;
white-space: pre-wrap;
}
.trend-chart-inner {
flex: 1;
min-height: 160px;

View File

@@ -256,8 +256,13 @@
element-loading-text="加载中..."
>
<div class="card-title category-title">
<span class="category-title-main">品类全维度分析</span>
<span class="category-title-sub">实收/标准/正价/特价/对比/目标</span>
<div class="category-title-left">
<span class="category-title-main">品类全维度分析</span>
<span class="category-title-sub">实收/标准/正价/特价/对比/目标</span>
</div>
<el-button type="primary" link class="category-image-wall-link" @click="goProductImageWall">
商品图片墙
</el-button>
</div>
<div class="table-scroll">
@@ -293,7 +298,9 @@
<tr v-for="(row, idx) in categoryRows" :key="row.PP || idx">
<td class="sxrb-td-sticky sxrb-col-brand">
<div class="sxrb-brand-cell">
<span class="sxrb-brand-name">{{ row.PP || '-' }}</span>
<el-button link type="primary" class="sxrb-brand-btn" @click="goCategoryPenetration(row)">
<span class="sxrb-brand-name">{{ row.PP || '-' }}</span>
</el-button>
<span class="sxrb-brand-zb">{{ formatCategoryPpzb(row.PPZB) }}</span>
</div>
</td>
@@ -354,7 +361,7 @@ import dayjs from 'dayjs'
import type { EChartsOption } from 'echarts'
import { useCssVar } from '@vueuse/core'
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ReportApi, type ExecuteProcedureParams } from '@/api/ydoyun/report/reportpage'
import { getWeatherAggregate } from '@/api/ydoyun/weather'
import { useAppStore } from '@/store/modules/app'
@@ -374,6 +381,69 @@ const SALES_DAILY_COMPONENT = 'SalesDaily'
const SALES_DAILY_MODULE_KEY = `${SALES_DAILY_COMPONENT}:main`
const route = useRoute()
const router = useRouter()
/** 品类行品牌代号(存储过程 YDY_AI_GET_PLCT1 的 @pp优先 PPDM */
function resolveCategoryRowPpCode(row: Record<string, any>): string {
return String(
row?.PPDM ??
row?.ppdm ??
row?.PPBH ??
row?.ppbh ??
row?.BH ??
row?.bh ??
row?.CODE ??
row?.code ??
row?.PP ??
row?.pp ??
''
).trim()
}
/** 跳转品类全维度穿透看板 */
function goCategoryPenetration(row: Record<string, any>) {
const pp = resolveCategoryRowPpCode(row)
if (!pp) {
ElMessage.warning('该行缺少品牌代号,无法穿透')
return
}
if (!String(queryParams.ckdm || '').trim() || !String(queryParams.rq || '').trim()) {
ElMessage.warning('请先选择门店与日期并完成查询')
return
}
router.push({
path: '/reports/sales-daily/category-penetration',
query: {
rq: String(queryParams.rq).trim(),
ckdm: String(queryParams.ckdm).trim(),
pp,
ppmc: String(row.PP ?? row.pp ?? '').trim()
}
})
}
/** 跳转商品图片墙明细(与商品日报「更多」同源页,携带门店/日期) */
function goProductImageWall() {
if (!String(queryParams.ckdm || '').trim()) {
ElMessage.warning('请先选择门店')
return
}
if (!String(queryParams.rq || '').trim()) {
ElMessage.warning('请先选择日期')
return
}
const rqStr = String(queryParams.rq).trim()
router.push({
path: '/reports/product-daily/top-wall-more',
query: {
fromSalesDaily: '1',
ckdm: String(queryParams.ckdm).trim(),
rq: rqStr,
rq_s: rqStr,
rq_e: rqStr
}
})
}
/** 每日汇报模块名称与后台菜单名称一致meta.title缺省为「销售日报」 */
const aiMenuModuleName = computed(() => {
const t = route.meta?.title
@@ -688,8 +758,18 @@ watch(weatherPrimaryStackRef, () => nextTick(bindWeatherPrimaryStackResizeObserv
flush: 'post'
})
/** 與 onMounted 一致:從 keep-alive 再次進入時重拉報表(避免分頁切回仍為舊數據);首次掛載已由 onMounted 查詢,此處跳過一次 */
let skipSalesDailyActivateRefetchOnce = true
onActivated(() => {
setAiAssistantFabEnabled(true)
if (skipSalesDailyActivateRefetchOnce) {
skipSalesDailyActivateRefetchOnce = false
return
}
if (queryParams.ckdm && queryParams.rq) {
void handleQuery()
}
})
onDeactivated(() => {
@@ -1901,10 +1981,25 @@ const timeSlotBarChartOptions = computed<EChartsOption>(() => {
}
.category-title {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.category-title-left {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
min-width: 0;
}
.category-image-wall-link {
flex-shrink: 0;
font-weight: 600;
}
.category-title-main {
@@ -2028,9 +2123,24 @@ const timeSlotBarChartOptions = computed<EChartsOption>(() => {
min-width: 0;
}
.sxrb-brand-btn {
max-width: 100%;
min-width: 0;
flex: 1 1 auto;
padding: 0 !important;
height: auto !important;
}
.sxrb-brand-btn :deep(.el-button__text) {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
}
.sxrb-brand-name {
font-weight: 500;
color: var(--el-text-color-primary);
color: inherit;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;