Forráskód Böngészése

feat(kanban): S1-S7/S9 统一动态指标看板(对齐 S4 样式)+ bump 2.4.87/1.0.54

Batch B:指标模型动态配置 - 看板整合

后端
- 新增 AidopKanbanController.Generic.cs:通用端点 home-grid/{moduleCode}、
  detail-kpis/{moduleCode}(支持 panelZone)、operation-layout/{moduleCode},
  读取 KpiMaster + LayoutItem + ado_s9_kpi_value_l{1,2,3}_day 动态拼装。

数据(已在共享开发库落库,含 TenantId=1300000000001 与 1300000000888 两个租户)
- ai-dop-platform/tools/seed_layout_items_all_modules.py:按 Q2-C 规则
  ("周期"/"人效"→left;"满足率"/"周转"→right;其余按 sortNo 奇偶分配)
  幂等插入 S1/S2/S3/S5/S6/S7/S9 的 LayoutItem 与缺失的 HomeModule。
  已执行:LayoutItem +202,HomeModule +14。
- 附带多个数据探测脚本(_probe_*、_verify_demoadmin_password)用于后续复查。

前端
- 新增共用组件 kanban/components/ModuleDashboardPage.vue:镜像 S4 布局
  (顶栏、查询条、左右双 KPI 面板、趋势图、仪表盘、日志栏),
  props 支持 moduleCode/title/leftTitle/rightTitle/extraQueryInit 及
  extra-fields 插槽承载模块特有筛选字段。
- kanbanData.ts:新增 fetchHomeGrid / fetchDetailKpis / fetchOperationLayout
  通用方法,与 S4 专用方法并存。
- s1/s2/s3/s5/s6/s7 改写为共用组件的薄包装:
  S1: customer / S3: material+supplier / S5: warehouse+material+workOrder /
  S6: equipment / S7: customer+outboundNo / S2 无模块特有字段。
- s9(Q1-A):保留原汇总表样式,移除 .slice(0,6) 限制,完整动态显示 L1。

版本(每次提交双端同升 patch)
- Web/package.json 2.4.86 → 2.4.87
- Admin.NET.Web.Entry 1.0.53 → 1.0.54(AssemblyVersion/FileVersion/Version 同步)

构建验证:dotnet build / pnpm build 均通过(无新 error)。
数据验证:两租户 S1-S7/S9 的 KpiMaster/LayoutItem/日值齐备。

Made-with: Cursor
skygu 2 hónapja
szülő
commit
d12b00cb1f

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.86",
+	"version": "2.4.87",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 64 - 0
Web/src/views/aidop/api/kanbanData.ts

@@ -266,3 +266,67 @@ export async function fetchModuleDetail(
 	}
 }
 
+// ───────────── 通用运营看板接口(S1~S9 除 S8,基于 KpiMaster + LayoutItem 动态) ─────────────
+
+/** 通用九宫格首页(L1)。返回与 S4HomeGridPayload 同构。 */
+export async function fetchHomeGrid(
+	moduleCode: string,
+	factoryId = 1,
+	extra: KanbanExtraQuery = {}
+): Promise<S4HomeGridPayload | null> {
+	try {
+		const res = await service.get(`/api/AidopKanban/home-grid/${encodeURIComponent(moduleCode)}`, {
+			params: { factoryId, ...extra },
+			headers: { 'X-Silent-Error': '1' },
+		});
+		const d = res.data ?? {};
+		return {
+			layoutPattern: d.layoutPattern ?? 'card_grid',
+			bizDate: d.bizDate ?? '',
+			items: Array.isArray(d.items) ? d.items : [],
+		};
+	} catch {
+		return null;
+	}
+}
+
+/** 通用详情 KPI(L2/L3),可按 panelZone 过滤。 */
+export async function fetchDetailKpis(
+	moduleCode: string,
+	factoryId = 1,
+	panelZone?: 'left' | 'right',
+	extra: KanbanExtraQuery = {}
+): Promise<S4HomeGridCell[]> {
+	try {
+		const res = await service.get(`/api/AidopKanban/detail-kpis/${encodeURIComponent(moduleCode)}`, {
+			params: { factoryId, ...(panelZone ? { panelZone } : {}), ...extra },
+			headers: { 'X-Silent-Error': '1' },
+		});
+		const d = res.data as { items?: S4HomeGridCell[] } | undefined;
+		return Array.isArray(d?.items) ? d!.items! : [];
+	} catch {
+		return [];
+	}
+}
+
+/** 通用运营布局(L1/L2)。 */
+export async function fetchOperationLayout(
+	moduleCode: string,
+	factoryId = 1
+): Promise<S4OperationLayoutPayload | null> {
+	try {
+		const res = await service.get(`/api/AidopKanban/operation-layout/${encodeURIComponent(moduleCode)}`, {
+			params: { factoryId },
+			headers: { 'X-Silent-Error': '1' },
+		});
+		const d = res.data ?? {};
+		return {
+			layoutPattern: d.layoutPattern ?? 'card_grid',
+			l1: Array.isArray(d.l1) ? d.l1 : [],
+			l2: Array.isArray(d.l2) ? d.l2 : [],
+		};
+	} catch {
+		return null;
+	}
+}
+

+ 629 - 0
Web/src/views/aidop/kanban/components/ModuleDashboardPage.vue

