1. 新增模块品类穿透

This commit is contained in:
2026-05-11 15:29:08 +08:00
parent 969d86f867
commit 06e76af055
9 changed files with 331 additions and 74 deletions

View File

@@ -5,7 +5,7 @@ VITE_DEV=true
# 请求路径
#VITE_BASE_URL='http://localhost:48080'
VITE_BASE_URL='http://118.253.178.8:48080'
VITE_BASE_URL='http://118.253.178.8:58080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server

View File

@@ -4,7 +4,7 @@ NODE_ENV=development
VITE_DEV=true
# 请求路径
VITE_BASE_URL='http://118.253.178.8:48080'
VITE_BASE_URL='http://118.253.178.8:58080'
#VITE_BASE_URL='http://localhost:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务

View File

@@ -4,7 +4,7 @@ NODE_ENV=production
VITE_DEV=true
# 请求路径
VITE_BASE_URL='http://118.253.178.8:48080'
VITE_BASE_URL='http://118.253.178.8:58080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server

View File

@@ -1,6 +1,7 @@
<template>
<slot></slot>
<!-- 全局右下角悬浮按钮参考 AIRBT.html 样式登录页不展示 -->
<!-- Teleport body避免父級 transform/overflow position:fixed 參考錯容器而被裁切 -->
<Teleport to="body">
<div
v-if="showAssistantFab"
ref="fabRef"
@@ -15,6 +16,7 @@
</div>
<span class="fab-text">AI 决策助手</span>
</div>
</Teleport>
<AiImageChatDialog
v-model="dialogVisible"
@@ -34,6 +36,7 @@ import {
} from './useAiAssistant'
import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
import { routeShouldShowAiAssistantFab } from './aiFabRoute'
defineOptions({ name: 'AiAssistantProvider' })
@@ -212,6 +215,20 @@ function setAiAssistantFabEnabled(enabled: boolean) {
}
const route = useRoute()
/** 依當前路由同步浮層(後台動態路由 name 常與組件 defineOptions 不一致,僅靠業務頁 setFab 易失效) */
watch(
() => route.fullPath,
() => {
if (isHideAssistantFabPath(route.path)) {
fabEnabled.value = false
return
}
fabEnabled.value = routeShouldShowAiAssistantFab(route)
},
{ immediate: true }
)
const showAssistantFab = computed(
() => !isHideAssistantFabPath(route.path) && fabEnabled.value
)

View File

