Просмотр исходного кода

feat(home-card): 九宫格模块卡支持多呈现样式 + 预留注册表扩展

● 新增 table_list 表格样式(指标 / 目标 / 当前 / 期量差),与现有
  card_grid 形成两种样式。"运营指标建模"页 Drawer 顶部新增样式选择器,
  内嵌 SVG 缩略图可视化对比。
● 样式注册表 homeCardStyles.ts 作为扩展入口:未来新增样式只需加一条
  { code, label, thumbnail, component },s0.vue 与 HomeModuleKpiCard 自动
  派发,无需修改两者代码。
● 后端 PUT /operation-layout/{mc} 接收可选 LayoutPattern 并落库到
  AdoSmartOpsHomeModule.LayoutPattern;带白名单校验,保留旧客户端兼容。
● HomeModuleKpiCard 按 layoutPattern 动态 <component :is>;card_grid
  增加溢出兜底(cap + ellipsis + "+N" 徽章),一并解决 S9 指标溢出问题。
● 登录页 getTenantInfo 对 apiSysTenantListGet 空响应加 null / Array.isArray
  防御,避免后端异常时登录页白屏。

chore: bump version Web 2.4.91→2.4.92 / 后端 1.0.58→1.0.59
Made-with: Cursor
skygu 5 месяцев назад
Родитель
Сommit
6653a2a15b

+ 6 - 2
Web/src/views/aidop/api/kanbanData.ts

@@ -351,10 +351,14 @@ export async function fetchMetricCatalog(
 	}
 }
 
