瀏覽代碼

feat(aidop): 首页九宫格 S1-S9(除 S8) 统一 S4 样式 + 建模页通用化打通结构化公式 + bump 2.4.91/1.0.58

本次对应"指标模型动态配置方案 批次 C2":把 S4 的首页 KPI 呈现与建模配置闭环扩展到 S1-S7/S9。

前端变更
- 新增 Web/src/views/dashboard/components/HomeModuleKpiCard.vue
  * 首页卡片:统一采用 S4 风格的 KPI 格子(label/value/单位/目标/达成箭头+色调/悬浮公式),按 moduleCode 通用渲染。
  * 数据源:/api/AidopKanban/home-grid/{moduleCode}(批 C1 的通用端点)。
  * 刷新:监听通用 AIDOP_LAYOUT_SAVED 事件(模块级精准刷新),并兼容旧的 AIDOP_S4_LAYOUT_SAVED。
  * 空配置占位文案:引导至"运营指标建模"。
- Web/src/views/dashboard/home.vue
  * 以 HomeModuleKpiCard 批量替换 S1/S2/S3/S4/S5/S6/S7/S9 原各自 section 与模块级 loader/mapper;S8(异常协同)保留原有自定义 section。
  * 清理 S1~S7/S9 专属 CSS(模板不再引用,保留容器/栅格布局与 S8 专属样式)。
  * 顶部筛选参数(日期/产品/订单号/产线)通过 kpiExtraParams 透传到每个卡片;查询/重置仅驱动 S8 数据重载。
- Web/src/views/aidop/kanban/s0.vue("运营指标建模"页)
  * Drawer 通用化:MODELABLE_MODULES = [S1,S2,S3,S4,S5,S6,S7,S9];点击图谱中任一建模模块节点均进入同一配置面板。
  * API 切换到批 C1 的通用端点:fetchMetricCatalog / fetchOperationLayout / saveOperationLayout / notifyLayoutSaved。
  * RowId 动态拼接 `${mc}-L1-${metricCode}` / `${mc}-L2-${metricCode}`;标题与文案按 currentModuleCode 动态展示。
  * 公式回填策略:LayoutItem.FormulaText 为空时使用 KpiMaster.FormulaExpr 作为默认(后端为事实源),租户在此可覆写。
  * 4 处与计算公式相关的 textarea 改为 FormulaEditor(结构化 DSL:@指标 / $事实 / 四则运算):
    - L1 表格"计算公式(结构化)"(模块级面板)
    - L2 表格"计算公式(结构化)"(S4 L1 焦点面板)
    - L2 节点单编辑"计算模型 / 结构化公式"
    - 下级 L2 本地草稿编辑"计算模型 / 结构化公式"
  * 新增 nodeModuleCode(node) 从 rawId 解析 S\d+,正确驱动 FormulaEditor 的候选词(指标/事实)过滤。

兼容与边界
- S4 详情看板与批 C1 其它改动不受影响;保留 AIDOP_S4_LAYOUT_SAVED 旧事件。
- L2 父级映射、L2 面板的"服务端 L2 表"仍沿用 S4 试点的数据关系;S1-S7/S9 详情页的动态呈现放到批 C3 再做。
- pnpm build 通过,lint 无报错。

版本递增(按仓规:每次实质提交同步升前后端 patch)
- Web/package.json: 2.4.90 -> 2.4.91
- server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj Version/AssemblyVersion/FileVersion: 1.0.57 -> 1.0.58

Made-with: Cursor
skygu 3 月之前
父節點
當前提交
6893777e1e
共有 3 個文件被更改,包括 628 次插入1125 次删除
  1. 107 51
      Web/src/views/aidop/kanban/s0.vue
  2. 373 0
      Web/src/views/dashboard/components/HomeModuleKpiCard.vue
  3. 148 1074
      Web/src/views/dashboard/home.vue

+ 107 - 51
Web/src/views/aidop/kanban/s0.vue

@@ -44,15 +44,19 @@
       <div v-if="drawerS4" v-loading="!s4PanelReady" class="s0-drawer-s4">
         <template v-if="s4PanelReady">
           <p class="s0-s4__desc s0-drawer-s4__intro">
