Parcourir la source

fix:仓储首页跳转修改

mlchen il y a 1 jour
Parent
commit
d274344ccc

+ 1 - 0
docs/仓储首页看板口径.md

@@ -22,6 +22,7 @@
 - 净趋势=当日前端重算的「入库量 − 出库量」,紫色虚线 `#9B7DF7`,绑右侧第二 Y 轴「单日净差」,与左侧数量轴分开,读数不混用刻度。
 - 连续同号风险:取窗口末尾最近连续同号段(末尾无作业日跳过),`>= 2` 天时提示——连续为正=积压风险(橙)、连续为负=缺货风险(红);0(持平)与中间的无作业日都打断连续段,没有连续段不提示。
 - 点击柱体与无作业日都带该日 `startTime=00:00:00`/`endTime=23:59:59` 打开 V4 出入库动态;无作业日列表为空态,但日期照带。
+- 卡片右上角「查看动态 ›」跳**系统菜单真实路由** `/warehouseManagement/inboundRequests`(出入库申请,独立页签),不是首页内嵌的 ioapp 视图、也不是自建的 V4 动态列表:`HomeOverview.vue` 发 `$emit('navigate', '/warehouseManagement/inboundRequests', { warehouseId })`,由 `dashboard.vue` 的 `@navigate="navigate"` 走 `$router.push`。只带当前仓库,状态/时间/页签全部交给目标页自己的默认值(`search.vue` 的 `defaultWhere.status=''` 即全部状态,默认入库页签);不得带 `dashboard=1`,否则会误开看板的批量收货勾选列。V4 仍由「最近作业动态 › 全部动态」、趋势柱体与无作业日、以及调拨行的「查看单据」进入,入口不丢。
 - 口径实现集中在 `dashboard-utils.js`:`TREND_WINDOW_DAYS`、`trendWindowDates`、`trendQuantity`、`isIdleTrendDay`、`trendNetOf`、`buildTrendWindow`、`trendStreak`、`trendRisk`,由 `scripts/check-warehouse-dashboard.cjs` 静态断言守护。
 
 ## D2 库存分类分布图

Fichier diff supprimé car celui-ci est trop grand
+ 2 - 0
docs/仓储首页重构实施说明.md


+ 133 - 0
scripts/check-warehouse-dashboard.cjs

@@ -1164,3 +1164,136 @@ console.log('E3 最近作业动态断言通过:ACTIVITY_LIMIT=5 与待办条
   assert.equal(state.receiving, false);
   console.log('仓储看板业务断言通过:月份、空值、大整数身份、授权入口、待办范围、分组下钻、收货去重与失败重试、周转/呆滞后端分级映射、呆滞分子分母不可颠倒(3÷70=4.29%)、pp 环比缺失回归、口径条阈值占位符与 180 回落、stagnantDays 命名守卫。');
 })().catch(error => { console.error(error); process.exitCode = 1; });
