Преглед на файлове

Merge remote-tracking branch 'origin/master'

zengy преди 1 месец
родител
ревизия
087a81213f
променени са 12 файла, в които са добавени 1105 реда и са изтрити 19 реда
  1. 148 0
      docs/cockpit/overview-warning-settings-demo.html
  2. 13 15
      pipe-network-service/docs/domain/work-order-lifecycle.md
  3. 61 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsController.java
  4. 192 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsControllerTest.java
  5. 123 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsServiceTest.java
  6. 13 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFreshnessService.java
  7. 6 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsAdapter.java
  8. 211 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsResponse.java
  9. 215 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsService.java
  10. 69 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsSnapshot.java
  11. 18 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/EmptyCockpitOverviewWarningSettingsAdapter.java
  12. 36 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/cockpit/foundation/CockpitFreshnessServiceWiringTest.java

+ 148 - 0
docs/cockpit/overview-warning-settings-demo.html

@@ -0,0 +1,148 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+  <title>总体概览 / 预警设置 Demo</title>
+  <style>
+    :root { color-scheme:dark; --panel:#0c1a2c; --line:#22486a; --cyan:#45d6ff; --green:#49d17c; --red:#ff5f6d; --yellow:#ffd166; --muted:#8ba7bd; }
+    * { box-sizing:border-box; }
+    body { margin:0; font-family:"Microsoft YaHei",Arial,sans-serif; background:#071322; color:#eaf5ff; }
+    header { display:flex; justify-content:space-between; align-items:center; padding:18px 24px; border-bottom:1px solid var(--line); }
+    h1 { margin:0; font-size:22px; letter-spacing:2px; }
+    h1 small { margin-left:10px; font-size:12px; color:var(--muted); }
+    main { display:grid; grid-template-columns:minmax(320px,440px) 1fr; gap:18px; padding:20px; }
+    .panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; overflow:hidden; }
+    .panel h2 { margin:0; padding:12px 14px; font-size:15px; border-bottom:1px solid var(--line); }
+    .body { padding:14px; }
+    .seg { display:flex; gap:8px; margin-bottom:14px; }
+    button { cursor:pointer; padding:8px 14px; border:1px solid var(--line); background:#102c44; color:#eaf5ff; border-radius:4px; }
+    button.active { border-color:var(--cyan); background:#1a5f83; }
+    .cards { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; }
+    .card { background:#08182b; border:1px solid var(--line); border-radius:6px; padding:12px; }
+    .card b { display:block; font-size:25px; color:var(--cyan); }
+    .card span { color:var(--muted); font-size:12px; }
+    .meta { margin:14px 0 8px; font-size:13px; color:#d3e9f8; }
+    .states { display:grid; grid-template-columns:repeat(7,1fr); gap:6px; }
+    .states div { text-align:center; padding:8px 2px; background:#08182b; border:1px solid var(--line); border-radius:4px; }
+    .states b { display:block; }
+    ul { padding-left:20px; margin:8px 0; }
+    .bar { height:10px; background:#12324c; border-radius:5px; overflow:hidden; margin:5px 0 12px; }
+    .bar i { display:block; height:100%; background:linear-gradient(90deg,#1a8ec7,var(--cyan)); }
+    .map { position:relative; min-height:470px; background:#020b16; }
+    .grid { position:absolute; inset:0; opacity:.4; background-image:linear-gradient(#12324c 1px,transparent 1px),linear-gradient(90deg,#12324c 1px,transparent 1px); background-size:50px 50px; }
+    .point { position:absolute; width:25px; height:25px; margin:-12px; border-radius:50%; border:2px solid #ffffff22; display:flex; align-items:center; justify-content:center; cursor:pointer; }
+    .point.WARNING { background:#55431a; border-color:var(--yellow); }
+    .point.ALARM { background:#57232a; border-color:var(--red); }
+    .point small { font-size:10px; font-weight:700; }
+    .legend { padding:10px 14px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; }
+    .map-title { position:absolute; top:14px; left:14px; z-index:2; padding:7px 11px; background:#08182bcc; border:1px solid var(--cyan); border-radius:4px; }
+  </style>
+</head>
+<body>
+<header>
+  <h1>总体概览 / 预警设置 <small>合同固定名称 · 只读展示</small></h1>
+  <div>更新时间:<span id="updatedAt">2026-09-02 09:59:50</span></div>
+</header>
+<main>
+  <section class="panel">
+    <h2>预警 / 报警处置态势</h2>
+    <div class="body">
+      <div class="seg">
+        <button id="btn-WARNING" class="active">预警</button>
+        <button id="btn-ALARM">报警</button>
+      </div>
+      <div class="cards">
+        <div class="card"><b id="total">--</b><span>事件总数</span></div>
+        <div class="card"><b id="handled">--</b><span>已处置</span></div>
+        <div class="card"><b id="pending">--</b><span>未处置</span></div>
+        <div class="card"><b id="efficiency">--</b><span>处置效率</span></div>
+        <div class="card"><b id="unlinked">--</b><span>未关联工单</span></div>
+        <div class="card"><b id="invalid">--</b><span>异常工单状态</span></div>
+      </div>
+      <div class="meta">近两日趋势</div>
+      <div id="trend"></div>
+      <div class="meta">七态工单分布</div>
+      <div class="states" id="states"></div>
+    </div>
+  </section>
+  <section class="panel">
+    <h2>中央一张图联动</h2>
+    <div class="map">
+      <div class="grid"></div>
+      <div class="map-title" id="mapTitle">筛选:预警</div>
+    </div>
+    <div class="legend">黄点=预警事件;红点=报警事件;切换模式只过滤当前事件族,不合并 warning / alarm 统计。</div>
+  </section>
+</main>
+
+<script>
+  const data = {
+    updatedAt: '2026-09-02T09:59:50+08:00',
+    WARNING: {
+      total: 3, handled: 1, pending: 2, efficiency: '0.3333', numerator: 1, denominator: 3, unlinked: 0, invalid: 0,
+      trend: [{ bucket:'09-01', total:2, handled:1 }, { bucket:'09-02', total:1, handled:0 }],
+      states: { 1:1, 2:0, 3:0, 4:0, 5:1, 6:1, 7:0 },
+      points: [
+        { code:'WRN-001', x:20, y:27 }, { code:'WRN-002', x:35, y:45 }, { code:'WRN-003', x:48, y:30 }
+      ]
+    },
+    ALARM: {
+      total: 2, handled: 0, pending: 2, efficiency: '0.0000', numerator: 0, denominator: 2, unlinked: 0, invalid: 0,
+      trend: [{ bucket:'09-01', total:1, handled:0 }, { bucket:'09-02', total:1, handled:0 }],
+      states: { 1:0, 2:0, 3:0, 4:2, 5:0, 6:0, 7:0 },
+      points: [
+        { code:'ALM-001', x:64, y:36 }, { code:'ALM-002', x:82, y:58 }
+      ]
+    }
+  };
+  const labels = { 1:'待派单', 2:'待接单', 3:'已接单', 4:'处理中', 5:'待验收', 6:'已结案', 7:'已取消' };
+  let mode = 'WARNING';
+
+  function render() {
+    const item = data[mode];
+    document.getElementById('total').textContent = item.total;
+    document.getElementById('handled').textContent = item.handled;
+    document.getElementById('pending').textContent = item.pending;
+    document.getElementById('efficiency').textContent = item.efficiency + ' (' + item.numerator + '/' + item.denominator + ')';
+    document.getElementById('unlinked').textContent = item.unlinked;
+    document.getElementById('invalid').textContent = item.invalid;
+    document.getElementById('mapTitle').textContent = '筛选:' + (mode === 'WARNING' ? '预警' : '报警');
+    document.getElementById('btn-WARNING').classList.toggle('active', mode === 'WARNING');
+    document.getElementById('btn-ALARM').classList.toggle('active', mode === 'ALARM');
+
+    const trend = document.getElementById('trend');
+    trend.innerHTML = item.trend.map(point => `
+      <div>
+        <div style="display:flex;justify-content:space-between;font-size:12px;color:#8ba7bd;">
+          <span>${point.bucket}</span><span>${point.handled}/${point.total}</span>
+        </div>
+        <div class="bar"><i style="width:${point.total ? point.handled / point.total * 100 : 0}%"></i></div>
+      </div>
+    `).join('');
+
+    const states = document.getElementById('states');
+    states.innerHTML = Object.entries(labels).map(([code, label]) => `
+      <div><b>${item.states[code] || 0}</b><span>${label}</span></div>
+    `).join('');
+
+    const map = document.querySelector('.map');
+    map.querySelectorAll('.point').forEach(node => node.remove());
+    item.points.forEach(point => {
+      const node = document.createElement('div');
+      node.className = 'point ' + mode;
+      node.style.left = point.x + '%';
+      node.style.top = point.y + '%';
+      node.title = point.code;
+      node.innerHTML = '<small>' + point.code.slice(0, 3) + '</small>';
+      node.onclick = () => alert(point.code + ' / ' + mode);
+      map.appendChild(node);
+    });
+  }
+
+  document.getElementById('btn-WARNING').onclick = () => { mode = 'WARNING'; render(); };
+  document.getElementById('btn-ALARM').onclick = () => { mode = 'ALARM'; render(); };
+  render();
+</script>
+</body>
+</html>

+ 13 - 15
pipe-network-service/docs/domain/work-order-lifecycle.md

@@ -98,24 +98,22 @@ stateDiagram-v2
 - 同一设备同一报警类型,同一时间只能存在一张未结束(非 6/7 终态)的同类工单;不同报警类型可分别建单。
 - 一次维修同时消除其他异常属于“随同修复”,其他工单仍各自保留并完成验收,不静默结案。
 
-## 8. 目标编码与现网编码对照(迁移期)
+## 8. 目标编码与现网接口编码(迁移期)
 
-现网 `work_order.order_status` 仍是旧 1–9 编码;**对齐到目标 1–7 的代码与存量数据迁移另开任务**,迁移完成前按下表换算,业务讨论一律使用状态名。目标编码从 1 开始后,前六态与现网旧编码 1–6 **同值**,仅第 7 态语义不同。
+目标七态仍以 1–7 为权威。当前后端已出现两类并存契约:驾驶舱只读适配器输出目标 1–7;管理端 `BusinessWorkOrderService` 与供水设备详情仍读取数据库现网 0–7 编码,并保留 `6-延期中` 兼容分支。
 
-| 状态名 | 目标编码(权威) | 现网旧编码(迁移前) |
+| 状态名 | 目标编码(权威/驾驶舱只读) | 管理端现网编码 |
 | --- | --- | --- |
-| 待派单 | 1 | 1(同值) |
-| 待接单 | 2 | 2(同值) |
-| 已接单 | 3 | 3(同值) |
-| 处理中 | 4 | 4(同值) |
-| 待验收 | 5 | 5(同值) |
-| 已结案 | 6 | 6(同值) |
-| 已取消 | 7 | 旧实现无独立值(`cancel`/`reject` 都落到旧 7) |
-| ~~已驳回(废止)~~ | — | 7(旧终态,取消与驳回混用) |
-| ~~延期审核(废止)~~ | — | 8 |
-| ~~已延期(废止)~~ | — | 9 |
-
-迁移任务至少需要:把旧 7(“已驳回”/取消混用)重定义为独立“已取消(7)”、把“验收驳回”实现为 5→4 回退、下线延期状态(旧 8/9)与字段语义、同步工单操作日志类型与各前端/UE 状态字典;前六态编码与现网一致,无需整体重排。
+| 待派单 | 1 | 0 |
+| 待接单 | 2 | 1 |
+| 已接单 | 3 | 2 |
+| 处理中 | 4 | 3 |
+| 待验收 | 5 | 4 |
+| 已结案 | 6 | 5 |
+| 已取消 | 7 | 7 |
+| ~~延期中(废止目标状态)~~ | — | 6(迁移期兼容) |
+
+迁移期间调用方必须按接口契约解释编码并以状态名展示,不能跨接口复用裸数字。监测设备 GIS 只消费管理端关联工单摘要,不承担数据库状态迁移。后续迁移任务需把管理端状态编码对齐目标 1–7、删除延期兼容分支,并同步操作日志、前端和 UE 状态字典。
 
 ## 9. 本版明确删除/不纳入的内容
 

+ 61 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsController.java

@@ -0,0 +1,61 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsService;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitOverviewWarningSettingsController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitOverviewWarningSettingsService warningSettingsService;
+
+    public CockpitOverviewWarningSettingsController(
+            CockpitOverviewWarningSettingsService warningSettingsService) {
+        this.warningSettingsService = warningSettingsService;
+    }
+
+    @GetMapping("/api/cockpit/v1/overview/warning-settings")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> warningSettings(
+            @RequestParam(value = "mode", required = false) String mode,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(
+                warningSettingsService.load(resolveRequestId(requestId), mode)));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+        return error(HttpStatus.BAD_REQUEST, warningSettingsService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, warningSettingsService.serverError(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorPayload payload) {
+        return ResponseEntity.status(status)
+                .body(new AjaxResult(status.value(), payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+}

+ 192 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsControllerTest.java

@@ -0,0 +1,192 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsAdapter;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsService;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.lang.reflect.Method;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class CockpitOverviewWarningSettingsControllerTest {
+
+    private CockpitOverviewWarningSettingsAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitOverviewWarningSettingsAdapter.class);
+        CockpitOverviewWarningSettingsService service = new CockpitOverviewWarningSettingsService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitOverviewWarningSettingsController(service))
+                .build();
+    }
+
+    @Test
+    void warningSettingsUsesEnvelopeCamelCaseAndDefaultWarningMode() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .header("X-Request-Id", "warning-settings-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("warning-settings-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.displayName").value("预警设置"))
+                .andExpect(jsonPath("$.data.selectedMode").value("WARNING"))
+                .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
+                .andExpect(jsonPath("$.data.range.type").value("CURRENT_BASELINE"))
+                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
+                .andExpect(jsonPath("$.data.freshness.reason").value("READ_ONLY_AGGREGATE"))
+                .andExpect(jsonPath("$.data.updatedAt").value("2026-09-02T09:59:50+08:00"))
+                .andExpect(jsonPath("$.data.warning.total").value(3))
+                .andExpect(jsonPath("$.data.warning.handled").value(1))
+                .andExpect(jsonPath("$.data.warning.pending").value(2))
+                .andExpect(jsonPath("$.data.warning.handlingEfficiency.value").value("0.3333"))
+                .andExpect(jsonPath("$.data.warning.handlingEfficiency.state").value("AVAILABLE"))
+                .andExpect(jsonPath("$.data.warning.handlingEfficiency.numerator").value(1))
+                .andExpect(jsonPath("$.data.warning.handlingEfficiency.denominator").value(3))
+                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.value").value("0.5000"))
+                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.numerator").value(1))
+                .andExpect(jsonPath("$.data.warning.trend[0].handlingEfficiency.denominator").value(2))
+                .andExpect(jsonPath("$.data.warning.workOrderStates['1']").value(1))
+                .andExpect(jsonPath("$.data.warning.workOrderStates['5']").value(1))
+                .andExpect(jsonPath("$.data.warning.workOrderStates['6']").value(1))
+                .andExpect(jsonPath("$.data.warning.unlinkedEventCount").value(0))
+                .andExpect(jsonPath("$.data.alarm.total").value(2))
+                .andExpect(jsonPath("$.data.alarm.handled").value(0))
+                .andExpect(jsonPath("$.data.alarm.pending").value(2))
+                .andExpect(jsonPath("$.data.alarm.cancelledCount").value(0))
+                .andExpect(jsonPath("$.data.alarm.handlingEfficiency.value").value("0.0000"))
+                .andExpect(jsonPath("$.data.alarm.handlingEfficiency.numerator").value(0))
+                .andExpect(jsonPath("$.data.alarm.handlingEfficiency.denominator").value(2))
+                .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("WARNING"))
+                .andExpect(jsonPath("$.data.mapFilter.label").value("预警"));
+    }
+
+    @Test
+    void alarmModeSelectsAlarmMapFilterWithoutMergingFamilies() throws Exception {
+        when(adapter.load()).thenReturn(fixture());
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("mode", "ALARM")
+                        .header("X-Request-Id", "warning-settings-alarm"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.selectedMode").value("ALARM"))
+                .andExpect(jsonPath("$.data.warning.total").value(3))
+                .andExpect(jsonPath("$.data.alarm.total").value(2))
+                .andExpect(jsonPath("$.data.alarm.workOrderStates['4']").value(2))
+                .andExpect(jsonPath("$.data.mapFilter.eventFamily").value("ALARM"))
+                .andExpect(jsonPath("$.data.mapFilter.label").value("报警"));
+    }
+
+    @Test
+    void invalidModeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .queryParam("mode", "THRESHOLD"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("mode must be WARNING or ALARM"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/overview/warning-settings"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerExposesAuthenticatedReadOnlyMethodsOnly() {
+        assertTrue(Arrays.stream(CockpitOverviewWarningSettingsController.class.getDeclaredMethods())
+                .filter(method -> method.isAnnotationPresent(GetMapping.class)
+                        || method.isAnnotationPresent(PostMapping.class)
+                        || method.isAnnotationPresent(PutMapping.class)
+                        || method.isAnnotationPresent(PatchMapping.class)
+                        || method.isAnnotationPresent(DeleteMapping.class))
+                .allMatch(method -> method.isAnnotationPresent(GetMapping.class)
+                        && !method.isAnnotationPresent(PostMapping.class)
+                        && !method.isAnnotationPresent(PutMapping.class)
+                        && !method.isAnnotationPresent(PatchMapping.class)
+                        && !method.isAnnotationPresent(DeleteMapping.class)));
+
+        assertTrue(Arrays.stream(CockpitOverviewWarningSettingsController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(PreAuthorize.class)
+                        && "isAuthenticated()".equals(method.getAnnotation(
+                                PreAuthorize.class).value())));
+    }
+
+    private static void assertTrue(boolean condition) {
+        org.junit.jupiter.api.Assertions.assertTrue(condition);
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/overview/warning-settings")
+                        .header("X-Request-Id", "warning-settings-500"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱预警设置概览数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("warning-settings-500"))
+                .andExpect(jsonPath("$.data.serverTime").value("2026-09-02T10:00:00+08:00"));
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot fixture() {
+        return new CockpitOverviewWarningSettingsSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Arrays.asList(
+                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                                "WARNING", "WRN-001", "DEV-001", "WO-001", 1),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                                "WARNING", "WRN-002", "DEV-002", "WO-002", 5),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                                "WARNING", "WRN-003", "DEV-003", "WO-003", 6),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                                "ALARM", "ALM-001", "DEV-004", "WO-004", 4),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                                "ALARM", "ALM-002", "DEV-005", "WO-005", 4)),
+                Arrays.asList(
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "WARNING", "2026-09-01", 2, 1),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "WARNING", "2026-09-02", 1, 0),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "ALARM", "2026-09-01", 1, 0),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint(
+                                "ALARM", "2026-09-02", 1, 0)));
+    }
+}
+

+ 123 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitOverviewWarningSettingsServiceTest.java

@@ -0,0 +1,123 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsAdapter;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsResponse;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsService;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class CockpitOverviewWarningSettingsServiceTest {
+
+    private CockpitOverviewWarningSettingsAdapter adapter;
+    private CockpitOverviewWarningSettingsService service;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitOverviewWarningSettingsAdapter.class);
+        service = new CockpitOverviewWarningSettingsService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+    }
+
+    @Test
+    void currentSevenStateModelSeparatesHandledPendingAndUnlinkedEvents() {
+        when(adapter.load()).thenReturn(sevenStateFixture());
+
+        CockpitOverviewWarningSettingsResponse response = service.load("request-seven", "WARNING");
+
+        assertEquals(Integer.valueOf(10), response.getWarning().getTotal());
+        assertEquals(Integer.valueOf(2), response.getWarning().getHandled());
+        assertEquals(Integer.valueOf(5), response.getWarning().getPending());
+        assertEquals(Integer.valueOf(1), response.getWarning().getCancelledCount());
+        assertEquals("0.2000", response.getWarning().getHandlingEfficiency().getValue().toPlainString());
+        assertEquals(Integer.valueOf(2), response.getWarning().getHandlingEfficiency().getNumerator());
+        assertEquals(Integer.valueOf(10), response.getWarning().getHandlingEfficiency().getDenominator());
+        assertEquals(Integer.valueOf(1), response.getWarning().getUnlinkedEventCount());
+        assertEquals(Integer.valueOf(1), response.getWarning().getInvalidWorkOrderStateCount());
+        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(1));
+        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(2));
+        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(3));
+        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(4));
+        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(5));
+        assertEquals(Integer.valueOf(2), response.getWarning().getWorkOrderStates().get(6));
+        assertEquals(Integer.valueOf(1), response.getWarning().getWorkOrderStates().get(7));
+        assertEquals(7, response.getWarning().getWorkOrderStates().size());
+        assertTrue(response.getWarning().getWorkOrderStates().keySet().stream()
+                .allMatch(code -> code >= 1 && code <= 7));
+    }
+
+    @Test
+    void emptyFamilyUsesExplicitEmptyStateAndZeroDenominatorEfficiency() {
+        when(adapter.load()).thenReturn(emptyFixture());
+
+        CockpitOverviewWarningSettingsResponse response = service.load("request-empty", "ALARM");
+
+        assertEquals("EMPTY", response.getAlarm().getState());
+        assertEquals("NO_EVENTS", response.getAlarm().getStateReason());
+        assertEquals(Integer.valueOf(0), response.getAlarm().getTotal());
+        assertEquals(Integer.valueOf(0), response.getAlarm().getHandled());
+        assertEquals(Integer.valueOf(0), response.getAlarm().getPending());
+        assertEquals("0.0000", response.getAlarm().getHandlingEfficiency().getValue().toPlainString());
+        assertEquals(Integer.valueOf(0), response.getAlarm().getHandlingEfficiency().getNumerator());
+        assertEquals(Integer.valueOf(0), response.getAlarm().getHandlingEfficiency().getDenominator());
+        assertEquals("EMPTY", response.getAlarm().getHandlingEfficiency().getState());
+        assertEquals("NO_EVENTS", response.getAlarm().getHandlingEfficiency().getReason());
+    }
+
+    @Test
+    void missingSnapshotUsesExplicitEmptyFreshness() {
+        when(adapter.load()).thenReturn(null);
+
+        CockpitOverviewWarningSettingsResponse response = service.load("request-missing", "WARNING");
+
+        assertEquals("EMPTY", response.getFreshness().getStatus());
+        assertEquals("NO_SNAPSHOT", response.getFreshness().getReason());
+        assertTrue(response.getUpdatedAt() == null);
+        assertEquals("EMPTY", response.getWarning().getState());
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot sevenStateFixture() {
+        return new CockpitOverviewWarningSettingsSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Arrays.asList(
+                        event("WARNING", "WRN-001", "WO-001", 1),
+                        event("WARNING", "WRN-002", "WO-002", 2),
+                        event("WARNING", "WRN-003", "WO-003", 3),
+                        event("WARNING", "WRN-004", "WO-004", 4),
+                        event("WARNING", "WRN-005", "WO-005", 5),
+                        event("WARNING", "WRN-006", "WO-006", 6),
+                        event("WARNING", "WRN-007", "WO-007", 6),
+                        event("WARNING", "WRN-008", "WO-008", 7),
+                        new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                                "WARNING", "WRN-009", "DEV-009", null, null),
+                        event("WARNING", "WRN-010", "WO-010", 9),
+                        event("ALARM", "ALM-001", "WO-101", 4),
+                        event("ALARM", "ALM-002", "WO-102", 5)),
+                Collections.emptyList());
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot.SourceEvent event(
+            String family, String eventCode, String workOrderCode, Integer state) {
+        return new CockpitOverviewWarningSettingsSnapshot.SourceEvent(
+                family, eventCode, "DEV-" + eventCode, workOrderCode, state);
+    }
+
+    private static CockpitOverviewWarningSettingsSnapshot emptyFixture() {
+        return new CockpitOverviewWarningSettingsSnapshot(
+                "2026-09-02T09:59:50+08:00",
+                Collections.emptyList(),
+                Collections.emptyList());
+    }
+}

+ 13 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFreshnessService.java

@@ -1,5 +1,6 @@
 package com.zksy.cockpit.foundation;
 
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
 import java.time.Clock;
@@ -14,15 +15,23 @@ import java.util.Objects;
 public class CockpitFreshnessService {
     private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
     private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final int DEFAULT_CALIBRATION_PERIOD_SECONDS = 60;
     private static final int MAX_CALIBRATION_PERIOD_SECONDS = 3600;
 
     private final CockpitFreshnessAdapter adapter;
     private final Clock clock;
+    private final int defaultCalibrationPeriodSeconds;
 
-    public CockpitFreshnessService(CockpitFreshnessAdapter adapter, Clock clock) {
+    public CockpitFreshnessService(
+            CockpitFreshnessAdapter adapter,
+            Clock clock,
+            @Value("${cockpit.freshness.default-calibration-period-seconds:60}")
+            int defaultCalibrationPeriodSeconds) {
         this.adapter = adapter;
         this.clock = clock;
+        this.defaultCalibrationPeriodSeconds = defaultCalibrationPeriodSeconds;
+        if (defaultCalibrationPeriodSeconds < 1 || defaultCalibrationPeriodSeconds > MAX_CALIBRATION_PERIOD_SECONDS) {
+            throw new IllegalArgumentException("defaultCalibrationPeriodSeconds must be between 1 and 3600 seconds");
+        }
     }
 
     public CockpitFreshnessResponse load(String requestId, String deviceCode, Integer calibrationPeriodSeconds) {
@@ -107,7 +116,7 @@ public class CockpitFreshnessService {
 
     private int resolvePeriod(Integer calibrationPeriodSeconds) {
         int period = calibrationPeriodSeconds == null
-                ? DEFAULT_CALIBRATION_PERIOD_SECONDS
+                ? defaultCalibrationPeriodSeconds
                 : calibrationPeriodSeconds;
         if (period < 1 || period > MAX_CALIBRATION_PERIOD_SECONDS) {
             throw new CockpitInvalidRequestException("calibration period must be between 1 and 3600 seconds");
@@ -161,4 +170,4 @@ public class CockpitFreshnessService {
     private static boolean isBlank(String value) {
         return value == null || value.trim().isEmpty();
     }
-}
+}

+ 6 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsAdapter.java

@@ -0,0 +1,6 @@
+package com.zksy.cockpit.overview;
+
+public interface CockpitOverviewWarningSettingsAdapter {
+    CockpitOverviewWarningSettingsSnapshot load();
+}
+

+ 211 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsResponse.java

@@ -0,0 +1,211 @@
+package com.zksy.cockpit.overview;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+public class CockpitOverviewWarningSettingsResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String displayName;
+    private final String selectedMode;
+    private final String timezone;
+    private final Range range;
+    private final String updatedAt;
+    private final Freshness freshness;
+    private final FamilyMetrics warning;
+    private final FamilyMetrics alarm;
+    private final MapFilter mapFilter;
+
+    public CockpitOverviewWarningSettingsResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String displayName,
+            String selectedMode,
+            String timezone,
+            Range range,
+            String updatedAt,
+            Freshness freshness,
+            FamilyMetrics warning,
+            FamilyMetrics alarm,
+            MapFilter mapFilter) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.displayName = displayName;
+        this.selectedMode = selectedMode;
+        this.timezone = timezone;
+        this.range = range;
+        this.updatedAt = updatedAt;
+        this.freshness = freshness;
+        this.warning = warning;
+        this.alarm = alarm;
+        this.mapFilter = mapFilter;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getDisplayName() { return displayName; }
+    public String getSelectedMode() { return selectedMode; }
+    public String getTimezone() { return timezone; }
+    public Range getRange() { return range; }
+    public String getUpdatedAt() { return updatedAt; }
+    public Freshness getFreshness() { return freshness; }
+    public FamilyMetrics getWarning() { return warning; }
+    public FamilyMetrics getAlarm() { return alarm; }
+    public MapFilter getMapFilter() { return mapFilter; }
+
+    public static class Range {
+        private final String type;
+        private final String start;
+        private final String end;
+
+        public Range(String type, String start, String end) {
+            this.type = type;
+            this.start = start;
+            this.end = end;
+        }
+
+        public String getType() { return type; }
+        public String getStart() { return start; }
+        public String getEnd() { return end; }
+    }
+
+    public static class Freshness {
+        private final String status;
+        private final String reason;
+        private final String updatedAt;
+        private final String serverUpdatedAt;
+
+        public Freshness(
+                String status,
+                String reason,
+                String updatedAt,
+                String serverUpdatedAt) {
+            this.status = status;
+            this.reason = reason;
+            this.updatedAt = updatedAt;
+            this.serverUpdatedAt = serverUpdatedAt;
+        }
+
+        public String getStatus() { return status; }
+        public String getReason() { return reason; }
+        public String getUpdatedAt() { return updatedAt; }
+        public String getServerUpdatedAt() { return serverUpdatedAt; }
+    }
+
+    public static class FamilyMetrics {
+        private final String eventFamily;
+        private final String state;
+        private final String stateReason;
+        private final int total;
+        private final int handled;
+        private final int pending;
+        private final int cancelledCount;
+        private final int unlinkedEventCount;
+        private final int invalidWorkOrderStateCount;
+        private final Efficiency handlingEfficiency;
+        private final Map<Integer, Integer> workOrderStates;
+        private final List<TrendPoint> trend;
+
+        public FamilyMetrics(
+                String eventFamily,
+                String state,
+                String stateReason,
+                int total,
+                int handled,
+                int pending,
+                int cancelledCount,
+                int unlinkedEventCount,
+                int invalidWorkOrderStateCount,
+                Efficiency handlingEfficiency,
+                Map<Integer, Integer> workOrderStates,
+                List<TrendPoint> trend) {
+            this.eventFamily = eventFamily;
+            this.state = state;
+            this.stateReason = stateReason;
+            this.total = total;
+            this.handled = handled;
+            this.pending = pending;
+            this.cancelledCount = cancelledCount;
+            this.unlinkedEventCount = unlinkedEventCount;
+            this.invalidWorkOrderStateCount = invalidWorkOrderStateCount;
+            this.handlingEfficiency = handlingEfficiency;
+            this.workOrderStates = workOrderStates;
+            this.trend = trend;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public String getState() { return state; }
+        public String getStateReason() { return stateReason; }
+        public int getTotal() { return total; }
+        public int getHandled() { return handled; }
+        public int getPending() { return pending; }
+        public int getCancelledCount() { return cancelledCount; }
+        public int getUnlinkedEventCount() { return unlinkedEventCount; }
+        public int getInvalidWorkOrderStateCount() { return invalidWorkOrderStateCount; }
+        public Efficiency getHandlingEfficiency() { return handlingEfficiency; }
+        public Map<Integer, Integer> getWorkOrderStates() { return workOrderStates; }
+        public List<TrendPoint> getTrend() { return trend; }
+    }
+
+    public static class Efficiency {
+        private final int numerator;
+        private final int denominator;
+        private final BigDecimal value;
+        private final String state;
+        private final String reason;
+
+        public Efficiency(int numerator, int denominator, BigDecimal value, String state, String reason) {
+            this.numerator = numerator;
+            this.denominator = denominator;
+            this.value = value;
+            this.state = state;
+            this.reason = reason;
+        }
+
+        public int getNumerator() { return numerator; }
+        public int getDenominator() { return denominator; }
+        @JsonFormat(shape = JsonFormat.Shape.STRING)
+        public BigDecimal getValue() { return value; }
+        public String getState() { return state; }
+        public String getReason() { return reason; }
+    }
+
+    public static class TrendPoint {
+        private final String bucket;
+        private final int total;
+        private final int handled;
+        private final Efficiency handlingEfficiency;
+
+        public TrendPoint(String bucket, int total, int handled, Efficiency handlingEfficiency) {
+            this.bucket = bucket;
+            this.total = total;
+            this.handled = handled;
+            this.handlingEfficiency = handlingEfficiency;
+        }
+
+        public String getBucket() { return bucket; }
+        public int getTotal() { return total; }
+        public int getHandled() { return handled; }
+        public Efficiency getHandlingEfficiency() { return handlingEfficiency; }
+    }
+
+    public static class MapFilter {
+        private final String eventFamily;
+        private final String label;
+
+        public MapFilter(String eventFamily, String label) {
+            this.eventFamily = eventFamily;
+            this.label = label;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public String getLabel() { return label; }
+    }
+}

+ 215 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsService.java

@@ -0,0 +1,215 @@
+package com.zksy.cockpit.overview;
+
+import com.zksy.cockpit.workorder.CockpitWorkOrderState;
+import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class CockpitOverviewWarningSettingsService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final List<String> MODES =
+            Collections.unmodifiableList(Arrays.asList("WARNING", "ALARM"));
+    private static final List<Integer> WORK_ORDER_STATES =
+            CockpitWorkOrderStateDictionary.states().stream()
+                    .map(CockpitWorkOrderState::getCode)
+                    .collect(Collectors.toList());
+    private static final int HANDLED_STATE = 6;
+
+    private final CockpitOverviewWarningSettingsAdapter adapter;
+    private final Clock clock;
+
+    public CockpitOverviewWarningSettingsService(
+            CockpitOverviewWarningSettingsAdapter adapter,
+            Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitOverviewWarningSettingsResponse load(String requestId, String mode) {
+        String selectedMode = validateMode(mode);
+        CockpitOverviewWarningSettingsSnapshot snapshot = adapter.load();
+        boolean hasSnapshot = snapshot != null && !isBlank(snapshot.getUpdatedAt());
+        String updatedAt = hasSnapshot ? snapshot.getUpdatedAt() : null;
+        String freshnessStatus = hasSnapshot ? "CURRENT" : "EMPTY";
+        String freshnessReason = hasSnapshot ? "READ_ONLY_AGGREGATE" : "NO_SNAPSHOT";
+
+        return new CockpitOverviewWarningSettingsResponse(
+                requestId,
+                "v1",
+                true,
+                "预警设置",
+                selectedMode,
+                "Asia/Shanghai",
+                new CockpitOverviewWarningSettingsResponse.Range("CURRENT_BASELINE", null, null),
+                updatedAt,
+                new CockpitOverviewWarningSettingsResponse.Freshness(
+                        freshnessStatus,
+                        freshnessReason,
+                        updatedAt,
+                        businessTime()),
+                familyMetrics(snapshot, "WARNING"),
+                familyMetrics(snapshot, "ALARM"),
+                mapFilter(selectedMode));
+    }
+
+    public CockpitErrorPayload invalidRequest(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST,
+                "mode must be WARNING or ALARM",
+                requestId,
+                businessTime());
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return new CockpitErrorPayload(
+                CockpitErrorCategory.SERVER_ERROR,
+                "驾驶舱预警设置概览数据暂不可用",
+                requestId,
+                businessTime());
+    }
+
+    private String validateMode(String mode) {
+        if (isBlank(mode)) {
+            return "WARNING";
+        }
+        if (!MODES.contains(mode)) {
+            throw new CockpitInvalidRequestException("mode must be WARNING or ALARM");
+        }
+        return mode;
+    }
+
+    private CockpitOverviewWarningSettingsResponse.FamilyMetrics familyMetrics(
+            CockpitOverviewWarningSettingsSnapshot snapshot,
+            String eventFamily) {
+        List<CockpitOverviewWarningSettingsSnapshot.SourceEvent> sourceEvents =
+                snapshot == null || snapshot.getEvents() == null
+                        ? Collections.emptyList()
+                        : snapshot.getEvents();
+
+        Map<Integer, Integer> workOrderStates = new LinkedHashMap<>();
+        for (Integer state : WORK_ORDER_STATES) {
+            workOrderStates.put(state, 0);
+        }
+
+        int total = 0;
+        int handled = 0;
+        int pending = 0;
+        int cancelledCount = 0;
+        int unlinkedEventCount = 0;
+        int invalidWorkOrderStateCount = 0;
+        for (CockpitOverviewWarningSettingsSnapshot.SourceEvent event : sourceEvents) {
+            if (event == null || !eventFamily.equals(event.getEventFamily())) {
+                continue;
+            }
+            Integer state = event.getWorkOrderState();
+            if (state == null) {
+                total++;
+                unlinkedEventCount++;
+                continue;
+            }
+            if (!WORK_ORDER_STATES.contains(state)) {
+                total++;
+                invalidWorkOrderStateCount++;
+                continue;
+            }
+            total++;
+            workOrderStates.merge(state, 1, Integer::sum);
+            if (state < HANDLED_STATE) {
+                pending++;
+            } else if (state == HANDLED_STATE) {
+                handled++;
+            } else {
+                cancelledCount++;
+            }
+        }
+
+        int efficiencyDenominator = total;
+        BigDecimal value = efficiencyDenominator == 0
+                ? BigDecimal.ZERO.setScale(4)
+                : BigDecimal.valueOf(handled)
+                        .divide(BigDecimal.valueOf(efficiencyDenominator), 4, RoundingMode.HALF_UP);
+        CockpitOverviewWarningSettingsResponse.Efficiency efficiency =
+                new CockpitOverviewWarningSettingsResponse.Efficiency(
+                        handled,
+                        efficiencyDenominator,
+                        value,
+                        efficiencyDenominator == 0 ? "EMPTY" : "AVAILABLE",
+                        efficiencyDenominator == 0 ? "NO_EVENTS" : null);
+
+        return new CockpitOverviewWarningSettingsResponse.FamilyMetrics(
+                eventFamily,
+                total == 0 ? "EMPTY" : "READY",
+                total == 0 ? "NO_EVENTS" : "EVENTS_RESOLVED",
+                total,
+                handled,
+                pending,
+                cancelledCount,
+                unlinkedEventCount,
+                invalidWorkOrderStateCount,
+                efficiency,
+                workOrderStates,
+                trend(snapshot, eventFamily));
+    }
+
+    private List<CockpitOverviewWarningSettingsResponse.TrendPoint> trend(
+            CockpitOverviewWarningSettingsSnapshot snapshot,
+            String eventFamily) {
+        if (snapshot == null || snapshot.getTrendPoints() == null) {
+            return Collections.emptyList();
+        }
+        List<CockpitOverviewWarningSettingsResponse.TrendPoint> trend = new ArrayList<>();
+        for (CockpitOverviewWarningSettingsSnapshot.SourceTrendPoint point : snapshot.getTrendPoints()) {
+            if (point != null && eventFamily.equals(point.getEventFamily())) {
+                trend.add(new CockpitOverviewWarningSettingsResponse.TrendPoint(
+                        point.getBucket(), point.getTotal(), point.getHandled(),
+                        efficiency(point.getTotal(), point.getHandled())));
+            }
+        }
+        return trend;
+    }
+
+    private static CockpitOverviewWarningSettingsResponse.Efficiency efficiency(int total, int handled) {
+        BigDecimal value = total == 0
+                ? BigDecimal.ZERO.setScale(4)
+                : BigDecimal.valueOf(handled)
+                        .divide(BigDecimal.valueOf(total), 4, RoundingMode.HALF_UP);
+        return new CockpitOverviewWarningSettingsResponse.Efficiency(
+                handled,
+                total,
+                value,
+                total == 0 ? "EMPTY" : "AVAILABLE",
+                total == 0 ? "NO_EVENTS" : null);
+    }
+
+    private CockpitOverviewWarningSettingsResponse.MapFilter mapFilter(String selectedMode) {
+        return new CockpitOverviewWarningSettingsResponse.MapFilter(
+                selectedMode,
+                "WARNING".equals(selectedMode) ? "预警" : "报警");
+    }
+
+    private String businessTime() {
+        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                .format(clock.instant().atZone(BUSINESS_ZONE));
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}
+

+ 69 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/CockpitOverviewWarningSettingsSnapshot.java

@@ -0,0 +1,69 @@
+package com.zksy.cockpit.overview;
+
+import java.util.List;
+
+public class CockpitOverviewWarningSettingsSnapshot {
+    private final String updatedAt;
+    private final List<SourceEvent> events;
+    private final List<SourceTrendPoint> trendPoints;
+
+    public CockpitOverviewWarningSettingsSnapshot(
+            String updatedAt,
+            List<SourceEvent> events,
+            List<SourceTrendPoint> trendPoints) {
+        this.updatedAt = updatedAt;
+        this.events = events;
+        this.trendPoints = trendPoints;
+    }
+
+    public String getUpdatedAt() { return updatedAt; }
+    public List<SourceEvent> getEvents() { return events; }
+    public List<SourceTrendPoint> getTrendPoints() { return trendPoints; }
+
+    public static class SourceEvent {
+        private final String eventFamily;
+        private final String eventCode;
+        private final String deviceCode;
+        private final String workOrderCode;
+        private final Integer workOrderState;
+
+        public SourceEvent(
+                String eventFamily,
+                String eventCode,
+                String deviceCode,
+                String workOrderCode,
+                Integer workOrderState) {
+            this.eventFamily = eventFamily;
+            this.eventCode = eventCode;
+            this.deviceCode = deviceCode;
+            this.workOrderCode = workOrderCode;
+            this.workOrderState = workOrderState;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public String getEventCode() { return eventCode; }
+        public String getDeviceCode() { return deviceCode; }
+        public String getWorkOrderCode() { return workOrderCode; }
+        public Integer getWorkOrderState() { return workOrderState; }
+    }
+
+    public static class SourceTrendPoint {
+        private final String eventFamily;
+        private final String bucket;
+        private final int total;
+        private final int handled;
+
+        public SourceTrendPoint(String eventFamily, String bucket, int total, int handled) {
+            this.eventFamily = eventFamily;
+            this.bucket = bucket;
+            this.total = total;
+            this.handled = handled;
+        }
+
+        public String getEventFamily() { return eventFamily; }
+        public String getBucket() { return bucket; }
+        public int getTotal() { return total; }
+        public int getHandled() { return handled; }
+    }
+}
+

+ 18 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/overview/EmptyCockpitOverviewWarningSettingsAdapter.java

@@ -0,0 +1,18 @@
+package com.zksy.cockpit.overview;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+
+@Component
+public class EmptyCockpitOverviewWarningSettingsAdapter
+        implements CockpitOverviewWarningSettingsAdapter {
+    @Override
+    public CockpitOverviewWarningSettingsSnapshot load() {
+        return new CockpitOverviewWarningSettingsSnapshot(
+                null,
+                Collections.emptyList(),
+                Collections.emptyList());
+    }
+}
+

+ 36 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/cockpit/foundation/CockpitFreshnessServiceWiringTest.java

@@ -0,0 +1,36 @@
+package com.zksy.cockpit.foundation;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import java.time.Clock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+
+class CockpitFreshnessServiceWiringTest {
+    @Test
+    void springWiringUsesDocumentedDefaultCalibrationPeriod() {
+        new ApplicationContextRunner()
+                .withBean(CockpitFreshnessAdapter.class, () -> mock(CockpitFreshnessAdapter.class))
+                .withBean(Clock.class, () -> Clock.systemUTC())
+                .withBean(CockpitFreshnessService.class)
+                .run(context -> {
+                    assertThat(context).hasSingleBean(CockpitFreshnessService.class);
+                    CockpitFreshnessResponse response = context.getBean(CockpitFreshnessService.class)
+                            .load("freshness-wiring-001", "TC-PS-WS-202609020012", null);
+                    assertThat(response.getCalibrationPeriodSeconds()).isEqualTo(60);
+                });
+    }
+
+    @Test
+    void invalidConfiguredDefaultCalibrationPeriodFailsFast() {
+        assertThatThrownBy(() -> new CockpitFreshnessService(
+                mock(CockpitFreshnessAdapter.class),
+                Clock.systemUTC(),
+                0))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("between 1 and 3600 seconds");
+    }
+}