-            勾选首页展示的 L1(最多 {{ MAX_S4_L1 }} 个),编辑显示名与公式说明。二级指标请在图谱中点击对应 <strong>S4 的 L1 节点</strong> 进行配置。保存后九宫格与采购看板将自动刷新。
+            勾选首页展示的 L1(最多 {{ MAX_S4_L1 }} 个),编辑显示名与计算公式(结构化 DSL:支持 <code>@指标</code>、<code>$事实</code>、+ - × ÷ ( ))。
+            <template v-if="currentModuleCode === 'S4'">
+              二级指标请在图谱中点击对应 <strong>S4 的 L1 节点</strong> 进行配置。
+            </template>
+            保存后九宫格与对应看板将自动刷新。
           </p>
           <div class="s0-s4__actions s0-drawer-s4__actions">
-            <el-button type="primary" size="small" :loading="s4Saving" @click="saveS4LayoutClick">保存 S4 布局</el-button>
+            <el-button type="primary" size="small" :loading="s4Saving" @click="saveS4LayoutClick">保存 {{ currentModuleCode }} 布局</el-button>
             <el-button size="small" @click="loadS4Panel">重新加载</el-button>
           </div>
           <div class="s0-s4__l1">
-            <div class="s0-s4__subhd">一级指标 L1(首页九宫格 S4)</div>
-            <p class="s0-s4__hint">勾选后可编辑显示名称与计算公式说明。</p>
+            <div class="s0-s4__subhd">一级指标 L1(首页九宫格 {{ currentModuleCode }})</div>
+            <p class="s0-s4__hint">勾选后可编辑显示名称与结构化公式。公式默认回填自 KpiMaster 的 FormulaExpr(租户公式事实源)。</p>
             <el-table :data="catalogL1" size="small" border stripe class="s0-s4__table">
               <el-table-column label="首页" width="56" align="center">
                 <template #default="{ row }">
@@ -75,16 +79,16 @@
                   />
                 </template>
               </el-table-column>
-              <el-table-column label="计算公式说明" min-width="160">
+              <el-table-column label="计算公式(结构化)" min-width="260">
                 <template #default="{ row }">
-                  <el-input
+                  <FormulaEditor
+                    v-if="pickedL1.includes(row.metricCode)"
                     v-model="l1Formula[row.metricCode]"
-                    type="textarea"
-                    :rows="2"
-                    placeholder="例:Σ准时到货行 / Σ应到货行"
-                    size="small"
-                    :disabled="!pickedL1.includes(row.metricCode)"
+                    :module-code="currentModuleCode"
+                    :exclude-kpi-id="row.id"
+                    placeholder="例:@PO_ON_TIME_ROWS / @PO_DUE_ROWS"
                   />
+                  <span v-else class="s0-s4__formula-off">(未勾选)</span>
                 </template>
               </el-table-column>
             </el-table>
@@ -94,7 +98,7 @@
       <div v-else-if="drawerS4L1" v-loading="!s4PanelReady" class="s0-drawer-s4">
         <template v-if="s4PanelReady">
           <p class="s0-s4__desc s0-drawer-s4__intro">
-            以下为字典中<strong>父级</strong>指向「{{ s4FocusedServerMetric || '(加载中或未映射)' }}」的 L2。勾选、指定左右栏并填写说明后,与上方共用「保存 S4 布局」。
+            以下为字典中<strong>父级</strong>指向「{{ s4FocusedServerMetric || '(加载中或未映射)' }}」的 L2。勾选、指定左右栏并填写结构化公式后,与上方共用「保存 S4 布局」。
           </p>
           <el-form label-position="top" size="small" class="s0-drawer-s4__l1local">
             <el-form-item label="图谱 L1 显示名(本地草稿)">
@@ -173,16 +177,16 @@
                   </el-select>
                 </template>
               </el-table-column>
-              <el-table-column label="计算公式说明" min-width="140">
+              <el-table-column label="计算公式(结构化)" min-width="260">
                 <template #default="{ row }">
-                  <el-input
+                  <FormulaEditor
+                    v-if="l2On[row.metricCode]"
                     v-model="l2Formula[row.metricCode]"
