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

fix(kanban): 九宫格同时展示最后真实值和最新计算状态

最新 NO_DATA 不再盖住上一期真实值。新鲜度按指标 SLA 计算,S8 空列表和加载失败分开显示。server 1.0.593 / Web 2.4.414。

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 1 день назад
Родитель
Сommit
a64d177ff8
20 измененных файлов с 787 добавлено и 132 удалено
  1. 1 1
      Web/package.json
  2. 10 0
      Web/src/views/aidop/api/kanbanData.ts
  3. 11 1
      Web/src/views/dashboard/components/HomeCardGridView.vue
  4. 11 1
      Web/src/views/dashboard/components/HomeCardTableView.vue
  5. 11 1
      Web/src/views/dashboard/components/HomeCardTilesView.vue
  6. 2 92
      Web/src/views/dashboard/components/HomeModuleKpiCard.vue
  7. 87 0
      Web/src/views/dashboard/components/homeKpiPresentation.spec.ts
  8. 98 0
      Web/src/views/dashboard/components/homeKpiPresentation.ts
  9. 28 0
      Web/src/views/dashboard/home.s8-empty.spec.ts
  10. 18 1
      Web/src/views/dashboard/home.vue
  11. 9 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  12. 31 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.sql
  13. 14 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.verify.sql
  14. 3 3
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/GridAndInboundGuardTests.cs
  15. 211 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/HomeGridLastKnownValueContractTests.cs
  16. 122 28
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs
  17. 5 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs
  18. 3 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiMaster.cs
  19. 14 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs
  20. 98 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/HomeGridDisplayPolicy.cs

+ 1 - 1
Web/package.json

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

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