-/** 通用保存运营布局(与 S4 PUT 同构,moduleCode 参数化)。 */
+/**
+ * 通用保存运营布局(与 S4 PUT 同构,moduleCode 参数化)。
+ * body.LayoutPattern:可选,九宫格首页卡呈现样式(card_grid | table_list | 未来新增样式)。
+ * 后端兼容旧前端:若不传则保持 HomeModule 原值;首次无记录时落 card_grid。
+ */
 export async function saveOperationLayout(
 	moduleCode: string,
-	body: { L1: S4LayoutL1SaveRow[]; L2: S4LayoutL2SaveRow[] },
+	body: { L1: S4LayoutL1SaveRow[]; L2: S4LayoutL2SaveRow[]; LayoutPattern?: string },
 	factoryId = 1
 ): Promise<{ ok: boolean; message?: string }> {
 	try {

+ 114 - 1
Web/src/views/aidop/kanban/s0.vue

@@ -54,6 +54,31 @@
             <el-button type="primary" size="small" :loading="s4Saving" @click="saveS4LayoutClick">保存 {{ currentModuleCode }} 布局</el-button>
             <el-button size="small" @click="loadS4Panel">重新加载</el-button>
           </div>
+
+          <div class="s0-s4__style-picker">
+            <div class="s0-s4__subhd">呈现样式(首页九宫格 {{ currentModuleCode }})</div>
+            <p class="s0-s4__hint">保存后首页对应模块卡将按所选样式渲染。新增样式由 <code>homeCardStyles.ts</code> 注册表扩展。</p>
+            <div class="style-picker">
+              <label
+                v-for="opt in styleOptions"
+                :key="opt.code"
+                class="style-option"
+                :class="{ 'is-active': layoutPattern === opt.code }"
+                :title="opt.description || opt.label"
+              >
+                <input
+                  type="radio"
+                  name="home-card-style"
+                  :value="opt.code"
+                  :checked="layoutPattern === opt.code"
+                  @change="layoutPattern = opt.code"
+                />
+                <span class="style-option__thumb" v-html="opt.thumbnail"></span>
+                <span class="style-option__label">{{ opt.label }}</span>
+              </label>
+            </div>
+          </div>
+
           <div class="s0-s4__l1">
             <div class="s0-s4__subhd">一级指标 L1(首页九宫格 {{ currentModuleCode }})</div>
             <p class="s0-s4__hint">勾选后可编辑显示名称与结构化公式。公式默认回填自 KpiMaster 的 FormulaExpr(租户公式事实源)。</p>
@@ -277,6 +302,7 @@ import {
 } from '../api/kanbanData'
 import { notifyLayoutSaved } from './utils/s4LayoutEvents'
 import FormulaEditor from './components/FormulaEditor.vue'
+import { HOME_CARD_STYLES, DEFAULT_STYLE_CODE } from '../../dashboard/components/homeCardStyles'
 
 /** 当前打开的模块级/模块下 L1 抽屉对应的模块编码;非空表示"通用模块建模" */
 const currentModuleCode = ref('')
@@ -286,6 +312,8 @@ const s4Saving = ref(false)
 const catalogL1 = ref([])
 const catalogL2 = ref([])
 const pickedL1 = ref([])
+/** 九宫格首页该模块卡的呈现样式(见 homeCardStyles.ts 注册表) */
+const layoutPattern = ref(DEFAULT_STYLE_CODE)
 const l2On = reactive({})
 const l2ParentMetric = reactive({})
 const l2Zone = reactive({})
@@ -297,6 +325,9 @@ const l2DisplayName = reactive({})
 /** 支持通用建模的模块集合(S8 以异常监控页独立处理,不纳入建模抽屉) */
 const MODELABLE_MODULES = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S9']
 
+/** 九宫格模块卡可选样式(从注册表派生;新增样式无需改本页代码) */
+const styleOptions = computed(() => HOME_CARD_STYLES)
+
 const route = useRoute()
 const chartRef = ref(null)
 const moduleFilter = ref('')
@@ -438,6 +469,8 @@ async function loadModulePanel(moduleCode, opts = {}) {
       if (l2DisplayName[row.metricCode] === undefined) l2DisplayName[row.metricCode] = ''
     }
 
+    layoutPattern.value = (layout && layout.layoutPattern) || DEFAULT_STYLE_CODE
+
     if (layout?.l1?.length) {
       pickedL1.value = layout.l1
         .slice()
@@ -529,7 +562,7 @@ async function saveModuleLayoutClick(moduleCode) {
   }
   s4Saving.value = true
   try {
-    const r = await saveOperationLayout(mc, { L1, L2 }, 1)
+    const r = await saveOperationLayout(mc, { L1, L2, LayoutPattern: layoutPattern.value || DEFAULT_STYLE_CODE }, 1)
     if (r.ok) {
       notifyLayoutSaved(mc)
       await loadModulePanel(mc, { keepVisible: true })
@@ -1234,6 +1267,86 @@ onUnmounted(() => {
   margin-bottom: 8px;
 }
 
+.s0-s4__style-picker {
+  margin: 10px 0 14px;
+  padding: 10px 12px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+}
+
+.s0-s4__style-picker .s0-s4__hint {
+  margin-top: 2px;
+  margin-bottom: 8px;
+}
+
+.style-picker {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.style-option {
+  display: inline-flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  padding: 8px;
+  border: 1px solid #cbd5e1;
+  border-radius: 6px;
+  background: #ffffff;
+  cursor: pointer;
+  transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
+  user-select: none;
+  min-width: 110px;
+}
+
+.style-option:hover {
+  border-color: #94a3b8;
+  background: #f8fafc;
+}
+
+.style-option.is-active {
+  border-color: #3b82f6;
+  background: #eff6ff;
+  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
+}
+
+.style-option input[type='radio'] {
+  position: absolute;
+  opacity: 0;
+  pointer-events: none;
+  width: 0;
+  height: 0;
+}
+
+.style-option__thumb {
+  display: block;
+  width: 96px;
+  height: 56px;
+  border-radius: 4px;
+  overflow: hidden;
+  background: #0f172a;
+  border: 1px solid #334155;
+}
+
+.style-option__thumb :deep(svg) {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+
+.style-option__label {
+  font-size: 12px;
+  color: #334155;
+  font-weight: 600;
+  letter-spacing: 0.02em;
+}
+
+.style-option.is-active .style-option__label {
+  color: #1d4ed8;
+}
+
 .s0-s4__l1 {
   margin-bottom: 14px;
 }

+ 229 - 0
Web/src/views/dashboard/components/HomeCardGridView.vue

@@ -0,0 +1,229 @@
+<template>
+  <div
+    v-if="visibleCells.length > 0"
+    class="s4-kpi-grid"
+    :class="{ 's4-kpi-grid--dense': props.cells.length > 4 }"
+  >
+    <div v-for="cell in visibleCells" :key="cell.key" class="s4-kpi-cell">
+      <el-tooltip
+        v-if="cell.formulaText"
+        placement="top"
+        :show-after="200"
+        :content="cell.formulaText"
+      >
+        <div class="s4-kpi-label s4-kpi-label--formula">{{ cell.label }}</div>
+      </el-tooltip>
+      <div v-else class="s4-kpi-label">{{ cell.label }}</div>
+      <div class="s4-kpi-body">
+        <div class="s4-kpi-main">
+          <div class="s4-kpi-value-row">
+            <span class="s4-kpi-value">{{ cell.value }}</span>
+            <span v-if="cell.unit" class="s4-kpi-unit">{{ cell.unit }}</span>
+          </div>
+          <div v-if="cell.targetDisplay" class="s4-kpi-target">目标: {{ cell.targetDisplay }}</div>
+        </div>
+        <span
+          class="s4-kpi-trend"
+          :class="{
+            's4-kpi-trend--good': cell.trendTone === 'good',
+            's4-kpi-trend--warn': cell.trendTone === 'warn',
+            's4-kpi-trend--bad': cell.trendTone === 'bad',
+          }"
+        >
+          <span class="s4-kpi-trend__arrow" aria-hidden="true">{{
+            cell.trendArrow === 'up' ? '▲' : cell.trendArrow === 'down' ? '▼' : '—'
+          }}</span>
+          {{ cell.trend }}
+        </span>
+      </div>
+    </div>
+    <div v-if="overflowCount > 0" class="s4-kpi-overflow" :title="`还有 ${overflowCount} 个指标未显示`">
+      +{{ overflowCount }}
+    </div>
+  </div>
+  <div v-else class="home-kpi-card__empty">
+    <span v-if="loading">加载中…</span>
+    <span v-else>暂无指标配置,点击卡片进入模块后到"运营指标建模"配置 L1 指标</span>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  moduleCode: { type: String, required: true },
+  cells: { type: Array, required: true },
+  loading: { type: Boolean, default: false },
+})
+
+const MAX_DENSE = 8
+const MAX_NORMAL = 4
+
+const visibleCells = computed(() => {
+  const arr = Array.isArray(props.cells) ? props.cells : []
+  const cap = arr.length > MAX_NORMAL ? MAX_DENSE : MAX_NORMAL
+  return arr.slice(0, cap)
+})
+
+const overflowCount = computed(() => {
+  const arr = Array.isArray(props.cells) ? props.cells : []
+  const cap = arr.length > MAX_NORMAL ? MAX_DENSE : MAX_NORMAL
+  return Math.max(0, arr.length - cap)
+})
+</script>
+
+<style scoped>
+.s4-kpi-grid {
+  position: relative;
+  flex: 1;
+  min-height: 0;
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  grid-template-rows: repeat(2, minmax(0, 1fr));
+  grid-auto-rows: 0;
+  gap: 8px 12px;
+  align-content: stretch;
+  overflow: hidden;
+}
+
+.s4-kpi-grid--dense {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  grid-template-rows: repeat(2, minmax(0, 1fr));
+}
+
+.s4-kpi-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+  gap: 4px;
+  padding: 6px 8px;
+  min-height: 0;
+  min-width: 0;
+  background: rgba(0, 0, 0, 0.22);
+  border-radius: 6px;
+  border: 1px solid rgba(51, 65, 85, 0.35);
+  overflow: hidden;
+}
+
+.s4-kpi-label {
+  font-size: 11px;
+  color: #94a3b8;
+  line-height: 1.3;
+  font-weight: 500;
+  width: 100%;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+
+.s4-kpi-label--formula {
+  cursor: help;
+  border-bottom: 1px dashed rgba(148, 163, 184, 0.55);
+}
+
+.s4-kpi-body {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  width: 100%;
+  min-width: 0;
+}
+
+.s4-kpi-main {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 3px;
+}
+
+.s4-kpi-value-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 4px 6px;
+}
+
+.s4-kpi-target {
+  font-size: 11px;
+  font-weight: 500;
+  color: #94a3b8;
+  line-height: 1.3;
+}
+
+.s4-kpi-value {
+  font-size: clamp(16px, 3vw, 22px);
+  font-weight: 800;
+  color: #ffffff;
+  line-height: 1.05;
+  letter-spacing: 0.02em;
+}
+
+.s4-kpi-grid--dense .s4-kpi-value {
+  font-size: clamp(14px, 2.2vw, 18px);
+}
+
+.s4-kpi-unit {
+  font-size: 11px;
+  color: #e2e8f0;
+  font-weight: 500;
+}
+
+.s4-kpi-trend {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  font-size: 11px;
+  font-weight: 700;
+  flex-shrink: 0;
+  align-self: center;
+}
+
+.s4-kpi-trend__arrow {
+  font-size: 9px;
+  line-height: 1;
+  transform: translateY(0.5px);
+}
+
+.s4-kpi-trend--good {
+  color: #4ade80;
+}
+
+.s4-kpi-trend--warn {
+  color: #facc15;
+}
+
+.s4-kpi-trend--bad {
+  color: #fb923c;
+}
+
+.s4-kpi-overflow {
+  position: absolute;
+  right: 4px;
+  bottom: 2px;
+  font-size: 10px;
+  font-weight: 700;
+  color: #93c5fd;
+  background: rgba(15, 23, 42, 0.75);
+  border: 1px solid rgba(96, 165, 250, 0.55);
+  border-radius: 10px;
+  padding: 1px 6px;
+  pointer-events: none;
+  line-height: 1.2;
+}
+
+.home-kpi-card__empty {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #94a3b8;
+  font-size: 11px;
+  padding: 8px;
+  text-align: center;
+  line-height: 1.4;
+}
+</style>

+ 195 - 0
Web/src/views/dashboard/components/HomeCardTableView.vue

@@ -0,0 +1,195 @@
+<template>
+  <div v-if="cells.length > 0" class="home-table-view">
+    <table class="home-table">
+      <colgroup>
+        <col class="col-label" />
+        <col class="col-target" />
+        <col class="col-current" />
+        <col class="col-gap" />
+      </colgroup>
+      <thead>
+        <tr>
+          <th class="th-label">指标</th>
+          <th class="th-num">目标</th>
+          <th class="th-num">当前</th>
+          <th class="th-gap">期量差</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="cell in cells" :key="cell.key">
+          <td class="td-label" :title="cell.label">
+            <el-tooltip
+              v-if="cell.formulaText"
+              placement="top"
+              :show-after="200"
+              :content="cell.formulaText"
+            >
+              <span class="td-label__text td-label__text--formula">{{ cell.label }}</span>
+            </el-tooltip>
+            <span v-else class="td-label__text">{{ cell.label }}</span>
+          </td>
+          <td class="td-num">{{ cell.targetDisplay || '—' }}</td>
+          <td class="td-num">
+            <span class="td-num__value">{{ cell.value }}</span>
+            <span v-if="cell.unit" class="td-num__unit">{{ cell.unit }}</span>
+          </td>
+          <td
+            class="td-gap"
+            :class="{
+              'td-gap--good': cell.trendTone === 'good',
+              'td-gap--warn': cell.trendTone === 'warn',
+              'td-gap--bad': cell.trendTone === 'bad',
+            }"
+          >
+            <span class="td-gap__arrow" aria-hidden="true">{{
+              cell.trendArrow === 'up' ? '▲' : cell.trendArrow === 'down' ? '▼' : '—'
+            }}</span>
+            <span class="td-gap__text">{{ cell.trend }}</span>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+  <div v-else class="home-kpi-card__empty">
+    <span v-if="loading">加载中…</span>
+    <span v-else>暂无指标配置,点击卡片进入模块后到"运营指标建模"配置 L1 指标</span>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  moduleCode: { type: String, required: true },
+  cells: { type: Array, required: true },
+  loading: { type: Boolean, default: false },
+})
+</script>
+
+<style scoped>
+.home-table-view {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+  overscroll-behavior: contain;
+}
+
+.home-table-view::-webkit-scrollbar {
+  width: 4px;
+  height: 4px;
+}
+
+.home-table-view::-webkit-scrollbar-thumb {
+  background: rgba(148, 163, 184, 0.35);
+  border-radius: 2px;
+}
+
+.home-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 12px;
+  color: #e2e8f0;
+  table-layout: fixed;
+}
+
+.col-label { width: 42%; }
+.col-target { width: 18%; }
+.col-current { width: 20%; }
+.col-gap { width: 20%; }
+
+.home-table thead th {
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  background: rgba(15, 23, 42, 0.92);
+  color: #94a3b8;
+  font-weight: 600;
+  font-size: 11px;
+  letter-spacing: 0.02em;
+  padding: 6px 6px;
+  border-bottom: 1px solid rgba(100, 116, 139, 0.45);
+  text-align: left;
+  white-space: nowrap;
+}
+
+.home-table .th-num,
+.home-table .th-gap { text-align: right; }
+
+.home-table tbody td {
+  padding: 6px 6px;
+  border-bottom: 1px solid rgba(51, 65, 85, 0.45);
+  line-height: 1.3;
+  vertical-align: middle;
+}
+
+.home-table tbody tr:last-child td { border-bottom: none; }
+
+.home-table tbody tr:hover td { background: rgba(30, 41, 59, 0.55); }
+
+.td-label {
+  color: #cbd5e1;
+}
+
+.td-label__text {
+  display: inline-block;
+  max-width: 100%;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  vertical-align: bottom;
+}
+
+.td-label__text--formula {
+  cursor: help;
+  border-bottom: 1px dashed rgba(148, 163, 184, 0.55);
+}
+
+.td-num {
+  text-align: right;
+  color: #f8fafc;
+  font-variant-numeric: tabular-nums;
+  font-weight: 600;
+  white-space: nowrap;
+}
+
+.td-num__value {
+  font-size: 13px;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+}
+
+.td-num__unit {
+  font-size: 10px;
+  color: #94a3b8;
+  margin-left: 2px;
+  font-weight: 500;
+}
+
+.td-gap {
+  text-align: right;
+  font-weight: 700;
+  font-size: 12px;
+  white-space: nowrap;
+  color: #cbd5e1;
+}
+
+.td-gap__arrow {
+  font-size: 9px;
+  margin-right: 2px;
+  vertical-align: middle;
+}
+
+.td-gap--good { color: #4ade80; }
+.td-gap--warn { color: #facc15; }
+.td-gap--bad { color: #fb923c; }
+
+.home-kpi-card__empty {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #94a3b8;
+  font-size: 11px;
+  padding: 8px;
+  text-align: center;
+  line-height: 1.4;
+}
+</style>

+ 14 - 170
Web/src/views/dashboard/components/HomeModuleKpiCard.vue

@@ -7,45 +7,13 @@
         <component :is="icon" />
       </el-icon>
     </header>
-    <div class="dash-card__body s4-body">
-      <div
-        v-if="cells.length > 0"
-        class="s4-kpi-grid"
-        :class="{ 's4-kpi-grid--dense': cells.length > 4 }"
-      >
-        <div v-for="cell in cells" :key="cell.key" class="s4-kpi-cell">
-          <el-tooltip v-if="cell.formulaText" placement="top" :show-after="200" :content="cell.formulaText">
-            <div class="s4-kpi-label s4-kpi-label--formula">{{ cell.label }}</div>
-          </el-tooltip>
-          <div v-else class="s4-kpi-label">{{ cell.label }}</div>
-          <div class="s4-kpi-body">
-            <div class="s4-kpi-main">
-              <div class="s4-kpi-value-row">
-                <span class="s4-kpi-value">{{ cell.value }}</span>
-                <span v-if="cell.unit" class="s4-kpi-unit">{{ cell.unit }}</span>
-              </div>
-              <div v-if="cell.targetDisplay" class="s4-kpi-target">目标: {{ cell.targetDisplay }}</div>
-            </div>
-            <span
-              class="s4-kpi-trend"
-              :class="{
-                's4-kpi-trend--good': cell.trendTone === 'good',
-                's4-kpi-trend--warn': cell.trendTone === 'warn',
-                's4-kpi-trend--bad': cell.trendTone === 'bad',
-              }"
-            >
-              <span class="s4-kpi-trend__arrow" aria-hidden="true">{{
-                cell.trendArrow === 'up' ? '▲' : cell.trendArrow === 'down' ? '▼' : '—'
-              }}</span>
-              {{ cell.trend }}
-            </span>
-          </div>
-        </div>
-      </div>
-      <div v-else class="home-kpi-card__empty">
-        <span v-if="loading">加载中…</span>
-        <span v-else>暂无指标配置,点击卡片进入模块后到“运营指标建模”配置 L1 指标</span>
-      </div>
+    <div class="dash-card__body home-kpi-card__body">
+      <component
+        :is="styleDef.component"
+        :module-code="moduleCode"
+        :cells="cells"
+        :loading="loading"
+      />
     </div>
   </section>
 </template>
@@ -54,6 +22,7 @@
 import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 import { fetchHomeGrid } from '../../aidop/api/kanbanData'
 import { AIDOP_LAYOUT_SAVED, AIDOP_S4_LAYOUT_SAVED } from '../../aidop/kanban/utils/s4LayoutEvents'
+import { getStyleDef, DEFAULT_STYLE_CODE } from './homeCardStyles'
 
 const props = defineProps({
   moduleCode: { type: String, required: true },
@@ -67,6 +36,7 @@ const props = defineProps({
 const emit = defineEmits(['click'])
 
 const apiItems = ref([])
+const layoutPattern = ref(DEFAULT_STYLE_CODE)
 const loading = ref(false)
 
 function fmtValue(v, unit) {
@@ -124,6 +94,8 @@ const cells = computed(() =>
   Array.isArray(apiItems.value) ? apiItems.value.map(mapItem) : []
 )
 
+const styleDef = computed(() => getStyleDef(layoutPattern.value))
+
 async function load() {
   const mc = String(props.moduleCode || '').toUpperCase()
   if (!mc) return
@@ -131,8 +103,10 @@ async function load() {
   try {
     const grid = await fetchHomeGrid(mc, props.factoryId, props.extra || {})
     apiItems.value = grid?.items && grid.items.length > 0 ? grid.items : []
+    layoutPattern.value = grid?.layoutPattern || DEFAULT_STYLE_CODE
   } catch {
     apiItems.value = []
+    layoutPattern.value = DEFAULT_STYLE_CODE
   } finally {
     loading.value = false
   }
@@ -233,141 +207,11 @@ defineExpose({ reload: load })
   min-height: 0;
   display: flex;
   flex-direction: column;
-  gap: 6px;
   overflow: hidden;
 }
 
-.s4-body {
-  flex: 1;
-  min-height: 0;
-  overflow: hidden;
+.home-kpi-card__body {
   padding: 2px 10px 4px;
   box-sizing: border-box;
 }
-
-.s4-kpi-grid {
-  flex: 1;
-  min-height: 0;
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  grid-template-rows: 1fr 1fr;
-  gap: 8px 12px;
-  align-content: stretch;
-}
-
-.s4-kpi-grid--dense {
-  grid-template-columns: repeat(4, minmax(0, 1fr));
-  grid-template-rows: repeat(2, minmax(0, 1fr));
-}
-
-.s4-kpi-cell {
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  justify-content: center;
-  gap: 6px;
-  padding: 6px 8px;
-  min-height: 0;
-  background: rgba(0, 0, 0, 0.22);
-  border-radius: 6px;
-  border: 1px solid rgba(51, 65, 85, 0.35);
-}
-
-.s4-kpi-label {
-  font-size: 11px;
-  color: #94a3b8;
-  line-height: 1.3;
-  font-weight: 500;
-}
-
-.s4-kpi-label--formula {
-  cursor: help;
-  border-bottom: 1px dashed rgba(148, 163, 184, 0.55);
-}
-
-.s4-kpi-body {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px;
-  width: 100%;
-  min-width: 0;
-}
-
-.s4-kpi-main {
-  flex: 1;
-  min-width: 0;
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  gap: 3px;
-}
-
-.s4-kpi-value-row {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: baseline;
-  gap: 4px 6px;
-}
-
-.s4-kpi-target {
-  font-size: 11px;
-  font-weight: 500;
-  color: #94a3b8;
-  line-height: 1.3;
-}
-
-.s4-kpi-value {
-  font-size: clamp(18px, 3.8vw, 24px);
-  font-weight: 800;
-  color: #ffffff;
-  line-height: 1.05;
-  letter-spacing: 0.02em;
-}
-
-.s4-kpi-unit {
-  font-size: 11px;
-  color: #e2e8f0;
-  font-weight: 500;
-}
-
-.s4-kpi-trend {
-  display: inline-flex;
-  align-items: center;
-  gap: 2px;
-  font-size: 11px;
-  font-weight: 700;
-  flex-shrink: 0;
-  align-self: center;
-}
-
-.s4-kpi-trend__arrow {
-  font-size: 9px;
-  line-height: 1;
-  transform: translateY(0.5px);
-}
-
-.s4-kpi-trend--good {
-  color: #4ade80;
-}
-
-.s4-kpi-trend--warn {
-  color: #facc15;
-}
-
-.s4-kpi-trend--bad {
-  color: #fb923c;
-}
-
-.home-kpi-card__empty {
-  flex: 1;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #94a3b8;
-  font-size: 11px;
-  padding: 8px;
-  text-align: center;
-  line-height: 1.4;
-}
 </style>

+ 100 - 0
Web/src/views/dashboard/components/homeCardStyles.ts

@@ -0,0 +1,100 @@
+/**
+ * 九宫格首页模块卡「呈现样式」注册表。
+ *
+ * 扩展入口:新增一种样式只需要
+ *   1) 写一个实现 <props: moduleCode/cells/loading> 的 Vue SFC 放到同目录;
+ *   2) 在下面 HOME_CARD_STYLES 里加一条 { code, label, description, thumbnail, component }。
+ *      s0.vue 的样式选择器与 HomeModuleKpiCard.vue 的动态派发都会自动读取。
+ * 约束:
+ *   - code 与后端 AdoSmartOpsHomeModule.LayoutPattern 值保持一致(snake_case,<=32 字符);
+ *   - thumbnail 为内嵌 SVG 字符串,viewBox 统一为 "0 0 80 48",黑底半透明浅线;
+ *   - 默认样式(DEFAULT_STYLE_CODE)必须存在于注册表中。
+ */
+import type { Component } from 'vue';
+import HomeCardGridView from './HomeCardGridView.vue';
+import HomeCardTableView from './HomeCardTableView.vue';
+
+export interface HomeCardStyleDef {
+	/** 后端 LayoutPattern 值(snake_case) */
+	code: string;
+	/** 建模页下拉/单选显示的短标签 */
+	label: string;
+	/** 说明文案(hover/tooltip 可用) */
+	description?: string;
+	/** 内嵌 SVG 缩略图字符串(viewBox 0 0 80 48) */
+	thumbnail: string;
+	/** 对应渲染组件 */
+	component: Component;
+}
+
+export const DEFAULT_STYLE_CODE = 'card_grid';
+
+const SVG_CARD_GRID = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" width="100%" height="100%">
+  <rect x="0.5" y="0.5" width="79" height="47" rx="4" fill="rgba(30,41,59,0.55)" stroke="rgba(100,116,139,0.7)"/>
+  <rect x="6" y="6" width="32" height="16" rx="2" fill="rgba(96,165,250,0.18)" stroke="rgba(96,165,250,0.55)"/>
+  <rect x="42" y="6" width="32" height="16" rx="2" fill="rgba(94,234,212,0.18)" stroke="rgba(94,234,212,0.55)"/>
+  <rect x="6" y="26" width="32" height="16" rx="2" fill="rgba(250,204,21,0.18)" stroke="rgba(250,204,21,0.55)"/>
+  <rect x="42" y="26" width="32" height="16" rx="2" fill="rgba(251,146,60,0.18)" stroke="rgba(251,146,60,0.55)"/>
+  <line x1="12" y1="12" x2="20" y2="12" stroke="#cbd5e1" stroke-width="1"/>
+  <line x1="12" y1="16" x2="30" y2="16" stroke="#e2e8f0" stroke-width="2"/>
+  <line x1="48" y1="12" x2="56" y2="12" stroke="#cbd5e1" stroke-width="1"/>
+  <line x1="48" y1="16" x2="66" y2="16" stroke="#e2e8f0" stroke-width="2"/>
+  <line x1="12" y1="32" x2="20" y2="32" stroke="#cbd5e1" stroke-width="1"/>
+  <line x1="12" y1="36" x2="30" y2="36" stroke="#e2e8f0" stroke-width="2"/>
+  <line x1="48" y1="32" x2="56" y2="32" stroke="#cbd5e1" stroke-width="1"/>
+  <line x1="48" y1="36" x2="66" y2="36" stroke="#e2e8f0" stroke-width="2"/>
+</svg>`;
+
+const SVG_TABLE_LIST = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" width="100%" height="100%">
+  <rect x="0.5" y="0.5" width="79" height="47" rx="4" fill="rgba(30,41,59,0.55)" stroke="rgba(100,116,139,0.7)"/>
+  <rect x="4" y="6" width="72" height="9" rx="1.5" fill="rgba(96,165,250,0.22)"/>
+  <line x1="28" y1="6" x2="28" y2="42" stroke="rgba(100,116,139,0.45)" stroke-width="0.6"/>
+  <line x1="44" y1="6" x2="44" y2="42" stroke="rgba(100,116,139,0.45)" stroke-width="0.6"/>
+  <line x1="60" y1="6" x2="60" y2="42" stroke="rgba(100,116,139,0.45)" stroke-width="0.6"/>
+  <line x1="4" y1="15" x2="76" y2="15" stroke="rgba(100,116,139,0.55)" stroke-width="0.6"/>
+  <line x1="4" y1="24" x2="76" y2="24" stroke="rgba(100,116,139,0.35)" stroke-width="0.6"/>
+  <line x1="4" y1="33" x2="76" y2="33" stroke="rgba(100,116,139,0.35)" stroke-width="0.6"/>
+  <line x1="7" y1="10.5" x2="25" y2="10.5" stroke="#e2e8f0" stroke-width="1"/>
+  <line x1="31" y1="10.5" x2="40" y2="10.5" stroke="#e2e8f0" stroke-width="1"/>
+  <line x1="47" y1="10.5" x2="56" y2="10.5" stroke="#e2e8f0" stroke-width="1"/>
+  <line x1="63" y1="10.5" x2="73" y2="10.5" stroke="#e2e8f0" stroke-width="1"/>
+  <line x1="7" y1="19.5" x2="25" y2="19.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="31" y1="19.5" x2="40" y2="19.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="47" y1="19.5" x2="56" y2="19.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="63" y1="19.5" x2="73" y2="19.5" stroke="#fb923c" stroke-width="0.8"/>
+  <line x1="7" y1="28.5" x2="25" y2="28.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="31" y1="28.5" x2="40" y2="28.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="47" y1="28.5" x2="56" y2="28.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="63" y1="28.5" x2="73" y2="28.5" stroke="#4ade80" stroke-width="0.8"/>
+  <line x1="7" y1="37.5" x2="25" y2="37.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="31" y1="37.5" x2="40" y2="37.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="47" y1="37.5" x2="56" y2="37.5" stroke="#cbd5e1" stroke-width="0.8"/>
+  <line x1="63" y1="37.5" x2="73" y2="37.5" stroke="#facc15" stroke-width="0.8"/>
+</svg>`;
+
+/** 全局样式注册表。新增样式在此追加一条即可。 */
+export const HOME_CARD_STYLES: HomeCardStyleDef[] = [
+	{
+		code: 'card_grid',
+		label: '卡片样式',
+		description: 'KPI 方格布局(S4 首页样式)。适合 ≤8 个一级指标,视觉突出。',
+		thumbnail: SVG_CARD_GRID,
+		component: HomeCardGridView,
+	},
+	{
+		code: 'table_list',
+		label: '表格样式',
+		description: '表格列表(指标 / 目标 / 当前 / 期量差)。适合较多指标或数据对比场景,无溢出风险。',
+		thumbnail: SVG_TABLE_LIST,
+		component: HomeCardTableView,
+	},
+];
+
+/** 按 code 取样式定义;找不到时回落到默认样式,保证永不返回 undefined。 */
+export function getStyleDef(code: string | null | undefined): HomeCardStyleDef {
+	const key = String(code || '').toLowerCase();
+	return (
+		HOME_CARD_STYLES.find((s) => s.code === key) ||
+		HOME_CARD_STYLES.find((s) => s.code === DEFAULT_STYLE_CODE)!
+	);
+}

+ 35 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs

@@ -271,8 +271,22 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0
     {
         public List<GenericLayoutL1Dto>? L1 { get; set; }
         public List<GenericLayoutL2Dto>? L2 { get; set; }
+        /// <summary>
+        /// 九宫格首页模块卡呈现样式(card_grid | table_list | 未来新增)。
+        /// 可选:未传或空值时保持 HomeModule 当前值;首次无记录时回落默认 card_grid。
+        /// </summary>
+        public string? LayoutPattern { get; set; }
     }
 
+    /// <summary>
+    /// 允许的 LayoutPattern 白名单。新增样式时在此增加条目(与前端 homeCardStyles.ts 注册表保持一致)。
+    /// </summary>
+    private static readonly HashSet<string> AllowedLayoutPatterns = new(StringComparer.OrdinalIgnoreCase)
+    {
+        "card_grid",
+        "table_list",
+    };
+
     public class GenericLayoutL1Dto
     {
         public string? RowId { get; set; }
@@ -394,16 +408,32 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code=@m AND is_deleted=0
                 }
             }
 
-            var hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
-                .SetColumns(x => new AdoSmartOpsHomeModule { UpdateTime = now })
-                .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
-                .ExecuteCommandAsync();
+            var lp = (body.LayoutPattern ?? "").Trim();
+            if (!string.IsNullOrEmpty(lp) && !AllowedLayoutPatterns.Contains(lp))
+                return BadRequest(new { message = $"无效的 LayoutPattern: {body.LayoutPattern}" });
+            var lpNormalized = string.IsNullOrEmpty(lp) ? null : lp.ToLowerInvariant();
+
+            int hmRows;
+            if (lpNormalized != null)
+            {
+                hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
+                    .SetColumns(x => new AdoSmartOpsHomeModule { LayoutPattern = lpNormalized, UpdateTime = now })
+                    .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+                    .ExecuteCommandAsync();
+            }
+            else
+            {
+                hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
+                    .SetColumns(x => new AdoSmartOpsHomeModule { UpdateTime = now })
+                    .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
+                    .ExecuteCommandAsync();
+            }
             if (hmRows == 0)
             {
                 await _db.Insertable(new AdoSmartOpsHomeModule
                 {
                     TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
-                    LayoutPattern = "card_grid", UpdateTime = now
+                    LayoutPattern = lpNormalized ?? "card_grid", UpdateTime = now
                 }).ExecuteCommandAsync();
             }