@@ -865,7 +865,6 @@ const remainingRouter: AppRouteRecordRaw[] = [
name: 'SalesDailyCategoryPenetration',
meta: {
title: '品类穿透看板',
noCache: true,
hidden: true,
canTo: true
},

View File

@@ -884,6 +884,7 @@ import { Icon } from '@/components/Icon'
import { useRoute, useRouter } from 'vue-router'
import type { CategoryDiagnosticData } from './components/categoryCardListComponents.vue'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
import { routeKeepsAiAssistantFab } from '@/components/AiAssistant/aiFabRoute'
defineOptions({ name: 'ProductDashboard' })
@@ -2876,7 +2877,11 @@ onActivated(() => {
setAiAssistantFabEnabled(true)
})
onDeactivated(() => {
void nextTick(() => {
if (!routeKeepsAiAssistantFab(router.currentRoute.value)) {
setAiAssistantFabEnabled(false)
}
})
saveDataCache()
})
</script>

View File

@@ -501,6 +501,7 @@ import { useAiModulePromptEditor } from '@/hooks/web/useAiModulePromptEditor'
import { ElMessage } from 'element-plus'
import { AiModulePromptApi } from '@/api/ydoyun/aiModulePrompt'
import AiPromptEditDialog from '../lijun/reportpage6/components/AiPromptEditDialog.vue'
import { routeKeepsAiAssistantFab } from '@/components/AiAssistant/aiFabRoute'
defineOptions({ name: 'ProductDaily' })
@@ -925,7 +926,11 @@ onActivated(() => {
})
onDeactivated(() => {
void nextTick(() => {
if (!routeKeepsAiAssistantFab(router.currentRoute.value)) {
setAiAssistantFabEnabled(false)
}
})
})
onMounted(async () => {

View File

@@ -1,5 +1,5 @@
<template>
<div class="plct-page">
<div ref="plctPageRootRef" class="plct-page">
<div class="plct-toolbar card">
<el-form :model="queryForm" class="plct-query-form" inline @submit.prevent="handleQuery">
<el-form-item label="日期">
@@ -209,15 +209,6 @@
min-width="68"
show-overflow-tooltip
/>
<el-table-column
label="销量"
align="right"
width="64"
label-class-name="plct-jj-hd-sl"
class-name="plct-jj-col-sl"
>
<template #default="{ row }">{{ formatJjInt(row.sl) }}</template>
</el-table-column>
<el-table-column
label="库存"
align="right"
@@ -227,6 +218,15 @@
>
<template #default="{ row }">{{ formatJjInt(row.kc) }}</template>
</el-table-column>
<el-table-column
label="销量"
align="right"
width="64"
label-class-name="plct-jj-hd-sl"
class-name="plct-jj-col-sl"
>
<template #default="{ row }">{{ formatJjInt(row.sl) }}</template>
</el-table-column>
</el-table>
</div>
</template>
@@ -276,14 +276,14 @@
<span class="plct3-size-dot plct3-size-dot--muted" aria-hidden="true"></span>
尺码
</span>
<span class="plct3-size-legend-i">
<span class="plct3-size-dot plct3-size-dot--kc" aria-hidden="true"></span>
库存
</span>
<span class="plct3-size-legend-i">
<span class="plct3-size-dot plct3-size-dot--ls" aria-hidden="true"></span>
零售
</span>
<span class="plct3-size-legend-i">
<span class="plct3-size-dot plct3-size-dot--kc" aria-hidden="true"></span>
库存
</span>
<span class="plct3-size-legend-i">
<span class="plct3-size-dot plct3-size-dot--zt" aria-hidden="true"></span>
状态
@@ -294,8 +294,8 @@
<template #default="{ row }">
<template v-if="col.type === 'sku'">
<div class="plct3-sku-merged">
<div class="plct3-sku-code">{{ plct3FormatCell(row[col.spdmKey]) }}</div>
<div class="plct3-sku-name">{{ plct3FormatCell(row[col.spmcKey]) }}</div>
<div class="plct3-sku-code">{{ plct3FormatCell(row[col.spdmKey]) }}</div>
</div>
</template>
<template v-else-if="col.type === 'sizeSku'">
@@ -319,9 +319,9 @@
</div>
<div class="plct3-size-chip-vals">
<template v-if="t.kc != null || t.ls != null">
<span v-if="t.kc != null" class="plct3-size-kc">{{ t.kc }}</span>
<span v-if="t.kc != null && t.ls != null" class="plct3-size-sep">|</span>
<span v-if="t.ls != null" class="plct3-size-ls">{{ t.ls }}</span>
<span v-if="t.kc != null && t.ls != null" class="plct3-size-sep">|</span>
<span v-if="t.kc != null" class="plct3-size-kc">{{ t.kc }}</span>
</template>
<span v-else class="plct3-size-dash"></span>
</div>
@@ -369,16 +369,42 @@
import dayjs from 'dayjs'
import type { EChartsOption } from 'echarts'
import { useCssVar } from '@vueuse/core'
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
reactive,
ref,
watch
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ReportApi, type ExecuteProcedureParams } from '@/api/ydoyun/report/reportpage'
import { useAppStore } from '@/store/modules/app'
import { useTagsViewStore } from '@/store/modules/tagsView'
import { useUserStore } from '@/store/modules/user'
import Echart from '@/components/Echart/src/Echart.vue'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
import { routeKeepsAiAssistantFab } from '@/components/AiAssistant/aiFabRoute'
import { ElMessage } from 'element-plus'
defineOptions({ name: 'SalesDailyCategoryPenetration' })
/** 與 ProductDashboard 一致SPA 內是否已做過初次初始化;配合 window 快取,不依賴 keep-alive */
const INITIAL_QUERY_FLAG_KEY = '__SalesDailyPlct_initialQueryDone'
const DATA_CACHE_KEY = '__SalesDailyPlct_dataCache'
/** 關閉分頁或刷新時清除下次打開重新完整查詢SPA 內切路由不會觸發 beforeunload */
function clearPlctFlagsOnLeave() {
const win = window as unknown as Record<string, unknown>
try {
delete win[INITIAL_QUERY_FLAG_KEY]
delete win[DATA_CACHE_KEY]
} catch (_) {}
}
const REPORT_ID = 1
const PROCEDURE_SECRET = '123'
const PROC_QX_KEHU = 'YDY_AI_GET_QX_KEHU'
@@ -390,6 +416,27 @@ const PROC_PLCT3 = 'YDY_AI_GET_PLCT3'
const PLCT3_HIDDEN_KEYS = /^(ysdm)$/i
const route = useRoute()
const router = useRouter()
/** AI 決策助手:須在本頁顯式開啟;從銷售日報跳入時上一頁 onDeactivated 會關閉懸浮按鈕,此處需重新打開 */
const PLCT_AI_MODULE_KEY = 'SalesDailyCategoryPenetration:main'
const aiAssistant = useAiAssistant()
const { setAiAssistantFabEnabled } = aiAssistant
const plctPageRootRef = ref<HTMLElement | null>(null)
const aiMenuModuleName = computed(() => {
const t = route.meta?.title
return typeof t === 'string' && t.trim() ? t.trim() : '品类穿透看板'
})
watch(
aiMenuModuleName,
(name) => {
aiAssistant.setPageModuleName(name)
},
{ immediate: true }
)
const userStore = useUserStore()
const appStore = useAppStore()
const username = computed(() => userStore.user.username || '')
@@ -488,6 +535,77 @@ function syncFormFromRoute() {
}
}
/** 路由穿透條件快照:用於與上次成功拉數後對比,避免 keep-alive 切回無條件重查(對齊 ProductDashboard 思路) */
function snapshotRouteQueryKey(): string {
return [
pickRouteQuery(route.query.rq),
pickRouteQuery(route.query.ckdm),
pickRouteQuery(route.query.pp),
pickRouteQuery(route.query.ppmc)
].join('|')
}
/** 最近一次 loadDetail 完成後對應的路由 query 快照(切回分頁時若與當前路由一致則不重查) */
const lastPlctRouteKeyAfterFetch = ref('')
const plctBootstrapDone = ref(false)
function isPlctCacheRestorable(data: unknown): data is Record<string, any> {
if (!data || typeof data !== 'object') return false
const d = data as Record<string, any>
if (String(d.routeQueryKey || '') !== snapshotRouteQueryKey()) return false
return d.queried === true
}
function savePlctDataCache() {
const win = window as unknown as Record<string, any>
try {
win[DATA_CACHE_KEY] = {
routeQueryKey: snapshotRouteQueryKey(),
queried: queried.value,
detail: detail.value ? JSON.parse(JSON.stringify(detail.value)) : null,
plct2Rows: JSON.parse(JSON.stringify(plct2Rows.value)),
plct3Rows: JSON.parse(JSON.stringify(plct3Rows.value)),
plct3SkuSizeLines: JSON.parse(JSON.stringify(plct3SkuSizeLines.value)),
queryForm: JSON.parse(JSON.stringify(queryForm)),
brandOptions: JSON.parse(JSON.stringify(brandOptions.value)),
userStoreRows: JSON.parse(JSON.stringify(userStoreRows.value))
}
} catch (_) {}
}
function restorePlctFromWindowCache(data: Record<string, any>) {
if (Array.isArray(data.userStoreRows)) userStoreRows.value = data.userStoreRows
if (Array.isArray(data.brandOptions)) brandOptions.value = data.brandOptions
if (data.queryForm && typeof data.queryForm === 'object') {
Object.assign(queryForm, data.queryForm)
}
queried.value = !!data.queried
detail.value = data.detail && typeof data.detail === 'object' ? data.detail : null
plct2Rows.value = Array.isArray(data.plct2Rows) ? data.plct2Rows : []
plct3Rows.value = Array.isArray(data.plct3Rows) ? data.plct3Rows : []
plct3SkuSizeLines.value = Array.isArray(data.plct3SkuSizeLines) ? data.plct3SkuSizeLines : []
lastPlctRouteKeyAfterFetch.value = String(data.routeQueryKey || snapshotRouteQueryKey())
}
function maybeRefetchPlctFromRouteIfChanged() {
const cur = snapshotRouteQueryKey()
const rqQ = pickRouteQuery(route.query.rq)
const ckdmQ = pickRouteQuery(route.query.ckdm)
const ppQ = pickRouteQuery(route.query.pp)
const ppmcQ = pickRouteQuery(route.query.ppmc)
if (!rqQ || !ckdmQ || !(ppQ || ppmcQ)) return
if (cur === lastPlctRouteKeyAfterFetch.value) return
syncFormFromRoute()
ensureSelectionsAfterSync()
const rq = String(queryForm.rq || '').trim()
const ckdm = String(queryForm.ckdm || '').trim()
const pp = String(queryForm.pp || '').trim()
if (rq && ckdm && pp) {
void handleQuery()
}
}
function normalizePercent(v: any): number {
const n = toFiniteNumber(v)
if (!Number.isFinite(n)) return NaN as unknown as number
@@ -2084,12 +2202,15 @@ async function loadDetail() {
plct3SkuSizeLines.value = []
} finally {
loading.value = false
if (rq && ckdm && pp) {
lastPlctRouteKeyAfterFetch.value = snapshotRouteQueryKey()
savePlctDataCache()
}
}
}
/** 初次 / 路由同步後:路由帶齊 rq+ckdm+品牌pp 或 ppmc且表單解析自動查詢 */
async function bootstrapPenetrationFromRoute() {
await Promise.all([loadUserStores(), loadBrandOptions()])
/** 同步路由條件到表單;路徑參數齊全且表單解析自動查詢(僅在需要打接口時調用) */
async function syncFormAndAutoQueryIfRouteReady() {
syncFormFromRoute()
ensureSelectionsAfterSync()
@@ -2107,30 +2228,73 @@ async function bootstrapPenetrationFromRoute() {
}
}
/**
* 與 SalesDailyAiReport 一致:從 keep-alive 再次進入時有完整條件則重拉穿透數據。
* 首次掛載已由 onMounted 走 bootstrap此處跳過一次避免雙次請求。
*/
let skipPlctActivateRefetchOnce = true
/** 與 ProductDashboard 相同套路:首次進入完整拉選項+可能自動查;再次進入實例時若有 window 快取且與當前 URL 一致則還原不查 */
onMounted(async () => {
await bootstrapPenetrationFromRoute()
const win = window as unknown as Record<string, any>
const isFirstEnter = !win[INITIAL_QUERY_FLAG_KEY]
window.addEventListener('beforeunload', clearPlctFlagsOnLeave)
if (isFirstEnter) {
await Promise.all([loadUserStores(), loadBrandOptions()])
win[INITIAL_QUERY_FLAG_KEY] = true
await syncFormAndAutoQueryIfRouteReady()
} else {
const dataCache = win[DATA_CACHE_KEY]
if (isPlctCacheRestorable(dataCache)) {
restorePlctFromWindowCache(dataCache)
} else {
await Promise.all([loadUserStores(), loadBrandOptions()])
await syncFormAndAutoQueryIfRouteReady()
}
}
plctBootstrapDone.value = true
setAiAssistantFabEnabled(true)
aiAssistant.setPageModuleCode(PLCT_AI_MODULE_KEY)
await nextTick()
if (plctPageRootRef.value) aiAssistant.setScreenshotTarget(plctPageRootRef.value)
})
onActivated(() => {
if (skipPlctActivateRefetchOnce) {
skipPlctActivateRefetchOnce = false
return
}
syncFormFromRoute()
ensureSelectionsAfterSync()
const rq = String(queryForm.rq || '').trim()
const ckdm = String(queryForm.ckdm || '').trim()
const pp = String(queryForm.pp || '').trim()
if (rq && ckdm && pp) {
void handleQuery()
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', clearPlctFlagsOnLeave)
savePlctDataCache()
setAiAssistantFabEnabled(false)
aiAssistant.setPageModuleName(null)
aiAssistant.setPageModuleCode(null)
aiAssistant.setScreenshotTarget(null)
const tagsViewStore = useTagsViewStore()
if (tagsViewStore.getVisitedViews.length <= 1) {
clearPlctFlagsOnLeave()
}
})
onDeactivated(() => {
savePlctDataCache()
void nextTick(() => {
if (!routeKeepsAiAssistantFab(router.currentRoute.value)) {
setAiAssistantFabEnabled(false)
}
})
})
/** keep-alive 切回:僅當 URL 與上次拉數不一致時再同步並查詢(與 ProductDashboard 的 onActivated 不重查相輔) */
onActivated(() => {
setAiAssistantFabEnabled(true)
void nextTick(() => {
if (plctPageRootRef.value) aiAssistant.setScreenshotTarget(plctPageRootRef.value)
})
maybeRefetchPlctFromRouteIfChanged()
})
watch(
() => snapshotRouteQueryKey(),
(_key, oldKey) => {
if (!plctBootstrapDone.value) return
if (oldKey === undefined || oldKey === _key) return
maybeRefetchPlctFromRouteIfChanged()
}
)
</script>
<style scoped lang="scss">
@@ -2250,6 +2414,9 @@ onActivated(() => {
/** 與左欄「當日指標」外框等高 */
align-items: stretch;
gap: var(--plct-section-gap);
width: 100%;
max-width: 100%;
min-width: 0;
}
.plct-kpi-col,
@@ -2263,6 +2430,8 @@ onActivated(() => {
.plct-charts-col {
overflow: hidden;
max-width: 100%;
min-width: 0;
}
.plct-kpi-col > .plct-upper-pane {
@@ -2510,15 +2679,32 @@ onActivated(() => {
@media (max-width: 1180px) {
.plct-content-split {
flex-direction: column;
align-items: stretch;
}
/**
* 直向堆疊時勿再對兩塊使用 flex:1 1 0 均分父級高度,否則在矮視窗下會把「當日指標」與
* 圖表區壓扁並被 overflow:hidden 裁掉,窄屏看起來像「兩塊都沒了」。
*/
.plct-kpi-col,
.plct-charts-col {
flex: 0 0 auto;
width: 100%;
max-width: 100%;
}
.plct-charts-col {
height: auto;
min-height: 0;
overflow: visible;
}
.plct-charts-shell.plct-upper-pane {
overflow: visible;
}
.plct-kpi-col .plct-upper-pane--kpi {
min-height: 340px;
min-height: 0;
}
.plct-chart-grid {
@@ -2527,6 +2713,18 @@ onActivated(() => {
}
}
/** 平板寬度:四宮格改單欄,避免兩列餅圖擠在過窄寬度內被裁切 */
@media (max-width: 900px) {
.plct-chart-grid {
grid-template-columns: 1fr;
grid-template-rows: repeat(4, minmax(160px, auto));
height: auto;
max-height: none;
min-height: 0;
flex: 0 1 auto;
}
}
@media (max-width: 640px) {
.plct-chart-grid {
grid-template-columns: 1fr;
@@ -2536,6 +2734,43 @@ onActivated(() => {
min-height: 0;
flex: 0 1 auto;
}
.plct-page {
margin-left: 0;
margin-right: 0;
padding-left: 8px;
padding-right: 8px;
}
.plct-field-rq {
width: 100%;
max-width: 100%;
}
.plct-field-ckdm,
.plct-field-pp {
width: 100%;
max-width: 100%;
}
.plct-query-form {
flex: 1 1 100%;
:deep(.el-form--inline .el-form-item) {
display: flex;
flex-wrap: wrap;
margin-right: 0;
}
:deep(.el-form--inline .el-form-item__label) {
flex: 0 0 auto;
}
:deep(.el-form--inline .el-form-item__content) {
flex: 1 1 auto;
min-width: 0;
}
}
}
.plct-bullet-row {

View File

@@ -373,6 +373,7 @@ import { useAiModulePromptEditor } from '@/hooks/web/useAiModulePromptEditor'
import { ElMessage } from 'element-plus'
import { AiModulePromptApi } from '@/api/ydoyun/aiModulePrompt'
import AiPromptEditDialog from '../lijun/reportpage6/components/AiPromptEditDialog.vue'
import { routeKeepsAiAssistantFab } from '@/components/AiAssistant/aiFabRoute'
defineOptions({ name: 'SalesDailyAiReport' })
@@ -758,22 +759,17 @@ watch(weatherPrimaryStackRef, () => nextTick(bindWeatherPrimaryStackResizeObserv
flush: 'post'
})
/** 與 onMounted 一致:keep-alive 再次進入時重拉報表(避免分頁切回仍為舊數據);首次掛載已由 onMounted 查詢,此處跳過一次 */
let skipSalesDailyActivateRefetchOnce = true
/** 與 ProductDashboard 一致keep-alive 切回本頁不重拉接口,由緩存實例保留表單與報表數據 */
onActivated(() => {
setAiAssistantFabEnabled(true)
if (skipSalesDailyActivateRefetchOnce) {
skipSalesDailyActivateRefetchOnce = false
return
}
if (queryParams.ckdm && queryParams.rq) {
void handleQuery()
}
})
onDeactivated(() => {
void nextTick(() => {
if (!routeKeepsAiAssistantFab(router.currentRoute.value)) {
setAiAssistantFabEnabled(false)
}
})
})
onMounted(async () => {