contractMocks.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707
  1. /**
  2. * 契约化 mock 样例(票 05):与真实接口**同一份字段名与嵌套形状**。
  3. *
  4. * 依据(Q3=A):字段名与结构照抄后端演示载荷 ——
  5. * `E:\pipe-ner-server\docs\cockpit\*.html` 与 `research/panel-field-coverage.md` 的逐字段表。
  6. * 因此适配器单测证明的是「转换吃得下真载荷」,而不是「吃得下我自己编的小样」。
  7. *
  8. * 覆盖票 05 要求的五类样例(每个接口都能取到):
  9. * `success`(正常)/ `empty`(空数据)/ `degraded`(字段缺失)/ `failed`(失败)/ `stale`(过期)。
  10. * 另有 `unauthorized`:票 05 验收第 6 条的 401 场景,只影响被请求的那个分区。
  11. *
  12. * 数据是**精心构造的边界值**,不是随手填的:坐标覆盖 VALID/UNVERIFIED/INVALID/ZERO、
  13. * 指标覆盖 0 与「无采样」、时间覆盖未结案(null)。
  14. */
  15. import { REQUEST_REASON } from '../domain/requestReason.js'
  16. const SNAPSHOT_AT = '2026-09-02T10:00:00+08:00'
  17. // ---------------------------------------------------------------- 地图摘要
  18. const MAP_POINTS = [
  19. {
  20. pointCode: 'MP-001',
  21. pointName: '供水干管监测点',
  22. pointStatus: 'NORMAL',
  23. statusReasons: [],
  24. longitude: 110.141,
  25. latitude: 28.452,
  26. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  27. },
  28. {
  29. pointCode: 'MP-002',
  30. pointName: '燃气调压站监测点',
  31. pointStatus: 'ALARM',
  32. statusReasons: ['EQ-ASSOC-001 报警'],
  33. longitude: 110.142,
  34. latitude: 28.453,
  35. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  36. },
  37. {
  38. pointCode: 'MP-003',
  39. pointName: '污水排口监测点',
  40. pointStatus: 'WARNING',
  41. statusReasons: ['EQ-FREE-003 预警'],
  42. longitude: 110.143,
  43. latitude: 28.454,
  44. coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' },
  45. },
  46. ]
  47. const MAP_EQUIPMENT = [
  48. {
  49. deviceCode: 'EQ-FREE-001',
  50. deviceName: '供水压力计-001',
  51. longitude: 181,
  52. latitude: 28.455,
  53. coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' },
  54. },
  55. {
  56. deviceCode: 'EQ-FREE-004',
  57. deviceName: '零坐标设备-004',
  58. longitude: 0,
  59. latitude: 0,
  60. coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' },
  61. },
  62. ]
  63. /** 地图摘要的演示分页(票 08):契约允许任意页大小,演示用 2 —— 三页翻完,看得出来是渐进加载。 */
  64. const MAP_SNAPSHOT_PAGE_SIZE = 2
  65. /**
  66. * 票 09 的点位状态样例:**后端六态各一个**,全部坐标合法(都该上图),
  67. * 外加一个后端新增、前端不认识的第九个状态 —— 它必须落「状态未发布」而不是被猜成正常。
  68. *
  69. * 为什么要单独一份而不是改上面那三个点位:票 07/08 的走查把 `2,4,5` / 可上图 2 · 不上图 3
  70. * 这些数写成了断言,改默认样例就会改掉那两张票的证据。所以状态演示走**独立场景**
  71. * (`__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'`),默认样例一个字节不动。
  72. */
  73. const STATUS_SAMPLE_POINTS = [
  74. { pointCode: 'MP-ST-ALARM', pointName: '燃气调压站报警监测点', pointStatus: 'ALARM' },
  75. { pointCode: 'MP-ST-WARNING', pointName: '污水排口预警监测点', pointStatus: 'WARNING' },
  76. { pointCode: 'MP-ST-WORKORDER', pointName: '供水干管工单处置监测点', pointStatus: 'WORK_ORDER' },
  77. { pointCode: 'MP-ST-NORMAL', pointName: '排水泵站正常监测点', pointStatus: 'NORMAL' },
  78. { pointCode: 'MP-ST-OFFLINE', pointName: '井盖离线监测点', pointStatus: 'OFFLINE' },
  79. { pointCode: 'MP-ST-EMPTY', pointName: '预留无设备监测点', pointStatus: 'EMPTY' },
  80. // 后端将来新增的状态:前端不认识,必须如实说「状态未发布」,不得落到任何真实状态上。
  81. { pointCode: 'MP-ST-UNKNOWN', pointName: '未知状态监测点', pointStatus: 'MAINTENANCE' },
  82. ].map((item, index) => ({
  83. ...item,
  84. statusReasons: [],
  85. // 沿经纬度铺开一点,免得标记叠在一起看不出形状差异。
  86. longitude: 110.141 + index * 0.002,
  87. latitude: 28.452 + index * 0.001,
  88. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  89. }))
  90. /** 状态样例里的未关联设备(第六态「无关联设备」的落点,形状是十字)。 */
  91. const STATUS_SAMPLE_EQUIPMENT = [
  92. {
  93. deviceCode: 'EQ-ST-FREE',
  94. deviceName: '未关联设备-001',
  95. longitude: 110.155,
  96. latitude: 28.459,
  97. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  98. },
  99. ]
  100. /**
  101. * 按后端**真实语义**造一页地图摘要(票 08)。
  102. *
  103. * 照抄 `CockpitMapSnapshotService.load()` 的三条行为,而不是造一份"理想载荷":
  104. * 1. 分页窗口是「监测点 + 未关联设备」这**一条总序列**:先排满监测点,再排未关联设备
  105. * (所以后一页可能整页都是设备,或两类混在一页);
  106. * 2. 第 2 页起**必须**带 `snapshotAt`,且与当前快照一致,否则抛 `SNAPSHOT_EXPIRED`;
  107. * 3. `complete` 只在「本页末尾已到总数」时为真。
  108. *
  109. * 票 07 起另有一条**必须照抄**:响应带 `coordinateValidation` **全量**汇总
  110. * (后端用整份 points/equipment 算完再切片分页,所以它不随页码变化)。
  111. *
  112. * @param {object} options
  113. * @param {number} [options.page] 页码(1 起)
  114. * @param {number} [options.pageSize] 每页条数
  115. * @param {string} [options.snapshotAt] 客户端带的快照标记(第 1 页不带)
  116. * @param {string} [options.currentSnapshot] 本次请求后端当前快照(固定值,见 `planMapSnapshot`)
  117. * @param {Array} [options.points] 覆盖监测点集合(票 09 的状态样例用)
  118. * @param {Array} [options.equipment] 覆盖未关联设备集合
  119. */
  120. function mapSnapshotPage({
  121. page = 1,
  122. pageSize = MAP_SNAPSHOT_PAGE_SIZE,
  123. snapshotAt = '',
  124. currentSnapshot = SNAPSHOT_AT,
  125. points = MAP_POINTS,
  126. equipment = MAP_EQUIPMENT,
  127. } = {}) {
  128. if (page > 1 && !snapshotAt) {
  129. const error = new Error('mock: 第 2 页起必须带 snapshotAt')
  130. error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED
  131. throw error
  132. }
  133. if (snapshotAt && snapshotAt !== currentSnapshot) {
  134. const error = new Error('mock: 快照标记与当前快照不一致')
  135. error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED
  136. throw error
  137. }
  138. const all = [...points, ...equipment]
  139. const from = Math.max(0, (page - 1) * pageSize)
  140. const to = from + pageSize
  141. const slice = all.slice(from, to)
  142. return {
  143. page,
  144. pageSize,
  145. snapshotAt: currentSnapshot,
  146. batchUpdatedAt: currentSnapshot,
  147. total: all.length,
  148. complete: to >= all.length,
  149. monitoringPoints: slice.filter((item) => 'pointCode' in item),
  150. unassociatedEquipment: slice.filter((item) => 'deviceCode' in item),
  151. // 全量口径:与 `CockpitMapSnapshotService.coordinateValidation(points, equipment)` 一致。
  152. coordinateValidation: coordinateValidationSummary(all),
  153. }
  154. }
  155. /**
  156. * 坐标校验汇总(票 07):照抄后端 `CockpitCoordinateValidationSummary` 的字段名与判据。
  157. *
  158. * 后端的分法是**三桶**:`renderable` 优先,其余里 `state === 'INVALID'` 记非法,
  159. * 剩下的一律记「未校验」(`CockpitMapSnapshotService` 第 205-231 行)。
  160. * 这里照抄同一个分法 —— 换一套算法就会让前端显示的数与后端对不上。
  161. */
  162. function coordinateValidationSummary(objects) {
  163. let renderableCount = 0
  164. let invalidCount = 0
  165. let unverifiedCount = 0
  166. for (const item of objects) {
  167. const validation = item.coordinateValidation || {}
  168. if (validation.renderable === true) renderableCount += 1
  169. else if (validation.state === 'INVALID') invalidCount += 1
  170. else unverifiedCount += 1
  171. }
  172. return {
  173. crs: 'WGS84',
  174. totalObjectCount: objects.length,
  175. renderableCount,
  176. invalidCount,
  177. unverifiedCount,
  178. }
  179. }
  180. /**
  181. * 从请求路径的查询串里取一个整数参数。
  182. * 走查与演示要能现场改页大小(`window.__COCKPIT_MOCK_MAP_PAGE_SIZE__`),
  183. * 所以分页参数必须真的从请求上读,而不是写死一份。
  184. */
  185. function queryInteger(path, key, fallback) {
  186. const query = String(path || '').split('?')[1] || ''
  187. const value = new URLSearchParams(query).get(key)
  188. const parsed = Number(value)
  189. return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
  190. }
  191. /** 查询串里的快照标记(第 1 页不带)。 */
  192. function querySnapshot(path) {
  193. const query = String(path || '').split('?')[1] || ''
  194. return new URLSearchParams(query).get('snapshotAt') || ''
  195. }
  196. /**
  197. * 地图摘要样例(票 08):**按后端真实分页语义**造,而不是只给一页。
  198. *
  199. * 场景(`overrides` 由走查现场给,见 `mocks/index.js`):
  200. * · `success` —— 正常分页,后端当前快照固定;
  201. * · `empty` —— 后端说总数 0(不是「这一页恰好没有」);
  202. * · `degraded` —— 保持票 05 的字段缺失样例(单页、`complete: false`);
  203. * · `stale` —— **取完第 1 页后后端换了快照**(`expireAfterPage`),第 2 页带着旧标记回来即失效;
  204. * · `failed` —— 由统一出口抛出,不在这里给载荷。
  205. *
  206. * @param {string} scenario 五类样例
  207. * @param {string} path 请求路径(分页参数在这里)
  208. * @param {object} [overrides] `{ pageSize, snapshotAt, expireAfterPage }`
  209. */
  210. function mapSnapshotPayload(scenario, path, overrides = {}) {
  211. const pageSize = overrides.pageSize || MAP_SNAPSHOT_PAGE_SIZE
  212. const page = queryInteger(path, 'page', 1)
  213. const baseSnapshot = overrides.snapshotAt || SNAPSHOT_AT
  214. if (scenario === 'empty') {
  215. return {
  216. page,
  217. pageSize,
  218. snapshotAt: baseSnapshot,
  219. batchUpdatedAt: baseSnapshot,
  220. total: 0,
  221. complete: true,
  222. monitoringPoints: [],
  223. unassociatedEquipment: [],
  224. // 真·空快照:全量口径各项都是 0(这里 0 是真实值,不是缺失)。
  225. coordinateValidation: coordinateValidationSummary([]),
  226. }
  227. }
  228. if (scenario === 'degraded') {
  229. const degraded = [{ pointCode: 'MP-DEGRADED', pointName: '字段缺失点位', pointStatus: 'NORMAL', statusReasons: [] }]
  230. return {
  231. page: 1,
  232. pageSize: 500,
  233. snapshotAt: baseSnapshot,
  234. complete: false,
  235. monitoringPoints: degraded,
  236. unassociatedEquipment: [],
  237. // 后端没给 coordinateValidation 的点位:仍占一个位置,落在「未校验」桶里。
  238. coordinateValidation: coordinateValidationSummary(degraded),
  239. }
  240. }
  241. // 票 09:后端六态 + 未关联设备 + 一个前端不认识的状态,全部可上图(状态演示用)。
  242. if (scenario === 'point-statuses') {
  243. return mapSnapshotPage({
  244. page,
  245. pageSize,
  246. snapshotAt: querySnapshot(path),
  247. currentSnapshot: baseSnapshot,
  248. points: STATUS_SAMPLE_POINTS,
  249. equipment: STATUS_SAMPLE_EQUIPMENT,
  250. })
  251. }
  252. // 过期样例:第 1 页照常给旧快照,之后的请求后端已经换成新快照 —— 客户端带着旧标记回来即失效。
  253. const expired = scenario === 'stale' && Number.isInteger(overrides.expireAfterPage) && page > overrides.expireAfterPage
  254. const currentSnapshot = expired ? '2026-09-02T10:30:00+08:00' : baseSnapshot
  255. return mapSnapshotPage({ page, pageSize, snapshotAt: querySnapshot(path), currentSnapshot })
  256. }
  257. // ---------------------------------------------------------------- 监测点详情
  258. /**
  259. * 监测点详情的样例载荷(票 05 的默认 + 四类异常样例)。
  260. *
  261. * 票 10 追加 `point-detail-order`:设备卡片刻意**逆状态优先级**排列
  262. * (正常 → 报警 → 预警),后端的返回顺序 ≠ 前端按优先级排出来的顺序 ——
  263. * 界面若自作主张排序,真机走查里那条「卡片顺序 = 后端顺序」就会当场露馅。
  264. * 默认样例一个字节未动(票 05 的断言依赖它)。
  265. */
  266. function pointDetailPayload(scenario) {
  267. if (scenario === 'empty') {
  268. return {
  269. ...pointDetail({ code: 'MP-EMPTY', name: '预留监测点', status: 'EMPTY' }),
  270. devices: [],
  271. deviceStatistics: { totalDeviceCount: 0, alarmDeviceCount: 0, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 0, offlineDeviceCount: 0 },
  272. statusReasons: [],
  273. }
  274. }
  275. if (scenario === 'degraded') {
  276. return { monitoringPointCode: 'MP-DEGRADED', monitoringPointName: '字段缺失点位', devices: [], statusReasons: [] }
  277. }
  278. if (scenario === 'point-detail-order') {
  279. const devices = [
  280. { deviceCode: 'EQ-ORDER-NORMAL', deviceName: '顺序样例-正常设备', deviceTypeName: '压力计', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00' },
  281. { deviceCode: 'EQ-ORDER-ALARM', deviceName: '顺序样例-报警设备', deviceTypeName: '流量计', deviceStatus: 'ALARM', statusLabel: '报警', statusReasons: ['流量超高'], statusTime: '2026-09-02T09:59:40+08:00' },
  282. { deviceCode: 'EQ-ORDER-WARNING', deviceName: '顺序样例-预警设备', deviceTypeName: '液位计', deviceStatus: 'WARNING', statusLabel: '预警', statusReasons: ['液位偏高'], statusTime: '2026-09-02T09:59:50+08:00' },
  283. ]
  284. return {
  285. ...pointDetail({ code: 'MP-ORDER', name: '顺序样例监测点', status: 'ALARM' }),
  286. devices,
  287. deviceStatistics: { totalDeviceCount: 3, alarmDeviceCount: 1, warningDeviceCount: 1, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 },
  288. statusReasons: [{ status: 'ALARM', deviceCode: 'EQ-ORDER-ALARM', reason: '流量超高' }],
  289. }
  290. }
  291. return pointDetail()
  292. }
  293. function pointDetail({ code = 'MP-002', name = '燃气调压站监测点', status = 'ALARM' } = {}) {
  294. const devices = [
  295. {
  296. deviceCode: 'EQ-001',
  297. deviceName: '供水压力计-001',
  298. deviceTypeName: '压力计',
  299. deviceStatus: 'ALARM',
  300. statusLabel: '报警',
  301. statusReasons: ['压力超高'],
  302. statusTime: '2026-09-02T09:59:00+08:00',
  303. },
  304. {
  305. deviceCode: 'EQ-002',
  306. deviceName: '智能井盖-002',
  307. deviceTypeName: '智能井盖',
  308. deviceStatus: 'NORMAL',
  309. statusLabel: '正常',
  310. statusReasons: [],
  311. statusTime: '2026-09-02T09:59:30+08:00',
  312. },
  313. ]
  314. return {
  315. requestId: 'mock-point-detail',
  316. apiVersion: 'v1',
  317. readOnly: true,
  318. monitoringPointCode: code,
  319. monitoringPointName: name,
  320. pointStatus: status,
  321. statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[status] || '',
  322. statusReasons: [{ status, deviceCode: 'EQ-001', reason: '压力超高' }],
  323. deviceStatistics: {
  324. totalDeviceCount: devices.length,
  325. alarmDeviceCount: 1,
  326. warningDeviceCount: 0,
  327. workOrderDeviceCount: 0,
  328. normalDeviceCount: 1,
  329. offlineDeviceCount: 0,
  330. },
  331. devices,
  332. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  333. statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({
  334. status: item,
  335. label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item],
  336. priority: index + 1,
  337. })),
  338. }
  339. }
  340. // ---------------------------------------------------------------- 设备详情聚合
  341. function aggregateSection(state, reason, data, message) {
  342. return { state, reason, message: message || null, data }
  343. }
  344. const ARCHIVE = {
  345. deviceCode: 'TC-PS-WS-202609020012',
  346. deviceName: '污水环境监测设备',
  347. deviceModel: 'WS-PRO-2000',
  348. deviceSpec: '悬浮物/COD/氨氮/电导率/PH',
  349. manufacturer: '沅陵专用仪表厂',
  350. deviceTypeName: '污水环境监测设备',
  351. assetValue: 128000,
  352. expectedServiceYears: 10,
  353. equipmentLocation: '太常片区',
  354. ownershipUnit: '沅陵县排水管理所',
  355. maintainer: '沅陵运维一组',
  356. maintainerPhone: '0745-0000000',
  357. installTime: '2024-03-01T00:00:00+08:00',
  358. latestImage: { url: '/cockpit/aerial.webp', capturedAt: '2026-08-30T10:20:00+08:00' },
  359. }
  360. const METRICS = {
  361. deviceCode: ARCHIVE.deviceCode,
  362. state: 'CURRENT',
  363. stateReason: 'READY',
  364. metrics: [
  365. { metricCode: 'COD', metricName: 'COD', value: 18.2, unit: 'mg/L', status: 'WARNING', sampledAt: '2026-09-02T09:59:30+08:00', freshness: 'CURRENT' },
  366. // 边界:真实 0 是有效读数,适配层不得当成缺失。
  367. { metricCode: 'FLOW', metricName: '流量', value: 0, unit: 'm³/h', status: 'NORMAL', sampledAt: '2026-09-02T09:59:30+08:00', freshness: 'CURRENT' },
  368. ],
  369. }
  370. 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: '太常片区' }
  371. 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: '太常片区' }
  372. const RELATION = { relationId: 'REL-001', warningNo: WARNING.warningNo, alarmId: ALARM.alarmId, relatedAt: '2026-09-02T10:00:00+08:00' }
  373. const WORK_ORDER = { workOrderCode: 'WO-2026090200001', deviceCode: ARCHIVE.deviceCode, orderType: 1, orderTypeName: '故障维修', orderLevel: 2, orderDesc: '处理异常', stateCode: 4, 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' } }
  374. const TIMELINE = { workOrder: { ...WORK_ORDER }, timelineEntries: [{ logId: 1, operationName: '创建工单', operationDesc: '待派单', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00' }] }
  375. function equipmentAggregate(scenario, deviceCode = '') {
  376. const success = aggregateSection('SUCCESS', 'SUCCESS', null)
  377. // 请求哪台设备就报哪台(与真接口一致,客户端传错编码能看出来)。
  378. const code = deviceCode || ARCHIVE.deviceCode
  379. const archive = { ...ARCHIVE, deviceCode: code }
  380. if (scenario === 'empty') {
  381. return {
  382. deviceCode: code,
  383. aggregateState: 'EMPTY',
  384. serverTime: SNAPSHOT_AT,
  385. archive: aggregateSection('EMPTY', 'NO_ARCHIVE', null),
  386. metrics: aggregateSection('EMPTY', 'NO_METRICS', null),
  387. warnings: aggregateSection('EMPTY', 'NO_EVENTS', null),
  388. alarms: aggregateSection('EMPTY', 'NO_EVENTS', null),
  389. alarmWarningRelations: aggregateSection('EMPTY', 'NO_EVENTS', null),
  390. workOrders: aggregateSection('EMPTY', 'NO_WORK_ORDERS', null),
  391. timelines: aggregateSection('EMPTY', 'NO_WORK_ORDERS', null),
  392. latestImage: aggregateSection('EMPTY', 'NO_IMAGE', null),
  393. }
  394. }
  395. if (scenario === 'degraded') {
  396. // 字段缺失:档案只剩编码,指标缺 value/unit,工单缺 stateName(靠七态字典补)。
  397. return {
  398. deviceCode: code,
  399. aggregateState: 'READY',
  400. serverTime: SNAPSHOT_AT,
  401. archive: aggregateSection('SUCCESS', 'SUCCESS', { deviceCode: code }),
  402. metrics: aggregateSection('SUCCESS', 'SUCCESS', { deviceCode: code, state: 'CURRENT', stateReason: 'READY', metrics: [{ metricCode: 'COD', metricName: 'COD', sampledAt: '2026-09-02T09:59:30+08:00' }] }),
  403. warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
  404. alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
  405. alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
  406. workOrders: aggregateSection('SUCCESS', 'SUCCESS', [{ workOrderCode: 'WO-DEGRADED', stateCode: 3 }]),
  407. timelines: aggregateSection('SUCCESS', 'SUCCESS', [TIMELINE]),
  408. latestImage: aggregateSection('EMPTY', 'NO_IMAGE', null),
  409. }
  410. }
  411. if (scenario === 'stale') {
  412. return {
  413. deviceCode: code,
  414. aggregateState: 'PARTIAL',
  415. serverTime: SNAPSHOT_AT,
  416. archive: aggregateSection('SUCCESS', 'SUCCESS', archive),
  417. // 过期但保留旧值(票 16:保留最后已知值 + 过期标识)。
  418. metrics: aggregateSection('STALE', 'SAMPLE_OUTDATED', { ...METRICS, state: 'STALE', stateReason: 'SAMPLE_OUTDATED' }),
  419. warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
  420. alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
  421. alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
  422. workOrders: aggregateSection('SUCCESS', 'SUCCESS', [WORK_ORDER]),
  423. timelines: aggregateSection('SUCCESS', 'SUCCESS', [TIMELINE]),
  424. latestImage: aggregateSection('SUCCESS', 'SUCCESS', archive.latestImage),
  425. }
  426. }
  427. if (scenario === 'equipment-partial') {
  428. // 票 11 验收第 3 条:任一分区失败不遮盖其它成功分区。
  429. //
  430. // 为什么要一个**独立场景名**而不是复用 `failed`:`failed` 在注册层就抛错
  431. // (整个端点读不到,`data` 为 null),那是"这份详情根本没取到",
  432. // 与"取到了、只是其中三块分区各自失败"是两件事。后端契约会把后者原样返回
  433. // (`aggregateState: PARTIAL` + 每分区自带 state/reason/message),
  434. // 所以必须有一个能真正走通的样例,否则这条验收在 mock 上无法演示。
  435. return {
  436. deviceCode: code,
  437. aggregateState: 'PARTIAL',
  438. serverTime: SNAPSHOT_AT,
  439. archive: aggregateSection('FAILED', 'READ_FAILURE', null, '设备档案数据暂不可用'),
  440. metrics: aggregateSection('SUCCESS', 'SUCCESS', METRICS),
  441. warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
  442. alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
  443. alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
  444. workOrders: aggregateSection('FAILED', 'READ_FAILURE', null, '工单数据暂不可用'),
  445. timelines: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '工单分区失败,时间线不可用'),
  446. latestImage: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '设备档案失败,图片不可用'),
  447. }
  448. }
  449. return {
  450. deviceCode: code,
  451. aggregateState: 'READY',
  452. serverTime: SNAPSHOT_AT,
  453. archive: { ...success, data: archive },
  454. metrics: { ...success, data: METRICS },
  455. warnings: { ...success, data: [WARNING] },
  456. alarms: { ...success, data: [ALARM] },
  457. alarmWarningRelations: { ...success, data: [RELATION] },
  458. workOrders: { ...success, data: [WORK_ORDER] },
  459. timelines: { ...success, data: [TIMELINE] },
  460. latestImage: { ...success, data: archive.latestImage },
  461. }
  462. }
  463. // ---------------------------------------------------------------- 事件与工单
  464. function eventRead(scenario, deviceCode = '') {
  465. const base = {
  466. requestId: 'mock-event-read',
  467. apiVersion: 'v1',
  468. readOnly: true,
  469. deviceCode: deviceCode || ARCHIVE.deviceCode,
  470. deviceType: 'ws_sewage_environment',
  471. deviceTypeName: '污水环境监测设备',
  472. startTime: null,
  473. endTime: null,
  474. timezone: 'Asia/Shanghai',
  475. page: 1,
  476. pageSize: 0,
  477. }
  478. if (scenario === 'empty') {
  479. return { ...base, state: 'EMPTY', stateReason: 'NO_EVENTS', warnings: [], alarms: [], alarmWarningRelations: [], total: 0 }
  480. }
  481. if (scenario === 'degraded') {
  482. // 缺 levelColor 与设备编码:适配层不得猜颜色,也不得伪造归属设备。
  483. return {
  484. ...base,
  485. state: 'READY',
  486. stateReason: 'READY',
  487. warnings: [{ warningNo: 'WRN-DEGRADED-001', warningName: '字段缺失预警', level: 2, status: 'PROCESSING', eventTime: '2026-09-02T09:59:30+08:00', unit: 'mg/L' }],
  488. alarms: [],
  489. alarmWarningRelations: [],
  490. total: 1,
  491. }
  492. }
  493. if (scenario === 'stale') {
  494. return {
  495. ...base,
  496. state: 'STALE',
  497. stateReason: 'SAMPLE_OUTDATED',
  498. warnings: [{ ...WARNING, freshness: 'STALE' }],
  499. alarms: [],
  500. alarmWarningRelations: [],
  501. total: 1,
  502. }
  503. }
  504. return {
  505. ...base,
  506. state: 'READY',
  507. stateReason: 'READY',
  508. warnings: [WARNING],
  509. alarms: [ALARM],
  510. alarmWarningRelations: [RELATION],
  511. total: 2,
  512. }
  513. }
  514. // ---------------------------------------------------------------- 新鲜度
  515. function freshnessPayload(scenario, deviceCode = '') {
  516. const base = {
  517. requestId: 'mock-freshness',
  518. apiVersion: 'v1',
  519. readOnly: true,
  520. deviceCode: deviceCode || ARCHIVE.deviceCode,
  521. serverUpdatedAt: SNAPSHOT_AT,
  522. calibrationPeriodSeconds: 60,
  523. }
  524. if (scenario === 'empty') {
  525. return { ...base, updatedAt: null, statusTime: null, freshness: 'EMPTY', staleReason: null, missedCycles: 0, dataAgeSeconds: 0, retained: false, message: null }
  526. }
  527. if (scenario === 'degraded') {
  528. // 只给了 freshness,没给任何时间:界面必须显示占位符而不是「0 秒前」。
  529. return { ...base, freshness: 'CURRENT' }
  530. }
  531. if (scenario === 'stale') {
  532. 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: '数据暂不可用/已过期' }
  533. }
  534. 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 }
  535. }
  536. // ---------------------------------------------------------------- 注册
  537. /** 各接口在五类样例下的载荷(`failed` 由处理器抛出,不在这里给载荷)。 */
  538. const PAYLOADS = {
  539. // 票 08:地图摘要按**真实分页语义**给(分页窗口横跨监测点与未关联设备、第 2 页起要求快照一致)。
  540. '/map-snapshot': (s, _requested, context) => mapSnapshotPayload(s, context.path, context.mapSnapshot),
  541. '/monitoring-points/*': (s) => pointDetailPayload(s),
  542. '/equipments/*/detail-aggregate': (s, requested) => equipmentAggregate(s, requested),
  543. '/equipments/*/events/current': (s, requested) => eventRead(s, requested),
  544. '/equipments/*/events/history': (s, requested) => eventRead(s, requested),
  545. '/equipments/*/work-orders': (s) => ({
  546. requestId: 'mock-work-orders',
  547. apiVersion: 'v1',
  548. readOnly: true,
  549. deviceCode: ARCHIVE.deviceCode,
  550. eventSource: null,
  551. eventCode: null,
  552. state: s === 'empty' ? 'EMPTY' : 'READY',
  553. stateReason: s === 'empty' ? 'NO_WORK_ORDERS' : 'READY',
  554. stateDictionary: [
  555. { code: 1, name: '待派单' },
  556. { code: 2, name: '待接单' },
  557. { code: 3, name: '已接单' },
  558. { code: 4, name: '处理中' },
  559. { code: 5, name: '待验收' },
  560. { code: 6, name: '已结案' },
  561. { code: 7, name: '已取消' },
  562. ],
  563. workOrders: s === 'empty' ? [] : [WORK_ORDER],
  564. }),
  565. '/work-orders/*/timeline': (s) => (s === 'empty' ? { workOrder: null, timelineEntries: [] } : TIMELINE),
  566. '/equipments/*/freshness': (s, requested) => freshnessPayload(s, requested),
  567. '/equipments/*/archive': (s) => (s === 'empty' ? null : ARCHIVE),
  568. }
  569. /**
  570. * 从请求路径里取出模板 `*` 命中的那一段(即路径参数)。
  571. * `('/equipments/TC-1/detail-aggregate', '/equipments/' + '*' + '/detail-aggregate')` → `TC-1`。
  572. * 模板里有多个 `*` 时只取第一个——当前接口最多一个路径参数,够用且不猜。
  573. */
  574. function pathParameter(requestPath, template) {
  575. const star = template.indexOf('*')
  576. if (star === -1) return ''
  577. const prefix = template.slice(0, star)
  578. const suffix = template.slice(star + 1)
  579. const bare = String(requestPath || '').split('?')[0]
  580. if (!bare.startsWith(prefix)) return ''
  581. const rest = bare.slice(prefix.length)
  582. const end = suffix ? rest.indexOf(suffix) : rest.length
  583. if (end === -1) return ''
  584. return decodeURIComponent(rest.slice(0, end))
  585. }
  586. /**
  587. * 注册全部契约化 mock。
  588. * @param {object} [options]
  589. * @param {string} [options.scenario] `success`(默认)/`empty`/`degraded`/`stale`/`failed`/`unauthorized`
  590. * @param {string} [options.onlyPath] 只让这一个路径走「异常样例」,其余保持 `success`
  591. * —— 票 05 验收第 6 条要的正是「只把**受影响分区**标为数据不可用」,
  592. * 所以必须能只让一个分区坏掉、其余照常成功。
  593. * @param {(path: string) => boolean} [options.only] 同上,函数形式(便于按前缀选中一族接口)
  594. * @param {string} [options.prefix] 同上,按路径前缀选中一族接口(`/equipments/`)
  595. * @param {object} [options.mapSnapshot] 地图摘要分页的现场覆盖(票 08 走查用):
  596. * `{ pageSize, snapshotAt, expireAfterPage }`
  597. * @param {object} handlers services/cockpitApi.js 的三个注册函数(注入以便单测)
  598. */
  599. export function registerCockpitMocks(options = {}) {
  600. const { scenario = 'success', onlyPath = '', prefix = '', handlers } = options
  601. const mapSnapshot = options.mapSnapshot || {}
  602. // `prefix` 是 `only` 的便捷写法:两者都给时以 `only` 为准。
  603. const only = options.only || (prefix ? (path) => path.startsWith(prefix) : null)
  604. const register = (handlers && handlers.registerMockTransport) || null
  605. if (!register) {
  606. throw new Error('registerCockpitMocks 需要注入 registerMockTransport')
  607. }
  608. const reset = (handlers && handlers.resetMockTransportsForTest) || null
  609. if (reset) reset()
  610. const targets = new Set()
  611. for (const [path, build] of Object.entries(PAYLOADS)) {
  612. const applies = !onlyPath && !only ? true : only ? !!only(path) : path === onlyPath
  613. const active = applies ? scenario : 'success'
  614. register(path, async (context) => {
  615. // `failed`:抛出让统一出口归类成「不可用」,模拟分区自己读取失败。
  616. if (active === 'failed') {
  617. throw new Error(`mock: ${path} 读取失败`)
  618. }
  619. if (active === 'unauthorized') {
  620. // 401 不是载荷:把凭据类失败原因交给统一出口归类,
  621. // 由它回 `{ok:false, reason:'unauthorized'}`(服务层只认白名单里的原因)。
  622. const error = new Error('mock: 凭据未通过校验')
  623. error.reason = REQUEST_REASON.UNAUTHORIZED
  624. throw error
  625. }
  626. // 带路径参数的接口回显请求里的编码:这样「按编码取数」的行为与真接口一致
  627. // (客户端传错编码时能看出来,而不是永远拿到同一台设备)。
  628. const requested = pathParameter(context.path, path)
  629. const payload = build(active, requested, { path: context.path, mapSnapshot })
  630. if (!requested) return payload
  631. if (path === '/monitoring-points/*') {
  632. return { ...payload, monitoringPointCode: requested }
  633. }
  634. // 设备族接口:统一把请求的设备编码写回载荷(设备详情/事件/工单/新鲜度/档案都一样)。
  635. if (typeof payload === 'object' && payload !== null && 'deviceCode' in payload) {
  636. return { ...payload, deviceCode: requested }
  637. }
  638. return payload
  639. })
  640. targets.add(path)
  641. }
  642. return { scenario, paths: [...targets], isolated: onlyPath || prefix || (only ? 'predicate' : '') }
  643. }