@@ -771,6 +771,16 @@ export interface S4HomeGridCell {
 	gapArrow: string;
 	achievementLevel: string;
 	formulaText?: string | null;
+	resultStatus?: string | null;
+	resultReason?: string | null;
+	bizDate?: string | null;
+	latestResultStatus?: string | null;
+	latestResultReason?: string | null;
+	latestBizDate?: string | null;
+	displayState?: string | null;
+	freshnessState?: string | null;
+	valueAgeDays?: number | null;
+	freshnessSlaDays?: number | null;
 }
 
 export interface S4HomeGridPayload {

+ 11 - 1
Web/src/views/dashboard/components/HomeCardGridView.vue

@@ -17,11 +17,13 @@
       <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 class="s4-kpi-value" :class="{ 's4-kpi-value--status': cell.valueKind === 'status' }">{{ 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 v-if="cell.asOfText" class="s4-kpi-asof" :title="cell.staleHint || cell.asOfText">{{ cell.asOfText }}</div>
+          <div v-if="cell.latestText" class="s4-kpi-asof">{{ cell.latestText }}</div>
+          <div v-if="cell.policyText" class="s4-kpi-asof">{{ cell.policyText }}</div>
         </div>
         <span
           class="s4-kpi-trend"
@@ -180,6 +182,14 @@ const overflowCount = computed(() => {
   font-size: clamp(14px, 2.2vw, 18px);
 }
 
+.s4-kpi-value.s4-kpi-value--status {
+  font-size: 11px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 100%;
+}
+
 .s4-kpi-unit {
   font-size: 11px;
   color: #e2e8f0;

+ 11 - 1
Web/src/views/dashboard/components/HomeCardTableView.vue

@@ -28,10 +28,12 @@
             </el-tooltip>
             <span v-else class="td-label__text">{{ cell.label }}</span>
             <div v-if="cell.asOfText" class="td-asof" :title="cell.staleHint || cell.asOfText">{{ cell.asOfText }}</div>
+            <div v-if="cell.latestText" class="td-asof">{{ cell.latestText }}</div>
+            <div v-if="cell.policyText" class="td-asof">{{ cell.policyText }}</div>
           </td>
           <td class="td-num">{{ cell.targetDisplay || '—' }}</td>
           <td class="td-num">
-            <span class="td-num__value">{{ cell.value }}</span>
+            <span class="td-num__value" :class="{ 'td-num__value--status': cell.valueKind === 'status' }">{{ cell.value }}</span>
             <span v-if="cell.unit" class="td-num__unit">{{ cell.unit }}</span>
           </td>
           <td
@@ -157,6 +159,14 @@ defineProps({
   letter-spacing: 0.02em;
 }
 
+.td-num__value.td-num__value--status {
+  font-size: 11px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 100%;
+}
+
 .td-num__unit {
   font-size: 10px;
   color: #94a3b8;

+ 11 - 1
Web/src/views/dashboard/components/HomeCardTilesView.vue

@@ -30,10 +30,12 @@
           目标值: <span class="tile__target-val">{{ cell.targetDisplay || '—' }}</span>
         </div>
         <div class="tile__main">
-          <span class="tile__value">{{ cell.value }}</span>
+          <span class="tile__value" :class="{ 'tile__value--status': cell.valueKind === 'status' }">{{ cell.value }}</span>
           <span v-if="cell.unit" class="tile__unit">{{ cell.unit }}</span>
         </div>
         <div v-if="cell.asOfText" class="tile__asof" :title="cell.staleHint || cell.asOfText">{{ cell.asOfText }}</div>
+        <div v-if="cell.latestText" class="tile__asof">{{ cell.latestText }}</div>
+        <div v-if="cell.policyText" class="tile__asof">{{ cell.policyText }}</div>
       </div>
     </div>
     <div v-if="overflowCount > 0" class="tiles-view__overflow" :title="`还有 ${overflowCount} 个指标未显示`">
@@ -194,6 +196,14 @@ const overflowCount = computed(() => {
   white-space: nowrap;
 }
 
+.tile__value.tile__value--status {
+  font-size: 11px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 100%;
+}
+
 .tile__unit {
   font-size: clamp(10px, 7cqi, 13px);
   color: #e2e8f0;

+ 2 - 92
Web/src/views/dashboard/components/HomeModuleKpiCard.vue

@@ -23,6 +23,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'
+import { mapHomeKpiItem } from './homeKpiPresentation'
 
 const props = defineProps({
   moduleCode: { type: String, required: true },
@@ -39,99 +40,8 @@ const apiItems = ref([])
 const layoutPattern = ref(DEFAULT_STYLE_CODE)
 const loading = ref(false)
 
-function fmtResult(item, unit) {
-  const status = String(item?.resultStatus || '')
-  if (status === 'SOURCE_UNSUPPORTED') return '数据源不支持'
-  if (status === 'NO_DATA') return '无业务数据'
-  return fmtValue(item?.currentValue, unit)
-}
-
-function fmtValue(v, unit) {
-  if (v == null || v === '') return '—'
-  const n = Number(v)
-  if (Number.isNaN(n)) return String(v)
-  if (unit === '%') return `${Math.round(n * 10) / 10}`
-  if (unit === '天') return `${Math.round(n * 10) / 10}`
-  if (Math.abs(n - Math.round(n)) < 1e-9) return `${Math.round(n)}`
-  // 小数值(|n|<1)保留 3 位,避免 0.018 被 round 成 0.02 失真
-  if (Math.abs(n) < 1) return `${Math.round(n * 1000) / 1000}`
-  return `${Math.round(n * 100) / 100}`
-}
-
-function fmtTargetDisplay(v, unit) {
-  if (v == null || v === '') return ''
-  const n = Number(v)
-  if (Number.isNaN(n)) return String(v)
-  const num =
-    unit === '%'
-      ? `${Math.round(n * 10) / 10}`
-      : unit === '天'
-      ? `${Math.round(n * 10) / 10}`
-      : Math.abs(n - Math.round(n)) < 1e-9
-      ? `${Math.round(n)}`
-      : Math.abs(n) < 1
-      ? `${Math.round(n * 1000) / 1000}`
-      : `${Math.round(n * 100) / 100}`
-  if (unit === '%') return `${num}%`
-  if (unit === '天') return `${num} 天`
-  if (unit) return `${num} ${unit}`
-  return `${num}`
-}
-
-const STALE_AFTER_DAYS = 7
-
-function asOfMeta(item) {
-  const status = String(item?.resultStatus || '')
-  if (status === 'NO_DATA' || status === 'SOURCE_UNSUPPORTED') {
-    return { asOfText: '', stale: false, staleHint: '' }
-  }
-  const raw = item?.bizDate
-  if (raw == null || raw === '') return { asOfText: '', stale: false, staleHint: '' }
-  const d = String(raw).slice(0, 10)
-  if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return { asOfText: '', stale: false, staleHint: '' }
-  const biz = new Date(`${d}T00:00:00`)
-  const today = new Date()
-  today.setHours(0, 0, 0, 0)
-  const ageDays = Math.round((today.getTime() - biz.getTime()) / 86400000)
-  const stale = ageDays > STALE_AFTER_DAYS
-  return {
-    asOfText: `数据截至 ${d}`,
-    stale,
-    staleHint: stale ? `数据陈旧:业务日 ${d},已超过 ${STALE_AFTER_DAYS} 天` : '',
-  }
-}
-
-function mapItem(item) {
-  const arrow = item.gapArrow
-  const trendArrow = arrow === 'up' ? 'up' : arrow === 'down' ? 'down' : 'flat'
-  const lv = String(item.achievementLevel || 'green')
-  const trendTone = lv === 'red' ? 'bad' : lv === 'yellow' ? 'warn' : 'good'
-  const unit = item.unit ?? ''
-  const asOf = asOfMeta(item)
-  const formula =
-    item.formulaText != null && String(item.formulaText).trim()
-      ? String(item.formulaText).trim()
-      : ''
-  const reason = String(item?.resultReason || '').trim()
-  const ft = reason ? (formula ? `${formula}\n${reason}` : reason) : formula
-  return {
-    key: item.rowId || item.metricCode,
-    label: item.displayName || item.metricCode,
-    formulaText: ft,
-    value: fmtResult(item, unit),
-    unit,
-    targetDisplay: fmtTargetDisplay(item.targetValue, unit),
-    trend: item.gapLabel || '—',
-    trendArrow,
-    trendTone,
-    asOfText: asOf.asOfText,
-    stale: asOf.stale,
-    staleHint: asOf.staleHint,
-  }
-}
-
 const cells = computed(() =>
-  Array.isArray(apiItems.value) ? apiItems.value.map(mapItem) : []
+  Array.isArray(apiItems.value) ? apiItems.value.map(mapHomeKpiItem) : []
 )
 
 const styleDef = computed(() => getStyleDef(layoutPattern.value))

+ 87 - 0
Web/src/views/dashboard/components/homeKpiPresentation.spec.ts

@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest'
+import { mapHomeKpiItem, safeResultReason } from './homeKpiPresentation'
+
+describe('home kpi presentation', () => {
+	it('shows the last real number when the latest attempt has no data', () => {
+		const cell = mapHomeKpiItem({
+			metricCode: 'S6_L1_001',
+			displayName: '生产周期',
+			unit: '天',
+			currentValue: 3,
+			targetValue: 5,
+			bizDate: '2026-09-23',
+			displayState: 'LAST_KNOWN_VALUE',
+			latestResultStatus: 'NO_DATA',
+			latestBizDate: '2026-09-28',
+			freshnessState: 'STALE',
+			valueAgeDays: 6,
+			freshnessSlaDays: 7,
+			gapArrow: 'flat',
+			achievementLevel: 'green',
+		})
+		expect(cell.value).toBe('3')
+		expect(cell.valueKind).toBe('number')
+		expect(cell.value).not.toContain('无业务数据')
+		expect(cell.asOfText).toBe('数据截至 2026-09-23')
+		expect(cell.latestText).toContain('无业务数据')
+		expect(cell.stale).toBe(true)
+	})
+
+	it('does not dim a fresh card', () => {
+		const cell = mapHomeKpiItem({
+			currentValue: 1,
+			displayState: 'CURRENT_VALUE',
+			bizDate: '2026-09-29',
+			freshnessState: 'FRESH',
+			unit: '天',
+		})
+		expect(cell.stale).toBe(false)
+	})
+
+	it('does not apply a local seven-day rule to a monthly value', () => {
+		const cell = mapHomeKpiItem({
+			currentValue: 12,
+			displayState: 'CURRENT_VALUE',
+			bizDate: '2026-08-31',
+			freshnessState: 'FRESH',
+			valueAgeDays: 29,
+			freshnessSlaDays: 45,
+			unit: '天',
+		})
+		expect(cell.stale).toBe(false)
+		expect(cell.formulaText).toContain('SLA 45 天')
+	})
+
+	it('keeps a no-data status on one line', () => {
+		const cell = mapHomeKpiItem({
+			displayState: 'NO_DATA',
+			latestResultStatus: 'NO_DATA',
+			resultReason: '无完工',
+		})
+		expect(cell.valueKind).toBe('status')
+		expect(cell.value).toBe('无业务数据')
+		expect(cell.value).not.toMatch(/\n/)
+	})
+
+	it('replaces a reason that carries a connection secret', () => {
+		expect(safeResultReason('login failed Password=secret Server=10.0.0.1')).toBe('计算状态异常,请查看运行日志')
+		const cell = mapHomeKpiItem({
+			displayState: 'NO_DATA',
+			latestResultReason: 'Pwd=abc',
+		})
+		expect(cell.formulaText).toContain('计算状态异常,请查看运行日志')
+		expect(cell.formulaText).not.toContain('Pwd=')
+	})
+
+	it('shows a neutral note when freshness policy is missing', () => {
+		const cell = mapHomeKpiItem({
+			currentValue: 2,
+			displayState: 'CURRENT_VALUE',
+			bizDate: '2026-09-01',
+			freshnessState: 'POLICY_MISSING',
+			unit: '天',
+		})
+		expect(cell.stale).toBe(false)
+		expect(cell.policyText).toBe('未配置新鲜度规则')
+	})
+})

+ 98 - 0
Web/src/views/dashboard/components/homeKpiPresentation.ts

@@ -0,0 +1,98 @@
+export type HomeKpiValueKind = 'number' | 'status'
+
+const SECRET_REASON = /Password=|Pwd=|ConnectionString|Server=/i
+
+export function safeResultReason(reason: unknown): string {
+	const text = String(reason ?? '').trim()
+	if (!text) return ''
+	if (SECRET_REASON.test(text)) return '计算状态异常,请查看运行日志'
+	return text
+}
+
+function fmtNumber(v: unknown, unit: string): string {
+	if (v == null || v === '') return '—'
+	const n = Number(v)
+	if (Number.isNaN(n)) return String(v)
+	if (unit === '%' || unit === '天') return `${Math.round(n * 10) / 10}`
+	if (Math.abs(n - Math.round(n)) < 1e-9) return `${Math.round(n)}`
+	if (Math.abs(n) < 1) return `${Math.round(n * 1000) / 1000}`
+	return `${Math.round(n * 100) / 100}`
+}
+
+export function fmtTargetDisplay(v: unknown, unit: string): string {
+	if (v == null || v === '') return ''
+	const num = fmtNumber(v, unit)
+	if (num === '—') return ''
+	if (unit === '%') return `${num}%`
+	if (unit === '天') return `${num} 天`
+	if (unit) return `${num} ${unit}`
+	return num
+}
+
+function statusText(displayState: string): string {
+	if (displayState === 'SOURCE_UNSUPPORTED') return '数据源不支持'
+	return '无业务数据'
+}
+
+function latestPhrase(status: string): string {
+	if (status === 'SOURCE_UNSUPPORTED') return '数据源不支持'
+	if (status === 'NO_DATA' || status === '') return '无业务数据'
+	return status
+}
+
+export function mapHomeKpiItem(item: Record<string, any>) {
+	const unit = item?.unit ?? ''
+	const displayState = String(item?.displayState || '')
+	const hasNumber = displayState === 'CURRENT_VALUE' || displayState === 'LAST_KNOWN_VALUE'
+	const valueKind: HomeKpiValueKind = hasNumber ? 'number' : 'status'
+	const value = hasNumber ? fmtNumber(item?.currentValue, unit) : statusText(displayState || String(item?.resultStatus || ''))
+	const bizDate = String(item?.bizDate || '').slice(0, 10)
+	const latestDate = String(item?.latestBizDate || '').slice(0, 10)
+	const latestStatus = String(item?.latestResultStatus || '')
+	const freshnessState = String(item?.freshnessState || '')
+	const asOfText = hasNumber && /^\d{4}-\d{2}-\d{2}$/.test(bizDate) ? `数据截至 ${bizDate}` : ''
+	const latestText =
+		displayState === 'LAST_KNOWN_VALUE' && latestDate
+			? `最新一期:${latestPhrase(latestStatus)}(${latestDate})`
+			: ''
+	const policyText = freshnessState === 'POLICY_MISSING' ? '未配置新鲜度规则' : ''
+	const formula = item?.formulaText != null && String(item.formulaText).trim() ? String(item.formulaText).trim() : ''
+	const reason = safeResultReason(item?.latestResultReason || item?.resultReason)
+	const freshnessLine =
+		item?.valueAgeDays != null && item?.freshnessSlaDays != null
+			? `新鲜度:${item.valueAgeDays} 天 / SLA ${item.freshnessSlaDays} 天`
+			: ''
+	const tooltip = [formula, asOfText ? `最后真实值:${bizDate}` : '', latestDate ? `最新计算:${latestDate} / ${latestStatus || '—'} / ${reason}` : reason, freshnessLine, policyText]
+		.filter((line) => String(line || '').trim())
+		.join('\n')
+	const arrow = item?.gapArrow
+	return {
+		key: item?.rowId || item?.metricCode,
+		label: item?.displayName || item?.metricCode,
+		formulaText: tooltip,
+		value,
+		valueKind,
+		unit: valueKind === 'number' ? unit : '',
+		targetDisplay: valueKind === 'number' ? fmtTargetDisplay(item?.targetValue, unit) : '',
+		trend: item?.gapLabel || '—',
+		trendArrow: arrow === 'up' ? 'up' : arrow === 'down' ? 'down' : 'flat',
+		trendTone: String(item?.achievementLevel || 'green') === 'red' ? 'bad' : String(item?.achievementLevel || '') === 'yellow' ? 'warn' : 'good',
+		asOfText,
+		latestText,
+		policyText,
+		stale: freshnessState === 'STALE',
+		staleHint: freshnessState === 'STALE' ? `数据陈旧:业务日 ${bizDate}` : policyText,
+	}
+}
+
+export function s8AlarmMessage(state: 'ok' | 'empty' | 'error'): string {
+	if (state === 'error') return '异常数据加载失败'
+	if (state === 'empty') return '暂无异常告警'
+	return ''
+}
+
+export function s8TrendNote(points: Array<{ count?: number }> | null): string {
+	if (points == null) return '异常数据加载失败'
+	if (points.length > 0 && points.every((p) => Number(p?.count || 0) === 0)) return '近 7 日无异常'
+	return ''
+}

+ 28 - 0
Web/src/views/dashboard/home.s8-empty.spec.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { s8AlarmMessage, s8TrendNote } from './components/homeKpiPresentation'
+
+describe('S8 home empty states', () => {
+	it('says there are no alarms when the list is empty', () => {
+		expect(s8AlarmMessage('empty')).toBe('暂无异常告警')
+	})
+
+	it('does not call a failed request an empty list', () => {
+		expect(s8AlarmMessage('error')).toBe('异常数据加载失败')
+		expect(s8AlarmMessage('error')).toContain('加载失败')
+	})
+
+	it('keeps a real zero trend and says the week had no exceptions', () => {
+		expect(s8TrendNote([{ count: 0 }, { count: 0 }])).toBe('近 7 日无异常')
+		expect(s8TrendNote(null)).toBe('异常数据加载失败')
+	})
+
+	it('does not restore the deleted demo alarms', () => {
+		const raw = readFileSync(resolve(__dirname, './home.vue'), 'utf8')
+		const home = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/<!--[\s\S]*?-->/g, '')
+		expect(home).not.toContain('机台 A-04')
+		expect(home).toContain('s8AlarmMessage')
+		expect(home).toContain("s8AlarmState.value = 'error'")
+	})
+})

+ 18 - 1
Web/src/views/dashboard/home.vue

@@ -106,6 +106,7 @@
               <span class="s8-alarm-header__text">重点报警信息</span>
             </div>
             <div class="s8-alarm-list">
+              <div v-if="s8AlarmMessageText" class="s8-alarm-empty">{{ s8AlarmMessageText }}</div>
               <div
                 v-for="(log, idx) in displayS8Alarms"
                 :key="idx"
@@ -127,6 +128,7 @@
           <div class="s8-bottom-band">
             <div class="s8-chart-wrap">
               <div id="chart-s8-trend" class="chart-s8" />
+              <div v-if="s8TrendNoteText" class="s8-trend-note">{{ s8TrendNoteText }}</div>
             </div>
             <div class="s8-modules">
               <button
@@ -227,6 +229,7 @@ import {
   summarizeSmartOpsBaseQuery,
 } from '../aidop/kanban/utils/smartOpsBaseQuery'
 import HomeModuleKpiCard from './components/HomeModuleKpiCard.vue'
+import { s8AlarmMessage, s8TrendNote } from './components/homeKpiPresentation'
 import ChatBIFloat from '../aidop/chatbi/ChatBIFloat.vue'
 import DashboardFullscreenButton from '../aidop/kanban/components/DashboardFullscreenButton.vue'
 
@@ -283,6 +286,9 @@ const s8ModuleStatus = [
  * 即请求返回之前真实渲染出来,与随后的真实报警长得一模一样。初始值改为空。
  */
 const s8TopAlarmsAll = ref([])
+const s8AlarmState = ref('ok')
+const s8TrendNoteText = ref('')
+const s8AlarmMessageText = computed(() => s8AlarmMessage(s8AlarmState.value))
 
 const displayS8Alarms = computed(() => {
   const q = dashboardBaseQuery.value
@@ -313,9 +319,11 @@ async function loadS8Data() {
   const extra = baseQueryToApiParams(dashboardBaseQuery.value)
   try {
     const alerts = await fetchS8Alerts(1, extra)
-    s8TopAlarmsAll.value = alerts?.length ? alerts : []
+    s8TopAlarmsAll.value = Array.isArray(alerts) ? alerts : []
+    s8AlarmState.value = s8TopAlarmsAll.value.length ? 'ok' : 'empty'
   } catch {
     s8TopAlarmsAll.value = []
+    s8AlarmState.value = 'error'
   }
   await renderS8Trend(extra)
 }
@@ -349,6 +357,7 @@ async function renderS8Trend(extra) {
   const chart = ensureS8Chart()
   if (!chart) return
   const trend = await fetchS8HomeTrend(1, extra || {})
+  s8TrendNoteText.value = s8TrendNote(trend ? trend.points || [] : null)
   const labels = (trend?.points || []).map((p) => p.date)
   const values = (trend?.points || []).map((p) => Number(p.count || 0))
   const maxVal = Math.max(0, ...values)
@@ -930,6 +939,14 @@ onUnmounted(() => {
   color: #fecaca;
 }
 
+.s8-alarm-empty,
+.s8-trend-note {
+  color: #94a3b8;
+  font-size: 12px;
+  line-height: 1.4;
+  padding: 8px 4px;
+}
+
 .s8-alarm-list {
   flex: 1;
   min-height: 0;

+ 9 - 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.592</AssemblyVersion>
-    <FileVersion>1.0.592</FileVersion>
-    <Version>1.0.592</Version>
+    <AssemblyVersion>1.0.593</AssemblyVersion>
+    <FileVersion>1.0.593</FileVersion>
+    <Version>1.0.593</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -1018,6 +1018,12 @@
     <None Update="UpdateScripts\1.0.591.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.593.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.593.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 31 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.sql

@@ -0,0 +1,31 @@
+-- 首页指标新鲜度 SLA。月度口径按 code 写 45,实时写 1,其余首页指标写 7。
+SET @has_freshness_sla := (
+  SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA=DATABASE()
+    AND TABLE_NAME='ado_smart_ops_kpi_master'
+    AND COLUMN_NAME='FreshnessSlaDays'
+);
+SET @ddl := IF(
+  @has_freshness_sla=0,
+  'ALTER TABLE ado_smart_ops_kpi_master ADD COLUMN FreshnessSlaDays int NULL COMMENT ''首页数据新鲜度SLA(自然日)'' AFTER StatFrequency',
+  'SELECT 1'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=45, UpdatedAt=NOW()
+WHERE MetricCode IN
+  ('S5_L1_003','S5_L1_004','S6_L1_003','S7_L1_003','S9_L1_001');
+
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=1, UpdatedAt=NOW()
+WHERE IsHomePage=1 AND IsEnabled=1
+  AND StatFrequency='实时'
+  AND FreshnessSlaDays IS NULL;
+
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=7, UpdatedAt=NOW()
+WHERE IsHomePage=1 AND IsEnabled=1
+  AND FreshnessSlaDays IS NULL;

+ 14 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.verify.sql

@@ -0,0 +1,14 @@
+SELECT
+  (SELECT COUNT(*) FROM information_schema.TABLES
+   WHERE TABLE_SCHEMA=DATABASE()
+     AND TABLE_NAME='ado_smart_ops_kpi_master')=0
+  OR (
+    (SELECT COUNT(*) FROM information_schema.COLUMNS
+     WHERE TABLE_SCHEMA=DATABASE()
+       AND TABLE_NAME='ado_smart_ops_kpi_master'
+       AND COLUMN_NAME='FreshnessSlaDays')=1
+    AND
+    (SELECT COUNT(*) FROM ado_smart_ops_kpi_master
+     WHERE IsHomePage=1 AND IsEnabled=1
+       AND (FreshnessSlaDays IS NULL OR FreshnessSlaDays<=0))=0
+  );

+ 3 - 3
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/GridAndInboundGuardTests.cs

@@ -27,12 +27,12 @@ public class GridAndInboundGuardTests
     public void HomeGrid_ItemBizDate_DoesNotFallBackToModuleDate()
     {
         var source = Read(@"server\Plugins\Admin.NET.Plugin.AiDOP\Controllers\AidopKanbanController.Generic.cs");
-        Assert.Contains("v.biz_date AS BizDate", source);
-        Assert.Contains("filteredBundle == null ? v?.BizDate : null", source);
+        Assert.Contains("biz_date AS BizDate", source);
+        Assert.Contains("禁止拿未筛选的最后真实值补上", source);
         var items = source.IndexOf("items.Add(new", StringComparison.Ordinal);
         Assert.True(items > 0);
         var slice = source[items..source.IndexOf("return Ok", items, StringComparison.Ordinal)];
-        Assert.Contains("bizDate = itemBizDate", slice);
+        Assert.Contains("bizDate = card.BizDate", slice);
         Assert.DoesNotContain("bizDate = bizDate", slice);
     }
 

+ 211 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/HomeGridLastKnownValueContractTests.cs

@@ -0,0 +1,211 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+public class HomeGridLastKnownValueContractTests
+{
+    private static readonly DateTime Today = new(2026, 9, 29);
+
+    [Fact]
+    public void LatestNoData_ShowsPreviousOk_AsLastKnown()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(1, 3m, 10m, "OK", new DateTime(2026, 9, 23)),
+            Row(2, null, null, "NO_DATA", new DateTime(2026, 9, 28)),
+            7, Today);
+        Assert.Equal(3m, card.CurrentValue);
+        Assert.Equal(10m, card.TargetValue);
+        Assert.Equal("LAST_KNOWN_VALUE", card.DisplayState);
+        Assert.Equal(new DateTime(2026, 9, 23), card.BizDate);
+        Assert.Equal("NO_DATA", card.LatestResultStatus);
+        Assert.Equal(new DateTime(2026, 9, 28), card.LatestBizDate);
+    }
+
+    [Fact]
+    public void LatestOk_IsCurrentValue()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(5, 1m, 2m, "OK", Today),
+            Row(5, 1m, 2m, "OK", Today),
+            7, Today);
+        Assert.Equal("CURRENT_VALUE", card.DisplayState);
+        Assert.Equal(0, card.ValueAgeDays);
+        Assert.Equal("FRESH", card.FreshnessState);
+    }
+
+    [Fact]
+    public void NeverHadValue_IsNoData()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            null, Row(3, null, null, "NO_DATA", Today), 7, Today);
+        Assert.Null(card.CurrentValue);
+        Assert.Equal("NO_DATA", card.DisplayState);
+        Assert.Equal("NO_VALUE", card.FreshnessState);
+    }
+
+    [Fact]
+    public void SourceUnsupported_WithoutHistory_StaysUnsupported()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            null, Row(4, null, null, "SOURCE_UNSUPPORTED", Today), 7, Today);
+        Assert.Equal("SOURCE_UNSUPPORTED", card.DisplayState);
+    }
+
+    [Fact]
+    public void ValueAndTarget_ComeFromTheSameUsableRow()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(8, 100m, 80m, "OK", new DateTime(2026, 9, 23)),
+            Row(9, null, 1m, "NO_DATA", Today),
+            7, Today);
+        Assert.Equal(100m, card.CurrentValue);
+        Assert.Equal(80m, card.TargetValue);
+        Assert.NotEqual(1m, card.TargetValue);
+    }
+
+    [Fact]
+    public void BlankHistoricalStatus_IsNormalizedToOk()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(6, 4m, null, "", new DateTime(2026, 9, 1)),
+            Row(7, null, null, "NO_DATA", Today),
+            7, Today);
+        Assert.Equal("OK", card.ResultStatus);
+        Assert.Equal("NO_DATA", card.LatestResultStatus);
+    }
+
+    [Fact]
+    public void DailySla_EightDaysIsStale_SevenDaysIsFresh()
+    {
+        var fresh = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(-7)),
+            Row(1, 1m, null, "OK", Today.AddDays(-7)),
+            7, Today);
+        var stale = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(-8)),
+            Row(1, 1m, null, "OK", Today.AddDays(-8)),
+            7, Today);
+        Assert.Equal("FRESH", fresh.FreshnessState);
+        Assert.Equal("STALE", stale.FreshnessState);
+    }
+
+    [Fact]
+    public void MonthlySla_ThirtyDaysFresh_FortySixDaysStale()
+    {
+        var fresh = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(-30)),
+            Row(1, 1m, null, "OK", Today.AddDays(-30)),
+            45, Today);
+        var stale = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(-46)),
+            Row(1, 1m, null, "OK", Today.AddDays(-46)),
+            45, Today);
+        Assert.Equal("FRESH", fresh.FreshnessState);
+        Assert.Equal("STALE", stale.FreshnessState);
+    }
+
+    [Fact]
+    public void MissingSla_IsPolicyMissing_NotFresh()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today),
+            Row(1, 1m, null, "OK", Today),
+            null, Today);
+        Assert.Equal("POLICY_MISSING", card.FreshnessState);
+    }
+
+    [Fact]
+    public void FutureBizDate_ClampsAgeToZero()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(2)),
+            Row(1, 1m, null, "OK", Today.AddDays(2)),
+            7, Today);
+        Assert.True(card.FutureBizDate);
+        Assert.Equal(0, card.ValueAgeDays);
+        Assert.Equal("FRESH", card.FreshnessState);
+    }
+
+    [Fact]
+    public void Sql_UsesSameScope_AndDoesNotFallBackOutsideTheRange()
+    {
+        var source = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Controllers", "AidopKanbanController.Generic.cs"));
+        Assert.Contains("ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC", source);
+        Assert.Equal(2, Count(source, "AND (@ds IS NULL OR v.biz_date>=@ds)"));
+        Assert.Equal(2, Count(source, "AND (@de IS NULL OR v.biz_date<=@de)"));
+        Assert.Contains("v.metric_value IS NOT NULL", source);
+        Assert.Contains("v.result_status='OK' OR v.result_status IS NULL OR v.result_status=''", source);
+        Assert.Contains("v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m", source);
+        Assert.Contains("禁止拿未筛选的最后真实值补上", source);
+        Assert.DoesNotContain("ToDictionary", Slice(source, "latestAttemptSql", "public async Task<IActionResult> GetDetailKpisGeneric"));
+    }
+
+    [Fact]
+    public void FutureBizDateWarning_NamesOnlyTenantFactoryMetricAndDate()
+    {
+        var source = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Controllers", "AidopKanbanController.Generic.cs"));
+        var i = source.IndexOf("home grid future biz date", StringComparison.Ordinal);
+        Assert.True(i > 0);
+        var window = source[i..(i + 220)];
+        Assert.Contains("tenant={TenantId}", window);
+        Assert.Contains("factory={FactoryId}", window);
+        Assert.Contains("metric={MetricCode}", window);
+        Assert.Contains("bizDate={BizDate}", window);
+        Assert.DoesNotContain("resultReason", window, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Theory]
+    [InlineData("S5_L1_003", "天", 45)]
+    [InlineData("S5_L1_004", "天", 45)]
+    [InlineData("S6_L1_003", "天", 45)]
+    [InlineData("S7_L1_003", "天", 45)]
+    [InlineData("S9_L1_001", "天", 45)]
+    [InlineData("S8_L1_001", "实时", 1)]
+    [InlineData("S1_L1_001", "天", 7)]
+    public void SeedHelper_UsesMonthlyCodeThenRealtimeThenSeven(string code, string frequency, int expected)
+    {
+        Assert.Equal(expected, AidopKpiMasterSeed.ResolveFreshnessSlaDays(code, frequency));
+    }
+
+    private static HomeGridDisplayPolicy.Row Row(long id, decimal? value, decimal? target, string? status, DateTime bizDate) =>
+        new()
+        {
+            Id = id,
+            MetricValue = value,
+            TargetValue = target,
+            ResultStatus = status,
+            BizDate = bizDate,
+        };
+
+    private static string RepoRoot()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        return dir?.FullName ?? throw new InvalidOperationException("repo root not found");
+    }
+
+    private static string Slice(string src, string start, string end)
+    {
+        var i = src.IndexOf(start, StringComparison.Ordinal);
+        var j = src.IndexOf(end, i, StringComparison.Ordinal);
+        Assert.True(i >= 0 && j > i);
+        return src[i..j];
+    }
+
+    private static int Count(string src, string token)
+    {
+        var n = 0;
+        var i = 0;
+        while ((i = src.IndexOf(token, i, StringComparison.Ordinal)) >= 0)
+        {
+            n++;
+            i += token.Length;
+        }
+        return n;
+    }
+}