+
+
+// —— G1 深色主题作用域:只允许作用于看板自身,禁止 body 级全局 class ——
+// 背景:body.wms-dashboard-theme 曾把 .ele-admin-content 下的 Element 组件整片刷成深色,
+// 首页下钻到出库单详情、盘点工单详情等业务页时 URL 带着 dashboard=1,新页签因此整页变深蓝背景。
+const dashboardElementCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-element.scss'), 'utf8'));
+const dashboardVueCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'));
+const routerCode = stripCodeComments(fs.readFileSync(path.join(root, 'src/router/index.js'), 'utf8'));
+assert.doesNotMatch(dashboardElementCode, /body\.[A-Za-z-]+/, 'dashboard-element.scss 不得以 body 级 class 为作用域:它会把下钻后的业务页整页刷成深色');
+assert.match(dashboardElementCode, /\.wms-home\s*(,|\{)/, 'dashboard-element.scss 必须保留 .wms-home 作用域,否则看板自身的深色样式全部失效');
+assert.match(dashboardElementCode, /\.wms-dashboard-dialog/, '看板专用弹层 .wms-dashboard-dialog 必须保留:append-to-body 的弹层只能靠它着色');
+assert.match(dashboardElementCode, /\.wms-dashboard-popper/, '看板专用下拉面板 .wms-dashboard-popper 必须保留:popper 被挂到 body,靠 .wms-home 覆盖不到');
+assert.doesNotMatch(dashboardVueCode, /document\.body\.classList/, 'dashboard.vue 不得再操作 body 全局主题 class:销毁/失活时机与目标页渲染交错,残留即污染下一个页签');
+assert.doesNotMatch(routerCode, /document\.body\.classList\.(toggle|add|remove)/, 'router 不得按 to.query.dashboard 切换 body 主题 class:dashboard=1 会随首页下钻带到业务页');
+const navigateSource = (dashboardVueCode.match(/navigate\(path, query = \{\}\)[^\n]*/) || [''])[0];
+assert.ok(navigateSource, 'dashboard.vue 必须保留下钻方法 navigate');
+assert.doesNotMatch(navigateSource, /dashboard: *'1'/, 'navigate 不得把 dashboard=1 带进业务页 URL:系统原有入口不携带该参数');
+assert.match(navigateSource, /NavigationDuplicated/, 'navigate 必须吞掉 NavigationDuplicated:重复点同一行不该弹错误提示');
+console.log('G1 深色主题作用域断言通过:dashboard-element.scss 不再挂 body 级 class(下钻到出库单详情/盘点工单详情等业务页保持系统白底)、.wms-home/.wms-dashboard-dialog/.wms-dashboard-popper 三个作用域保留、dashboard.vue 与 router 均不再操作 body 主题 class、navigate 不再带 dashboard=1 且吞掉 NavigationDuplicated。');
+
+
+// —— H1 首页默认基本单位:不选单位时 KPI 整片为空,必须默认第一个 ——
+const unitWith = (state) => Object.assign({ defaultUnit: page.methods.defaultUnit }, state);
+const defaultedState = unitWith({ unit: '', filters: {}, options: { defaultUnit: 'PCS', units: [{ unit: '个' }, { unit: 'PCS' }] } });
+page.methods.applyDefaultUnit.call(defaultedState, false);
+assert.equal(defaultedState.unit, 'PCS', 'URL 未带 unit 时必须默认选中后端 home-options 的 defaultUnit');
+assert.equal(defaultedState.filters.unit, 'PCS', '默认单位必须同时写进 filters,否则请求参数里根本没有 unit');
+const explicitEmptyState = unitWith({ unit: '', filters: {}, options: { defaultUnit: 'PCS' } });
+page.methods.applyDefaultUnit.call(explicitEmptyState, true);
+assert.equal(explicitEmptyState.unit, '', 'URL 显式 unit=(用户主动选「全部单位」)时必须保持空,不能被默认值覆盖');
+assert.deepEqual(explicitEmptyState.filters, {}, '显式空单位不得写进 filters');
+const keepState = unitWith({ unit: 'KG', filters: { unit: 'KG' }, options: { defaultUnit: 'PCS' } });
+page.methods.applyDefaultUnit.call(keepState, false);
+assert.equal(keepState.unit, 'KG', '用户已选单位不得被默认值覆盖');
+assert.equal(page.methods.defaultUnit.call({ options: { units: [{ unit: ' ' }, { unit: 'KG' }] } }), 'KG', 'defaultUnit 缺失时必须回退 units 第一个非空单位(空白不算)');
+assert.equal(page.methods.defaultUnit.call({ options: { units: [] } }), '', '没有任何单位时返回空串,不得编造默认值');
+assert.equal(page.methods.defaultUnit.call({ options: { units: [{ unit: '个' }] } }), '个', '未下发 defaultUnit 的老响应按 units[0] 回退');
+const homeUnitSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'));
+assert.match(homeUnitSource, /if \(this\.view === 'home'\) this\.applyDefaultUnit\(/, 'readRoute 必须在首页兜底默认单位(首次进入、切仓、返回首页都走这里)');
+assert.match(homeUnitSource, /this\.applyDefaultUnit\(Object\.prototype\.hasOwnProperty\.call\(this\.\$route\.query, 'unit'\)\)/, 'created 拉到筛选项后必须再兜一次默认单位:第一次 readRoute 时 options 还没到');
+assert.doesNotMatch(homeUnitSource, /warehouseId, unit: ''/, '切换仓库不得清空基本单位:清空后立刻落到「全部单位」,首页又没有数据');
+assert.match(homeUnitSource, /if \(view === 'home' && this\.view !== 'home' && !filters\.unit\) filters\.unit = this\.defaultUnit\(\)/, '从出入库申请/入总库申请返回首页必须补回单位,否则业务视图清掉的空单位会一直留着');
+console.log('H1 首页默认基本单位断言通过:URL 未带 unit 时默认选中 home-options.defaultUnit(缺失回退 units 首个非空单位)并写进请求参数、显式「全部单位」不被覆盖、已选单位不被覆盖、切换仓库与从业务视图返回首页都不再落回「全部单位」。');
+
+
+// —— I1 盘点工单详情下钻:必须带 planId,且不得把首页筛选整包塞进业务页 URL ——
+let pushed = null;
+const navState = {
+  filters: { startTime: '2026-10-01 00:00:00', endTime: '2026-10-31 23:59:59', page: '1', warehouseId: '2103077489545842690', unit: 'KG', categoryId: '2082647086249619457' },
+  $router: { push(target) { pushed = target; return Promise.resolve(); } },
+  $message: { error(message) { throw new Error(message); } }
+};
+// cleanQuery 在 vm 上下文里执行,返回对象与本上下文原型不同,deepEqual 会误报,统一用 JSON 串比对。
+const sameQuery = (actual, expected, message) => assert.equal(JSON.stringify(actual), JSON.stringify(expected), message);
+page.methods.navigate.call(navState, '/warehouseManagement/stocktaking/work/details', { id: '2115696383397093378', planId: '2115696383174795265' });
+sameQuery(pushed.query, { id: '2115696383397093378', planId: '2115696383174795265' },
+  '盘点工单详情 URL 只允许 id 与 planId:首页的 startTime/endTime/page/unit/categoryId/warehouseId 一个都不该带进去');
+assert.equal(pushed.path, '/warehouseManagement/stocktaking/work/details');
+pushed = null;
+page.methods.navigate.call(navState, '/warehouseManagement/outgoingManagement/details', { id: '2115696383397093378', planId: undefined });
+sameQuery(pushed.query, { id: '2115696383397093378' }, '没有 planId 的入口(旧数据/旧链接)不得拼出 planId=undefined');
+
+let businessPush = null;
+const businessState = {
+  navigate(path, query) { businessPush = { path, query }; },
+  open() {}, portal() {}, filters: {},
+  $refs: { actions: { open() {} } },
+  $message: { warning() {}, error() {} }
+};
+businessPush = null;
+page.methods.business.call(businessState, 'todo', { type: 'inventory', id: '2115696383397093378', planId: '2115696383174795265' });
+assert.equal(businessPush.path, '/warehouseManagement/stocktaking/work/details');
+sameQuery(businessPush.query, { id: '2115696383397093378', planId: '2115696383174795265' },
+  '待办「盘点任务进行中」下钻必须同时带 id(planOrderId) 与 planId,否则详情页拿不到计划信息');
+businessPush = null;
+page.methods.business.call(businessState, 'io', { activityType: 'inventory', orderId: '2115696383397093378', planId: '2115696383174795265' });
+assert.equal(businessPush.path, '/warehouseManagement/stocktaking/work/details');
+sameQuery(businessPush.query, { id: '2115696383397093378', planId: '2115696383174795265' },
+  '出入库动态里的盘点流水下钻同样要带 planId');
+const navigateNoFiltersSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8')).match(/navigate\(path, query = \{\}\)[^\n]*/)[0];
+assert.doesNotMatch(navigateNoFiltersSource, /\.\.\.this\.filters/, 'navigate 不得再整包透传首页筛选(this.filters)到业务页 URL');
+console.log('I1 盘点工单详情下钻断言通过:待办/动态两个入口都带 id+planId、planId 缺失时不拼 undefined、下钻 URL 只保留显式参数(首页 startTime/endTime/page/unit/categoryId/warehouseId 不再进业务页)。');
+
+
+
+// —— J1 「出入库动态 › 查看动态」必须跳系统菜单真实路由 warehouseManagement/inboundRequests ——
+// 不是首页内嵌的 ioapp 视图,也不是自建 V4 动态列表:用户要找的是「出入库申请」这张菜单页本身。
+const homeTplSource = fs.readFileSync(path.join(folder, 'components/HomeOverview.vue'), 'utf8');
+const trendLink = (homeTplSource.match(/<a\b[^>]*>查看动态 ›<\/a>/) || [])[0] || '';
+assert.ok(trendLink, '「查看动态 ›」入口必须存在');
+assert.match(trendLink, /\$emit\('navigate', '\/warehouseManagement\/inboundRequests', \{ warehouseId: warehouseId \}\)/,
+  '「查看动态 ›」必须发 navigate(\'/warehouseManagement/inboundRequests\', { warehouseId }):跳菜单真实路由,而不是内嵌 ioapp 视图');
+assert.doesNotMatch(trendLink, /\$emit\('open', 'ioapp'/, '不得再用内嵌 ioapp 视图顶替菜单页');
+assert.doesNotMatch(trendLink, /\$emit\('open', 'io'\)/, '也不得落到自建 V4 动态列表');
+const dashboardTplSource = fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8');
+const homeOverviewTag = (dashboardTplSource.match(/<home-overview\b[^>]*>/) || [])[0] || '';
+assert.match(homeOverviewTag, /@navigate="navigate"/, 'dashboard.vue 必须把 home-overview 的 navigate 事件接到 navigate(),否则点「查看动态」无反应');
+let routePushed = null;
+const routeState = {
+  $route: { path: '/warehouseManagement/index', query: {} },
+  $router: { push(target) { routePushed = target; return Promise.resolve(); } },
+  $message: { error(message) { throw new Error(message); } }
+};
+page.methods.navigate.call(routeState, '/warehouseManagement/inboundRequests', { warehouseId: '2103077489545842690' });
+assert.equal(routePushed.path, '/warehouseManagement/inboundRequests', '必须跳真实菜单路由,路径与系统菜单一致');
+sameQuery(routePushed.query, { warehouseId: '2103077489545842690' }, '只带当前仓库,状态/时间/页签交给目标页默认值');
+assert.ok(!('dashboard' in routePushed.query), '跳菜单页不得再带 dashboard=1:会误开看板批量收货勾选列');
+routePushed = null;
+page.methods.navigate.call(routeState, '/warehouseManagement/inboundRequests', { warehouseId: '' });
+sameQuery(routePushed.query, {}, '未选仓库(全部授权仓库)时 query 必须为空,不能塞空串或首页筛选');
+const allLink = (homeTplSource.match(/<a\b[^>]*>全部动态 ›<\/a>/) || [])[0] || '';
+assert.match(allLink, /\$emit\('open', 'io'\)/, '「全部动态 ›」仍落 V4 动态列表,不被本次改动带走');
+console.log('J1 出入库动态入口断言通过:「查看动态 ›」发 navigate(\'/warehouseManagement/inboundRequests\', { warehouseId }),dashboard.vue 已接 @navigate,跳真实菜单路由(独立页签)、只带仓库、不带 dashboard=1;「全部动态 ›」与趋势柱体仍落 V4 动态列表。');
+
+
+// —— K1 首页首屏遮罩:数据回来之前不显示内容,且只在首屏出现一次 ——
+const dashSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'));
+const maskTag = (dashSource.match(/<div\b[^>]*class="home-loading"[^>]*>/) || [])[0] || '';
+assert.ok(maskTag, 'dashboard.vue 必须有首屏遮罩节点(.home-loading)');
+assert.match(maskTag, /v-if="homeLoading && view === 'home'"/, '遮罩只能在首页视图且 homeLoading 为真时出现:明细视图有自己的 v-loading,不能被这块盖住');
+assert.ok(dashSource.includes('正在加载首页数据'), '遮罩必须给出中文加载文案,不能只放一个转圈');
+assert.equal(page.data().homeLoading, true, 'homeLoading 必须默认 true:首屏还没请求就不能先渲染空卡片');
+const createdBlock = dashSource.slice(dashSource.indexOf('async created()'), dashSource.indexOf('activated()'));
+assert.match(createdBlock, /this\.homeLoading = false;/, 'created 里首屏(筛选项 + 汇总)结束后必须撤掉遮罩');
+assert.ok(createdBlock.indexOf('this.homeLoading = false') > createdBlock.indexOf('await this.refresh()'), '必须先 await refresh() 再撤遮罩,否则数据还没到就露出来了');
+assert.ok((dashSource.match(/this\.homeLoading = false/g) || []).length === 1, 'homeLoading 只能在首屏置一次 false:定时刷新与筛选项切换不该闪遮罩');
+const layoutScss = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-layout.scss'), 'utf8'));
+assert.match(layoutScss, /\.wms-home\s*\{[^}]*position:\s*relative/, '.wms-home 必须是定位上下文,否则遮罩会跑去页面别处');
+assert.match(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*position:\s*absolute/, '遮罩必须绝对定位在内容区内');
+assert.doesNotMatch(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*position:\s*fixed/, '遮罩不得用 fixed:会把系统页头、菜单、标签栏一起盖掉');
+assert.match(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*z-index:\s*\d+/, '遮罩必须显式抬层级,否则盖不住卡片');
+assert.match(layoutScss, /@keyframes\s+home-loading-spin\s*\{[\s\S]{0,120}?rotate\(360deg\)/, '转圈动画必须存在,否则 spinner 静态不转');
+console.log('K1 首页首屏遮罩断言通过:homeLoading 默认 true、created 中 await refresh() 之后置 false(且只置一次),遮罩仅在首页视图出现并带中文文案;样式为 .wms-home 内的 absolute 遮罩(非 fixed,不盖系统页头/菜单)+ z-index + 转圈关键帧。');

+ 2 - 1
src/router/index.js

@@ -73,7 +73,8 @@ router.beforeEach((to, from, next) => {
 });
 
 router.afterEach((to) => {
-  document.body.classList.toggle('wms-dashboard-theme', to.query.dashboard === '1');
+  // 深色主题不再按 to.query.dashboard 切换 body class:该参数会随首页下钻带到业务页,
+  // 让出库单详情、盘点工单详情等页签整体变深色。看板深色只由 .wms-home 自己承担。
   if (!to.path.includes(REDIRECT_PATH) && NProgress.isStarted()) {
     setTimeout(() => {
       NProgress.done(true);

+ 5 - 1
src/views/warehouseManagement/index/components/HomeOverview.vue

@@ -36,7 +36,11 @@
                 <div style="display:flex;align-items:center;gap:8px">
                   <!-- 连续同号风险:连续为正=积压风险(橙)、连续为负=缺货风险(红);不足 2 天不渲染。 -->
                   <span v-if="trendRiskHint" class="tag" :class="trendRiskHint.tone === 'danger' ? 'r' : 'o'" :title="trendRiskHint.title">{{ trendRiskHint.text }}</span>
-                  <a class="link" href="#" @click.prevent="$emit('open', 'io')">查看动态 ›</a>
+                  <!-- 「查看动态」跳系统菜单「出入库申请」的真实路由(warehouseManagement/inboundRequests,独立页签),
+                       不是首页内嵌的 ioapp 视图,也不是自建的 V4 动态列表:用户要找的是单据本身。
+                       只带当前仓库;状态/时间/页签全部交给目标页自己的默认值(defaultWhere.status='' 即全部状态)。
+                       V4 列表仍由「最近作业动态 › 全部动态」和趋势柱体点击进入,入口不丢。 -->
+                  <a class="link" href="#" @click.prevent="$emit('navigate', '/warehouseManagement/inboundRequests', { warehouseId: warehouseId })">查看动态 ›</a>
                 </div>
               </div>
               <div class="chart-body">

+ 3 - 2
src/views/warehouseManagement/index/dashboard-element.scss

@@ -1,5 +1,6 @@
-/* 深色样式仅覆盖看板内容和专用弹层,保留系统页头、菜单及其下拉框原样。 */
-body.wms-dashboard-theme .ele-admin-content,
+/* 深色样式只覆盖看板自身容器(.wms-home)和看板专用弹层,保留系统页头、菜单及其下拉框原样。
+   不再挂在 body.wms-dashboard-theme 上:从首页下钻到出库单详情、盘点工单详情等业务页时该 class 若残留,
+   会把新页签里的整页 Element 组件(卡片/表格/输入框)刷成深色 #0A1A30,看着就是「蓝色背景」。 */
 .wms-home,
 .wms-dashboard-dialog,
 .wms-dashboard-popper {

+ 18 - 0
src/views/warehouseManagement/index/dashboard-layout.scss

@@ -1,5 +1,7 @@
 .wms-home {
   width: 100%; max-width: 100%; overflow: auto;
+  /* 首屏遮罩用绝对定位盖住内容区,所以这里必须是定位上下文;不能改成 fixed——那会连系统页头、菜单一起盖掉。 */
+  position: relative;
   button:disabled { opacity: .45; cursor: not-allowed; }
   select option { background: #0A1830; color: #EAF4FF; }
   .data-note,.empty { padding: 18px; color: var(--t2); font-size: 13px; }
@@ -26,4 +28,20 @@
   .dashboard-business .el-table .cell { padding: 0 10px; }
   .dashboard-business .el-form-item__label { padding-right: 12px; }
 }
+/* 首屏加载遮罩:与看板底色同色,盖住「空卡片逐步填充」的中间态,加载完由 dashboard.vue 的 homeLoading 撤掉。
+   尺寸只跟随内容区,不盖系统页头/菜单/标签栏;层级低于弹层(DashboardActions 走 element 的 z-index 2000+)。 */
+.wms-home .home-loading {
+  position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 20;
+  display: flex; align-items: center; justify-content: center; gap: 10px;
+  background: radial-gradient(ellipse at 50% 0%, #123258 0%, #0A1830 52%, #060F1F 100%);
+  color: #EAF4FF; font-size: 13px; letter-spacing: .5px;
+  /* 首屏内容为空时 .wms-home 的高度只靠 min-height:100% 撑,给遮罩一个下限,避免极端情况下压成一条线。 */
+  min-height: 320px;
+}
+.wms-home .home-loading__spinner {
+  width: 18px; height: 18px; border-radius: 50%;
+  border: 2px solid rgba(56,200,245,.25); border-top-color: #38C8F5;
+  animation: home-loading-spin .9s linear infinite;
+}
+@keyframes home-loading-spin { to { transform: rotate(360deg); } }
 @media (max-width: 1000px) { .wms-home { min-width: 900px; } }

+ 55 - 10
src/views/warehouseManagement/index/dashboard.vue

@@ -1,11 +1,18 @@
 <template>
   <div class="wms-home">
     <main class="main">
-        <home-overview v-if="view === 'home'" :summary="summary" :options="options" :warehouse-id="warehouseId" :unit="unit" :updated-at="updatedAt" :error="error" @open="open" @refresh="refresh" @business="business" @warehouse="setWarehouse" @unit="setUnit" />
+        <home-overview v-if="view === 'home'" :summary="summary" :options="options" :warehouse-id="warehouseId" :unit="unit" :updated-at="updatedAt" :error="error" @open="open" @refresh="refresh" @business="business" @warehouse="setWarehouse" @unit="setUnit" @navigate="navigate" />
         <section v-else-if="businessComponent" class="view active dashboard-business"><nav class="crumb"><a href="#" @click.prevent="open('home')">‹ 返回首页</a><span>仓储管理系统</span> / <b>{{ businessTitle }}</b></nav><div class="drill-head"><h2>{{ businessTitle }}</h2></div><div v-if="receiveQueue.length" class="receive-progress">批量收货:正在核对第 {{ preparedReceipts.length + 1 }} / {{ receiveQueue.length }} 单。请核对仓库、产品明细及包装后确认。<el-button @click="cancelReceive">取消批量收货</el-button></div><div v-if="!receiveQueue.length && preparedReceipts.length" class="receive-progress">有 {{ preparedReceipts.length }} 单待重试。<el-button :loading="receiving" @click="submitReceipts">重试收货</el-button></div><component :is="businessComponent" :key="view + ($route.query.inId || '')" :batch-prepare="receiveQueue.length > 0" @batch-receive="startReceive" @prepared="prepareReceipt" @cancelled="cancelReceive" /></section>
         <dashboard-detail v-else :key="view" :view="view" :filters="filters" :options="options" :result="result" :summary="summary" :loading="loading" :error="error" :exporting="exporting" :stagnant-days="stagnantDays" @back="open('home')" @navigate="open" @search="search" @retry="loadDetail" @export="exportRows" @business="business" @batch="batchApprove" />
     </main>
     <dashboard-actions ref="actions" :options="options" :filters="filters" :alert-rows="alertRows" @success="refresh" @business="business" />
+    <!-- 首屏遮罩:看板要等 home-options + home-summary 都回来才有数字,之前是空卡片逐步填充,
+         用户看到的是「没有数据」。这里盖住内容,加载完再显示;失败也会撤掉遮罩,露出 HomeOverview 的错误条与「重试」。
+         只管首屏一次,不是每次 refresh 都盖(15 分钟定时刷新和筛选项切换不该闪遮罩)。 -->
+    <div v-if="homeLoading && view === 'home'" class="home-loading" role="status" aria-live="polite">
+      <i class="home-loading__spinner" aria-hidden="true"></i>
+      <span>正在加载首页数据…</span>
+    </div>
   </div>
 </template>
 <script>
@@ -27,7 +34,7 @@ export default {
   name: 'WmsHome',
   components: { HomeOverview, DashboardDetail, DashboardActions },
   data() { return {
-    view: 'home', warehouseId: '', unit: '', filters: {}, options: {}, summary: {}, result: {}, updatedAt: '', error: '', loading: false, exporting: false, portalRoots: [], optionsLoaded: false, receiveQueue: [], preparedReceipts: [], receiving: false
+    view: 'home', warehouseId: '', unit: '', filters: {}, options: {}, summary: {}, result: {}, updatedAt: '', error: '', loading: false, exporting: false, portalRoots: [], optionsLoaded: false, receiveQueue: [], preparedReceipts: [], receiving: false, homeLoading: true
   }; },
   computed: {
     // 呆滞阈值向下透传:优先用首页已请求到的同一份 home-summary.stagnantDays(不额外发请求),
@@ -56,15 +63,21 @@ export default {
     this.readRoute();
     try {
       this.options = await homeOptions({ warehouseId: this.warehouseId }); this.optionsLoaded = true;
+      // 默认单位来自筛选项(后缀 defaultUnit),第一条 readRoute 时 options 还没到,这里必须再兜一次。
+      this.applyDefaultUnit(Object.prototype.hasOwnProperty.call(this.$route.query, 'unit'));
       this.filters = { ...this.filters, unit: this.unit };
       await this.refresh();
     } catch (e) { this.error = e.message || '筛选项加载失败,请刷新重试'; }
+    // 首屏数据(筛选项 + 汇总)已经落定或失败,撤掉遮罩;失败时由 HomeOverview 的错误条 + 重试接手。
+    this.homeLoading = false;
     this.startTimers();
   },
-  mounted() { document.body.classList.add('wms-dashboard-theme'); },
-  activated() { const resumed = !this.active; this.active = true; document.body.classList.add('wms-dashboard-theme'); this.startTimers(); if (resumed) this.refresh(); },
+  // 深色主题只由页面自身的 .wms-home 承担(见 dashboard-element.scss),不再往 body 挂全局 class。
+  // 原来的 body class 依赖路由销毁时机与 $route.query.dashboard 判断,而下钻目标页同样带着 dashboard=1,
+  // 一旦判断落空就把深色主题留给了下一个页签,业务页因此变成深蓝背景。
+  activated() { const resumed = !this.active; this.active = true; this.startTimers(); if (resumed) this.refresh(); },
   deactivated() { this.stop(); },
-  beforeDestroy() { this.stop(); if (this.$route.query.dashboard !== '1') document.body.classList.remove('wms-dashboard-theme'); },
+  beforeDestroy() { this.stop(); },
   methods: {
     readRoute() {
       const query = this.$route.query;
@@ -73,10 +86,29 @@ export default {
       this.filters = filterKeys.reduce((r,key) => { if (query[key] != null) r[key] = query[key]; return r; }, { ...monthRange(), page: 1 });
       if (this.view === 'todo' && !this.filters.type) this.filters.type = 'inventory';
       this.warehouseId = this.filters.warehouseId || ''; this.unit = this.filters.unit || '';
+      if (this.view === 'home') this.applyDefaultUnit(Object.prototype.hasOwnProperty.call(query, 'unit'));
+    },
+    // 首页默认基本单位:多基本单位下不选单位,库存总量/周转天数/库龄/分类分布会整片为空,
+    // 用户进首页看到的就是「没有数据」。优先用后端 home-options 的 defaultUnit(已按授权仓库算好),
+    // 缺失时回退 units 里的第一个非空单位。
+    defaultUnit() {
+      const options = this.options || {};
+      if (options.defaultUnit) return String(options.defaultUnit);
+      const rows = Array.isArray(options.units) ? options.units : [];
+      const first = rows.find((row) => row && row.unit != null && String(row.unit).trim() !== '');
+      return first ? String(first.unit) : '';
+    },
+    // 只在用户没表态时兜底:URL 里没有 unit 参数才算没表态,显式 unit=(全部单位)保持不动。
+    applyDefaultUnit(explicit) {
+      if (explicit || this.unit) return;
+      const unit = this.defaultUnit();
+      if (!unit) return;
+      this.unit = unit;
+      this.filters = { ...this.filters, unit };
     },
     route(view, filters, replace = false) {
       const query = cleanQuery({ ...filters, unit: filters.unit || '', dashboard: '1', dashboardView: view });
-      // 显式保留空单位,返回时不再自动选入默认单位。
+      // 单位始终写进 query:空值也要显式落成 unit=,代表用户主动选了「全部单位」,下钻/返回时不能被默认单位覆盖。
       query.unit = filters.unit || '';
       if (this.$route.query.dashboardView === view && JSON.stringify(query) === JSON.stringify(this.$route.query)) return;
       this.$router[replace ? 'replace' : 'push']({ path: this.$route.path, query }).catch((e) => { if (e.name !== 'NavigationDuplicated') this.$message.error(e.message); });
@@ -96,11 +128,16 @@ export default {
       if (view === 'ioapp' && !filters.direction) filters.direction = 'in';
       if (['ioapp','inapp'].includes(view)) {
         filters.status = Object.prototype.hasOwnProperty.call(extra,'status') ? extra.status : '0';
+        // 出入库申请/入总库申请按单位过滤会把申请单筛空,这两个视图不继承首页单位。
         delete filters.categoryId; filters.unit = ''; Object.assign(filters,monthRange());
       }
+      // 回到首页时把单位补回来:上面的业务视图清了 unit,不补就会落到「全部单位」,KPI 又变成没有数据。
+      if (view === 'home' && this.view !== 'home' && !filters.unit) filters.unit = this.defaultUnit();
       this.route(view, filters);
     },
-    setWarehouse(warehouseId) { this.route('home', { ...this.filters, warehouseId, unit: '', page: 1 }); },
+    // 切换仓库不再清空基本单位:单位列表是「全部授权仓库」口径,与单仓无关;
+    // 清空后落到「全部单位」会立刻变成没有数据,正是要避免的情况。
+    setWarehouse(warehouseId) { this.route('home', { ...this.filters, warehouseId, page: 1 }); },
     setUnit(unit) { this.route('home', { ...this.filters, unit, page: 1 }); },
     search(filters) { this.route(this.view, filters); },
     parameters(extra = {}) { return cleanQuery({ ...this.filters, ...extra }); },
@@ -181,7 +218,13 @@ export default {
         const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook,XLSX.utils.json_to_sheet(output),'明细'); XLSX.writeFile(workbook,screens[view].title + '-' + new Date().toISOString().slice(0,10) + '.xlsx');
       } catch (e) { this.$message.error(e.message || '导出失败,请重试'); } finally { this.exporting = false; }
     },
-    navigate(path, query = {}) { this.$router.push({ path, query: cleanQuery({ ...this.filters, ...query, dashboard:'1' }) }); },
+    // 下钻到系统原有业务页(详情、批次)时只带目标页真正需要的参数:
+    // 不再带 dashboard=1(该参数只服务看板自身的内部视图:tgTable 批量收货勾选、warehouseApplication 批量审批),
+    // 也不再整包透传首页筛选(startTime/endTime/page/unit/categoryId/warehouseId 详情页一个都不认,
+    // 只会把 URL 撑长、让人误以为详情被筛过)。unknown/null 参数由 cleanQuery 丢弃,缺 planId 的旧链接
+    // 仍由 PlanServiceImpl#getOrderInfoByPlanId 反查兼容。
+    // 同时吞掉 NavigationDuplicated:重复点同一行不该弹错误提示。
+    navigate(path, query = {}) { this.$router.push({ path, query: cleanQuery({ ...query }) }).catch((e) => { if (e.name !== 'NavigationDuplicated') this.$message.error(e.message); }); },
     async portal(predicate, query) {
       try {
         if (!this.portalRoots.length) { const user = getCurrentUser(); this.portalRoots = await getResourcesTree({ groupId:user.currentGroupId,roleId:user.currentRoleId }); }
@@ -211,10 +254,12 @@ export default {
         return this.portal((n) => /purchasingManage\/purchaseNeedManage/.test((n.component || '') + (n.path || '')), { requirementId, requirementCode:row.purchaseRequestCode || row.requirementCode, code:row.purchaseRequestCode, dashboard:'1' });
       }
       if (kind === 'qc') return this.portal((n) => /inspectionWork|检验工作台|质检报告/.test((n.component || '') + (n.title || n.name || '')), { stockBatchId:row.stockBatchId || row.id,systemBatchNo:row.systemBatchNo,warehouseId:row.warehouseId,dashboard:'1' });
-      if (kind === 'io' && row.activityType === 'inventory') return this.navigate('/warehouseManagement/stocktaking/work/details',{ id:row.orderId });
+      // 盘点工单详情同时要 planOrderId(=id) 与 planId:只带 id 时详情页拿不到计划信息(服务端虽能由工单反查,
+      // 但 URL 与列表页入口不一致,排查时两套地址对不上)。planId 由首页 detail/recentActivities 的查询列下发。
+      if (kind === 'io' && row.activityType === 'inventory') return this.navigate('/warehouseManagement/stocktaking/work/details',{ id:row.orderId, planId:row.planId });
       if (kind === 'io' && row.activityType === 'transfer') return this.open('io',{ warehouseId:row.warehouseId,keyword:row.orderNo });
       if (kind === 'io') return row.orderId ? this.navigate(String(row.direction) === '1' ? '/warehouseManagement/stockManagement/details' : '/warehouseManagement/outgoingManagement/details',{ id:row.orderId }) : this.$message.warning('该流水缺少来源单据身份,请先核实');
-      if (kind === 'todo') return row.type === 'inventory' ? this.navigate('/warehouseManagement/stocktaking/work/details',{ id:row.id }) : this.open(row.type === 'central' ? 'inapp' : 'ioapp',{ direction:row.type === 'out' ? 'out' : 'in',keyword:row.orderNo });
+      if (kind === 'todo') return row.type === 'inventory' ? this.navigate('/warehouseManagement/stocktaking/work/details',{ id:row.id, planId:row.planId }) : this.open(row.type === 'central' ? 'inapp' : 'ioapp',{ direction:row.type === 'out' ? 'out' : 'in',keyword:row.orderNo });
       if (kind === 'turnover') return this.open('stockLedger');
       if (['batch','age'].includes(kind)) return this.navigate('/warehouseManagement/stockLedger/batchDetails',{ id:row.categoryId,batchNo:row.batchNo,systemBatchNo:row.systemBatchNo,stockBatchId:row.stockBatchId || row.id,warehouseId:row.warehouseId });
       if (kind === 'disposal-document') return this.navigate(row.documentType === 'ALLOT' ? '/warehouseManagement/inventoryAllocation/details' : '/warehouseManagement/outgoingManagement/details',{ id:row.documentId });

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff