/** * 契约化 mock 样例(票 05):与真实接口**同一份字段名与嵌套形状**。 * * 依据(Q3=A):字段名与结构照抄后端演示载荷 —— * `E:\pipe-ner-server\docs\cockpit\*.html` 与 `research/panel-field-coverage.md` 的逐字段表。 * 因此适配器单测证明的是「转换吃得下真载荷」,而不是「吃得下我自己编的小样」。 * * 覆盖票 05 要求的五类样例(每个接口都能取到): * `success`(正常)/ `empty`(空数据)/ `degraded`(字段缺失)/ `failed`(失败)/ `stale`(过期)。 * 另有 `unauthorized`:票 05 验收第 6 条的 401 场景,只影响被请求的那个分区。 * * 票 12 追加两个场景(时间线是**工单面板内**的分区,成败与工单列表分开): * `work-order-timeline`:三张工单、各一条独立时间线(推进 / 回退 / 取消三种形状都齐); * `work-order-timeline-failed`:工单列表成功、**只有时间线分区失败**(分区独立)。 * * 数据是**精心构造的边界值**,不是随手填的:坐标覆盖 VALID/UNVERIFIED/INVALID/ZERO、 * 指标覆盖 0 与「无采样」、时间覆盖未结案(null)。 */ import { REQUEST_REASON } from '../domain/requestReason.js' // 票 20:工单状态字典**只从唯一源取**(后端 `WorkOrderStatusEnum` 0–7 的前端映射)。 // mock 不再自备一份副本 —— 那正是"后端改成 0–7 而 mock 还发 1–7"的成因。 import { WORK_ORDER_STATES } from '../domain/workOrder.js' // 票 61:历史分析的逐事件行与当前事件表共用同一套排序口径(不另写一份比较器)。 import { sortEventRows } from '../domain/eventList.js' const SNAPSHOT_AT = '2026-09-02T10:00:00+08:00' /** 票 42 的受控样例:承载有效坐标设备的点位与其设备编码清单(地图与详情必须一致)。 */ const DEVICE_LAYER_POINT = 'MP-001' const DEVICE_LAYER_CODES = ['EQ-001', 'EQ-002'] const DEVICE_LAYER_COORDS = [ { longitude: 110.3991, latitude: 28.4351 }, { longitude: 110.3975, latitude: 28.4338 }, ] const OVERLAP_DEVICE_LAYER_COORDS = [ { longitude: 110.3991, latitude: 28.4351 }, { longitude: 110.3991, latitude: 28.4351 }, ] /** * 票 76(2026-10-23 维护者裁定「删过期 mock」):旧的 `EQ-DG-*` K>10 样例已删除。 * 它经多轮修改后快照/详情/坐标三处各说各话(走查反复出现「太常跳到城南」「11 台不一致」), * 聚合测试统一改用下面的 `aggregate-lab`(`EQ-AG-*`,三处同源)。 */ /** * 票 76(mock 串点修正,2026-10-23 走查):MP-002「太常」的 K=11 样例改用**独立编码**。 * 此前它与专用场景点位(MP-001 城南)共用 `EQ-DG-*` —— 同一台设备同时挂在两个点位下, * 点设备按编码回联时命中另一点位,走查表现为「太常的设备跳到城南聚合」。 */ const OVERLAP_TAICHANG_CODES = Array.from({ length: 11 }, (_, index) => `EQ-TC-${String(index + 1).padStart(3, '0')}`) /** * 票 76(聚合测试数据重建,维护者 2026-10-23 要求):**干净的聚合实验样例**。 * * 旧样例(`EQ-DG-*`)经多轮修改已不可靠(快照/详情/坐标三处各说各话)。这份新样例只做一件事: * **4 台设备同坐标**(K=4 > 1 且 ≤ maxFan=10,不进降级),且 * 快照 `deviceCodes`、详情 `devices[]`、设备坐标 **三处同源**。 * 走查入口:`?scenario=aggregate-lab` → 点开 **MP-001(城南测试监测点)**。 */ const AGGREGATE_LAB_CODES = Array.from({ length: 4 }, (_, index) => `EQ-AG-${String(index + 1).padStart(3, '0')}`) const AGGREGATE_LAB_COORDS = Array.from({ length: 4 }, () => ({ longitude: 110.3989, latitude: 28.4349 })) /** * 票 47:**设备状态动效样例** —— 五态各一台(坐标各自错开,不触发重叠聚合), * 用来验证「只有报警/预警/工单三种需处置状态有动效,正常/离线完全静止」。 */ const EFFECTS_SAMPLE = [ { deviceCode: 'EQ-EF-ALARM', deviceName: '动效样例-报警设备', deviceStatus: 'ALARM' }, { deviceCode: 'EQ-EF-WARNING', deviceName: '动效样例-预警设备', deviceStatus: 'WARNING' }, { deviceCode: 'EQ-EF-WORK', deviceName: '动效样例-工单处置设备', deviceStatus: 'WORK_ORDER' }, { deviceCode: 'EQ-EF-NORMAL', deviceName: '动效样例-正常设备', deviceStatus: 'NORMAL' }, { deviceCode: 'EQ-EF-OFFLINE', deviceName: '动效样例-离线设备', deviceStatus: 'OFFLINE' }, ].map((item, index) => ({ ...item, // 错开约 80m,既不重叠也不会因为贴太近互相遮挡。 longitude: 110.3991 + index * 0.0008, latitude: 28.4351 + index * 0.0006, })) /** 票 47:**降级样例** —— 31 台报警设备(超过 `map.effects.abnormalLimit` 默认 30)。 */ const EFFECTS_DEGRADED_SAMPLE = Array.from({ length: 31 }, (_, index) => ({ deviceCode: `EQ-EF-D${String(index + 1).padStart(2, '0')}`, deviceName: `动效降级设备-${index + 1}`, deviceStatus: 'ALARM', longitude: 110.3991 + (index % 6) * 0.0008, latitude: 28.4351 + Math.floor(index / 6) * 0.0006, })) // ---------------------------------------------------------------- 地图摘要 const MAP_POINTS = [ { pointCode: 'MP-001', // 维护者指定的走查测试点位(原名「供水干管监测点」):坐标落在主场景区内, // 县域默认视角下**可见**,用于验收「点选 → 点位级定位 → 面板锚定」。 pointName: '城南测试监测点', pointStatus: 'NORMAL', statusReasons: [], // 维护者要求的主场景示例之一:**K=2 正常重叠**(两台设备同坐标,可逐台展开)。 deviceCodes: DEVICE_LAYER_CODES.slice(), // 票 18:`map-snapshot` 真的收 industry/district/deviceType 三个筛选入参(票 25 第 3 节的 // 冻结路径),mock 必须照此**真的过滤** —— 否则「筛选下发到地图」的断言会变成假绿 // (请求串里有参数、但样例永远回同一份数据)。这两个字段域对象不读,只服务过滤。 industry: 'WATER_SUPPLY', district: 0, districtName: '城南', longitude: 110.39832789395186, latitude: 28.434456975811777, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, }, { pointCode: 'MP-002', // 维护者指定的走查测试点位(原名「燃气调压站监测点」)。**必须与台账分布里的坐标一致** —— // 台账卡片点选后按编码回联的是本表(`map-snapshot`),两处不一致会让"点同一个点飞到两个地方"。 pointName: '太常测试监测点', pointStatus: 'ALARM', statusReasons: ['EQ-ASSOC-001 报警'], // 维护者要求的主场景示例之一:**K=11 降级**(超过 `map.overlap.maxFan` 默认 10)。 // 票 76:用太常专属编码(不再与城南场景点共用 `EQ-DG-*`,避免同一设备挂两个点位)。 deviceCodes: OVERLAP_TAICHANG_CODES.slice(), industry: 'GAS', district: 2, districtName: '太常', longitude: 110.36299369455823, latitude: 28.452196938060194, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, }, { pointCode: 'MP-003', pointName: '污水排口监测点', pointStatus: 'WARNING', statusReasons: ['EQ-FREE-003 预警'], deviceCodes: ['EQ-003'], industry: 'DRAINAGE', // 空区域被后端归一为字面量「未分区」——那是**真实取值**,筛选它能筛到东西。 district: null, districtName: '未分区', longitude: 110.143, latitude: 28.454, coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' }, }, ] const MAP_EQUIPMENT = [ { deviceCode: 'EQ-FREE-001', deviceName: '供水压力计-001', // 类型不可推导时后端给 null,且**默认不排除对象**(不筛时仍返回)。 industry: null, district: 0, districtName: '城南', // 票 14:未关联设备按**设备状态词表**参与状态筛选(决策 02 的 Q22)。 deviceStatus: 'NORMAL', longitude: 181, latitude: 28.455, coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' }, }, { deviceCode: 'EQ-FREE-004', deviceName: '零坐标设备-004', industry: null, district: null, districtName: '未分区', // 状态未发布(缺字段):落 UNKNOWN 桶——不猜成正常。 deviceStatus: null, longitude: 0, latitude: 0, coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' }, }, ] /** 地图摘要的演示分页(票 08):契约允许任意页大小,演示用 2 —— 三页翻完,看得出来是渐进加载。 */ const MAP_SNAPSHOT_PAGE_SIZE = 2 /** * 票 09 的点位状态样例:**后端六态各一个**,全部坐标合法(都该上图), * 外加一个后端新增、前端不认识的第九个状态 —— 它必须落「状态未发布」而不是被猜成正常。 * * 为什么要单独一份而不是改上面那三个点位:票 07/08 的走查把 `2,4,5` / 可上图 2 · 不上图 3 * 这些数写成了断言,改默认样例就会改掉那两张票的证据。所以状态演示走**独立场景** * (`__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'`),默认样例一个字节不动。 */ const STATUS_SAMPLE_POINTS = [ { pointCode: 'MP-ST-ALARM', pointName: '燃气调压站报警监测点', pointStatus: 'ALARM' }, { pointCode: 'MP-ST-WARNING', pointName: '污水排口预警监测点', pointStatus: 'WARNING' }, { pointCode: 'MP-ST-WORKORDER', pointName: '供水干管工单处置监测点', pointStatus: 'WORK_ORDER' }, { pointCode: 'MP-ST-NORMAL', pointName: '排水泵站正常监测点', pointStatus: 'NORMAL' }, { pointCode: 'MP-ST-OFFLINE', pointName: '窨井离线监测点', pointStatus: 'OFFLINE' }, { pointCode: 'MP-ST-EMPTY', pointName: '预留无设备监测点', pointStatus: 'EMPTY' }, // 后端将来新增的状态:前端不认识,必须如实说「状态未发布」,不得落到任何真实状态上。 { pointCode: 'MP-ST-UNKNOWN', pointName: '未知状态监测点', pointStatus: 'MAINTENANCE' }, ].map((item, index) => ({ ...item, statusReasons: [], // 沿经纬度铺开一点,免得标记叠在一起看不出形状差异。 longitude: 110.141 + index * 0.002, latitude: 28.452 + index * 0.001, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, })) /** 状态样例里的未关联设备(第六态「无关联设备」的落点,形状是十字)。 */ const STATUS_SAMPLE_EQUIPMENT = [ { deviceCode: 'EQ-ST-FREE', deviceName: '未关联设备-001', // 票 14:设备状态参与状态筛选(决策 02 的 Q22)——样例给一个真实态。 deviceStatus: 'WARNING', longitude: 110.155, latitude: 28.459, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, }, ] /** * 票 14:mock 侧的**设备级事实表**(决策 02 的 Q7/Q8——命中设备子集与点位 OR 归并的依据)。 * * 真实后端按「任一设备命中 industry/district/deviceType → 点位进入结果」归并, * 且点位响应的 `deviceCodes` 按当前筛选裁剪为**命中设备子集**(未筛=全部)。 * mock 要演示这条契约,就得知道每台关联设备的行业/区域/类型 —— * 这里登记的是**测试事实**,不是业务数据(正式口径归后端执行票 12+)。 * 未登记的设备不参与裁剪(宁可多给,不吞)。 */ const DEVICE_FACTS = { 'EQ-001': { industry: 'WATER_SUPPLY', district: 0, districtName: '城南', deviceType: 'WATER_SUPPLY_SENSOR' }, 'EQ-002': { industry: 'GAS', district: 2, districtName: '太常', deviceType: 'GAS_SENSOR' }, 'EQ-003': { industry: 'DRAINAGE', district: null, districtName: '未分区', deviceType: 'SEWAGE_ENV' }, } /** 设备自身状态的五个取值(`domain/deviceStatus.js` 的词表;`EMPTY` 是点位聚合态专有)。 */ const DEVICE_STATUS_CODES_MOCK = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE'] /** 后端六个点位状态(`domain/pointStatus.js` 的 `POINT_STATUS`)。 */ const POINT_STATUS_CODES_MOCK = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'] /** * 一个摘要对象的状态桶(票 14 的八桶匹配语义,决策 02 的 Q22/Q23): * · 监测点按**聚合状态**六态匹配;缺失或不在六态词典 → `UNKNOWN`(不猜成正常); * · 未关联设备按**设备状态词表**五态匹配(mock 载荷新增 `deviceStatus` 字段建模这条契约); * 缺失或认不出 → `UNKNOWN`(设备状态未发布)。 */ function statusBucketOf(item) { if ('pointCode' in item) { const status = item.pointStatus return status && POINT_STATUS_CODES_MOCK.includes(status) ? status : 'UNKNOWN' } const status = item.deviceStatus return status && DEVICE_STATUS_CODES_MOCK.includes(status) ? status : 'UNKNOWN' } /** * 多值状态筛选的匹配(决策 02 的 Q11/Q16——「无关联设备」桶是类别筛选,与状态正交): * · 空集合 → 全部; * · 只选 `UNASSOCIATED` → 只看未关联设备(监测点整类排除); * · `UNASSOCIATED` + 状态桶 → 只看命中状态的未关联设备; * · 只选状态桶 → 命中状态的监测点 + 命中状态的未关联设备(各自用各自的状态来源)。 */ function matchesStatusFilter(item, statusSet) { if (!statusSet.length) return true const onlyDevices = statusSet.includes('UNASSOCIATED') const statusKeys = statusSet.filter((key) => key !== 'UNASSOCIATED') if ('pointCode' in item) { if (onlyDevices) return false if (!statusKeys.length) return true return statusKeys.includes(statusBucketOf(item)) } if (statusKeys.length && !statusKeys.includes(statusBucketOf(item))) return false return true } /** district 空值归一(后端把空归一为字面量「未分区」——那是真实取值)。 */ function normalizedDistrict(value) { const raw = value === null || value === undefined ? '' : String(value).trim() return ['0', '1', '2'].includes(raw) ? raw : 'UNASSIGNED' } /** * 一台关联设备的**设备级**筛选命中(行业/区域/类型;未登记的设备不判负)。 * 票 37:`deviceType` 是后端码的**精确匹配**(`GAS_SENSOR`/`WATER_SUPPLY_SENSOR`/`SEWAGE_ENV`); * mock 样例的类型码全部来自 `DEVICE_FACTS`(与后端字典同形),不发明第二个类型域。 */ function deviceHitsFilters(code, filters) { const facts = DEVICE_FACTS[code] if (!facts) return true if (filters.industry && facts.industry !== filters.industry) return false if (filters.district && normalizedDistrict(facts.district) !== normalizedDistrict(filters.district)) return false if (filters.deviceType && facts.deviceType !== filters.deviceType) return false return true } /** * 票 14:按 `map-snapshot` 的筛选入参**真的过滤**,照抄决策 02 冻结的后端口径。 * * 为什么 mock 必须真的筛(而不是"收了参数照旧回全量"):`mapStore.applyFilters()` 会先清空 * 装载器再取第 1 页;若样例永远回同一份数据,「筛选真的下发到地图了吗」这条断言就只是 * 在看请求串 —— 参数拼错了、或者根本没传,画面都一样,是**假绿**。 * * 四条语义(决策 02 的 Answer 第一节 + 第二节): * · 空值归一:「未分区」是 district 空值归一后的**真实取值**,筛选它能筛到东西; * · **不带筛选时返回全部对象**(`industry` 为 null 也算数),保证"全城底图"不因类型缺失丢对象; * · 行业/范围/类型=**设备级匹配、点位 OR 归并**:点位自身命中或任一关联设备命中都进入结果; * · 状态=八桶多值 OR(六态 + UNKNOWN + UNASSOCIATED,见 `matchesStatusFilter`)。 */ function matchesMapFilters(item, filters) { const industry = (filters && filters.industry) || '' const district = (filters && filters.district) || '' const deviceType = (filters && filters.deviceType) || '' const statusRaw = (filters && filters.pointStatus) || [] const statusSet = Array.isArray(statusRaw) ? statusRaw : String(statusRaw).split(',').filter(Boolean) if (!matchesStatusFilter(item, statusSet)) return false if ('pointCode' in item) { // 点位:设备级 OR 归并——自身命中,或任一关联设备命中(未筛时该维度是"不判")。 if (industry && item.industry !== industry && !(item.deviceCodes || []).some((code) => deviceHitsFilters(code, { industry }))) return false if (district) { const own = normalizedDistrict(item.district) === normalizedDistrict(district) const viaDevice = (item.deviceCodes || []).some((code) => deviceHitsFilters(code, { district })) if (!own && !viaDevice) return false } if (deviceType) { const viaDevice = (item.deviceCodes || []).some((code) => deviceHitsFilters(code, { deviceType })) if (!viaDevice) return false } return true } // 未关联设备:按自身属性直接匹配。 if (industry && item.industry !== industry) return false if (district && normalizedDistrict(item.district) !== normalizedDistrict(district)) return false // `deviceType` 不可推导的对象在带该筛选时被排除(后端同款)。 if (deviceType) return false return true } /** * 票 14:命中设备子集(决策 02 的 Q8——点位响应的 `deviceCodes` 按当前筛选裁剪,未筛=全部)。 * 只在**设备级**筛选(行业/范围/类型)生效时裁剪;未登记的设备保留(不吞)。 */ function cropToDeviceHits(point, filters) { const hasDeviceLevel = !!(filters && (filters.industry || filters.district || filters.deviceType)) const codes = Array.isArray(point.deviceCodes) ? point.deviceCodes : [] if (!hasDeviceLevel) return codes return codes.filter((code) => deviceHitsFilters(code, filters)) } /** * 票 14:八桶**全局未筛**计数(决策 02 的第三节——状态栏与饼图共用同一份,筛选时不变)。 * 六态与 UNKNOWN 数**监测点**;UNASSOCIATED 数**未关联设备**(类别桶的计数)。 * 判据是完整集合(filter 之前),与分页/筛选无关。 */ function globalStatusDistribution(points, equipment) { const counts = { ALARM: 0, WARNING: 0, WORK_ORDER: 0, NORMAL: 0, OFFLINE: 0, EMPTY: 0, UNKNOWN: 0, UNASSOCIATED: 0 } for (const point of points) counts[statusBucketOf(point)] += 1 for (const device of equipment) counts.UNASSOCIATED += 1 return Object.keys(counts).map((status) => ({ status, count: counts[status] })) } /** * 按后端**真实语义**造一页地图摘要(票 08 的分页 + 票 18 的筛选 + 票 14 的契约)。 * * 照抄 `CockpitMapSnapshotService.load()` 的三条行为,而不是造一份"理想载荷": * 1. 分页窗口是「监测点 + 未关联设备」这**一条总序列**:先排满监测点,再排未关联设备 * (所以后一页可能整页都是设备,或两类混在一页); * 2. 第 2 页起**必须**带 `snapshotAt`,且与当前快照一致,否则抛 `SNAPSHOT_EXPIRED`; * 3. `complete` 只在「本页末尾已到总数」时为真。 * * 票 07 起另有一条**必须照抄**:响应带 `coordinateValidation` **全量**汇总 * (后端用整份 points/equipment 算完再切片分页,所以它不随页码变化)。 * * 票 14 追加两条(决策 02 冻结的契约): * · 点位响应的 `deviceCodes` 按当前筛选裁剪为**命中设备子集**(未筛=全部); * · 响应带八桶**全局未筛**计数 `pointStatusDistribution`(状态栏与饼图共用,筛选时不变)。 * * @param {object} options * @param {number} [options.page] 页码(1 起) * @param {number} [options.pageSize] 每页条数 * @param {string} [options.snapshotAt] 客户端带的快照标记(第 1 页不带) * @param {string} [options.currentSnapshot] 本次请求后端当前快照(固定值,见 `planMapSnapshot`) * @param {Array} [options.points] 覆盖监测点集合(票 09 的状态样例用) * @param {Array} [options.equipment] 覆盖未关联设备集合 */ function mapSnapshotPage({ page = 1, pageSize = MAP_SNAPSHOT_PAGE_SIZE, snapshotAt = '', currentSnapshot = SNAPSHOT_AT, points = MAP_POINTS, equipment = MAP_EQUIPMENT, filters = null, echoFilters = null, } = {}) { if (page > 1 && !snapshotAt) { const error = new Error('mock: 第 2 页起必须带 snapshotAt') error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED throw error } if (snapshotAt && snapshotAt !== currentSnapshot) { const error = new Error('mock: 快照标记与当前快照不一致') error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED throw error } // 票 18:筛选**先于分页**(后端也是先过滤再切片),且 `coordinateValidation` 汇总是 // 「当前筛选范围内的全量口径」——所以它跟着筛选变,不跟着页码变。 const selected = filters ? [...points, ...equipment].filter((item) => matchesMapFilters(item, filters)) : [...points, ...equipment] const from = Math.max(0, (page - 1) * pageSize) const to = from + pageSize const slice = selected.slice(from, to) // 票 14:本页的点位带上**命中设备子集**(未筛=全部;决策 02 的 Q8)。 const slicedPoints = slice .filter((item) => 'pointCode' in item) .map((point) => ({ ...point, deviceCodes: cropToDeviceHits(point, filters) })) return { page, pageSize, snapshotAt: currentSnapshot, batchUpdatedAt: currentSnapshot, total: selected.length, complete: to >= selected.length, monitoringPoints: slicedPoints, unassociatedEquipment: slice.filter((item) => 'deviceCode' in item), // 编码回显来自**过滤后全量**(分页前),与后端同口径。 mapFilter: mapFilterEcho(echoFilters || filters || {}, selected.filter((item) => 'pointCode' in item), selected.filter((item) => 'deviceCode' in item)), // 全量口径:与 `CockpitMapSnapshotService.coordinateValidation(points, equipment)` 一致。 coordinateValidation: coordinateValidationSummary(selected), // 票 14:八桶全局未筛计数(完整集合口径,筛选时不变;状态栏与饼图共用)。 pointStatusDistribution: globalStatusDistribution(points, equipment), } } /** * 坐标校验汇总(票 07):照抄后端 `CockpitCoordinateValidationSummary` 的字段名与判据。 * * 后端的分法是**三桶**:`renderable` 优先,其余里 `state === 'INVALID'` 记非法, * 剩下的一律记「未校验」(`CockpitMapSnapshotService` 第 205-231 行)。 * 这里照抄同一个分法 —— 换一套算法就会让前端显示的数与后端对不上。 */ function coordinateValidationSummary(objects) { let renderableCount = 0 let invalidCount = 0 let unverifiedCount = 0 for (const item of objects) { const validation = item.coordinateValidation || {} if (validation.renderable === true) renderableCount += 1 else if (validation.state === 'INVALID') invalidCount += 1 else unverifiedCount += 1 } return { crs: 'WGS84', totalObjectCount: objects.length, renderableCount, invalidCount, unverifiedCount, } } /** * 从请求路径的查询串里取一个整数参数。 * 走查与演示要能现场改页大小(`window.__COCKPIT_MOCK_MAP_PAGE_SIZE__`), * 所以分页参数必须真的从请求上读,而不是写死一份。 */ function queryInteger(path, key, fallback) { const query = String(path || '').split('?')[1] || '' const value = new URLSearchParams(query).get(key) const parsed = Number(value) return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback } /** 查询串里的快照标记(第 1 页不带)。 */ function querySnapshot(path) { const query = String(path || '').split('?')[1] || '' return new URLSearchParams(query).get('snapshotAt') || '' } /** 查询串里的一个字符串参数(缺失回空串;票 18 的筛选回显与过滤都读它)。 */ function queryValue(path, key) { const query = String(path || '').split('?')[1] || '' return new URLSearchParams(query).get(key) || '' } /** 请求查询串 → 普通对象(票 18:筛选与分页在 mock 上要真的按请求回)。 */ function queryParams(path) { const query = String(path || '').split('?')[1] || '' const result = {} for (const [key, value] of new URLSearchParams(query)) result[key] = value return result } /** * 地图摘要样例(票 08):**按后端真实分页语义**造,而不是只给一页。 * * 场景(`overrides` 由走查现场给,见 `mocks/index.js`): * · `success` —— 正常分页,后端当前快照固定; * · `empty` —— 后端说总数 0(不是「这一页恰好没有」); * · `degraded` —— 保持票 05 的字段缺失样例(单页、`complete: false`); * · `stale` —— **取完第 1 页后后端换了快照**(`expireAfterPage`),第 2 页带着旧标记回来即失效; * · `failed` —— 由统一出口抛出,不在这里给载荷。 * * @param {string} scenario 五类样例 * @param {string} path 请求路径(分页参数在这里) * @param {object} [overrides] `{ pageSize, snapshotAt, expireAfterPage }` */ function mapSnapshotPayload(scenario, path, overrides = {}) { const pageSize = overrides.pageSize || MAP_SNAPSHOT_PAGE_SIZE const page = queryInteger(path, 'page', 1) const baseSnapshot = overrides.snapshotAt || SNAPSHOT_AT // 票 18:三个筛选入参从**请求查询串**读(不是从 overrides)—— // 「筛选真的下发了」这件事必须由请求本身证明,不能靠现场注入的开关。 // 票 14:`pointStatus` 多值(逗号分隔,OR)也在这里解析。 const filters = { industry: queryValue(path, 'industry'), district: queryValue(path, 'district'), deviceType: queryValue(path, 'deviceType'), pointStatus: queryValue(path, 'pointStatus') ? queryValue(path, 'pointStatus').split(',') : [], } const filtered = !!(filters.industry || filters.district || filters.deviceType || filters.pointStatus.length) if (scenario === 'empty') { return { page, pageSize, snapshotAt: baseSnapshot, batchUpdatedAt: baseSnapshot, total: 0, complete: true, monitoringPoints: [], unassociatedEquipment: [], mapFilter: mapFilterEcho(filters, [], []), // 真·空快照:全量口径各项都是 0(这里 0 是真实值,不是缺失)。 coordinateValidation: coordinateValidationSummary([]), // 空 = 全局八桶也全 0(这是真实值,不是缺失)。 pointStatusDistribution: globalStatusDistribution([], []), } } if (scenario === 'degraded') { const degraded = [{ pointCode: 'MP-DEGRADED', pointName: '字段缺失点位', pointStatus: 'NORMAL', statusReasons: [] }] return { page: 1, pageSize: 500, snapshotAt: baseSnapshot, complete: false, monitoringPoints: degraded, unassociatedEquipment: [], mapFilter: mapFilterEcho(filters, degraded, []), // 后端没给 coordinateValidation 的点位:仍占一个位置,落在「未校验」桶里。 coordinateValidation: coordinateValidationSummary(degraded), // 票 14:降级样例仍带全局八桶(未筛口径:字段缺失点位落 UNKNOWN 桶——不猜成正常)。 pointStatusDistribution: globalStatusDistribution(degraded, []), } } // 票 09:后端六态 + 未关联设备 + 一个前端不认识的状态,全部可上图(状态演示用)。 // 这个样例**不带筛选字段**,所以带筛选请求时会被全部排除 —— 与后端"推导不出行业即排除"一致。 if (scenario === 'point-statuses') { return mapSnapshotPage({ page, pageSize, snapshotAt: querySnapshot(path), currentSnapshot: baseSnapshot, points: STATUS_SAMPLE_POINTS, equipment: STATUS_SAMPLE_EQUIPMENT, filters: filtered ? filters : null, }) } // 票 42/45/47:受控有效坐标样例 —— 地图侧的点位 `deviceCodes` 必须与详情 `devices[]` **一致**, // 否则第二台设备会被判「与当前快照不一致」而禁止上图(那是另一条要验的语义,不该混在这个基线里)。 const deviceLayerCodes = { 'device-layer': DEVICE_LAYER_CODES, 'device-layer-overlap': DEVICE_LAYER_CODES, // 票 76:全新的干净聚合样例(4 台同坐标,快照/详情/坐标同源)。 'aggregate-lab': AGGREGATE_LAB_CODES, // 票 47:动效样例与降级样例的设备编码(与详情一一对应)。 'device-effects': EFFECTS_SAMPLE.map((item) => item.deviceCode), 'device-effects-degraded': EFFECTS_DEGRADED_SAMPLE.map((item) => item.deviceCode), }[scenario] if (deviceLayerCodes) { // 票 76(2026-10-23 修正):不再清空其它点位的编码 —— 详情已按 `requestedCode` 回各自原生载荷 // (`nativePointDetail`),清空反而让「快照 0 台 / 详情 11 台」再次不一致、设备点不上图。 const points = MAP_POINTS.map((item) => item.pointCode === DEVICE_LAYER_POINT ? { ...item, deviceCodes: deviceLayerCodes.slice() } : item, ) return mapSnapshotPage({ page, pageSize, snapshotAt: querySnapshot(path), currentSnapshot: baseSnapshot, points, filters: filtered ? filters : null, }) } // 过期样例:第 1 页照常给旧快照,之后的请求后端已经换成新快照 —— 客户端带着旧标记回来即失效。 const expired = scenario === 'stale' && Number.isInteger(overrides.expireAfterPage) && page > overrides.expireAfterPage const currentSnapshot = expired ? '2026-09-02T10:30:00+08:00' : baseSnapshot return mapSnapshotPage({ page, pageSize, snapshotAt: querySnapshot(path), currentSnapshot, // 不带筛选时传 null(=返回全部对象,含 industry 为 null 的设备)。 filters: filtered ? filters : null, // 筛选生效时回显的编码来自**过滤后全量**(分页前),与后端 `mapFilter` 口径一致。 echoFilters: filters, }) } /** `mapFilter` 回显(后端在筛选生效时回显实际生效范围与过滤后全量编码;票 14 增多值状态回显)。 */ function mapFilterEcho(filters, points, equipment) { const statusRaw = (filters && filters.pointStatus) || [] const statusText = Array.isArray(statusRaw) ? statusRaw.join(',') : String(statusRaw || '') return { industry: filters.industry || null, district: filters.district && filters.district !== 'UNASSIGNED' ? Number(filters.district) : null, districtFilter: !filters.district ? 'NONE' : filters.district === 'UNASSIGNED' ? 'UNASSIGNED' : 'ASSIGNED', deviceType: filters.deviceType || null, pointStatus: statusText || null, pointCodes: points.map((item) => item.pointCode).filter(Boolean), deviceCodes: equipment.map((item) => item.deviceCode).filter(Boolean), } } // ---------------------------------------------------------------- 监测点详情 /** * 监测点详情的样例载荷(票 05 的默认 + 四类异常样例)。 * * 票 10 追加 `point-detail-order`:设备卡片刻意**逆状态优先级**排列 * (正常 → 报警 → 预警),后端的返回顺序 ≠ 前端按优先级排出来的顺序 —— * 界面若自作主张排序,真机走查里那条「卡片顺序 = 后端顺序」就会当场露馅。 * 默认样例一个字节未动(票 05 的断言依赖它)。 */ /** * 票 45:**K>10 降级载荷**(11 台设备同坐标、状态混合)。 * * 主场景(MP-002 太常)与 `aggregate-lab`(MP-001 城南)共用这一份构造器 —— * 两处各写一份必然漂移(本仓反复栽在"同一份样例登记两处"上)。 */ function degradedOverlapPointDetail({ code, name, codes = [], coords = [], statuses = ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL'] }) { const devices = codes.map((code_, index) => { const deviceStatus = statuses[index] return { deviceCode: code_, deviceName: `重叠降级设备-${index + 1}`, deviceTypeName: ['压力计', '流量计', '液位计'][index % 3], deviceStatus, statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[deviceStatus], statusReasons: deviceStatus === 'ALARM' ? ['压力超高'] : deviceStatus === 'WARNING' ? ['液位偏高'] : [], statusTime: '2026-09-02T09:59:00+08:00', ...deviceCoordFields(coords, index), } }) return { requestId: 'mock-point-detail', apiVersion: 'v1', readOnly: true, monitoringPointCode: code, monitoringPointName: name, pointStatus: 'ALARM', statusLabel: '报警', statusReasons: [{ status: 'ALARM', deviceCode: codes[0], reason: '压力超高' }], deviceStatistics: { totalDeviceCount: devices.length, alarmDeviceCount: statuses.filter((status) => status === 'ALARM').length, warningDeviceCount: statuses.filter((status) => status === 'WARNING').length, workOrderDeviceCount: statuses.filter((status) => status === 'WORK_ORDER').length, normalDeviceCount: statuses.filter((status) => status === 'NORMAL').length, offlineDeviceCount: statuses.filter((status) => status === 'OFFLINE').length, }, devices, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({ status: item, label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item], priority: index + 1, })), } } /** * 票 45:**K=2 正常重叠载荷**(两台设备同坐标,可逐台展开)。 * 主场景(MP-001)与专用场景 `device-layer-overlap` 共用。 */ function overlapPointDetail({ code, name, status = 'NORMAL' }) { return pointDetail({ code, name, status, deviceCoord: OVERLAP_DEVICE_LAYER_COORDS }) } /** * 票 47:按给定设备清单造详情载荷(每台都带**设备自身**的有效坐标 ⇒ `renderable=true`)。 * * 决策票 07 的可断言口径要求:动效只能在**受控可上图样例**上验证 * (真实环境仍是清单-only 时不得从纯逻辑断言推断现场特效已生效)。 */ function effectsPointDetail({ code, name, samples, pointStatus = 'ALARM' }) { const counts = { ALARM: 0, WARNING: 0, WORK_ORDER: 0, NORMAL: 0, OFFLINE: 0 } const devices = samples.map((item, index) => { const deviceStatus = item.deviceStatus if (counts[deviceStatus] !== undefined) counts[deviceStatus] += 1 return { deviceCode: item.deviceCode, deviceName: item.deviceName, deviceTypeName: ['压力计', '流量计', '液位计'][index % 3], deviceStatus, statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线' }[deviceStatus] || '', statusReasons: deviceStatus === 'ALARM' ? ['压力超高'] : deviceStatus === 'WARNING' ? ['液位偏高'] : [], statusTime: '2026-09-02T09:59:00+08:00', ...deviceCoordFields(samples, index), } }) return { requestId: 'mock-point-detail', apiVersion: 'v1', readOnly: true, monitoringPointCode: code, monitoringPointName: name, pointStatus, statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[pointStatus] || '', statusReasons: [{ status: pointStatus, deviceCode: devices[0] ? devices[0].deviceCode : '', reason: '压力超高' }], deviceStatistics: { totalDeviceCount: devices.length, alarmDeviceCount: counts.ALARM, warningDeviceCount: counts.WARNING, workOrderDeviceCount: counts.WORK_ORDER, normalDeviceCount: counts.NORMAL, offlineDeviceCount: counts.OFFLINE, }, devices, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({ status: item, label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item], priority: index + 1, })), } } /** * 票 76(mock 串点修正,2026-10-23 走查):任意点位的「原生详情」。 * * 场景样例只覆盖 `DEVICE_LAYER_POINT`;请求其它点位时必须回**它自己的**载荷 —— * `deviceLayerStore` 只接受 `data.code === 选中编码` 的卡片(`deviceLayerStore.js:184`), * 回错点位的载荷会让该点位的设备全部「详情待确认 / 与快照不一致 → 不上图」, * 走查表现为「点 MP-002 面板不出、关联设备不出现、要点几次或刷新地图才出来」。 */ function nativePointDetail(requestedCode) { if (requestedCode === 'MP-001') { return overlapPointDetail({ code: 'MP-001', name: '城南测试监测点' }) } if (requestedCode === 'MP-002') { // 太常样例:K=11 同坐标,编码用自己的 `EQ-TC-*`(与场景点不再共用)。 return degradedOverlapPointDetail({ code: 'MP-002', name: '太常测试监测点', codes: OVERLAP_TAICHANG_CODES, coords: OVERLAP_TAICHANG_CODES.map(() => ({ longitude: 110.3632, latitude: 28.4524 })), statuses: ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'WORK_ORDER', 'NORMAL', 'NORMAL', 'OFFLINE', 'NORMAL'], }) } return pointDetail() } function pointDetailPayload(scenario, requestedCode = '') { // 票 76:下列场景样例都只覆盖 DEVICE_LAYER_POINT;请求别的点位 → 回它自己的原生详情。 const scenarioSamples = ['device-layer', 'device-layer-overlap', 'device-effects', 'device-effects-degraded', 'aggregate-lab'] if (scenarioSamples.includes(scenario) && requestedCode && requestedCode !== DEVICE_LAYER_POINT) { return nativePointDetail(requestedCode) } if (scenario === 'empty') { return { ...pointDetail({ code: 'MP-EMPTY', name: '预留监测点', status: 'EMPTY' }), devices: [], deviceStatistics: { totalDeviceCount: 0, alarmDeviceCount: 0, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 0, offlineDeviceCount: 0 }, statusReasons: [], } } if (scenario === 'degraded') { return { monitoringPointCode: 'MP-DEGRADED', monitoringPointName: '字段缺失点位', devices: [], statusReasons: [] } } /** * 票 42:**受控有效坐标样例** —— 专门用来验证独立设备图层的渲染与拾取。 * * 与默认场景的关键差别:两台设备都带**设备自身**的有效坐标(`VALID/renderable=true`), * 且 `deviceCodes` 与详情 `devices[]` 完全一致(否则会触发"与当前快照不一致"的提示)。 * 坐标落在主场景区内、贴近点位但不重合(重合会走票 11 的重叠分支,不在本票)。 */ if (scenario === 'device-layer') { return pointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点', status: 'NORMAL', // 两台设备各有有效坐标且不重叠:票 42 的单设备层基线。 deviceCoord: DEVICE_LAYER_COORDS, }) } /** 票 45:两台设备拥有**各自真实字段**,但坐标恰好重叠;用于测试纯渲染层聚合/展开。 */ if (scenario === 'device-layer-overlap') { return overlapPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点' }) } /** * 票 45:**K>10 降级样例** —— 11 台设备同坐标、状态混合(3 ALARM / 3 WARNING / 5 NORMAL)。 * 设备编码与地图快照 `deviceCodes` 完全一致,全部坐标准入有效 ⇒ K=11 > maxFan(10) ⇒ 降级形态 * 「K 台设备(本屏显示 10 台 / 其余见清单)」。聚合符号显示最高优先级状态(ALARM)。 */ /** * 票 76:**干净聚合实验样例**(维护者要求重建测试数据)—— * 4 台设备同坐标、编码与快照 `deviceCodes` 一致、坐标同源;不降级(K=4 ≤ maxFan)。 * 这是验证「聚合符号 / 展开 fan / 选中强调」的**唯一入口**(旧 `EQ-DG-*` 样例已按维护者裁定删除)。 */ if (scenario === 'aggregate-lab') { return degradedOverlapPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点', codes: AGGREGATE_LAB_CODES, coords: AGGREGATE_LAB_COORDS, statuses: ['ALARM', 'WARNING', 'NORMAL', 'NORMAL'], }) } /** * 票 47:**设备状态动效样例** —— 五态各一台,验证「只有报警/预警/工单有动效、 * 正常/离线完全静止」(决策票 07 的核心取舍)。全部设备坐标准入有效。 */ if (scenario === 'device-effects') { return effectsPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点', samples: EFFECTS_SAMPLE, pointStatus: 'ALARM' }) } /** 票 47:**动效降级样例** —— 31 台报警设备(> 默认上限 30)⇒ 动画全停 + 静态异常标记 + 计数。 */ if (scenario === 'device-effects-degraded') { return effectsPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点', samples: EFFECTS_DEGRADED_SAMPLE, pointStatus: 'ALARM' }) } if (scenario === 'point-detail-order') { const devices = [ { deviceCode: 'EQ-ORDER-NORMAL', deviceName: '顺序样例-正常设备', deviceTypeName: '压力计', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00' }, { deviceCode: 'EQ-ORDER-ALARM', deviceName: '顺序样例-报警设备', deviceTypeName: '流量计', deviceStatus: 'ALARM', statusLabel: '报警', statusReasons: ['流量超高'], statusTime: '2026-09-02T09:59:40+08:00' }, { deviceCode: 'EQ-ORDER-WARNING', deviceName: '顺序样例-预警设备', deviceTypeName: '液位计', deviceStatus: 'WARNING', statusLabel: '预警', statusReasons: ['液位偏高'], statusTime: '2026-09-02T09:59:50+08:00' }, ] return { ...pointDetail({ code: 'MP-ORDER', name: '顺序样例监测点', status: 'ALARM' }), devices, deviceStatistics: { totalDeviceCount: 3, alarmDeviceCount: 1, warningDeviceCount: 1, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 }, statusReasons: [{ status: 'ALARM', deviceCode: 'EQ-ORDER-ALARM', reason: '流量超高' }], } } /** * **主场景**(默认 `success`)按请求的点位编码分发两个重叠示例 —— * 维护者要求「进入系统即可见 K=2 与 K=11」,不必再注入场景: * · MP-001 城南测试监测点 —— K=2 正常重叠(两台设备同坐标,可逐台展开); * · MP-002 太常测试监测点 —— K=11 降级(本屏 10 台 / 其余见清单)。 * 其余点位仍走默认现网边界(设备 `UNVERIFIED_COORDINATE/renderable=false`,不上图仅清单), * 那条边界是票 42 照抄的正式行为,不被示例覆盖。 */ if (scenario === 'success') { if (requestedCode === 'MP-001') { return overlapPointDetail({ code: 'MP-001', name: '城南测试监测点' }) } if (requestedCode === 'MP-002') { // 主场景的 MP-002 是 ALARM 点位(示例用于展示降级聚合的最高优先级状态)。 return degradedOverlapPointDetail({ code: 'MP-002', name: '太常测试监测点', // 票 76(mock 串点修正):太常样例用**自己的编码**,不再与城南场景点共用 `EQ-DG-*`。 codes: OVERLAP_TAICHANG_CODES, // 主场景仍是 K=11 同坐标样例,位置在太常点附近;五态沿用已验收的动效词表。 coords: OVERLAP_TAICHANG_CODES.map(() => ({ longitude: 110.3632, latitude: 28.4524 })), statuses: ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'WORK_ORDER', 'NORMAL', 'NORMAL', 'OFFLINE', 'NORMAL'], }) } } return pointDetail() } /** * 设备级坐标字段(票 42)。`coords` 为 null ⇒ 现网降级(`UNVERIFIED_COORDINATE/renderable=false`), * 有值 ⇒ 受控测试样例(`VALID/renderable=true`)。两种都不复制监测点坐标。 */ function deviceCoordFields(coords, index) { if (!Array.isArray(coords) || !coords[index]) { return { coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' }, } } const point = coords[index] return { longitude: point.longitude, latitude: point.latitude, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, } } function pointDetail({ code = 'MP-002', name = '太常测试监测点', status = 'ALARM', deviceCoord = null } = {}) { // 票 42:设备级坐标与校验。默认 `deviceCoord` 为 null ⇒ 逐台 `UNVERIFIED_COORDINATE/renderable=false`, // **照抄现网正式边界**(后端当前无可信校核源,真实设备统一不可上图)。 // 只有显式传入受控坐标的样例场景才会 `renderable=true` —— 那是**测试样例**, // 不得用来冒称"正式关联设备已能上图"(票 42 验收第 2 条原句)。 const devices = [ { deviceCode: 'EQ-001', deviceName: '供水压力计-001', deviceTypeName: '压力计', deviceStatus: 'ALARM', statusLabel: '报警', statusReasons: ['压力超高'], statusTime: '2026-09-02T09:59:00+08:00', ...deviceCoordFields(deviceCoord, 0), }, { deviceCode: 'EQ-002', deviceName: '智能窨井-002', deviceTypeName: '智能窨井', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00', ...deviceCoordFields(deviceCoord, 1), }, ] return { requestId: 'mock-point-detail', apiVersion: 'v1', readOnly: true, monitoringPointCode: code, monitoringPointName: name, pointStatus: status, statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[status] || '', statusReasons: [{ status, deviceCode: 'EQ-001', reason: '压力超高' }], deviceStatistics: { totalDeviceCount: devices.length, alarmDeviceCount: 1, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0, }, devices, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({ status: item, label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item], priority: index + 1, })), } } // ---------------------------------------------------------------- 设备详情聚合 function aggregateSection(state, reason, data, message) { return { state, reason, message: message || null, data } } const ARCHIVE = { deviceCode: 'TC-PS-WS-202609020012', deviceName: '污水环境监测设备', deviceModel: 'WS-PRO-2000', deviceSpec: '悬浮物/COD/氨氮/电导率/PH', manufacturer: '沅陵专用仪表厂', deviceTypeName: '污水环境监测设备', assetValue: 128000, expectedServiceYears: 10, equipmentLocation: '太常片区', ownershipUnit: '沅陵县排水管理所', maintainer: '沅陵运维一组', maintainerPhone: '0745-0000000', installTime: '2024-03-01T00:00:00+08:00', latestImage: { url: '/cockpit/aerial.webp', capturedAt: '2026-08-30T10:20:00+08:00' }, } const METRICS = { deviceCode: ARCHIVE.deviceCode, state: 'CURRENT', stateReason: 'READY', metrics: [ { metricCode: 'COD', metricName: 'COD', value: 18.2, unit: 'mg/L', status: 'WARNING', sampledAt: '2026-09-02T09:59:30+08:00', freshness: 'CURRENT' }, // 边界:真实 0 是有效读数,适配层不得当成缺失。 { metricCode: 'FLOW', metricName: '流量', value: 0, unit: 'm³/h', status: 'NORMAL', sampledAt: '2026-09-02T09:59:30+08:00', freshness: 'CURRENT' }, ], } const WARNING = { warningNo: 'WRN-20260902-001', warningName: '污水COD预警', warningType: 'WS_ENV', level: 2, levelColor: 'YELLOW', status: 'PROCESSING', eventTime: '2026-09-02T09:59:30+08:00', value: 18.2, unit: 'mg/L', deviceCode: ARCHIVE.deviceCode, deviceName: ARCHIVE.deviceName, location: '太常片区' } const ALARM = { alarmId: 'ALM-20260902-001', warningCode: 'WS_ENV_HIGH', warningName: '污水COD报警', warningType: 'WS_ENV', level: 1, levelColor: 'RED', status: 'UNHANDLED', eventTime: '2026-09-02T10:00:00+08:00', value: 42, thresholdMinValue: 40, thresholdMaxValue: 50, unit: 'mg/L', deviceCode: ARCHIVE.deviceCode, deviceName: ARCHIVE.deviceName, location: '太常片区' } const RELATION = { relationId: 'REL-001', warningNo: WARNING.warningNo, alarmId: ALARM.alarmId, relatedAt: '2026-09-02T10:00:00+08:00' } const WORK_ORDER = { associatedWithCurrentEvent: true, workOrderCode: 'WO-2026090200001', deviceCode: ARCHIVE.deviceCode, orderType: 1, orderTypeName: '故障维修', orderLevel: 2, orderDesc: '处理异常', stateCode: 3, stateName: '处理中', createTime: '2026-09-02T09:00:00+08:00', updateTime: '2026-09-02T10:00:00+08:00', planFinishTime: '2026-09-03T09:00:00+08:00', realFinishTime: null, eventAssociation: { eventSource: 'ALARM', eventCode: ALARM.alarmId, associatedAt: '2026-09-02T10:00:00+08:00' } } /** * 票 12:默认时间线走**完整一遍状态路径 + 一次回退**。 * * 为什么要带回退而不是只给一条顺溜的轨迹:票 12 的验收原句就是 * 「验收驳回表现为『待验收回退处理中』的动作轨迹」——样例里没有回退,这条验收在 mock 上 * 根本无法演示(票 11 已经在「分区级失败」上踩过一次同样的坑)。 * 末条 `stateCode: 3` 与 `WORK_ORDER.stateCode` 一致:当前态永远取工单自己的状态。 * * ⚠️ **票 20:全部样例已按后端 `WorkOrderStatusEnum`(0–7)重排。** * 旧样例沿用 1–7,于是每条记录的码都比真实语义大一格(例如"待验收"标成 5、实际是 4)。 * 现在:0待派单 / 1待接单 / 2已接单 / 3处理中 / 4待验收 / 5已结案 / 6延期中 / 7已取消。 */ const TIMELINE_ENTRIES = [ { logId: 1, operationName: '创建工单', operationDesc: '故障维修', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00', stateCode: 0, stateName: '待派单' }, { logId: 2, operationName: '派单', operationDesc: '指派沅陵运维一组', operatorName: '调度员', operatedAt: '2026-09-02T09:10:00+08:00', stateCode: 1, stateName: '待接单' }, { logId: 3, operationName: '接单', operationDesc: '已接单', operatorName: '李四', operatedAt: '2026-09-02T09:20:00+08:00', stateCode: 2, stateName: '已接单' }, { logId: 4, operationName: '开始处置', operationDesc: '现场处理', operatorName: '李四', operatedAt: '2026-09-02T09:40:00+08:00', stateCode: 3, stateName: '处理中' }, { logId: 5, operationName: '提交验收', operationDesc: '申请验收', operatorName: '李四', operatedAt: '2026-09-02T11:30:00+08:00', stateCode: 4, stateName: '待验收' }, // 回退动作:状态从 4 回到 3。它是**动作**,不是多出来的一个状态。 { logId: 6, operationName: '验收驳回', operationDesc: '待验收回退处理中', operatorName: '张三', operatedAt: '2026-09-02T12:00:00+08:00', stateCode: 3, stateName: '处理中' }, ] const TIMELINE = { workOrder: { ...WORK_ORDER }, timelineEntries: TIMELINE_ENTRIES } /** * 票 12 的专用场景:**同设备多张工单、每张一条独立时间线**(验收第 2 条)。 * 四张刻意选在四个不同形状上: * · `WO-TL-FORWARD` 停在待验收(一路推进,走到过 4); * · `WO-TL-ROLLBACK` 当前处理中、但走到过待验收(回退动作 —— 本票的核心形态); * · `WO-TL-DELAYED` 延期中(**处理旁路**,票 20 新增:6 不在推进主线上); * · `WO-TL-CANCELLED` 已取消(**路径外**终态,不能接在已结案后面冒充下一步)。 */ const TIMELINE_ORDERS = [ { workOrderCode: 'WO-TL-FORWARD', deviceCode: ARCHIVE.deviceCode, orderType: 1, orderTypeName: '故障维修', orderLevel: 2, orderDesc: '水泵异响处置', stateCode: 4, stateName: '待验收', createTime: '2026-09-01T08:00:00+08:00', updateTime: '2026-09-01T12:00:00+08:00', planFinishTime: '2026-09-02T08:00:00+08:00', realFinishTime: null, eventAssociation: { eventSource: 'ALARM', eventCode: ALARM.alarmId, associatedAt: '2026-09-01T08:05:00+08:00' } }, { workOrderCode: 'WO-TL-ROLLBACK', deviceCode: ARCHIVE.deviceCode, orderType: 1, orderTypeName: '故障维修', orderLevel: 2, orderDesc: 'COD 探头校验', stateCode: 3, stateName: '处理中', createTime: '2026-09-02T09:00:00+08:00', updateTime: '2026-09-02T12:00:00+08:00', planFinishTime: '2026-09-03T09:00:00+08:00', realFinishTime: null, eventAssociation: { eventSource: 'ALARM', eventCode: ALARM.alarmId, associatedAt: '2026-09-02T09:00:00+08:00' } }, { workOrderCode: 'WO-TL-DELAYED', deviceCode: ARCHIVE.deviceCode, orderType: 1, orderTypeName: '故障维修', orderLevel: 2, orderDesc: '闸阀更换(等备件)', stateCode: 6, stateName: '延期中', createTime: '2026-09-03T08:00:00+08:00', updateTime: '2026-09-03T10:00:00+08:00', planFinishTime: '2026-09-10T08:00:00+08:00', realFinishTime: null, eventAssociation: null }, { workOrderCode: 'WO-TL-CANCELLED', deviceCode: ARCHIVE.deviceCode, orderType: 2, orderTypeName: '计划检修', orderLevel: 3, orderDesc: '重复报单,取消', stateCode: 7, stateName: '已取消', createTime: '2026-09-02T07:00:00+08:00', updateTime: '2026-09-02T07:30:00+08:00', planFinishTime: null, realFinishTime: null, eventAssociation: null }, ] const TIMELINE_BY_CODE = { 'WO-TL-FORWARD': [ { logId: 11, operationName: '创建工单', operationDesc: '故障维修', operatorName: '系统', operatedAt: '2026-09-01T08:00:00+08:00', stateCode: 0, stateName: '待派单' }, { logId: 12, operationName: '派单', operationDesc: '指派沅陵运维二组', operatorName: '调度员', operatedAt: '2026-09-01T08:15:00+08:00', stateCode: 1, stateName: '待接单' }, { logId: 13, operationName: '接单', operationDesc: '已接单', operatorName: '王五', operatedAt: '2026-09-01T08:30:00+08:00', stateCode: 2, stateName: '已接单' }, { logId: 14, operationName: '开始处置', operationDesc: '现场处理', operatorName: '王五', operatedAt: '2026-09-01T09:00:00+08:00', stateCode: 3, stateName: '处理中' }, { logId: 15, operationName: '提交验收', operationDesc: '申请验收', operatorName: '王五', operatedAt: '2026-09-01T12:00:00+08:00', stateCode: 4, stateName: '待验收' }, ], 'WO-TL-ROLLBACK': [ { logId: 21, operationName: '创建工单', operationDesc: '故障维修', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00', stateCode: 0, stateName: '待派单' }, { logId: 22, operationName: '派单', operationDesc: '指派沅陵运维一组', operatorName: '调度员', operatedAt: '2026-09-02T09:05:00+08:00', stateCode: 1, stateName: '待接单' }, { logId: 23, operationName: '接单', operationDesc: '已接单', operatorName: '李四', operatedAt: '2026-09-02T09:10:00+08:00', stateCode: 2, stateName: '已接单' }, { logId: 24, operationName: '开始处置', operationDesc: '现场处理', operatorName: '李四', operatedAt: '2026-09-02T09:30:00+08:00', stateCode: 3, stateName: '处理中' }, { logId: 25, operationName: '提交验收', operationDesc: '申请验收', operatorName: '李四', operatedAt: '2026-09-02T11:00:00+08:00', stateCode: 4, stateName: '待验收' }, { logId: 26, operationName: '验收驳回', operationDesc: '待验收回退处理中', operatorName: '张三', operatedAt: '2026-09-02T12:00:00+08:00', stateCode: 3, stateName: '处理中' }, ], // 票 20 新增:延期审批通过 → 进入 6 延期中(**旁路**),之后再回到 3 处理中。 // 这条样例是 `BYPASS` 动作的演示依据 —— 没有它,"延期中是旁路不是主线"就只写在注释里。 'WO-TL-DELAYED': [ { logId: 41, operationName: '创建工单', operationDesc: '故障维修', operatorName: '系统', operatedAt: '2026-09-03T08:00:00+08:00', stateCode: 0, stateName: '待派单' }, { logId: 42, operationName: '派单', operationDesc: '指派沅陵运维一组', operatorName: '调度员', operatedAt: '2026-09-03T08:10:00+08:00', stateCode: 1, stateName: '待接单' }, { logId: 43, operationName: '接单', operationDesc: '已接单', operatorName: '李四', operatedAt: '2026-09-03T08:20:00+08:00', stateCode: 2, stateName: '已接单' }, { logId: 44, operationName: '开始处置', operationDesc: '现场处理', operatorName: '李四', operatedAt: '2026-09-03T08:30:00+08:00', stateCode: 3, stateName: '处理中' }, { logId: 45, operationName: '申请延期', operationDesc: '等待备件到货', operatorName: '李四', operatedAt: '2026-09-03T09:00:00+08:00', stateCode: 3, stateName: '处理中' }, { logId: 46, operationName: '延期通过', operationDesc: '备件周期确认', operatorName: '张三', operatedAt: '2026-09-03T10:00:00+08:00', stateCode: 6, stateName: '延期中' }, ], 'WO-TL-CANCELLED': [ { logId: 31, operationName: '创建工单', operationDesc: '计划检修', operatorName: '系统', operatedAt: '2026-09-02T07:00:00+08:00', stateCode: 0, stateName: '待派单' }, { logId: 32, operationName: '派单', operationDesc: '指派沅陵运维三组', operatorName: '调度员', operatedAt: '2026-09-02T07:10:00+08:00', stateCode: 1, stateName: '待接单' }, { logId: 33, operationName: '取消工单', operationDesc: '重复报单', operatorName: '调度员', operatedAt: '2026-09-02T07:30:00+08:00', stateCode: 7, stateName: '已取消' }, ], } /** 某工单编码的时间线载荷(认不出的编码回 null,与真接口的 404 语义一致)。 */ function timelinePayload(workOrderCode) { if (workOrderCode === WORK_ORDER.workOrderCode) return TIMELINE const order = TIMELINE_ORDERS.find((item) => item.workOrderCode === workOrderCode) const entries = TIMELINE_BY_CODE[workOrderCode] if (!order || !entries) return null return { workOrder: { ...order }, timelineEntries: entries } } function equipmentAggregate(scenario, deviceCode = '') { const success = aggregateSection('SUCCESS', 'SUCCESS', null) // 请求哪台设备就报哪台(与真接口一致,客户端传错编码能看出来)。 const code = deviceCode || ARCHIVE.deviceCode const archive = { ...ARCHIVE, deviceCode: code } if (scenario === 'empty') { return { deviceCode: code, aggregateState: 'EMPTY', serverTime: SNAPSHOT_AT, archive: aggregateSection('EMPTY', 'NO_ARCHIVE', null), metrics: aggregateSection('EMPTY', 'NO_METRICS', null), warnings: aggregateSection('EMPTY', 'NO_EVENTS', null), alarms: aggregateSection('EMPTY', 'NO_EVENTS', null), alarmWarningRelations: aggregateSection('EMPTY', 'NO_EVENTS', null), workOrders: aggregateSection('EMPTY', 'NO_WORK_ORDERS', null), timelines: aggregateSection('EMPTY', 'NO_WORK_ORDERS', null), latestImage: aggregateSection('EMPTY', 'NO_IMAGE', null), } } if (scenario === 'degraded') { // 字段缺失:档案只剩编码,指标缺 value/unit,工单缺 stateName(靠状态字典补)。 return { deviceCode: code, aggregateState: 'READY', serverTime: SNAPSHOT_AT, archive: aggregateSection('SUCCESS', 'SUCCESS', { deviceCode: code }), metrics: aggregateSection('SUCCESS', 'SUCCESS', { deviceCode: code, state: 'CURRENT', stateReason: 'READY', metrics: [{ metricCode: 'COD', metricName: 'COD', sampledAt: '2026-09-02T09:59:30+08:00' }] }), warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]), alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]), alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]), workOrders: aggregateSection('SUCCESS', 'SUCCESS', [{ workOrderCode: 'WO-DEGRADED', stateCode: 2 }]), timelines: aggregateSection('SUCCESS', 'SUCCESS', [TIMELINE]), latestImage: aggregateSection('EMPTY', 'NO_IMAGE', null), } } if (scenario === 'stale') { return { deviceCode: code, aggregateState: 'PARTIAL', serverTime: SNAPSHOT_AT, archive: aggregateSection('SUCCESS', 'SUCCESS', archive), // 过期但保留旧值(票 16:保留最后已知值 + 过期标识)。 metrics: aggregateSection('STALE', 'SAMPLE_OUTDATED', { ...METRICS, state: 'STALE', stateReason: 'SAMPLE_OUTDATED' }), warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]), alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]), alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]), workOrders: aggregateSection('SUCCESS', 'SUCCESS', [WORK_ORDER]), timelines: aggregateSection('SUCCESS', 'SUCCESS', [TIMELINE]), latestImage: aggregateSection('SUCCESS', 'SUCCESS', archive.latestImage), } } if (scenario === 'equipment-partial') { // 票 11 验收第 3 条:任一分区失败不遮盖其它成功分区。 // // 为什么要一个**独立场景名**而不是复用 `failed`:`failed` 在注册层就抛错 // (整个端点读不到,`data` 为 null),那是"这份详情根本没取到", // 与"取到了、只是其中三块分区各自失败"是两件事。后端契约会把后者原样返回 // (`aggregateState: PARTIAL` + 每分区自带 state/reason/message), // 所以必须有一个能真正走通的样例,否则这条验收在 mock 上无法演示。 return { deviceCode: code, aggregateState: 'PARTIAL', serverTime: SNAPSHOT_AT, archive: aggregateSection('FAILED', 'READ_FAILURE', null, '设备档案数据暂不可用'), metrics: aggregateSection('SUCCESS', 'SUCCESS', METRICS), warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]), alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]), alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]), workOrders: aggregateSection('FAILED', 'READ_FAILURE', null, '工单数据暂不可用'), timelines: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '工单分区失败,时间线不可用'), latestImage: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '设备档案失败,图片不可用'), } } if (scenario === 'work-order-timeline') { // 票 12 的专用样例:三张工单、各一条独立时间线(推进 / 回退 / 取消三种形状都齐)。 // 与 `equipment-partial` 一样,它必须是一个**能真正走通**的独立场景 —— // 挂在别的分支下就永远演示不到,那等于这条验收没有样例。 return { deviceCode: code, aggregateState: 'READY', serverTime: SNAPSHOT_AT, archive: { ...success, data: archive }, metrics: { ...success, data: METRICS }, warnings: { ...success, data: [WARNING] }, alarms: { ...success, data: [ALARM] }, alarmWarningRelations: { ...success, data: [RELATION] }, workOrders: { ...success, data: TIMELINE_ORDERS }, timelines: { ...success, data: TIMELINE_ORDERS.map((order) => ({ workOrder: { ...order }, timelineEntries: TIMELINE_BY_CODE[order.workOrderCode] })), }, latestImage: { ...success, data: archive.latestImage }, } } if (scenario === 'work-order-timeline-failed') { // 票 12 的**分区独立**样例:工单列表照常成功,只有时间线分区失败 // (后端 `loadTimelines()` 在工单读得到、时间线读不到时正是这个形状: // `SOURCE_SECTION_FAILED` + 工单列表 SUCCESS)。界面必须说「轨迹没取到」, // 而不是把缺数据画成「这张工单没有动作记录」—— 那是把"没拿到"说成"没有"。 return { deviceCode: code, aggregateState: 'PARTIAL', serverTime: SNAPSHOT_AT, archive: { ...success, data: archive }, metrics: { ...success, data: METRICS }, warnings: { ...success, data: [WARNING] }, alarms: { ...success, data: [ALARM] }, alarmWarningRelations: { ...success, data: [RELATION] }, workOrders: { ...success, data: [WORK_ORDER] }, timelines: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '工单分区失败,时间线不可用'), latestImage: { ...success, data: archive.latestImage }, } } return { deviceCode: code, aggregateState: 'READY', serverTime: SNAPSHOT_AT, archive: { ...success, data: archive }, metrics: { ...success, data: METRICS }, warnings: { ...success, data: [WARNING] }, alarms: { ...success, data: [ALARM] }, alarmWarningRelations: { ...success, data: [RELATION] }, workOrders: { ...success, data: [{ ...WORK_ORDER, associatedWithCurrentEvent: true }, { ...WORK_ORDER, workOrderCode: 'WO-UNLINKED', associatedWithCurrentEvent: false, eventAssociation: null }] }, timelines: { ...success, data: [TIMELINE] }, latestImage: { ...success, data: archive.latestImage }, } } // ---------------------------------------------------------------- 事件与工单 function eventRead(scenario, deviceCode = '') { const base = { requestId: 'mock-event-read', apiVersion: 'v1', readOnly: true, deviceCode: deviceCode || ARCHIVE.deviceCode, deviceType: 'ws_sewage_environment', deviceTypeName: '污水环境监测设备', startTime: null, endTime: null, timezone: 'Asia/Shanghai', page: 1, pageSize: 0, } if (scenario === 'empty') { return { ...base, state: 'EMPTY', stateReason: 'NO_EVENTS', warnings: [], alarms: [], alarmWarningRelations: [], total: 0 } } if (scenario === 'degraded') { // 缺 levelColor 与设备编码:适配层不得猜颜色,也不得伪造归属设备。 return { ...base, state: 'READY', stateReason: 'READY', warnings: [{ warningNo: 'WRN-DEGRADED-001', warningName: '字段缺失预警', level: 2, status: 'PROCESSING', eventTime: '2026-09-02T09:59:30+08:00', unit: 'mg/L' }], alarms: [], alarmWarningRelations: [], total: 1, } } if (scenario === 'stale') { return { ...base, state: 'STALE', stateReason: 'SAMPLE_OUTDATED', warnings: [{ ...WARNING, freshness: 'STALE' }], alarms: [], alarmWarningRelations: [], total: 1, } } return { ...base, state: 'READY', stateReason: 'READY', warnings: [WARNING], alarms: [ALARM], alarmWarningRelations: [RELATION], total: 2, } } // ---------------------------------------------------------------- 跨设备当前事件(票 13) /** * 票 13:跨设备 `/events/current` 的契约化样例(`CockpitCurrentEventsResponse`)。 * * 字段名与嵌套形状照抄后端响应类(`CockpitCurrentEventsResponse` + * `CockpitCurrentEventsService`),所以适配层单测证明的是「转换吃得下真载荷」。 * * 五处刻意照抄真实形状的**坑**: * 1. **`mode` 必填并校验**(WARNING|ALARM,认不出的值 400)—— mock 收到非法 mode * 抛错,逼调用方永远带上合法 mode; * 2. **预警与报警永不混合**:`mode` 决定取哪一族,两块样例数据刻意不同, * 切换后列表必须真的换内容(否则「预警/报警可切换」是假绿); * 3. **`levelColor` 由后端按 level 解析**(`CockpitEventLevelColor`:1→RED … * 4→GREEN,认不出→UNKNOWN),mock 照抄同一个映射; * 4. **排序**:eventTime 倒序、同时间按 eventNo 升序(`eventOrdering()`), * 服务端分页的页序靠它成立(票 61:每页 10 条,翻页由 `page`/`pageSize` 真切片); * 5. **内部分区**:`sections.events` 是 `CockpitSectionResult`(空时 * `{state: EMPTY, reason: NO_EVENTS}`),工单分区独立(样例给空); * 顶层不算空 —— 与设备详情聚合同一模式。 * * 样例规模刻意大于一页:预警和报警各 14 条,让 `pageSize=10` 的服务端分页 * 在 mock 上真能演示第二页(票 61;此前是「仅展示最新 12 条」帽的演示数据)。 */ /** mock 里事件等级 → 颜色(照抄后端 `CockpitEventLevelColor.resolve`)。 */ function eventLevelColorOf(level) { if (level === 1) return 'RED' if (level === 2) return 'YELLOW' if (level === 3) return 'BLUE' if (level === 4) return 'GREEN' return 'UNKNOWN' } /** 一条预警样例(字段照抄 `EventItem`;警报/工单摘要按族给)。 */ function eventItem({ no, name, type, level, status, eventTime, updatedTime, value, unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName, location, industry, min = null, max = null }) { return { eventNo: no, eventName: name, eventType: type, level, levelColor: eventLevelColorOf(level), status, eventTime, updatedTime, value, unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName, location, industry, longitude: 110.14, latitude: 28.45, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, thresholdMinValue: min, thresholdMaxValue: max, workOrderSummary: { workOrderCode: `WO-${no}`, stateCode: level === 1 ? 3 : 2, stateName: level === 1 ? '处理中' : '已接单' }, } } /** 预警样例(14 条,等级 1-4 都覆盖;状态用后端契约测试见过的取值)。 */ const CURRENT_WARNINGS = [ eventItem({ no: 'WRN-1001', name: '燃气浓度预警', type: 'GAS_CONCENTRATION', level: 1, status: 'ACTIVE', eventTime: '2026-09-02T10:00:00+08:00', updatedTime: '2026-09-02T10:00:00+08:00', value: 0.9, unit: '%LEL', deviceCode: 'EQ-002', deviceName: '燃气调压站设备', monitoringPointCode: 'MP-002', monitoringPointName: '太常测试监测点', location: '城区A', industry: 'GAS' }), eventItem({ no: 'WRN-1002', name: '供水压力预警', type: 'WATER_PRESSURE', level: 2, status: 'PENDING', eventTime: '2026-09-02T09:55:00+08:00', updatedTime: '2026-09-02T09:55:00+08:00', value: 0.28, unit: 'MPa', deviceCode: 'EQ-001', deviceName: '供水压力计-001', monitoringPointCode: 'MP-001', monitoringPointName: '城南测试监测点', location: '城区B', industry: 'WATER_SUPPLY' }), eventItem({ no: 'WRN-1003', name: '污水 COD 预警', type: 'SEWAGE_CONCENTRATION', level: 3, status: 'PROCESSING', eventTime: '2026-09-02T09:50:00+08:00', updatedTime: '2026-09-02T09:52:00+08:00', value: 18.2, unit: 'mg/L', deviceCode: 'EQ-003', deviceName: '污水环境监测设备', monitoringPointCode: 'MP-003', monitoringPointName: '污水排口监测点', location: '太常片区', industry: 'DRAINAGE' }), eventItem({ no: 'WRN-1004', name: '排水水位预警', type: 'DRAINAGE_LEVEL', level: 4, status: 'ACTIVE', eventTime: '2026-09-02T09:45:00+08:00', updatedTime: '2026-09-02T09:45:00+08:00', value: 1.6, unit: 'm', deviceCode: 'EQ-004', deviceName: '排水泵站设备', monitoringPointCode: 'MP-004', monitoringPointName: '排水泵站监测点', location: '城区C', industry: 'DRAINAGE' }), eventItem({ no: 'WRN-1005', name: '燃气浓度预警', type: 'GAS_CONCENTRATION', level: 2, status: 'PENDING', eventTime: '2026-09-02T09:40:00+08:00', updatedTime: '2026-09-02T09:40:00+08:00', value: 0.72, unit: '%LEL', deviceCode: 'EQ-002', deviceName: '燃气调压站设备', monitoringPointCode: 'MP-002', monitoringPointName: '太常测试监测点', location: '城区A', industry: 'GAS' }), eventItem({ no: 'WRN-1006', name: '供水流量预警', type: 'WATER_FLOW', level: 3, status: 'ACTIVE', eventTime: '2026-09-02T09:30:00+08:00', updatedTime: '2026-09-02T09:30:00+08:00', value: 880, unit: 'm³/h', deviceCode: 'EQ-001', deviceName: '供水压力计-001', monitoringPointCode: 'MP-001', monitoringPointName: '城南测试监测点', location: '城区B', industry: 'WATER_SUPPLY' }), eventItem({ no: 'WRN-1007', name: '污水 PH 预警', type: 'SEWAGE_PH', level: 4, status: 'PROCESSING', eventTime: '2026-09-02T09:20:00+08:00', updatedTime: '2026-09-02T09:21:00+08:00', value: 8.9, unit: '', deviceCode: 'EQ-003', deviceName: '污水环境监测设备', monitoringPointCode: 'MP-003', monitoringPointName: '污水排口监测点', location: '太常片区', industry: 'DRAINAGE' }), eventItem({ no: 'WRN-1008', name: '燃气压力预警', type: 'GAS_PRESSURE', level: 1, status: 'ACTIVE', eventTime: '2026-09-02T09:10:00+08:00', updatedTime: '2026-09-02T09:10:00+08:00', value: 0.31, unit: 'MPa', deviceCode: 'EQ-002', deviceName: '燃气调压站设备', monitoringPointCode: 'MP-002', monitoringPointName: '太常测试监测点', location: '城区A', industry: 'GAS' }), eventItem({ no: 'WRN-1009', name: '供水余氯预警', type: 'WATER_CHLORINE', level: 2, status: 'PENDING', eventTime: '2026-09-02T09:00:00+08:00', updatedTime: '2026-09-02T09:00:00+08:00', value: 0.12, unit: 'mg/L', deviceCode: 'EQ-001', deviceName: '供水压力计-001', monitoringPointCode: 'MP-001', monitoringPointName: '城南测试监测点', location: '城区B', industry: 'WATER_SUPPLY' }), eventItem({ no: 'WRN-1010', name: '污水氨氮预警', type: 'SEWAGE_NH3', level: 3, status: 'ACTIVE', eventTime: '2026-09-02T08:50:00+08:00', updatedTime: '2026-09-02T08:50:00+08:00', value: 6.4, unit: 'mg/L', deviceCode: 'EQ-003', deviceName: '污水环境监测设备', monitoringPointCode: 'MP-003', monitoringPointName: '污水排口监测点', location: '太常片区', industry: 'DRAINAGE' }), eventItem({ no: 'WRN-1011', name: '排水流量预警', type: 'DRAINAGE_FLOW', level: 4, status: 'PROCESSING', eventTime: '2026-09-02T08:40:00+08:00', updatedTime: '2026-09-02T08:41:00+08:00', value: 420, unit: 'm³/h', deviceCode: 'EQ-004', deviceName: '排水泵站设备', monitoringPointCode: 'MP-004', monitoringPointName: '排水泵站监测点', location: '城区C', industry: 'DRAINAGE' }), eventItem({ no: 'WRN-1012', name: '燃气浓度预警', type: 'GAS_CONCENTRATION', level: 1, status: 'PENDING', eventTime: '2026-09-02T08:30:00+08:00', updatedTime: '2026-09-02T08:30:00+08:00', value: 0.85, unit: '%LEL', deviceCode: 'EQ-002', deviceName: '燃气调压站设备', monitoringPointCode: 'MP-002', monitoringPointName: '太常测试监测点', location: '城区A', industry: 'GAS' }), eventItem({ no: 'WRN-1013', name: '供水浊度预警', type: 'WATER_TURBIDITY', level: 3, status: 'ACTIVE', eventTime: '2026-09-02T08:20:00+08:00', updatedTime: '2026-09-02T08:20:00+08:00', value: 3.1, unit: 'NTU', deviceCode: 'EQ-001', deviceName: '供水压力计-001', monitoringPointCode: 'MP-001', monitoringPointName: '城南测试监测点', location: '城区B', industry: 'WATER_SUPPLY' }), eventItem({ no: 'WRN-1014', name: '污水温度预警', type: 'SEWAGE_TEMP', level: 4, status: 'ACTIVE', eventTime: '2026-09-02T08:10:00+08:00', updatedTime: '2026-09-02T08:10:00+08:00', value: 33, unit: '℃', deviceCode: 'EQ-003', deviceName: '污水环境监测设备', monitoringPointCode: 'MP-003', monitoringPointName: '污水排口监测点', location: '太常片区', industry: 'DRAINAGE' }), ] /** 报警样例(14 条,带阈值;状态用后端契约测试见过的取值)。 */ const CURRENT_ALARMS = [ eventItem({ no: 'ALM-2001', name: '燃气浓度报警', type: 'GAS_CONCENTRATION', level: 1, status: 'ACTIVE', eventTime: '2026-09-02T10:00:00+08:00', updatedTime: '2026-09-02T10:00:00+08:00', value: 2.4, unit: '%LEL', deviceCode: 'EQ-002', deviceName: '燃气调压站设备', monitoringPointCode: 'MP-002', monitoringPointName: '太常测试监测点', location: '城区A', industry: 'GAS', min: 1.5, max: 2.5 }), eventItem({ no: 'ALM-2002', name: '供水压力报警', type: 'WATER_PRESSURE', level: 2, status: 'PROCESSING', eventTime: '2026-09-02T09:55:00+08:00', updatedTime: '2026-09-02T09:55:00+08:00', value: 0.18, unit: 'MPa', deviceCode: 'EQ-001', deviceName: '供水压力计-001', monitoringPointCode: 'MP-001', monitoringPointName: '城南测试监测点', location: '城区B', industry: 'WATER_SUPPLY', min: 0.2, max: null }), eventItem({ no: 'ALM-2003', name: '污水 COD 报警', type: 'SEWAGE_CONCENTRATION', level: 3, status: 'ACTIVE', eventTime: '2026-09-02T09:50:00+08:00', updatedTime: '2026-09-02T09:50:00+08:00', value: 42, unit: 'mg/L', deviceCode: 'EQ-003', deviceName: '污水环境监测设备', monitoringPointCode: 'MP-003', monitoringPointName: '污水排口监测点', location: '太常片区', industry: 'DRAINAGE', min: 40, max: 50 }), eventItem({ no: 'ALM-2004', name: '排水水位报警', type: 'DRAINAGE_LEVEL', level: 4, status: 'PENDING', eventTime: '2026-09-02T09:45:00+08:00', updatedTime: '2026-09-02T09:45:00+08:00', value: 2.8, unit: 'm', deviceCode: 'EQ-004', deviceName: '排水泵站设备', monitoringPointCode: 'MP-004', monitoringPointName: '排水泵站监测点', location: '城区C', industry: 'DRAINAGE', min: 2.5, max: null }), eventItem({ no: 'ALM-2005', name: '燃气压力报警', type: 'GAS_PRESSURE', level: 1, status: 'PROCESSING', eventTime: '2026-09-02T09:40:00+08:00', updatedTime: '2026-09-02T09:42:00+08:00', value: 0.1, unit: 'MPa', deviceCode: 'EQ-002', deviceName: '燃气调压站设备', monitoringPointCode: 'MP-002', monitoringPointName: '太常测试监测点', location: '城区A', industry: 'GAS', min: 0.15, max: null }), eventItem({ no: 'ALM-2006', name: '供水流量报警', type: 'WATER_FLOW', level: 2, status: 'ACTIVE', eventTime: '2026-09-02T09:30:00+08:00', updatedTime: '2026-09-02T09:30:00+08:00', value: 1200, unit: 'm³/h', deviceCode: 'EQ-001', deviceName: '供水压力计-001', monitoringPointCode: 'MP-001', monitoringPointName: '城南测试监测点', location: '城区B', industry: 'WATER_SUPPLY', min: null, max: 1000 }), eventItem({ no: 'ALM-2007', name: '污水 PH 报警', type: 'SEWAGE_PH', level: 3, status: 'PROCESSING', eventTime: '2026-09-02T09:20:00+08:00', updatedTime: '2026-09-02T09:21:00+08:00', value: 9.6, unit: '', deviceCode: 'EQ-003', deviceName: '污水环境监测设备', monitoringPointCode: 'MP-003', monitoringPointName: '污水排口监测点', location: '太常片区', industry: 'DRAINAGE', min: 6, max: 9 }), eventItem({ no: 'ALM-2008', name: '燃气浓度报警', type: 'GAS_CONCENTRATION', level: 1, status: 'ACTIVE', eventTime: '2026-09-02T09:10:00+08:00', updatedTime: '2026-09-02T09:10:00+08:00', value: 3.1, unit: '%LEL', deviceCode: 'EQ-002', deviceName: '燃气调压站设备', monitoringPointCode: 'MP-002', monitoringPointName: '太常测试监测点', location: '城区A', industry: 'GAS', min: 2.5, max: null }), eventItem({ no: 'ALM-2009', name: '供水余氯报警', type: 'WATER_CHLORINE', level: 2, status: 'PENDING', eventTime: '2026-09-02T09:00:00+08:00', updatedTime: '2026-09-02T09:00:00+08:00', value: 0.05, unit: 'mg/L', deviceCode: 'EQ-001', deviceName: '供水压力计-001', monitoringPointCode: 'MP-001', monitoringPointName: '城南测试监测点', location: '城区B', industry: 'WATER_SUPPLY', min: 0.1, max: null }), eventItem({ no: 'ALM-2010', name: '污水氨氮报警', type: 'SEWAGE_NH3', level: 3, status: 'ACTIVE', eventTime: '2026-09-02T08:50:00+08:00', updatedTime: '2026-09-02T08:50:00+08:00', value: 12, unit: 'mg/L', deviceCode: 'EQ-003', deviceName: '污水环境监测设备', monitoringPointCode: 'MP-003', monitoringPointName: '污水排口监测点', location: '太常片区', industry: 'DRAINAGE', min: 8, max: null }), eventItem({ no: 'ALM-2011', name: '排水流量报警', type: 'DRAINAGE_FLOW', level: 4, status: 'PROCESSING', eventTime: '2026-09-02T08:40:00+08:00', updatedTime: '2026-09-02T08:41:00+08:00', value: 660, unit: 'm³/h', deviceCode: 'EQ-004', deviceName: '排水泵站设备', monitoringPointCode: 'MP-004', monitoringPointName: '排水泵站监测点', location: '城区C', industry: 'DRAINAGE', min: null, max: 600 }), eventItem({ no: 'ALM-2012', name: '燃气压力报警', type: 'GAS_PRESSURE', level: 1, status: 'PENDING', eventTime: '2026-09-02T08:30:00+08:00', updatedTime: '2026-09-02T08:30:00+08:00', value: 0.08, unit: 'MPa', deviceCode: 'EQ-002', deviceName: '燃气调压站设备', monitoringPointCode: 'MP-002', monitoringPointName: '太常测试监测点', location: '城区A', industry: 'GAS', min: 0.12, max: null }), eventItem({ no: 'ALM-2013', name: '供水浊度报警', type: 'WATER_TURBIDITY', level: 3, status: 'ACTIVE', eventTime: '2026-09-02T08:20:00+08:00', updatedTime: '2026-09-02T08:20:00+08:00', value: 5.2, unit: 'NTU', deviceCode: 'EQ-001', deviceName: '供水压力计-001', monitoringPointCode: 'MP-001', monitoringPointName: '城南测试监测点', location: '城区B', industry: 'WATER_SUPPLY', min: null, max: 4 }), eventItem({ no: 'ALM-2014', name: '污水温度报警', type: 'SEWAGE_TEMP', level: 4, status: 'ACTIVE', eventTime: '2026-09-02T08:10:00+08:00', updatedTime: '2026-09-02T08:10:00+08:00', value: 36, unit: '℃', deviceCode: 'EQ-003', deviceName: '污水环境监测设备', monitoringPointCode: 'MP-003', monitoringPointName: '污水排口监测点', location: '太常片区', industry: 'DRAINAGE', min: null, max: 35 }), ] /** * 按后端语义过滤 + 排序 + 分页(照抄 `CockpitCurrentEventsService` 的 * `warningRows/alarmRows`、`eventOrdering()` 与分页切片)。 * @param {string} mode WARNING / ALARM * @param {object} query URLSearchParams 解析出的请求查询串 */ function currentEventsPayload(scenario, query) { const mode = (query && query.mode) || 'WARNING' if (mode !== 'WARNING' && mode !== 'ALARM') { // 与后端一致:mode 必填且只认两个取值(认不出的 400)。 // 不声明自定义 reason:非白名单的原因会被统一出口收敛成「不可用」, // 而「调用方传了非法 mode」本来就是编程错误、不是可展示的业务态。 throw new Error('mock: mode must be WARNING or ALARM') } const source = mode === 'ALARM' ? CURRENT_ALARMS : CURRENT_WARNINGS // 空样例:事件分区 EMPTY(`CockpitSectionResult.empty("NO_EVENTS")`),工单分区也空。 if (scenario === 'empty') { return currentEventsEnvelope(mode, [], query, { state: 'EMPTY', reason: 'NO_EVENTS', message: null, data: null }) } // 坐标不可上图样例(票 14 验收第 5 条的真机版):事件数据照常给全, // 但**后端判定**坐标不可上图 —— 详情仍然完整可看,只是「位置」一栏给原因、定位点不动。 // 判据只改 `coordinateValidation`(与后端 `CockpitCoordinateValidator` 同形): // 前端只消费结论,所以这里改结论就能复现"无地图位置"的整条路径。 if (scenario === 'event-detail-no-coordinate') { const relocated = source.map((item) => ({ ...item, longitude: null, latitude: null, coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' }, })) return currentEventsEnvelope(mode, relocated.slice(0, 12), query, { state: 'SUCCESS', reason: 'SUCCESS', message: null, data: relocated.slice(0, 12), pagination: { page: 1, pageSize: 100, total: relocated.length, totalPages: 1, hasMore: false }, deviceCodes: [...new Set(relocated.map((item) => item.deviceCode).filter(Boolean))].sort(), }) } // 字段缺失样例:事件行只给编码/名称/等级,其余字段缺失(适配层要记 missing)。 if (scenario === 'degraded') { const degraded = source.slice(0, 3).map((item) => ({ eventNo: item.eventNo, eventName: item.eventName, level: item.level, status: item.status, eventTime: item.eventTime, })) return currentEventsEnvelope(mode, degraded, query, { state: 'SUCCESS', reason: 'SUCCESS', message: null, data: degraded }) } // 筛选(industry/level/status 与后端同口径;`industry` 只认正式行业码)。 const industry = (query && query.industry) || '' const level = query && query.level ? Number(query.level) : null const status = (query && query.status) || '' const filtered = source.filter((item) => { if (industry && item.industry !== industry) return false if (level !== null && item.level !== level) return false if (status && item.status !== status) return false return true }) // 排序:eventTime 倒序,同时间按 eventNo 升序(后端 eventOrdering())。 const sorted = [...filtered].sort((a, b) => { const at = a.eventTime || '' const bt = b.eventTime || '' if (at && bt && at !== bt) return at > bt ? -1 : 1 if (at && !bt) return -1 if (!at && bt) return 1 return a.eventNo < b.eventNo ? -1 : a.eventNo > b.eventNo ? 1 : 0 }) const page = Number(query && query.page) > 0 ? Number(query.page) : 1 const pageSize = Number(query && query.pageSize) > 0 ? Number(query.pageSize) : 100 const from = Math.max(0, (page - 1) * pageSize) const to = from + pageSize const slice = sorted.slice(from, to) const total = sorted.length return currentEventsEnvelope(mode, slice, query, { state: slice.length ? 'SUCCESS' : 'EMPTY', reason: slice.length ? 'SUCCESS' : 'NO_EVENTS', message: null, data: slice, pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0, hasMore: to < total }, deviceCodes: [...new Set(sorted.map((item) => item.deviceCode).filter(Boolean))].sort(), }) } /** 组装 `/events/current` 信封(sections 用内部分区,顶层永远 200 形状)。 */ function currentEventsEnvelope(mode, items, query, inner) { return { requestId: 'mock-current-events', apiVersion: 'v1', readOnly: true, displayName: mode === 'ALARM' ? '当前报警' : '当前预警', timezone: 'Asia/Shanghai', mode, updatedAt: SNAPSHOT_AT, filters: { mode, level: query && query.level ? Number(query.level) : null, industry: (query && query.industry) || null, status: (query && query.status) || null, }, pagination: inner.pagination || { page: 1, pageSize: 100, total: 0, totalPages: 0, hasMore: false }, mapFilter: { mode, level: query && query.level ? Number(query.level) : null, industry: (query && query.industry) || null, status: (query && query.status) || null, deviceCodes: inner.deviceCodes || [], }, sections: { events: { state: inner.state, reason: inner.reason, message: inner.message, data: inner.data, }, workOrders: { state: 'EMPTY', reason: 'NO_WORK_ORDERS', message: null, data: null, }, }, } } // ---------------------------------------------------------------- 预警处置聚合面板(票 19) function warningRange(query) { const type = (query && query.range) || 'LAST_7_DAYS' const days = type === 'LAST_90_DAYS' ? 90 : type === 'LAST_30_DAYS' ? 30 : 7 if (query?.startTime && query?.endTime) return { type, startTime: query.startTime, endTime: query.endTime } const end = new Date('2026-09-02T10:00:00+08:00') const start = new Date(end.getTime() - days * 24 * 3600 * 1000) return { type, startTime: start.toISOString(), endTime: end.toISOString() } } function responseEventSource(query) { const mode = (query && (query.eventFamily || query.mode)) === 'ALARM' ? 'ALARM' : 'WARNING' const source = mode === 'ALARM' ? CURRENT_ALARMS : CURRENT_WARNINGS const industry = (query && query.industry) || '' return { mode, items: source.filter((item) => !industry || item.industry === industry) } } /** * 历史分析载荷(票 19;票 61 补逐事件行 + 服务端分页)。 * * 与后端 `CockpitHistoryAnalysisResponse` 同形:`rows[]` 小时桶 + `summary` + * **`events[]` 逐事件行**(形状与 `events/current` 的 `EventItem` 同构)+ `pagination`。 * `page`/`pageSize` 按后端语义真实切片(默认 100、上限 1000),所以 `pageSize=10` * 在 mock 上就能演示两页(单窗直取;多窗由前端合并后集合分页)。 * * 一处如实留档的取舍:样例事件时间固定在 `SNAPSHOT_AT` 附近,而面板请求的是 * 「现在」往前 N 天的窗口 —— 按窗口过滤事件会恒空,多页翻页在 mock 上就演不出来。 * 与既有 mock「固定快照日」同一取舍:**事件行不按请求窗口过滤**(只按族/行业过滤), * 小时桶仍按请求窗口生成(既有行为不变)。真实契约的窗口过滤由闸门注入载荷证明。 */ function historyAnalysisPayload(scenario, query) { const source = responseEventSource(query) const range = warningRange(query) const items = source.items.filter((item) => Date.parse(item.eventTime) >= Date.parse(range.startTime) && Date.parse(item.eventTime) <= Date.parse(range.endTime)) const buckets = [] const hour = 3600000 for (let time = Math.floor(Date.parse(range.startTime) / hour) * hour; time <= Date.parse(range.endTime); time += hour) { buckets.push({ timeBucket: new Date(time).toISOString(), eventCount: scenario === 'empty' ? 0 : items.filter((item) => Math.floor(Date.parse(item.eventTime) / hour) === time / hour).length, handledCount: 0 }) } const events = scenario === 'empty' ? [] : sortEventRows(source.items) const page = Number(query && query.page) > 0 ? Number(query.page) : 1 const pageSize = Number(query && query.pageSize) > 0 ? Number(query.pageSize) : 100 const from = Math.min((page - 1) * pageSize, events.length) const to = Math.min(from + pageSize, events.length) const total = events.length return { requestId: 'mock-history-analysis', apiVersion: 'v1', readOnly: true, mode: source.mode, range, updatedAt: SNAPSHOT_AT, pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0, hasMore: to < total }, // 票 61:事件行的总数与 `pagination.total` 同源(不由当前页反推)。 events: events.slice(from, to), rows: scenario === 'empty' ? [] : buckets, summary: { eventCount: total }, } } /** * `/events/handling-efficiency`(票 19;票 65 两族语义分流;**票 78 随票 74 契约两族分叉**)。 * * 形状照抄后端 `CockpitHandlingEfficiencyResponse`:`eventFamily` 回显 + `summary` * (条数/工单关联实况 + 两族各自的处置口径聚合)+ 状态计数 + `durationTrend` * (逐桶 `eventCount` 与结案时长)+ 逐事件行 `events[]`。 * * **两族契约分叉**(票 74 已交付,后端实测见 `verify/artifacts/t74/`): * · 预警族:工单口径六字段(`handledCount/inProgressCount/cancelledCount/` * `handlingEfficiency/averageDuration/workOrderStateCounts`)显式 `null`; * 预警自身流程给 `warningStateCounts`(WarningWorkflow 六态全铺、缺桶计 0, * 脏状态入「未知」桶)+ `summary.warningHandlingEfficiency`(已解除 ÷ 窗口内预警数, * 分母 0 → EMPTY/NO_WARNINGS)+ `summary.warningDuration`(无样本 → EMPTY/NO_CLOSED_WARNINGS)。 * · 报警族:工单口径原样,三个 `warning*` 字段为 `null`。 * 六态计数从样例事件的 `status` 推导(认得出的入对应桶,认不出的一律入 UNKNOWN —— * 与后端 `normalizeWarningStatus` 的归一规则同构),六态合计恒等于条数; * 不为演示手调分布数字(map Notes 的静态样例裁定)。 */ function handlingEfficiencyPayload(scenario, query) { const source = responseEventSource(query) const range = warningRange(query) const blank = scenario === 'empty' const denominator = blank ? 0 : 3 const numerator = blank ? 0 : 2 const items = blank ? [] : source.items const isAlarm = source.mode === 'ALARM' // 预警不挂工单(实测),报警按等级 1 的样本给承接工单 —— 两族的关联实况刻意不同。 const linkedOf = (item) => isAlarm && Number(item.level) === 1 const linkedCount = items.filter(linkedOf).length const events = items.map((item) => { const linked = linkedOf(item) const stateCode = linked ? 3 : null return { eventNo: item.eventNo, deviceCode: item.deviceCode, deviceName: item.deviceName, level: item.level, levelColor: eventLevelColorOf(item.level), eventTime: item.eventTime, workOrderStateCode: stateCode, workOrderStateName: stateCode === null ? null : '处理中', durationHours: null, hasWorkOrder: linked, } }) // 票 78:WarningWorkflow 六态全铺(缺桶计 0),认不出的状态入 UNKNOWN 桶(后端同构)。 const warningStatuses = ['DRAFT', 'PENDING', 'RECEIVED', 'PROCESSING', 'HANDLED', 'CLOSED'] const warningStateCounts = isAlarm ? null : warningStatuses .map((stateCode) => ({ stateCode, stateName: { DRAFT: '草稿', PENDING: '待接收', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }[stateCode], count: items.filter((item) => item && item.status === stateCode).length, })) .concat((() => { const unknown = items.filter((item) => item && !warningStatuses.includes(item.status)).length return unknown > 0 ? [{ stateCode: 'UNKNOWN', stateName: '未知', count: unknown }] : [] })()) // 预警族自身的已解除率/时长:与后端同一算法(CLOSED ÷ 条数;无 CLOSED 样本即 EMPTY)。 const closedWarnings = isAlarm ? 0 : (warningStateCounts.find((row) => row.stateCode === 'CLOSED') || { count: 0 }).count const warningTotal = isAlarm ? 0 : items.length const summary = isAlarm ? { totalEvents: items.length, linkedWorkOrderCount: linkedCount, missingWorkOrderCount: items.length - linkedCount, handledCount: blank ? 0 : 2, inProgressCount: blank ? 0 : 1, cancelledCount: blank ? 0 : 1, handlingEfficiency: { value: blank ? 0 : numerator / denominator, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' }, averageDuration: { value: denominator ? 2.4 : 0, unit: 'HOURS', state: denominator ? 'AVAILABLE' : 'EMPTY', sampleCount: denominator ? 2 : 0, reason: denominator ? null : 'NO_CLOSED_WORK_ORDERS' }, // 票 78:报警族不回预警自身字段(后端显式 null,不缺键)。 warningHandlingEfficiency: null, warningDuration: null, } : { totalEvents: items.length, linkedWorkOrderCount: linkedCount, missingWorkOrderCount: items.length - linkedCount, // 票 78:工单口径在预警族显式 null(不把「不适用」数成 0)。 handledCount: null, inProgressCount: null, cancelledCount: null, handlingEfficiency: null, averageDuration: null, warningHandlingEfficiency: warningTotal > 0 ? { value: (closedWarnings / warningTotal).toFixed(4), numerator: closedWarnings, denominator: warningTotal, state: 'AVAILABLE', reason: null, basis: 'CLOSED_WARNINGS_OVER_WINDOW_WARNINGS' } : { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_WARNINGS', basis: 'CLOSED_WARNINGS_OVER_WINDOW_WARNINGS' }, warningDuration: closedWarnings > 0 ? { value: '1.25', unit: 'HOURS', sampleCount: closedWarnings, state: 'AVAILABLE', reason: null } : { value: '0.00', unit: 'HOURS', sampleCount: 0, state: 'EMPTY', reason: 'NO_CLOSED_WARNINGS' }, } return { requestId: 'mock-handling-efficiency', apiVersion: 'v1', readOnly: true, displayName: isAlarm ? '报警效率' : '预警效率', eventFamily: source.mode, range, updatedAt: SNAPSHOT_AT, pagination: { page: 1, pageSize: 100, total: items.length, totalPages: items.length ? 1 : 0, hasMore: false }, summary, // 正式端点仅提供结案平均时长,不编造平均响应时长。 // 票 20:键为后端 0–7 状态码(待派单…已取消)。旧的 1–7 键已随字典对齐改掉。 // 票 78:工单状态计数只属报警族;预警族显式 null。 workOrderStateCounts: isAlarm ? WORK_ORDER_STATES.map((state) => ({ stateCode: state.code, stateName: state.name, count: denominator ? state.code === 5 ? 2 : state.code === 6 ? 1 : state.code === 7 ? 1 : 0 : 0 })) : null, warningStateCounts, // 票 65:逐桶带 `eventCount`(事件发生量)。票 74 契约:预警族逐桶的 // `handledCount`/`averageDuration` 为 null(结案时长属工单口径),报警族保留。 durationTrend: blank ? [] : [1.4, 2.1, 2.4, 1.8, 2.6, 2.2, 2.4].map((value, index) => ({ timeBucket: `2026-09-${String(index + 1).padStart(2, '0')}`, eventCount: 2, handledCount: isAlarm ? 0 : null, averageDuration: isAlarm ? { value, unit: 'HOURS', state: 'AVAILABLE' } : null, })), events, } } function multidimensionalAnalysisPayload(scenario, query) { const source = responseEventSource(query) const count = (key) => [...new Set(source.items.map((item) => item[key]).filter(Boolean))].map((value) => ({ code: value, name: value, count: source.items.filter((item) => item[key] === value).length })) const levels = [1, 2, 3, 4].map((level) => ({ level, count: source.items.filter((item) => item.level === level).length })) return { requestId: 'mock-multidimensional-analysis', apiVersion: 'v1', readOnly: true, mode: source.mode, range: warningRange(query), updatedAt: SNAPSHOT_AT, dimensions: [ { dimensionCode: 'EVENT_TYPE', buckets: count('eventType').map((row) => ({ bucketCode: row.code, bucketLabel: row.name, count: row.count })) }, // 票 66:真实后端**恒定**回 CAUSE 维(缺成因走 `UNKNOWN_CAUSE` 兜底桶,服务端注释原句 // "The unknown bucket is always present so the cockpit never hides missing causes")。 // 样例照抄这一维:不抄的话「按成因」块在 mock 下永远不出现,样例比契约少一维是**形状**问题, // 不是为了「演示某项行为」补数据(见 map Notes 的静态样例裁定)。 { dimensionCode: 'CAUSE', buckets: [{ bucketCode: 'UNKNOWN_CAUSE', bucketLabel: '未知/缺失成因', count: source.items.length }] }, { dimensionCode: 'LEVEL', buckets: levels.map((row) => ({ bucketCode: String(row.level), bucketLabel: `${row.level}级`, count: row.count })) }, { dimensionCode: 'INDUSTRY', buckets: count('industry').map((row) => ({ bucketCode: row.code, bucketLabel: { GAS: '燃气', WATER_SUPPLY: '供水', DRAINAGE: '排水', MANHOLE: '窨井' }[row.code], count: row.count })) }, { dimensionCode: 'DISTRICT', buckets: [{ bucketCode: 'UNASSIGNED', bucketLabel: '未分区', count: source.items.length }] }, ], highlights: source.items.length ? [{ title: source.mode === 'ALARM' ? '高等级报警重点' : '高等级预警重点', detail: '按事件等级与数量识别', count: source.items.filter((item) => item.level === 1).length }] : [], } } function freshnessPayload(scenario, deviceCode = '') { const base = { requestId: 'mock-freshness', apiVersion: 'v1', readOnly: true, deviceCode: deviceCode || ARCHIVE.deviceCode, serverUpdatedAt: SNAPSHOT_AT, calibrationPeriodSeconds: 60, } if (scenario === 'empty') { return { ...base, updatedAt: null, statusTime: null, freshness: 'EMPTY', staleReason: null, missedCycles: 0, dataAgeSeconds: 0, retained: false, message: null } } if (scenario === 'degraded') { // 只给了 freshness,没给任何时间:界面必须显示占位符而不是「0 秒前」。 return { ...base, freshness: 'CURRENT' } } if (scenario === 'stale') { return { ...base, updatedAt: '2026-09-02T09:58:00+08:00', statusTime: null, freshness: 'EXPIRED', staleReason: 'TWO_UPDATE_CYCLES_MISSED', missedCycles: 2, dataAgeSeconds: 120, retained: true, message: '数据暂不可用/已过期' } } return { ...base, updatedAt: '2026-09-02T09:59:50+08:00', statusTime: '2026-09-02T09:59:55+08:00', freshness: 'CURRENT', staleReason: null, missedCycles: 0, dataAgeSeconds: 5, retained: false, message: null } } // ---------------------------------------------------------------- 基础设施四面板(票 17) /** * 票 17:基础设施模式四块面板的契约化样例。 * * 字段名与嵌套形状照抄后端四个响应类(`CockpitInfrastructureBaseArchivesResponse` / * `...OperatingInformationResponse` / `...ManagementInformationResponse` / `...EmergencySupportResponse`) * 与它们的演示载荷 `docs/cockpit/infrastructure-*-demo.html`,所以适配层单测证明的是 * 「转换吃得下真载荷」。 * * 三处刻意照抄真实形状的**坑**(不照抄就会漏掉真机才暴露的问题): * 1. **BigDecimal 在线上是字符串**(`"0.32"`):三块面板的样例都按字符串给,逼适配层用 * `numberish` 而不是 `Number()` 直吃; * 2. **`Distribution` 是三类维度共用结构**:应急保障的行业分布值在 `industry` 上、区域分布在 * `area` 上,`code` 一律是 `null`(后端的三个构造函数就是这么填的); * 3. **`pointCodes` 按面板各自的真实语义给**(票 49 裁定、票 50 落实): * 运行信息指标装的是**源设施编码**(真实载荷恒单元素、与一张图编码空间零交集—— * 面板因此不产定位按钮,票 50);应急保障样例仍指向 `MAP_POINTS` 真实点位(该面板保留 * 定位入口,票 50 非目标;真实侧该字段实测恒空、一个按钮都不画,出入见票 49 证据二)。 */ /** 四个面板的信封公共段(后端的 `requestId/apiVersion/readOnly/displayName/timezone`)。 */ function infraEnvelope(displayName, status) { return { requestId: `mock-${displayName}`, apiVersion: 'v1', readOnly: true, displayName, timezone: 'Asia/Shanghai', updatedAt: '2026-09-02T09:59:50+08:00', freshness: { status, reason: status === 'CURRENT' ? 'READY' : 'TWO_UPDATE_CYCLES_MISSED', updatedAt: '2026-09-02T09:59:50+08:00', serverUpdatedAt: '2026-09-02T10:00:00+08:00' }, } } /** 该场景下信封的新鲜度:空样例说 EMPTY,过期样例说 EXPIRED,其余 CURRENT。 */ function infraStatus(scenario) { if (scenario === 'empty') return 'EMPTY' if (scenario === 'stale') return 'EXPIRED' return 'CURRENT' } /** 基础档案样例(设施规模 / 行业分类 / 区域分布 / 档案摘要)。 */ function baseArchivesPayload(scenario, query = {}) { const status = infraStatus(scenario) // Fixture facts only: every aggregate is derived from the same filtered set. const facts = [ { district: 0, industry: 'WATER_SUPPLY', type: 'WATER_PLANT', name: '水厂', age: 'UNDER_5_YEARS', length: 0, commissioned: true }, { district: 0, industry: 'GAS', type: 'PUMP_STATION', name: '泵站', age: '5_TO_10_YEARS', length: 0, commissioned: true }, { district: 0, industry: 'GAS', type: 'GAS_PIPE', name: '燃气管线', age: '10_TO_20_YEARS', length: 4.2, commissioned: true }, { district: 1, industry: 'WATER_SUPPLY', type: 'WATER_PIPE', name: '供水管线', age: '20_YEARS_OR_OVER', length: 5.6, commissioned: false }, { district: null, industry: 'DRAINAGE', type: 'DRAINAGE_PIPE', name: '排水管线', age: '20_YEARS_OR_OVER', length: 2.6, commissioned: false }, ] const selected = status === 'EMPTY' ? [] : facts.filter((row) => (!query.industry || row.industry === query.industry) && (!query.district || normalizedDistrict(row.district) === query.district)) const countBy = (field) => [...new Set(selected.map((row) => row[field]))].map((code) => ({ code, count: selected.filter((row) => row[field] === code).length })) const selectedFilters = { industry: query.industry || null, district: query.district && query.district !== 'UNASSIGNED' ? Number(query.district) : null, districtFilter: !query.district ? 'NONE' : query.district === 'UNASSIGNED' ? 'UNASSIGNED' : 'ASSIGNED', } return { ...infraEnvelope('基础设施-基础档案', status), range: { type: status === 'EXPIRED' ? 'LAST_30_DAYS' : 'CURRENT_BASELINE' }, // 票 38 mock 同构:真实后端 `totals.scale` **恒 null**(t18 审计实证,五端如此)—— // mock 若给 'medium',误留的规模消费点在真实下显示 `—`、mock 下却显示「中型」, // 走查抓不到(票 08/18 记过的同构纪律)。`facilityTypes[].scale` 给真实形状: // 管线行是 "km"(长度单位串,不是规模语义),其余 null。 totals: { facilityCount: selected.length, lengthKm: selected.length ? selected.reduce((sum, row) => sum + row.length, 0).toFixed(2) : null, scale: null }, industryDistribution: countBy('industry'), facilityTypeDistribution: selected.map((row) => ({ code: row.type, name: row.name, count: 1, lengthKm: row.length || null, scale: row.length ? 'km' : null, range: null })), ageDistribution: countBy('age'), ageReference: { asOfDate: '2026-09-02', commissionedCount: selected.filter((row) => row.commissioned).length, builtCount: selected.filter((row) => !row.commissioned).length, unknownCount: 0 }, districtDistribution: countBy('district').map((row) => ({ district: row.code, districtName: row.code === null ? '未分区' : ['城南', '城北', '太常'][row.code], count: row.count })), selectedFilters, mapFilter: selectedFilters, excluded: scenario === 'degraded' ? { unknownTypeCount: null } : { unknownTypeCount: 0, typeKindMismatchCount: 0, smartDeviceCount: 1, futureDateCount: 0 }, } } /** 运行信息样例:三个行业块,指标覆盖三类能力 + 边界值(真 0 / 缺能力基准 / 超载)。 */ function operatingInformationPayload(scenario, query = {}) { const status = infraStatus(scenario) const envelope = infraEnvelope('基础设施-运行信息', status) const blank = status === 'EMPTY' // 票 49 裁定:`pointCodes` 实为**源设施编码**(真实载荷 6362 条无一例外恒单元素),不进一张图 // 对象序列 → 运行信息面板不产定位按钮(票 50)。样例按行业用真实同形的设施码 // (TCRQ/CNRQ 燃气、TCGC/CNGC 供水、TCWS/TCYS 排水),**不得**用 `MAP_POINTS` 的 MP-*—— // 否则 mock 下按钮能点、真实下全 MISSING,同构纪律(票 08/18/38)被打破。 // 票 68:设施点图层从运行信息行上枚举「可上图的设施实体」(facilityId/facilityKind/facilityStatus), // 所以 mock 的对应行也带这三个字段(同构纪律:不带上就永远演示不出设施点图层)。 const metric = (code, name, category, unit, value, capacity, ratio, load, pointCodes, facility = null) => ({ metricCode: code, metricName: name, category, unit, // 照抄线上形状:BigDecimal 是字符串。 value, capacityValue: capacity, loadRatio: ratio, loadState: load, sampledAt: '2026-09-02T09:59:30+08:00', freshness: { status: 'CURRENT', reason: 'SAMPLE_CURRENT', ageSeconds: 20 }, trend: [{ bucket: '2026-09-01', value }, { bucket: '2026-09-02', value }], abnormal: load === 'OVERLOAD', pointCodes, ...(facility || {}), }) const industries = blank ? [] : [ { industry: { code: 'GAS', key: 'gas', label: '燃气' }, state: 'READY', stateReason: 'READY', abnormalMetricCount: 0, metrics: [ metric('gas_supply_pressure', '燃气供应压力', 'SUPPLY', 'MPa', '0.32', '0.40', '0.8000', 'WATCH', ['TCRQ202609020001']), metric('gas_flow_rate', '燃气流量', 'FLOW', 'm3/h', '1200.5', '2000', '0.6003', 'NORMAL', ['CNRQ202609020001']), metric('gas_remaining_capacity', '燃气剩余承载能力', 'CAPACITY', 'm3', '800', '2000', '0.4000', 'NORMAL', ['TCRQ202609020002']), ], }, { industry: { code: 'WATER_SUPPLY', key: 'waterSupply', label: '供水' }, state: 'READY', stateReason: 'READY', abnormalMetricCount: 1, metrics: [ // 边界:真 0 是有效读数;超载项走 lv1 色键。 // 票 68:带设施实体身份(水厂),设施点图层据此取实体详情坐标上图。 metric('water_supply_flow', '供水流量', 'SUPPLY', 'm3/h', '0', '4000', '0.0000', 'NORMAL', ['TCGC202609020001'], { facilityId: '1', facilityKind: 'WATER_PLANT', facilityStatus: '0', }), metric('water_supply_load', '供水负荷', 'CAPACITY', 'm3/h', '5000', '4000', '1.2500', 'OVERLOAD', ['CNGC202609020001']), // 边界:能力基准缺失 → 负荷率也算不出来(界面显示占位符,不显示 0)。 metric('water_pressure', '供水压力', 'PRESSURE', 'MPa', '0.45', null, null, 'UNKNOWN', ['TCGC202609020002']), ], }, { industry: { code: 'DRAINAGE', key: 'drainage', label: '排水' }, state: 'READY', stateReason: 'READY', abnormalMetricCount: 0, metrics: [ metric('sewage_cod', '污水 COD', 'QUALITY', 'mg/L', '18.2', '50', '0.3640', 'NORMAL', ['TCWS202609020001']), // 票 68:带设施实体身份(泵站),与上面那条水厂一起覆盖「可上图 + 状态色」两条路径。 metric('rainwater_flow', '雨水流量', 'FLOW', 'm3/h', '880', '1000', '0.8800', 'WATCH', ['TCYS202609020001'], { facilityId: '2', facilityKind: 'PUMP_STATION', facilityStatus: '2', }), ], }, ] // 票 51:mock 必须**吃请求查询串**(至少 industry)—— 不吃的话 mock 下永远所有块全非空、 // 选行业不改变响应,真实缺陷在 mock 上演示不出来(票 08/18/38 记过的同一条纪律)。 // 照抄线上契约(2026-10-16 实测,载荷落盘 verify/artifacts/t51/):筛选把未选中块置空 // (`state: EMPTY` / `stateReason: NO_METRICS`,不删块、不改变块序);selectedIndustry 回显 // 命中块,未筛时回 null(线上未筛载荷同款,不再硬编码 GAS);mock 没有的行业也回显请求码。 const requested = blank ? '' : query.industry || '' const hit = requested ? industries.find((block) => block.industry.code === requested) : null const filtered = requested ? industries.map((block) => block.industry.code === requested ? block : { industry: block.industry, state: 'EMPTY', stateReason: 'NO_METRICS', abnormalMetricCount: 0, metrics: [] }, ) : industries return { ...envelope, range: { type: 'LAST_24_HOURS', start: '2026-09-01T10:00:00+08:00', end: '2026-09-02T10:00:00+08:00' }, selectedIndustry: hit ? hit.industry : requested ? { code: requested, key: '', label: '' } : null, industries: filtered, excluded: scenario === 'degraded' ? { unknownIndustryCount: null } : { unknownIndustryCount: 0, nonFormalIndustryCount: 0 }, } } // ---------------------------------------------------------------- 设施点实体详情(票 68) /** * 票 68:设施点图层补坐标用的实体详情样例(水厂 / 泵站各一条)。 * * 形状照抄真实 `/api/cockpit/v1/entities/{id}/detail`:坐标在 `coordinates` 上 * (缺坐标即 null,前端据此把该设施进「无地图位置」清单,不补默认值)。 * 两条样例与运行信息 mock 里带 `facilityId/facilityKind` 的指标行一一对应。 */ const FACILITY_ENTITY_FIXTURES = { 'WATER_PLANT|1': { category: 'WATER_PLANT', id: '1', code: 'WP001', name: 'XX自来水厂', status: '0', location: '沅陵县城东', coordinates: { longitude: 110.392, latitude: 28.437, endLongitude: null, endLatitude: null }, }, 'PUMP_STATION|2': { category: 'PUMP_STATION', id: '2', code: 'PS002', name: '城东泵站2', status: '2', location: '沅陵县城东', coordinates: { longitude: 110.385, latitude: 28.436, endLongitude: null, endLatitude: null }, }, } /** * 实体详情样例:查询串里的 `category` 决定查哪个设施表(与真接口同形)。 * 查不到就**抛错**(真接口是 404/业务错误,不是"返回一个空详情")。 */ function facilityEntityDetailPayload(scenario, requested, query = {}) { if (scenario === 'empty') throw new Error('mock: 实体不存在') const category = String(query.category || '').toUpperCase() const fixture = FACILITY_ENTITY_FIXTURES[`${category}|${requested}`] if (!fixture) throw new Error(`mock: 实体 ${requested || '(空)'} 不存在`) return { requestId: 'mock-entity-detail', apiVersion: 'v1', readOnly: true, category: fixture.category, id: fixture.id, code: fixture.code, name: fixture.name, status: fixture.status, location: fixture.location, district: null, districtName: '未分区', coordinates: fixture.coordinates, attributes: {}, updatedAt: '2026-09-02T09:59:50+08:00', } } /** 管理信息样例:四个「片区 × 行业」组合(含「未分区」与「有设备、没有设施」的城北)。 */ function managementInformationPayload(scenario, query = {}) { const status = infraStatus(scenario) const blank = status === 'EMPTY' const districtIndustry = blank ? [] : [ { district: 0, districtName: '城南', industry: 'WATER_SUPPLY', facilityCount: 1, equipmentCount: 1 }, { district: 1, districtName: '城北', industry: 'MANHOLE', facilityCount: 0, equipmentCount: 1 }, { district: 2, districtName: '太常', industry: 'GAS', facilityCount: 1, equipmentCount: 1 }, // 排水行带雨污二级分项(维护者 2026-11-02):与真实端点同形,码位 0-污水 1-雨水。 { district: null, districtName: '未分区', industry: 'DRAINAGE', facilityCount: 1, equipmentCount: 0, networkLevelCounts: [{ networkLevel: '0', facilityCount: 1 }] }, ].filter((row) => (!query.district || normalizedDistrict(row.district) === query.district) && (!query.industry || row.industry === query.industry)) const selectedFilters = { industry: query.industry || null, district: query.district && query.district !== 'UNASSIGNED' ? Number(query.district) : null, districtFilter: !query.district ? 'NONE' : query.district === 'UNASSIGNED' ? 'UNASSIGNED' : 'ASSIGNED', } return { ...infraEnvelope('基础设施-管理信息', status), selectedFilters, totals: { facilityCount: districtIndustry.reduce((sum, row) => sum + row.facilityCount, 0), equipmentCount: districtIndustry.reduce((sum, row) => sum + row.equipmentCount, 0), }, districtIndustry, mapFilter: selectedFilters, excluded: { unknownIndustryCount: 0, nonFormalIndustryCount: 0 }, } } /** 应急保障样例:三段各自成败(一段可用、一段空、一段不可用),三件套齐全。 */ function emergencySupportPayload(scenario) { const status = infraStatus(scenario) const envelope = infraEnvelope('基础设施-应急保障', status) const blank = status === 'EMPTY' const item = (objectCode, objectName, category, industry, area, pointCodes, counts) => ({ objectCode, objectName, category, industry, area, pointCodes, eventAssociations: { warningCount: counts[0], alarmCount: counts[1], workOrderCount: counts[2] }, }) // 空样例:三段都是 EMPTY("确实没有对象")。 const emptySections = { hazardSources: { state: 'EMPTY', stateReason: 'NO_OBJECTS', items: [] }, protectionTargets: { state: 'EMPTY', stateReason: 'NO_OBJECTS', items: [] }, emergencyResources: { state: 'EMPTY', stateReason: 'NO_OBJECTS', items: [] }, } const sections = blank ? emptySections : { // 三段**各自成败**:危险源可用、防护目标不可用、应急资源可用 —— // 「一段坏了不遮盖其它段」在 mock 上直接可演示(票 17 与票 11 同一条纪律)。 hazardSources: { state: 'READY', stateReason: 'READY', items: [ item('HS-WS-001', '太常片区污水管线危险源', 'HAZARD_SOURCE', 'WATER_SUPPLY', 'TC', ['MP-003'], [1, 1, 1]), ], }, protectionTargets: scenario === 'infrastructure-partial' ? { state: 'UNAVAILABLE', stateReason: 'SOURCE_UNAVAILABLE', items: [] } : { state: 'READY', stateReason: 'READY', items: [ // 区域为空 → 后端归一为未分区,界面照实说"未分区"而不是留白。 item('PT-DR-001', '污水处理厂防护目标', 'PROTECTION_TARGET', 'DRAINAGE', null, ['MP-003'], [0, 0, 1]), ], }, emergencyResources: { state: 'READY', stateReason: 'READY', items: [ item('ER-WS-001', '供水抢修物资库', 'EMERGENCY_RESOURCE', 'WATER_SUPPLY', 'CN', ['MP-001'], [0, 0, 0]), item('ER-GAS-001', '燃气应急抢险队', 'EMERGENCY_RESOURCE', 'GAS', 'CN', ['MP-002'], [0, 0, 0]), ], }, } const allItems = Object.values(sections).flatMap((section) => section.items) const countCategory = (category) => allItems.filter((entry) => entry.category === category).length return { ...envelope, selectedFilters: { category: null, industry: null, area: null }, totals: blank ? { emergencyResourceCount: 0, hazardSourceCount: 0, protectionTargetCount: 0, totalObjectCount: 0 } : { emergencyResourceCount: countCategory('EMERGENCY_RESOURCE'), hazardSourceCount: countCategory('HAZARD_SOURCE'), protectionTargetCount: countCategory('PROTECTION_TARGET'), totalObjectCount: allItems.length, }, sections, // 分布结构是三类维度共用:行业分布值在 `industry`、区域分布在 `area`,`code` 一律 null。 industryDistribution: blank ? [] : [ { category: null, industry: 'WATER_SUPPLY', area: null, count: 2 }, { category: null, industry: 'GAS', area: null, count: 1 }, { category: null, industry: 'DRAINAGE', area: null, count: 1 }, ], areaDistribution: blank ? [] : [ { category: null, industry: null, area: 'CN', count: 2 }, { category: null, industry: null, area: 'TC', count: 1 }, { category: null, industry: null, area: 'UNKNOWN', count: 1 }, ], categoryDistribution: blank ? [] : [ { category: 'EMERGENCY_RESOURCE', industry: null, area: null, count: 2 }, { category: 'HAZARD_SOURCE', industry: null, area: null, count: 1 }, { category: 'PROTECTION_TARGET', industry: null, area: null, count: 1 }, ], eventSummary: blank ? { warningCount: 0, alarmCount: 0, workOrderCount: 0 } : { warningCount: allItems.reduce((sum, entry) => sum + entry.eventAssociations.warningCount, 0), alarmCount: allItems.reduce((sum, entry) => sum + entry.eventAssociations.alarmCount, 0), workOrderCount: allItems.reduce((sum, entry) => sum + entry.eventAssociations.workOrderCount, 0), }, mapFilter: { category: null, industry: null, area: null, objectCodes: [] }, excluded: scenario === 'degraded' ? { unknownCategoryCount: null } : { unknownCategoryCount: 0, nonFormalIndustryCount: 0, duplicateObjectCount: 0 }, } } // ---------------------------------------------------------------- 运行监测三面板(票 18) /** * 票 18:运行监测模式三块面板的契约化样例。 * * 字段名与嵌套形状照抄后端三个响应类(`CockpitMonitoringOverviewResponse` / * `...DistributionResponse` / `...SituationResponse`)与它们的演示载荷 * `docs/cockpit/monitoring-{overview,distribution,operating-situation}-demo.html`, * 所以适配层单测证明的是「转换吃得下真载荷」。 * * 五处刻意照抄真实形状的**坑**(不照抄就会漏掉真机才暴露的问题): * 1. **BigDecimal 在线上是字符串**:`onlineRate.value` 给 `"0.7778"`,逼适配层用 `numberish`; * 2. **`district` 空值归一为字面量 `未分区`**(`CockpitMapSnapshotService` 的 * `UNASSIGNED_REGION`)—— 它是**真实取值**,界面要照实显示,不能读成"没有数据"; * 3. **状态分布给全六个状态**(含计数为 0 的),与后端 `for (status : values())` 一致; * 4. **`metricFreshness` 不在本端点上**(见 `domain/monitoringPanels.js` 文件头第 ① 条): * 本样例照真形状只给信封级 `updatedAt` + `freshness`,**不伪造一个逐设备采集时间**; * 5. **在线率分母为 0 时**,后端回 `state:"EMPTY"` 且 `value:"0.0000"` —— 那个 0 是 * "没有分母"的占位,样例里专门给一个 `monitoring-no-devices` 场景把它逼出来。 */ /** 三面板信封公共段(后端的 `requestId/apiVersion/readOnly/displayName/timezone/freshness`)。 */ function monitoringEnvelope(displayName, status) { return { requestId: `mock-${displayName}`, apiVersion: 'v1', readOnly: true, displayName, timezone: 'Asia/Shanghai', createdAt: status === 'EMPTY' ? null : '2026-09-01T08:00:00+08:00', updatedAt: '2026-09-02T09:59:50+08:00', freshness: { status, reason: status === 'CURRENT' ? 'READY' : status === 'EMPTY' ? 'NO_RESOLVED_DEVICES' : 'STALE_SAMPLES', updatedAt: '2026-09-02T09:59:50+08:00', serverUpdatedAt: '2026-09-02T10:00:00+08:00', }, } } /** 该场景下信封的新鲜度(与票 17 的 `infraStatus` 同款口径)。 */ function monitoringStatus(scenario) { if (scenario === 'empty') return 'EMPTY' if (scenario === 'stale') return 'STALE' return 'CURRENT' } /** * 监测概览样例(票 18 验收第 1 条:监测点/设备规模、在线率、类型分布、最近采集时间)。 * * 类型分布**照后端排序规则**给(服务按 `deviceCount` 降序、再按 `code`)—— * 界面若自作主张重排,真机走查那条「顺序 = 后端顺序」就会露馅。 */ function monitoringOverviewPayload(scenario, query) { const status = monitoringStatus(scenario) const envelope = monitoringEnvelope('监测概览', status) // 票 18 的专项场景:**分母为 0**(后端回 `state=EMPTY` + `value="0.0000"`)。 // 与 `empty` 不同:信封不是 EMPTY、类型分布仍非空,只有在线率"不适用"—— // 这种形状最容易骗人(照 value 画就是"在线率 0.0%",而真相是"没有可算的设备")。 const noDevices = scenario === 'monitoring-no-devices' const blank = status === 'EMPTY' const blankRate = blank || noDevices // **按请求的行业/设备类型真的过滤**(后端在服务端完成过滤,逐行读过 // `CockpitMonitoringOverviewService` 的三个 `selectedIndustry`/`deviceType` 判定)。 // 不这么做的话,走查里「切行业 → 面板数字变化」只能证明请求串里有参数, // 而画面纹丝不动 —— 真机走查第一轮正是这样红了一条(假绿的反面:请求对了、界面没变)。 const industry = (query && query.industry) || '' const deviceType = (query && query.deviceType) || '' // 四个行业的设备账本(照后端:每台设备归一个行业与一个类型)。 // **票 18 更正**:`MANHOLE`(窨井)现已是第四个正式行业,样例必须带上它 —— // 不带的话「窨井」筛选在界面上永远筛出空,看起来像坏了,而真相是样例没有该类数据。 // 类型码照后端真实形状:`equipment_type.id` 转字符串(后端测试里是 `"24"`)。 const allTypes = [ { deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', deviceCount: 3, onlineCount: 3, offlineCount: 0, staleCount: 0 }, { deviceType: { code: 'WATER_SUPPLY_SENSOR', name: '供水压力计' }, industry: 'WATER_SUPPLY', deviceCount: 3, onlineCount: 3, offlineCount: 0, staleCount: 0 }, { deviceType: { code: 'SEWAGE_ENV', name: '污水环境监测' }, industry: 'DRAINAGE', deviceCount: 3, onlineCount: 2, offlineCount: 0, staleCount: 1 }, { deviceType: { code: '24', name: '窨井监测' }, industry: 'MANHOLE', deviceCount: 2, onlineCount: 2, offlineCount: 0, staleCount: 0 }, ] // 监测点按行业分:GAS 2 个、WATER_SUPPLY 2 个、DRAINAGE 2 个、MANHOLE 1 个。 const pointsByIndustry = { GAS: 2, WATER_SUPPLY: 2, DRAINAGE: 2, MANHOLE: 1 } const selectedTypes = allTypes.filter( (row) => (!industry || row.industry === industry) && (!deviceType || row.deviceType.code === deviceType), ) const types = blank || noDevices ? [] : selectedTypes const equipmentCount = blank || noDevices ? 0 : selectedTypes.reduce((sum, row) => sum + row.deviceCount, 0) const onlineCount = blankRate ? 0 : selectedTypes.reduce((sum, row) => sum + row.onlineCount, 0) const offlineCount = blankRate ? 0 : selectedTypes.reduce((sum, row) => sum + row.offlineCount, 0) const staleCount = blankRate ? 0 : selectedTypes.reduce((sum, row) => sum + row.staleCount, 0) const denominator = onlineCount + offlineCount + staleCount // 监测点数按行业求和;带 `deviceType` 时后端按类型过滤点位,这里同口径。 const monitoringPointCount = blank || noDevices ? noDevices ? 2 : 0 : selectedTypes.reduce((sum, row) => sum + (pointsByIndustry[row.industry] || 0), 0) const rate = denominator === 0 ? '0.0000' : (onlineCount / denominator).toFixed(4) // 9 台里 8 台在线 1 台滞后 → 全量在线率 8/9(照后端口径:分母 = 在线 + 离线 + 滞后)。 return { ...envelope, range: { type: 'CURRENT_BASELINE' }, counts: blank ? { monitoringPointCount: 0, equipmentCount: 0, facilityCount: 0, infrastructureFacilityCount: 0 } : { monitoringPointCount, equipmentCount, facilityCount: 0, infrastructureFacilityCount: 0 }, status: blankRate ? { onlineCount: 0, offlineCount: 0, staleCount: 0, unknownOrMissingCount: noDevices ? 2 : 0, denominator: 0 } : { onlineCount, offlineCount, staleCount, unknownOrMissingCount: 0, denominator }, // BigDecimal 是字符串;分母为 0 时 state 是 EMPTY 且 value 是 "0.0000"(**不是**"在线率 0%")。 onlineRate: blankRate ? { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_RESOLVED_DEVICES' } : { value: rate, numerator: onlineCount, denominator, state: 'AVAILABLE', reason: null }, typeDistribution: types, // 票 62 追加需求:逐设备台账 `devices[]`(与 `counts.equipmentCount` 同源同口径)。 // 用与类型分布同一份账本**逐台展开**——行数结构性等于 `equipmentCount`, // 不是另写一组固定数字(那样两处读数早晚会漂)。 devices: blank || noDevices ? [] : overviewDevices(selectedTypes), mapFilter: { industry: industry || null, deviceType: deviceType || null, pointCodes: blank ? [] : ['MP-001', 'MP-002', 'MP-003'], deviceCodes: [] }, // `degraded` 走字段缺失样例:剔除数缺失,适配层要记 missing 而不是当 0。 excluded: scenario === 'degraded' ? { unknownTypeCount: null } : { unknownTypeCount: 0, typeKindMismatchCount: 0, nonFormalIndustryCount: 0 }, } } /** * 逐设备台账样例(票 62 追加需求)。 * * **从类型账本逐台展开**,不是另写一组固定行:后端 `build()` 里逐设备行与 * `counts.equipmentCount` 出自同一次遍历,mock 也照这个结构保证——行数恒等于 * `equipmentCount`(改账本就同时改两处,不会漂)。 * * 每行的状态按该类型的 online/offline/stale 配比逐台给出(前 N 台在线、接着离线、 * 再接着滞后),所以「设备台账」在 mock 上能看到三种状态并存,而不是清一色在线。 */ function overviewDevices(selectedTypes) { const devices = [] for (const row of selectedTypes || []) { const industry = row.industry const typeCode = row.deviceType.code const typeName = row.deviceType.name for (let index = 0; index < row.deviceCount; index += 1) { const seq = String(index + 1).padStart(2, '0') const deviceCode = `EQ-${industry}-${typeCode}-${seq}` const state = index < row.onlineCount ? 'ONLINE' : index < row.onlineCount + row.offlineCount ? 'OFFLINE' : 'STALE' devices.push({ deviceCode, deviceName: `${typeName}-${seq}`, deviceType: { code: typeCode, name: typeName }, industry, monitoringState: state, // 前半数设备挂到监测点上,后半数留空 —— 台账里"未关联点位"是真实取值, // 界面必须照实说(不拿空串冒充点位编码)。 monitoringPointCode: index % 2 === 0 ? `MP-${industry}-${seq}` : null, sampledAt: '2026-09-02T09:59:00+08:00', createdAt: '2026-08-31T08:00:00+08:00', }) } } return devices } /** 一个分布 marker(监测点或未关联设备;两族字段名只差 `point*` / `device*`)。 */ function distributionPoint(pointCode, pointName, district, industry, typeCode, typeName, pointStatus, pointCodes, coord) { return { pointCode, pointName, createdAt: '2026-08-31T08:00:00+08:00', districtName: district === null ? '未分区' : ['城南', '城北', '太常'][district], district, industry, deviceType: { code: typeCode, name: typeName }, pointStatus, statusReasons: pointStatus === 'ALARM' ? [`${pointCodes[0] || 'EQ'} 报警`] : [], ...coord, } } /** * 监测分布样例(票 18 验收第 2 条)。 * * **分页窗口横跨「监测点 + 未关联设备」一条总序列**(与 `map-snapshot` 同一套后端语义): * 这里 6 个监测点 + 2 台未关联设备 = 8 条,页大小由请求给(走查现场可改), * 所以「还有下一页」在 mock 上真的能演示(Q4 裁定台账按端点 `pagination` 渐进加载)。 * * 三份分布与状态计数**都从过滤后的集合现算**(照抄后端 `build()` 的顺序: * 先按筛选挑 marker,再聚合,最后切片分页)。为什么不写死一组数字: * 面板的分布若与筛选无关,「筛选真的作用到面板数据上了吗」这条断言就退化成在看请求串 —— * 筛错了、或者根本没传,画面都一样,是**假绿**(票 08 踩过同一个坑)。 */ function monitoringDistributionPayload(scenario, query, overrides = {}) { const status = monitoringStatus(scenario) const envelope = monitoringEnvelope('监测分布', status) const blank = status === 'EMPTY' const filters = { industry: (query && query.industry) || '', district: (query && query.district) || '', // 票 37:类型筛选真的过滤(后端 `matchesFilters` 逐行读过:类型码精确匹配, // 不可推导的对象在带该筛选时被排除)——不这么做,「点类型行 → 分布变窄」就是假绿。 deviceType: (query && query.deviceType) || '', pointStatus: (query && query.pointStatus) || '', } const pointList = blank ? [] : [ distributionPoint('MP-001', '城南测试监测点', 0, 'WATER_SUPPLY', 'WATER_SUPPLY_SENSOR', '供水压力计', 'NORMAL', [], { longitude: 110.39832789395186, latitude: 28.434456975811777, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }), distributionPoint('MP-002', '太常测试监测点', 2, 'GAS', 'GAS_SENSOR', '燃气浓度计', 'ALARM', ['EQ-ASSOC-001'], { longitude: 110.36299369455823, latitude: 28.452196938060194, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }), // 空区域归一为字面量「未分区」——真实取值,界面必须照实显示。 distributionPoint('MP-003', '污水排口监测点', null, 'DRAINAGE', 'SEWAGE_ENV', '污水环境监测', 'WARNING', ['EQ-FREE-003'], { longitude: 110.143, latitude: 28.454, coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' } }), distributionPoint('MP-004', '排水泵站监测点', 1, 'DRAINAGE', 'SEWAGE_ENV', '污水环境监测', 'NORMAL', [], { longitude: 110.144, latitude: 28.455, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }), distributionPoint('MP-005', '供水二厂监测点', 0, 'WATER_SUPPLY', 'WATER_SUPPLY_SENSOR', '供水压力计', 'OFFLINE', [], { longitude: 110.145, latitude: 28.456, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }), distributionPoint('MP-006', '预留无设备监测点', 2, 'GAS', 'GAS_SENSOR', '燃气浓度计', 'EMPTY', [], { longitude: 110.146, latitude: 28.457, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }), // 票 18 更正:窨井(`MANHOLE`)是第四个正式行业,样例带上它 —— // 类型码照后端真实形状(`equipment_type.id` 转字符串)。 distributionPoint('MP-007', '窨井监测点', 1, 'MANHOLE', '24', '窨井监测', 'NORMAL', [], { longitude: 110.147, latitude: 28.458, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }), ] const equipmentList = blank ? [] : [ { deviceCode: 'EQ-FREE-002', deviceName: '智能窨井-002', createdAt: null, district: null, districtName: '未分区', industry: null, // 类型不可推导时后端给 null(默认不排除对象)——界面据此照实说"类型未知"。 deviceType: null, pointStatus: 'NORMAL', statusReasons: [], longitude: 110.155, latitude: 28.459, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, }, { deviceCode: 'EQ-FREE-004', deviceName: '零坐标设备-004', createdAt: null, district: 2, districtName: '太常', industry: 'GAS', deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, pointStatus: 'OFFLINE', statusReasons: [], longitude: 0, latitude: 0, coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' }, }, ] // 后端 `matchesMarker()`:行业与状态按值比对,区域同样(`未分区` 是真实取值)。 // 票 37:`deviceType` 按 **marker 自身类型码**精确匹配(本端点服务逐行读过: // `isBlank(deviceType) || type.code === deviceType`——与 map-snapshot 的设备级 OR 归并 // 是两条路径);类型不可推导(`deviceType` 为 null)的对象在带该筛选时被排除(后端同款)。 const selected = [...pointList, ...equipmentList].filter((item) => { if (filters.industry && item.industry !== filters.industry) return false if (filters.pointStatus && !matchesStatusFilter(item, filters.pointStatus.split(','))) return false if (filters.district && (normalizedDistrict(item.district)) !== filters.district) return false if (filters.deviceType) { if (!item.deviceType || item.deviceType.code !== filters.deviceType) return false } return true }) const points = selected.filter((item) => 'pointCode' in item) const devices = selected.filter((item) => 'deviceCode' in item) /** 一条分布行(按给定维度键聚合;`pointCount` 只数监测点、`deviceCount` 只数未关联设备)。 */ const distributionRow = (key, extra = {}) => { const bucket = selected.filter((item) => key(item) === extra.value) const counts = { alarm: 0, warning: 0, workOrder: 0, normal: 0, offline: 0, empty: 0 } for (const item of bucket) { const map = { ALARM: 'alarm', WARNING: 'warning', WORK_ORDER: 'workOrder', NORMAL: 'normal', OFFLINE: 'offline', EMPTY: 'empty' } const slot = map[item.pointStatus] if (slot) counts[slot] += 1 } return { ...extra.build, pointCount: bucket.filter((item) => 'pointCode' in item).length, deviceCount: bucket.filter((item) => 'deviceCode' in item).length, statusCounts: counts, } } // 分页:窗口横跨两类对象(一条总序列)。 const page = Number(query && query.page) > 0 ? Number(query.page) : 1 // 页大小的**默认值照后端**(500)。但那样 6 个点的样例一页就到底, // 「台账自动渐进装载」这条验收在真机上永远演示不出来 —— 所以走查可用 // `window.__COCKPIT_MOCK_MONITORING__ = { pageSize: 3 }` 现场压小页大小 // (与票 08 地图摘要的 `__COCKPIT_MOCK_MAP__` 同一套手法)。 const fallbackPageSize = Number(overrides.pageSize) > 0 ? Number(overrides.pageSize) : 500 const pageSize = Number(query && query.pageSize) > 0 ? Number(query.pageSize) : fallbackPageSize // 票 62(执行票同期后端已补齐回显):快照 token 与 map-snapshot 同一套纪律—— // · 第 1 页可不带,后端开新快照并**在响应里回显**; // · 第 2 页起**必须**带,且要与当前快照一致,否则按后端原样抛(400 / SNAPSHOT_EXPIRED)。 // 注意 `query` 是**已解析的参数对象**(`context.query`),不是原始路径串。 const snapshotAt = (query && query.snapshotAt) || '' const snapshotToken = overrides.snapshotAt || SNAPSHOT_AT if (page > 1 && !snapshotAt) { // 后端对「第 2 页没带快照」回 400 + INVALID_REQUEST。mock 侧统一出口只认 // `REQUEST_REASON` 白名单,所以这里落成 `unavailable`(断言看的是「请求被拒、 // 台账停链且不回滚已装载内容」,不是具体原因码)。 const error = new Error('mock: 监测分布第 2 页起必须带 snapshotAt') error.reason = REQUEST_REASON.UNAVAILABLE throw error } if (snapshotAt && snapshotAt !== snapshotToken) { const error = new Error('mock: 监测分布快照已失效') error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED throw error } const from = Math.max(0, (page - 1) * pageSize) const to = from + pageSize const slice = selected.slice(from, to) // 三份分布(**全量口径**:先聚合完再切片,所以与页码无关,只与筛选有关)。 const districtKeys = [...new Set(selected.map((item) => normalizedDistrict(item.district)))].sort() const industryKeys = ['GAS', 'WATER_SUPPLY', 'DRAINAGE', 'MANHOLE'].filter((code) => selected.some((item) => item.industry === code)) const typeKeys = [...new Map(selected.filter((item) => item.deviceType).map((item) => [item.deviceType.code, item.deviceType])).entries()] return { ...envelope, range: { type: 'CURRENT_BASELINE' }, // 票 62:快照标记回显(与真实后端执行票同期补齐的字段同名同义), // 调用方第 2 页起原样回带。 snapshotAt: snapshotToken, districtDistribution: districtKeys.map((district) => distributionRow((item) => normalizedDistrict(item.district), { value: district, build: { district: district === 'UNASSIGNED' ? null : Number(district), districtName: district === 'UNASSIGNED' ? '未分区' : ['城南', '城北', '太常'][Number(district)] } })), industryDistribution: industryKeys.map((industry) => distributionRow((item) => item.industry, { value: industry, build: { industry } })), deviceTypeDistribution: typeKeys.map(([code, type]) => distributionRow((item) => (item.deviceType ? item.deviceType.code : null), { value: code, build: { deviceType: type, industry: (selected.find((item) => item.deviceType && item.deviceType.code === code) || {}).industry || null }, }), ), // 状态分布:**全六个状态都给**(含 0),与后端 `for (status : values())` 一致。 pointStatusDistribution: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((code) => ({ status: code, count: selected.filter((item) => item.pointStatus === code).length, })), mapFilter: { industry: filters.industry || null, deviceType: filters.deviceType || null, pointStatus: filters.pointStatus || null, district: filters.district && filters.district !== 'UNASSIGNED' ? Number(filters.district) : null, districtFilter: !filters.district ? 'NONE' : filters.district === 'UNASSIGNED' ? 'UNASSIGNED' : 'ASSIGNED', pointCodes: points.map((item) => item.pointCode), deviceCodes: devices.map((item) => item.deviceCode), }, pagination: { page, pageSize, total: selected.length, totalPages: selected.length ? Math.ceil(selected.length / pageSize) : 0, hasMore: to < selected.length }, monitoringPoints: slice.filter((item) => 'pointCode' in item), unassociatedEquipment: slice.filter((item) => 'deviceCode' in item), excluded: scenario === 'degraded' ? { unknownTypeCount: null } : { unknownTypeCount: 0, typeKindMismatchCount: 0, nonFormalIndustryCount: 0, invalidCoordinateCount: selected.filter((item) => item.coordinateValidation && !item.coordinateValidation.renderable).length }, } } /** 趋势桶:按小时铺一串(照后端「业务时区小时分桶」的形状,键是带偏移的 ISO 串)。 */ function hourlyTrend(counts, startHour = 1) { return counts.map((count, index) => ({ bucketStart: `2026-09-02T${String(startHour + index).padStart(2, '0')}:00:00+08:00`, count, })) } /** * 运行概况样例(票 18 验收第 3 条:事件趋势、离线设备、异常态势)。 * * 四处照后端语义: * 1. **预警与报警分成两条趋势**(服务注释原文:warnings feed warningTrend only, * alarms feed alarmTrend only)—— 样例里两条形状不同,合并就会当场露馅; * 2. `range` 回显实际生效窗口(服务回 `TREND_WINDOW` + start/end); * 3. `metricStatusDistribution` 给全六态(含 0); * 4. `abnormalEquipment` 只装**异常**设备(服务定义 WARNING/ALARM/EXPIRED/OFFLINE 四态)—— * 所以"离线设备清单"是它的子集,界面据此分开说。 * 票 37:`deviceType` 生效时 3/4 两段(六态计数、异常清单、行业覆盖)一并收窄(假绿防线)。 */ function monitoringSituationPayload(scenario, query) { const status = monitoringStatus(scenario) const envelope = monitoringEnvelope('运行概况', status) const blank = status === 'EMPTY' // 票 37:`deviceType` 真的过滤异常清单(后端服务同款:类型码精确匹配), // 否则「点类型行 → 运行概况变窄」也是假绿。 const deviceType = (query && query.deviceType) || '' // 窗口照后端回显:显式区间原样带出(`resolveWindow`),不传时是最近 24 小时。 const range = { type: 'TREND_WINDOW', startTime: (query && query.startTime) || '2026-09-01T10:00:00+08:00', endTime: (query && query.endTime) || '2026-09-02T10:00:00+08:00', } const abnormal = blank ? [] : [ { deviceCode: 'EQ-FREE-001', deviceName: '供水压力计-001', createdAt: null, deviceType: { code: 'WATER_SUPPLY_SENSOR', name: '供水压力计' }, industry: 'WATER_SUPPLY', district: 0, districtName: '城南', abnormalType: 'ALARM', status: 'ALARM', statusReasons: ['压力超高'], longitude: 110.141, latitude: 28.452, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }, }, { // 离线设备:它是异常清单的一个子集,界面据此分开报"离线几台"与"异常清单里有什么"。 deviceCode: 'EQ-FREE-004', deviceName: '零坐标设备-004', createdAt: null, deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', district: 2, districtName: '太常', abnormalType: 'OFFLINE', status: 'OFFLINE', statusReasons: [], longitude: 0, latitude: 0, coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' }, }, ].filter((item) => !deviceType || (item.deviceType && item.deviceType.code === deviceType)) return { ...envelope, range, metricStatusDistribution: blank ? [ { status: 'NORMAL', count: 0 }, { status: 'WARNING', count: 0 }, { status: 'ALARM', count: 0 }, { status: 'EXPIRED', count: 0 }, { status: 'OFFLINE', count: 0 }, { status: 'UNKNOWN', count: 0 }, ] : [ // 票 37:`deviceType` 生效时六态计数随筛选收窄(与 `abnormalEquipment` 同一集合), // 不收窄的话「异常统计」与"清单条数"会对不上 —— 又一处假绿。 ...(deviceType && deviceType !== 'GAS_SENSOR' ? [] : [ { status: 'NORMAL', count: 6 }, { status: 'WARNING', count: 1 }, { status: 'ALARM', count: 1 }, { status: 'EXPIRED', count: 0 }, { status: 'OFFLINE', count: 1 }, { status: 'UNKNOWN', count: 0 }, ]), ...(deviceType === 'GAS_SENSOR' ? [ { status: 'NORMAL', count: 0 }, { status: 'WARNING', count: 0 }, { status: 'ALARM', count: 0 }, { status: 'EXPIRED', count: 0 }, { status: 'OFFLINE', count: 1 }, { status: 'UNKNOWN', count: 0 }, ] : []), ], // 两条趋势形状不同(预警少、报警多),合并成一条就会露馅。 warningTrend: blank ? [] : hourlyTrend([0, 1, 0, 0, 2, 1, 0, 0]), alarmTrend: blank ? [] : hourlyTrend([1, 0, 3, 2, 0, 1, 4, 2]), industryCoverage: blank ? [] : [ // 票 37:类型生效时行业覆盖随之收窄(本端点唯一类型事实归 GAS_SENSOR)。 ...(deviceType && deviceType !== 'GAS_SENSOR' ? [] : [{ industry: 'GAS', deviceCount: 3, abnormalCount: 1, statusCounts: statusCountsOf(2, 0, 0, 0, 1, 0) }]), ...(!deviceType || deviceType === 'WATER_SUPPLY_SENSOR' ? [{ industry: 'WATER_SUPPLY', deviceCount: 3, abnormalCount: 1, statusCounts: statusCountsOf(2, 0, 1, 0, 0, 0) }] : []), ...(!deviceType ? [{ industry: 'DRAINAGE', deviceCount: 3, abnormalCount: 1, statusCounts: statusCountsOf(2, 1, 0, 0, 0, 0) }] : []), ], abnormalEquipment: abnormal, mapFilter: { industry: (query && query.industry) || null, deviceType: deviceType || null, deviceCodes: abnormal.filter((item) => item.coordinateValidation.renderable).map((item) => item.deviceCode), }, pagination: { page: 1, pageSize: 100, total: abnormal.length, totalPages: abnormal.length ? 1 : 0, hasMore: false }, excluded: scenario === 'degraded' ? { unknownTypeCount: null } : { unknownTypeCount: 0, typeKindMismatchCount: 0, nonFormalIndustryCount: 0, invalidCoordinateCount: 1 }, } } /** 运行概况 `industryCoverage.statusCounts` 的六态构造(与 `MetricStatusCount` 同序)。 */ function statusCountsOf(normal, warning, alarm, expired, offline, unknown) { return { normal, warning, alarm, expired, offline, unknown } } // ================================================================ 票 20:总体概览三端点 /** * 概览信封(三个端点同款:`requestId/apiVersion/readOnly/timezone/updatedAt/freshness`)。 * * 与 `infraEnvelope`/`monitoringEnvelope` 同形,但**不带 `displayName`** —— * 概览的三个响应类里只有 `warning-settings` 有它,另两个没有(逐类读过)。 */ function overviewEnvelope(updatedAt) { const status = updatedAt ? 'CURRENT' : 'EMPTY' return { requestId: 'mock-overview', apiVersion: 'v1', readOnly: true, timezone: 'Asia/Shanghai', updatedAt: updatedAt || null, freshness: { status, reason: updatedAt ? 'READ_ONLY_AGGREGATE' : 'NO_SNAPSHOT', updatedAt: updatedAt || null, serverUpdatedAt: SNAPSHOT_AT }, } } // ================================================================ 票 05–08:总体概览四模块端点 /** * 四模块信封(规格 §0:`requestId/apiVersion/readOnly/timezone/updatedAt/freshness`)。 * * 与旧 `overviewEnvelope` 的区别:**空态要能带固定行**。旧 helper 用 `updatedAt` 有无 * 推 `EMPTY`,而四模块的空态恰恰是「freshness=EMPTY + 仍回 4 类型/3 片区/4 等级/7 天」—— * 前端靠它画「无数据 0」(规格 §6.7 的例外)。所以这里显式给 `status/reason`。 */ function overviewModulesEnvelope(status, updatedAt = SNAPSHOT_AT, reason = null) { return { requestId: 'mock-overview-modules', apiVersion: 'v1', readOnly: true, timezone: 'Asia/Shanghai', updatedAt: updatedAt || null, freshness: { status, reason, updatedAt: updatedAt || null, serverUpdatedAt: SNAPSHOT_AT }, } } /** * `/overview/perception`(票 05;规格 §2)。 * * success 取**后端实测真值**(10 台 / 在线 3 / 离线 7 / 在线率 0.3000;类型 3·5·0·1; * 片区 6·3·1;excluded.unknownTypeCount = 1 是那台悬空类型设备,如实保留)。 * empty **仍回 4 类型 + 3 片区行(全 0)**:固定卡必须画 0,不是"无数据"。 */ function overviewPerceptionPayload(scenario) { const types = [ { code: '1', name: '供水设备', deviceCount: 3, onlineCount: 1, offlineCount: 2, monitoringContent: '压力 / 流量 / 漏失', monitoringContentSource: 'DERIVED' }, { code: '2', name: '排水设备', deviceCount: 5, onlineCount: 2, offlineCount: 3, monitoringContent: '液位 / 流量 / 内涝', monitoringContentSource: 'DERIVED' }, { code: '3', name: '燃气设备', deviceCount: 0, onlineCount: 0, offlineCount: 0, monitoringContent: '可燃气体 / 压力', monitoringContentSource: 'FALLBACK' }, { code: '4', name: '窨井设备', deviceCount: 1, onlineCount: 0, offlineCount: 1, monitoringContent: '井盖状态 / 液位', monitoringContentSource: 'FALLBACK' }, ] const districts = [ { district: 0, name: '城南', deviceCount: 6 }, { district: 1, name: '城北', deviceCount: 3 }, { district: 2, name: '太常', deviceCount: 1 }, ] if (scenario === 'empty') { return { ...overviewModulesEnvelope('EMPTY', null, 'NO_DEVICES'), totals: { deviceCount: 0, onlineCount: 0, offlineCount: 0, noStatusCount: 0, onlineRate: { value: '0', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_DEVICES' } }, byType: types.map((row) => ({ ...row, deviceCount: 0, onlineCount: 0, offlineCount: 0 })), byDistrict: districts.map((row) => ({ ...row, deviceCount: 0 })), excluded: { unknownTypeCount: 0, unassignedDistrictCount: 0 }, } } if (scenario === 'degraded') { // 缺监测内容小字与 excluded 段:卡片仍画(计数在),小字不画;界面不伪造小字。 return { ...overviewModulesEnvelope('CURRENT'), totals: { deviceCount: 10, onlineCount: 3, offlineCount: 7, noStatusCount: 0, onlineRate: { value: '0.3000', numerator: 3, denominator: 10, state: 'AVAILABLE', reason: null } }, byType: types.map(({ monitoringContent, monitoringContentSource, ...row }) => row), byDistrict: districts, } } return { ...overviewModulesEnvelope('CURRENT'), totals: { deviceCount: 10, onlineCount: 3, offlineCount: 7, noStatusCount: 0, onlineRate: { value: '0.3000', numerator: 3, denominator: 10, state: 'AVAILABLE', reason: null } }, byType: types, byDistrict: districts, excluded: { unknownTypeCount: 1, unassignedDistrictCount: 0 }, } } /** * `/overview/pipe-network`(票 06;规格 §3)。success 用实测真值(6762 条 / 78.55 km)。 * empty 回 `pipeCount=0` 且固定 3 行 0 —— 面板按空态画占位,**不画假环**。 */ function overviewPipeNetworkPayload(scenario) { const types = [ { code: 'WATER_PIPE', name: '供水管道', count: 2489, lengthKm: '19.10', ratio: '0.3681' }, { code: 'DRAINAGE_PIPE', name: '排水管道', count: 3574, lengthKm: '52.26', ratio: '0.5285' }, { code: 'GAS_PIPE', name: '燃气管道', count: 699, lengthKm: '7.18', ratio: '0.1034' }, ] if (scenario === 'empty') { return { ...overviewModulesEnvelope('EMPTY', null, 'NO_PIPES'), totals: { pipeCount: 0, lengthKm: '0.00' }, byPipeType: types.map((row) => ({ ...row, count: 0, lengthKm: '0.00', ratio: '0' })), excluded: { unclassifiedCount: 0 }, state: 'EMPTY', reason: 'NO_PIPES', } } return { ...overviewModulesEnvelope('CURRENT'), totals: { pipeCount: 6762, lengthKm: '78.55' }, byPipeType: types, excluded: { unclassifiedCount: 0 }, state: 'AVAILABLE', reason: null, } } /** * `/overview/warning-study`(票 07;规格 §4)。 * * success:今日 5 / 未解除 18(PENDING 11 / PROCESSING 5 / HANDLED 2), * 分级 9·6·2·1(**和 = 18** —— 规格示例曾把全量 42 的分级抄进来,是错的)。 * 类型排行的 `code` 取本地事件族同值,使「点击筛选」在 mock 上真的命中; * 真实后端返回 `WARN-*` 码,前端按精确 code 匹配,不匹配时如实提示覆盖范围(G11)。 */ function overviewWarningStudyPayload(scenario) { const levels = [ { level: 1, name: '一级预警', count: 9 }, { level: 2, name: '二级预警', count: 6 }, { level: 3, name: '三级预警', count: 2 }, { level: 4, name: '四级预警', count: 1 }, ] if (scenario === 'empty') { return { ...overviewModulesEnvelope('EMPTY', null, 'NO_WARNINGS'), today: { newCount: 0, yesterdayCount: 0, delta: 0 }, unresolved: { count: 0, statuses: { PENDING: 0, RECEIVED: 0, PROCESSING: 0, HANDLED: 0 } }, byLevel: levels.map((row) => ({ ...row, count: 0 })), typeRanking: { rangeDays: 30, total: 0, top: [] }, } } if (scenario === 'degraded') { // 缺排行段:等级卡照画,排行整块不画(不伪造榜单)。 return { ...overviewModulesEnvelope('CURRENT'), today: { newCount: 5, yesterdayCount: 0, delta: 5 }, unresolved: { count: 18, statuses: { PENDING: 11, RECEIVED: 0, PROCESSING: 5, HANDLED: 2 } }, byLevel: levels, } } return { ...overviewModulesEnvelope('CURRENT'), today: { newCount: 5, yesterdayCount: 0, delta: 5 }, unresolved: { count: 18, statuses: { PENDING: 11, RECEIVED: 0, PROCESSING: 5, HANDLED: 2 } }, byLevel: levels, // 排行**列出全部类型**且与 total 对得上(维护者 2026-10-26:页面写「共27起」而三行只凑出 23 起)。 // 下面 count 之和刻意等于 total(42) —— mock 也要自洽,否则走查看不出「对不上」这类问题。 typeRanking: { rangeDays: 30, total: 42, top: [ { code: 'GAS_CONCENTRATION', name: '燃气浓度预警', count: 14 }, { code: 'WATER_PRESSURE', name: '供水压力预警', count: 9 }, { code: 'SEWAGE_CONCENTRATION', name: '污水 COD 预警', count: 6 }, { code: 'WATER_FLOW', name: '供水流量预警', count: 5 }, { code: 'MANHOLE_COVER', name: '井盖状态预警', count: 4 }, { code: 'SEWAGE_LEVEL', name: '排水水位预警', count: 4 }, ], }, } } /** * `/overview/alarm-disposal`(票 08;规格 §5)。success 用**受控样本**(后端实测: * 已转 2 / 未转 0、处置率 1/2、平均响应 30.5h、平均处置 219.2h、等级 4 级 2 条)。 * * **7 日折线全 0 是真实形态,不是样例偷懒**(维护者 2026-10-23 反馈第 2 条复核): * 库里那 2 条报警的 create_time 是 2026-10-12/13,而窗口是「今天及前 6 天」; * 实测全库 90 条报警只分布在 10-10 / 10-12 / 10-16 三天,窗口内三种口径(audit_status、 * alarm_status、不过滤)都是 0 条。所以「已转 2 + 四级 2」与「折线 0」是**同时成立**的: * 上方读数是全量、折线是窗口内新增。面板用 `trendCaliberNote()` 把这层口径写明。 * * empty:固定 4 等级 0 行 + 7 天全 0 点,比例/时长 EMPTY(界面显示占位符,不是 0%)。 */ function overviewAlarmDisposalPayload(scenario) { const levels = [ { level: 1, name: '一级报警', count: 0 }, { level: 2, name: '二级报警', count: 0 }, { level: 3, name: '三级报警', count: 0 }, { level: 4, name: '四级报警', count: 2 }, ] const dates = ['2026-10-16', '2026-10-17', '2026-10-18', '2026-10-19', '2026-10-20', '2026-10-21', '2026-10-22'] // 每天两个读数(后端新字段):count = 当天新增;unhandledTotal = 截至当天未处理的累计。 // 样例取真实库形态:窗口内当天新增全 0(库里最新一条报警在 10-16),总未处理恒为 2。 // 不补 non-zero 的假新增 —— mock 要与真实数据同形,否则走查看不出「为什么折线贴底」。 const trend = (last = 0) => dates.map((date, index) => ({ date, count: index === dates.length - 1 ? last : 0, unhandledTotal: 2, })) if (scenario === 'empty') { return { ...overviewModulesEnvelope('EMPTY', null, 'NO_ALARMS'), today: { newCount: 0, yesterdayCount: 0, delta: 0 }, workOrderConversion: { convertedCount: 0, unconvertedCount: 0, totalCount: 0 }, disposal: { rate: { numerator: 0, denominator: 0, value: '0', state: 'EMPTY', reason: 'NO_ALARMS', basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' }, avgResponseDuration: { value: null, unit: 'HOURS', sampleCount: 0, invalidSampleCount: 0, state: 'EMPTY', reason: 'NO_RESPONSE_LOGS' }, avgDisposalDuration: { value: null, unit: 'HOURS', sampleCount: 0, invalidSampleCount: 0, state: 'EMPTY', reason: 'NO_DISPOSAL_LOGS' }, }, byLevel: levels.map((row) => ({ ...row, count: 0 })), sevenDayTrend: trend(0), } } if (scenario === 'degraded') { // 缺两个时长段:按"无有效样本"显示占位符,不显示 0 小时。 return { ...overviewModulesEnvelope('CURRENT'), today: { newCount: 0, yesterdayCount: 0, delta: 0 }, workOrderConversion: { convertedCount: 2, unconvertedCount: 0, totalCount: 2 }, disposal: { rate: { numerator: 1, denominator: 2, value: '0.5000', state: 'AVAILABLE', reason: null, basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' } }, byLevel: levels, sevenDayTrend: trend(0), } } return { ...overviewModulesEnvelope('CURRENT'), today: { newCount: 0, yesterdayCount: 0, delta: 0 }, workOrderConversion: { convertedCount: 2, unconvertedCount: 0, totalCount: 2 }, disposal: { rate: { numerator: 1, denominator: 2, value: '0.5000', state: 'AVAILABLE', reason: null, basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' }, avgResponseDuration: { value: '30.5', unit: 'HOURS', sampleCount: 1, invalidSampleCount: 0, state: 'AVAILABLE', reason: null }, avgDisposalDuration: { value: '219.2', unit: 'HOURS', sampleCount: 1, invalidSampleCount: 0, state: 'AVAILABLE', reason: null }, }, byLevel: levels, sevenDayTrend: trend(0), } } /** * `/overview/infrastructure`(票 20 验收第 2 条)。 * * 注意**没有 `districtDistribution`** —— 这正是本端点的契约现状(区域分布走票 17 的 * `base-archives`)。样例必须照实, * 否则"区域分布不在本端点上"这条判断就失去了证据。 */ function overviewInfrastructurePayload(scenario) { if (scenario === 'empty') { return { ...overviewEnvelope(null), totals: { facilityCount: 0, smartDeviceCount: 0 }, industries: [], excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 } } } if (scenario === 'degraded') { // 缺 `excluded`(可选段)与 `smartDeviceCount`:界面显示占位符,不伪造 0。 return { ...overviewEnvelope(SNAPSHOT_AT), totals: { facilityCount: 5 }, industries: [{ code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2 }] } } return { ...overviewEnvelope(SNAPSHOT_AT), totals: { facilityCount: 5, smartDeviceCount: 9 }, industries: [ // 票 38 mock 同构:概览端点 `facilityTypes[].scale` 照抄真实形状(管线行是长度单位串 "km", // 其余 null)——不再给 'medium' 这种规模语义值(真实后端从不给,见 t18 审计)。 { code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4, facilityTypes: [{ code: 'PIPE', name: '燃气管线', count: 2, lengthKm: 12.4, scale: 'km' }], deviceTypes: [] }, { code: 'WATER_SUPPLY', key: 'water', label: '供水', facilityCount: 2, smartDeviceCount: 3, facilityTypes: [], deviceTypes: [] }, { code: 'DRAINAGE', key: 'drainage', label: '排水', facilityCount: 1, smartDeviceCount: 2, facilityTypes: [], deviceTypes: [] }, ], excluded: { unknownTypeCount: 1, typeKindMismatchCount: 0 }, } } /** * `/overview/monitoring`(票 20 验收第 3 条)。 * * **状态桶是四态、没有"故障"**(`online/offline/stale/unknownOrMissing`), * 与 `CockpitOverviewMonitoringResponse.Status` 逐字段对齐 —— * 样例里若凭空多一个"故障"桶,验收第 3 条的缺口说明就自相矛盾了。 * * `industry` 入参**真的过滤并重算**(否则"筛选真的生效"会退化成只看请求串,票 08/18 的教训)。 */ function overviewMonitoringPayload(scenario, query) { const industry = (query && query.industry) || '' const all = [ { code: 'GAS', key: 'gas', label: '燃气', devices: 4, points: 3, online: 3, offline: 1, stale: 0, unknownOrMissing: 0, types: [['PIPE', '燃气管线', 4]] }, { code: 'WATER_SUPPLY', key: 'water', label: '供水', devices: 3, points: 2, online: 2, offline: 0, stale: 1, unknownOrMissing: 0, types: [['PUMP', '水泵', 3]] }, { code: 'DRAINAGE', key: 'drainage', label: '排水', devices: 2, points: 2, online: 1, offline: 0, stale: 0, unknownOrMissing: 1, types: [['FLOW', '流量计', 2]] }, ] // 认不出的行业码回空数组(后端会 400,mock 不模拟报错,但也不假装有数据)。 const rows = industry ? all.filter((row) => row.code === industry) : all if (scenario === 'empty') { return { ...overviewEnvelope(null), counts: { deviceCount: 0, monitoringPointCount: 0, monitoringFacilityCount: 0, infrastructureFacilityCount: 0, status: { online: 0, offline: 0, stale: 0, unknownOrMissing: 0, denominator: 0 }, onlineRate: { value: null, onlineCount: 0, denominator: 0, state: 'EMPTY', reason: 'NO_RESOLVED_DEVICES' } }, metricFreshness: { status: 'EMPTY', updatedAt: null, serverUpdatedAt: SNAPSHOT_AT }, selectedIndustry: null, formalScope: { industries: [], keys: [] }, typeDistribution: [], excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 }, } } const sum = (key) => rows.reduce((total, row) => total + row[key], 0) const devices = sum('devices') const online = sum('online') const offline = sum('offline') const stale = sum('stale') const unknownOrMissing = sum('unknownOrMissing') // 后端分母是 online+offline+stale(**不含** unknownOrMissing,服务逐行读过)。 const denominator = online + offline + stale const typeDistribution = rows.flatMap((row) => row.types.map(([typeCode, typeName, deviceCount]) => ({ industry: { code: row.code, key: row.key, label: row.label }, deviceType: { code: typeCode, name: typeName }, deviceCount })), ) return { ...overviewEnvelope(SNAPSHOT_AT), counts: { deviceCount: devices, monitoringPointCount: sum('points'), monitoringFacilityCount: rows.length, infrastructureFacilityCount: 5, status: { online, offline, stale, unknownOrMissing, denominator }, onlineRate: { // 分母为 0 时后端回 value="0.0000" + state=EMPTY(照实模拟,界面负责不画成 0%)。 value: denominator === 0 ? '0.0000' : (online / denominator).toFixed(4), onlineCount: online, denominator, state: denominator === 0 ? 'EMPTY' : stale === 0 ? 'CURRENT' : 'STALE', reason: denominator === 0 ? 'NO_RESOLVED_DEVICES' : null, }, }, // **本端点独有**:逐设备指标最近采集时间(票 18 的 I2/BT-8 在这里有值)。 metricFreshness: { status: 'CURRENT', updatedAt: SNAPSHOT_AT, serverUpdatedAt: SNAPSHOT_AT }, selectedIndustry: industry ? { code: industry, key: (all.find((row) => row.code === industry) || {}).key || '', label: (all.find((row) => row.code === industry) || {}).label || '' } : null, formalScope: { industries: rows.map((row) => row.code), keys: rows.map((row) => row.key) }, typeDistribution, excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 }, } } /** * `/overview/warning-settings`(票 20 验收第 1、4 条;票 42 起契约两族分叉)。 * * **一次返回预警与报警两族**(`warning` + `alarm`),所以态势总览与预警设置共用这一次取数。 * `range` 入参真的过滤趋势桶:不声明时后端维持无界(`CURRENT_BASELINE`), * 声明 `LAST_7_DAYS` 时只留窗口内的桶 —— 本票固定声明它(验收第 1 条的"近 7 日")。 * * **票 42(后端 `b047a60d`)**:预警族只给**预警自身生命周期**(状态/等级/行业/片区分布 + * 已解除率),工单口径六字段(含 `workOrderStates`)恒 `null` —— mock 与 real 同构, * **不用 0 冒充「不适用」**;报警族**保留工单口径**(报警靠工单闭环)并**并存** * `alarm_status` 生命周期分布。分布数值在自家总数(8/9)内自洽(`sum(桶)==total`), * 桶种类与真实契约一一对应(含 UNKNOWN / UNASSIGNED 这类显式桶)。 */ function overviewWarningSettingsPayload(scenario, query) { const rangeType = (query && query.range) || 'CURRENT_BASELINE' const bounded = rangeType === 'LAST_7_DAYS' const blank = scenario === 'empty' // 预警族:WarningWorkflow 六态(DRAFT/PENDING/RECEIVED/PROCESSING/HANDLED/CLOSED)。 const warningStatusDistribution = [ { code: 'DRAFT', label: '草稿', count: 0 }, { code: 'PENDING', label: '待办', count: 1 }, { code: 'RECEIVED', label: '已接收', count: 0 }, { code: 'PROCESSING', label: '处置中', count: 1 }, { code: 'HANDLED', label: '已处置', count: 1 }, { code: 'CLOSED', label: '已解除', count: 5 }, ] const warningLevelDistribution = [ { code: '1', label: '1级', count: 2 }, { code: '2', label: '2级', count: 3 }, { code: '3', label: '3级', count: 2 }, { code: '4', label: '4级', count: 1 }, ] const warningIndustryDistribution = [ { code: 'GAS', label: '燃气', count: 3 }, { code: 'WATER_SUPPLY', label: '供水', count: 2 }, { code: 'DRAINAGE', label: '排水', count: 2 }, { code: 'MANHOLE', label: '窨井', count: 0 }, { code: 'UNKNOWN', label: '未知', count: 1 }, ] const warningDistrictDistribution = [ { code: '0', label: '城南', count: 3 }, { code: '1', label: '城北', count: 2 }, { code: '2', label: '太常', count: 1 }, { code: 'UNASSIGNED', label: '未分区', count: 2 }, ] // 报警族:工单口径 0–7 全键(缺的态是真实计数 0)+ alarm_status 两态。 const alarmWorkOrderStates = { 0: 0, 1: 1, 2: 2, 3: 1, 4: 1, 5: 3, 6: 1, 7: 0 } const alarmStatusDistribution = [ { code: 'UNHANDLED', label: '未处理', count: 3 }, { code: 'HANDLED', label: '已处理', count: 6 }, ] const alarmLevelDistribution = [ { code: '1', label: '1级', count: 1 }, { code: '2', label: '2级', count: 2 }, { code: '3', label: '3级', count: 4 }, { code: '4', label: '4级', count: 2 }, ] const alarmIndustryDistribution = [ { code: 'GAS', label: '燃气', count: 2 }, { code: 'WATER_SUPPLY', label: '供水', count: 3 }, { code: 'DRAINAGE', label: '排水', count: 3 }, { code: 'MANHOLE', label: '窨井', count: 1 }, ] const alarmDistrictDistribution = [ { code: '0', label: '城南', count: 4 }, { code: '1', label: '城北', count: 2 }, { code: '2', label: '太常', count: 2 }, { code: 'UNASSIGNED', label: '未分区', count: 1 }, ] // 空场景:分布仍全铺(缺的态是真实计数 0,不是"没有数据")。 const zeroed = (rows) => rows.map((row) => ({ ...row, count: 0 })) const pick = (rows) => (blank ? zeroed(rows) : rows) // 趋势桶:`handled` 的含义按族不同 —— 预警=桶内已解除,报警=关联工单已结案(工单口径)。 const trendOf = (base) => { const trend = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((index) => { const total = blank ? 0 : base + (index % 3) const handled = blank ? 0 : base return { bucket: `2026-09-${String(index + 1).padStart(2, '0')}`, total, handled, // 分母为 0 时后端回 "0.0000" + EMPTY(照实模拟;界面不得画成 0.0%)。 handlingEfficiency: { value: total === 0 ? '0.0000' : (handled / total).toFixed(4), state: total === 0 ? 'EMPTY' : 'AVAILABLE' }, } }) // 桶是业务时区日期;声明窗口后只留窗口内的桶。 return bounded ? trend.slice(-7) : trend } return { ...overviewEnvelope(blank ? null : SNAPSHOT_AT), displayName: '预警设置', // `mode` 不传时后端默认 WARNING(本票不传,因为两族都要)。 selectedMode: (query && query.mode) || 'WARNING', range: { type: rangeType, start: bounded ? '2026-09-23T00:00:00+08:00' : null, end: bounded ? '2026-09-30T00:00:00+08:00' : null }, warning: { eventFamily: 'WARNING', state: blank ? 'EMPTY' : 'READY', stateReason: blank ? 'NO_EVENTS' : 'WARNINGS_RESOLVED', total: blank ? 0 : 8, // 预警不走工单流程(维护者 2026-10-14 裁定):工单口径六字段恒 null —— 不适用 ≠ 0。 handled: null, pending: null, cancelledCount: null, unlinkedEventCount: null, invalidWorkOrderStateCount: null, // 已解除率=已解除 ÷ 预警总数(预警自身口径;分母 0 → EMPTY/NO_WARNINGS)。 handlingEfficiency: { numerator: blank ? 0 : 5, denominator: blank ? 0 : 8, value: blank ? '0.0000' : '0.6250', state: blank ? 'EMPTY' : 'AVAILABLE', reason: blank ? 'NO_WARNINGS' : null, }, workOrderStates: null, statusDistribution: pick(warningStatusDistribution), levelDistribution: pick(warningLevelDistribution), industryDistribution: pick(warningIndustryDistribution), districtDistribution: pick(warningDistrictDistribution), trend: trendOf(1), }, alarm: { eventFamily: 'ALARM', state: blank ? 'EMPTY' : 'READY', stateReason: blank ? 'NO_EVENTS' : 'EVENTS_RESOLVED', total: blank ? 0 : 9, // 报警族保留工单口径;`handled` 是 alarm_status 自身生命周期(已处理 6)。 handled: blank ? 0 : 6, pending: blank ? 0 : 6, cancelledCount: 0, unlinkedEventCount: 0, invalidWorkOrderStateCount: 0, // 处置率仍是**工单口径**(状态 5 ÷ 状态 0–6 = 3/9;分母 0 → NO_ELIGIBLE_WORK_ORDERS)。 handlingEfficiency: { numerator: blank ? 0 : 3, denominator: blank ? 0 : 9, value: blank ? '0.0000' : '0.3333', state: blank ? 'EMPTY' : 'AVAILABLE', reason: blank ? 'NO_ELIGIBLE_WORK_ORDERS' : null, }, workOrderStates: blank ? { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0 } : alarmWorkOrderStates, statusDistribution: pick(alarmStatusDistribution), levelDistribution: pick(alarmLevelDistribution), industryDistribution: pick(alarmIndustryDistribution), districtDistribution: pick(alarmDistrictDistribution), trend: trendOf(2), }, mapFilter: { eventFamily: (query && query.mode) || 'WARNING', label: (query && query.mode) === 'ALARM' ? '报警' : '预警' }, } } // ---------------------------------------------------------------- 注册 /** 各接口在五类样例下的载荷(`failed` 由处理器抛出,不在这里给载荷)。 */ const PAYLOADS = { // 票 08:地图摘要按**真实分页语义**给(分页窗口横跨监测点与未关联设备、第 2 页起要求快照一致)。 '/map-snapshot': (s, _requested, context) => mapSnapshotPayload(s, context.path, context.mapSnapshot), '/monitoring-points/*': (s, requested) => pointDetailPayload(s, requested), '/equipments/*/detail-aggregate': (s, requested) => equipmentAggregate(s, requested), '/equipments/*/events/current': (s, requested) => eventRead(s, requested), '/equipments/*/events/history': (s, requested) => eventRead(s, requested), // 票 13:跨设备当前事件(预警/报警切换 + 当前事件列表;`mode` 必填并校验)。 // 用 `context.query` 真的读 mode/level/industry/status/page/pageSize —— // 否则「切到报警列表真的换内容」这条断言会退化成在看请求串(票 08 的教训)。 '/events/current': (s, _requested, context) => currentEventsPayload(s, context.query), '/events/history-analysis': (s, _requested, context) => historyAnalysisPayload(s, context.query), '/events/handling-efficiency': (s, _requested, context) => handlingEfficiencyPayload(s, context.query), '/events/multidimensional-analysis': (s, _requested, context) => multidimensionalAnalysisPayload(s, context.query), // 票 65:全量工单分页(报警效率面板的「当前工单列表」)。 // 与下面 `/equipments/*/work-orders` 的区别只在**没有设备/事件维度**: // 真实后端 `GET /api/cockpit/v1/work-orders` 就是这个形状(含 pagination 四件套)。 // 分页在这里真的切窗口 —— mock 若总是回全量,前端的翻页与「共 N 张」口径就永远测不到。 '/work-orders': (s, _requested, context) => { const query = (context && context.query) || {} const all = s === 'empty' ? [] : TIMELINE_ORDERS const page = Math.max(1, Number(query.page) || 1) const pageSize = Math.max(1, Number(query.pageSize) || 10) const total = all.length const totalPages = Math.max(1, Math.ceil(total / pageSize)) const safePage = Math.min(page, totalPages) return { requestId: 'mock-work-order-list', apiVersion: 'v1', readOnly: true, state: total === 0 ? 'EMPTY' : 'READY', stateReason: total === 0 ? 'NO_WORK_ORDERS' : 'READY', // 字典与设备维度同源(`domain/workOrder.js` 的 `WORK_ORDER_STATES`),不登记第二份。 stateDictionary: WORK_ORDER_STATES.map((state) => ({ code: state.code, name: state.name })), workOrders: all.slice((safePage - 1) * pageSize, safePage * pageSize), pagination: { page: safePage, pageSize, total, totalPages }, } }, '/equipments/*/work-orders': (s) => ({ requestId: 'mock-work-orders', apiVersion: 'v1', readOnly: true, deviceCode: ARCHIVE.deviceCode, eventSource: null, eventCode: null, state: s === 'empty' ? 'EMPTY' : 'READY', stateReason: s === 'empty' ? 'NO_WORK_ORDERS' : 'READY', // 票 20:字典**从唯一源派生**(`domain/workOrder.js` 的 `WORK_ORDER_STATES`, // 其真理是后端 `WorkOrderStatusEnum` 0–7)。这里原先硬编码了一份 1–7 的副本, // 是"同一份业务词表登记两处"的典型 —— 后端一改成 0–7,两份就各说各话。 stateDictionary: WORK_ORDER_STATES.map((state) => ({ code: state.code, name: state.name })), // 票 12 的专用场景给多张工单(每张一条轨迹的形状都不同);票 20 加了「延期中」那张。 workOrders: s === 'empty' ? [] : s === 'work-order-timeline' ? TIMELINE_ORDERS : [WORK_ORDER], }), // 票 12:按工单编码回显该工单自己的轨迹(与真接口一致 —— 认不出的编码**抛错**, // 而不是回一张空轨迹或永远给同一张工单;那会把"没找到"画成"没有动作")。 '/work-orders/*/timeline': (s, requested) => { if (s === 'empty') return { workOrder: null, timelineEntries: [] } const payload = timelinePayload(requested) if (!payload) throw new Error(`mock: 工单 ${requested || '(空)'} 不存在`) return payload }, '/equipments/*/freshness': (s, requested) => freshnessPayload(s, requested), '/equipments/*/archive': (s) => (s === 'empty' ? null : ARCHIVE), // 票 17:基础设施模式四块面板的面板级聚合读模型(各块独立成败,互不牵连)。 '/infrastructure/base-archives': (s, _requested, context) => baseArchivesPayload(s, context.query), '/infrastructure/operating-information': (s, _requested, context) => operatingInformationPayload(s, context.query), '/infrastructure/management-information': (s, _requested, context) => managementInformationPayload(s, context.query), '/infrastructure/emergency-support': (s) => emergencySupportPayload(s), // 票 18:运行监测模式三块面板(台账与监测分布**共用** distribution 的这一个响应)。 // 三个 builder 都吃**请求查询串**:分页/筛选/时间窗口在 mock 上必须真的按请求回, // 否则「筛选真的下发了」「翻页真的带快照」这些断言全是假绿(票 08 踩过的同一个坑)。 '/monitoring/overview': (s, _requested, context) => monitoringOverviewPayload(s, context.query), '/monitoring/distribution': (s, _requested, context) => monitoringDistributionPayload(s, context.query, context.monitoring), '/monitoring/situation': (s, _requested, context) => monitoringSituationPayload(s, context.query), // 票 05–08:总体概览四模块端点(四块各一个,互不牵连)。票 89 起前端会下发 // district/industry(后端已补齐入参);mock 样例仍是**固定读数、不按条件裁剪** —— // 参数是否真的进了 URL 由 t67 闸门注入 fetch 捕获判定,不靠 mock 假装筛过。 '/overview/perception': (s) => overviewPerceptionPayload(s), '/overview/pipe-network': (s) => overviewPipeNetworkPayload(s), '/overview/warning-study': (s) => overviewWarningStudyPayload(s), '/overview/alarm-disposal': (s) => overviewAlarmDisposalPayload(s), // 票 20:总体概览模式三块面板的面板级聚合读模型(各块独立成败)。 // 三个 builder 都吃**请求查询串**:`/overview/monitoring` 的行业筛选与 // `/overview/warning-settings` 的窗口必须真的按请求回,否则 // 「筛选真的生效」「近 7 日窗口真的声明了」这些断言全是假绿(票 08/18 的教训)。 '/overview/infrastructure': (s) => overviewInfrastructurePayload(s), '/overview/monitoring': (s, _requested, context) => overviewMonitoringPayload(s, context.query), '/overview/warning-settings': (s, _requested, context) => overviewWarningSettingsPayload(s, context.query), // 票 24:设施点位与管线几何读模型(票 30 的真实端点 `/infrastructure/geometry`,免鉴权)。 '/infrastructure/geometry': (s, _requested, context) => pipeNetworkPayload(s, context.query), // 票 68:四类设施点的实体详情(设施点图层补坐标;查询串 `category` 与真接口同形)。 '/entities/*/detail': (s, requested, context) => facilityEntityDetailPayload(s, requested, context.query), } /** * 管线/设施几何样例(票 24)。 * * **形状照抄票 30 的真实契约**(`GET /api/cockpit/v1/infrastructure/geometry`,实测于 2026-10-12): * 标准 GeoJSON `FeatureCollection`——`{type:'FeatureCollection', generatedAt, features, coverage}`; * feature 是 `{type:'Feature', id, geometry, properties}`,**没有 `kind` 字段**(按 `geometry.type` 判别); * 非法坐标的 feature `geometry` 为 **null**,原始坐标留在 `properties.sourceCoordinates`; * `coverage` 是**三态行** `{industry, source, geometryType, state, reason, recordCount, geometryCount, renderableCount}`, * `state` ∈ `AVAILABLE`/`EMPTY`/`UNAVAILABLE`,同一行业可有管线/构件两行。 * * 样例内容仍照实,不编"完美数据": * · **燃气**给构件 —— 真实 `pointType` 是英文(实测 `valve`),样例两种写法都覆盖; * · **供水/排水**只有真实的两点直线段(数据底座就是两点,**不编造弯头与三通**); * · **窨井**没有管线,只有点位; * · 刻意埋"不得上图":越界 / 零坐标 / 未校验 —— 必须进「无地图位置」清单, * 既不被悄悄丢掉,也不被"就近挪一下"救回来(票 07 的坐标纪律)。 * * 坐标取沅陵县城一带(与本仓其它 mock 同一区域)。 */ function pipeNetworkPayload(s, query) { const valid = { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } const invalid = (reason) => ({ state: 'INVALID', reason, renderable: false, crs: 'WGS84' }) const unverified = { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' } const industry = String((query && query.industry) || '').trim() const wanted = industry ? industry.toUpperCase() : '' const keep = (code) => !wanted || wanted === code const pipeRows = [ // 燃气:三点样例演示"样条管道"(真实库里多为两点,这里刻意给一条三点以覆盖多段路径) ['GAS-P-001', '燃气主干管东段', 'GAS', 'PE', 400, [[110.3900, 28.4430], [110.3960, 28.4455], [110.4020, 28.4440]]], ['GAS-P-002', '燃气支管南段', 'GAS', '钢', 200, [[110.3900, 28.4430], [110.3862, 28.4380]]], ['GAS-P-003', '燃气主干管西段', 'GAS', 'PE', 300, [[110.3862, 28.4380], [110.3790, 28.4392]]], // 供水/排水:真实只有两点直线段 ['WS-P-001', '供水输水干管', 'WATER_SUPPLY', '球铁', 600, [[110.3950, 28.4500], [110.4050, 28.4520]]], ['WS-P-002', '供水配水支管', 'WATER_SUPPLY', 'PE', 150, [[110.4050, 28.4520], [110.4090, 28.4560]]], ['DR-P-001', '排水主干管', 'DRAINAGE', '混凝土', 800, [[110.3880, 28.4400], [110.3800, 28.4420]]], ].filter((row) => keep(row[2])) const componentRows = [ // 真实 pointType 是英文(实测 valve);中文词表也覆盖,两种都不得漏 ['GAS-C-001', 'valve', '阀门', 'GAS', 'GAS-P-001', 110.3960, 28.4455], ['GAS-C-002', 'elbow', '弯头', 'GAS', 'GAS-P-001', 110.4020, 28.4440], ['GAS-C-003', 'tee', '三通', 'GAS', 'GAS-P-002', 110.3862, 28.4380], ['GAS-C-004', 'regulator', '调压', 'GAS', 'GAS-P-003', 110.3790, 28.4392], ].filter((row) => keep(row[3])) const features = [ ...pipeRows.map(([code, name, ind, material, diameter, coordinates]) => ({ type: 'Feature', id: `pipe_network_base:${code}`, geometry: { type: 'LineString', coordinates }, properties: { source: 'pipe_network_base', sourceId: code, code, name, industry: ind, typeCode: 'PIPE', networkType: ind === 'GAS' ? '0' : ind === 'DRAINAGE' ? '1' : '2', pointType: null, networkId: code, material, diameter, length: 120, sourceCoordinates: coordinates, coordinateValidation: valid, coordinateValidations: [valid, valid], }, })), ...componentRows.map(([code, pointType, label, ind, networkId, longitude, latitude]) => ({ type: 'Feature', id: `gas_pipe_point:${code}`, geometry: { type: 'Point', coordinates: [longitude, latitude] }, properties: { source: 'gas_pipe_point', sourceId: code, code, name: label, industry: ind, typeCode: 'GAS_PIPE_POINT', networkType: null, pointType, networkId, sourceCoordinates: [[longitude, latitude]], coordinateValidation: valid, coordinateValidations: [valid], }, })), // 窨井:有坐标的点位设施,没有管线 { type: 'Feature', id: 'manhole_base:MH-001', geometry: { type: 'Point', coordinates: [110.3920, 28.4460] }, properties: { source: 'manhole_base', sourceId: 'MH-001', code: 'MH-001', name: '窨井001', industry: 'MANHOLE', typeCode: 'MANHOLE', networkType: null, pointType: null, networkId: '', sourceCoordinates: [[110.3920, 28.4460]], coordinateValidation: valid, coordinateValidations: [valid], }, }, // ↓ 三条「不得上图」:真实契约里 geometry 为 null,原始坐标留在 sourceCoordinates { type: 'Feature', id: 'pipe_network_base:GAS-P-004', geometry: null, properties: { source: 'pipe_network_base', sourceId: 'GAS-P-004', code: 'GAS-P-004', name: '燃气管(坐标未校验)', industry: 'GAS', typeCode: 'PIPE', networkType: '0', pointType: null, networkId: 'GAS-P-004', sourceCoordinates: [[110.3700, 28.4300], [110.3740, 28.4320]], coordinateValidation: unverified, coordinateValidations: [unverified, unverified], }, }, { type: 'Feature', id: 'pipe_network_base:GAS-P-005', geometry: null, properties: { source: 'pipe_network_base', sourceId: 'GAS-P-005', code: 'GAS-P-005', name: '燃气管(零坐标)', industry: 'GAS', typeCode: 'PIPE', networkType: '0', pointType: null, networkId: 'GAS-P-005', sourceCoordinates: [[0, 0], [0.0010, 0.0010]], coordinateValidation: invalid('ZERO_COORDINATE'), coordinateValidations: [invalid('ZERO_COORDINATE')], }, }, { type: 'Feature', id: 'gas_pipe_point:GAS-C-005', geometry: null, properties: { source: 'gas_pipe_point', sourceId: 'GAS-C-005', code: 'GAS-C-005', name: '阀门(主场景区外)', industry: 'GAS', typeCode: 'GAS_PIPE_POINT', networkType: null, pointType: 'valve', networkId: 'GAS-P-003', sourceCoordinates: [[12.0, 12.0]], coordinateValidation: invalid('OUTSIDE_PRIMARY_AREA'), coordinateValidations: [invalid('OUTSIDE_PRIMARY_AREA')], }, }, ] // coverage 是**三态行**:同一行业可有管线/构件两行,缺口用 UNAVAILABLE + reason 如实标注。 const coverage = [ { industry: 'GAS', source: 'pipe_network_base', geometryType: 'LineString', state: 'AVAILABLE', reason: 'TWO_ENDPOINTS_ONLY', recordCount: 3, geometryCount: 3, renderableCount: 3 }, { industry: 'WATER_SUPPLY', source: 'pipe_network_base', geometryType: 'LineString', state: 'AVAILABLE', reason: 'TWO_ENDPOINTS_ONLY', recordCount: 2, geometryCount: 2, renderableCount: 2 }, { industry: 'DRAINAGE', source: 'pipe_network_base', geometryType: 'LineString', state: 'AVAILABLE', reason: 'TWO_ENDPOINTS_ONLY', recordCount: 1, geometryCount: 1, renderableCount: 1 }, { industry: 'GAS', source: 'gas_pipe_point', geometryType: 'Point', state: 'AVAILABLE', reason: null, recordCount: 4, geometryCount: 4, renderableCount: 4 }, { industry: 'WATER_SUPPLY', source: null, geometryType: 'Point', state: 'UNAVAILABLE', reason: 'NO_COMPONENT_SOURCE', recordCount: 0, geometryCount: 0, renderableCount: 0 }, { industry: 'DRAINAGE', source: null, geometryType: 'Point', state: 'UNAVAILABLE', reason: 'NO_TYPED_COMPONENT_SOURCE', recordCount: 0, geometryCount: 0, renderableCount: 0 }, { industry: 'MANHOLE', source: 'manhole_base', geometryType: 'Point', state: 'AVAILABLE', reason: 'NO_COMPONENT_TYPE', recordCount: 1, geometryCount: 1, renderableCount: 1 }, { industry: 'MANHOLE', source: null, geometryType: 'LineString', state: 'UNAVAILABLE', reason: 'NO_PIPELINE_SOURCE', recordCount: 0, geometryCount: 0, renderableCount: 0 }, ] return { type: 'FeatureCollection', generatedAt: '2026-10-12T11:00:00+08:00', coverage, features: s === 'empty' ? [] : features, } } /** * 从请求路径里取出模板 `*` 命中的那一段(即路径参数)。 * `('/equipments/TC-1/detail-aggregate', '/equipments/' + '*' + '/detail-aggregate')` → `TC-1`。 * 模板里有多个 `*` 时只取第一个——当前接口最多一个路径参数,够用且不猜。 */ function pathParameter(requestPath, template) { const star = template.indexOf('*') if (star === -1) return '' const prefix = template.slice(0, star) const suffix = template.slice(star + 1) const bare = String(requestPath || '').split('?')[0] if (!bare.startsWith(prefix)) return '' const rest = bare.slice(prefix.length) const end = suffix ? rest.indexOf(suffix) : rest.length if (end === -1) return '' return decodeURIComponent(rest.slice(0, end)) } /** * 注册全部契约化 mock。 * @param {object} [options] * @param {string} [options.scenario] `success`(默认)/`empty`/`degraded`/`stale`/`failed`/`unauthorized` * @param {string} [options.onlyPath] 只让这一个路径走「异常样例」,其余保持 `success` * —— 票 05 验收第 6 条要的正是「只把**受影响分区**标为数据不可用」, * 所以必须能只让一个分区坏掉、其余照常成功。 * @param {(path: string) => boolean} [options.only] 同上,函数形式(便于按前缀选中一族接口) * @param {string} [options.prefix] 同上,按路径前缀选中一族接口(`/equipments/`) * @param {object} [options.mapSnapshot] 地图摘要分页的现场覆盖(票 08 走查用): * `{ pageSize, snapshotAt, expireAfterPage }` * @param {object} handlers services/cockpitApi.js 的三个注册函数(注入以便单测) */ export function registerCockpitMocks(options = {}) { const { scenario = 'success', onlyPath = '', prefix = '', handlers } = options const mapSnapshot = options.mapSnapshot || {} // 票 18:运行监测的现场覆盖({ pageSize }),让台账渐进加载在样例上演示得出来。 const monitoring = options.monitoring || {} // `prefix` 是 `only` 的便捷写法:两者都给时以 `only` 为准。 const only = options.only || (prefix ? (path) => path.startsWith(prefix) : null) const register = (handlers && handlers.registerMockTransport) || null if (!register) { throw new Error('registerCockpitMocks 需要注入 registerMockTransport') } const reset = (handlers && handlers.resetMockTransportsForTest) || null if (reset) reset() const targets = new Set() for (const [path, build] of Object.entries(PAYLOADS)) { const applies = !onlyPath && !only ? true : only ? !!only(path) : path === onlyPath const active = applies ? scenario : 'success' register(path, async (context) => { // `failed`:抛出让统一出口归类成「不可用」,模拟分区自己读取失败。 if (active === 'failed') { throw new Error(`mock: ${path} 读取失败`) } if (active === 'unauthorized') { // 401 不是载荷:把凭据类失败原因交给统一出口归类, // 由它回 `{ok:false, reason:'unauthorized'}`(服务层只认白名单里的原因)。 const error = new Error('mock: 凭据未通过校验') error.reason = REQUEST_REASON.UNAUTHORIZED throw error } // 带路径参数的接口回显请求里的编码:这样「按编码取数」的行为与真接口一致 // (客户端传错编码时能看出来,而不是永远拿到同一台设备)。 const requested = pathParameter(context.path, path) // 票 18:把**请求的查询串**解析成对象交给 builder —— 分页 / 筛选 / 时间窗口 // 必须真的按请求回,否则相关断言会变成假绿(票 08 的教训)。 const payload = build(active, requested, { path: context.path, mapSnapshot, monitoring, query: queryParams(context.path), }) if (!requested) return payload if (path === '/monitoring-points/*') { // 票 41:名称必须按**请求的编码**回,不能永远回默认载荷那一个名字。 // 之前 MP-001/MP-002 的样例名与默认名恰好一致,所以这个缺口看不出来; // 走查点 MP-001 却显示 MP-002 的名字时才暴露(真机走查抓到的真缺陷)。 // **只回显名称**:状态/原因仍是本样例手工构造的那一份,不在这里改 // (否则会出现"状态正常、原因却是压力超高"的自相矛盾组合)。 const known = MAP_POINTS.find((item) => item.pointCode === requested) return { ...payload, monitoringPointCode: requested, ...(known ? { monitoringPointName: known.pointName } : {}), } } // 设备族接口:统一把请求的设备编码写回载荷(设备详情/事件/工单/新鲜度/档案都一样)。 if (typeof payload === 'object' && payload !== null && 'deviceCode' in payload) { return { ...payload, deviceCode: requested } } return payload }) targets.add(path) } return { scenario, paths: [...targets], isolated: onlyPath || prefix || (only ? 'predicate' : '') } }