+ 122 - 28
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs

@@ -1,6 +1,7 @@
 using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.SmartOps;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers;
@@ -92,30 +93,56 @@ public partial class AidopKanbanController
 
         var bizDate = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l1_day", tenantId, factoryId, mc, filter.DateStart, filter.DateEnd);
 
-        const string valSql = """
-SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue,
-       v.result_status AS ResultStatus, v.result_reason AS ResultReason,
-       v.biz_date AS BizDate
-FROM ado_s9_kpi_value_l1_day v
-WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
-  AND v.biz_date=(
-      SELECT MAX(v2.biz_date)
-      FROM ado_s9_kpi_value_l1_day v2
-      WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
-        AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0
-        AND (@ds IS NULL OR v2.biz_date >= @ds)
-        AND (@de IS NULL OR v2.biz_date <= @de)
-  )
+        const string latestAttemptSql = """
+WITH ranked AS (
+  SELECT v.*,
+         ROW_NUMBER() OVER (
+           PARTITION BY v.metric_code
+           ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC
+         ) AS rn
+  FROM ado_s9_kpi_value_l1_day v
+  WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m
+    AND v.is_deleted=0
+    AND (@ds IS NULL OR v.biz_date>=@ds)
+    AND (@de IS NULL OR v.biz_date<=@de)
+)
+SELECT id AS Id, metric_code AS MetricCode, metric_value AS MetricValue,
+       target_value AS TargetValue, result_status AS ResultStatus,
+       result_reason AS ResultReason, biz_date AS BizDate, calc_time AS CalcTime
+FROM ranked
+WHERE rn=1
+""";
+        const string latestUsableSql = """
+WITH ranked AS (
+  SELECT v.*,
+         ROW_NUMBER() OVER (
+           PARTITION BY v.metric_code
+           ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC
+         ) AS rn
+  FROM ado_s9_kpi_value_l1_day v
+  WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m
+    AND v.is_deleted=0
+    AND (@ds IS NULL OR v.biz_date>=@ds)
+    AND (@de IS NULL OR v.biz_date<=@de)
+    AND v.metric_value IS NOT NULL
+    AND (v.result_status='OK' OR v.result_status IS NULL OR v.result_status='')
+)
+SELECT id AS Id, metric_code AS MetricCode, metric_value AS MetricValue,
+       target_value AS TargetValue, result_status AS ResultStatus,
+       result_reason AS ResultReason, biz_date AS BizDate, calc_time AS CalcTime
+FROM ranked
+WHERE rn=1
 """;
-        var vals = await _db.Ado.SqlQueryAsync<S4ValRow>(valSql, new
+        var valueArgs = new
         {
             t = tenantId,
             f = factoryId,
             m = mc,
             ds = filter.DateStart,
             de = filter.DateEnd
-        });
-        var valBy = vals.ToDictionary(x => x.MetricCode ?? "", StringComparer.OrdinalIgnoreCase);
+        };
+        var latestBy = IndexHomeGridValues(await _db.Ado.SqlQueryAsync<HomeGridValueRow>(latestAttemptSql, valueArgs));
+        var usableBy = IndexHomeGridValues(await _db.Ado.SqlQueryAsync<HomeGridValueRow>(latestUsableSql, valueArgs));
         DashboardValuesBundle? filteredBundle = null;
         if (filter.HasBusinessDimension)
             filteredBundle = await ResolveDashboardValuesBundleAsync(tenantId, factoryId, mc, filter, kpi);
@@ -128,23 +155,48 @@ WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
         foreach (var row in layout)
         {
             kpiBy.TryGetValue(row.MetricCode, out var k);
-            valBy.TryGetValue(row.MetricCode, out var v);
-            decimal? cur = v?.MetricValue;
-            var tgt = v?.TargetValue;
+            HomeGridDisplayPolicy.Card card;
             if (filteredBundle != null)
             {
+                // 业务维度旁路没有历史业务日。只返回本次筛选结果,禁止拿未筛选的最后真实值补上。
                 filteredBundle.Values.TryGetValue(row.MetricCode, out var fv);
-                cur = fv?.MetricValue;
-                tgt ??= fv?.TargetValue;
+                var filteredStatus = fv?.MetricValue == null
+                    ? (string.IsNullOrWhiteSpace(fv?.ResultStatus) ? "NO_DATA" : fv.ResultStatus)
+                    : (string.IsNullOrWhiteSpace(fv.ResultStatus) ? "OK" : fv.ResultStatus);
+                card = new HomeGridDisplayPolicy.Card
+                {
+                    CurrentValue = fv?.MetricValue,
+                    TargetValue = fv?.TargetValue,
+                    ResultStatus = filteredStatus,
+                    ResultReason = fv?.ResultReason,
+                    DisplayState = fv?.MetricValue == null
+                        ? (string.Equals(fv?.ResultStatus, "SOURCE_UNSUPPORTED", StringComparison.OrdinalIgnoreCase)
+                            ? "SOURCE_UNSUPPORTED" : "NO_DATA")
+                        : "CURRENT_VALUE",
+                    FreshnessState = "NO_VALUE",
+                    FreshnessSlaDays = k?.FreshnessSlaDays,
+                };
+            }
+            else
+            {
+                usableBy.TryGetValue(row.MetricCode, out var usable);
+                latestBy.TryGetValue(row.MetricCode, out var latest);
+                card = HomeGridDisplayPolicy.Decide(
+                    ToPolicyRow(usable), ToPolicyRow(latest), k?.FreshnessSlaDays, DateTime.Today);
+                if (card.FutureBizDate)
+                {
+                    _logger.LogWarning(
+                        "home grid future biz date tenant={TenantId} factory={FactoryId} metric={MetricCode} bizDate={BizDate}",
+                        tenantId, factoryId, row.MetricCode, card.BizDate?.ToString("yyyy-MM-dd"));
+                }
             }
+            var cur = card.CurrentValue;
+            var tgt = card.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);
-            // 筛选旁路(filteredBundle)目前给不出业务日。显式 null,禁止回落到模块级 bizDate,
-            // 否则一张 8 月的卡会被标成模块里最新的那一天。
-            DateTime? itemBizDate = filteredBundle == null ? v?.BizDate : null;
             items.Add(new
             {
                 rowId = row.RowId,
@@ -153,9 +205,16 @@ WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
                 unit = k?.Unit ?? "",
                 currentValue = cur,
                 targetValue = tgt,
-                resultStatus = v?.ResultStatus,
-                resultReason = v?.ResultReason,
-                bizDate = itemBizDate?.ToString("yyyy-MM-dd"),
+                resultStatus = card.ResultStatus,
+                resultReason = card.ResultReason,
+                bizDate = card.BizDate?.ToString("yyyy-MM-dd"),
+                latestResultStatus = card.LatestResultStatus,
+                latestResultReason = card.LatestResultReason,
+                latestBizDate = card.LatestBizDate?.ToString("yyyy-MM-dd"),
+                displayState = card.DisplayState,
+                freshnessState = card.FreshnessState,
+                valueAgeDays = card.ValueAgeDays,
+                freshnessSlaDays = card.FreshnessSlaDays,
                 gapValue = gap,
                 gapLabel = label,
                 gapArrow = arrow,
@@ -535,4 +594,39 @@ WHERE tenant_id=@t AND module_code=@m AND is_deleted=0
 
         return Ok(new { ok = true });
     }
+
+    private sealed class HomeGridValueRow
+    {
+        public long Id { get; set; }
+        public string? MetricCode { get; set; }
+        public decimal? MetricValue { get; set; }
+        public decimal? TargetValue { get; set; }
+        public string? ResultStatus { get; set; }
+        public string? ResultReason { get; set; }
+        public DateTime BizDate { get; set; }
+        public DateTime? CalcTime { get; set; }
+    }
+
+    private static Dictionary<string, HomeGridValueRow> IndexHomeGridValues(List<HomeGridValueRow> rows)
+    {
+        var map = new Dictionary<string, HomeGridValueRow>(StringComparer.OrdinalIgnoreCase);
+        foreach (var row in rows ?? [])
+        {
+            var code = row.MetricCode ?? "";
+            if (!map.TryAdd(code, row))
+                throw new InvalidOperationException($"home grid duplicated metric {code}");
+        }
+        return map;
+    }
+
+    private static HomeGridDisplayPolicy.Row? ToPolicyRow(HomeGridValueRow? row) =>
+        row == null ? null : new HomeGridDisplayPolicy.Row
+        {
+            Id = row.Id,
+            MetricValue = row.MetricValue,
+            TargetValue = row.TargetValue,
+            ResultStatus = row.ResultStatus,
+            ResultReason = row.ResultReason,
+            BizDate = row.BizDate,
+        };
 }

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

@@ -10,6 +10,7 @@ using Admin.NET.Plugin.AiDOP.Production;
 using Admin.NET.Plugin.AiDOP.Supply;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Admin.NET.Plugin.AiDOP.SmartOps;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers;
@@ -40,6 +41,7 @@ public partial class AidopKanbanController : ControllerBase
     private readonly SmartOpsTrustedScopeResolver _trustedScope;
     private readonly SmartDiagnosisEvidenceService _evidence;
     private readonly S8KpiSnapshotService _s8KpiSnapshot;
+    private readonly ILogger<AidopKanbanController> _logger;
 
     public AidopKanbanController(
         ISqlSugarClient db,
@@ -62,7 +64,8 @@ public partial class AidopKanbanController : ControllerBase
         ReportWorkMdpSyncService reportWorkMdpSyncService,
         SmartOpsTrustedScopeResolver trustedScope,
         SmartDiagnosisEvidenceService evidence,
-        S8KpiSnapshotService s8KpiSnapshot)
+        S8KpiSnapshotService s8KpiSnapshot,
+        ILogger<AidopKanbanController> logger)
     {
         _db = db;
         _s1MdpSyncTransformService = s1MdpSyncTransformService;
@@ -85,6 +88,7 @@ public partial class AidopKanbanController : ControllerBase
         _trustedScope = trustedScope;
         _evidence = evidence;
         _s8KpiSnapshot = s8KpiSnapshot;
+        _logger = logger;
     }
 
     /// <summary>

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiMaster.cs

@@ -51,6 +51,9 @@ public class AdoSmartOpsKpiMaster : ITenantIdFilter
     [SugarColumn(ColumnDescription = "统计频率", Length = 50, IsNullable = true)]
     public string? StatFrequency { get; set; }
 
+    [SugarColumn(ColumnDescription = "首页数据新鲜度SLA(自然日)", IsNullable = true)]
+    public int? FreshnessSlaDays { get; set; }
+
     [SugarColumn(ColumnDescription = "责任部门", Length = 200, IsNullable = true)]
     public string? Department { get; set; }
 

+ 14 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs

@@ -1130,6 +1130,19 @@ public static class AidopKpiMasterSeed
         return id;
     }
 
+    /// <summary>
+    /// 新建指标的默认新鲜度。月度口径按 code 固定 45 天,实时 1 天,其余 7 天。
+    /// 手工补的指标若漏填,接口返回 POLICY_MISSING,不会在这里被当成新鲜。
+    /// </summary>
+    public static int ResolveFreshnessSlaDays(string metricCode, string? statFrequency)
+    {
+        if (metricCode is "S5_L1_003" or "S5_L1_004" or "S6_L1_003" or "S7_L1_003" or "S9_L1_001")
+            return 45;
+        if (string.Equals(statFrequency, "实时", StringComparison.Ordinal))
+            return 1;
+        return 7;
+    }
+
     private static long Ins(ISqlSugarClient db, long tenantId, string code, string module, int level, long? parentId,
         string name, string unit, string direction, int sortNo, bool isHomePage,
         string desc, string formula, string calcRule,
@@ -1152,6 +1165,7 @@ public static class AidopKpiMasterSeed
             CalcRule = string.IsNullOrEmpty(calcRule) ? null : calcRule,
             DataSource = string.IsNullOrEmpty(dataSource) ? null : dataSource,
             StatFrequency = string.IsNullOrEmpty(statFreq) ? null : statFreq,
+            FreshnessSlaDays = ResolveFreshnessSlaDays(code, statFreq),
             Department = string.IsNullOrEmpty(dept) ? null : dept,
             DopFields = string.IsNullOrEmpty(dopFields) ? null : dopFields,
             Remark = string.IsNullOrEmpty(remark) ? null : remark,

+ 98 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/HomeGridDisplayPolicy.cs

@@ -0,0 +1,98 @@
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// 九宫格卡片同时保留最后可用值和最新一次计算。展示状态只看行主键,不看日期是否相同。
+/// </summary>
+public static class HomeGridDisplayPolicy
+{
+    public sealed class Row
+    {
+        public long Id { get; init; }
+        public decimal? MetricValue { get; init; }
+        public decimal? TargetValue { get; init; }
+        public string? ResultStatus { get; init; }
+        public string? ResultReason { get; init; }
+        public DateTime BizDate { get; init; }
+    }
+
+    public sealed class Card
+    {
+        public decimal? CurrentValue { get; init; }
+        public decimal? TargetValue { get; init; }
+        public string? ResultStatus { get; init; }
+        public string? ResultReason { get; init; }
+        public DateTime? BizDate { get; init; }
+        public string? LatestResultStatus { get; init; }
+        public string? LatestResultReason { get; init; }
+        public DateTime? LatestBizDate { get; init; }
+        public string DisplayState { get; init; } = "NO_DATA";
+        public string FreshnessState { get; init; } = "NO_VALUE";
+        public int? ValueAgeDays { get; init; }
+        public int? FreshnessSlaDays { get; init; }
+        public bool FutureBizDate { get; init; }
+    }
+
+    public static Card Decide(Row? usable, Row? latest, int? slaDays, DateTime today)
+    {
+        string displayState;
+        if (usable == null)
+        {
+            displayState = string.Equals(latest?.ResultStatus, "SOURCE_UNSUPPORTED", StringComparison.OrdinalIgnoreCase)
+                ? "SOURCE_UNSUPPORTED"
+                : "NO_DATA";
+        }
+        else if (latest != null && usable.Id == latest.Id)
+        {
+            displayState = "CURRENT_VALUE";
+        }
+        else
+        {
+            displayState = "LAST_KNOWN_VALUE";
+        }
+
+        var freshness = "NO_VALUE";
+        int? age = null;
+        var future = false;
+        if (usable != null)
+        {
+            if (slaDays is null or <= 0)
+            {
+                freshness = "POLICY_MISSING";
+            }
+            else
+            {
+                var rawAge = (today.Date - usable.BizDate.Date).Days;
+                if (rawAge < 0)
+                {
+                    future = true;
+                    age = 0;
+                }
+                else
+                {
+                    age = rawAge;
+                }
+                freshness = age > slaDays ? "STALE" : "FRESH";
+            }
+        }
+
+        return new Card
+        {
+            CurrentValue = usable?.MetricValue,
+            TargetValue = usable?.TargetValue,
+            ResultStatus = usable == null ? latest?.ResultStatus : NormalizeUsableStatus(usable.ResultStatus),
+            ResultReason = usable?.ResultReason,
+            BizDate = usable?.BizDate,
+            LatestResultStatus = latest?.ResultStatus,
+            LatestResultReason = latest?.ResultReason,
+            LatestBizDate = latest?.BizDate,
+            DisplayState = displayState,
+            FreshnessState = freshness,
+            ValueAgeDays = age,
+            FreshnessSlaDays = slaDays,
+            FutureBizDate = future,
+        };
+    }
+
+    private static string NormalizeUsableStatus(string? status) =>
+        string.IsNullOrWhiteSpace(status) ? "OK" : status;
+}