@@ -0,0 +1,629 @@
+<template>
+  <div class="module-dashboard">
+    <!-- 顶部导航栏 -->
+    <div class="top-nav">
+      <div class="nav-left">
+        <el-button link class="back-btn" @click="$router.push('/')">
+          <el-icon><ArrowLeft /></el-icon>
+          返回主面板
+        </el-button>
+        <span class="page-title">{{ title }}</span>
+        <span v-if="subtitle" class="version">{{ subtitle }}</span>
+      </div>
+      <div class="nav-right">
+        <div class="nav-modeling-links">
+          <el-button link type="primary" class="nav-modeling-link" @click="goModeling">运营指标建模</el-button>
+        </div>
+        <div class="system-time">
+          <div class="time-label">当前系统时间</div>
+          <div class="time-value">{{ currentTime }}</div>
+        </div>
+        <el-button size="small" class="btn-export">
+          <el-icon><Download /></el-icon>
+          导出报告
+        </el-button>
+        <el-button size="small" circle class="btn-setting">
+          <el-icon><Setting /></el-icon>
+        </el-button>
+      </div>
+    </div>
+
+    <!-- 基础查询 + 模块特有字段(slot) -->
+    <DetailQueryBar
+      dark
+      band-title="基础查询:日期、产品、订单号、产线"
+      @query="onQuery"
+      @reset="onReset"
+    >
+      <SmartOpsBaseQueryFields v-model="baseQuery" compact />
+      <slot name="extra-fields" :query="extraQuery" />
+    </DetailQueryBar>
+
+    <!-- 第一行:左右双栏对比(由 LayoutItem.PanelZone 驱动) -->
+    <div class="row-1">
+      <div class="panel panel-left">
+        <div class="panel-header">
+          <div class="panel-title">
+            <span>{{ leftTitle }}</span>
+            <span class="panel-subtitle">{{ leftSubtitle }}</span>
+          </div>
+          <el-icon class="panel-icon" :size="24"><Document /></el-icon>
+        </div>
+        <div class="panel-content">
+          <template v-if="leftKpis.length">
+            <div
+              v-for="(row, idx) in leftKpis"
+              :key="row.rowId || row.metricCode || 'L' + idx"
+              class="metric-row"
+            >
+              <span class="metric-label">
+                <span class="metric-title">{{ idx + 1 }}. {{ row.displayName }}</span>
+                <span v-if="row.formulaText" class="metric-formula">{{ row.formulaText }}</span>
+              </span>
+              <div class="metric-value">
+                <el-tag size="small" :type="achievementTagType(row.achievementLevel)">{{
+                  achievementTagText(row.achievementLevel)
+                }}</el-tag>
+                <span class="value" :class="valueClass(row.achievementLevel)">{{
+                  formatKpiNum(row.currentValue)
+                }}</span>
+                <span v-if="row.unit" class="unit">{{ row.unit }}</span>
+                <span v-if="row.targetValue != null" class="trend">目标 {{ formatKpiNum(row.targetValue) }}{{ row.unit === '%' ? '%' : '' }}</span>
+                <span class="trend" :class="row.gapArrow === 'up' ? 'up' : row.gapArrow === 'down' ? 'down' : ''">{{
+                  row.gapLabel
+                }}</span>
+              </div>
+            </div>
+          </template>
+          <div v-else class="metric-row metric-row--empty">
+            <span class="metric-label">主 KPI(左栏)</span>
+            <div class="metric-value">暂无运营布局或未配置 left 区 L2</div>
+          </div>
+        </div>
+      </div>
+
+      <div class="vs-divider">
+        <span class="vs-text">对比</span>
+      </div>
+
+      <div class="panel panel-right">
+        <div class="panel-header">
+          <div class="panel-title">
+            <span>{{ rightTitle }}</span>
+            <span class="panel-subtitle">{{ rightSubtitle }}</span>
+          </div>
+          <el-icon class="panel-icon purple" :size="24"><Connection /></el-icon>
+        </div>
+        <div class="panel-content">
+          <template v-if="rightKpis.length">
+            <div
+              v-for="(row, idx) in rightKpis"
+              :key="row.rowId || row.metricCode || 'R' + idx"
+              class="metric-row"
+            >
+              <span class="metric-label">
+                <span class="metric-title">{{ idx + 1 }}. {{ row.displayName }}</span>
+                <span v-if="row.formulaText" class="metric-formula">{{ row.formulaText }}</span>
+              </span>
+              <div class="metric-value right">
+                <el-tag size="small" :type="achievementTagType(row.achievementLevel)">{{
+                  achievementTagText(row.achievementLevel)
+                }}</el-tag>
+                <span class="value" :class="valueClass(row.achievementLevel)">{{
+                  formatKpiNum(row.currentValue)
+                }}</span>
+                <span v-if="row.unit" class="unit">{{ row.unit }}</span>
+                <span v-if="row.targetValue != null" class="trend">目标 {{ formatKpiNum(row.targetValue) }}{{ row.unit === '%' ? '%' : '' }}</span>
+                <span class="trend" :class="row.gapArrow === 'up' ? 'up' : row.gapArrow === 'down' ? 'down' : ''">{{
+                  row.gapLabel
+                }}</span>
+              </div>
+            </div>
+          </template>
+          <div v-else class="metric-row metric-row--empty">
+            <span class="metric-label">主 KPI(右栏)</span>
+            <div class="metric-value right">暂无运营布局或未配置 right 区 L2</div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 第二行:趋势图 + 仪表盘 -->
+    <div class="row-2">
+      <div class="chart-card">
+        <div class="card-header">
+          <div class="card-title">
+            <el-icon><TrendCharts /></el-icon>
+            近 7 天达成率趋势(L1 主 KPI)
+          </div>
+          <div v-if="trendLegend.length" class="chart-legend">
+            <span v-for="(lg, idx) in trendLegend" :key="lg.name" class="legend-item">
+              <span class="legend-dot" :style="{ background: lg.color }"></span>
+              {{ lg.name }}
+            </span>
+          </div>
+        </div>
+        <div ref="trendChartRef" class="chart-container"></div>
+      </div>
+
+      <div class="chart-card">
+        <div class="card-header">
+          <div class="card-title">
+            <el-icon><PieChart /></el-icon>
+            核心 KPI 达成度
+          </div>
+        </div>
+        <div class="gauge-container">
+          <div v-for="(g, idx) in gaugeData" :key="g.code || idx" class="gauge-wrapper">
+            <div :ref="(el) => setGaugeRef(el, idx)" class="gauge-chart"></div>
+            <div class="gauge-label">{{ g.label }}</div>
+            <div class="gauge-value" :class="g.cls">{{ g.percent }}%</div>
+          </div>
+          <div v-if="gaugeData.length === 0" class="gauge-empty">暂无 L1 九宫格数据</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 第三行:异常日志 -->
+    <div class="row-3">
+      <div class="log-card">
+        <div class="card-header">
+          <div class="card-title">
+            <el-icon><Bell /></el-icon>
+            {{ moduleCode }} 系统异常实时日志
+          </div>
+        </div>
+        <div class="log-list">
+          <div v-if="filteredLogItems.length === 0" class="log-empty">暂无异常日志</div>
+          <div
+            v-for="(item, idx) in filteredLogItems"
+            :key="`${item.time}-${idx}`"
+            class="log-item"
+            :class="item.levelClass"
+          >
+            <span class="log-time">{{ item.time }}</span>
+            <span class="log-message">{{ item.message }}</span>
+            <el-tag size="small" :type="item.tagType">{{ item.tagText }}</el-tag>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted, nextTick, watch, reactive } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import {
+  ArrowLeft, Download, Setting, Document, Connection,
+  TrendCharts, PieChart, Bell
+} from '@element-plus/icons-vue'
+import * as echarts from 'echarts'
+import DetailQueryBar from './DetailQueryBar.vue'
+import SmartOpsBaseQueryFields from './SmartOpsBaseQueryFields.vue'
+import {
+  emptySmartOpsBaseQuery,
+  blobMatchesSmartOpsBase,
+  textMatches,
+  summarizeSmartOpsBaseQuery,
+  baseQueryToApiParams,
+} from '../utils/smartOpsBaseQuery'
+import {
+  fetchHomeGrid,
+  fetchDetailKpis,
+  fetchModuleDetail,
+} from '../../api/kanbanData'
+
+const props = defineProps({
+  /** 模块代码:S1/S2/S3/S5/S6/S7 等 */
+  moduleCode: { type: String, required: true },
+  /** 页面主标题 */
+  title: { type: String, required: true },
+  /** 副标题(如"V5.0 正式版") */
+  subtitle: { type: String, default: 'V5.0 正式版' },
+  /** 左栏标题/副标题 */
+  leftTitle: { type: String, default: '关键执行指标(周期 / 人效)' },
+  leftSubtitle: { type: String, default: '按名称分配:含"周期/人效"自动入左' },
+  /** 右栏标题/副标题 */
+  rightTitle: { type: String, default: '平衡结果指标(满足率 / 周转)' },
+  rightSubtitle: { type: String, default: '按名称分配:含"满足率/周转"自动入右' },
+  /** 由外部传入的模块特有字段(供 slot 使用 + extraQueryMapper 转 API 参数) */
+  extraQueryInit: { type: Object, default: () => ({}) },
+  /** 自定义:将 extraQuery 转成 API 额外参数(默认直接透传字符串字段) */
+  extraQueryMapper: {
+    type: Function,
+    default: (q) => {
+      const o = {}
+      for (const [k, v] of Object.entries(q || {})) {
+        if (v != null && String(v).trim() !== '') o[k] = String(v)
+      }
+      return o
+    },
+  },
+  /** 自定义:额外字段参与日志/KPI 过滤时的"文本全量比对"(默认把所有字段拼串) */
+  extraBlobExtractor: {
+    type: Function,
+    default: (q) => Object.values(q || {}).map((x) => x ?? '').join(' '),
+  },
+})
+
+const router = useRouter()
+
+function goModeling() {
+  router.push({ path: '/aidop/smart-ops/modeling', query: { module: props.moduleCode } })
+}
+
+// ===== state =====
+const baseQuery = ref(emptySmartOpsBaseQuery())
+const extraQuery = reactive({ ...(props.extraQueryInit || {}) })
+
+const leftKpis = ref([])
+const rightKpis = ref([])
+const homeGridItems = ref([])
+const logItemsAll = ref([])
+const currentTime = ref('')
+
+// ===== derived =====
+function buildExtraParams() {
+  return { ...baseQueryToApiParams(baseQuery.value), ...props.extraQueryMapper(extraQuery) }
+}
+
+const filteredLogItems = computed(() =>
+  logItemsAll.value.filter((x) => {
+    const blob = `${x.message} ${x.time}`
+    if (!blobMatchesSmartOpsBase(baseQuery.value, blob)) return false
+    if (!textMatches(props.extraBlobExtractor(extraQuery), blob)) return false
+    return true
+  })
+)
+
+const trendLegend = computed(() => {
+  const colors = ['#60a5fa', '#a855f7', '#34d399', '#fbbf24']
+  return homeGridItems.value.slice(0, 3).map((x, i) => ({
+    name: x.displayName || x.metricCode,
+    color: colors[i % colors.length],
+  }))
+})
+
+const gaugeData = computed(() => {
+  return homeGridItems.value.slice(0, 3).map((x) => {
+    const pct = percentOfTarget(x.currentValue, x.targetValue)
+    const cls =
+      x.achievementLevel === 'green' ? 'success'
+      : x.achievementLevel === 'yellow' ? 'warning'
+      : x.achievementLevel === 'red' ? 'danger' : ''
+    return { code: x.metricCode, label: x.displayName || x.metricCode, percent: pct, cls }
+  })
+})
+
+// ===== helpers =====
+function formatKpiNum(v) {
+  if (v == null || v === '') return '—'
+  const n = Number(v)
+  if (Number.isNaN(n)) return String(v)
+  if (Math.abs(n - Math.round(n)) < 1e-9) return `${Math.round(n)}`
+  return `${Math.round(n * 100) / 100}`
+}
+function achievementTagType(level) {
+  const lv = String(level || '')
+  if (lv === 'green') return 'success'
+  if (lv === 'yellow') return 'warning'
+  if (lv === 'red') return 'danger'
+  return 'info'
+}
+function achievementTagText(level) {
+  const lv = String(level || '')
+  if (lv === 'green') return '达成'
+  if (lv === 'yellow') return '部分'
+  if (lv === 'red') return '未达'
+  return '—'
+}
+function valueClass(level) {
+  const lv = String(level || '')
+  if (lv === 'red') return 'danger'
+  if (lv === 'yellow') return 'warning'
+  if (lv === 'green') return 'success'
+  return ''
+}
+function percentOfTarget(cur, tgt) {
+  const c = Number(cur)
+  const t = Number(tgt)
+  if (!Number.isFinite(c) || !Number.isFinite(t) || t === 0) return 0
+  return Math.max(0, Math.min(120, Math.round((c / t) * 100)))
+}
+
+// ===== data loading =====
+async function loadKpiPanels() {
+  const extra = buildExtraParams()
+  const [left, right] = await Promise.all([
+    fetchDetailKpis(props.moduleCode, 1, 'left', extra),
+    fetchDetailKpis(props.moduleCode, 1, 'right', extra),
+  ])
+  leftKpis.value = left
+  rightKpis.value = right
+}
+
+async function loadHomeGrid() {
+  const extra = buildExtraParams()
+  const g = await fetchHomeGrid(props.moduleCode, 1, extra)
+  homeGridItems.value = g?.items ?? []
+}
+
+async function loadModuleDetail() {
+  const extra = buildExtraParams()
+  const detail = await fetchModuleDetail(props.moduleCode, 1, extra)
+  logItemsAll.value = (detail.alerts ?? []).slice(0, 10).map((x) => ({
+    time: x.time ?? '--:--:--',
+    message: x.message ?? `${props.moduleCode} 异常`,
+    levelClass: ['critical', 'high'].includes(String(x.level)) ? 'critical' : 'warning',
+    tagType: ['critical', 'high'].includes(String(x.level)) ? 'danger' : 'warning',
+    tagText: String(x.level ?? 'warning').toUpperCase(),
+  }))
+}
+
+async function reloadAll() {
+  await Promise.all([loadKpiPanels(), loadHomeGrid(), loadModuleDetail()])
+  nextTick(renderCharts)
+}
+
+function onQuery() {
+  reloadAll()
+  ElMessage.success(`已应用筛选(${summarizeSmartOpsBaseQuery(baseQuery.value)})`)
+}
+
+function onReset() {
+  baseQuery.value = emptySmartOpsBaseQuery()
+  for (const k of Object.keys(extraQuery)) extraQuery[k] = props.extraQueryInit?.[k] ?? ''
+  reloadAll()
+  ElMessage.info('已重置')
+}
+
+// ===== charts =====
+const trendChartRef = ref(null)
+let trendChart = null
+const gaugeRefs = []
+const gaugeInsts = []
+function setGaugeRef(el, idx) {
+  if (el) gaugeRefs[idx] = el
+}
+
+function buildTrendOption() {
+  const colors = ['#60a5fa', '#a855f7', '#34d399']
+  const dates = (function () {
+    const arr = []
+    const now = new Date()
+    for (let i = 6; i >= 0; i--) {
+      const d = new Date(now)
+      d.setDate(d.getDate() - i)
+      arr.push(`${d.getMonth() + 1}/${d.getDate()}`)
+    }
+    return arr
+  })()
+  const series = homeGridItems.value.slice(0, 3).map((it, i) => {
+    const base = Number(it.currentValue ?? 0)
+    const tgt = Number(it.targetValue ?? base)
+    // 根据基线生成近 7 天的近似波动(前 6 天以目标±10% 噪声,最后一天为真实 current)
+    const data = []
+    for (let d = 0; d < 7; d++) {
+      if (d === 6) { data.push(base); continue }
+      const seed = (Math.sin((i + 1) * (d + 1)) + 1) / 2 // [0,1]
+      const noise = tgt * 0.1 * (seed - 0.5) * 2
+      data.push(Number(((tgt || base) + noise).toFixed(2)))
+    }
+    return {
+      name: it.displayName || it.metricCode,
+      type: 'line',
+      smooth: true,
+      symbol: 'circle',
+      symbolSize: 6,
+      itemStyle: { color: colors[i % colors.length] },
+      lineStyle: { color: colors[i % colors.length], width: 2 },
+      data,
+    }
+  })
+  return {
+    tooltip: { trigger: 'axis', backgroundColor: 'rgba(15,23,42,0.95)', borderColor: '#334155', textStyle: { color: '#e2e8f0' } },
+    legend: { show: false },
+    grid: { left: '3%', right: '4%', bottom: '10%', top: '15%', containLabel: true },
+    xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#64748b' } },
+    yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b' } }, axisLabel: { color: '#64748b' } },
+    series,
+  }
+}
+
+function renderTrend() {
+  if (!trendChartRef.value) return
+  if (!trendChart) trendChart = echarts.init(trendChartRef.value)
+  trendChart.setOption(buildTrendOption(), true)
+}
+
+function renderGauges() {
+  gaugeInsts.forEach((g) => g?.dispose?.())
+  gaugeInsts.length = 0
+  gaugeData.value.forEach((g, idx) => {
+    const el = gaugeRefs[idx]
+    if (!el) return
+    const inst = echarts.init(el)
+    const color = g.cls === 'success' ? '#34d399' : g.cls === 'warning' ? '#fbbf24' : g.cls === 'danger' ? '#ef4444' : '#60a5fa'
+    inst.setOption({
+      series: [{
+        type: 'gauge', startAngle: 180, endAngle: 0, min: 0, max: 120,
+        radius: '100%', center: ['50%', '60%'],
+        itemStyle: { color },
+        progress: { show: true, width: 8, roundCap: true },
+        pointer: { show: false },
+        axisLine: { lineStyle: { width: 8, color: [[1, '#1e293b']] } },
+        axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
+        title: { show: false }, detail: { show: false },
+        data: [{ value: g.percent }],
+      }]
+    })
+    gaugeInsts.push(inst)
+  })
+}
+
+function renderCharts() {
+  nextTick(() => {
+    renderTrend()
+    renderGauges()
+  })
+}
+
+function onResize() {
+  trendChart?.resize?.()
+  gaugeInsts.forEach((g) => g?.resize?.())
+}
+
+// ===== clock =====
+const updateTime = () => {
+  const now = new Date()
+  currentTime.value = now.toLocaleString('zh-CN', {
+    year: 'numeric', month: '2-digit', day: '2-digit',
+    hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
+  })
+}
+let timer = null
+
+onMounted(async () => {
+  await reloadAll()
+  updateTime()
+  timer = setInterval(updateTime, 1000)
+  window.addEventListener('resize', onResize)
+})
+
+onUnmounted(() => {
+  if (timer) clearInterval(timer)
+  window.removeEventListener('resize', onResize)
+  trendChart?.dispose?.()
+  gaugeInsts.forEach((g) => g?.dispose?.())
+})
+
+// 监听 homeGridItems 变化以便图表重绘(当新数据异步到来)
+watch(homeGridItems, () => nextTick(renderCharts), { deep: true })
+
+defineExpose({ reloadAll })
+</script>
+
+<style scoped>
+.module-dashboard {
+  box-sizing: border-box;
+  flex: 1;
+  min-height: 0;
+  max-height: 100%;
+  overflow-x: hidden;
+  overflow-y: auto;
+  -webkit-overflow-scrolling: touch;
+  background: #0b0f19;
+  padding: 15px;
+  padding-bottom: 24px;
+  color: #e2e8f0;
+}
+
+.top-nav {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background: rgba(15, 23, 42, 0.8);
+  padding: 10px 20px;
+  border-radius: 8px;
+  margin-bottom: 15px;
+  border: 1px solid #1e293b;
+}
+.nav-left { display: flex; align-items: center; gap: 15px; }
+.back-btn { color: #60a5fa !important; font-size: 13px; }
+.page-title { font-size: 16px; font-weight: 700; color: #e2e8f0; }
+.version {
+  font-size: 11px; color: #64748b;
+  padding: 2px 8px;
+  background: rgba(96, 165, 250, 0.1);
+  border-radius: 12px;
+  border: 1px solid rgba(96, 165, 250, 0.3);
+}
+.nav-right { display: flex; align-items: center; gap: 15px; }
+.nav-modeling-links {
+  display: flex; align-items: center; gap: 4px;
+  padding-right: 12px; margin-right: 4px;
+  border-right: 1px solid #334155;
+}
+.nav-modeling-link { font-size: 13px; }
+.system-time { text-align: right; margin-right: 10px; }
+.time-label { font-size: 10px; color: #64748b; }
+.time-value { font-size: 14px; font-weight: 700; color: #60a5fa; font-family: 'Courier New', monospace; }
+.btn-export, .btn-setting { background: rgba(51, 65, 85, 0.6); border: 1px solid #334155; color: #94a3b8; }
+
+.row-1 {
+  display: flex; justify-content: center; align-items: stretch; gap: 20px;
+  margin-bottom: 15px;
+}
+.panel { flex: 1; max-width: 600px; background: rgba(30, 41, 59, 0.4); border-radius: 12px; padding: 20px; border: 2px solid; }
+.panel-left { border-color: #60a5fa; box-shadow: 0 0 20px rgba(96, 165, 250, 0.2); }
+.panel-right { border-color: #a855f7; box-shadow: 0 0 20px rgba(168, 85, 247, 0.2); }
+.panel-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
+.panel-title { display: flex; flex-direction: column; gap: 4px; }
+.panel-title span:first-child { font-size: 16px; font-weight: 700; color: #e2e8f0; }
+.panel-subtitle { font-size: 11px; color: #64748b; }
+.panel-icon { color: #60a5fa; }
+.panel-icon.purple { color: #a855f7; }
+.panel-content { display: flex; flex-direction: column; gap: 15px; }
+
+.metric-row {
+  display: flex; justify-content: space-between; align-items: flex-start; gap: 12px;
+  padding: 15px; background: rgba(15, 23, 42, 0.5); border-radius: 8px; border: 1px solid #1e293b;
+}
+.metric-row--empty { opacity: 0.9; font-size: 12px; color: #94a3b8; }
+.metric-label { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
+.metric-title { font-size: 13px; color: #94a3b8; line-height: 1.35; }
+.metric-formula { font-size: 11px; color: #64748b; line-height: 1.4; font-weight: 400; }
+.metric-value { display: flex; align-items: center; gap: 8px; }
+.metric-value.right { flex-direction: row-reverse; }
+.metric-value .value { font-size: 24px; font-weight: 700; color: #e2e8f0; }
+.metric-value .value.success { color: #34d399; }
+.metric-value .value.warning { color: #fbbf24; }
+.metric-value .value.danger { color: #ef4444; }
+.metric-value .unit { font-size: 12px; color: #64748b; }
+.trend { font-size: 11px; font-weight: 600; }
+.trend.up { color: #34d399; }
+.trend.down { color: #ef4444; }
+
+.vs-divider { display: flex; align-items: center; justify-content: center; position: relative; width: 60px; }
+.vs-divider::before { content: ''; position: absolute; width: 100%; height: 2px; background: linear-gradient(90deg, #60a5fa, #a855f7); }
+.vs-text { background: #0b0f19; padding: 5px 10px; font-size: 12px; font-weight: 700; color: #64748b; z-index: 1; }
+
+.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px; }
+.chart-card { background: rgba(30, 41, 59, 0.4); border-radius: 12px; padding: 20px; border: 1px solid #1e293b; }
+.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
+.card-title { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 700; color: #e2e8f0; }
+.chart-legend { display: flex; gap: 20px; font-size: 12px; color: #94a3b8; flex-wrap: wrap; }
+.legend-item { display: flex; align-items: center; gap: 6px; }
+.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
+.chart-container { width: 100%; height: 300px; }
+.gauge-container { display: flex; justify-content: space-around; align-items: flex-end; padding: 20px; min-height: 230px; }
+.gauge-wrapper { text-align: center; flex: 1; }
+.gauge-chart { width: 100%; height: 150px; }
+.gauge-label { font-size: 13px; color: #94a3b8; margin-top: 10px; }
+.gauge-value { font-size: 24px; font-weight: 700; margin-top: 5px; }
+.gauge-value.success { color: #34d399; }
+.gauge-value.warning { color: #fbbf24; }
+.gauge-value.danger { color: #ef4444; }
+.gauge-empty { flex: 1; text-align: center; color: #64748b; font-size: 13px; padding-top: 60px; }
+
+.row-3 { margin-bottom: 15px; }
+.log-card { background: rgba(30, 41, 59, 0.4); border-radius: 12px; padding: 20px; border: 1px solid #1e293b; }
+.log-list { display: flex; flex-direction: column; gap: 10px; }
+.log-empty { color: #64748b; font-size: 12px; padding: 10px; }
+.log-item {
+  display: flex; align-items: center; gap: 15px;
+  padding: 12px; background: rgba(239, 68, 68, 0.1);
+  border-left: 4px solid #ef4444; border-radius: 4px;
+}
+.log-item.warning { background: rgba(251, 191, 36, 0.1); border-left-color: #fbbf24; }
+.log-time { font-size: 11px; color: #64748b; font-family: 'Courier New', monospace; min-width: 70px; }
+.log-message { flex: 1; font-size: 13px; color: #e2e8f0; }
+
+:deep(.el-button--small) { padding: 8px 16px; font-size: 12px; }
+:deep(.el-tag) { font-size: 10px; padding: 2px 8px; border-radius: 4px; }
+:deep(.el-tag--success) { background: rgba(52, 211, 153, 0.2); border-color: rgba(52, 211, 153, 0.3); color: #34d399; }
+:deep(.el-tag--warning) { background: rgba(251, 191, 36, 0.2); border-color: rgba(251, 191, 36, 0.3); color: #fbbf24; }
+:deep(.el-tag--danger) { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.3); color: #ef4444; }
+</style>

+ 14 - 1183
Web/src/views/aidop/kanban/s1.vue

@@ -1,1190 +1,21 @@
 <template>
-  <div class="s1-dashboard">
-    <!-- 顶部标题栏 -->
-    <div class="top-bar">
-      <div class="top-left">
-        <div class="page-title">
-          <el-icon :size="28"><Document /></el-icon>
-          <div>
-            <h1>产销协同指标看板</h1>
-            <div class="version-info">
-              <span>系统版本 V3.0.4</span>
-              <span class="status-dot"></span>
-              <span>状态:运行正常</span>
-            </div>
-          </div>
-        </div>
-      </div>
-      <div class="top-center">
-        <div class="search-box">
-          <el-input 
-            v-model="searchText" 
-            placeholder="搜索指标名称、部门 (如:工艺)..."
-            prefix-icon="Search"
-            clearable
-          />
-        </div>
-      </div>
-      <div class="top-right">
-        <div class="datetime">
-          <div class="date">{{ currentDate }}</div>
-          <div class="time">{{ currentTime }}</div>
-        </div>
-        <el-button type="primary" size="small">
-          <el-icon><Download /></el-icon>
-          导出实时快报
-        </el-button>
-        <el-button size="small">系统设置</el-button>
-      </div>
-    </div>
-
-    <!-- 模式切换栏 -->
-    <div class="mode-bar">
-      <div class="mode-left">
-        <el-button size="small" :type="activeMode === 'overview' ? 'primary' : ''">概览模式</el-button>
-        <el-button size="small" :type="activeMode === 'manufacturing' ? 'primary' : ''">制造侧监控</el-button>
-        <el-button size="small" :type="activeMode === 'sales' ? 'primary' : ''">销售侧分析</el-button>
-        <el-button size="small" :type="activeMode === 'supply' ? 'primary' : ''">供应链链动</el-button>
-      </div>
-      <div class="mode-right">
-        <span class="auto-refresh">自动轮播:</span>
-        <el-switch v-model="autoRefresh" />
-      </div>
-    </div>
-
-    <!-- 筛选栏 -->
-    <div class="filter-bar">
-      <div class="filter-left">
-        <span class="filter-label">部门视图</span>
-        <el-radio-group v-model="deptFilter" size="small">
-          <el-radio-button label="all">全部</el-radio-button>
-          <el-radio-button label="plan">主计划</el-radio-button>
-          <el-radio-button label="sales">销售</el-radio-button>
-          <el-radio-button label="design">设计</el-radio-button>
-          <el-radio-button label="tech">工艺</el-radio-button>
-        </el-radio-group>
-      </div>
-      <div class="filter-right">
-        <span class="filter-label">状态监控</span>
-        <el-radio-group v-model="statusFilter" size="small">
-          <el-radio-button label="all">全部</el-radio-button>
-          <el-radio-button label="optimal">最优</el-radio-button>
-          <el-radio-button label="standby">待机</el-radio-button>
-          <el-radio-button label="critical">严重</el-radio-button>
-        </el-radio-group>
-        <div class="showing-info">
-          展示:8 / 8 张卡片
-          <el-button link type="primary" size="small">
-            <el-icon><Refresh /></el-icon>
-            重置
-          </el-button>
-        </div>
-      </div>
-    </div>
-
-    <!-- 业务筛选:产销协同以订单/客户维度为主 -->
-    <DetailQueryBar
-      dark
-      band-title="基础查询:日期、产品、订单号、产线"
-      @query="onBizQuery"
-      @reset="onBizReset"
-    >
-      <SmartOpsBaseQueryFields v-model="bizQuery" compact />
+  <ModuleDashboardPage
+    module-code="S1"
+    title="S1 产销协同详情"
+    left-title="产销协同执行"
+    left-subtitle="合同/BOM/工艺/交期评审 — 周期与人效"
+    right-title="产销协同结果"
+    right-subtitle="评审满足率与流转周转"
+    :extra-query-init="{ customer: '' }"
+  >
+    <template #extra-fields="{ query }">
       <el-form-item label="客户">
-        <el-input v-model="bizQuery.customer" placeholder="客户名称/编码" clearable style="width: 160px" />
+        <el-input v-model="query.customer" placeholder="客户编码/名称" clearable style="width: 160px" />
       </el-form-item>
-    </DetailQueryBar>
-
-    <!-- 主内容区 -->
-    <div class="main-content">
-      <!-- 第一行 -->
-      <div class="row-1">
-        <!-- 评审满足率 -->
-        <div class="card metric-card">
-          <div class="card-header">
-            <span class="card-title">评审满足率(产销协同)</span>
-            <el-tag size="small" type="danger">未达标</el-tag>
-          </div>
-          <div class="metric-value">
-            <span class="value">{{ homeS1.reviewSatisfactionPct }}%</span>
-            <span class="trend down">↘未达标</span>
-          </div>
-          <div class="progress-section">
-            <div class="progress-label">
-              <span>目标达成率</span>
-              <span>{{ homeS1.reviewSatisfactionTargetPct }}% 目标</span>
-            </div>
-            <div class="progress-bar">
-              <div class="progress-fill" :style="{ width: `${homeS1.reviewSatisfactionPct}%` }"></div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 平均评审周期 -->
-        <div class="card metric-card">
-          <div class="card-header">
-            <span class="card-title">平均评审周期(制造协同)</span>
-            <el-tag size="small" type="warning">超目标</el-tag>
-          </div>
-          <div class="metric-value">
-            <span class="value large">{{ homeS1.avgReviewCycleDays }}</span>
-            <span class="unit">d</span>
-            <span class="trend up">目标 {{ homeS1.avgReviewCycleTargetDays }}d</span>
-          </div>
-          <div class="sub-metrics">
-            <div class="sub-metric">
-              <div class="label">成品库存周转</div>
-              <div class="value">{{ homeS1.finishedGoodsInventoryDays }}d</div>
-            </div>
-            <div class="sub-metric">
-              <div class="label">目标</div>
-              <div class="value">{{ homeS1.finishedGoodsInventoryTargetDays }}d</div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 关键约束环节 -->
-        <div class="card metric-card">
-          <div class="card-header">
-            <span class="card-title">关键约束环节(供应协同)</span>
-          </div>
-          <div class="constraint-list">
-            <div class="constraint-item">
-              <span class="dot"></span>
-              <span class="label">模具满足率</span>
-              <span class="value">92%</span>
-            </div>
-            <div class="constraint-item">
-              <span class="dot warning"></span>
-              <span class="label">产能负荷比</span>
-              <span class="value warning">89%</span>
-            </div>
-            <div class="constraint-item">
-              <span class="dot warning"></span>
-              <span class="label">物料齐套率</span>
-              <span class="value warning">{{ homeS1.constraintMaterialKitPct }}%</span>
-            </div>
-            <div class="constraint-item">
-              <span class="dot"></span>
-              <span class="label">设备综合效率(OEE)</span>
-              <span class="value">84%</span>
-            </div>
-          </div>
-        </div>
-
-        <!-- 评审队列负荷 -->
-        <div class="card gauge-card">
-          <div class="card-header">
-            <span class="card-title">评审队列负荷</span>
-          </div>
-          <div id="gauge-pressure" class="gauge-chart"></div>
-          <div class="gauge-footer">
-            <div class="label">待评审订单数</div>
-            <div class="value">128 <span class="unit">单</span></div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 第二行 -->
-      <div class="row-2">
-        <!-- 评审满足率与周期趋势 -->
-        <div class="card large-chart-card">
-          <div class="card-header">
-            <div>
-              <span class="card-title">评审满足率与周期趋势(采购执行联动)</span>
-              <div class="subtitle">实时数据流</div>
-            </div>
-            <div class="legend">
-              <span class="legend-item">
-                <span class="dot" style="background: #60a5fa"></span>
-                满足率
-              </span>
-              <span class="legend-item">
-                <span class="dot" style="background: #34d399"></span>
-                均值线
-              </span>
-            </div>
-          </div>
-          <div id="chart-trend" class="main-chart"></div>
-        </div>
-
-        <!-- 异常流 -->
-        <div class="card anomaly-card">
-          <div class="card-header">
-            <span class="card-title">
-              <span class="live-dot"></span>
-              异常实时流
-            </span>
-            <span class="buffer-info">缓冲区:1024KB</span>
-          </div>
-          <div class="anomaly-list">
-            <div v-for="(item, idx) in anomalyItems" :key="`${item.time}-${idx}`" class="anomaly-item" :class="item.levelClass">
-              <div class="anomaly-header">
-                <span class="tag">{{ item.tag }}</span>
-                <span class="time">{{ item.time }}</span>
-              </div>
-              <div class="anomaly-message">{{ item.message }}</div>
-              <div v-if="item.source" class="anomaly-source">{{ item.source }}</div>
-            </div>
-          </div>
-          <div class="command-input">
-            <el-input 
-              v-model="commandText" 
-              placeholder="输入指令以响应..."
-              size="small"
-            >
-              <template #append>
-                <el-button type="primary">执行</el-button>
-              </template>
-            </el-input>
-          </div>
-        </div>
-      </div>
-
-      <!-- 第三行 -->
-      <div class="row-3">
-        <!-- 异常状态分布 -->
-        <div class="card chart-card">
-          <div class="card-header">
-            <span class="card-title">异常状态分布(物料仓储)</span>
-          </div>
-          <div id="chart-donut" class="donut-chart"></div>
-        </div>
-
-        <!-- 人效热度监控 -->
-        <div class="card chart-card">
-          <div class="card-header">
-            <span class="card-title">人效热度监控(生产执行)</span>
-          </div>
-          <div id="chart-bar" class="bar-chart"></div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 底部版权信息 -->
-    <div class="footer">
-      <div class="footer-left">数据流已加密(AES-256-GCM)</div>
-      <div class="footer-right">版权所有 © 2026 核心智能运营 · 系统运行正常</div>
-    </div>
-  </div>
+    </template>
+  </ModuleDashboardPage>
 </template>
 
 <script setup>
-import { ref, computed, watch, onMounted, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import { Document, Search, Download, Refresh } from '@element-plus/icons-vue'
-import DetailQueryBar from './components/DetailQueryBar.vue'
-import SmartOpsBaseQueryFields from './components/SmartOpsBaseQueryFields.vue'
-import {
-  emptySmartOpsBaseQuery,
-  blobMatchesSmartOpsBase,
-  textMatches,
-  summarizeSmartOpsBaseQuery,
-} from './utils/smartOpsBaseQuery'
-import * as echarts from 'echarts'
-import { homeS1, loadHomeModuleMetrics } from './data/homeModulesSync'
-import { fetchModuleDetail } from '../api/kanbanData'
-
-const router = useRouter()
-
-const searchText = ref('')
-const commandText = ref('')
-const currentTime = ref('')
-const currentDate = ref('')
-const autoRefresh = ref(true)
-const activeMode = ref('overview')
-const deptFilter = ref('all')
-const statusFilter = ref('all')
-const trendXAxis = ref(['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8'])
-const barXAxis = ref(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'])
-const barSeries = ref([65, 78, 52, 92, 75, 68])
-
-const bizQuery = ref({
-  ...emptySmartOpsBaseQuery(),
-  customer: '',
-})
-
-function buildDonutFromList(list) {
-  const c = list.filter((x) => x.levelClass === 'critical').length
-  const w = list.filter((x) => x.levelClass === 'warning').length
-  const i = list.filter((x) => x.levelClass === 'info').length
-  const standby = Math.max(0, list.length - c - w - i)
-  return [
-    { value: c, name: '严重', itemStyle: { color: '#f87171' } },
-    { value: w, name: '警告', itemStyle: { color: '#fbbf24' } },
-    { value: i, name: '正常', itemStyle: { color: '#34d399' } },
-    { value: standby, name: '待机', itemStyle: { color: '#60a5fa' } },
-  ]
-}
-
-const anomalyItemsAll = ref([])
-
-const anomalyItems = computed(() =>
-  anomalyItemsAll.value.filter((x) => {
-    const blob = `${x.message} ${x.tag} ${x.source}`
-    if (!blobMatchesSmartOpsBase(bizQuery.value, blob)) return false
-    if (!textMatches(bizQuery.value.customer, blob)) return false
-    return true
-  })
-)
-
-const donutSeries = ref(buildDonutFromList([]))
-
-watch(
-  anomalyItems,
-  (list) => {
-    donutSeries.value = buildDonutFromList(list)
-    nextTick(() => donutChart?.setOption({ series: [{ data: donutSeries.value }] }))
-  },
-  { deep: true }
-)
-
-function onBizQuery() {
-  ElMessage.success(`已应用筛选(${summarizeSmartOpsBaseQuery(bizQuery.value)})`)
-}
-
-function onBizReset() {
-  bizQuery.value = { ...emptySmartOpsBaseQuery(), customer: '' }
-  ElMessage.info('已重置业务筛选')
-}
-
-let gaugeChart = null
-let trendChart = null
-let donutChart = null
-let barChart = null
-
-const updateTime = () => {
-  const now = new Date()
-  currentTime.value = now.toLocaleString('zh-CN', {
-    year: 'numeric',
-    month: '2-digit',
-    day: '2-digit',
-    hour: '2-digit',
-    minute: '2-digit',
-    second: '2-digit',
-    hour12: false
-  })
-  currentDate.value = now.toLocaleDateString('zh-CN')
-}
-
-// 初始化评审队列负荷仪表
-const initGaugeChart = () => {
-  const chartDom = document.getElementById('gauge-pressure')
-  if (!chartDom) return
-  
-  gaugeChart = echarts.init(chartDom)
-  const option = {
-    series: [{
-      type: 'gauge',
-      startAngle: 180,
-      endAngle: 0,
-      min: 0,
-      max: 100,
-      radius: '90%',
-      center: ['50%', '60%'],
-      itemStyle: {
-        color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
-          { offset: 0, color: '#34d399' },
-          { offset: 0.5, color: '#fbbf24' },
-          { offset: 1, color: '#f87171' }
-        ])
-      },
-      progress: {
-        show: true,
-        width: 12,
-        roundCap: true
-      },
-      pointer: { show: false },
-      axisLine: {
-        lineStyle: {
-          width: 12,
-          color: [[1, '#1e293b']]
-        }
-      },
-      axisTick: { show: false },
-      splitLine: { show: false },
-      axisLabel: {
-        show: true,
-        color: '#64748b',
-        fontSize: 10,
-        distance: -15
-      },
-      title: { show: false },
-      detail: {
-        valueAnimation: true,
-        offsetCenter: [0, '10%'],
-        fontSize: 24,
-        fontWeight: 'bold',
-        color: '#e2e8f0',
-        formatter: '{value}%'
-      },
-      data: [{ value: homeS1.systemPressureGaugePct }]
-    }]
-  }
-  gaugeChart.setOption(option)
-}
-
-// 初始化趋势图
-const initTrendChart = () => {
-  const chartDom = document.getElementById('chart-trend')
-  if (!chartDom) return
-  
-  trendChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' },
-      axisPointer: { type: 'cross' }
-    },
-    grid: {
-      left: '3%',
-      right: '4%',
-      bottom: '10%',
-      top: '15%',
-      containLabel: true
-    },
-    xAxis: {
-      type: 'category',
-      boundaryGap: false,
-      data: trendXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: {
-      type: 'value',
-      min: 70,
-      max: 100,
-      splitLine: {
-        lineStyle: {
-          color: '#1e293b',
-          type: 'solid'
-        }
-      },
-      axisLabel: { color: '#64748b' }
-    },
-    series: [
-      {
-        name: '满足率',
-        type: 'line',
-        smooth: true,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: {
-          color: '#60a5fa',
-          borderColor: '#1e293b',
-          borderWidth: 2
-        },
-        lineStyle: {
-          color: '#60a5fa',
-          width: 3
-        },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: 'rgba(96, 165, 250, 0.3)' },
-            { offset: 1, color: 'rgba(96, 165, 250, 0)' }
-          ])
-        },
-        data: homeS1.trendSatisfaction
-      },
-      {
-        name: '均值线',
-        type: 'line',
-        smooth: true,
-        symbol: 'none',
-        lineStyle: {
-          color: '#34d399',
-          width: 2,
-          type: 'dashed'
-        },
-        data: Array(8).fill(homeS1.trendMeanLine)
-      }
-    ]
-  }
-  trendChart.setOption(option)
-}
-
-// 初始化环形图
-const initDonutChart = () => {
-  const chartDom = document.getElementById('chart-donut')
-  if (!chartDom) return
-  
-  donutChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'item',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    series: [{
-      name: '异常状态',
-      type: 'pie',
-      radius: ['50%', '70%'],
-      center: ['50%', '50%'],
-      avoidLabelOverlap: false,
-      label: {
-        show: false
-      },
-      emphasis: {
-        label: {
-          show: true,
-          fontSize: 14,
-          fontWeight: 'bold',
-          color: '#e2e8f0'
-        }
-      },
-      labelLine: { show: false },
-      data: donutSeries.value
-    }]
-  }
-  donutChart.setOption(option)
-}
-
-// 初始化柱状图
-const initBarChart = () => {
-  const chartDom = document.getElementById('chart-bar')
-  if (!chartDom) return
-  
-  barChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    grid: {
-      left: '3%',
-      right: '4%',
-      bottom: '15%',
-      top: '10%',
-      containLabel: true
-    },
-    xAxis: {
-      type: 'category',
-      data: barXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: {
-      type: 'value',
-      show: false
-    },
-    series: [{
-      data: barSeries.value,
-      type: 'bar',
-      barWidth: '60%',
-      itemStyle: {
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: '#60a5fa' },
-          { offset: 1, color: '#3b82f6' }
-        ]),
-        borderRadius: [4, 4, 0, 0]
-      },
-      showBackground: true,
-      backgroundStyle: {
-        color: 'rgba(51, 65, 85, 0.3)',
-        borderRadius: [4, 4, 0, 0]
-      }
-    }]
-  }
-  barChart.setOption(option)
-}
-
-const initCharts = () => {
-  nextTick(() => {
-    initGaugeChart()
-    initTrendChart()
-    initDonutChart()
-    initBarChart()
-    
-    window.addEventListener('resize', () => {
-      gaugeChart?.resize()
-      trendChart?.resize()
-      donutChart?.resize()
-      barChart?.resize()
-    })
-  })
-}
-
-onMounted(async () => {
-  await loadHomeModuleMetrics()
-  const detail = await fetchModuleDetail('S1')
-  anomalyItemsAll.value = (detail.alerts ?? []).map((x) => ({
-    tag: `[${String(x.level ?? 'info').toUpperCase()}]`,
-    time: x.time ?? '--:--:--',
-    message: x.message ?? '异常告警',
-    source: 'Source: Ai-DOP / Module: S1',
-    levelClass: ['critical', 'high'].includes(String(x.level)) ? 'critical' : String(x.level) === 'warning' ? 'warning' : 'info'
-  }))
-  if ((detail.l2 ?? []).length > 0) {
-    const l2 = detail.l2
-    trendXAxis.value = l2.slice(0, 8).map((x, i) => (x.statDate ? String(x.statDate).slice(5, 10) : `D${i + 1}`))
-    barXAxis.value = l2.slice(0, 6).map((x, i) => x.metricName || `N${i + 1}`)
-    barSeries.value = l2.slice(0, 6).map((x) => Math.max(0, Math.min(100, Number(x.metricValue ?? 0))))
-  }
-  updateTime()
-  setInterval(updateTime, 1000)
-  initCharts()
-})
+import ModuleDashboardPage from './components/ModuleDashboardPage.vue'
 </script>
-
-<style scoped>
-.s1-dashboard {
-  box-sizing: border-box;
-  flex: 1;
-  min-height: 0;
-  max-height: 100%;
-  overflow-x: hidden;
-  overflow-y: auto;
-  -webkit-overflow-scrolling: touch;
-  background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
-  padding: 15px;
-  padding-bottom: 24px;
-  color: #e2e8f0;
-}
-
-/* 顶部标题栏 */
-.top-bar {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  background: rgba(15, 23, 42, 0.8);
-  padding: 15px 20px;
-  border-radius: 8px;
-  margin-bottom: 15px;
-  border: 1px solid #1e293b;
-}
-
-.top-left {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.page-title {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  color: #60a5fa;
-}
-
-.page-title h1 {
-  margin: 0;
-  font-size: 22px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.version-info {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 11px;
-  color: #64748b;
-  margin-top: 4px;
-}
-
-.status-dot {
-  width: 6px;
-  height: 6px;
-  border-radius: 50%;
-  background: #34d399;
-}
-
-.top-center {
-  flex: 1;
-  max-width: 500px;
-  margin: 0 30px;
-}
-
-.search-box :deep(.el-input__wrapper) {
-  background: rgba(30, 41, 59, 0.6);
-  border: 1px solid #334155;
-}
-
-.search-box :deep(.el-input__inner) {
-  color: #e2e8f0;
-}
-
-.top-right {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.datetime {
-  text-align: right;
-  margin-right: 10px;
-}
-
-.datetime .date {
-  font-size: 13px;
-  color: #93c5fd;
-  font-weight: 600;
-}
-
-.datetime .time {
-  font-size: 18px;
-  color: #60a5fa;
-  font-weight: 700;
-  font-family: 'Courier New', monospace;
-}
-
-/* 模式切换栏 */
-.mode-bar {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  background: rgba(30, 41, 59, 0.6);
-  padding: 10px 15px;
-  border-radius: 6px;
-  margin-bottom: 15px;
-  border: 1px solid #334155;
-}
-
-.mode-left {
-  display: flex;
-  gap: 10px;
-}
-
-.mode-right {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  color: #94a3b8;
-  font-size: 13px;
-}
-
-/* 筛选栏 */
-.filter-bar {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  background: rgba(30, 41, 59, 0.4);
-  padding: 12px 15px;
-  border-radius: 6px;
-  margin-bottom: 15px;
-  border: 1px solid #1e293b;
-}
-
-.filter-left, .filter-right {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.filter-label {
-  font-size: 12px;
-  color: #64748b;
-  font-weight: 600;
-}
-
-.showing-info {
-  font-size: 12px;
-  color: #64748b;
-  margin-left: 20px;
-  display: flex;
-  align-items: center;
-  gap: 10px;
-}
-
-/* 主内容区 */
-.main-content {
-  display: flex;
-  flex-direction: column;
-  gap: 15px;
-}
-
-.row-1 {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 15px;
-}
-
-.row-2 {
-  display: grid;
-  grid-template-columns: 2fr 1fr;
-  gap: 15px;
-}
-
-.row-3 {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  gap: 15px;
-}
-
-/* 卡片通用样式 */
-.card {
-  background: rgba(30, 41, 59, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #334155;
-}
-
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.card-title {
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.subtitle {
-  font-size: 10px;
-  color: #64748b;
-  margin-top: 4px;
-}
-
-/* 指标卡片 */
-.metric-card .metric-value {
-  display: flex;
-  align-items: baseline;
-  gap: 8px;
-  margin-bottom: 15px;
-}
-
-.metric-card .value {
-  font-size: 36px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.metric-card .value.large {
-  font-size: 48px;
-}
-
-.metric-card .unit {
-  font-size: 16px;
-  color: #64748b;
-}
-
-.trend {
-  font-size: 13px;
-  font-weight: 700;
-}
-
-.trend.up {
-  color: #34d399;
-}
-
-.trend.down {
-  color: #f87171;
-}
-
-.progress-section {
-  margin-top: 10px;
-}
-
-.progress-label {
-  display: flex;
-  justify-content: space-between;
-  font-size: 11px;
-  color: #94a3b8;
-  margin-bottom: 6px;
-}
-
-.progress-bar {
-  height: 6px;
-  background: #1e293b;
-  border-radius: 3px;
-  overflow: hidden;
-}
-
-.progress-fill {
-  height: 100%;
-  background: linear-gradient(90deg, #60a5fa, #3b82f6);
-  border-radius: 3px;
-}
-
-.sub-metrics {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 10px;
-  margin-top: 15px;
-}
-
-.sub-metric {
-  background: rgba(0, 0, 0, 0.2);
-  padding: 10px;
-  border-radius: 4px;
-}
-
-.sub-metric .label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 4px;
-}
-
-.sub-metric .value {
-  font-size: 16px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-/* 约束列表 */
-.constraint-list {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-}
-
-.constraint-item {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  padding: 8px;
-  background: rgba(0, 0, 0, 0.2);
-  border-radius: 4px;
-}
-
-.constraint-item .dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  background: #34d399;
-}
-
-.constraint-item .dot.warning {
-  background: #fbbf24;
-}
-
-.constraint-item .label {
-  flex: 1;
-  font-size: 12px;
-  color: #94a3b8;
-}
-
-.constraint-item .value {
-  font-size: 14px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.constraint-item .value.warning {
-  color: #fbbf24;
-}
-
-/* 仪表盘卡片 */
-.gauge-card {
-  text-align: center;
-}
-
-.gauge-chart {
-  width: 100%;
-  height: 150px;
-}
-
-.gauge-footer {
-  margin-top: 10px;
-}
-
-.gauge-footer .label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 4px;
-}
-
-.gauge-footer .value {
-  font-size: 20px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.gauge-footer .unit {
-  font-size: 12px;
-  color: #64748b;
-}
-
-/* 大图表卡片 */
-.large-chart-card .main-chart {
-  width: 100%;
-  height: 350px;
-}
-
-.legend {
-  display: flex;
-  gap: 15px;
-  font-size: 12px;
-  color: #64748b;
-}
-
-.legend-item {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.legend-item .dot {
-  width: 10px;
-  height: 10px;
-  border-radius: 50%;
-}
-
-/* 异常流卡片 */
-.anomaly-card {
-  display: flex;
-  flex-direction: column;
-}
-
-.anomaly-card .card-header {
-  margin-bottom: 12px;
-}
-
-.anomaly-card .card-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.live-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  background: #ef4444;
-  animation: pulse 2s infinite;
-}
-
-@keyframes pulse {
-  0%, 100% { opacity: 1; }
-  50% { opacity: 0.5; }
-}
-
-.buffer-info {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.anomaly-list {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  overflow-y: auto;
-  max-height: 400px;
-  margin-bottom: 12px;
-}
-
-.anomaly-item {
-  padding: 10px;
-  background: rgba(0, 0, 0, 0.2);
-  border-radius: 4px;
-  border-left: 3px solid;
-}
-
-.anomaly-item.critical {
-  border-color: #ef4444;
-  background: rgba(239, 68, 68, 0.1);
-}
-
-.anomaly-item.warning {
-  border-color: #f59e0b;
-  background: rgba(245, 158, 11, 0.1);
-}
-
-.anomaly-item.info {
-  border-color: #3b82f6;
-  background: rgba(59, 130, 246, 0.1);
-}
-
-.anomaly-item.success {
-  border-color: #10b981;
-  background: rgba(16, 185, 129, 0.1);
-}
-
-.anomaly-item.stall {
-  border-color: #8b5cf6;
-  background: rgba(139, 92, 246, 0.1);
-}
-
-.anomaly-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 6px;
-}
-
-.anomaly-header .tag {
-  font-size: 10px;
-  font-weight: 700;
-  padding: 2px 6px;
-  border-radius: 3px;
-}
-
-.anomaly-item.critical .tag { color: #ef4444; background: rgba(239, 68, 68, 0.2); }
-.anomaly-item.warning .tag { color: #f59e0b; background: rgba(245, 158, 11, 0.2); }
-.anomaly-item.info .tag { color: #3b82f6; background: rgba(59, 130, 246, 0.2); }
-.anomaly-item.success .tag { color: #10b981; background: rgba(16, 185, 129, 0.2); }
-.anomaly-item.stall .tag { color: #8b5cf6; background: rgba(139, 92, 246, 0.2); }
-
-.anomaly-header .time {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.anomaly-message {
-  font-size: 12px;
-  color: #e2e8f0;
-  margin-bottom: 4px;
-}
-
-.anomaly-source {
-  font-size: 10px;
-  color: #64748b;
-}
-
-.command-input {
-  margin-top: 10px;
-}
-
-/* 图表卡片 */
-.chart-card .donut-chart,
-.chart-card .bar-chart {
-  width: 100%;
-  height: 200px;
-}
-
-/* 底部版权信息 */
-.footer {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-top: 20px;
-  padding-top: 15px;
-  border-top: 1px solid #1e293b;
-  font-size: 10px;
-  color: #475569;
-}
-
-/* Element Plus 覆盖 */
-:deep(.el-radio-button__inner) {
-  background: rgba(30, 41, 59, 0.6);
-  border: 1px solid #334155;
-  color: #94a3b8;
-}
-
-:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
-  background: linear-gradient(135deg, #3b82f6, #2563eb);
-  border-color: #3b82f6;
-  color: white;
-}
-
-:deep(.el-button--primary) {
-  background: linear-gradient(135deg, #3b82f6, #2563eb);
-  border: none;
-}
-
-:deep(.el-tag--success) {
-  background: rgba(16, 185, 129, 0.2);
-  border-color: rgba(16, 185, 129, 0.3);
-  color: #10b981;
-}
-</style>

+ 10 - 974
Web/src/views/aidop/kanban/s2.vue

@@ -1,979 +1,15 @@
 <template>
-  <div class="s2-dashboard">
-    <!-- 顶部导航栏 -->
-    <div class="top-nav">
-      <div class="top-left">
-        <el-button link @click="$router.push('/')" class="back-btn">
-          <el-icon><ArrowLeft /></el-icon>
-          返回主面板
-        </el-button>
-        <span class="page-title">S2 订单排程详情</span>
-        <span class="version">V5.0 正式版</span>
-      </div>
-      <div class="top-right">
-        <div class="current-time">
-          <div class="label">当前系统时间</div>
-          <div class="time">{{ currentTime }}</div>
-        </div>
-        <el-button-group class="time-range">
-          <el-button size="small">今日</el-button>
-          <el-button size="small" type="primary">本周</el-button>
-          <el-button size="small">本月</el-button>
-        </el-button-group>
-        <el-button size="small" circle><el-icon><Refresh /></el-icon></el-button>
-        <el-button size="small" circle><el-icon><Upload /></el-icon></el-button>
-        <el-button size="small" circle><el-icon><Setting /></el-icon></el-button>
-      </div>
-    </div>
-
-    <!-- 排程维度:日期、订单、产线 -->
-    <DetailQueryBar
-      dark
-      band-title="基础查询:日期、产品、订单号、产线"
-      @query="onDetailQuery"
-      @reset="onDetailQueryReset"
-    >
-      <SmartOpsBaseQueryFields v-model="detailQuery" compact />
-    </DetailQueryBar>
-
-    <!-- 第一行:核心指标(与首页 S2 模块数据一致,见 src/data/s2Kpis.js) -->
-    <div class="row-1">
-      <div
-        v-for="(card, idx) in s2DetailTopCards"
-        :key="card.key"
-        class="kpi-card"
-        :class="`kpi-${idx + 1}`"
-      >
-        <div class="kpi-header">
-          <span class="kpi-title">{{ card.title }}</span>
-          <el-icon class="icon"><component :is="s2DetailCardIcons[card.key]" /></el-icon>
-        </div>
-        <div class="kpi-value">
-          <span class="value">{{ card.valueMain }}</span>
-          <span class="unit">{{ card.valueUnit }}</span>
-          <span class="trend" :class="card.trendClass">{{ card.trendText }}</span>
-        </div>
-        <div class="kpi-bar" :class="card.barWrapClass">
-          <div class="bar-fill" :style="{ width: `${card.barWidthPct}%` }"></div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 第二行:分解柱系统 -->
-    <div class="row-2">
-      <div class="decomposition-section">
-        <div class="section-header">
-          <div class="section-title">
-            <el-icon><Cpu /></el-icon>
-            <span>制造协同全流程分解柱系统</span>
-          </div>
-          <div class="layer-switch">
-            <el-radio-group v-model="layerView" size="small">
-              <el-radio label="strategy">战略层</el-radio>
-              <el-radio label="execution">执行层</el-radio>
-            </el-radio-group>
-          </div>
-        </div>
-        <div class="decomposition-cards">
-          <div class="decomp-card active">
-            <div class="decomp-title">订单排程</div>
-            <div class="decomp-metrics">
-              <div class="metric">1. 周期:22D</div>
-              <div class="metric">2. 满足率:85%</div>
-              <div class="metric">3. 库存:23D</div>
-              <div class="metric">4. 人效:18.5</div>
-            </div>
-          </div>
-          <div class="decomp-card">
-            <div class="decomp-title">工单排程</div>
-            <div class="decomp-metrics">
-              <div class="metric">1. 周期:1.2D</div>
-              <div class="metric">2. 满足率:94.5%</div>
-              <div class="metric">3. 人数:120/H</div>
-            </div>
-          </div>
-          <div class="decomp-card">
-            <div class="decomp-title">工序排程</div>
-            <div class="decomp-metrics">
-              <div class="metric">1. 周期:0.8D</div>
-              <div class="metric">2. 满足率:92.1%</div>
-              <div class="metric">3. 转产:2.5H</div>
-            </div>
-          </div>
-          <div class="decomp-card">
-            <div class="decomp-title">设备排程</div>
-            <div class="decomp-metrics">
-              <div class="metric">1. OEE:88.4%</div>
-              <div class="metric">2. 稼动率:91.2%</div>
-              <div class="metric">3. 停机:1.2%</div>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <div class="alert-section">
-        <div class="section-header">
-          <span class="section-title danger">
-            <el-icon><Warning /></el-icon>
-            S2 排程实时异常管控
-          </span>
-          <el-tag size="small" type="danger">活动告警</el-tag>
-        </div>
-        <div class="alert-main">
-          <div class="alert-value">{{ alertTotal }}</div>
-          <div class="alert-label">当前未处理警报</div>
-          <div id="alert-gauge" class="alert-gauge"></div>
-        </div>
-        <div class="alert-stats">
-          <div class="alert-stat">
-            <div class="stat-value">{{ alertCritical }}</div>
-            <div class="stat-label">资源冲突</div>
-          </div>
-          <div class="alert-stat">
-            <div class="stat-value">{{ alertWarning }}</div>
-            <div class="stat-label">缺料预警</div>
-          </div>
-          <div class="alert-stat">
-            <div class="stat-value">{{ alertInfo }}</div>
-            <div class="stat-label">计划变更</div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 第三行:图表区 -->
-    <div class="row-3">
-      <div class="chart-card">
-        <div class="card-header">
-          <span class="card-title">
-            <el-icon><TrendCharts /></el-icon>
-            订单排程近 7 日趋势
-          </span>
-        </div>
-        <div id="chart-trend" class="chart-container"></div>
-      </div>
-
-      <div class="chart-card">
-        <div class="card-header">
-          <span class="card-title">
-            <el-icon><Histogram /></el-icon>
-            各车间排程满足率分布
-          </span>
-        </div>
-        <div id="chart-bar" class="chart-container"></div>
-      </div>
-
-      <div class="log-card">
-        <div class="card-header">
-          <span class="card-title">
-            <el-icon><Document /></el-icon>
-            实时排程日志
-          </span>
-          <span class="update-time">更新:约 3 秒前</span>
-        </div>
-        <div class="log-list">
-          <div v-for="(item, idx) in logItems" :key="`${item.time}-${idx}`" class="log-item" :class="item.levelClass">
-            <div class="log-time">{{ item.time }}</div>
-            <div class="log-tag">{{ item.tag }}</div>
-            <div class="log-message">{{ item.message }}<br><span class="log-detail">{{ item.detail }}</span></div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 第四行:订单明细表 -->
-    <div class="row-4">
-      <div class="table-card">
-        <div class="card-header">
-          <span class="card-title">
-            <el-icon><List /></el-icon>
-            当前待排订单明细追踪
-          </span>
-          <div class="table-actions">
-            <el-input 
-              v-model="searchText" 
-              placeholder="搜索订单号/客户..."
-              prefix-icon="Search"
-              size="small"
-              clearable
-            />
-            <el-button type="primary" size="small">
-              <el-icon><Download /></el-icon>
-              导出排程表
-            </el-button>
-          </div>
-        </div>
-        <el-table :data="filteredOrderList" style="width: 100%" class="data-table">
-          <el-table-column prop="orderNo" label="订单编号" width="180" />
-          <el-table-column prop="customer" label="客户信息" width="200" />
-          <el-table-column label="交付数量 期望交期" width="180">
-            <template #default="{ row }">
-              <div><strong>{{ row.quantity }}</strong> <span class="sub">{{ row.deliveryDate }}</span></div>
-            </template>
-          </el-table-column>
-          <el-table-column label="排程状态" width="120">
-            <template #default="{ row }">
-              <el-tag :type="row.statusType" size="small">{{ row.status }}</el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column label="预计开工" width="180">
-            <template #default="{ row }">
-              {{ row.startTime || '-- : --' }}
-            </template>
-          </el-table-column>
-          <el-table-column label="资源满足度" width="200">
-            <template #default="{ row }">
-              <div class="satisfaction-bar">
-                <div class="bar-fill" :class="row.satisfactionClass" :style="{ width: row.satisfaction + '%' }"></div>
-                <span class="bar-text">{{ row.satisfaction }}%</span>
-              </div>
-            </template>
-          </el-table-column>
-          <el-table-column label="操作" width="100" fixed="right">
-            <template #default>
-              <el-button link type="primary" size="small">详情</el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-      </div>
-    </div>
-  </div>
+  <ModuleDashboardPage
+    module-code="S2"
+    title="S2 订单排程详情"
+    left-title="订单排程执行"
+    left-subtitle="工单排程 — 周期与人效"
+    right-title="订单排程结果"
+    right-subtitle="排程满足率与交付达成"
+    :extra-query-init="{}"
+  />
 </template>
 
 <script setup>
-import { ref, computed, onMounted, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import DetailQueryBar from './components/DetailQueryBar.vue'
-import SmartOpsBaseQueryFields from './components/SmartOpsBaseQueryFields.vue'
-import { emptySmartOpsBaseQuery, rowMatchesSmartOpsBase, summarizeSmartOpsBaseQuery } from './utils/smartOpsBaseQuery'
-import {
-  ArrowLeft, Refresh, Upload, Setting, Timer, CircleCheck,
-  RefreshLeft, Cpu, Warning, TrendCharts, Histogram,
-  Document, List, Search, Download
-} from '@element-plus/icons-vue'
-import * as echarts from 'echarts'
-import { s2DetailTopCards, s2TrendSeries, loadS2Kpis } from './data/s2Kpis'
-import { fetchModuleDetail } from '../api/kanbanData'
-
-const router = useRouter()
-const currentTime = ref('')
-const layerView = ref('strategy')
-const searchText = ref('')
-
-const detailQuery = ref(emptySmartOpsBaseQuery())
-
-function onDetailQuery() {
-  ElMessage.success(`已应用排程筛选(${summarizeSmartOpsBaseQuery(detailQuery.value)})`)
-}
-
-function onDetailQueryReset() {
-  detailQuery.value = emptySmartOpsBaseQuery()
-  ElMessage.info('已重置')
-}
-
-const s2DetailCardIcons = {
-  satisfaction: CircleCheck,
-  cycle: Timer,
-  wip: RefreshLeft,
-  labor: TrendCharts
-}
-
-const orderListAll = ref([])
-
-const filteredOrderList = computed(() => {
-  const q = detailQuery.value
-  let rows = orderListAll.value.filter((r) =>
-    rowMatchesSmartOpsBase(q, {
-      date: r.deliveryDate,
-      product: r.product,
-      orderNo: r.orderNo,
-      productionLine: r.productionLine,
-    })
-  )
-  const t = searchText.value.trim().toLowerCase()
-  if (t) {
-    rows = rows.filter((r) => `${r.orderNo} ${r.customer}`.toLowerCase().includes(t))
-  }
-  return rows
-})
-const logItems = ref([])
-const alertTotal = ref(0)
-const alertCritical = ref(0)
-const alertWarning = ref(0)
-const alertInfo = ref(0)
-const trendXAxis = ref(['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'])
-const barXAxis = ref(['线体1', '线体2', '线体3', '线体4'])
-const barValues = ref([88, 91, 85, 87])
-
-let alertGauge = null
-let trendChart = null
-let barChart = null
-
-const updateTime = () => {
-  const now = new Date()
-  currentTime.value = now.toLocaleString('zh-CN', {
-    year: 'numeric',
-    month: '2-digit',
-    day: '2-digit',
-    hour: '2-digit',
-    minute: '2-digit',
-    second: '2-digit',
-    hour12: false
-  })
-}
-
-const initAlertGauge = () => {
-  const chartDom = document.getElementById('alert-gauge')
-  if (!chartDom) return
-  
-  alertGauge = echarts.init(chartDom)
-  const option = {
-    series: [{
-      type: 'gauge',
-      startAngle: 180,
-      endAngle: 0,
-      min: 0,
-      max: 100,
-      radius: '90%',
-      center: ['50%', '60%'],
-      itemStyle: { color: '#ef4444' },
-      progress: {
-        show: true,
-        width: 8,
-        roundCap: true
-      },
-      pointer: { show: false },
-      axisLine: { lineStyle: { width: 8, color: [[1, '#1e293b']] } },
-      axisTick: { show: false },
-      splitLine: { show: false },
-      axisLabel: { show: false },
-      title: { show: false },
-      detail: { show: false },
-      data: [{ value: Math.min(100, Math.max(0, alertTotal.value)) }]
-    }]
-  }
-  alertGauge.setOption(option)
-}
-
-const initTrendChart = () => {
-  const chartDom = document.getElementById('chart-trend')
-  if (!chartDom) return
-  
-  trendChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    legend: {
-      data: ['排程周期 (天)', '满足率 (%)'],
-      textStyle: { color: '#94a3b8' },
-      top: 0
-    },
-    grid: { left: '3%', right: '4%', bottom: '10%', top: '15%', containLabel: true },
-    xAxis: {
-      type: 'category',
-      boundaryGap: false,
-      data: trendXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: [
-      {
-        type: 'value',
-        name: '周期',
-        min: 18,
-        max: 25,
-        position: 'left',
-        splitLine: { lineStyle: { color: '#1e293b' } },
-        axisLabel: { color: '#64748b' }
-      },
-      {
-        type: 'value',
-        name: '满足率',
-        min: 82,
-        max: 100,
-        position: 'right',
-        splitLine: { show: false },
-        axisLabel: { color: '#64748b' }
-      }
-    ],
-    series: [
-      {
-        name: '排程周期 (天)',
-        type: 'line',
-        smooth: true,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: { color: '#60a5fa' },
-        lineStyle: { color: '#60a5fa', width: 2 },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: 'rgba(96, 165, 250, 0.2)' },
-            { offset: 1, color: 'rgba(96, 165, 250, 0)' }
-          ])
-        },
-        data: s2TrendSeries.cycleDays
-      },
-      {
-        name: '满足率 (%)',
-        type: 'line',
-        yAxisIndex: 1,
-        smooth: true,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: { color: '#f87171' },
-        lineStyle: { color: '#f87171', width: 2 },
-        data: s2TrendSeries.satisfactionPct
-      }
-    ]
-  }
-  trendChart.setOption(option)
-}
-
-const initBarChart = () => {
-  const chartDom = document.getElementById('chart-bar')
-  if (!chartDom) return
-  
-  barChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' },
-      axisPointer: { type: 'shadow' }
-    },
-    grid: { left: '3%', right: '4%', bottom: '10%', top: '10%', containLabel: true },
-    xAxis: {
-      type: 'category',
-      data: barXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b', interval: 0 }
-    },
-    yAxis: {
-      type: 'value',
-      min: 70,
-      max: 100,
-      splitLine: { lineStyle: { color: '#1e293b' } },
-      axisLabel: { color: '#64748b' }
-    },
-    series: [{
-      data: barValues.value.map((v) => ({ value: v, itemStyle: { color: v < 88 ? '#ef4444' : '#60a5fa' } })),
-      type: 'bar',
-      barWidth: '60%',
-      itemStyle: { borderRadius: [4, 4, 0, 0] }
-    }]
-  }
-  barChart.setOption(option)
-}
-
-const initCharts = () => {
-  nextTick(() => {
-    initAlertGauge()
-    initTrendChart()
-    initBarChart()
-    
-    window.addEventListener('resize', () => {
-      alertGauge?.resize()
-      trendChart?.resize()
-      barChart?.resize()
-    })
-  })
-}
-
-onMounted(async () => {
-  await loadS2Kpis()
-  const detail = await fetchModuleDetail('S2')
-  orderListAll.value = (detail.l3 ?? []).slice(0, 8).map((x, idx) => {
-    const v = Number(x.metricValue ?? 0)
-    const sat = Math.max(0, Math.min(100, Math.round(v)))
-    return {
-      orderNo: x.metricCode || `#S2-${idx + 1}`,
-      customer: x.metricName || `S2 指标 ${idx + 1}`,
-      product: x.metricName || '',
-      productionLine: '',
-      quantity: `${Math.max(1, Math.round(v * 100))} PCS`,
-      deliveryDate: x.statDate ? String(x.statDate).slice(0, 10) : '--',
-      status: sat >= 95 ? '已锁定' : sat >= 80 ? '排程中' : '资源异常',
-      statusType: sat >= 95 ? 'success' : sat >= 80 ? '' : 'danger',
-      startTime: x.statDate ? String(x.statDate).replace('T', ' ').slice(0, 16) : '-- : --',
-      satisfaction: sat,
-      satisfactionClass: sat >= 95 ? 'success' : sat >= 80 ? 'primary' : 'danger'
-    }
-  })
-  logItems.value = (detail.alerts ?? []).slice(0, 8).map((x) => ({
-    time: x.time ?? '--:--:--',
-    tag: String(x.level ?? 'info').toUpperCase(),
-    message: x.message ?? '排程告警',
-    detail: '来源:S2 实时告警',
-    levelClass: ['critical', 'high'].includes(String(x.level)) ? 'critical' : String(x.level) === 'warning' ? 'medium' : 'info'
-  }))
-  alertTotal.value = (detail.alerts ?? []).length
-  alertCritical.value = (detail.alerts ?? []).filter((x) => ['critical', 'high'].includes(String(x.level))).length
-  alertWarning.value = (detail.alerts ?? []).filter((x) => String(x.level) === 'warning').length
-  alertInfo.value = Math.max(0, alertTotal.value - alertCritical.value - alertWarning.value)
-  const l2 = detail.l2 ?? []
-  if (l2.length > 0) {
-    const vals = l2.slice(0, 7).map((x) => Number(x.metricValue ?? 0))
-    trendXAxis.value = l2.slice(0, 7).map((x, i) => (x.statDate ? String(x.statDate).slice(5, 10) : `D${i + 1}`))
-    s2TrendSeries.cycleDays = vals.map((v) => Number((Math.max(v, 1) / 4).toFixed(1)))
-    s2TrendSeries.satisfactionPct = vals.map((v) => Math.max(0, Math.min(100, Number(v.toFixed(1)))))
-    barXAxis.value = l2.slice(0, 4).map((x, i) => x.metricName || `线体${i + 1}`)
-    barValues.value = l2.slice(0, 4).map((x) => Math.max(0, Math.min(100, Number(x.metricValue ?? 0))))
-  }
-  updateTime()
-  setInterval(updateTime, 1000)
-  initCharts()
-})
+import ModuleDashboardPage from './components/ModuleDashboardPage.vue'
 </script>
-
-<style scoped>
-.s2-dashboard {
-  box-sizing: border-box;
-  flex: 1;
-  min-height: 0;
-  max-height: 100%;
-  overflow-x: hidden;
-  overflow-y: auto;
-  -webkit-overflow-scrolling: touch;
-  background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
-  padding: 15px;
-  padding-bottom: 24px;
-}
-
-/* 顶部导航栏 */
-.top-nav {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  background: rgba(15, 23, 42, 0.9);
-  padding: 12px 20px;
-  border-radius: 8px;
-  margin-bottom: 15px;
-  border: 1px solid #1e293b;
-}
-
-.top-left {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.back-btn {
-  color: #60a5fa !important;
-  font-size: 13px;
-}
-
-.page-title {
-  font-size: 16px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.version {
-  font-size: 11px;
-  color: #64748b;
-  padding: 2px 8px;
-  background: rgba(96, 165, 250, 0.1);
-  border-radius: 12px;
-  border: 1px solid rgba(96, 165, 250, 0.3);
-}
-
-.top-right {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.current-time {
-  text-align: right;
-  margin-right: 10px;
-}
-
-.current-time .label {
-  font-size: 10px;
-  color: #64748b;
-}
-
-.current-time .time {
-  font-size: 16px;
-  font-weight: 700;
-  color: #60a5fa;
-  font-family: 'Courier New', monospace;
-}
-
-/* 第一行 KPI */
-.row-1 {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 15px;
-  margin-bottom: 15px;
-}
-
-.kpi-card {
-  background: rgba(30, 41, 59, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #334155;
-}
-
-.kpi-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 12px;
-  color: #94a3b8;
-  font-size: 12px;
-}
-
-.kpi-value {
-  display: flex;
-  align-items: baseline;
-  gap: 8px;
-  margin-bottom: 12px;
-}
-
-.kpi-value .value {
-  font-size: 36px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.kpi-value .unit {
-  font-size: 14px;
-  color: #64748b;
-}
-
-.trend {
-  font-size: 12px;
-  font-weight: 600;
-}
-
-.trend.up { color: #34d399; }
-.trend.down { color: #34d399; }
-
-.kpi-bar {
-  height: 4px;
-  background: #1e293b;
-  border-radius: 2px;
-  overflow: hidden;
-}
-
-.kpi-bar .bar-fill {
-  height: 100%;
-  background: linear-gradient(90deg, #60a5fa, #3b82f6);
-  border-radius: 2px;
-}
-
-.kpi-bar.success .bar-fill { background: linear-gradient(90deg, #34d399, #10b981); }
-.kpi-bar.warning .bar-fill { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
-.kpi-bar.danger .bar-fill { background: linear-gradient(90deg, #ef4444, #dc2626); }
-
-/* 第二行 */
-.row-2 {
-  display: grid;
-  grid-template-columns: 2fr 1fr;
-  gap: 15px;
-  margin-bottom: 15px;
-}
-
-.decomposition-section, .alert-section {
-  background: rgba(30, 41, 59, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #334155;
-}
-
-.section-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.section-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.section-title.danger { color: #f87171; }
-
-.decomposition-cards {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 12px;
-}
-
-.decomp-card {
-  background: rgba(15, 23, 42, 0.6);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-  cursor: pointer;
-  transition: all 0.3s;
-}
-
-.decomp-card:hover, .decomp-card.active {
-  border-color: #60a5fa;
-  background: rgba(96, 165, 250, 0.1);
-}
-
-.decomp-title {
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-  margin-bottom: 10px;
-}
-
-.decomp-metrics {
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-.decomp-metrics .metric {
-  font-size: 11px;
-  color: #94a3b8;
-}
-
-.alert-main {
-  text-align: center;
-  padding: 20px 0;
-}
-
-.alert-value {
-  font-size: 56px;
-  font-weight: 700;
-  color: #ef4444;
-  line-height: 1;
-}
-
-.alert-label {
-  font-size: 12px;
-  color: #94a3b8;
-  margin: 10px 0;
-}
-
-.alert-gauge {
-  width: 120px;
-  height: 60px;
-  margin: 15px auto;
-}
-
-.alert-stats {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 10px;
-  margin-top: 15px;
-  padding-top: 15px;
-  border-top: 1px solid #1e293b;
-}
-
-.alert-stat {
-  text-align: center;
-}
-
-.alert-stat .stat-value {
-  font-size: 20px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.alert-stat .stat-label {
-  font-size: 11px;
-  color: #64748b;
-  margin-top: 4px;
-}
-
-/* 第三行 */
-.row-3 {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 15px;
-  margin-bottom: 15px;
-}
-
-.chart-card, .log-card {
-  background: rgba(30, 41, 59, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #334155;
-}
-
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.card-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.update-time {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.chart-container {
-  width: 100%;
-  height: 250px;
-}
-
-.log-list {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  max-height: 250px;
-  overflow-y: auto;
-}
-
-.log-item {
-  padding: 10px;
-  background: rgba(0, 0, 0, 0.2);
-  border-radius: 4px;
-  border-left: 3px solid;
-}
-
-.log-item.critical { border-color: #ef4444; }
-.log-item.high { border-color: #f97316; }
-.log-item.medium { border-color: #eab308; }
-.log-item.info { border-color: #3b82f6; }
-
-.log-time {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 4px;
-}
-
-.log-tag {
-  font-size: 10px;
-  font-weight: 700;
-  padding: 2px 6px;
-  border-radius: 3px;
-  display: inline-block;
-  margin-bottom: 4px;
-}
-
-.log-item.critical .log-tag { color: #ef4444; background: rgba(239, 68, 68, 0.2); }
-.log-item.high .log-tag { color: #f97316; background: rgba(249, 115, 22, 0.2); }
-.log-item.medium .log-tag { color: #eab308; background: rgba(234, 179, 8, 0.2); }
-.log-item.info .log-tag { color: #3b82f6; background: rgba(59, 130, 246, 0.2); }
-
-.log-message {
-  font-size: 12px;
-  color: #e2e8f0;
-}
-
-.log-detail {
-  font-size: 11px;
-  color: #64748b;
-}
-
-/* 第四行表格 */
-.row-4 {
-  margin-bottom: 15px;
-}
-
-.table-card {
-  background: rgba(30, 41, 59, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #334155;
-}
-
-.table-actions {
-  display: flex;
-  gap: 10px;
-}
-
-.data-table {
-  background: rgba(15, 23, 42, 0.5);
-}
-
-.data-table :deep(.el-table__header th) {
-  background: rgba(30, 41, 59, 0.8);
-  color: #94a3b8;
-  font-size: 12px;
-}
-
-.data-table :deep(.el-table__body td) {
-  background: rgba(15, 23, 42, 0.5);
-  color: #e2e8f0;
-  font-size: 12px;
-}
-
-.data-table :deep(.el-table__row:hover) {
-  background: rgba(96, 165, 250, 0.1);
-}
-
-.sub {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.satisfaction-bar {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.satisfaction-bar .bar-fill {
-  flex: 1;
-  height: 6px;
-  background: #334155;
-  border-radius: 3px;
-  overflow: hidden;
-}
-
-.satisfaction-bar .bar-fill.success { background: linear-gradient(90deg, #34d399, #10b981); }
-.satisfaction-bar .bar-fill.primary { background: linear-gradient(90deg, #60a5fa, #3b82f6); }
-.satisfaction-bar .bar-fill.danger { background: linear-gradient(90deg, #ef4444, #dc2626); }
-
-.bar-text {
-  font-size: 11px;
-  color: #94a3b8;
-  min-width: 35px;
-}
-
-/* Element Plus 覆盖 */
-:deep(.el-button--primary) {
-  background: linear-gradient(135deg, #3b82f6, #2563eb);
-  border: none;
-}
-
-:deep(.el-input__wrapper) {
-  background: rgba(30, 41, 59, 0.6);
-  border: 1px solid #334155;
-}
-
-:deep(.el-input__inner) {
-  color: #e2e8f0;
-}
-
-:deep(.el-radio-group .el-radio-button__inner) {
-  background: rgba(30, 41, 59, 0.6);
-  border: 1px solid #334155;
-  color: #94a3b8;
-}
-
-:deep(.el-radio-group .el-radio-button__original-radio:checked + .el-radio-button__inner) {
-  background: linear-gradient(135deg, #3b82f6, #2563eb);
-  border-color: #3b82f6;
-  color: white;
-}
-</style>

+ 15 - 838
Web/src/views/aidop/kanban/s3.vue

@@ -1,847 +1,24 @@
 <template>
-  <div class="s3-dashboard">
-    <!-- 顶部标题栏 -->
-    <div class="top-header">
-      <div class="header-left">
-        <h1 class="page-title">S3 供应协同(物料计划)<span class="subtitle">详情看板</span></h1>
-        <p class="page-desc">物料供需平衡与交货执行的核心监控节点</p>
-      </div>
-      <div class="header-right">
-        <div class="header-modeling-links">
-          <el-button size="small" text type="primary" @click="goModelingS3">运营指标建模</el-button>
-          <el-button size="small" text @click="goModelingS4Pilot">S4 服务端试点</el-button>
-        </div>
-        <el-button size="small" class="btn-export">
-          <el-icon><Download /></el-icon>
-          导出报表
-        </el-button>
-        <el-button size="small" type="primary" class="btn-sim">
-          <el-icon><DataAnalysis /></el-icon>
-          策略模拟
-        </el-button>
-      </div>
-    </div>
-
-    <el-alert
-      class="s3-modeling-hint"
-      type="info"
-      :closable="false"
-      show-icon
-      title="指标建模与配置入口"
-    >
-      <template #default>
-        本页为 S3 供应协同<strong>详情看板</strong>。分步建模与指标树在侧栏「运营指标建模」;进入后可用筛选器切到 <strong>S3</strong> 查看口径说明。
-        与首页九宫格联动的<strong>服务端 Catalog / 布局勾选</strong>当前仅在 <strong>S4</strong> 试点,请点击上方「S4 服务端试点」跳转配置区。
-      </template>
-    </el-alert>
-
-    <!-- 供应计划:日期、物料、供应商 -->
-    <DetailQueryBar
-      dark
-      band-title="基础查询:日期、产品、订单号、产线"
-      @query="onDetailQuery"
-      @reset="onDetailQueryReset"
-    >
-      <SmartOpsBaseQueryFields v-model="detailQuery" compact />
+  <ModuleDashboardPage
+    module-code="S3"
+    title="S3 供应协同详情"
+    left-title="供应协同执行"
+    left-subtitle="物料需求计划 / 物料交货计划 — 周期与人效"
+    right-title="供应协同结果"
+    right-subtitle="需求计划与交货计划满足率"
+    :extra-query-init="{ material: '', supplier: '' }"
+  >
+    <template #extra-fields="{ query }">
       <el-form-item label="物料">
-        <el-input v-model="detailQuery.material" placeholder="物料编码/SKU" clearable style="width: 160px" />
+        <el-input v-model="query.material" placeholder="物料编码(SKU)" clearable style="width: 150px" />
       </el-form-item>
       <el-form-item label="供应商">
-        <el-input v-model="detailQuery.supplier" placeholder="供应商编码/名称" clearable style="width: 160px" />
+        <el-input v-model="query.supplier" placeholder="供应商编码/名称" clearable style="width: 160px" />
       </el-form-item>
-    </DetailQueryBar>
-
-    <!-- 第一行:4 个核心 KPI -->
-    <div class="row-1">
-      <div class="kpi-card">
-        <div class="kpi-top">
-          <div class="kpi-label">
-            <span>物料计划周期</span>
-            <el-icon class="icon"><Timer /></el-icon>
-          </div>
-          <el-tag size="small" :type="homeS3.kitCycleDays <= homeS3.kitCycleTargetDays ? 'success' : 'warning'" class="status-tag">{{ homeS3.kitCycleDays <= homeS3.kitCycleTargetDays ? '优秀' : '警告' }}</el-tag>
-        </div>
-        <div class="kpi-value">
-          <span class="value">{{ homeS3.kitCycleDays }}</span>
-          <span class="unit">Days</span>
-        </div>
-        <div class="kpi-trend up">
-          <el-icon><ArrowDown /></el-icon>
-          <span>{{ s3TrendA }}</span>
-        </div>
-      </div>
-
-      <div class="kpi-card critical">
-        <div class="kpi-top">
-          <div class="kpi-label">
-            <span>物料计划满足率</span>
-            <el-icon class="icon"><CircleCheck /></el-icon>
-          </div>
-          <el-tag size="small" type="danger" class="status-tag">严重</el-tag>
-        </div>
-        <div class="kpi-value">
-          <span class="value danger">{{ homeS3.demandKitRatePct }}</span>
-          <span class="unit">%</span>
-        </div>
-        <div class="kpi-trend down">
-          <el-icon><ArrowDown /></el-icon>
-          <span>{{ s3TrendB }}</span>
-        </div>
-      </div>
-
-      <div class="kpi-card">
-        <div class="kpi-top">
-          <div class="kpi-label">
-            <span>物料计划人数</span>
-            <el-icon class="icon"><User /></el-icon>
-          </div>
-          <el-tag size="small" class="status-tag normal">正常</el-tag>
-        </div>
-        <div class="kpi-value">
-          <span class="value">{{ s3HeadCount }}</span>
-          <span class="unit">SKU/P</span>
-        </div>
-        <div class="kpi-trend up">
-          <el-icon><ArrowUp /></el-icon>
-          <span>{{ s3TrendC }}</span>
-        </div>
-      </div>
-
-      <div class="kpi-card">
-        <div class="kpi-top">
-          <div class="kpi-label">
-            <span>物料库存周转</span>
-            <el-icon class="icon"><Refresh /></el-icon>
-          </div>
-          <el-tag size="small" :type="homeS3.materialInventoryTurnoverDays <= homeS3.materialInventoryTurnoverTargetDays ? 'success' : 'warning'" class="status-tag">{{ homeS3.materialInventoryTurnoverDays <= homeS3.materialInventoryTurnoverTargetDays ? '优秀' : '警告' }}</el-tag>
-        </div>
-        <div class="kpi-value">
-          <span class="value">{{ homeS3.materialInventoryTurnoverDays }}</span>
-          <span class="unit">Days</span>
-        </div>
-        <div class="kpi-trend up">
-          <el-icon><ArrowUp /></el-icon>
-          <span>{{ s3TrendD }}</span>
-        </div>
-      </div>
-    </div>
-
-    <!-- 第二行:两个分支 -->
-    <div class="row-2">
-      <!-- 分支一:MRP -->
-      <div class="branch-section">
-        <div class="branch-header">
-          <div class="branch-title">
-            <span class="branch-line"></span>
-            <span>分支一:物料需求计划</span>
-          </div>
-          <span class="branch-desc">侧重前端预测与拆解精准度</span>
-        </div>
-        <div class="branch-kpis">
-          <div class="branch-kpi">
-            <div class="bkpi-label">需求计划周期</div>
-            <div class="bkpi-value">0.8<span class="unit">d</span></div>
-            <div class="bkpi-bar"><div class="bar-fill" style="width: 80%"></div></div>
-          </div>
-          <div class="branch-kpi">
-            <div class="bkpi-label">需求计划满足率</div>
-            <div class="bkpi-value success">94.2<span class="unit">%</span></div>
-            <div class="bkpi-bar success"><div class="bar-fill" style="width: 94.2%"></div></div>
-          </div>
-          <div class="branch-kpi">
-            <div class="bkpi-label">需求计划人数</div>
-            <div class="bkpi-value">450<span class="unit">L/H</span></div>
-            <div class="bkpi-bar"><div class="bar-fill" style="width: 70%"></div></div>
-          </div>
-        </div>
-        <div class="trend-chart">
-          <div class="chart-title">需求计划核心趋势(近 7 天)</div>
-          <div id="mrp-trend" class="chart-container"></div>
-        </div>
-      </div>
-
-      <!-- 分支二:MDP -->
-      <div class="branch-section">
-        <div class="branch-header">
-          <div class="branch-title">
-            <span class="branch-line warning"></span>
-            <span>分支二:物料交货计划</span>
-          </div>
-          <span class="branch-desc">侧重后端供应可执行性及交付</span>
-        </div>
-        <div class="branch-kpis">
-          <div class="branch-kpi">
-            <div class="bkpi-label">交货计划周期</div>
-            <div class="bkpi-value warning">2.1<span class="unit">d</span></div>
-            <div class="bkpi-bar warning"><div class="bar-fill" style="width: 60%"></div></div>
-          </div>
-          <div class="branch-kpi">
-            <div class="bkpi-label">交货计划满足率</div>
-            <div class="bkpi-value danger">{{ homeS3.demandKitRatePct }}<span class="unit">%</span></div>
-            <div class="bkpi-bar danger"><div class="bar-fill" :style="{ width: `${homeS3.demandKitRatePct}%` }"></div></div>
-          </div>
-          <div class="branch-kpi">
-            <div class="bkpi-label">交货计划人数</div>
-            <div class="bkpi-value">280<span class="unit">L/H</span></div>
-            <div class="bkpi-bar"><div class="bar-fill" style="width: 50%"></div></div>
-          </div>
-        </div>
-        <div class="log-section">
-          <div class="log-header">
-            <span class="log-title">交货异常实时日志</span>
-            <el-tag size="small" type="danger" class="critical-badge">3 条新增严重项</el-tag>
-          </div>
-          <div class="log-list">
-            <div v-for="(item, idx) in filteredLogItems" :key="`${item.time}-${idx}`" class="log-item" :class="item.levelClass">
-              <span class="log-time">{{ item.time }}</span>
-              <span class="log-tag">{{ item.tag }}</span>
-              <span class="log-msg">{{ item.msg }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 第三行:健康度下钻分析 -->
-    <div class="row-3">
-      <div class="drill-card">
-        <div class="card-header">
-          <span class="card-title">物料计划健康度下钻分析</span>
-          <div class="chart-legend">
-            <span class="legend-item"><span class="dot" style="background: #34d399"></span>计划满足率</span>
-            <span class="legend-item"><span class="dot" style="background: #e2e8f0"></span>历史平均满足率</span>
-          </div>
-        </div>
-        <div id="drill-chart" class="drill-chart"></div>
-      </div>
-    </div>
-  </div>
+    </template>
+  </ModuleDashboardPage>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import DetailQueryBar from './components/DetailQueryBar.vue'
-import SmartOpsBaseQueryFields from './components/SmartOpsBaseQueryFields.vue'
-import {
-  emptySmartOpsBaseQuery,
-  blobMatchesSmartOpsBase,
-  textMatches,
-  summarizeSmartOpsBaseQuery,
-} from './utils/smartOpsBaseQuery'
-import { Download, DataAnalysis, Timer, CircleCheck, User, Refresh, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
-import * as echarts from 'echarts'
-import { homeS3 } from './data/homeModulesSync'
-import { loadHomeModuleMetrics } from './data/homeModulesSync'
-import { fetchModuleDetail } from '../api/kanbanData'
-
-const router = useRouter()
-
-function goModelingS3() {
-  router.push({ path: '/aidop/smart-ops/modeling', query: { module: 'S3' } })
-}
-
-function goModelingS4Pilot() {
-  router.push({ path: '/aidop/smart-ops/modeling', query: { focus: 's4' } })
-}
-
-const s3HeadCount = ref('1,240')
-const s3TrendA = ref('12.5%')
-const s3TrendB = ref('4.2%')
-const s3TrendC = ref('8.1%')
-const s3TrendD = ref('2.3%')
-
-const detailQuery = ref({
-  ...emptySmartOpsBaseQuery(),
-  material: '',
-  supplier: '',
-})
-
-function onDetailQuery() {
-  ElMessage.success(`已应用供应筛选(${summarizeSmartOpsBaseQuery(detailQuery.value)})`)
-}
-
-function onDetailQueryReset() {
-  detailQuery.value = { ...emptySmartOpsBaseQuery(), material: '', supplier: '' }
-  ElMessage.info('已重置')
-}
-
-let mrpTrendChart = null
-let drillChart = null
-const logItemsAll = ref([])
-
-const filteredLogItems = computed(() =>
-  logItemsAll.value.filter((x) => {
-    const blob = `${x.msg} ${x.tag} ${x.time}`
-    if (!blobMatchesSmartOpsBase(detailQuery.value, blob)) return false
-    if (!textMatches(detailQuery.value.material, blob)) return false
-    if (!textMatches(detailQuery.value.supplier, blob)) return false
-    return true
-  })
-)
-const mrpXAxis = ref(['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'])
-const drillXAxis = ref(['节点1', '节点2', '节点3', '节点4', '节点5', '节点6', '节点7'])
-const drillPrimary = ref([82, 80, 78, 76, 75, 74, 72])
-const drillSecondary = ref([95, 94, 93, 92, 91, 93, 92])
-
-const initMrpTrendChart = () => {
-  const chartDom = document.getElementById('mrp-trend')
-  if (!chartDom) return
-  
-  mrpTrendChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    grid: { left: '3%', right: '4%', bottom: '10%', top: '15%' },
-    xAxis: {
-      type: 'category',
-      data: mrpXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: {
-      type: 'value',
-      min: 0,
-      max: 100,
-      splitLine: { lineStyle: { color: '#1e293b' } },
-      axisLabel: { color: '#64748b' }
-    },
-    series: [{
-      data: homeS3.mrpTrendSeries,
-      type: 'line',
-      smooth: true,
-      symbol: 'none',
-      lineStyle: { color: '#60a5fa', width: 2 },
-      areaStyle: {
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: 'rgba(96, 165, 250, 0.3)' },
-          { offset: 1, color: 'rgba(96, 165, 250, 0)' }
-        ])
-      }
-    }]
-  }
-  mrpTrendChart.setOption(option)
-}
-
-const initDrillChart = () => {
-  const chartDom = document.getElementById('drill-chart')
-  if (!chartDom) return
-  
-  drillChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' },
-      axisPointer: { type: 'shadow' }
-    },
-    legend: {
-      data: ['计划满足率', '历史平均满足率'],
-      textStyle: { color: '#94a3b8' },
-      right: '5%'
-    },
-    grid: { left: '3%', right: '4%', bottom: '10%', top: '15%' },
-    xAxis: {
-      type: 'category',
-      data: drillXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: {
-      type: 'value',
-      min: 0,
-      max: 100,
-      splitLine: { lineStyle: { color: '#1e293b' } },
-      axisLabel: { color: '#64748b' }
-    },
-    series: [
-      {
-        name: '计划满足率',
-        type: 'bar',
-        data: drillPrimary.value,
-        itemStyle: {
-          color: '#34d399',
-          borderRadius: [4, 4, 0, 0]
-        },
-        barWidth: '40%'
-      },
-      {
-        name: '历史平均满足率',
-        type: 'line',
-        data: drillSecondary.value,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: { color: '#e2e8f0' },
-        lineStyle: { color: '#e2e8f0', width: 2, type: 'dashed' }
-      }
-    ]
-  }
-  drillChart.setOption(option)
-}
-
-const initCharts = () => {
-  nextTick(() => {
-    initMrpTrendChart()
-    initDrillChart()
-    
-    window.addEventListener('resize', () => {
-      mrpTrendChart?.resize()
-      drillChart?.resize()
-    })
-  })
-}
-
-onMounted(async () => {
-  await loadHomeModuleMetrics()
-  const detail = await fetchModuleDetail('S3')
-  logItemsAll.value = (detail.alerts ?? []).slice(0, 8).map((x) => ({
-    time: x.time ?? '--:--:--',
-    tag: `[${String(x.level ?? 'info').toUpperCase()}]`,
-    msg: x.message ?? '供应协同告警',
-    levelClass: ['critical', 'high'].includes(String(x.level)) ? 'critical' : String(x.level) === 'warning' ? 'warning' : 'info'
-  }))
-  if ((detail.l2 ?? []).length > 0) {
-    const vals = detail.l2.map((x) => Number(x.metricValue ?? 0))
-    s3HeadCount.value = String(Math.max(1, Math.round((vals[0] ?? 124) * 10)))
-    s3TrendA.value = `${Math.abs((vals[1] ?? 12.5) % 20).toFixed(1)}%`
-    s3TrendB.value = `${Math.abs((vals[2] ?? 4.2) % 10).toFixed(1)}%`
-    s3TrendC.value = `${Math.abs((vals[3] ?? 8.1) % 15).toFixed(1)}%`
-    s3TrendD.value = `${Math.abs((vals[4] ?? 2.3) % 8).toFixed(1)}%`
-    mrpXAxis.value = detail.l2.slice(0, 7).map((x, i) => (x.statDate ? String(x.statDate).slice(5, 10) : `D${i + 1}`))
-    drillXAxis.value = detail.l2.slice(0, 7).map((x, i) => x.metricName || `节点${i + 1}`)
-    drillPrimary.value = detail.l2.slice(0, 7).map((x) => Math.max(0, Math.min(100, Number(x.metricValue ?? 0))))
-    drillSecondary.value = drillPrimary.value.map((v) => Math.max(0, Math.min(100, Number((v + 8).toFixed(1)))))
-  }
-  initCharts()
-})
+import ModuleDashboardPage from './components/ModuleDashboardPage.vue'
 </script>
-
-<style scoped>
-.s3-dashboard {
-  box-sizing: border-box;
-  flex: 1;
-  min-height: 0;
-  max-height: 100%;
-  overflow-x: hidden;
-  overflow-y: auto;
-  -webkit-overflow-scrolling: touch;
-  background: #0b0f19;
-  padding: 20px;
-  padding-bottom: 24px;
-  color: #e2e8f0;
-}
-
-/* 顶部标题栏 */
-.top-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: flex-start;
-  margin-bottom: 20px;
-}
-
-.header-left {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.page-title {
-  margin: 0;
-  font-size: 24px;
-  font-weight: 700;
-  color: #e2e8f0;
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-
-.page-title .subtitle {
-  font-size: 14px;
-  font-weight: 400;
-  color: #64748b;
-}
-
-.page-desc {
-  margin: 0;
-  font-size: 12px;
-  color: #64748b;
-}
-
-.header-right {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 10px;
-}
-
-.header-modeling-links {
-  display: flex;
-  align-items: center;
-  gap: 2px;
-  margin-right: 4px;
-  padding-right: 12px;
-  border-right: 1px solid #334155;
-}
-
-.header-modeling-links :deep(.el-button) {
-  color: #93c5fd;
-}
-
-.header-modeling-links :deep(.el-button:hover) {
-  color: #bfdbfe;
-}
-
-.s3-modeling-hint {
-  margin-bottom: 16px;
-}
-
-:deep(.s3-modeling-hint.el-alert) {
-  background: rgba(30, 58, 138, 0.2);
-  border: 1px solid #1e3a8a;
-}
-
-.btn-export {
-  background: rgba(51, 65, 85, 0.6);
-  border: 1px solid #334155;
-  color: #94a3b8;
-}
-
-.btn-sim {
-  background: linear-gradient(135deg, #3b82f6, #2563eb);
-  border: none;
-}
-
-/* 第一行 KPI */
-.row-1 {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 15px;
-  margin-bottom: 20px;
-}
-
-.kpi-card {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-  position: relative;
-}
-
-.kpi-card.critical {
-  border-color: #ef4444;
-  box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
-}
-
-.kpi-top {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 12px;
-}
-
-.kpi-label {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  font-size: 12px;
-  color: #94a3b8;
-}
-
-.kpi-label .icon {
-  color: #64748b;
-}
-
-.status-tag {
-  font-size: 10px;
-  padding: 2px 8px;
-}
-
-.status-tag.normal {
-  background: rgba(59, 130, 246, 0.2);
-  border-color: rgba(59, 130, 246, 0.3);
-  color: #60a5fa;
-}
-
-.kpi-value {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-  margin-bottom: 8px;
-}
-
-.kpi-value .value {
-  font-size: 32px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.kpi-value .value.danger {
-  color: #ef4444;
-}
-
-.kpi-value .unit {
-  font-size: 12px;
-  color: #64748b;
-}
-
-.kpi-trend {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  font-size: 11px;
-  font-weight: 600;
-}
-
-.kpi-trend.up { color: #34d399; }
-.kpi-trend.down { color: #ef4444; }
-
-/* 第二行:两个分支 */
-.row-2 {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 20px;
-  margin-bottom: 20px;
-}
-
-.branch-section {
-  background: rgba(30, 41, 59, 0.3);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-}
-
-.branch-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.branch-title {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  font-size: 14px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.branch-line {
-  width: 4px;
-  height: 16px;
-  background: #60a5fa;
-  border-radius: 2px;
-}
-
-.branch-line.warning {
-  background: #f97316;
-}
-
-.branch-desc {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.branch-kpis {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 12px;
-  margin-bottom: 15px;
-}
-
-.branch-kpi {
-  background: rgba(15, 23, 42, 0.5);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.bkpi-label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 8px;
-}
-
-.bkpi-value {
-  font-size: 20px;
-  font-weight: 700;
-  color: #e2e8f0;
-  margin-bottom: 8px;
-}
-
-.bkpi-value .unit {
-  font-size: 12px;
-  color: #64748b;
-  margin-left: 4px;
-}
-
-.bkpi-value.success { color: #34d399; }
-.bkpi-value.warning { color: #f97316; }
-.bkpi-value.danger { color: #ef4444; }
-
-.bkpi-bar {
-  height: 4px;
-  background: #1e293b;
-  border-radius: 2px;
-  overflow: hidden;
-}
-
-.bkpi-bar .bar-fill {
-  height: 100%;
-  background: #60a5fa;
-  border-radius: 2px;
-}
-
-.bkpi-bar.success .bar-fill { background: #34d399; }
-.bkpi-bar.warning .bar-fill { background: #f97316; }
-.bkpi-bar.danger .bar-fill { background: #ef4444; }
-
-/* 趋势图 */
-.trend-chart {
-  background: rgba(15, 23, 42, 0.5);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.chart-title {
-  font-size: 12px;
-  color: #94a3b8;
-  margin-bottom: 10px;
-}
-
-.chart-container {
-  width: 100%;
-  height: 200px;
-}
-
-/* 日志区域 */
-.log-section {
-  background: rgba(15, 23, 42, 0.5);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-  flex: 1;
-}
-
-.log-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 10px;
-}
-
-.log-title {
-  font-size: 12px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.critical-badge {
-  font-size: 10px;
-  padding: 2px 8px;
-}
-
-.log-list {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.log-item {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  padding: 8px;
-  background: rgba(0, 0, 0, 0.2);
-  border-radius: 4px;
-  font-size: 11px;
-}
-
-.log-time {
-  color: #64748b;
-  font-family: 'Courier New', monospace;
-  min-width: 70px;
-}
-
-.log-tag {
-  font-weight: 700;
-  font-size: 10px;
-  padding: 2px 6px;
-  border-radius: 3px;
-}
-
-.log-item.critical .log-tag { color: #ef4444; background: rgba(239, 68, 68, 0.2); }
-.log-item.warning .log-tag { color: #f97316; background: rgba(249, 115, 22, 0.2); }
-.log-item.info .log-tag { color: #60a5fa; background: rgba(96, 165, 250, 0.2); }
-
-.log-msg {
-  color: #e2e8f0;
-  flex: 1;
-}
-
-/* 第三行:下钻分析 */
-.row-3 {
-  margin-bottom: 20px;
-}
-
-.drill-card {
-  background: rgba(30, 41, 59, 0.3);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-}
-
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.card-title {
-  font-size: 14px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.chart-legend {
-  display: flex;
-  gap: 20px;
-  font-size: 12px;
-  color: #94a3b8;
-}
-
-.legend-item {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.legend-item .dot {
-  width: 10px;
-  height: 10px;
-  border-radius: 50%;
-}
-
-.drill-chart {
-  width: 100%;
-  height: 350px;
-}
-
-/* Element Plus 覆盖 */
-:deep(.el-button--small) {
-  padding: 8px 16px;
-  font-size: 12px;
-}
-
-:deep(.el-tag) {
-  border-radius: 4px;
-}
-</style>

+ 17 - 1060
Web/src/views/aidop/kanban/s5.vue

@@ -1,1070 +1,27 @@
 <template>
-  <div class="s5-dashboard">
-    <!-- 顶部标题栏 -->
-    <div class="top-header">
-      <div class="header-left">
-        <div class="breadcrumb">
-          <el-icon class="back-icon" @click="$router.push('/')"><ArrowLeft /></el-icon>
-          <span>企业数字化运营看板 V5.0</span>
-          <el-tag size="small" class="gold-tag">正式版</el-tag>
-        </div>
-        <h1 class="page-title">
-          S5 物料仓储(仓储)全流程详情
-          <span class="subtitle">实时监控仓储五大环节核心指标与异常状态</span>
-        </h1>
-      </div>
-      <div class="header-right">
-        <div class="system-time">{{ currentTime }}</div>
-        <el-button size="small" class="btn-icon"><el-icon><Search /></el-icon></el-button>
-        <el-button size="small" class="btn-icon"><el-icon><Bell /></el-icon></el-button>
-        <el-button size="small" class="btn-icon"><el-icon><Setting /></el-icon></el-button>
-        <el-button size="small" class="btn-export">
-          <el-icon><Download /></el-icon>
-          导出数据集
-        </el-button>
-        <el-button size="small" type="primary" class="btn-sync">
-          <el-icon><Refresh /></el-icon>
-          立即同步
-        </el-button>
-      </div>
-    </div>
-
-    <!-- 仓储作业:日期、仓库、物料、工单(齐套/发运关联) -->
-    <DetailQueryBar
-      dark
-      band-title="基础查询:日期、产品、订单号、产线"
-      @query="onDetailQuery"
-      @reset="onDetailQueryReset"
-    >
-      <SmartOpsBaseQueryFields v-model="detailQuery" compact />
+  <ModuleDashboardPage
+    module-code="S5"
+    title="S5 物料仓储详情"
+    left-title="物料仓储执行"
+    left-subtitle="收货 / 检验 / 上架 / 备货 / 发货 — 周期与人效"
+    right-title="物料仓储结果"
+    right-subtitle="各环节满足率与周转"
+    :extra-query-init="{ warehouse: '', material: '', workOrder: '' }"
+  >
+    <template #extra-fields="{ query }">
       <el-form-item label="仓库">
-        <el-input v-model="detailQuery.warehouse" placeholder="仓库/库区编码" clearable style="width: 140px" />
+        <el-input v-model="query.warehouse" placeholder="仓库编码/名称" clearable style="width: 150px" />
       </el-form-item>
       <el-form-item label="物料">
-        <el-input v-model="detailQuery.material" placeholder="物料编码" clearable style="width: 140px" />
+        <el-input v-model="query.material" placeholder="物料编码(SKU)" clearable style="width: 150px" />
       </el-form-item>
-      <el-form-item label="工单">
-        <el-input v-model="detailQuery.workOrder" placeholder="生产/配送工单" clearable style="width: 160px" />
+      <el-form-item label="工单">
+        <el-input v-model="query.workOrder" placeholder="工单号" clearable style="width: 140px" />
       </el-form-item>
-    </DetailQueryBar>
-
-    <!-- 第一行:4 个 KPI 卡片 -->
-    <div class="row-1">
-      <div class="kpi-card">
-        <div class="kpi-header">
-          <span class="kpi-label">物料上线周期</span>
-          <el-icon :size="24"><Timer /></el-icon>
-        </div>
-        <div class="kpi-value">
-          <span class="value">{{ homeS5.matchLeadTimeActualHrs }}</span>
-          <span class="unit">Hrs</span>
-        </div>
-        <div class="kpi-trend down">
-          <el-icon><ArrowDown /></el-icon>
-          <span>{{ d(0, 12.5, 1) }}%</span>
-        </div>
-        <div class="kpi-bar warning">
-          <div
-            class="bar-fill"
-            :style="{
-              width: `${Math.min(100, (homeS5.matchLeadTimeTargetHrs / homeS5.matchLeadTimeActualHrs) * 100)}%`
-            }"
-          />
-        </div>
-      </div>
-
-      <div class="kpi-card success">
-        <div class="kpi-header">
-          <span class="kpi-label">物料上线满足率</span>
-          <el-icon :size="24"><CircleCheck /></el-icon>
-        </div>
-        <div class="kpi-value">
-          <span class="value success">{{ homeS5.matchOnTimeActualPct }}</span>
-          <span class="unit">%</span>
-        </div>
-        <div class="kpi-trend up">
-          <el-icon><ArrowUp /></el-icon>
-          <span>{{ d(1, 0.34, 2) }}%</span>
-        </div>
-        <div class="kpi-bar success">
-          <div class="bar-fill" :style="{ width: `${homeS5.matchOnTimeActualPct}%` }"></div>
-        </div>
-      </div>
-
-      <div class="kpi-card">
-        <div class="kpi-header">
-          <span class="kpi-label">物料仓储人效</span>
-          <el-icon :size="24"><User /></el-icon>
-        </div>
-        <div class="kpi-value">
-          <span class="value">{{ homeS5.rawWarehouseHeadcount }}</span>
-          <span class="unit subtle">人</span>
-        </div>
-        <div class="kpi-status">
-          <el-icon><UserFilled /></el-icon>
-          <span>Active</span>
-        </div>
-        <div class="kpi-bar danger">
-          <div
-            class="bar-fill"
-            :style="{
-              width: `${Math.min(100, (homeS5.rawWarehouseHeadcount / homeS5.rawWarehouseHeadcountTarget) * 100)}%`
-            }"
-          />
-        </div>
-      </div>
-
-      <div class="kpi-card warning">
-        <div class="kpi-header">
-          <span class="kpi-label">物料库存周转</span>
-          <el-icon :size="24"><Refresh /></el-icon>
-        </div>
-        <div class="kpi-value">
-          <span class="value">{{ homeS5.materialInventoryTurnoverDays }}</span>
-          <span class="unit">Days</span>
-        </div>
-        <div class="kpi-trend up">
-          <el-icon><ArrowUp /></el-icon>
-          <span>{{ d(2, 1.2, 1) }}d</span>
-        </div>
-        <div class="kpi-bar"><div class="bar-fill" :style="{ width: `${Math.min(100, (homeS5.materialInventoryTurnoverDays / 30) * 100)}%` }"></div></div>
-      </div>
-    </div>
-
-    <!-- 第二行:两个图表 -->
-    <div class="row-2">
-      <div class="chart-card">
-        <div class="card-header">
-          <span class="card-title">
-            <span class="title-line"></span>
-            7 日环节周期变化趋势
-          </span>
-          <div class="chart-legend">
-            <span class="legend-item"><span class="legend-dot blue"></span>收货</span>
-            <span class="legend-item"><span class="legend-dot red"></span>发货</span>
-          </div>
-        </div>
-        <div id="trend-chart" class="chart-container"></div>
-      </div>
-
-      <div class="chart-card">
-        <div class="card-header">
-          <span class="card-title">
-            <span class="title-line"></span>
-            子流程满足率对比分析
-          </span>
-        </div>
-        <div id="radar-chart" class="radar-container"></div>
-      </div>
-    </div>
-
-    <!-- 第三行:5 个流程卡片 -->
-    <div class="row-3">
-      <!-- 收货 -->
-      <div class="process-card">
-        <div class="process-header">
-          <span class="process-title">
-            <el-icon><Folder /></el-icon>
-            物料仓储(收货)
-          </span>
-          <el-tag size="small" :type="statusTypeByRate(Number(d(7, 94.5, 1)))">{{ statusTextByRate(Number(d(7, 94.5, 1))) }}</el-tag>
-        </div>
-        <div class="process-metrics">
-          <div class="metric-item">
-            <span class="metric-label">1. 物料收货周期</span>
-            <div class="metric-value">
-              <span class="value">{{ d(3, 2.4, 1) }}</span>
-              <span class="unit">h</span>
-              <span class="trend down"><el-icon><ArrowDown /></el-icon>{{ t(0, '0.2h') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">2. 物料收货满足率</span>
-            <div class="metric-value">
-              <span class="value success">{{ homeS5.matchOnTimeActualPct }}</span>
-              <span class="unit">%</span>
-              <span class="trend">目标 {{ homeS5.matchOnTimeTargetPct }}%</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">3. 收货人效</span>
-            <div class="metric-value">
-              <span class="value">{{ d(4, 158, 0) }}</span>
-              <span class="unit">P/H</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(1, '5%') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">4. 收货物料周转</span>
-            <div class="metric-value">
-              <span class="value">{{ d(5, 12.2, 1) }}</span>
-              <span class="unit">x</span>
-              <span class="trend stable">{{ t(2, '平稳') }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- IQC -->
-      <div class="process-card" :class="statusTypeByRate(Number(d(7, 94.5, 1))) === 'danger' ? 'danger-border' : statusTypeByRate(Number(d(7, 94.5, 1))) === 'warning' ? 'warning-border' : ''">
-        <div class="process-header">
-          <span class="process-title">
-            <el-icon><Checked /></el-icon>
-            来料检验 (IQC)
-          </span>
-          <el-tag size="small" :type="statusTypeByRate(Number(d(7, 94.5, 1)))">{{ statusTextByRate(Number(d(7, 94.5, 1))) }}</el-tag>
-        </div>
-        <div class="process-metrics">
-          <div class="metric-item">
-            <span class="metric-label">1. 物料检验周期</span>
-            <div class="metric-value">
-              <span class="value warning">{{ d(6, 4.8, 1) }}</span>
-              <span class="unit">h</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(3, '1.2h') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">2. 物料检验合格率</span>
-            <div class="metric-value">
-              <span class="value">{{ d(7, 94.5, 1) }}</span>
-              <span class="unit">%</span>
-              <span class="trend down"><el-icon><ArrowDown /></el-icon>{{ t(4, '2.1%') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">3. 物料检验人效</span>
-            <div class="metric-value">
-              <span class="value">{{ d(8, 92, 0) }}</span>
-              <span class="unit">P/H</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(5, '2%') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">4. 检验物料周转</span>
-            <div class="metric-value">
-              <span class="value">{{ d(9, 8.4, 1) }}</span>
-              <span class="unit">x</span>
-              <span class="trend down"><el-icon><ArrowDown /></el-icon>{{ t(6, '0.5') }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 上架 -->
-      <div class="process-card">
-        <div class="process-header">
-          <span class="process-title">
-            <el-icon><Top /></el-icon>
-            物料仓储(上架)
-          </span>
-          <el-tag size="small" :type="statusTypeByRate(Number(d(11, 99.1, 1)))">{{ statusTextByRate(Number(d(11, 99.1, 1))) }}</el-tag>
-        </div>
-        <div class="process-metrics">
-          <div class="metric-item">
-            <span class="metric-label">1. 物料上架周期</span>
-            <div class="metric-value">
-              <span class="value">{{ d(10, 1.2, 1) }}</span>
-              <span class="unit">h</span>
-              <span class="trend down"><el-icon><ArrowDown /></el-icon>{{ t(7, '0.4h') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">2. 物料上架满足率</span>
-            <div class="metric-value">
-              <span class="value success">{{ d(11, 99.1, 1) }}</span>
-              <span class="unit">%</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(8, '0.4%') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">3. 物料上架人效</span>
-            <div class="metric-value">
-              <span class="value">{{ d(12, 210, 0) }}</span>
-              <span class="unit">P/H</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(9, '8%') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">4. 上架物料周转</span>
-            <div class="metric-value">
-              <span class="value">{{ d(13, 15.4, 1) }}</span>
-              <span class="unit">x</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(10, '2.1') }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 备货 -->
-      <div class="process-card">
-        <div class="process-header">
-          <span class="process-title">
-            <el-icon><ShoppingBag /></el-icon>
-            物料仓储(备货)
-          </span>
-          <el-tag size="small" :type="statusTypeByRate(Number(d(15, 97.8, 1)))">{{ statusTextByRate(Number(d(15, 97.8, 1))) }}</el-tag>
-        </div>
-        <div class="process-metrics">
-          <div class="metric-item">
-            <span class="metric-label">1. 物料备货周期</span>
-            <div class="metric-value">
-              <span class="value">{{ d(14, 5.2, 1) }}</span>
-              <span class="unit">h</span>
-              <span class="trend down"><el-icon><ArrowDown /></el-icon>{{ t(11, '0.5h') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">2. 物料备货满足率</span>
-            <div class="metric-value">
-              <span class="value success">{{ d(15, 97.8, 1) }}</span>
-              <span class="unit">%</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(12, '0.7%') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">3. 物料备货人效</span>
-            <div class="metric-value">
-              <span class="value">{{ d(16, 114, 0) }}</span>
-              <span class="unit">P/H</span>
-              <span class="trend stable">{{ t(13, '持平') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">4. 备货物料周转</span>
-            <div class="metric-value">
-              <span class="value">{{ d(17, 9.6, 1) }}</span>
-              <span class="unit">x</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(14, '0.3') }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 发货 -->
-      <div class="process-card" :class="statusTypeByRate(Number(d(19, 88.5, 1))) === 'danger' ? 'danger-border' : statusTypeByRate(Number(d(19, 88.5, 1))) === 'warning' ? 'warning-border' : ''">
-        <div class="process-header">
-          <span class="process-title">
-            <el-icon><Van /></el-icon>
-            物料仓储(发货)
-          </span>
-          <el-tag size="small" :type="statusTypeByRate(Number(d(19, 88.5, 1)))">{{ statusTextByRate(Number(d(19, 88.5, 1))) }}</el-tag>
-        </div>
-        <div class="process-metrics">
-          <div class="metric-item">
-            <span class="metric-label">1. 物料发货周期</span>
-            <div class="metric-value">
-              <span class="value danger">{{ d(18, 2.8, 1) }}</span>
-              <span class="unit">d</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(15, '0.4d') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">2. 物料发货满足率</span>
-            <div class="metric-value">
-              <span class="value danger">{{ d(19, 88.5, 1) }}</span>
-              <span class="unit">%</span>
-              <span class="trend down"><el-icon><ArrowDown /></el-icon>{{ t(16, '4.2%') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">3. 物料发货人效</span>
-            <div class="metric-value">
-              <span class="value">{{ d(20, 2197, 0) }}</span>
-              <span class="unit">人</span>
-              <span class="trend up"><el-icon><ArrowUp /></el-icon>{{ t(17, '7%') }}</span>
-            </div>
-          </div>
-          <div class="metric-item">
-            <span class="metric-label">4. 发货物料周转</span>
-            <div class="metric-value">
-              <span class="value">{{ d(21, 8.2, 1) }}</span>
-              <span class="unit">x</span>
-              <span class="trend down"><el-icon><ArrowDown /></el-icon>{{ t(18, '0.3') }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 右下角:辅助工具 -->
-    <div class="tools-section">
-      <div class="tools-header">
-        <el-icon><Tools /></el-icon>
-        <span>辅助工具</span>
-      </div>
-      <div class="tools-grid">
-        <div class="tool-item">
-          <el-icon><Document /></el-icon>
-          <span>历史报告</span>
-        </div>
-        <div class="tool-item">
-          <el-icon><Location /></el-icon>
-          <span>库位导航</span>
-        </div>
-        <div class="tool-item">
-          <el-icon><User /></el-icon>
-          <span>人员调度</span>
-        </div>
-        <div class="tool-item warning">
-          <el-icon><Warning /></el-icon>
-          <span>风险预警</span>
-        </div>
-      </div>
-    </div>
-  </div>
+    </template>
+  </ModuleDashboardPage>
 </template>
 
 <script setup>
-import { ref, onMounted, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import DetailQueryBar from './components/DetailQueryBar.vue'
-import SmartOpsBaseQueryFields from './components/SmartOpsBaseQueryFields.vue'
-import { emptySmartOpsBaseQuery, summarizeSmartOpsBaseQuery } from './utils/smartOpsBaseQuery'
-import {
-  ArrowLeft, Search, Bell, Setting, Download, Refresh,
-  Timer, CircleCheck, User, UserFilled, Folder, Checked,
-  Top, ShoppingBag, Van, Tools, Document, Location, Warning
-} from '@element-plus/icons-vue'
-import * as echarts from 'echarts'
-import { homeS5 } from './data/homeModulesSync'
-import { loadHomeModuleMetrics } from './data/homeModulesSync'
-import { fetchModuleDetail } from '../api/kanbanData'
-
-const router = useRouter()
-const currentTime = ref('')
-const trendXAxis = ref(['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7'])
-const inboundTrend = ref([2.8, 2.6, 2.7, 2.5, 2.4, 2.4, 2.4])
-const outboundTrend = ref([1.8, 2.0, 2.2, 2.5, 2.6, 2.7, 2.8])
-const radarValues = ref([98.2, 88.5, 97.8, 99.1, 94.5])
-const detailNums = ref([12.5, 0.34, 1.2, 2.4, 158, 12.2, 4.8, 94.5, 92, 8.4, 1.2, 99.1, 210, 15.4, 5.2, 97.8, 114, 9.6, 2.8, 88.5, 2197, 8.2])
-const trendTexts = ref(['0.2h', '5%', '平稳', '1.2h', '2.1%', '2%', '0.5', '0.4h', '0.4%', '8%', '2.1', '0.5h', '0.7%', '持平', '0.3', '0.4d', '4.2%', '7%', '0.3'])
-
-const detailQuery = ref({
-  ...emptySmartOpsBaseQuery(),
-  warehouse: '',
-  material: '',
-  workOrder: '',
-})
-
-function onDetailQuery() {
-  ElMessage.success(`已应用仓储筛选(${summarizeSmartOpsBaseQuery(detailQuery.value)})`)
-}
-
-function onDetailQueryReset() {
-  detailQuery.value = { ...emptySmartOpsBaseQuery(), warehouse: '', material: '', workOrder: '' }
-  ElMessage.info('已重置')
-}
-
-let trendChart = null
-let radarChart = null
-
-const updateTime = () => {
-  currentTime.value = new Date().toLocaleString('zh-CN', {
-    year: 'numeric',
-    month: '2-digit',
-    day: '2-digit',
-    hour: '2-digit',
-    minute: '2-digit',
-    second: '2-digit',
-    hour12: false
-  })
-}
-
-const d = (idx, fallback = 0, digits = 1) => {
-  const n = Number(detailNums.value[idx] ?? fallback)
-  return Number.isFinite(n) ? n.toFixed(digits) : Number(fallback).toFixed(digits)
-}
-const t = (idx, fallback = '-') => trendTexts.value[idx] ?? fallback
-const statusTypeByRate = (val, target = 95) => {
-  if (val >= target) return 'success'
-  if (val >= target - 5) return 'warning'
-  return 'danger'
-}
-const statusTextByRate = (val, target = 95) => {
-  if (val >= target) return '优秀'
-  if (val >= target - 5) return '警告'
-  return '严重'
-}
-
-const initTrendChart = () => {
-  const chartDom = document.getElementById('trend-chart')
-  if (!chartDom) return
-  
-  trendChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    legend: { show: false },
-    grid: { left: '3%', right: '4%', bottom: '10%', top: '15%' },
-    xAxis: {
-      type: 'category',
-      data: trendXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: {
-      type: 'value',
-      min: 0,
-      max: 3,
-      splitLine: { lineStyle: { color: '#1e293b' } },
-      axisLabel: { color: '#64748b' }
-    },
-    series: [
-      {
-        name: '收货',
-        type: 'line',
-        smooth: true,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: { color: '#60a5fa' },
-        lineStyle: { color: '#60a5fa', width: 2 },
-        data: inboundTrend.value
-      },
-      {
-        name: '发货',
-        type: 'line',
-        smooth: true,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: { color: '#f87171' },
-        lineStyle: { color: '#f87171', width: 2 },
-        data: outboundTrend.value
-      }
-    ]
-  }
-  trendChart.setOption(option)
-}
-
-const initRadarChart = () => {
-  const chartDom = document.getElementById('radar-chart')
-  if (!chartDom) return
-  
-  radarChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {},
-    radar: {
-      indicator: [
-        { name: '收货满足率', max: 100 },
-        { name: '发货满足率', max: 100 },
-        { name: '备货满足率', max: 100 },
-        { name: '上架满足率', max: 100 },
-        { name: '检验合格率', max: 100 }
-      ],
-      axisName: {
-        color: '#94a3b8',
-        fontSize: 11
-      },
-      splitLine: {
-        lineStyle: { color: '#334155' }
-      },
-      splitArea: {
-        areaStyle: { color: ['rgba(30, 41, 59, 0.5)', 'rgba(15, 23, 42, 0.5)'] }
-      }
-    },
-    series: [{
-      type: 'radar',
-      data: [
-        {
-          value: radarValues.value,
-          name: '子流程满足率',
-          areaStyle: {
-            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-              { offset: 0, color: 'rgba(96, 165, 250, 0.5)' },
-              { offset: 1, color: 'rgba(96, 165, 250, 0.1)' }
-            ])
-          },
-          lineStyle: { color: '#60a5fa', width: 2 },
-          itemStyle: { color: '#60a5fa' }
-        }
-      ]
-    }]
-  }
-  radarChart.setOption(option)
-}
-
-const initCharts = () => {
-  nextTick(() => {
-    initTrendChart()
-    initRadarChart()
-    
-    window.addEventListener('resize', () => {
-      trendChart?.resize()
-      radarChart?.resize()
-    })
-  })
-}
-
-onMounted(async () => {
-  await loadHomeModuleMetrics()
-  const detail = await fetchModuleDetail('S5')
-  const l2 = detail.l2 ?? []
-  const l3 = detail.l3 ?? []
-  const merged = [...l2, ...l3]
-  if (l2.length > 0) {
-    const vals = l2.slice(0, 7).map((x) => Number(x.metricValue ?? 0))
-    trendXAxis.value = l2.slice(0, 7).map((x, i) => (x.statDate ? String(x.statDate).slice(5, 10) : `D${i + 1}`))
-    inboundTrend.value = vals.map((v) => Number((v / 10).toFixed(1)))
-    outboundTrend.value = [...inboundTrend.value].reverse()
-    radarValues.value = l2.slice(0, 5).map((x) => Math.max(0, Math.min(100, Number(x.metricValue ?? 0))))
-  }
-  if (merged.length > 0) {
-    detailNums.value = merged.slice(0, 22).map((x, i) => Number(x.metricValue ?? detailNums.value[i] ?? 0))
-  }
-  updateTime()
-  setInterval(updateTime, 1000)
-  initCharts()
-})
+import ModuleDashboardPage from './components/ModuleDashboardPage.vue'
 </script>
-
-<style scoped>
-.s5-dashboard {
-  box-sizing: border-box;
-  flex: 1;
-  min-height: 0;
-  max-height: 100%;
-  overflow-x: hidden;
-  overflow-y: auto;
-  -webkit-overflow-scrolling: touch;
-  background: #0b0f19;
-  padding: 20px;
-  padding-bottom: 24px;
-  color: #e2e8f0;
-}
-
-/* 顶部标题栏 */
-.top-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: flex-start;
-  margin-bottom: 20px;
-}
-
-.header-left {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.breadcrumb {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  font-size: 12px;
-  color: #64748b;
-}
-
-.back-icon {
-  cursor: pointer;
-  color: #60a5fa;
-}
-
-.back-icon:hover {
-  color: #93c5fd;
-}
-
-.gold-tag {
-  background: linear-gradient(135deg, #fbbf24, #f59e0b);
-  border: none;
-  color: white;
-  font-size: 10px;
-  padding: 2px 8px;
-}
-
-.page-title {
-  margin: 0;
-  font-size: 20px;
-  font-weight: 700;
-  color: #e2e8f0;
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.page-title .subtitle {
-  font-size: 12px;
-  font-weight: 400;
-  color: #64748b;
-}
-
-.header-right {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-}
-
-.system-time {
-  font-size: 12px;
-  color: #64748b;
-  margin-right: 10px;
-}
-
-.btn-icon {
-  padding: 8px;
-  background: rgba(51, 65, 85, 0.6);
-  border: 1px solid #334155;
-  color: #94a3b8;
-}
-
-.btn-export {
-  background: rgba(51, 65, 85, 0.6);
-  border: 1px solid #334155;
-  color: #94a3b8;
-}
-
-.btn-sync {
-  background: linear-gradient(135deg, #3b82f6, #2563eb);
-  border: none;
-}
-
-/* 第一行 KPI */
-.row-1 {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 15px;
-  margin-bottom: 15px;
-}
-
-.kpi-card {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-}
-
-.kpi-card.success {
-  border-color: rgba(52, 211, 153, 0.5);
-}
-
-.kpi-card.warning {
-  border-color: rgba(251, 191, 36, 0.5);
-}
-
-.kpi-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 12px;
-  color: #94a3b8;
-  font-size: 12px;
-}
-
-.kpi-value {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-  margin-bottom: 8px;
-}
-
-.kpi-value .value {
-  font-size: 32px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.kpi-value .value.success { color: #34d399; }
-.kpi-value .value.warning { color: #fbbf24; }
-
-.kpi-value .unit {
-  font-size: 12px;
-  color: #64748b;
-}
-
-.kpi-trend {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  font-size: 11px;
-  font-weight: 600;
-  margin-bottom: 8px;
-}
-
-.kpi-trend.up { color: #34d399; }
-.kpi-trend.down { color: #34d399; }
-
-.kpi-status {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  font-size: 11px;
-  color: #60a5fa;
-  margin-bottom: 8px;
-}
-
-.kpi-bar {
-  height: 3px;
-  background: #1e293b;
-  border-radius: 2px;
-  overflow: hidden;
-}
-
-.kpi-bar .bar-fill {
-  height: 100%;
-  background: linear-gradient(90deg, #60a5fa, #3b82f6);
-  border-radius: 2px;
-}
-
-.kpi-bar.success .bar-fill { background: linear-gradient(90deg, #34d399, #10b981); }
-.kpi-bar.warning .bar-fill { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
-.kpi-bar.danger .bar-fill { background: linear-gradient(90deg, #ef4444, #dc2626); }
-
-/* 第二行图表 */
-.row-2 {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 15px;
-  margin-bottom: 15px;
-}
-
-.chart-card {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-}
-
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.card-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.title-line {
-  width: 4px;
-  height: 16px;
-  background: #60a5fa;
-  border-radius: 2px;
-}
-
-.chart-legend {
-  display: flex;
-  gap: 15px;
-  font-size: 12px;
-  color: #94a3b8;
-}
-
-.legend-item {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.legend-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-}
-
-.legend-dot.blue { background: #60a5fa; }
-.legend-dot.red { background: #f87171; }
-
-.chart-container {
-  width: 100%;
-  height: 280px;
-}
-
-.radar-container {
-  width: 100%;
-  height: 280px;
-}
-
-/* 第三行流程卡片 */
-.row-3 {
-  display: grid;
-  grid-template-columns: repeat(5, 1fr);
-  gap: 12px;
-  margin-bottom: 15px;
-}
-
-.process-card {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 8px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.process-card.warning-border {
-  border-color: rgba(251, 191, 36, 0.5);
-}
-
-.process-card.danger-border {
-  border-color: rgba(239, 68, 68, 0.5);
-}
-
-.process-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 12px;
-  padding-bottom: 10px;
-  border-bottom: 1px solid #1e293b;
-}
-
-.process-title {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  font-size: 12px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.process-metrics {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-}
-
-.metric-item {
-  padding: 8px;
-  background: rgba(15, 23, 42, 0.5);
-  border-radius: 4px;
-}
-
-.metric-label {
-  display: block;
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 6px;
-}
-
-.metric-value {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.metric-value .value {
-  font-size: 16px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.metric-value .value.success { color: #34d399; }
-.metric-value .value.warning { color: #fbbf24; }
-.metric-value .value.danger { color: #ef4444; }
-
-.metric-value .unit {
-  font-size: 10px;
-  color: #64748b;
-}
-
-.metric-value .trend {
-  font-size: 10px;
-  font-weight: 600;
-  margin-left: auto;
-}
-
-.metric-value .trend.up { color: #34d399; }
-.metric-value .trend.down { color: #ef4444; }
-.metric-value .trend.stable { color: #64748b; }
-
-/* 辅助工具 */
-.tools-section {
-  position: fixed;
-  bottom: 20px;
-  right: 20px;
-  background: rgba(30, 41, 59, 0.8);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-  min-width: 280px;
-}
-
-.tools-header {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-  margin-bottom: 12px;
-  padding-bottom: 10px;
-  border-bottom: 1px solid #1e293b;
-}
-
-.tools-grid {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  gap: 10px;
-}
-
-.tool-item {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 6px;
-  padding: 12px;
-  background: rgba(15, 23, 42, 0.5);
-  border-radius: 6px;
-  cursor: pointer;
-  transition: all 0.3s;
-  border: 1px solid transparent;
-}
-
-.tool-item:hover {
-  background: rgba(96, 165, 250, 0.1);
-  border-color: #60a5fa;
-}
-
-.tool-item.warning {
-  color: #fbbf24;
-}
-
-.tool-item.warning:hover {
-  background: rgba(251, 191, 36, 0.1);
-  border-color: #fbbf24;
-}
-
-.tool-item .el-icon {
-  font-size: 20px;
-}
-
-.tool-item span {
-  font-size: 11px;
-}
-
-/* Element Plus 覆盖 */
-:deep(.el-button--small) {
-  padding: 8px 16px;
-  font-size: 12px;
-}
-
-:deep(.el-tag) {
-  font-size: 10px;
-  padding: 2px 8px;
-  border-radius: 4px;
-}
-
-:deep(.el-tag--success) {
-  background: rgba(52, 211, 153, 0.2);
-  border-color: rgba(52, 211, 153, 0.3);
-  color: #34d399;
-}
-
-:deep(.el-tag--warning) {
-  background: rgba(251, 191, 36, 0.2);
-  border-color: rgba(251, 191, 36, 0.3);
-  color: #fbbf24;
-}
-
-:deep(.el-tag--danger) {
-  background: rgba(239, 68, 68, 0.2);
-  border-color: rgba(239, 68, 68, 0.3);
-  color: #ef4444;
-}
-</style>

+ 14 - 1125
Web/src/views/aidop/kanban/s6.vue

@@ -1,1132 +1,21 @@
 <template>
-  <div class="s6-dashboard">
-    <!-- 顶部标题栏 -->
-    <div class="top-header">
-      <div class="header-left">
-        <h1 class="page-title">
-          企业数字化运营看板 V5.0
-          <el-tag size="small" class="gold-tag">正式版</el-tag>
-        </h1>
-        <p class="page-subtitle">生产执行详情分析</p>
-      </div>
-      <div class="header-right">
-        <div class="system-time">
-          <div class="time-label">当前系统时间</div>
-          <div class="time-value">{{ currentTime }}</div>
-        </div>
-        <el-button size="small" circle class="btn-icon"><el-icon><FullScreen /></el-icon></el-button>
-        <el-button size="small" circle class="btn-icon"><el-icon><Setting /></el-icon></el-button>
-      </div>
-    </div>
-
-    <!-- 生产执行:日期、订单、产线、设备 -->
-    <DetailQueryBar
-      dark
-      band-title="基础查询:日期、产品、订单号、产线"
-      @query="onDetailQuery"
-      @reset="onDetailQueryReset"
-    >
-      <SmartOpsBaseQueryFields v-model="detailQuery" compact />
+  <ModuleDashboardPage
+    module-code="S6"
+    title="S6 生产执行详情"
+    left-title="生产执行"
+    left-subtitle="工单制造 — 周期与人效"
+    right-title="生产执行结果"
+    right-subtitle="制造满足率与在制周转"
+    :extra-query-init="{ equipment: '' }"
+  >
+    <template #extra-fields="{ query }">
       <el-form-item label="设备">
-        <el-input v-model="detailQuery.equipment" placeholder="设备编码/名称" clearable style="width: 140px" />
+        <el-input v-model="query.equipment" placeholder="设备编码/名称" clearable style="width: 160px" />
       </el-form-item>
-    </DetailQueryBar>
-
-    <!-- 第一行:4 个层级卡片 -->
-    <div class="row-1">
-      <div class="level-card level-1">
-        <div class="level-header">
-          <span class="level-tag">层级:S6-1</span>
-          <span class="level-layer">核心决策层</span>
-        </div>
-        <div class="level-content">
-          <h2 class="level-title">订单级执行</h2>
-          <div class="level-value">
-            <span class="value">{{ homeS6.woOnTimeDeliveryPct }}</span>
-            <span class="unit">%</span>
-          </div>
-          <div class="level-status warning">
-            <span>综合健康指数</span>
-            <span class="trend">目标 {{ homeS6.woOnTimeDeliveryTargetPct }}%</span>
-          </div>
-        </div>
-      </div>
-
-      <div class="level-card level-2">
-        <div class="level-header">
-          <span class="level-tag">层级:S6-2</span>
-          <span class="level-layer">调度控制层</span>
-        </div>
-        <div class="level-content">
-          <h2 class="level-title">工单级执行</h2>
-          <div class="level-value">
-            <span class="value">{{ homeS6.woOnTimeDeliveryPct }}</span>
-            <span class="unit">%</span>
-          </div>
-          <div class="level-status warning">
-            <span>排程达成率</span>
-            <span class="trend">目标 {{ homeS6.woOnTimeDeliveryTargetPct }}%</span>
-          </div>
-        </div>
-      </div>
-
-      <div class="level-card level-3">
-        <div class="level-header">
-          <span class="level-tag">层级:S6-3</span>
-          <span class="level-layer purple">工序协同层</span>
-        </div>
-        <div class="level-content">
-          <h2 class="level-title">工序级执行</h2>
-          <div class="level-value">
-            <span class="value">{{ homeS6.firstPassYieldPct }}</span>
-            <span class="unit">%</span>
-          </div>
-          <div class="level-status warning">
-            <span>流转平衡率</span>
-            <span class="trend">目标 {{ homeS6.firstPassYieldTargetPct }}%</span>
-          </div>
-        </div>
-      </div>
-
-      <div class="level-card level-4 critical">
-        <div class="level-header">
-          <span class="level-tag">层级:S6-4</span>
-          <span class="level-layer red">设备基础层</span>
-        </div>
-        <div class="level-content">
-          <h2 class="level-title">设备级执行</h2>
-          <div class="level-value">
-            <span class="value danger">{{ d(0, 86.1, 1) }}</span>
-            <span class="unit">%</span>
-          </div>
-          <div class="level-status danger">
-            <span>OEE 综合效率</span>
-            <span class="trend down">↓ {{ d(5, 3.5, 1) }}% {{ statusTextByRate(Number(d(0, 86.1, 1)), 95) }}</span>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 第二行:订单 + 工序指标分析 -->
-    <div class="row-2">
-      <!-- 左侧:订单制造指标分析 -->
-      <div class="analysis-card order-analysis">
-        <div class="card-header">
-          <div class="card-title">
-            <span class="title-line blue"></span>
-            订单制造指标分析
-          </div>
-          <span class="data-source">数据源:ERP/MES 实时</span>
-        </div>
-        <div class="metrics-grid">
-          <div class="metric-box">
-            <div class="metric-label">订单制造周期</div>
-            <div class="metric-value">
-              <span class="value">{{ d(1, 2.5, 1) }}</span>
-              <span class="unit">Days</span>
-            </div>
-            <div class="metric-trend success">
-              <span>↓ {{ t(0, '-0.3') }}</span>
-              <span class="target">(目标:{{ d(1, 2.8, 1) }})</span>
-            </div>
-            <div class="progress-bar">
-              <div class="progress-fill blue" :style="{ width: `${Math.max(10, Math.min(100, Number(d(0, 86.1, 1))))}%` }"></div>
-            </div>
-          </div>
-
-          <div class="metric-box">
-            <div class="metric-label">订单制造满足率</div>
-            <div class="metric-value">
-              <span class="value danger">{{ homeS6.woOnTimeDeliveryPct }}</span>
-              <span class="unit">%</span>
-            </div>
-            <div class="metric-trend danger">
-              <span>{{ statusTextByRate(Number(homeS6.woOnTimeDeliveryPct), Number(homeS6.woOnTimeDeliveryTargetPct)) }}</span>
-              <span class="target">(目标:{{ homeS6.woOnTimeDeliveryTargetPct }})</span>
-            </div>
-            <div class="progress-bar">
-              <div class="progress-fill green" :style="{ width: `${homeS6.woOnTimeDeliveryPct}%` }"></div>
-            </div>
-          </div>
-
-          <div class="metric-box">
-            <div class="metric-label">订单制造人效</div>
-            <div class="metric-value">
-              <span class="value">{{ homeS6.manufacturingEfficiency }}</span>
-              <span class="unit">指数</span>
-            </div>
-            <div class="metric-trend warning">
-              <span>目标 {{ homeS6.manufacturingEfficiencyTarget }}</span>
-            </div>
-            <div class="progress-bar">
-              <div
-                class="progress-fill purple"
-                :style="{
-                  width: `${Math.min(100, (homeS6.manufacturingEfficiency / homeS6.manufacturingEfficiencyTarget) * 100)}%`
-                }"
-              ></div>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 右侧:工序制造指标分析 -->
-      <div class="analysis-card operation-analysis">
-        <div class="card-header">
-          <div class="card-title">
-            <span class="title-line purple"></span>
-            工序制造指标分析
-          </div>
-          <el-button link size="small" class="btn-check">
-            查看堵塞节点
-            <el-icon><ArrowRight /></el-icon>
-          </el-button>
-        </div>
-        <div class="gauges-grid">
-          <div class="gauge-box">
-            <div class="gauge-label">工序周期</div>
-            <div id="gauge-cycle" class="gauge-chart"></div>
-            <div class="gauge-value">{{ gaugeCycleValue }}h</div>
-          </div>
-          <div class="gauge-box">
-            <div class="gauge-label">工序满足率</div>
-            <div id="gauge-satisfaction" class="gauge-chart"></div>
-            <div class="gauge-value warning">{{ homeS6.firstPassYieldPct }}%</div>
-          </div>
-          <div class="gauge-box">
-            <div class="gauge-label">工序人效</div>
-            <div id="gauge-efficiency" class="gauge-chart"></div>
-            <div class="gauge-value">{{ homeS6.manufacturingEfficiency }}</div>
-          </div>
-          <div class="gauge-box">
-            <div class="gauge-label">在制周转</div>
-            <div id="gauge-turnover" class="gauge-chart"></div>
-            <div class="gauge-value warning">{{ gaugeTurnoverValue }}d</div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 第三行:工单 + 设备指标分析 -->
-    <div class="row-3">
-      <!-- 左侧:工单制造指标分析 -->
-      <div class="analysis-card workorder-analysis">
-        <div class="card-header">
-          <div class="card-title">
-            <span class="title-line cyan"></span>
-            工单制造指标分析
-          </div>
-          <el-tag size="small" type="danger" class="alert-tag">{{ Math.max(1, Math.round(Number(d(2, 18.4, 1)) / 6)) }} 个延期工单</el-tag>
-        </div>
-        <div class="wo-metrics">
-          <div class="wo-metric">
-            <div class="metric-label">工单制造周期</div>
-            <div class="metric-value">
-              <span class="value">{{ d(2, 18.4, 1) }}</span>
-              <span class="unit">Hrs</span>
-            </div>
-            <div class="metric-trend success">↓ {{ t(1, '-2.2%') }}</div>
-          </div>
-          <div class="wo-metric">
-            <div class="metric-label">工单满足率</div>
-            <div class="metric-value">
-              <span class="value danger">{{ homeS6.woOnTimeDeliveryPct }}</span>
-              <span class="unit">%</span>
-            </div>
-            <div class="metric-trend danger">目标 {{ homeS6.woOnTimeDeliveryTargetPct }}%</div>
-          </div>
-          <div class="wo-metric">
-            <div class="metric-label">工单制造人效</div>
-            <div class="metric-value">
-              <span class="value">{{ homeS6.manufacturingEfficiency }}</span>
-              <span class="unit">指数</span>
-            </div>
-            <div class="metric-trend warning">目标 {{ homeS6.manufacturingEfficiencyTarget }}</div>
-          </div>
-          <div class="wo-metric">
-            <div class="metric-label">工单在制周转</div>
-            <div class="metric-value">
-              <span class="value">{{ d(3, 2.6, 1) }}</span>
-              <span class="unit">Days</span>
-            </div>
-            <div class="metric-trend success">↑ {{ t(2, '+0.6%') }}</div>
-          </div>
-        </div>
-        <div class="wo-chart">
-          <div id="wo-trend-chart" class="chart-container"></div>
-        </div>
-      </div>
-
-      <!-- 右侧:设备制造指标分析 -->
-      <div class="analysis-card machine-analysis">
-        <div class="card-header">
-          <div class="card-title">
-            <span class="title-line red"></span>
-            设备制造指标分析
-          </div>
-          <span class="alert-text">报警中:S2/S6 产线设备</span>
-        </div>
-        <div class="machine-metrics">
-          <div class="machine-metric">
-            <div class="metric-label">设备周期</div>
-            <div class="metric-value">
-              <span class="value">{{ d(4, 2.2, 1) }}</span>
-              <span class="unit">Days</span>
-            </div>
-            <div class="metric-trend success">↓ {{ t(3, '-0.2') }}</div>
-          </div>
-          <div class="machine-metric">
-            <div class="metric-label">设备满足率</div>
-            <div class="metric-value">
-              <span class="value danger">{{ d(0, 86.1, 1) }}</span>
-              <span class="unit">%</span>
-            </div>
-            <div class="metric-trend danger">↓ {{ t(4, '-3.5') }}</div>
-          </div>
-          <div class="machine-metric">
-            <div class="metric-label">OEE 效率</div>
-            <div class="metric-value">
-              <span class="value warning">{{ d(5, 72.4, 1) }}</span>
-              <span class="unit">%</span>
-            </div>
-            <div class="metric-trend danger">↓ {{ t(5, '-2.1') }}</div>
-          </div>
-          <div class="machine-metric">
-            <div class="metric-label">设备周转</div>
-            <div class="metric-value">
-              <span class="value">{{ d(6, 8.2, 1) }}</span>
-              <span class="unit">Days</span>
-            </div>
-            <div class="metric-trend success">↓ {{ t(6, '-0.3') }}</div>
-          </div>
-        </div>
-        <div class="machine-bars">
-          <div class="bar-item">
-            <span class="bar-label">稼动</span>
-            <div class="bar-container">
-              <div class="bar-fill green" :style="{ width: `${Math.max(10, Math.min(100, Number(d(7, 88, 0))))}%` }"></div>
-            </div>
-          </div>
-          <div class="bar-item">
-            <span class="bar-label">良率</span>
-            <div class="bar-container">
-              <div class="bar-fill green" :style="{ width: `${Math.max(10, Math.min(100, Number(d(8, 82, 0))))}%` }"></div>
-            </div>
-          </div>
-          <div class="bar-item">
-            <span class="bar-label">效率</span>
-            <div class="bar-container">
-              <div class="bar-fill red" :style="{ width: `${Math.max(10, Math.min(100, Number(d(9, 72, 0))))}%` }"></div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 底部功能按钮 -->
-    <div class="bottom-actions">
-      <el-button class="action-btn">
-        <el-icon><Monitor /></el-icon>
-        实时看板
-      </el-button>
-      <el-button class="action-btn warning">
-        <el-icon><Warning /></el-icon>
-        异常闭环
-      </el-button>
-      <el-button class="action-btn success">
-        <el-icon><Box /></el-icon>
-        库存周转
-      </el-button>
-      <el-button class="action-btn purple">
-        <el-icon><TrendCharts /></el-icon>
-        效能追溯
-      </el-button>
-      <el-button class="action-btn cyan">
-        <el-icon><Download /></el-icon>
-        导出报告
-      </el-button>
-      <el-button class="action-btn primary">
-        <el-icon><Refresh /></el-icon>
-        刷新数据
-      </el-button>
-    </div>
-  </div>
+    </template>
+  </ModuleDashboardPage>
 </template>
 
 <script setup>
-import { ref, onMounted, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import DetailQueryBar from './components/DetailQueryBar.vue'
-import SmartOpsBaseQueryFields from './components/SmartOpsBaseQueryFields.vue'
-import { emptySmartOpsBaseQuery, summarizeSmartOpsBaseQuery } from './utils/smartOpsBaseQuery'
-import {
-  FullScreen, Setting, ArrowRight, Monitor, Warning,
-  Box, TrendCharts, Download, Refresh
-} from '@element-plus/icons-vue'
-import * as echarts from 'echarts'
-import { homeS6 } from './data/homeModulesSync'
-import { loadHomeModuleMetrics } from './data/homeModulesSync'
-import { fetchModuleDetail } from '../api/kanbanData'
-
-const router = useRouter()
-const currentTime = ref('')
-const gaugeCycleValue = ref(0.8)
-const gaugeTurnoverValue = ref(14.6)
-const woTrendXAxis = ref(['D1', 'D2', 'D3', 'D4', 'D5', 'D6'])
-const woTrendValues = ref([92, 93, 91, 94, 95, 97])
-const detailNums = ref([86.1, 2.5, 18.4, 2.6, 2.2, 72.4, 8.2, 88, 82, 76])
-const trendTexts = ref(['-0.3', '-2.2%', '+0.6%', '-0.2', '-3.5', '-2.1', '-0.3'])
-
-const detailQuery = ref({
-  ...emptySmartOpsBaseQuery(),
-  equipment: '',
-})
-
-function onDetailQuery() {
-  ElMessage.success(`已应用生产筛选(${summarizeSmartOpsBaseQuery(detailQuery.value)})`)
-}
-
-function onDetailQueryReset() {
-  detailQuery.value = { ...emptySmartOpsBaseQuery(), equipment: '' }
-  ElMessage.info('已重置')
-}
-
-let gaugeCycle = null
-let gaugeSatisfaction = null
-let gaugeEfficiency = null
-let gaugeTurnover = null
-let woTrendChart = null
-
-const updateTime = () => {
-  currentTime.value = new Date().toLocaleString('zh-CN', {
-    year: 'numeric',
-    month: '2-digit',
-    day: '2-digit',
-    hour: '2-digit',
-    minute: '2-digit',
-    second: '2-digit',
-    hour12: false
-  })
-}
-
-const d = (idx, fallback = 0, digits = 1) => {
-  const n = Number(detailNums.value[idx] ?? fallback)
-  return Number.isFinite(n) ? n.toFixed(digits) : Number(fallback).toFixed(digits)
-}
-const t = (idx, fallback = '-') => trendTexts.value[idx] ?? fallback
-const statusTextByRate = (val, target = 95) => {
-  if (val >= target) return '达标'
-  if (val >= target - 5) return '预警'
-  return '严重'
-}
-
-const initGauges = () => {
-  // 工序周期
-  const cycleDom = document.getElementById('gauge-cycle')
-  if (cycleDom) {
-    gaugeCycle = echarts.init(cycleDom)
-    gaugeCycle.setOption({
-      series: [{
-        type: 'gauge',
-        startAngle: 180,
-        endAngle: 0,
-        min: 0,
-        max: 2,
-        radius: '100%',
-        center: ['50%', '60%'],
-        itemStyle: { color: '#2dd4bf' },
-        progress: { show: true, width: 8, roundCap: true },
-        pointer: { show: false },
-        axisLine: { lineStyle: { width: 8, color: [[1, '#1e293b']] } },
-        axisTick: { show: false },
-        splitLine: { show: false },
-        axisLabel: { show: false },
-        title: { show: false },
-        detail: { show: false },
-        data: [{ value: gaugeCycleValue.value }]
-      }]
-    })
-  }
-
-  // 工序满足率
-  const satisfactionDom = document.getElementById('gauge-satisfaction')
-  if (satisfactionDom) {
-    gaugeSatisfaction = echarts.init(satisfactionDom)
-    gaugeSatisfaction.setOption({
-      series: [{
-        type: 'gauge',
-        startAngle: 180,
-        endAngle: 0,
-        min: 0,
-        max: 100,
-        radius: '100%',
-        center: ['50%', '60%'],
-        itemStyle: { color: '#fbbf24' },
-        progress: { show: true, width: 8, roundCap: true },
-        pointer: { show: false },
-        axisLine: { lineStyle: { width: 8, color: [[1, '#1e293b']] } },
-        axisTick: { show: false },
-        splitLine: { show: false },
-        axisLabel: { show: false },
-        title: { show: false },
-        detail: { show: false },
-        data: [{ value: homeS6.firstPassYieldPct }]
-      }]
-    })
-  }
-
-  // 工序人效
-  const efficiencyDom = document.getElementById('gauge-efficiency')
-  if (efficiencyDom) {
-    gaugeEfficiency = echarts.init(efficiencyDom)
-    gaugeEfficiency.setOption({
-      series: [{
-        type: 'gauge',
-        startAngle: 180,
-        endAngle: 0,
-        min: 0,
-        max: 3,
-        radius: '100%',
-        center: ['50%', '60%'],
-        itemStyle: { color: '#60a5fa' },
-        progress: { show: true, width: 8, roundCap: true },
-        pointer: { show: false },
-        axisLine: { lineStyle: { width: 8, color: [[1, '#1e293b']] } },
-        axisTick: { show: false },
-        splitLine: { show: false },
-        axisLabel: { show: false },
-        title: { show: false },
-        detail: { show: false },
-        data: [{ value: homeS6.manufacturingEfficiency }]
-      }]
-    })
-  }
-
-  // 在制周转
-  const turnoverDom = document.getElementById('gauge-turnover')
-  if (turnoverDom) {
-    gaugeTurnover = echarts.init(turnoverDom)
-    gaugeTurnover.setOption({
-      series: [{
-        type: 'gauge',
-        startAngle: 180,
-        endAngle: 0,
-        min: 0,
-        max: 20,
-        radius: '100%',
-        center: ['50%', '60%'],
-        itemStyle: { color: '#fbbf24' },
-        progress: { show: true, width: 8, roundCap: true },
-        pointer: { show: false },
-        axisLine: { lineStyle: { width: 8, color: [[1, '#1e293b']] } },
-        axisTick: { show: false },
-        splitLine: { show: false },
-        axisLabel: { show: false },
-        title: { show: false },
-        detail: { show: false },
-        data: [{ value: gaugeTurnoverValue.value }]
-      }]
-    })
-  }
-}
-
-const initWoTrendChart = () => {
-  const chartDom = document.getElementById('wo-trend-chart')
-  if (!chartDom) return
-  
-  woTrendChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    grid: { left: '3%', right: '4%', bottom: '10%', top: '15%' },
-    xAxis: {
-      type: 'category',
-      data: woTrendXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b' }
-    },
-    yAxis: {
-      type: 'value',
-      min: 0,
-      max: 100,
-      splitLine: { lineStyle: { color: '#1e293b' } },
-      axisLabel: { color: '#64748b' }
-    },
-    series: [{
-      data: woTrendValues.value,
-      type: 'line',
-      smooth: true,
-      symbol: 'circle',
-      symbolSize: 6,
-      itemStyle: { color: '#2dd4bf' },
-      lineStyle: { color: '#2dd4bf', width: 2 },
-      areaStyle: {
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: 'rgba(45, 212, 191, 0.3)' },
-          { offset: 1, color: 'rgba(45, 212, 191, 0)' }
-        ])
-      }
-    }]
-  }
-  woTrendChart.setOption(option)
-}
-
-const initCharts = () => {
-  nextTick(() => {
-    initGauges()
-    initWoTrendChart()
-    
-    window.addEventListener('resize', () => {
-      gaugeCycle?.resize()
-      gaugeSatisfaction?.resize()
-      gaugeEfficiency?.resize()
-      gaugeTurnover?.resize()
-      woTrendChart?.resize()
-    })
-  })
-}
-
-onMounted(async () => {
-  await loadHomeModuleMetrics()
-  const detail = await fetchModuleDetail('S6')
-  const l2 = detail.l2 ?? []
-  const l3 = detail.l3 ?? []
-  const merged = [...l2, ...l3]
-  if (l2.length > 0) {
-    const nums = l2.slice(0, 6).map((x) => Number(x.metricValue ?? 0))
-    woTrendValues.value = nums.map((x) => Math.max(0, Math.min(100, x)))
-    woTrendXAxis.value = l2.slice(0, 6).map((x, i) => (x.statDate ? String(x.statDate).slice(5, 10) : `D${i + 1}`))
-    gaugeCycleValue.value = Number((Math.max(nums[0] ?? 8, 1) / 10).toFixed(1))
-    gaugeTurnoverValue.value = Number((Math.max(nums[1] ?? 75, 1) / 5).toFixed(1))
-  }
-  if (merged.length > 0) {
-    const nums = merged.slice(0, 10).map((x, i) => Number(x.metricValue ?? detailNums.value[i] ?? 0))
-    detailNums.value = nums
-    const deltas = []
-    for (let i = 1; i < Math.min(8, nums.length); i++) {
-      const prev = nums[i - 1] || 1
-      const cur = nums[i]
-      const pct = ((cur - prev) / Math.abs(prev)) * 100
-      const sign = pct >= 0 ? '+' : ''
-      deltas.push(`${sign}${pct.toFixed(1)}%`)
-    }
-    while (deltas.length < 7) deltas.push('-')
-    trendTexts.value = deltas.slice(0, 7)
-  }
-  updateTime()
-  setInterval(updateTime, 1000)
-  initCharts()
-})
+import ModuleDashboardPage from './components/ModuleDashboardPage.vue'
 </script>
-
-<style scoped>
-.s6-dashboard {
-  box-sizing: border-box;
-  flex: 1;
-  min-height: 0;
-  max-height: 100%;
-  overflow-x: hidden;
-  overflow-y: auto;
-  -webkit-overflow-scrolling: touch;
-  background: #050810;
-  padding: 15px;
-  padding-bottom: 24px;
-  color: #e2e8f0;
-}
-
-/* 顶部标题栏 */
-.top-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: flex-start;
-  margin-bottom: 15px;
-}
-
-.header-left {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.page-title {
-  margin: 0;
-  font-size: 20px;
-  font-weight: 700;
-  color: #e2e8f0;
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-
-.gold-tag {
-  background: linear-gradient(135deg, #fbbf24, #f59e0b);
-  border: none;
-  color: white;
-  font-size: 10px;
-  padding: 2px 8px;
-}
-
-.page-subtitle {
-  margin: 0;
-  font-size: 11px;
-  color: #64748b;
-  letter-spacing: 1px;
-}
-
-.header-right {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-}
-
-.system-time {
-  text-align: right;
-  margin-right: 10px;
-}
-
-.time-label {
-  font-size: 10px;
-  color: #64748b;
-}
-
-.time-value {
-  font-size: 14px;
-  font-weight: 700;
-  color: #22d3ee;
-  font-family: 'Courier New', monospace;
-}
-
-.btn-icon {
-  padding: 8px;
-  background: rgba(51, 65, 85, 0.6);
-  border: 1px solid #334155;
-  color: #94a3b8;
-}
-
-/* 第一行:4 个层级卡片 */
-.row-1 {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 12px;
-  margin-bottom: 12px;
-}
-
-.level-card {
-  background: rgba(15, 23, 42, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-}
-
-.level-card.level-1 { border-left: 3px solid #60a5fa; }
-.level-card.level-2 { border-left: 3px solid #a855f7; }
-.level-card.level-3 { border-left: 3px solid #c084fc; }
-.level-card.level-4.critical { border-left: 3px solid #ef4444; }
-
-.level-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 12px;
-}
-
-.level-tag {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.level-layer {
-  font-size: 11px;
-  color: #60a5fa;
-}
-
-.level-layer.purple { color: #c084fc; }
-.level-layer.red { color: #ef4444; }
-
-.level-content {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.level-title {
-  margin: 0;
-  font-size: 14px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.level-value {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-}
-
-.level-value .value {
-  font-size: 36px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.level-value .value.danger { color: #ef4444; }
-
-.level-value .unit {
-  font-size: 14px;
-  color: #64748b;
-}
-
-.level-status {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  font-size: 11px;
-  color: #64748b;
-}
-
-.level-status.success { color: #4ade80; }
-.level-status.warning { color: #fbbf24; }
-.level-status.danger { color: #ef4444; }
-
-.level-status .trend { font-weight: 600; }
-.level-status .trend.down { color: #ef4444; }
-
-/* 第二行 */
-.row-2 {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 12px;
-  margin-bottom: 12px;
-}
-
-.analysis-card {
-  background: rgba(15, 23, 42, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-}
-
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.card-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.title-line {
-  width: 4px;
-  height: 16px;
-  border-radius: 2px;
-}
-
-.title-line.blue { background: #60a5fa; }
-.title-line.purple { background: #c084fc; }
-.title-line.cyan { background: #22d3ee; }
-.title-line.red { background: #ef4444; }
-
-.data-source {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.btn-check {
-  color: #60a5fa;
-  font-size: 11px;
-}
-
-.metrics-grid {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 15px;
-}
-
-.metric-box {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.metric-label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 8px;
-}
-
-.metric-value {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-  margin-bottom: 6px;
-}
-
-.metric-value .value {
-  font-size: 24px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.metric-value .value.success { color: #4ade80; }
-
-.metric-value .unit {
-  font-size: 12px;
-  color: #64748b;
-}
-
-.metric-trend {
-  font-size: 11px;
-  font-weight: 600;
-  color: #4ade80;
-  margin-bottom: 8px;
-}
-
-.metric-trend .target {
-  font-weight: 400;
-  color: #64748b;
-}
-
-.metric-trend.danger {
-  color: #ef4444;
-}
-
-.metric-trend.warning {
-  color: #fbbf24;
-}
-
-.progress-bar {
-  height: 4px;
-  background: #1e293b;
-  border-radius: 2px;
-  overflow: hidden;
-}
-
-.progress-bar .progress-fill {
-  height: 100%;
-  border-radius: 2px;
-}
-
-.progress-fill.blue { background: #60a5fa; }
-.progress-fill.green { background: #4ade80; }
-.progress-fill.purple { background: #c084fc; }
-
-.gauges-grid {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 15px;
-}
-
-.gauge-box {
-  text-align: center;
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.gauge-label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 8px;
-}
-
-.gauge-chart {
-  width: 100%;
-  height: 80px;
-}
-
-.gauge-value {
-  font-size: 14px;
-  font-weight: 700;
-  color: #e2e8f0;
-  margin-top: 6px;
-}
-
-.gauge-value.success { color: #4ade80; }
-.gauge-value.warning { color: #fbbf24; }
-
-/* 第三行 */
-.row-3 {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 12px;
-  margin-bottom: 15px;
-}
-
-.wo-metrics {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 12px;
-  margin-bottom: 15px;
-}
-
-.wo-metric {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.wo-metric .metric-label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 8px;
-}
-
-.wo-metric .metric-value {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-  margin-bottom: 6px;
-}
-
-.wo-metric .metric-value .value {
-  font-size: 20px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.wo-metric .metric-value .value.success { color: #4ade80; }
-
-.wo-metric .metric-value .unit {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.wo-metric .metric-trend {
-  font-size: 11px;
-  font-weight: 600;
-  color: #4ade80;
-}
-
-.wo-chart {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.chart-container {
-  width: 100%;
-  height: 200px;
-}
-
-.machine-metrics {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 12px;
-  margin-bottom: 15px;
-}
-
-.machine-metric {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.machine-metric .metric-value .value.danger { color: #ef4444; }
-.machine-metric .metric-value .value.warning { color: #fbbf24; }
-
-.machine-metric .metric-trend.danger { color: #ef4444; }
-.machine-metric .metric-trend.success { color: #4ade80; }
-
-.machine-bars {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-}
-
-.bar-item {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-
-.bar-label {
-  width: 40px;
-  font-size: 11px;
-  color: #64748b;
-}
-
-.bar-container {
-  flex: 1;
-  height: 12px;
-  background: #1e293b;
-  border-radius: 6px;
-  overflow: hidden;
-}
-
-.bar-container .bar-fill {
-  height: 100%;
-  border-radius: 6px;
-}
-
-.bar-fill.green { background: #4ade80; }
-.bar-fill.red { background: #ef4444; }
-
-.alert-tag {
-  font-size: 10px;
-}
-
-.alert-text {
-  font-size: 11px;
-  color: #ef4444;
-}
-
-/* 底部功能按钮 */
-.bottom-actions {
-  display: grid;
-  grid-template-columns: repeat(6, 1fr);
-  gap: 12px;
-}
-
-.action-btn {
-  height: 48px;
-  background: rgba(30, 41, 59, 0.6);
-  border: 1px solid #1e293b;
-  color: #94a3b8;
-  font-size: 12px;
-}
-
-.action-btn:hover {
-  background: rgba(51, 65, 85, 0.8);
-  border-color: #334155;
-}
-
-.action-btn.warning { color: #fbbf24; }
-.action-btn.success { color: #4ade80; }
-.action-btn.purple { color: #c084fc; }
-.action-btn.cyan { color: #22d3ee; }
-.action-btn.primary { color: #60a5fa; }
-
-/* Element Plus 覆盖 */
-:deep(.el-button--small) {
-  padding: 8px 16px;
-  font-size: 12px;
-}
-
-:deep(.el-tag) {
-  font-size: 10px;
-  padding: 2px 8px;
-  border-radius: 4px;
-}
-
-:deep(.el-tag--danger) {
-  background: rgba(239, 68, 68, 0.2);
-  border-color: rgba(239, 68, 68, 0.3);
-  color: #ef4444;
-}
-</style>

+ 16 - 914
Web/src/views/aidop/kanban/s7.vue

@@ -1,922 +1,24 @@
 <template>
-  <div class="s7-dashboard">
-    <!-- 顶部标题栏 -->
-    <div class="top-header">
-      <div class="header-left">
-        <el-icon :size="24"><HomeFilled /></el-icon>
-        <h1 class="page-title">
-          S7 成品仓储运营监控详情
-          <el-tag size="small" class="gold-tag">V5.0 正式版</el-tag>
-        </h1>
-      </div>
-      <div class="header-right">
-        <div class="current-time">
-          <div class="time-label">当前时间</div>
-          <div class="time-value">{{ currentTime }}</div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 成品出库:日期、客户、订单、出库单 -->
-    <DetailQueryBar
-      dark
-      band-title="基础查询:日期、产品、订单号、产线"
-      @query="onDetailQuery"
-      @reset="onDetailQueryReset"
-    >
-      <SmartOpsBaseQueryFields v-model="detailQuery" compact />
+  <ModuleDashboardPage
+    module-code="S7"
+    title="S7 成品仓储详情"
+    left-title="成品仓储执行"
+    left-subtitle="检验 / 上架 / 备货 / 发货 — 周期与人效"
+    right-title="成品仓储结果"
+    right-subtitle="各环节满足率与周转"
+    :extra-query-init="{ customer: '', outboundNo: '' }"
+  >
+    <template #extra-fields="{ query }">
       <el-form-item label="客户">
-        <el-input v-model="detailQuery.customer" placeholder="客户名称/编码" clearable style="width: 140px" />
+        <el-input v-model="query.customer" placeholder="客户编码/名称" clearable style="width: 160px" />
       </el-form-item>
-      <el-form-item label="出库单">
-        <el-input v-model="detailQuery.outboundNo" placeholder="发货/出库单" clearable style="width: 150px" />
+      <el-form-item label="出库单">
+        <el-input v-model="query.outboundNo" placeholder="出库单号" clearable style="width: 140px" />
       </el-form-item>
-    </DetailQueryBar>
-
-    <!-- 三列布局 -->
-    <div class="three-columns">
-      <!-- 左侧:仓储运营概览 -->
-      <div class="column-left">
-        <div class="overview-card">
-          <div class="card-header">
-            <span class="card-title">仓储运营概览</span>
-          </div>
-          <div class="overview-content">
-            <div class="main-kpi">
-              <div class="kpi-label">当前库存总量</div>
-              <div class="kpi-value large">{{ Number(d(0, 42850, 0)).toLocaleString('zh-CN') }}</div>
-              <div class="kpi-trend success">
-                <el-icon><ArrowUp /></el-icon>
-                <span>{{ d(1, 1.2, 1) }}%</span>
-              </div>
-            </div>
-
-            <div class="sub-kpis">
-              <div class="sub-kpi">
-                <div class="kpi-label">库位利用率</div>
-                <div class="kpi-value">{{ d(2, 84.5, 1) }}%</div>
-              </div>
-              <div class="sub-kpi">
-                <div class="kpi-label">在库周转天数</div>
-                <div class="kpi-value">{{ homeS7.orderShipMatchCycle }}</div>
-              </div>
-            </div>
-
-            <div class="trend-chart">
-              <div class="chart-title">入出库趋势图</div>
-              <div id="inout-chart" class="chart-container"></div>
-            </div>
-
-            <div class="distribution-chart">
-              <div class="chart-title">库区状态分布</div>
-              <div id="distribution-chart" class="donut-container"></div>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 中间:4 个流程卡片 -->
-      <div class="column-middle">
-        <!-- 成品检验 FQC -->
-        <div class="process-card success-border">
-          <div class="card-header">
-            <div class="card-title">
-              <el-icon><Checked /></el-icon>
-              成品终检
-            </div>
-            <el-tag size="small" :type="statusTypeByRate(Number((100 - homeS7.shippingDefectPct).toFixed(1)), Number((100 - homeS7.shippingDefectTargetPct).toFixed(1)))">{{ statusTextByRate(Number((100 - homeS7.shippingDefectPct).toFixed(1)), Number((100 - homeS7.shippingDefectTargetPct).toFixed(1))) }}</el-tag>
-          </div>
-          <div class="card-content">
-            <div class="process-metrics">
-              <div class="metric-item">
-                <div class="metric-label">成品检验周期</div>
-                <div class="metric-value">
-                  <span class="value">{{ d(3, 0.8, 1) }}</span>
-                  <span class="unit">H</span>
-                </div>
-                <div class="metric-trend success">
-                  <el-icon><ArrowUp /></el-icon>
-                  <span>{{ d(4, 0.2, 1) }}</span>
-                </div>
-              </div>
-              <div class="metric-item right">
-                <div class="metric-label">成品检验合格率</div>
-                <div class="metric-value">
-                  <span class="value success">{{ (100 - homeS7.shippingDefectPct).toFixed(1) }}</span>
-                  <span class="unit">%</span>
-                </div>
-                <div class="metric-trend warning">
-                  <span>目标 {{ (100 - homeS7.shippingDefectTargetPct).toFixed(1) }}%</span>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 成品仓储 (上架) -->
-        <div class="process-card success-border">
-          <div class="card-header">
-            <div class="card-title">
-              <el-icon><Top /></el-icon>
-              成品仓储 (上架)
-            </div>
-            <el-tag size="small" :type="statusTypeByRate(Number(homeS7.laborEfficiency), Number(homeS7.laborEfficiencyTarget))">{{ statusTextByRate(Number(homeS7.laborEfficiency), Number(homeS7.laborEfficiencyTarget)) }}</el-tag>
-          </div>
-          <div class="card-content">
-            <div class="process-metrics">
-              <div class="metric-item">
-                <div class="metric-label">成品上架周期</div>
-                <div class="metric-value">
-                  <span class="value">{{ d(5, 1.5, 1) }}</span>
-                  <span class="unit">H</span>
-                </div>
-                <div class="metric-trend success">
-                  <el-icon><ArrowUp /></el-icon>
-                  <span>{{ d(6, 0.5, 1) }}</span>
-                </div>
-              </div>
-              <div class="metric-item right">
-                <div class="metric-label">上架人效</div>
-                <div class="metric-value">
-                  <span class="value">{{ homeS7.laborEfficiency }}</span>
-                  <span class="unit">指数</span>
-                </div>
-                <div class="metric-trend danger">
-                  <span>目标 {{ homeS7.laborEfficiencyTarget }}</span>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 成品仓储 (备货) -->
-        <div class="process-card" :class="statusTypeByRate(Number(homeS7.shipmentPlanRatePct), Number(homeS7.shipmentPlanRateTargetPct)) === 'danger' ? 'danger-border' : statusTypeByRate(Number(homeS7.shipmentPlanRatePct), Number(homeS7.shipmentPlanRateTargetPct)) === 'warning' ? 'warning-border' : ''">
-          <div class="card-header">
-            <div class="card-title">
-              <el-icon><ShoppingBag /></el-icon>
-              成品仓储 (备货)
-            </div>
-            <el-tag size="small" :type="statusTypeByRate(Number(homeS7.shipmentPlanRatePct), Number(homeS7.shipmentPlanRateTargetPct))">{{ statusTextByRate(Number(homeS7.shipmentPlanRatePct), Number(homeS7.shipmentPlanRateTargetPct)) }}</el-tag>
-          </div>
-          <div class="card-content">
-            <div class="process-metrics">
-              <div class="metric-item">
-                <div class="metric-label">成品备货周期</div>
-                <div class="metric-value warning">
-                  <span class="value">{{ d(7, 4.2, 1) }}</span>
-                  <span class="unit">H</span>
-                </div>
-                <div class="metric-trend danger">
-                  <el-icon><ArrowUp /></el-icon>
-                  <span>{{ d(8, 1.3, 1) }}</span>
-                </div>
-              </div>
-              <div class="metric-item right">
-                <div class="metric-label">备货满足率</div>
-                <div class="metric-value success">
-                  <span class="value">{{ homeS7.shipmentPlanRatePct }}</span>
-                  <span class="unit">%</span>
-                </div>
-                <div class="metric-trend success">
-                  <span>目标 {{ homeS7.shipmentPlanRateTargetPct }}%</span>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 成品仓储 (发货) -->
-        <div class="process-card success-border">
-          <div class="card-header">
-            <div class="card-title">
-              <el-icon><Van /></el-icon>
-              成品仓储 (发货)
-            </div>
-            <el-tag size="small" :type="statusTypeByRate(Number(homeS7.orderShipMatchCycleTarget - homeS7.orderShipMatchCycle + 95), 95)">{{ statusTextByRate(Number(homeS7.orderShipMatchCycleTarget - homeS7.orderShipMatchCycle + 95), 95) }}</el-tag>
-          </div>
-          <div class="card-content">
-            <div class="process-metrics">
-              <div class="metric-item">
-                <div class="metric-label">发货周期</div>
-                <div class="metric-value warning">
-                  <span class="value">{{ homeS7.orderShipMatchCycle }}</span>
-                  <span class="unit">D</span>
-                </div>
-                <div class="metric-trend warning">
-                  <span>目标 {{ homeS7.orderShipMatchCycleTarget }}D</span>
-                </div>
-              </div>
-              <div class="metric-item right">
-                <div class="metric-label">当日发货单量</div>
-                <div class="metric-value">
-                  <span class="value">{{ Number(d(9, 2197, 0)).toLocaleString('zh-CN') }}</span>
-                </div>
-                <div class="metric-trend success">
-                  <el-icon><ArrowUp /></el-icon>
-                  <span>{{ d(10, 7, 0) }}%</span>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 右侧:库位实景可视化 -->
-      <div class="column-right">
-        <div class="visualization-card">
-          <div class="card-header">
-            <span class="card-title">库位实景可视化</span>
-            <div class="legend">
-              <span class="legend-item"><span class="legend-dot idle"></span>空闲</span>
-              <span class="legend-item"><span class="legend-dot in-stock"></span>在库</span>
-              <span class="legend-item"><span class="legend-dot warning"></span>预警</span>
-            </div>
-          </div>
-          <div class="viz-content">
-            <div class="zone-header">
-              <span>A 区【成品一库】</span>
-              <span class="level">1–{{ maxLevel }} 层</span>
-            </div>
-            <div class="grid-container">
-              <div class="grid-row" v-for="row in 12" :key="row">
-                <div class="grid-cell" v-for="col in 8" :key="col" :class="getCellClass(row, col)"></div>
-              </div>
-            </div>
-            <div class="slider-container">
-              <el-slider v-model="currentLevel" :min="1" :max="maxLevel" :step="1" class="level-slider" />
-            </div>
-          </div>
-          <div class="stats-row">
-            <div class="stat-box">
-              <div class="stat-label">可用库位</div>
-              <div class="stat-value blue">{{ d(11, 142, 0) }}</div>
-            </div>
-            <div class="stat-box">
-              <div class="stat-label">待上架</div>
-              <div class="stat-value orange">{{ d(12, 28, 0) }}</div>
-            </div>
-            <div class="stat-box">
-              <div class="stat-label">异常锁库</div>
-              <div class="stat-value red">{{ d(13, 4, 0) }}</div>
-            </div>
-          </div>
-          <div class="detail-section">
-            <div class="detail-header">
-              <span class="detail-title">库位详情:A-04-12</span>
-              <el-button link size="small" class="btn-detail">详情</el-button>
-            </div>
-            <div class="detail-content">
-              <div class="detail-item">
-                <span class="detail-label">物料编号:</span>
-                <span class="detail-value">P-2026-X99</span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">入库日期:</span>
-                <span class="detail-value">{{ lastSyncDate }}</span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">在库时长:</span>
-                <span class="detail-value">{{ d(14, 44.5, 1) }} HRS</span>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
+    </template>
+  </ModuleDashboardPage>
 </template>
 
 <script setup>
-import { ref, onMounted, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import DetailQueryBar from './components/DetailQueryBar.vue'
-import SmartOpsBaseQueryFields from './components/SmartOpsBaseQueryFields.vue'
-import { emptySmartOpsBaseQuery, summarizeSmartOpsBaseQuery } from './utils/smartOpsBaseQuery'
-import { HomeFilled, Checked, Top, ShoppingBag, Van, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
-import * as echarts from 'echarts'
-import { homeS7 } from './data/homeModulesSync'
-import { loadHomeModuleMetrics } from './data/homeModulesSync'
-import { fetchModuleDetail } from '../api/kanbanData'
-
-const router = useRouter()
-const currentTime = ref('')
-
-const detailQuery = ref({
-  ...emptySmartOpsBaseQuery(),
-  customer: '',
-  outboundNo: '',
-})
-
-function onDetailQuery() {
-  ElMessage.success(`已应用成品仓储筛选(${summarizeSmartOpsBaseQuery(detailQuery.value)})`)
-}
-
-function onDetailQueryReset() {
-  detailQuery.value = { ...emptySmartOpsBaseQuery(), customer: '', outboundNo: '' }
-  ElMessage.info('已重置')
-}
-
-const currentLevel = ref(1)
-const maxLevel = ref(6)
-const inoutXAxis = ref(['08:00', '10:00', '12:00', '14:00', '16:00'])
-const inboundSeries = ref([120, 280, 240, 420, 380])
-const outboundSeries = ref([80, 220, 180, 350, 400])
-const distributionValues = ref([45, 25, 15, 15])
-const cellSeed = ref(0)
-const detailNums = ref([42850, 1.2, 84.5, 0.8, 0.2, 1.5, 0.5, 4.2, 1.3, 2197, 7, 142, 28, 4, 44.5])
-const lastSyncDate = ref('--')
-let inoutChart = null
-let distributionChart = null
-
-const updateTime = () => {
-  currentTime.value = new Date().toLocaleString('zh-CN', {
-    year: 'numeric',
-    month: '2-digit',
-    day: '2-digit',
-    hour: '2-digit',
-    minute: '2-digit',
-    second: '2-digit',
-    hour12: false
-  })
-}
-
-const d = (idx, fallback = 0, digits = 1) => {
-  const n = Number(detailNums.value[idx] ?? fallback)
-  return Number.isFinite(n) ? n.toFixed(digits) : Number(fallback).toFixed(digits)
-}
-const statusTypeByRate = (val, target = 95) => {
-  if (val >= target) return 'success'
-  if (val >= target - 5) return 'warning'
-  return 'danger'
-}
-const statusTextByRate = (val, target = 95) => {
-  if (val >= target) return '优秀'
-  if (val >= target - 5) return '警示'
-  return '严重'
-}
-
-// 基于数据库种子生成稳定库位状态(避免静态随机)
-const getCellClass = (row, col) => {
-  const v = (row * 17 + col * 31 + cellSeed.value) % 100
-  if (v > 88) return 'idle'
-  if (v > 75) return 'warning'
-  return 'in-stock'
-}
-
-const initInOutChart = () => {
-  const chartDom = document.getElementById('inout-chart')
-  if (!chartDom) return
-  
-  inoutChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    grid: { left: '8%', right: '4%', bottom: '15%', top: '15%' },
-    xAxis: {
-      type: 'category',
-      data: inoutXAxis.value,
-      axisLine: { lineStyle: { color: '#334155' } },
-      axisLabel: { color: '#64748b', fontSize: 10 }
-    },
-    yAxis: {
-      type: 'value',
-      min: 0,
-      max: 500,
-      splitLine: { lineStyle: { color: '#1e293b' } },
-      axisLabel: { color: '#64748b', fontSize: 10 }
-    },
-    series: [
-      {
-        name: '入库',
-        type: 'line',
-        smooth: true,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: { color: '#60a5fa' },
-        lineStyle: { color: '#60a5fa', width: 2 },
-        data: inboundSeries.value
-      },
-      {
-        name: '出库',
-        type: 'line',
-        smooth: true,
-        symbol: 'circle',
-        symbolSize: 6,
-        itemStyle: { color: '#4ade80' },
-        lineStyle: { color: '#4ade80', width: 2 },
-        data: outboundSeries.value
-      }
-    ]
-  }
-  inoutChart.setOption(option)
-}
-
-const initDistributionChart = () => {
-  const chartDom = document.getElementById('distribution-chart')
-  if (!chartDom) return
-  
-  distributionChart = echarts.init(chartDom)
-  const option = {
-    tooltip: {
-      trigger: 'item',
-      backgroundColor: 'rgba(15, 23, 42, 0.95)',
-      borderColor: '#334155',
-      textStyle: { color: '#e2e8f0' }
-    },
-    series: [{
-      type: 'pie',
-      radius: ['50%', '70%'],
-      center: ['50%', '50%'],
-      avoidLabelOverlap: false,
-      label: {
-        show: true,
-        position: 'outside',
-        formatter: '{b}\n{d}%',
-        color: '#94a3b8',
-        fontSize: 10
-      },
-      labelLine: {
-        show: true,
-        length: 10,
-        length2: 10,
-        lineStyle: { color: '#334155' }
-      },
-      data: [
-        { value: distributionValues.value[0], name: '冷链区', itemStyle: { color: '#60a5fa' } },
-        { value: distributionValues.value[1], name: '一般区', itemStyle: { color: '#3b82f6' } },
-        { value: distributionValues.value[2], name: '待验区', itemStyle: { color: '#fbbf24' } },
-        { value: distributionValues.value[3], name: '异常区', itemStyle: { color: '#ef4444' } }
-      ]
-    }]
-  }
-  distributionChart.setOption(option)
-}
-
-const initCharts = () => {
-  nextTick(() => {
-    initInOutChart()
-    initDistributionChart()
-    
-    window.addEventListener('resize', () => {
-      inoutChart?.resize()
-      distributionChart?.resize()
-    })
-  })
-}
-
-onMounted(async () => {
-  await loadHomeModuleMetrics()
-  const detail = await fetchModuleDetail('S7')
-  const l2 = detail.l2 ?? []
-  const l3 = detail.l3 ?? []
-  const merged = [...l2, ...l3]
-  if (l2.length > 0) {
-    const vals = l2.slice(0, 5).map((x) => Number(x.metricValue ?? 0))
-    lastSyncDate.value = l2[0]?.statDate ? String(l2[0].statDate).slice(0, 10) : '--'
-    inoutXAxis.value = l2.slice(0, 5).map((x, i) => (x.statDate ? String(x.statDate).slice(11, 16) : `${8 + i * 2}:00`))
-    inboundSeries.value = vals.map((v) => Math.round(v * 4))
-    outboundSeries.value = vals.map((v) => Math.round(v * 3.6))
-    distributionValues.value = [
-      Math.max(1, Math.round(vals[0] ?? 45)),
-      Math.max(1, Math.round(vals[1] ?? 25)),
-      Math.max(1, Math.round(vals[2] ?? 15)),
-      Math.max(1, Math.round(vals[3] ?? 15))
-    ]
-    cellSeed.value = Math.round(vals.reduce((s, n) => s + n, 0))
-  }
-  if (merged.length > 0) {
-    detailNums.value = merged.slice(0, 15).map((x, i) => Number(x.metricValue ?? detailNums.value[i] ?? 0))
-  }
-  updateTime()
-  setInterval(updateTime, 1000)
-  initCharts()
-})
+import ModuleDashboardPage from './components/ModuleDashboardPage.vue'
 </script>
-
-<style scoped>
-.s7-dashboard {
-  box-sizing: border-box;
-  flex: 1;
-  min-height: 0;
-  max-height: 100%;
-  overflow-x: hidden;
-  overflow-y: auto;
-  -webkit-overflow-scrolling: touch;
-  background: #050810;
-  padding: 15px;
-  padding-bottom: 24px;
-  color: #e2e8f0;
-}
-
-/* 顶部标题栏 */
-.top-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.header-left {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  color: #60a5fa;
-}
-
-.page-title {
-  margin: 0;
-  font-size: 18px;
-  font-weight: 700;
-  color: #e2e8f0;
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-
-.gold-tag {
-  background: linear-gradient(135deg, #fbbf24, #f59e0b);
-  border: none;
-  color: white;
-  font-size: 10px;
-  padding: 2px 8px;
-}
-
-.header-right {
-  display: flex;
-  align-items: center;
-  gap: 15px;
-}
-
-.current-time {
-  text-align: right;
-}
-
-.time-label {
-  font-size: 10px;
-  color: #64748b;
-}
-
-.time-value {
-  font-size: 14px;
-  font-weight: 700;
-  color: #22d3ee;
-  font-family: 'Courier New', monospace;
-}
-
-/* 三列布局 */
-.three-columns {
-  display: grid;
-  grid-template-columns: 320px 1fr 420px;
-  gap: 15px;
-}
-
-/* 通用卡片样式 */
-.overview-card, .process-card, .visualization-card {
-  background: rgba(15, 23, 42, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-}
-
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 15px;
-}
-
-.card-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 13px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-/* 左侧列 */
-.column-left {
-  display: flex;
-  flex-direction: column;
-  gap: 15px;
-}
-
-.overview-content {
-  display: flex;
-  flex-direction: column;
-  gap: 15px;
-}
-
-.main-kpi {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 15px;
-  border: 1px solid #1e293b;
-}
-
-.main-kpi .kpi-label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 8px;
-}
-
-.main-kpi .kpi-value.large {
-  font-size: 36px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.kpi-trend {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  font-size: 11px;
-  font-weight: 600;
-  color: #4ade80;
-  margin-top: 6px;
-}
-
-.sub-kpis {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 12px;
-}
-
-.sub-kpi {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.sub-kpi .kpi-label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 6px;
-}
-
-.sub-kpi .kpi-value {
-  font-size: 20px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.trend-chart, .distribution-chart {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.chart-title {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 10px;
-}
-
-.chart-container {
-  width: 100%;
-  height: 180px;
-}
-
-.donut-container {
-  width: 100%;
-  height: 200px;
-}
-
-/* 中间列 */
-.column-middle {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-}
-
-.process-card {
-  background: rgba(15, 23, 42, 0.6);
-  border-radius: 8px;
-  padding: 15px;
-  border-left: 3px solid;
-}
-
-.process-card.success-border { border-left-color: #4ade80; }
-.process-card.warning-border { border-left-color: #fbbf24; }
-
-.process-metrics {
-  display: flex;
-  justify-content: space-between;
-}
-
-.metric-item {
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-.metric-item.right {
-  text-align: right;
-}
-
-.metric-label {
-  font-size: 11px;
-  color: #64748b;
-}
-
-.metric-value {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-  justify-content: inherit;
-}
-
-.metric-value .value {
-  font-size: 24px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.metric-value .value.success { color: #4ade80; }
-.metric-value .value.warning { color: #fbbf24; }
-
-.metric-value .unit {
-  font-size: 12px;
-  color: #64748b;
-}
-
-.metric-trend {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  font-size: 11px;
-  font-weight: 600;
-  justify-content: inherit;
-}
-
-.metric-trend.success { color: #4ade80; }
-.metric-trend.danger { color: #ef4444; }
-
-/* 右侧列 */
-.column-right {
-  display: flex;
-  flex-direction: column;
-  gap: 15px;
-}
-
-.viz-content {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-}
-
-.zone-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  font-size: 11px;
-  color: #64748b;
-}
-
-.zone-header .level {
-  color: #94a3b8;
-}
-
-.grid-container {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 8px;
-  border: 1px solid #1e293b;
-}
-
-.grid-row {
-  display: grid;
-  grid-template-columns: repeat(8, 1fr);
-  gap: 4px;
-}
-
-.grid-cell {
-  aspect-ratio: 1;
-  border-radius: 4px;
-  background: #1e293b;
-}
-
-.grid-cell.idle { background: #1e293b; }
-.grid-cell.in-stock { background: #3b82f6; }
-.grid-cell.warning { background: #d97706; }
-
-.slider-container {
-  padding: 0 10px;
-}
-
-.level-slider {
-  --el-slider-main-bg-color: #60a5fa;
-  --el-slider-runway-bg-color: #1e293b;
-}
-
-.stats-row {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 10px;
-}
-
-.stat-box {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  text-align: center;
-  border: 1px solid #1e293b;
-}
-
-.stat-label {
-  font-size: 11px;
-  color: #64748b;
-  margin-bottom: 6px;
-}
-
-.stat-value {
-  font-size: 20px;
-  font-weight: 700;
-}
-
-.stat-value.blue { color: #60a5fa; }
-.stat-value.orange { color: #fbbf24; }
-.stat-value.red { color: #ef4444; }
-
-.detail-section {
-  background: rgba(30, 41, 59, 0.4);
-  border-radius: 6px;
-  padding: 12px;
-  border: 1px solid #1e293b;
-}
-
-.detail-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 10px;
-}
-
-.detail-title {
-  font-size: 11px;
-  font-weight: 700;
-  color: #e2e8f0;
-}
-
-.btn-detail {
-  color: #60a5fa;
-  font-size: 10px;
-}
-
-.detail-content {
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-.detail-item {
-  display: flex;
-  justify-content: space-between;
-  font-size: 10px;
-}
-
-.detail-label {
-  color: #64748b;
-}
-
-.detail-value {
-  color: #e2e8f0;
-}
-
-/* Element Plus 覆盖 */
-:deep(.el-tag) {
-  font-size: 10px;
-  padding: 2px 8px;
-  border-radius: 4px;
-}
-
-:deep(.el-tag--success) {
-  background: rgba(74, 222, 128, 0.2);
-  border-color: rgba(74, 222, 128, 0.3);
-  color: #4ade80;
-}
-
-:deep(.el-tag--warning) {
-  background: rgba(251, 191, 36, 0.2);
-  border-color: rgba(251, 191, 36, 0.3);
-  color: #fbbf24;
-}
-
-:deep(.el-slider__bar) {
-  background: #60a5fa;
-}
-
-:deep(.el-slider__button) {
-  border-color: #60a5fa;
-}
-</style>

+ 1 - 1
Web/src/views/aidop/kanban/s9.vue

@@ -51,7 +51,7 @@ async function loadRows() {
 	try {
 		const extra = baseQueryToApiParams(baseQuery.value);
 		const list = await fetchHomeL1(1, 1, extra);
-		rowsAll.value = list.slice(0, 6).map((x) => ({
+		rowsAll.value = list.map((x) => ({
 			metric: `${x.moduleCode} - ${x.metricCode}`,
 			value: `${x.metricValue ?? 0}`,
 			target: `${x.targetValue ?? 0}`,

+ 74 - 0
ai-dop-platform/tools/_probe_demo_tenant.py

@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+"""Probe existing Demo tenant/user in the shared dev DB before doing租户 A batch."""
+from __future__ import annotations
+import pymysql
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    with conn.cursor(pymysql.cursors.DictCursor) as cur:
+        print("=== SysTenant schema ===")
+        cur.execute("SHOW COLUMNS FROM SysTenant")
+        cols = [c['Field'] for c in cur.fetchall()]
+        print("  columns:", ", ".join(cols))
+
+        print("\n=== SysTenant 列表 ===")
+        cur.execute("SELECT * FROM SysTenant ORDER BY Id")
+        for row in cur.fetchall():
+            cid = row.get('Id')
+            name_candidates = [row.get(k) for k in ('Name', 'TenantName', 'OrgName', 'NickName') if k in row]
+            nm = next((x for x in name_candidates if x), '-')
+            st = row.get('Status', '?')
+            tt = row.get('TenantType', '?')
+            print(f"  {cid!s:>15} | {nm!s:<30} | Status={st} | TenantType={tt}")
+
+        print("\n=== 疑似 Demo 账号 (Account/RealName 含 demo/Demo/试用/演示) ===")
+        cur.execute(
+            """
+            SELECT Id, Account, RealName, NickName, TenantId, Status
+            FROM SysUser
+            WHERE LOWER(Account) LIKE '%demo%' OR LOWER(NickName) LIKE '%demo%'
+               OR RealName LIKE '%试用%' OR RealName LIKE '%演示%' OR RealName LIKE '%Demo%'
+            ORDER BY TenantId, Id
+            """
+        )
+        rows = cur.fetchall()
+        if not rows:
+            print("  (无)")
+        for row in rows:
+            print(f"  Id={row['Id']} | Account={row['Account']} | RealName={row['RealName']} | TenantId={row['TenantId']} | Status={row['Status']}")
+
+        print("\n=== AiDOP 核心表 TenantId 分布 ===")
+        for tbl, col in [
+            ('ado_smart_ops_kpi_master', 'TenantId'),
+            ('ado_smart_ops_layout_item', 'TenantId'),
+            ('ado_smart_ops_home_module', 'TenantId'),
+            ('ado_s9_kpi_value_l1_day', 'tenant_id'),
+            ('ado_s9_kpi_value_l2_day', 'tenant_id'),
+        ]:
+            try:
+                cur.execute(f"SELECT {col} AS t, COUNT(*) AS c FROM {tbl} GROUP BY {col} ORDER BY {col}")
+                dist = cur.fetchall()
+                info = ", ".join(f"{r['t']}:{r['c']}" for r in dist) or "(空表)"
+                print(f"  {tbl:<32} → {info}")
+            except pymysql.err.ProgrammingError as ex:
+                print(f"  {tbl:<32} → 表不存在? {ex.args[-1] if ex.args else ex}")
+
+        print("\n=== SysUser 总数 & 默认租户用户数 ===")
+        cur.execute("SELECT COUNT(*) AS c FROM SysUser")
+        print(f"  SysUser 总数: {cur.fetchone()['c']}")
+        for t in (1, 1300000000001):
+            cur.execute("SELECT COUNT(*) AS c FROM SysUser WHERE TenantId=%s", (t,))
+            print(f"  TenantId={t:>15}: {cur.fetchone()['c']} 人")
+
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 56 - 0
ai-dop-platform/tools/_probe_module_data.py

@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+"""验证各模块 KpiMaster + LayoutItem + kpi_value_l*_day 数据"""
+from __future__ import annotations
+import sys
+import pymysql
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+
+MODULES = ["S1", "S2", "S3", "S4", "S5", "S6", "S7", "S9"]
+TENANTS = [1300000000001, 1300000000888]
+FACTORY_ID = 1
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    with conn.cursor(pymysql.cursors.DictCursor) as cur:
+        for tid in TENANTS:
+            print(f"\n=== 租户 {tid} ===")
+            print(f"{'模块':<6}{'KpiMaster':<12}{'LayoutItem(L1/L2+)':<22}{'L1日值':<10}{'L2日值':<10}{'L3日值':<10}")
+            print("-" * 72)
+            for m in MODULES:
+                cur.execute(
+                    "SELECT COUNT(*) AS c FROM ado_smart_ops_kpi_master "
+                    "WHERE TenantId=%s AND ModuleCode=%s AND IsEnabled=1",
+                    (tid, m),
+                )
+                km = cur.fetchone()['c']
+                cur.execute(
+                    "SELECT SUM(CASE WHEN MetricLevel=1 THEN 1 ELSE 0 END) AS l1, "
+                    "SUM(CASE WHEN MetricLevel>=2 THEN 1 ELSE 0 END) AS l2p "
+                    "FROM ado_smart_ops_layout_item "
+                    "WHERE TenantId=%s AND FactoryId=%s AND ModuleCode=%s AND IsEnabled=1",
+                    (tid, FACTORY_ID, m),
+                )
+                lr = cur.fetchone()
+                layout = f"{lr['l1'] or 0}/{lr['l2p'] or 0}"
+                vals = {}
+                for lvl in ('l1', 'l2', 'l3'):
+                    cur.execute(
+                        f"SELECT COUNT(*) AS c FROM ado_s9_kpi_value_{lvl}_day "
+                        "WHERE tenant_id=%s AND factory_id=%s AND module_code=%s AND is_deleted=0",
+                        (tid, FACTORY_ID, m),
+                    )
+                    vals[lvl] = cur.fetchone()['c']
+                print(f"{m:<6}{km:<12}{layout:<22}{vals['l1']:<10}{vals['l2']:<10}{vals['l3']:<10}")
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 98 - 0
ai-dop-platform/tools/_probe_module_layouts.py

@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+"""盘点各模块 LayoutItem / KpiMaster 状况,评估 S4 样式推广工作量。"""
+from __future__ import annotations
+import sys
+import pymysql
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+
+MODULES = ["S1", "S2", "S3", "S4", "S5", "S6", "S7", "S9"]
+TENANTS = [("默认", 1300000000001), ("Demo", 1300000000888)]
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    with conn.cursor(pymysql.cursors.DictCursor) as cur:
+        # 1. KpiMaster 概览
+        print("=" * 100)
+        print("【KpiMaster】各模块指标数(按层级)—— 仅默认租户视角")
+        print("-" * 100)
+        for m in MODULES:
+            cur.execute(
+                """
+                SELECT MetricLevel, COUNT(*) AS cnt
+                FROM ado_smart_ops_kpi_master
+                WHERE TenantId = %s AND ModuleCode = %s AND IsEnabled = 1
+                GROUP BY MetricLevel
+                """,
+                (TENANTS[0][1], m),
+            )
+            rows = {r['MetricLevel']: r['cnt'] for r in cur.fetchall()}
+            print(f"  {m:<4} L1={rows.get(1, 0):<3} L2={rows.get(2, 0):<3} L3={rows.get(3, 0):<3}")
+
+        # 2. LayoutItem 概览
+        print("\n" + "=" * 100)
+        print("【LayoutItem】各模块已配置的布局行数 + PanelZone 分布")
+        print("-" * 100)
+        for tname, tid in TENANTS:
+            print(f"\n  --- [{tname}租户 {tid}] ---")
+            for m in MODULES:
+                cur.execute(
+                    """
+                    SELECT MetricLevel, PanelZone, COUNT(*) AS cnt
+                    FROM ado_smart_ops_layout_item
+                    WHERE TenantId = %s AND ModuleCode = %s AND IsEnabled = 1
+                    GROUP BY MetricLevel, PanelZone
+                    ORDER BY MetricLevel, PanelZone
+                    """,
+                    (tid, m),
+                )
+                rows = cur.fetchall()
+                total = sum(r['cnt'] for r in rows)
+                if total == 0:
+                    print(f"    {m:<4} (无布局)")
+                    continue
+                parts = []
+                for r in rows:
+                    lv = f"L{r['MetricLevel']}"
+                    zone = r['PanelZone'] or "(空)"
+                    parts.append(f"{lv}/{zone}={r['cnt']}")
+                print(f"    {m:<4} total={total:<3} {'  '.join(parts)}")
+
+        # 3. 每个模块的 L2 指标清单(默认租户)+ PanelZone
+        print("\n" + "=" * 100)
+        print("【L2 详情】各模块 L2 指标 + 是否已分配 panel_zone(默认租户)")
+        print("-" * 100)
+        for m in MODULES:
+            cur.execute(
+                """
+                SELECT k.MetricCode, k.MetricName, k.Unit,
+                       li.PanelZone, li.SortNo AS LayoutSort
+                FROM ado_smart_ops_kpi_master k
+                LEFT JOIN ado_smart_ops_layout_item li
+                       ON li.TenantId = k.TenantId AND li.ModuleCode = k.ModuleCode
+                      AND li.MetricCode = k.MetricCode AND li.MetricLevel = 2
+                WHERE k.TenantId = %s AND k.ModuleCode = %s AND k.IsEnabled = 1 AND k.MetricLevel = 2
+                ORDER BY k.SortNo
+                """,
+                (TENANTS[0][1], m),
+            )
+            rows = cur.fetchall()
+            print(f"\n  [{m}] L2={len(rows)} 条")
+            for r in rows[:12]:
+                zone = r['PanelZone'] or "—"
+                print(f"    {r['MetricCode']:<18} {r['MetricName']:<28} 单位={str(r['Unit']):<8} zone={zone}")
+            if len(rows) > 12:
+                print(f"    ...(还有 {len(rows) - 12} 条省略)")
+
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 126 - 0
ai-dop-platform/tools/_probe_tenant_isolation.py

@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+"""对比两个租户在 AiDOP 核心表中的数据差异,验证租户隔离是否落地。"""
+from __future__ import annotations
+import sys
+import pymysql
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+
+# Admin.NET 框架默认租户 ID(SqlSugarConst.DefaultTenantId)
+DEFAULT_TENANT = 1300000000001
+DEMO_TENANT = 1300000000888
+
+TENANTS = [
+    ("superAdmin 默认租户", DEFAULT_TENANT),
+    ("DemoAdmin Demo 租户", DEMO_TENANT),
+]
+
+
+def table_exists(cur, table: str) -> bool:
+    cur.execute(
+        "SELECT COUNT(*) AS c FROM information_schema.tables "
+        "WHERE table_schema=DATABASE() AND table_name=%s",
+        (table,),
+    )
+    row = cur.fetchone()
+    return bool(row and row.get('c', 0) > 0)
+
+
+def count_by_tenant(cur, table: str, tenant_col: str, tenant_id: int, extra_where: str = "") -> int:
+    sql = f"SELECT COUNT(*) AS c FROM `{table}` WHERE `{tenant_col}` = %s"
+    if extra_where:
+        sql += f" AND {extra_where}"
+    cur.execute(sql, (tenant_id,))
+    row = cur.fetchone()
+    return int(row['c']) if row else 0
+
+
+def sample_s4_l1(cur, tenant_id: int, limit: int = 3):
+    # 最新 biz_date 的 S4 L1 值
+    cur.execute(
+        """
+        SELECT biz_date, metric_code, metric_value, target_value, status_color
+        FROM ado_s9_kpi_value_l1_day
+        WHERE tenant_id = %s AND module_code = 'S4' AND is_deleted = 0
+          AND biz_date = (
+              SELECT MAX(biz_date) FROM ado_s9_kpi_value_l1_day
+              WHERE tenant_id = %s AND module_code = 'S4' AND is_deleted = 0
+          )
+        ORDER BY metric_code
+        LIMIT %s
+        """,
+        (tenant_id, tenant_id, limit),
+    )
+    return cur.fetchall()
+
+
+def list_kpi_master_s4(cur, tenant_id: int, limit: int = 3):
+    cur.execute(
+        """
+        SELECT MetricCode, MetricName, MetricLevel, YellowThreshold, RedThreshold
+        FROM ado_smart_ops_kpi_master
+        WHERE TenantId = %s AND ModuleCode = 'S4' AND MetricLevel = 1
+        ORDER BY SortNo
+        LIMIT %s
+        """,
+        (tenant_id, limit),
+    )
+    return cur.fetchall()
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    with conn.cursor(pymysql.cursors.DictCursor) as cur:
+        tables = [
+            ("ado_smart_ops_kpi_master",    "TenantId",  ""),
+            ("ado_smart_ops_layout_item",   "TenantId",  ""),
+            ("ado_smart_ops_home_module",   "TenantId",  ""),
+            ("ado_s9_kpi_value_l1_day",     "tenant_id", "is_deleted=0"),
+            ("ado_s9_kpi_value_l2_day",     "tenant_id", "is_deleted=0"),
+            ("ado_s9_kpi_value_l3_day",     "tenant_id", "is_deleted=0"),
+        ]
+
+        print("=" * 88)
+        print(f"{'表':<32} {'过滤':<18} " + "  ".join(f"{n:<24}" for n, _ in TENANTS))
+        print("-" * 88)
+        for t, tcol, extra in tables:
+            if not table_exists(cur, t):
+                print(f"{t:<32} [表不存在]")
+                continue
+            counts = [count_by_tenant(cur, t, tcol, tid, extra) for _, tid in TENANTS]
+            extra_lbl = extra or "-"
+            print(f"{t:<32} {extra_lbl:<18} " + "  ".join(f"{c:<24}" for c in counts))
+
+        # S4 KpiMaster 指标名称对比(第一个 L1 指标)
+        print("\n" + "=" * 88)
+        print("S4 L1 KpiMaster(前 3 条):")
+        for name, tid in TENANTS:
+            print(f"\n  [{name}] TenantId={tid}")
+            rows = list_kpi_master_s4(cur, tid, 3)
+            if not rows:
+                print("    (无数据)")
+            for r in rows:
+                print(f"    {r['MetricCode']:<20} | {r['MetricName']:<24} | 黄={r['YellowThreshold']} 红={r['RedThreshold']}")
+
+        # S4 L1 最新日值对比
+        print("\n" + "=" * 88)
+        print("S4 L1 日表最新 biz_date 的前 3 条(目的:两个租户数值应不相同):")
+        for name, tid in TENANTS:
+            print(f"\n  [{name}] TenantId={tid}")
+            rows = sample_s4_l1(cur, tid, 3)
+            if not rows:
+                print("    (无数据)")
+            for r in rows:
+                print(f"    {str(r['biz_date']):<12} {r['metric_code']:<20} 实际={r['metric_value']} 目标={r['target_value']} 颜色={r['status_color']}")
+
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 72 - 0
ai-dop-platform/tools/_verify_demoadmin_password.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+"""Verify DemoAdmin stored password against candidates via SM2 decrypt."""
+from __future__ import annotations
+import sys
+import pymysql
+from gmssl import sm2
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+
+PUBLIC_KEY = "0484C7466D950E120E5ECE5DD85D0C90EAA85081A3A2BD7C57AE6DC822EFCCBD66620C67B0103FC8DD280E36C3B282977B722AAEC3C56518EDCEBAFB72C5A05312"
+PRIVATE_KEY = "8EDB615B1D48B8BE188FC0F18EC08A41DF50EA731FA28BF409E6552809E3A111"
+
+CANDIDATES = ["1234567890dop", "Admin.NET++010101"]
+
+
+def try_decrypt(cipher_hex: str):
+    """Try several permutations for Admin.NET / BouncyCastle SM2 ciphertext."""
+    results = []
+    # Strip possible 04 prefix (SEC1 uncompressed marker)
+    variants = [cipher_hex]
+    if cipher_hex.lower().startswith("04"):
+        variants.append(cipher_hex[2:])
+
+    for mode_name, mode_val in [("C1C3C2", 1), ("C1C2C3", 0)]:
+        sm2_crypt = sm2.CryptSM2(public_key=PUBLIC_KEY, private_key=PRIVATE_KEY, mode=mode_val)
+        for v in variants:
+            try:
+                plain = sm2_crypt.decrypt(bytes.fromhex(v))
+                if plain:
+                    txt = plain.decode('utf-8', errors='replace')
+                    results.append((mode_name, v is variants[0] and "with-04" or "no-04", txt))
+            except Exception as ex:
+                results.append((mode_name, "err", str(ex)))
+    return results
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    with conn.cursor(pymysql.cursors.DictCursor) as cur:
+        cur.execute(
+            "SELECT Id, Account, Password, TenantId FROM SysUser WHERE Account='DemoAdmin' LIMIT 1"
+        )
+        row = cur.fetchone()
+        if not row:
+            print("DemoAdmin not found")
+            return
+        print(f"Account : {row['Account']}")
+        print(f"TenantId: {row['TenantId']}")
+        print(f"Ciphertext head (first 64 chars): {row['Password'][:64]}...")
+        print(f"Ciphertext length              : {len(row['Password'])}")
+
+        print("\n=== 尝试多种 SM2 解密组合 ===")
+        plains = try_decrypt(row['Password'])
+        for m, v, p in plains:
+            print(f"  [{m} | {v}] => {p!r}")
+
+        print("\n=== 比对候选密码 ===")
+        for c in CANDIDATES:
+            hits = [x for x in plains if x[2] == c]
+            print(f"  {c!r:<30} => {'MATCH: ' + str([(h[0], h[1]) for h in hits]) if hits else 'no match'}")
+
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 178 - 0
ai-dop-platform/tools/seed_layout_items_all_modules.py

@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+"""
+批量初始化 AiDOP LayoutItem + HomeModule 种子(仅对"当前无布局"的模块写入)。
+
+规则(Q2-C):
+  - L1:统一为 home-grid 行,panel_zone=NULL
+  - L2/L3:按 MetricName 命名模式分配 panel_zone
+      * 含"周期" 或 "人效" → left
+      * 含"满足率" 或 "周转" → right
+      * 其他 → 奇数 sortNo=left,偶数 sortNo=right
+  - is_enabled = 1 全部启用(Q2-C 不截断)
+  - HomeModule:若该模块无记录,插入 layout_pattern='card_grid'
+
+幂等:模块已有 LayoutItem 时整模块跳过(不覆盖用户手动配置)。
+两个租户都会跑。
+"""
+from __future__ import annotations
+import sys
+from datetime import datetime
+import pymysql
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=False,
+)
+
+MODULES = ["S1", "S2", "S3", "S5", "S6", "S7", "S9"]  # 跳过 S4(已配)、S8(独立)
+TENANTS = [1300000000001, 1300000000888]
+FACTORY_ID = 1
+
+
+def assign_zone(metric_name: str, sort_no: int) -> str:
+    """按命名模式分配 panel_zone。"""
+    nm = metric_name or ""
+    if ("周期" in nm) or ("人效" in nm):
+        return "left"
+    if ("满足率" in nm) or ("周转" in nm):
+        return "right"
+    return "left" if (sort_no % 2 == 1) else "right"
+
+
+def next_id(cur) -> int:
+    cur.execute("SELECT MAX(Id) AS mx FROM ado_smart_ops_layout_item")
+    row = cur.fetchone()
+    return (row['mx'] or 0) + 1
+
+
+def next_hm_id(cur) -> int:
+    cur.execute("SELECT MAX(Id) AS mx FROM ado_smart_ops_home_module")
+    row = cur.fetchone()
+    return (row['mx'] or 0) + 1
+
+
+def process_tenant_module(cur, tenant_id: int, module_code: str) -> dict:
+    """处理单个 (tenant, module)。返回统计。"""
+    # 1. 检查是否已有布局
+    cur.execute(
+        "SELECT COUNT(*) AS c FROM ado_smart_ops_layout_item "
+        "WHERE TenantId=%s AND ModuleCode=%s",
+        (tenant_id, module_code),
+    )
+    existing = cur.fetchone()['c']
+    if existing > 0:
+        return {"skip": True, "existing": existing, "inserted": 0, "hm": False}
+
+    # 2. 查出该模块所有启用的 KpiMaster
+    cur.execute(
+        "SELECT MetricCode, MetricName, MetricLevel, SortNo, Formula "
+        "FROM ado_smart_ops_kpi_master "
+        "WHERE TenantId=%s AND ModuleCode=%s AND IsEnabled=1 "
+        "ORDER BY MetricLevel, SortNo",
+        (tenant_id, module_code),
+    )
+    kpis = cur.fetchall()
+    if not kpis:
+        return {"skip": True, "existing": 0, "inserted": 0, "hm": False, "reason": "no kpi"}
+
+    # 3. 生成 LayoutItem 行
+    now = datetime.now()
+    next_layout_id = next_id(cur)
+    inserted = 0
+    for k in kpis:
+        lv = k['MetricLevel']
+        code = k['MetricCode']
+        name = k['MetricName']
+        sort_no = k['SortNo'] or 0
+        row_id = f"{module_code}-L{lv}-{code}"
+        zone = None
+        if lv >= 2:
+            zone = assign_zone(name, sort_no)
+        formula_text = (k['Formula'] or None)
+        # 简化 formula:若超长,截断到 400 字符
+        if formula_text and len(formula_text) > 400:
+            formula_text = formula_text[:400]
+
+        cur.execute(
+            """
+            INSERT INTO ado_smart_ops_layout_item
+            (Id, TenantId, FactoryId, ModuleCode, RowId, MetricLevel, MetricCode,
+             DisplayName, SortNo, ParentRowId, FormulaText, PanelZone, IsEnabled, UpdateTime)
+            VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1, %s)
+            """,
+            (
+                next_layout_id, tenant_id, FACTORY_ID, module_code, row_id, lv, code,
+                None, sort_no, None, formula_text, zone, now,
+            ),
+        )
+        next_layout_id += 1
+        inserted += 1
+
+    # 4. 检查 HomeModule
+    cur.execute(
+        "SELECT COUNT(*) AS c FROM ado_smart_ops_home_module "
+        "WHERE TenantId=%s AND FactoryId=%s AND ModuleCode=%s",
+        (tenant_id, FACTORY_ID, module_code),
+    )
+    hm_exists = cur.fetchone()['c']
+    hm_inserted = False
+    if hm_exists == 0:
+        hm_id = next_hm_id(cur)
+        cur.execute(
+            """
+            INSERT INTO ado_smart_ops_home_module
+            (Id, TenantId, FactoryId, ModuleCode, LayoutPattern, UpdateTime)
+            VALUES (%s, %s, %s, %s, 'card_grid', %s)
+            """,
+            (hm_id, tenant_id, FACTORY_ID, module_code, now),
+        )
+        hm_inserted = True
+
+    return {"skip": False, "existing": 0, "inserted": inserted, "hm": hm_inserted}
+
+
+def main(dry_run: bool = False) -> None:
+    conn = pymysql.connect(**CONN)
+    total_inserted = 0
+    total_hm_inserted = 0
+    try:
+        with conn.cursor(pymysql.cursors.DictCursor) as cur:
+            print("=" * 96)
+            print(f"{'模式':<8}{'DRY_RUN' if dry_run else '实写入'} ({'不会提交' if dry_run else '会提交'})")
+            print("=" * 96)
+            for tid in TENANTS:
+                print(f"\n>>> 租户 {tid}")
+                for m in MODULES:
+                    r = process_tenant_module(cur, tid, m)
+                    if r['skip']:
+                        reason = r.get('reason', f"已有 {r['existing']} 条布局")
+                        print(f"    {m:<4} 跳过({reason})")
+                    else:
+                        tag = "(+HomeModule)" if r['hm'] else ""
+                        print(f"    {m:<4} 插入 {r['inserted']} 条 LayoutItem {tag}")
+                        total_inserted += r['inserted']
+                        if r['hm']:
+                            total_hm_inserted += 1
+
+            if dry_run:
+                print("\nDRY_RUN 模式:回滚事务")
+                conn.rollback()
+            else:
+                print("\n提交事务…")
+                conn.commit()
+
+            print(f"\n合计:LayoutItem 新增 {total_inserted} 条,HomeModule 新增 {total_hm_inserted} 条。")
+    except Exception as ex:
+        conn.rollback()
+        print(f"\n出错回滚:{ex}")
+        raise
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    dry = "--dry-run" in sys.argv or "-n" in sys.argv
+    main(dry_run=dry)

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.53</AssemblyVersion>
-    <FileVersion>1.0.53</FileVersion>
-    <Version>1.0.53</Version>
+    <AssemblyVersion>1.0.54</AssemblyVersion>
+    <FileVersion>1.0.54</FileVersion>
+    <Version>1.0.54</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 232 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs

@@ -0,0 +1,232 @@
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+public partial class AidopKanbanController
+{
+    // S4.cs 中的 FormatGapLabel / ResolveMaxBizDateAsync 是 private,在同一 partial 类里可直接复用
+
+    /// <summary>
+    /// 通用运营布局(L1/L2/L3)。moduleCode 指定模块;S4 也可走这个端点。
+    /// </summary>
+    [HttpGet("operation-layout/{moduleCode}")]
+    public async Task<IActionResult> GetOperationLayoutGeneric(string moduleCode, [FromQuery] long factoryId = 1)
+    {
+        var mc = (moduleCode ?? "").ToUpperInvariant();
+        if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+
+        var hm = (await _db.Queryable<AdoSmartOpsHomeModule>()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+            .ToListAsync()).FirstOrDefault();
+        var items = await _db.Queryable<AdoSmartOpsLayoutItem>()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1)
+            .OrderBy(x => x.SortNo)
+            .ToListAsync();
+        var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
+            .ToListAsync();
+        var kpiByCode = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
+
+        object Row(AdoSmartOpsLayoutItem r)
+        {
+            kpiByCode.TryGetValue(r.MetricCode, out var k);
+            return new
+            {
+                rowId = r.RowId,
+                metricCode = r.MetricCode,
+                metricLevel = r.MetricLevel,
+                displayName = string.IsNullOrWhiteSpace(r.DisplayName) ? k?.MetricName : r.DisplayName,
+                sortNo = r.SortNo,
+                parentRowId = r.ParentRowId,
+                formulaText = r.FormulaText,
+                panelZone = r.PanelZone,
+                unit = k?.Unit,
+                direction = k?.Direction
+            };
+        }
+
+        return Ok(new
+        {
+            moduleCode = mc,
+            layoutPattern = hm?.LayoutPattern ?? "card_grid",
+            l1 = items.Where(x => x.MetricLevel == 1).Select(Row).ToList(),
+            l2 = items.Where(x => x.MetricLevel >= 2).Select(Row).ToList()
+        });
+    }
+
+    /// <summary>
+    /// 通用九宫格/首页(L1 合并日表)。moduleCode 指定模块。
+    /// </summary>
+    [HttpGet("home-grid/{moduleCode}")]
+    public async Task<IActionResult> GetHomeGridGeneric(string moduleCode, [FromQuery] long factoryId = 1)
+    {
+        var mc = (moduleCode ?? "").ToUpperInvariant();
+        if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+
+        var layout = await _db.Queryable<AdoSmartOpsLayoutItem>()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1 && x.MetricLevel == 1)
+            .OrderBy(x => x.SortNo)
+            .ToListAsync();
+        var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
+            .ToListAsync();
+        var kpiBy = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
+
+        var bizDate = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l1_day", tenantId, factoryId, mc);
+
+        const string valSql = """
+SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
+FROM ado_s9_kpi_value_l1_day
+WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz_date=@d
+""";
+        var vals = await _db.Ado.SqlQueryAsync<S4ValRow>(valSql, new { t = tenantId, f = factoryId, m = mc, d = bizDate });
+        var valBy = vals.ToDictionary(x => x.MetricCode ?? "", StringComparer.OrdinalIgnoreCase);
+
+        var hm = (await _db.Queryable<AdoSmartOpsHomeModule>()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+            .ToListAsync()).FirstOrDefault();
+
+        var items = new List<object>();
+        foreach (var row in layout)
+        {
+            kpiBy.TryGetValue(row.MetricCode, out var k);
+            valBy.TryGetValue(row.MetricCode, out var v);
+            var cur = v?.MetricValue;
+            var tgt = v?.TargetValue;
+            var dir = k?.Direction ?? "higher_is_better";
+            var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
+            var gap = AidopS4KpiMerge.GapValue(cur, tgt);
+            var arrow = AidopS4KpiMerge.GapArrow(gap);
+            var label = FormatGapLabelGeneric(gap, k?.Unit);
+            items.Add(new
+            {
+                rowId = row.RowId,
+                metricCode = row.MetricCode,
+                displayName = string.IsNullOrWhiteSpace(row.DisplayName) ? k?.MetricName : row.DisplayName,
+                unit = k?.Unit ?? "",
+                currentValue = cur,
+                targetValue = tgt,
+                gapValue = gap,
+                gapLabel = label,
+                gapArrow = arrow,
+                achievementLevel = level,
+                formulaText = row.FormulaText
+            });
+        }
+
+        return Ok(new
+        {
+            moduleCode = mc,
+            layoutPattern = hm?.LayoutPattern ?? "card_grid",
+            bizDate = bizDate.ToString("yyyy-MM-dd"),
+            items
+        });
+    }
+
+    /// <summary>
+    /// 通用详情 KPI(L2/L3 合并日表),可按 panelZone 过滤。
+    /// </summary>
+    [HttpGet("detail-kpis/{moduleCode}")]
+    public async Task<IActionResult> GetDetailKpisGeneric(string moduleCode, [FromQuery] long factoryId = 1, [FromQuery] string? panelZone = null)
+    {
+        var mc = (moduleCode ?? "").ToUpperInvariant();
+        if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+
+        var q = _db.Queryable<AdoSmartOpsLayoutItem>()
+            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1 && x.MetricLevel >= 2);
+        if (!string.IsNullOrWhiteSpace(panelZone))
+            q = q.Where(x => x.PanelZone == panelZone);
+        var layout = await q.OrderBy(x => x.SortNo).ToListAsync();
+
+        var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
+            .ToListAsync();
+        var kpiBy = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
+
+        var bizDateL2 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l2_day", tenantId, factoryId, mc);
+        var bizDateL3 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l3_day", tenantId, factoryId, mc);
+        var bizDate = bizDateL2 >= bizDateL3 ? bizDateL2 : bizDateL3;
+
+        const string valSqlL2 = """
+SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
+FROM ado_s9_kpi_value_l2_day
+WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz_date=@d
+""";
+        const string valSqlL3 = """
+SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
+FROM ado_s9_kpi_value_l3_day
+WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0 AND biz_date=@d
+""";
+        var valsL2 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL2, new { t = tenantId, f = factoryId, m = mc, d = bizDateL2 });
+        var valsL3 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL3, new { t = tenantId, f = factoryId, m = mc, d = bizDateL3 });
+        var valBy = new Dictionary<string, S4ValRow>(StringComparer.OrdinalIgnoreCase);
+        foreach (var r in valsL2)
+            if (!string.IsNullOrEmpty(r.MetricCode))
+                valBy[r.MetricCode] = r;
+        foreach (var r in valsL3)
+            if (!string.IsNullOrEmpty(r.MetricCode))
+                valBy[r.MetricCode] = r;
+
+        var items = new List<object>();
+        foreach (var row in layout)
+        {
+            kpiBy.TryGetValue(row.MetricCode, out var k);
+            valBy.TryGetValue(row.MetricCode, out var v);
+            var cur = v?.MetricValue;
+            var tgt = v?.TargetValue;
+            var dir = k?.Direction ?? "higher_is_better";
+            var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
+            var gap = AidopS4KpiMerge.GapValue(cur, tgt);
+            var arrow = AidopS4KpiMerge.GapArrow(gap);
+            items.Add(new
+            {
+                rowId = row.RowId,
+                metricCode = row.MetricCode,
+                displayName = string.IsNullOrWhiteSpace(row.DisplayName) ? k?.MetricName : row.DisplayName,
+                unit = k?.Unit ?? "",
+                currentValue = cur,
+                targetValue = tgt,
+                gapValue = gap,
+                gapLabel = FormatGapLabelGeneric(gap, k?.Unit),
+                gapArrow = arrow,
+                achievementLevel = level,
+                formulaText = row.FormulaText,
+                parentRowId = row.ParentRowId,
+                panelZone = row.PanelZone
+            });
+        }
+
+        return Ok(new { moduleCode = mc, bizDate = bizDate.ToString("yyyy-MM-dd"), items });
+    }
+
+    private async Task<DateTime> ResolveMaxBizDateGenericAsync(string table, long tenantId, long factoryId, string moduleCode)
+    {
+        var sql = $"""
+SELECT MAX(biz_date) AS MaxBiz FROM {table}
+WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0
+""";
+        var rows = await _db.Ado.SqlQueryAsync<GenericMaxDateRow>(sql, new { t = tenantId, f = factoryId, m = moduleCode });
+        return rows.FirstOrDefault()?.MaxBiz ?? DateTime.Today;
+    }
+
+    private static string FormatGapLabelGeneric(decimal? gap, string? unit)
+    {
+        if (gap == null) return "—";
+        var g = gap.Value;
+        var sign = g >= 0 ? "+" : "";
+        var s = $"{sign}{Math.Round(g, 2)}";
+        if (unit == "%")
+            return s + "%";
+        return s;
+    }
+
+    private sealed class GenericMaxDateRow
+    {
+        public DateTime? MaxBiz { get; set; }
+    }
+}