-                    type="textarea"
-                    :rows="2"
-                    placeholder="详情主 KPI 行下方"
-                    size="small"
-                    :disabled="!l2On[row.metricCode]"
+                    :module-code="currentModuleCode"
+                    :exclude-kpi-id="row.id"
+                    placeholder="详情主 KPI 行下方(支持 @指标 / $事实)"
                   />
+                  <span v-else class="s0-s4__formula-off">(未选用)</span>
                 </template>
               </el-table-column>
             </el-table>
@@ -197,8 +201,13 @@
           <el-form-item label="显示名称">
             <el-input v-model="editForm.name" type="textarea" :rows="2" @input="markDirty" />
           </el-form-item>
-          <el-form-item v-if="selectedNode.level === 'l2'" label="计算模型 / 公式说明">
-            <el-input v-model="editForm.formula" type="textarea" :rows="5" @input="markDirty" />
+          <el-form-item v-if="selectedNode.level === 'l2'" label="计算模型 / 结构化公式">
+            <FormulaEditor
+              v-model="editForm.formula"
+              :module-code="nodeModuleCode(selectedNode)"
+              @update:model-value="markDirty"
+              placeholder="例:@METRIC_A / @METRIC_B 或 $FACT_A + $FACT_B"
+            />
           </el-form-item>
           <el-form-item v-if="selectedNode.level === 'l2'" label="上卷 / 聚合方式">
             <el-input v-model="editForm.aggregation" type="textarea" :rows="3" @input="markDirty" />
@@ -218,12 +227,12 @@
                   @update:model-value="(v) => patchGraphL2Local(row.id, 'detailLabel', v)"
                 />
               </el-form-item>
-              <el-form-item label="计算模型 / 公式说明">
-                <el-input
-                  type="textarea"
-                  :rows="3"
+              <el-form-item label="计算模型 / 结构化公式">
+                <FormulaEditor
                   :model-value="l2LocalField(row.id, 'formula', row.formula)"
+                  :module-code="nodeModuleCode(selectedNode)"
                   @update:model-value="(v) => patchGraphL2Local(row.id, 'formula', v)"
+                  placeholder="支持 @指标 / $事实 的结构化公式"
                 />
               </el-form-item>
               <el-form-item label="上卷 / 聚合方式">
@@ -262,12 +271,15 @@ import {
 } from './data/operationModelSchema'
 import { resolveS4ServerMetricForGraphL1 } from './data/s4GraphServerMap'
 import {
-  fetchS4MetricCatalog,
-  fetchS4OperationLayout,
-  saveS4OperationLayout
+  fetchMetricCatalog,
+  fetchOperationLayout,
+  saveOperationLayout
 } from '../api/kanbanData'
-import { notifyS4LayoutSaved } from './utils/s4LayoutEvents'
+import { notifyLayoutSaved } from './utils/s4LayoutEvents'
+import FormulaEditor from './components/FormulaEditor.vue'
 
+/** 当前打开的模块级/模块下 L1 抽屉对应的模块编码;非空表示"通用模块建模" */
+const currentModuleCode = ref('')
 const MAX_S4_L1 = 8
 const s4PanelReady = ref(false)
 const s4Saving = ref(false)
@@ -282,6 +294,9 @@ const l1DisplayName = reactive({})
 const l1Formula = reactive({})
 const l2DisplayName = reactive({})
 
+/** 支持通用建模的模块集合(S8 以异常监控页独立处理,不纳入建模抽屉) */
+const MODELABLE_MODULES = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S9']
+
 const route = useRoute()
 const chartRef = ref(null)
 const moduleFilter = ref('')
@@ -309,17 +324,25 @@ function applyModelingRouteQuery() {
         itemStyle: { color: 'rgba(59, 130, 246, 0.3)', borderColor: '#60a5fa', borderWidth: 2 },
         label: { color: '#eff6ff', fontSize: 10 }
       }
-      loadS4Panel()
+      loadModulePanel('S4')
     })
   }
 }
 
 function parentRowIdToMetricCode(pid) {
   if (!pid || typeof pid !== 'string') return ''
-  const m = pid.match(/^S4-L\d+-(.+)$/)
+  const m = pid.match(/^S\d+-L\d+-(.+)$/)
   return m ? m[1] : ''
 }
 
+function nodeModuleCode(node) {
+  if (!node) return currentModuleCode.value || ''
+  const raw = String(node.rawId || '')
+  const m = raw.match(/^(S\d+)/i)
+  if (m) return m[1].toUpperCase()
+  return currentModuleCode.value || ''
+}
+
 function l1Name(code) {
   const c = catalogL1.value.find((x) => x.metricCode === code)
   return c ? `${c.defaultName} (${code})` : code
@@ -378,12 +401,25 @@ function effectiveL2DisplayName(row) {
 }
 
 async function loadS4Panel(opts = {}) {
+  return loadModulePanel(currentModuleCode.value || 'S4', opts)
+}
+
+/**
+ * 通用模块建模面板加载:按 moduleCode 拉取 KpiMaster 字典 + LayoutItem,
+ * 回填 pickedL1 / l1DisplayName / l1Formula / l2On / ... 等 UI 状态。
+ * 说明:formula 字段优先使用 LayoutItem.FormulaText(租户级 override),
+ * 若为空则回落到 KpiMaster.FormulaExpr(方案 α:KpiMaster 为公式事实源)。
+ */
+async function loadModulePanel(moduleCode, opts = {}) {
+  const mc = String(moduleCode || '').toUpperCase()
+  if (!mc) return
+  currentModuleCode.value = mc
   const keepVisible = Boolean(opts.keepVisible)
   if (!keepVisible) s4PanelReady.value = false
   try {
     const [rawAll, layout] = await Promise.all([
-      fetchS4MetricCatalog(),
-      fetchS4OperationLayout(1, 1)
+      fetchMetricCatalog(mc),
+      fetchOperationLayout(mc, 1)
     ])
     const rawL1 = (rawAll || []).filter((x) => x.metricLevel === 1)
     const rawL2 = (rawAll || []).filter((x) => x.metricLevel >= 2)
@@ -392,13 +428,13 @@ async function loadS4Panel(opts = {}) {
 
     for (const row of catalogL1.value) {
       l1DisplayName[row.metricCode] = ''
-      l1Formula[row.metricCode] = ''
+      l1Formula[row.metricCode] = row.formulaExpr ?? ''
     }
     for (const row of catalogL2.value) {
       if (l2On[row.metricCode] === undefined) l2On[row.metricCode] = false
       if (l2ParentMetric[row.metricCode] === undefined) l2ParentMetric[row.metricCode] = ''
       if (l2Zone[row.metricCode] === undefined) l2Zone[row.metricCode] = 'left'
-      if (l2Formula[row.metricCode] === undefined) l2Formula[row.metricCode] = ''
+      if (l2Formula[row.metricCode] === undefined) l2Formula[row.metricCode] = row.formulaExpr ?? ''
       if (l2DisplayName[row.metricCode] === undefined) l2DisplayName[row.metricCode] = ''
     }
 
@@ -409,7 +445,7 @@ async function loadS4Panel(opts = {}) {
         .map((x) => x.metricCode)
       for (const x of layout.l1) {
         l1DisplayName[x.metricCode] = x.displayName ?? ''
-        l1Formula[x.metricCode] = x.formulaText ?? ''
+        if (x.formulaText) l1Formula[x.metricCode] = x.formulaText
       }
     } else {
       pickedL1.value = []
@@ -424,7 +460,7 @@ async function loadS4Panel(opts = {}) {
       l2On[row.metricCode] = false
       l2ParentMetric[row.metricCode] = row.parentId ? (idToCode[row.parentId] || '') : ''
       l2Zone[row.metricCode] = 'left'
-      l2Formula[row.metricCode] = ''
+      l2Formula[row.metricCode] = row.formulaExpr ?? ''
       l2DisplayName[row.metricCode] = ''
     }
 
@@ -435,17 +471,17 @@ async function loadS4Panel(opts = {}) {
         if (layoutParent) l2ParentMetric[row.metricCode] = layoutParent
         const z = String(row.panelZone || 'left').toLowerCase()
         l2Zone[row.metricCode] = z === 'right' ? 'right' : 'left'
-        l2Formula[row.metricCode] = row.formulaText ?? ''
+        if (row.formulaText) l2Formula[row.metricCode] = row.formulaText
         l2DisplayName[row.metricCode] = row.displayName ?? ''
       }
     }
 
     if (!catalogL1.value.length) {
-      ElMessage.warning('未加载到 S4 指标字典,请确认后端已初始化 KpiMaster(S4)种子数据')
+      ElMessage.warning(`未加载到 ${mc} 指标字典,请确认后端已初始化 KpiMaster(${mc})数据`)
     }
   } catch (e) {
     console.error(e)
-    ElMessage.error('加载 S4 指标配置失败,请检查接口 /api/AidopKanban/s4-metric-catalog 与网络')
+    ElMessage.error(`加载 ${mc} 指标配置失败,请检查接口 /api/AidopKanban/metric-catalog/${mc} 与网络`)
     catalogL1.value = []
     catalogL2.value = []
     pickedL1.value = []
@@ -455,6 +491,12 @@ async function loadS4Panel(opts = {}) {
 }
 
 async function saveS4LayoutClick() {
+  return saveModuleLayoutClick(currentModuleCode.value || 'S4')
+}
+
+async function saveModuleLayoutClick(moduleCode) {
+  const mc = String(moduleCode || '').toUpperCase()
+  if (!mc) return
   if (pickedL1.value.length > MAX_S4_L1) {
     ElMessage.warning(`L1 最多 ${MAX_S4_L1} 个`)
     return
@@ -462,7 +504,7 @@ async function saveS4LayoutClick() {
   const L1 = catalogL1.value
     .filter((c) => pickedL1.value.includes(c.metricCode))
     .map((c, i) => ({
-      RowId: `S4-L1-${c.metricCode}`,
+      RowId: `${mc}-L1-${c.metricCode}`,
       MetricCode: c.metricCode,
       DisplayName: effectiveL1DisplayName(c.metricCode),
       FormulaText: effectiveL1Formula(c.metricCode),
@@ -473,10 +515,10 @@ async function saveS4LayoutClick() {
     if (!l2On[row.metricCode]) continue
     const parent = String(l2ParentMetric[row.metricCode] || '').trim()
     const lvl = row.metricLevel ?? 2
-    const parentPrefix = lvl >= 3 ? 'S4-L2-' : 'S4-L1-'
+    const parentPrefix = lvl >= 3 ? `${mc}-L2-` : `${mc}-L1-`
     const parentRowId = parent ? `${parentPrefix}${parent}` : null
     L2.push({
-      RowId: `S4-L${lvl}-${row.metricCode}`,
+      RowId: `${mc}-L${lvl}-${row.metricCode}`,
       MetricCode: row.metricCode,
       DisplayName: effectiveL2DisplayName(row),
       SortNo: (L2.length + 1) * 10,
@@ -487,11 +529,11 @@ async function saveS4LayoutClick() {
   }
   s4Saving.value = true
   try {
-    const r = await saveS4OperationLayout({ L1, L2 }, 1, 1)
+    const r = await saveOperationLayout(mc, { L1, L2 }, 1)
     if (r.ok) {
-      notifyS4LayoutSaved()
-      await loadS4Panel({ keepVisible: true })
-      ElMessage.success('S4 布局已保存;首页九宫格与 S4 采购看板已自动刷新数据')
+      notifyLayoutSaved(mc)
+      await loadModulePanel(mc, { keepVisible: true })
+      ElMessage.success(`${mc} 布局已保存;首页九宫格${mc === 'S4' ? '与 S4 采购看板' : '与对应详情看板'}已自动刷新数据`)
     } else {
       ElMessage.error(r.message || '保存失败')
     }
@@ -526,10 +568,23 @@ const s4FocusedServerMetric = computed(() => {
   return resolveS4ServerMetricForGraphL1(rid, sortedS4CatalogL1.value)
 })
 
+/**
+ * 模块级抽屉:S1~S7/S9(S8 不纳入建模)。S4 为历史试点,此处保持完全兼容并
+ * 使用同一套 UI + 通用 API;区别仅在于 currentModuleCode。
+ */
 const drawerS4 = computed(
-  () => Boolean(selectedNode.value?.level === 'module' && selectedNode.value?.rawId === 'S4')
+  () =>
+    Boolean(
+      selectedNode.value?.level === 'module' &&
+        MODELABLE_MODULES.includes(selectedNode.value?.rawId)
+    )
 )
 
+/**
+ * 模块 L1 子抽屉:仅 S4 保留(因其 L1 已完整配好图谱 rawId → KpiMaster metricCode
+ * 的映射表 resolveS4ServerMetricForGraphL1)。其他模块的 L1 子抽屉在 drawerS4L1
+ * 之外走通用节点编辑表单(本地 override 草稿 + 公式编辑器)。
+ */
 const drawerS4L1 = computed(() => {
   if (selectedNode.value?.level !== 'l1') return false
   return Boolean(selectedNode.value.rawId?.startsWith('S4-L1-'))
@@ -913,10 +968,11 @@ function onChartClick(params) {
     if (d.level === 'module') {
       drawerVisible.value = true
       selectedNode.value = { ...d, name: d.name.replace(/\n/g, ' ') }
-      if (d.rawId === 'S4') {
-        loadS4Panel()
+      if (MODELABLE_MODULES.includes(d.rawId)) {
+        loadModulePanel(d.rawId)
         return
       }
+      currentModuleCode.value = ''
       editForm.value = { name: d.name.replace(/\n/g, ' '), formula: '', aggregation: '' }
       return
     }
@@ -932,7 +988,7 @@ function onChartClick(params) {
         aggregation: ''
       }
       if (block?.moduleId === 'S4') {
-        loadS4Panel()
+        loadModulePanel('S4')
       }
     } else if (l2) {
       selectedNode.value = { ...d, level: 'l2', formula: l2.formula, aggregation: l2.aggregation }

+ 373 - 0
Web/src/views/dashboard/components/HomeModuleKpiCard.vue

@@ -0,0 +1,373 @@
+<template>
+  <section class="dash-card home-kpi-card" @click="onCardClick">
+    <header class="dash-card__head">
+      <span class="dash-card__code">{{ moduleCode }}</span>
+      <span class="dash-card__title">{{ title }}</span>
+      <el-icon v-if="icon" class="dash-card__module-icon" :size="22">
+        <component :is="icon" />
+      </el-icon>
+    </header>
+    <div class="dash-card__body s4-body">
+      <div
+        v-if="cells.length > 0"
+        class="s4-kpi-grid"
+        :class="{ 's4-kpi-grid--dense': cells.length > 4 }"
+      >
+        <div v-for="cell in cells" :key="cell.key" class="s4-kpi-cell">
+          <el-tooltip v-if="cell.formulaText" placement="top" :show-after="200" :content="cell.formulaText">
+            <div class="s4-kpi-label s4-kpi-label--formula">{{ cell.label }}</div>
+          </el-tooltip>
+          <div v-else class="s4-kpi-label">{{ cell.label }}</div>
+          <div class="s4-kpi-body">
+            <div class="s4-kpi-main">
+              <div class="s4-kpi-value-row">
+                <span class="s4-kpi-value">{{ cell.value }}</span>
+                <span v-if="cell.unit" class="s4-kpi-unit">{{ cell.unit }}</span>
+              </div>
+              <div v-if="cell.targetDisplay" class="s4-kpi-target">目标: {{ cell.targetDisplay }}</div>
+            </div>
+            <span
+              class="s4-kpi-trend"
+              :class="{
+                's4-kpi-trend--good': cell.trendTone === 'good',
+                's4-kpi-trend--warn': cell.trendTone === 'warn',
+                's4-kpi-trend--bad': cell.trendTone === 'bad',
+              }"
+            >
+              <span class="s4-kpi-trend__arrow" aria-hidden="true">{{
+                cell.trendArrow === 'up' ? '▲' : cell.trendArrow === 'down' ? '▼' : '—'
+              }}</span>
+              {{ cell.trend }}
+            </span>
+          </div>
+        </div>
+      </div>
+      <div v-else class="home-kpi-card__empty">
+        <span v-if="loading">加载中…</span>
+        <span v-else>暂无指标配置,点击卡片进入模块后到“运营指标建模”配置 L1 指标</span>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
+import { fetchHomeGrid } from '../../aidop/api/kanbanData'
+import { AIDOP_LAYOUT_SAVED, AIDOP_S4_LAYOUT_SAVED } from '../../aidop/kanban/utils/s4LayoutEvents'
+
+const props = defineProps({
+  moduleCode: { type: String, required: true },
+  title: { type: String, default: '' },
+  icon: { type: [Object, Function], default: null },
+  factoryId: { type: Number, default: 1 },
+  /** 透传 smartOps base 查询参数(日期/产品/订单号/产线) */
+  extra: { type: Object, default: () => ({}) },
+})
+
+const emit = defineEmits(['click'])
+
+const apiItems = ref([])
+const loading = ref(false)
+
+function fmtValue(v, unit) {
+  if (v == null || v === '') return '—'
+  const n = Number(v)
+  if (Number.isNaN(n)) return String(v)
+  if (unit === '%') return `${Math.round(n * 10) / 10}`
+  if (unit === '天') return `${Math.round(n * 10) / 10}`
+  if (Math.abs(n - Math.round(n)) < 1e-9) return `${Math.round(n)}`
+  return `${Math.round(n * 100) / 100}`
+}
+
+function fmtTargetDisplay(v, unit) {
+  if (v == null || v === '') return ''
+  const n = Number(v)
+  if (Number.isNaN(n)) return String(v)
+  const num =
+    unit === '%'
+      ? `${Math.round(n * 10) / 10}`
+      : unit === '天'
+      ? `${Math.round(n * 10) / 10}`
+      : Math.abs(n - Math.round(n)) < 1e-9
+      ? `${Math.round(n)}`
+      : `${Math.round(n * 100) / 100}`
+  if (unit === '%') return `${num}%`
+  if (unit === '天') return `${num} 天`
+  if (unit) return `${num} ${unit}`
+  return `${num}`
+}
+
+function mapItem(item) {
+  const arrow = item.gapArrow
+  const trendArrow = arrow === 'up' ? 'up' : arrow === 'down' ? 'down' : 'flat'
+  const lv = String(item.achievementLevel || 'green')
+  const trendTone = lv === 'red' ? 'bad' : lv === 'yellow' ? 'warn' : 'good'
+  const unit = item.unit ?? ''
+  const ft =
+    item.formulaText != null && String(item.formulaText).trim()
+      ? String(item.formulaText).trim()
+      : ''
+  return {
+    key: item.rowId || item.metricCode,
+    label: item.displayName || item.metricCode,
+    formulaText: ft,
+    value: fmtValue(item.currentValue, unit),
+    unit,
+    targetDisplay: fmtTargetDisplay(item.targetValue, unit),
+    trend: item.gapLabel || '—',
+    trendArrow,
+    trendTone,
+  }
+}
+
+const cells = computed(() =>
+  Array.isArray(apiItems.value) ? apiItems.value.map(mapItem) : []
+)
+
+async function load() {
+  const mc = String(props.moduleCode || '').toUpperCase()
+  if (!mc) return
+  loading.value = true
+  try {
+    const grid = await fetchHomeGrid(mc, props.factoryId, props.extra || {})
+    apiItems.value = grid?.items && grid.items.length > 0 ? grid.items : []
+  } catch {
+    apiItems.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+function onLayoutSaved(e) {
+  const detailMc = String(e?.detail?.moduleCode || '').toUpperCase()
+  if (!detailMc || detailMc === String(props.moduleCode || '').toUpperCase()) load()
+}
+
+function onS4LayoutSavedLegacy() {
+  if (String(props.moduleCode || '').toUpperCase() === 'S4') load()
+}
+
+function onCardClick(e) {
+  emit('click', e)
+}
+
+watch(
+  () => [props.moduleCode, props.factoryId, props.extra],
+  () => {
+    load()
+  },
+  { deep: true }
+)
+
+onMounted(() => {
+  load()
+  if (typeof window !== 'undefined') {
+    window.addEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
+    window.addEventListener(AIDOP_S4_LAYOUT_SAVED, onS4LayoutSavedLegacy)
+  }
+})
+
+onUnmounted(() => {
+  if (typeof window !== 'undefined') {
+    window.removeEventListener(AIDOP_LAYOUT_SAVED, onLayoutSaved)
+    window.removeEventListener(AIDOP_S4_LAYOUT_SAVED, onS4LayoutSavedLegacy)
+  }
+})
+
+defineExpose({ reload: load })
+</script>
+
+<style scoped>
+.dash-card {
+  display: flex;
+  flex-direction: column;
+  background: rgba(30, 41, 59, 0.55);
+  border: 1px solid #334155;
+  border-radius: 8px;
+  padding: 8px 10px;
+  cursor: pointer;
+  transition: border-color 0.2s, box-shadow 0.2s;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.dash-card:hover {
+  border-color: #64748b;
+  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
+}
+
+.dash-card__head {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  flex-wrap: nowrap;
+  gap: 6px;
+  width: 100%;
+  margin-bottom: 6px;
+  padding-bottom: 6px;
+  border-bottom: 1px solid #334155;
+}
+
+.dash-card__module-icon {
+  margin-left: auto;
+  flex-shrink: 0;
+  color: #5eead4;
+  filter: drop-shadow(0 0 8px rgba(94, 234, 212, 0.45));
+}
+
+.dash-card__code {
+  font-size: 13px;
+  font-weight: 800;
+  color: #60a5fa;
+  letter-spacing: 0.5px;
+}
+
+.dash-card__title {
+  font-size: 13px;
+  font-weight: 700;
+  color: #e2e8f0;
+}
+
+.dash-card__body {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  overflow: hidden;
+}
+
+.s4-body {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+  padding: 2px 10px 4px;
+  box-sizing: border-box;
+}
+
+.s4-kpi-grid {
+  flex: 1;
+  min-height: 0;
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-template-rows: 1fr 1fr;
+  gap: 8px 12px;
+  align-content: stretch;
+}
+
+.s4-kpi-grid--dense {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  grid-template-rows: repeat(2, minmax(0, 1fr));
+}
+
+.s4-kpi-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+  gap: 6px;
+  padding: 6px 8px;
+  min-height: 0;
+  background: rgba(0, 0, 0, 0.22);
+  border-radius: 6px;
+  border: 1px solid rgba(51, 65, 85, 0.35);
+}
+
+.s4-kpi-label {
+  font-size: 11px;
+  color: #94a3b8;
+  line-height: 1.3;
+  font-weight: 500;
+}
+
+.s4-kpi-label--formula {
+  cursor: help;
+  border-bottom: 1px dashed rgba(148, 163, 184, 0.55);
+}
+
+.s4-kpi-body {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  width: 100%;
+  min-width: 0;
+}
+
+.s4-kpi-main {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 3px;
+}
+
+.s4-kpi-value-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 4px 6px;
+}
+
+.s4-kpi-target {
+  font-size: 11px;
+  font-weight: 500;
+  color: #94a3b8;
+  line-height: 1.3;
+}
+
+.s4-kpi-value {
+  font-size: clamp(18px, 3.8vw, 24px);
+  font-weight: 800;
+  color: #ffffff;
+  line-height: 1.05;
+  letter-spacing: 0.02em;
+}
+
+.s4-kpi-unit {
+  font-size: 11px;
+  color: #e2e8f0;
+  font-weight: 500;
+}
+
+.s4-kpi-trend {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  font-size: 11px;
+  font-weight: 700;
+  flex-shrink: 0;
+  align-self: center;
+}
+
+.s4-kpi-trend__arrow {
+  font-size: 9px;
+  line-height: 1;
+  transform: translateY(0.5px);
+}
+
+.s4-kpi-trend--good {
+  color: #4ade80;
+}
+
+.s4-kpi-trend--warn {
+  color: #facc15;
+}
+
+.s4-kpi-trend--bad {
+  color: #fb923c;
+}
+
+.home-kpi-card__empty {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #94a3b8;
+  font-size: 11px;
+  padding: 8px;
+  text-align: center;
+  line-height: 1.4;
+}
+</style>

文件差異過大導致無法顯示
+ 148 - 1074
Web/src/views/dashboard/home.vue


部分文件因文件數量過多而無法顯示