contractMocks.js 202 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430
  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. * 票 12 追加两个场景(时间线是**工单面板内**的分区,成败与工单列表分开):
  13. * `work-order-timeline`:三张工单、各一条独立时间线(推进 / 回退 / 取消三种形状都齐);
  14. * `work-order-timeline-failed`:工单列表成功、**只有时间线分区失败**(分区独立)。
  15. *
  16. * 数据是**精心构造的边界值**,不是随手填的:坐标覆盖 VALID/UNVERIFIED/INVALID/ZERO、
  17. * 指标覆盖 0 与「无采样」、时间覆盖未结案(null)。
  18. */
  19. import { REQUEST_REASON } from '../domain/requestReason.js'
  20. // 票 20:工单状态字典**只从唯一源取**(后端 `WorkOrderStatusEnum` 0–7 的前端映射)。
  21. // mock 不再自备一份副本 —— 那正是"后端改成 0–7 而 mock 还发 1–7"的成因。
  22. import { WORK_ORDER_STATES } from '../domain/workOrder.js'
  23. // 票 61:历史分析的逐事件行与当前事件表共用同一套排序口径(不另写一份比较器)。
  24. import { sortEventRows } from '../domain/eventList.js'
  25. const SNAPSHOT_AT = '2026-09-02T10:00:00+08:00'
  26. /** 票 42 的受控样例:承载有效坐标设备的点位与其设备编码清单(地图与详情必须一致)。 */
  27. const DEVICE_LAYER_POINT = 'MP-001'
  28. const DEVICE_LAYER_CODES = ['EQ-001', 'EQ-002']
  29. const DEVICE_LAYER_COORDS = [
  30. { longitude: 110.3991, latitude: 28.4351 },
  31. { longitude: 110.3975, latitude: 28.4338 },
  32. ]
  33. const OVERLAP_DEVICE_LAYER_COORDS = [
  34. { longitude: 110.3991, latitude: 28.4351 },
  35. { longitude: 110.3991, latitude: 28.4351 },
  36. ]
  37. /**
  38. * 票 76(2026-10-23 维护者裁定「删过期 mock」):旧的 `EQ-DG-*` K>10 样例已删除。
  39. * 它经多轮修改后快照/详情/坐标三处各说各话(走查反复出现「太常跳到城南」「11 台不一致」),
  40. * 聚合测试统一改用下面的 `aggregate-lab`(`EQ-AG-*`,三处同源)。
  41. */
  42. /**
  43. * 票 76(mock 串点修正,2026-10-23 走查):MP-002「太常」的 K=11 样例改用**独立编码**。
  44. * 此前它与专用场景点位(MP-001 城南)共用 `EQ-DG-*` —— 同一台设备同时挂在两个点位下,
  45. * 点设备按编码回联时命中另一点位,走查表现为「太常的设备跳到城南聚合」。
  46. */
  47. const OVERLAP_TAICHANG_CODES = Array.from({ length: 11 }, (_, index) => `EQ-TC-${String(index + 1).padStart(3, '0')}`)
  48. /**
  49. * 票 76(聚合测试数据重建,维护者 2026-10-23 要求):**干净的聚合实验样例**。
  50. *
  51. * 旧样例(`EQ-DG-*`)经多轮修改已不可靠(快照/详情/坐标三处各说各话)。这份新样例只做一件事:
  52. * **4 台设备同坐标**(K=4 > 1 且 ≤ maxFan=10,不进降级),且
  53. * 快照 `deviceCodes`、详情 `devices[]`、设备坐标 **三处同源**。
  54. * 走查入口:`?scenario=aggregate-lab` → 点开 **MP-001(城南测试监测点)**。
  55. */
  56. const AGGREGATE_LAB_CODES = Array.from({ length: 4 }, (_, index) => `EQ-AG-${String(index + 1).padStart(3, '0')}`)
  57. const AGGREGATE_LAB_COORDS = Array.from({ length: 4 }, () => ({ longitude: 110.3989, latitude: 28.4349 }))
  58. /**
  59. * 票 47:**设备状态动效样例** —— 五态各一台(坐标各自错开,不触发重叠聚合),
  60. * 用来验证「只有报警/预警/工单三种需处置状态有动效,正常/离线完全静止」。
  61. */
  62. const EFFECTS_SAMPLE = [
  63. { deviceCode: 'EQ-EF-ALARM', deviceName: '动效样例-报警设备', deviceStatus: 'ALARM' },
  64. { deviceCode: 'EQ-EF-WARNING', deviceName: '动效样例-预警设备', deviceStatus: 'WARNING' },
  65. { deviceCode: 'EQ-EF-WORK', deviceName: '动效样例-工单处置设备', deviceStatus: 'WORK_ORDER' },
  66. { deviceCode: 'EQ-EF-NORMAL', deviceName: '动效样例-正常设备', deviceStatus: 'NORMAL' },
  67. { deviceCode: 'EQ-EF-OFFLINE', deviceName: '动效样例-离线设备', deviceStatus: 'OFFLINE' },
  68. ].map((item, index) => ({
  69. ...item,
  70. // 错开约 80m,既不重叠也不会因为贴太近互相遮挡。
  71. longitude: 110.3991 + index * 0.0008,
  72. latitude: 28.4351 + index * 0.0006,
  73. }))
  74. /** 票 47:**降级样例** —— 31 台报警设备(超过 `map.effects.abnormalLimit` 默认 30)。 */
  75. const EFFECTS_DEGRADED_SAMPLE = Array.from({ length: 31 }, (_, index) => ({
  76. deviceCode: `EQ-EF-D${String(index + 1).padStart(2, '0')}`,
  77. deviceName: `动效降级设备-${index + 1}`,
  78. deviceStatus: 'ALARM',
  79. longitude: 110.3991 + (index % 6) * 0.0008,
  80. latitude: 28.4351 + Math.floor(index / 6) * 0.0006,
  81. }))
  82. // ---------------------------------------------------------------- 地图摘要
  83. const MAP_POINTS = [
  84. {
  85. pointCode: 'MP-001',
  86. // 维护者指定的走查测试点位(原名「供水干管监测点」):坐标落在主场景区内,
  87. // 县域默认视角下**可见**,用于验收「点选 → 点位级定位 → 面板锚定」。
  88. pointName: '城南测试监测点',
  89. pointStatus: 'NORMAL',
  90. statusReasons: [],
  91. // 维护者要求的主场景示例之一:**K=2 正常重叠**(两台设备同坐标,可逐台展开)。
  92. deviceCodes: DEVICE_LAYER_CODES.slice(),
  93. // 票 18:`map-snapshot` 真的收 industry/district/deviceType 三个筛选入参(票 25 第 3 节的
  94. // 冻结路径),mock 必须照此**真的过滤** —— 否则「筛选下发到地图」的断言会变成假绿
  95. // (请求串里有参数、但样例永远回同一份数据)。这两个字段域对象不读,只服务过滤。
  96. industry: 'WATER_SUPPLY',
  97. district: 0, districtName: '城南',
  98. longitude: 110.39832789395186,
  99. latitude: 28.434456975811777,
  100. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  101. },
  102. {
  103. pointCode: 'MP-002',
  104. // 维护者指定的走查测试点位(原名「燃气调压站监测点」)。**必须与台账分布里的坐标一致** ——
  105. // 台账卡片点选后按编码回联的是本表(`map-snapshot`),两处不一致会让"点同一个点飞到两个地方"。
  106. pointName: '太常测试监测点',
  107. pointStatus: 'ALARM',
  108. statusReasons: ['EQ-ASSOC-001 报警'],
  109. // 维护者要求的主场景示例之一:**K=11 降级**(超过 `map.overlap.maxFan` 默认 10)。
  110. // 票 76:用太常专属编码(不再与城南场景点共用 `EQ-DG-*`,避免同一设备挂两个点位)。
  111. deviceCodes: OVERLAP_TAICHANG_CODES.slice(),
  112. industry: 'GAS',
  113. district: 2, districtName: '太常',
  114. longitude: 110.36299369455823,
  115. latitude: 28.452196938060194,
  116. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  117. },
  118. {
  119. pointCode: 'MP-003',
  120. pointName: '污水排口监测点',
  121. pointStatus: 'WARNING',
  122. statusReasons: ['EQ-FREE-003 预警'],
  123. deviceCodes: ['EQ-003'],
  124. industry: 'DRAINAGE',
  125. // 空区域被后端归一为字面量「未分区」——那是**真实取值**,筛选它能筛到东西。
  126. district: null, districtName: '未分区',
  127. longitude: 110.143,
  128. latitude: 28.454,
  129. coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' },
  130. },
  131. ]
  132. const MAP_EQUIPMENT = [
  133. {
  134. deviceCode: 'EQ-FREE-001',
  135. deviceName: '供水压力计-001',
  136. // 类型不可推导时后端给 null,且**默认不排除对象**(不筛时仍返回)。
  137. industry: null,
  138. district: 0, districtName: '城南',
  139. // 票 14:未关联设备按**设备状态词表**参与状态筛选(决策 02 的 Q22)。
  140. deviceStatus: 'NORMAL',
  141. longitude: 181,
  142. latitude: 28.455,
  143. coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' },
  144. },
  145. {
  146. deviceCode: 'EQ-FREE-004',
  147. deviceName: '零坐标设备-004',
  148. industry: null,
  149. district: null, districtName: '未分区',
  150. // 状态未发布(缺字段):落 UNKNOWN 桶——不猜成正常。
  151. deviceStatus: null,
  152. longitude: 0,
  153. latitude: 0,
  154. coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' },
  155. },
  156. ]
  157. /** 地图摘要的演示分页(票 08):契约允许任意页大小,演示用 2 —— 三页翻完,看得出来是渐进加载。 */
  158. const MAP_SNAPSHOT_PAGE_SIZE = 2
  159. /**
  160. * 票 09 的点位状态样例:**后端六态各一个**,全部坐标合法(都该上图),
  161. * 外加一个后端新增、前端不认识的第九个状态 —— 它必须落「状态未发布」而不是被猜成正常。
  162. *
  163. * 为什么要单独一份而不是改上面那三个点位:票 07/08 的走查把 `2,4,5` / 可上图 2 · 不上图 3
  164. * 这些数写成了断言,改默认样例就会改掉那两张票的证据。所以状态演示走**独立场景**
  165. * (`__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'`),默认样例一个字节不动。
  166. */
  167. const STATUS_SAMPLE_POINTS = [
  168. { pointCode: 'MP-ST-ALARM', pointName: '燃气调压站报警监测点', pointStatus: 'ALARM' },
  169. { pointCode: 'MP-ST-WARNING', pointName: '污水排口预警监测点', pointStatus: 'WARNING' },
  170. { pointCode: 'MP-ST-WORKORDER', pointName: '供水干管工单处置监测点', pointStatus: 'WORK_ORDER' },
  171. { pointCode: 'MP-ST-NORMAL', pointName: '排水泵站正常监测点', pointStatus: 'NORMAL' },
  172. { pointCode: 'MP-ST-OFFLINE', pointName: '窨井离线监测点', pointStatus: 'OFFLINE' },
  173. { pointCode: 'MP-ST-EMPTY', pointName: '预留无设备监测点', pointStatus: 'EMPTY' },
  174. // 后端将来新增的状态:前端不认识,必须如实说「状态未发布」,不得落到任何真实状态上。
  175. { pointCode: 'MP-ST-UNKNOWN', pointName: '未知状态监测点', pointStatus: 'MAINTENANCE' },
  176. ].map((item, index) => ({
  177. ...item,
  178. statusReasons: [],
  179. // 沿经纬度铺开一点,免得标记叠在一起看不出形状差异。
  180. longitude: 110.141 + index * 0.002,
  181. latitude: 28.452 + index * 0.001,
  182. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  183. }))
  184. /** 状态样例里的未关联设备(第六态「无关联设备」的落点,形状是十字)。 */
  185. const STATUS_SAMPLE_EQUIPMENT = [
  186. {
  187. deviceCode: 'EQ-ST-FREE',
  188. deviceName: '未关联设备-001',
  189. // 票 14:设备状态参与状态筛选(决策 02 的 Q22)——样例给一个真实态。
  190. deviceStatus: 'WARNING',
  191. longitude: 110.155,
  192. latitude: 28.459,
  193. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  194. },
  195. ]
  196. /**
  197. * 票 14:mock 侧的**设备级事实表**(决策 02 的 Q7/Q8——命中设备子集与点位 OR 归并的依据)。
  198. *
  199. * 真实后端按「任一设备命中 industry/district/deviceType → 点位进入结果」归并,
  200. * 且点位响应的 `deviceCodes` 按当前筛选裁剪为**命中设备子集**(未筛=全部)。
  201. * mock 要演示这条契约,就得知道每台关联设备的行业/区域/类型 ——
  202. * 这里登记的是**测试事实**,不是业务数据(正式口径归后端执行票 12+)。
  203. * 未登记的设备不参与裁剪(宁可多给,不吞)。
  204. */
  205. const DEVICE_FACTS = {
  206. 'EQ-001': { industry: 'WATER_SUPPLY', district: 0, districtName: '城南', deviceType: 'WATER_SUPPLY_SENSOR' },
  207. 'EQ-002': { industry: 'GAS', district: 2, districtName: '太常', deviceType: 'GAS_SENSOR' },
  208. 'EQ-003': { industry: 'DRAINAGE', district: null, districtName: '未分区', deviceType: 'SEWAGE_ENV' },
  209. }
  210. /** 设备自身状态的五个取值(`domain/deviceStatus.js` 的词表;`EMPTY` 是点位聚合态专有)。 */
  211. const DEVICE_STATUS_CODES_MOCK = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE']
  212. /** 后端六个点位状态(`domain/pointStatus.js` 的 `POINT_STATUS`)。 */
  213. const POINT_STATUS_CODES_MOCK = ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY']
  214. /**
  215. * 一个摘要对象的状态桶(票 14 的八桶匹配语义,决策 02 的 Q22/Q23):
  216. * · 监测点按**聚合状态**六态匹配;缺失或不在六态词典 → `UNKNOWN`(不猜成正常);
  217. * · 未关联设备按**设备状态词表**五态匹配(mock 载荷新增 `deviceStatus` 字段建模这条契约);
  218. * 缺失或认不出 → `UNKNOWN`(设备状态未发布)。
  219. */
  220. function statusBucketOf(item) {
  221. if ('pointCode' in item) {
  222. const status = item.pointStatus
  223. return status && POINT_STATUS_CODES_MOCK.includes(status) ? status : 'UNKNOWN'
  224. }
  225. const status = item.deviceStatus
  226. return status && DEVICE_STATUS_CODES_MOCK.includes(status) ? status : 'UNKNOWN'
  227. }
  228. /**
  229. * 多值状态筛选的匹配(决策 02 的 Q11/Q16——「无关联设备」桶是类别筛选,与状态正交):
  230. * · 空集合 → 全部;
  231. * · 只选 `UNASSOCIATED` → 只看未关联设备(监测点整类排除);
  232. * · `UNASSOCIATED` + 状态桶 → 只看命中状态的未关联设备;
  233. * · 只选状态桶 → 命中状态的监测点 + 命中状态的未关联设备(各自用各自的状态来源)。
  234. */
  235. function matchesStatusFilter(item, statusSet) {
  236. if (!statusSet.length) return true
  237. const onlyDevices = statusSet.includes('UNASSOCIATED')
  238. const statusKeys = statusSet.filter((key) => key !== 'UNASSOCIATED')
  239. if ('pointCode' in item) {
  240. if (onlyDevices) return false
  241. if (!statusKeys.length) return true
  242. return statusKeys.includes(statusBucketOf(item))
  243. }
  244. if (statusKeys.length && !statusKeys.includes(statusBucketOf(item))) return false
  245. return true
  246. }
  247. /** district 空值归一(后端把空归一为字面量「未分区」——那是真实取值)。 */
  248. function normalizedDistrict(value) {
  249. const raw = value === null || value === undefined ? '' : String(value).trim()
  250. return ['0', '1', '2'].includes(raw) ? raw : 'UNASSIGNED'
  251. }
  252. /**
  253. * 一台关联设备的**设备级**筛选命中(行业/区域/类型;未登记的设备不判负)。
  254. * 票 37:`deviceType` 是后端码的**精确匹配**(`GAS_SENSOR`/`WATER_SUPPLY_SENSOR`/`SEWAGE_ENV`);
  255. * mock 样例的类型码全部来自 `DEVICE_FACTS`(与后端字典同形),不发明第二个类型域。
  256. */
  257. function deviceHitsFilters(code, filters) {
  258. const facts = DEVICE_FACTS[code]
  259. if (!facts) return true
  260. if (filters.industry && facts.industry !== filters.industry) return false
  261. if (filters.district && normalizedDistrict(facts.district) !== normalizedDistrict(filters.district)) return false
  262. if (filters.deviceType && facts.deviceType !== filters.deviceType) return false
  263. return true
  264. }
  265. /**
  266. * 票 14:按 `map-snapshot` 的筛选入参**真的过滤**,照抄决策 02 冻结的后端口径。
  267. *
  268. * 为什么 mock 必须真的筛(而不是"收了参数照旧回全量"):`mapStore.applyFilters()` 会先清空
  269. * 装载器再取第 1 页;若样例永远回同一份数据,「筛选真的下发到地图了吗」这条断言就只是
  270. * 在看请求串 —— 参数拼错了、或者根本没传,画面都一样,是**假绿**。
  271. *
  272. * 四条语义(决策 02 的 Answer 第一节 + 第二节):
  273. * · 空值归一:「未分区」是 district 空值归一后的**真实取值**,筛选它能筛到东西;
  274. * · **不带筛选时返回全部对象**(`industry` 为 null 也算数),保证"全城底图"不因类型缺失丢对象;
  275. * · 行业/范围/类型=**设备级匹配、点位 OR 归并**:点位自身命中或任一关联设备命中都进入结果;
  276. * · 状态=八桶多值 OR(六态 + UNKNOWN + UNASSOCIATED,见 `matchesStatusFilter`)。
  277. */
  278. function matchesMapFilters(item, filters) {
  279. const industry = (filters && filters.industry) || ''
  280. const district = (filters && filters.district) || ''
  281. const deviceType = (filters && filters.deviceType) || ''
  282. const statusRaw = (filters && filters.pointStatus) || []
  283. const statusSet = Array.isArray(statusRaw) ? statusRaw : String(statusRaw).split(',').filter(Boolean)
  284. if (!matchesStatusFilter(item, statusSet)) return false
  285. if ('pointCode' in item) {
  286. // 点位:设备级 OR 归并——自身命中,或任一关联设备命中(未筛时该维度是"不判")。
  287. if (industry && item.industry !== industry && !(item.deviceCodes || []).some((code) => deviceHitsFilters(code, { industry }))) return false
  288. if (district) {
  289. const own = normalizedDistrict(item.district) === normalizedDistrict(district)
  290. const viaDevice = (item.deviceCodes || []).some((code) => deviceHitsFilters(code, { district }))
  291. if (!own && !viaDevice) return false
  292. }
  293. if (deviceType) {
  294. const viaDevice = (item.deviceCodes || []).some((code) => deviceHitsFilters(code, { deviceType }))
  295. if (!viaDevice) return false
  296. }
  297. return true
  298. }
  299. // 未关联设备:按自身属性直接匹配。
  300. if (industry && item.industry !== industry) return false
  301. if (district && normalizedDistrict(item.district) !== normalizedDistrict(district)) return false
  302. // `deviceType` 不可推导的对象在带该筛选时被排除(后端同款)。
  303. if (deviceType) return false
  304. return true
  305. }
  306. /**
  307. * 票 14:命中设备子集(决策 02 的 Q8——点位响应的 `deviceCodes` 按当前筛选裁剪,未筛=全部)。
  308. * 只在**设备级**筛选(行业/范围/类型)生效时裁剪;未登记的设备保留(不吞)。
  309. */
  310. function cropToDeviceHits(point, filters) {
  311. const hasDeviceLevel = !!(filters && (filters.industry || filters.district || filters.deviceType))
  312. const codes = Array.isArray(point.deviceCodes) ? point.deviceCodes : []
  313. if (!hasDeviceLevel) return codes
  314. return codes.filter((code) => deviceHitsFilters(code, filters))
  315. }
  316. /**
  317. * 票 14:八桶**全局未筛**计数(决策 02 的第三节——状态栏与饼图共用同一份,筛选时不变)。
  318. * 六态与 UNKNOWN 数**监测点**;UNASSOCIATED 数**未关联设备**(类别桶的计数)。
  319. * 判据是完整集合(filter 之前),与分页/筛选无关。
  320. */
  321. function globalStatusDistribution(points, equipment) {
  322. const counts = { ALARM: 0, WARNING: 0, WORK_ORDER: 0, NORMAL: 0, OFFLINE: 0, EMPTY: 0, UNKNOWN: 0, UNASSOCIATED: 0 }
  323. for (const point of points) counts[statusBucketOf(point)] += 1
  324. for (const device of equipment) counts.UNASSOCIATED += 1
  325. return Object.keys(counts).map((status) => ({ status, count: counts[status] }))
  326. }
  327. /**
  328. * 按后端**真实语义**造一页地图摘要(票 08 的分页 + 票 18 的筛选 + 票 14 的契约)。
  329. *
  330. * 照抄 `CockpitMapSnapshotService.load()` 的三条行为,而不是造一份"理想载荷":
  331. * 1. 分页窗口是「监测点 + 未关联设备」这**一条总序列**:先排满监测点,再排未关联设备
  332. * (所以后一页可能整页都是设备,或两类混在一页);
  333. * 2. 第 2 页起**必须**带 `snapshotAt`,且与当前快照一致,否则抛 `SNAPSHOT_EXPIRED`;
  334. * 3. `complete` 只在「本页末尾已到总数」时为真。
  335. *
  336. * 票 07 起另有一条**必须照抄**:响应带 `coordinateValidation` **全量**汇总
  337. * (后端用整份 points/equipment 算完再切片分页,所以它不随页码变化)。
  338. *
  339. * 票 14 追加两条(决策 02 冻结的契约):
  340. * · 点位响应的 `deviceCodes` 按当前筛选裁剪为**命中设备子集**(未筛=全部);
  341. * · 响应带八桶**全局未筛**计数 `pointStatusDistribution`(状态栏与饼图共用,筛选时不变)。
  342. *
  343. * @param {object} options
  344. * @param {number} [options.page] 页码(1 起)
  345. * @param {number} [options.pageSize] 每页条数
  346. * @param {string} [options.snapshotAt] 客户端带的快照标记(第 1 页不带)
  347. * @param {string} [options.currentSnapshot] 本次请求后端当前快照(固定值,见 `planMapSnapshot`)
  348. * @param {Array} [options.points] 覆盖监测点集合(票 09 的状态样例用)
  349. * @param {Array} [options.equipment] 覆盖未关联设备集合
  350. */
  351. function mapSnapshotPage({
  352. page = 1,
  353. pageSize = MAP_SNAPSHOT_PAGE_SIZE,
  354. snapshotAt = '',
  355. currentSnapshot = SNAPSHOT_AT,
  356. points = MAP_POINTS,
  357. equipment = MAP_EQUIPMENT,
  358. filters = null,
  359. echoFilters = null,
  360. } = {}) {
  361. if (page > 1 && !snapshotAt) {
  362. const error = new Error('mock: 第 2 页起必须带 snapshotAt')
  363. error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED
  364. throw error
  365. }
  366. if (snapshotAt && snapshotAt !== currentSnapshot) {
  367. const error = new Error('mock: 快照标记与当前快照不一致')
  368. error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED
  369. throw error
  370. }
  371. // 票 18:筛选**先于分页**(后端也是先过滤再切片),且 `coordinateValidation` 汇总是
  372. // 「当前筛选范围内的全量口径」——所以它跟着筛选变,不跟着页码变。
  373. const selected = filters
  374. ? [...points, ...equipment].filter((item) => matchesMapFilters(item, filters))
  375. : [...points, ...equipment]
  376. const from = Math.max(0, (page - 1) * pageSize)
  377. const to = from + pageSize
  378. const slice = selected.slice(from, to)
  379. // 票 14:本页的点位带上**命中设备子集**(未筛=全部;决策 02 的 Q8)。
  380. const slicedPoints = slice
  381. .filter((item) => 'pointCode' in item)
  382. .map((point) => ({ ...point, deviceCodes: cropToDeviceHits(point, filters) }))
  383. return {
  384. page,
  385. pageSize,
  386. snapshotAt: currentSnapshot,
  387. batchUpdatedAt: currentSnapshot,
  388. total: selected.length,
  389. complete: to >= selected.length,
  390. monitoringPoints: slicedPoints,
  391. unassociatedEquipment: slice.filter((item) => 'deviceCode' in item),
  392. // 编码回显来自**过滤后全量**(分页前),与后端同口径。
  393. mapFilter: mapFilterEcho(echoFilters || filters || {}, selected.filter((item) => 'pointCode' in item), selected.filter((item) => 'deviceCode' in item)),
  394. // 全量口径:与 `CockpitMapSnapshotService.coordinateValidation(points, equipment)` 一致。
  395. coordinateValidation: coordinateValidationSummary(selected),
  396. // 票 14:八桶全局未筛计数(完整集合口径,筛选时不变;状态栏与饼图共用)。
  397. pointStatusDistribution: globalStatusDistribution(points, equipment),
  398. }
  399. }
  400. /**
  401. * 坐标校验汇总(票 07):照抄后端 `CockpitCoordinateValidationSummary` 的字段名与判据。
  402. *
  403. * 后端的分法是**三桶**:`renderable` 优先,其余里 `state === 'INVALID'` 记非法,
  404. * 剩下的一律记「未校验」(`CockpitMapSnapshotService` 第 205-231 行)。
  405. * 这里照抄同一个分法 —— 换一套算法就会让前端显示的数与后端对不上。
  406. */
  407. function coordinateValidationSummary(objects) {
  408. let renderableCount = 0
  409. let invalidCount = 0
  410. let unverifiedCount = 0
  411. for (const item of objects) {
  412. const validation = item.coordinateValidation || {}
  413. if (validation.renderable === true) renderableCount += 1
  414. else if (validation.state === 'INVALID') invalidCount += 1
  415. else unverifiedCount += 1
  416. }
  417. return {
  418. crs: 'WGS84',
  419. totalObjectCount: objects.length,
  420. renderableCount,
  421. invalidCount,
  422. unverifiedCount,
  423. }
  424. }
  425. /**
  426. * 从请求路径的查询串里取一个整数参数。
  427. * 走查与演示要能现场改页大小(`window.__COCKPIT_MOCK_MAP_PAGE_SIZE__`),
  428. * 所以分页参数必须真的从请求上读,而不是写死一份。
  429. */
  430. function queryInteger(path, key, fallback) {
  431. const query = String(path || '').split('?')[1] || ''
  432. const value = new URLSearchParams(query).get(key)
  433. const parsed = Number(value)
  434. return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
  435. }
  436. /** 查询串里的快照标记(第 1 页不带)。 */
  437. function querySnapshot(path) {
  438. const query = String(path || '').split('?')[1] || ''
  439. return new URLSearchParams(query).get('snapshotAt') || ''
  440. }
  441. /** 查询串里的一个字符串参数(缺失回空串;票 18 的筛选回显与过滤都读它)。 */
  442. function queryValue(path, key) {
  443. const query = String(path || '').split('?')[1] || ''
  444. return new URLSearchParams(query).get(key) || ''
  445. }
  446. /** 请求查询串 → 普通对象(票 18:筛选与分页在 mock 上要真的按请求回)。 */
  447. function queryParams(path) {
  448. const query = String(path || '').split('?')[1] || ''
  449. const result = {}
  450. for (const [key, value] of new URLSearchParams(query)) result[key] = value
  451. return result
  452. }
  453. /**
  454. * 地图摘要样例(票 08):**按后端真实分页语义**造,而不是只给一页。
  455. *
  456. * 场景(`overrides` 由走查现场给,见 `mocks/index.js`):
  457. * · `success` —— 正常分页,后端当前快照固定;
  458. * · `empty` —— 后端说总数 0(不是「这一页恰好没有」);
  459. * · `degraded` —— 保持票 05 的字段缺失样例(单页、`complete: false`);
  460. * · `stale` —— **取完第 1 页后后端换了快照**(`expireAfterPage`),第 2 页带着旧标记回来即失效;
  461. * · `failed` —— 由统一出口抛出,不在这里给载荷。
  462. *
  463. * @param {string} scenario 五类样例
  464. * @param {string} path 请求路径(分页参数在这里)
  465. * @param {object} [overrides] `{ pageSize, snapshotAt, expireAfterPage }`
  466. */
  467. function mapSnapshotPayload(scenario, path, overrides = {}) {
  468. const pageSize = overrides.pageSize || MAP_SNAPSHOT_PAGE_SIZE
  469. const page = queryInteger(path, 'page', 1)
  470. const baseSnapshot = overrides.snapshotAt || SNAPSHOT_AT
  471. // 票 18:三个筛选入参从**请求查询串**读(不是从 overrides)——
  472. // 「筛选真的下发了」这件事必须由请求本身证明,不能靠现场注入的开关。
  473. // 票 14:`pointStatus` 多值(逗号分隔,OR)也在这里解析。
  474. const filters = {
  475. industry: queryValue(path, 'industry'),
  476. district: queryValue(path, 'district'),
  477. deviceType: queryValue(path, 'deviceType'),
  478. pointStatus: queryValue(path, 'pointStatus') ? queryValue(path, 'pointStatus').split(',') : [],
  479. }
  480. const filtered = !!(filters.industry || filters.district || filters.deviceType || filters.pointStatus.length)
  481. if (scenario === 'empty') {
  482. return {
  483. page,
  484. pageSize,
  485. snapshotAt: baseSnapshot,
  486. batchUpdatedAt: baseSnapshot,
  487. total: 0,
  488. complete: true,
  489. monitoringPoints: [],
  490. unassociatedEquipment: [],
  491. mapFilter: mapFilterEcho(filters, [], []),
  492. // 真·空快照:全量口径各项都是 0(这里 0 是真实值,不是缺失)。
  493. coordinateValidation: coordinateValidationSummary([]),
  494. // 空 = 全局八桶也全 0(这是真实值,不是缺失)。
  495. pointStatusDistribution: globalStatusDistribution([], []),
  496. }
  497. }
  498. if (scenario === 'degraded') {
  499. const degraded = [{ pointCode: 'MP-DEGRADED', pointName: '字段缺失点位', pointStatus: 'NORMAL', statusReasons: [] }]
  500. return {
  501. page: 1,
  502. pageSize: 500,
  503. snapshotAt: baseSnapshot,
  504. complete: false,
  505. monitoringPoints: degraded,
  506. unassociatedEquipment: [],
  507. mapFilter: mapFilterEcho(filters, degraded, []),
  508. // 后端没给 coordinateValidation 的点位:仍占一个位置,落在「未校验」桶里。
  509. coordinateValidation: coordinateValidationSummary(degraded),
  510. // 票 14:降级样例仍带全局八桶(未筛口径:字段缺失点位落 UNKNOWN 桶——不猜成正常)。
  511. pointStatusDistribution: globalStatusDistribution(degraded, []),
  512. }
  513. }
  514. // 票 09:后端六态 + 未关联设备 + 一个前端不认识的状态,全部可上图(状态演示用)。
  515. // 这个样例**不带筛选字段**,所以带筛选请求时会被全部排除 —— 与后端"推导不出行业即排除"一致。
  516. if (scenario === 'point-statuses') {
  517. return mapSnapshotPage({
  518. page,
  519. pageSize,
  520. snapshotAt: querySnapshot(path),
  521. currentSnapshot: baseSnapshot,
  522. points: STATUS_SAMPLE_POINTS,
  523. equipment: STATUS_SAMPLE_EQUIPMENT,
  524. filters: filtered ? filters : null,
  525. })
  526. }
  527. // 票 42/45/47:受控有效坐标样例 —— 地图侧的点位 `deviceCodes` 必须与详情 `devices[]` **一致**,
  528. // 否则第二台设备会被判「与当前快照不一致」而禁止上图(那是另一条要验的语义,不该混在这个基线里)。
  529. const deviceLayerCodes = {
  530. 'device-layer': DEVICE_LAYER_CODES,
  531. 'device-layer-overlap': DEVICE_LAYER_CODES,
  532. // 票 76:全新的干净聚合样例(4 台同坐标,快照/详情/坐标同源)。
  533. 'aggregate-lab': AGGREGATE_LAB_CODES,
  534. // 票 47:动效样例与降级样例的设备编码(与详情一一对应)。
  535. 'device-effects': EFFECTS_SAMPLE.map((item) => item.deviceCode),
  536. 'device-effects-degraded': EFFECTS_DEGRADED_SAMPLE.map((item) => item.deviceCode),
  537. }[scenario]
  538. if (deviceLayerCodes) {
  539. // 票 76(2026-10-23 修正):不再清空其它点位的编码 —— 详情已按 `requestedCode` 回各自原生载荷
  540. // (`nativePointDetail`),清空反而让「快照 0 台 / 详情 11 台」再次不一致、设备点不上图。
  541. const points = MAP_POINTS.map((item) =>
  542. item.pointCode === DEVICE_LAYER_POINT
  543. ? { ...item, deviceCodes: deviceLayerCodes.slice() }
  544. : item,
  545. )
  546. return mapSnapshotPage({
  547. page,
  548. pageSize,
  549. snapshotAt: querySnapshot(path),
  550. currentSnapshot: baseSnapshot,
  551. points,
  552. filters: filtered ? filters : null,
  553. })
  554. }
  555. // 过期样例:第 1 页照常给旧快照,之后的请求后端已经换成新快照 —— 客户端带着旧标记回来即失效。
  556. const expired = scenario === 'stale' && Number.isInteger(overrides.expireAfterPage) && page > overrides.expireAfterPage
  557. const currentSnapshot = expired ? '2026-09-02T10:30:00+08:00' : baseSnapshot
  558. return mapSnapshotPage({
  559. page,
  560. pageSize,
  561. snapshotAt: querySnapshot(path),
  562. currentSnapshot,
  563. // 不带筛选时传 null(=返回全部对象,含 industry 为 null 的设备)。
  564. filters: filtered ? filters : null,
  565. // 筛选生效时回显的编码来自**过滤后全量**(分页前),与后端 `mapFilter` 口径一致。
  566. echoFilters: filters,
  567. })
  568. }
  569. /** `mapFilter` 回显(后端在筛选生效时回显实际生效范围与过滤后全量编码;票 14 增多值状态回显)。 */
  570. function mapFilterEcho(filters, points, equipment) {
  571. const statusRaw = (filters && filters.pointStatus) || []
  572. const statusText = Array.isArray(statusRaw) ? statusRaw.join(',') : String(statusRaw || '')
  573. return {
  574. industry: filters.industry || null,
  575. district: filters.district && filters.district !== 'UNASSIGNED' ? Number(filters.district) : null,
  576. districtFilter: !filters.district ? 'NONE' : filters.district === 'UNASSIGNED' ? 'UNASSIGNED' : 'ASSIGNED',
  577. deviceType: filters.deviceType || null,
  578. pointStatus: statusText || null,
  579. pointCodes: points.map((item) => item.pointCode).filter(Boolean),
  580. deviceCodes: equipment.map((item) => item.deviceCode).filter(Boolean),
  581. }
  582. }
  583. // ---------------------------------------------------------------- 监测点详情
  584. /**
  585. * 监测点详情的样例载荷(票 05 的默认 + 四类异常样例)。
  586. *
  587. * 票 10 追加 `point-detail-order`:设备卡片刻意**逆状态优先级**排列
  588. * (正常 → 报警 → 预警),后端的返回顺序 ≠ 前端按优先级排出来的顺序 ——
  589. * 界面若自作主张排序,真机走查里那条「卡片顺序 = 后端顺序」就会当场露馅。
  590. * 默认样例一个字节未动(票 05 的断言依赖它)。
  591. */
  592. /**
  593. * 票 45:**K>10 降级载荷**(11 台设备同坐标、状态混合)。
  594. *
  595. * 主场景(MP-002 太常)与 `aggregate-lab`(MP-001 城南)共用这一份构造器 ——
  596. * 两处各写一份必然漂移(本仓反复栽在"同一份样例登记两处"上)。
  597. */
  598. function degradedOverlapPointDetail({ code, name, codes = [], coords = [], statuses = ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL'] }) {
  599. const devices = codes.map((code_, index) => {
  600. const deviceStatus = statuses[index]
  601. return {
  602. deviceCode: code_,
  603. deviceName: `重叠降级设备-${index + 1}`,
  604. deviceTypeName: ['压力计', '流量计', '液位计'][index % 3],
  605. deviceStatus,
  606. statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[deviceStatus],
  607. statusReasons: deviceStatus === 'ALARM' ? ['压力超高'] : deviceStatus === 'WARNING' ? ['液位偏高'] : [],
  608. statusTime: '2026-09-02T09:59:00+08:00',
  609. ...deviceCoordFields(coords, index),
  610. }
  611. })
  612. return {
  613. requestId: 'mock-point-detail',
  614. apiVersion: 'v1',
  615. readOnly: true,
  616. monitoringPointCode: code,
  617. monitoringPointName: name,
  618. pointStatus: 'ALARM',
  619. statusLabel: '报警',
  620. statusReasons: [{ status: 'ALARM', deviceCode: codes[0], reason: '压力超高' }],
  621. deviceStatistics: {
  622. totalDeviceCount: devices.length,
  623. alarmDeviceCount: statuses.filter((status) => status === 'ALARM').length,
  624. warningDeviceCount: statuses.filter((status) => status === 'WARNING').length,
  625. workOrderDeviceCount: statuses.filter((status) => status === 'WORK_ORDER').length,
  626. normalDeviceCount: statuses.filter((status) => status === 'NORMAL').length,
  627. offlineDeviceCount: statuses.filter((status) => status === 'OFFLINE').length,
  628. },
  629. devices,
  630. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  631. statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({
  632. status: item,
  633. label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item],
  634. priority: index + 1,
  635. })),
  636. }
  637. }
  638. /**
  639. * 票 45:**K=2 正常重叠载荷**(两台设备同坐标,可逐台展开)。
  640. * 主场景(MP-001)与专用场景 `device-layer-overlap` 共用。
  641. */
  642. function overlapPointDetail({ code, name, status = 'NORMAL' }) {
  643. return pointDetail({ code, name, status, deviceCoord: OVERLAP_DEVICE_LAYER_COORDS })
  644. }
  645. /**
  646. * 票 47:按给定设备清单造详情载荷(每台都带**设备自身**的有效坐标 ⇒ `renderable=true`)。
  647. *
  648. * 决策票 07 的可断言口径要求:动效只能在**受控可上图样例**上验证
  649. * (真实环境仍是清单-only 时不得从纯逻辑断言推断现场特效已生效)。
  650. */
  651. function effectsPointDetail({ code, name, samples, pointStatus = 'ALARM' }) {
  652. const counts = { ALARM: 0, WARNING: 0, WORK_ORDER: 0, NORMAL: 0, OFFLINE: 0 }
  653. const devices = samples.map((item, index) => {
  654. const deviceStatus = item.deviceStatus
  655. if (counts[deviceStatus] !== undefined) counts[deviceStatus] += 1
  656. return {
  657. deviceCode: item.deviceCode,
  658. deviceName: item.deviceName,
  659. deviceTypeName: ['压力计', '流量计', '液位计'][index % 3],
  660. deviceStatus,
  661. statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线' }[deviceStatus] || '',
  662. statusReasons: deviceStatus === 'ALARM' ? ['压力超高'] : deviceStatus === 'WARNING' ? ['液位偏高'] : [],
  663. statusTime: '2026-09-02T09:59:00+08:00',
  664. ...deviceCoordFields(samples, index),
  665. }
  666. })
  667. return {
  668. requestId: 'mock-point-detail',
  669. apiVersion: 'v1',
  670. readOnly: true,
  671. monitoringPointCode: code,
  672. monitoringPointName: name,
  673. pointStatus,
  674. statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[pointStatus] || '',
  675. statusReasons: [{ status: pointStatus, deviceCode: devices[0] ? devices[0].deviceCode : '', reason: '压力超高' }],
  676. deviceStatistics: {
  677. totalDeviceCount: devices.length,
  678. alarmDeviceCount: counts.ALARM,
  679. warningDeviceCount: counts.WARNING,
  680. workOrderDeviceCount: counts.WORK_ORDER,
  681. normalDeviceCount: counts.NORMAL,
  682. offlineDeviceCount: counts.OFFLINE,
  683. },
  684. devices,
  685. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  686. statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({
  687. status: item,
  688. label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item],
  689. priority: index + 1,
  690. })),
  691. }
  692. }
  693. /**
  694. * 票 76(mock 串点修正,2026-10-23 走查):任意点位的「原生详情」。
  695. *
  696. * 场景样例只覆盖 `DEVICE_LAYER_POINT`;请求其它点位时必须回**它自己的**载荷 ——
  697. * `deviceLayerStore` 只接受 `data.code === 选中编码` 的卡片(`deviceLayerStore.js:184`),
  698. * 回错点位的载荷会让该点位的设备全部「详情待确认 / 与快照不一致 → 不上图」,
  699. * 走查表现为「点 MP-002 面板不出、关联设备不出现、要点几次或刷新地图才出来」。
  700. */
  701. function nativePointDetail(requestedCode) {
  702. if (requestedCode === 'MP-001') {
  703. return overlapPointDetail({ code: 'MP-001', name: '城南测试监测点' })
  704. }
  705. if (requestedCode === 'MP-002') {
  706. // 太常样例:K=11 同坐标,编码用自己的 `EQ-TC-*`(与场景点不再共用)。
  707. return degradedOverlapPointDetail({
  708. code: 'MP-002', name: '太常测试监测点',
  709. codes: OVERLAP_TAICHANG_CODES,
  710. coords: OVERLAP_TAICHANG_CODES.map(() => ({ longitude: 110.3632, latitude: 28.4524 })),
  711. statuses: ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'WORK_ORDER', 'NORMAL', 'NORMAL', 'OFFLINE', 'NORMAL'],
  712. })
  713. }
  714. return pointDetail()
  715. }
  716. function pointDetailPayload(scenario, requestedCode = '') {
  717. // 票 76:下列场景样例都只覆盖 DEVICE_LAYER_POINT;请求别的点位 → 回它自己的原生详情。
  718. const scenarioSamples = ['device-layer', 'device-layer-overlap', 'device-effects', 'device-effects-degraded', 'aggregate-lab']
  719. if (scenarioSamples.includes(scenario) && requestedCode && requestedCode !== DEVICE_LAYER_POINT) {
  720. return nativePointDetail(requestedCode)
  721. }
  722. if (scenario === 'empty') {
  723. return {
  724. ...pointDetail({ code: 'MP-EMPTY', name: '预留监测点', status: 'EMPTY' }),
  725. devices: [],
  726. deviceStatistics: { totalDeviceCount: 0, alarmDeviceCount: 0, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 0, offlineDeviceCount: 0 },
  727. statusReasons: [],
  728. }
  729. }
  730. if (scenario === 'degraded') {
  731. return { monitoringPointCode: 'MP-DEGRADED', monitoringPointName: '字段缺失点位', devices: [], statusReasons: [] }
  732. }
  733. /**
  734. * 票 42:**受控有效坐标样例** —— 专门用来验证独立设备图层的渲染与拾取。
  735. *
  736. * 与默认场景的关键差别:两台设备都带**设备自身**的有效坐标(`VALID/renderable=true`),
  737. * 且 `deviceCodes` 与详情 `devices[]` 完全一致(否则会触发"与当前快照不一致"的提示)。
  738. * 坐标落在主场景区内、贴近点位但不重合(重合会走票 11 的重叠分支,不在本票)。
  739. */
  740. if (scenario === 'device-layer') {
  741. return pointDetail({
  742. code: DEVICE_LAYER_POINT,
  743. name: '城南测试监测点',
  744. status: 'NORMAL',
  745. // 两台设备各有有效坐标且不重叠:票 42 的单设备层基线。
  746. deviceCoord: DEVICE_LAYER_COORDS,
  747. })
  748. }
  749. /** 票 45:两台设备拥有**各自真实字段**,但坐标恰好重叠;用于测试纯渲染层聚合/展开。 */
  750. if (scenario === 'device-layer-overlap') {
  751. return overlapPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点' })
  752. }
  753. /**
  754. * 票 45:**K>10 降级样例** —— 11 台设备同坐标、状态混合(3 ALARM / 3 WARNING / 5 NORMAL)。
  755. * 设备编码与地图快照 `deviceCodes` 完全一致,全部坐标准入有效 ⇒ K=11 > maxFan(10) ⇒ 降级形态
  756. * 「K 台设备(本屏显示 10 台 / 其余见清单)」。聚合符号显示最高优先级状态(ALARM)。
  757. */
  758. /**
  759. * 票 76:**干净聚合实验样例**(维护者要求重建测试数据)——
  760. * 4 台设备同坐标、编码与快照 `deviceCodes` 一致、坐标同源;不降级(K=4 ≤ maxFan)。
  761. * 这是验证「聚合符号 / 展开 fan / 选中强调」的**唯一入口**(旧 `EQ-DG-*` 样例已按维护者裁定删除)。
  762. */
  763. if (scenario === 'aggregate-lab') {
  764. return degradedOverlapPointDetail({
  765. code: DEVICE_LAYER_POINT,
  766. name: '城南测试监测点',
  767. codes: AGGREGATE_LAB_CODES,
  768. coords: AGGREGATE_LAB_COORDS,
  769. statuses: ['ALARM', 'WARNING', 'NORMAL', 'NORMAL'],
  770. })
  771. }
  772. /**
  773. * 票 47:**设备状态动效样例** —— 五态各一台,验证「只有报警/预警/工单有动效、
  774. * 正常/离线完全静止」(决策票 07 的核心取舍)。全部设备坐标准入有效。
  775. */
  776. if (scenario === 'device-effects') {
  777. return effectsPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点', samples: EFFECTS_SAMPLE, pointStatus: 'ALARM' })
  778. }
  779. /** 票 47:**动效降级样例** —— 31 台报警设备(> 默认上限 30)⇒ 动画全停 + 静态异常标记 + 计数。 */
  780. if (scenario === 'device-effects-degraded') {
  781. return effectsPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点', samples: EFFECTS_DEGRADED_SAMPLE, pointStatus: 'ALARM' })
  782. }
  783. if (scenario === 'point-detail-order') {
  784. const devices = [
  785. { deviceCode: 'EQ-ORDER-NORMAL', deviceName: '顺序样例-正常设备', deviceTypeName: '压力计', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00' },
  786. { deviceCode: 'EQ-ORDER-ALARM', deviceName: '顺序样例-报警设备', deviceTypeName: '流量计', deviceStatus: 'ALARM', statusLabel: '报警', statusReasons: ['流量超高'], statusTime: '2026-09-02T09:59:40+08:00' },
  787. { deviceCode: 'EQ-ORDER-WARNING', deviceName: '顺序样例-预警设备', deviceTypeName: '液位计', deviceStatus: 'WARNING', statusLabel: '预警', statusReasons: ['液位偏高'], statusTime: '2026-09-02T09:59:50+08:00' },
  788. ]
  789. return {
  790. ...pointDetail({ code: 'MP-ORDER', name: '顺序样例监测点', status: 'ALARM' }),
  791. devices,
  792. deviceStatistics: { totalDeviceCount: 3, alarmDeviceCount: 1, warningDeviceCount: 1, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 },
  793. statusReasons: [{ status: 'ALARM', deviceCode: 'EQ-ORDER-ALARM', reason: '流量超高' }],
  794. }
  795. }
  796. /**
  797. * **主场景**(默认 `success`)按请求的点位编码分发两个重叠示例 ——
  798. * 维护者要求「进入系统即可见 K=2 与 K=11」,不必再注入场景:
  799. * · MP-001 城南测试监测点 —— K=2 正常重叠(两台设备同坐标,可逐台展开);
  800. * · MP-002 太常测试监测点 —— K=11 降级(本屏 10 台 / 其余见清单)。
  801. * 其余点位仍走默认现网边界(设备 `UNVERIFIED_COORDINATE/renderable=false`,不上图仅清单),
  802. * 那条边界是票 42 照抄的正式行为,不被示例覆盖。
  803. */
  804. if (scenario === 'success') {
  805. if (requestedCode === 'MP-001') {
  806. return overlapPointDetail({ code: 'MP-001', name: '城南测试监测点' })
  807. }
  808. if (requestedCode === 'MP-002') {
  809. // 主场景的 MP-002 是 ALARM 点位(示例用于展示降级聚合的最高优先级状态)。
  810. return degradedOverlapPointDetail({
  811. code: 'MP-002', name: '太常测试监测点',
  812. // 票 76(mock 串点修正):太常样例用**自己的编码**,不再与城南场景点共用 `EQ-DG-*`。
  813. codes: OVERLAP_TAICHANG_CODES,
  814. // 主场景仍是 K=11 同坐标样例,位置在太常点附近;五态沿用已验收的动效词表。
  815. coords: OVERLAP_TAICHANG_CODES.map(() => ({ longitude: 110.3632, latitude: 28.4524 })),
  816. statuses: ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'WORK_ORDER', 'NORMAL', 'NORMAL', 'OFFLINE', 'NORMAL'],
  817. })
  818. }
  819. }
  820. return pointDetail()
  821. }
  822. /**
  823. * 设备级坐标字段(票 42)。`coords` 为 null ⇒ 现网降级(`UNVERIFIED_COORDINATE/renderable=false`),
  824. * 有值 ⇒ 受控测试样例(`VALID/renderable=true`)。两种都不复制监测点坐标。
  825. */
  826. function deviceCoordFields(coords, index) {
  827. if (!Array.isArray(coords) || !coords[index]) {
  828. return {
  829. coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' },
  830. }
  831. }
  832. const point = coords[index]
  833. return {
  834. longitude: point.longitude,
  835. latitude: point.latitude,
  836. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  837. }
  838. }
  839. function pointDetail({ code = 'MP-002', name = '太常测试监测点', status = 'ALARM', deviceCoord = null } = {}) {
  840. // 票 42:设备级坐标与校验。默认 `deviceCoord` 为 null ⇒ 逐台 `UNVERIFIED_COORDINATE/renderable=false`,
  841. // **照抄现网正式边界**(后端当前无可信校核源,真实设备统一不可上图)。
  842. // 只有显式传入受控坐标的样例场景才会 `renderable=true` —— 那是**测试样例**,
  843. // 不得用来冒称"正式关联设备已能上图"(票 42 验收第 2 条原句)。
  844. const devices = [
  845. {
  846. deviceCode: 'EQ-001',
  847. deviceName: '供水压力计-001',
  848. deviceTypeName: '压力计',
  849. deviceStatus: 'ALARM',
  850. statusLabel: '报警',
  851. statusReasons: ['压力超高'],
  852. statusTime: '2026-09-02T09:59:00+08:00',
  853. ...deviceCoordFields(deviceCoord, 0),
  854. },
  855. {
  856. deviceCode: 'EQ-002',
  857. deviceName: '智能窨井-002',
  858. deviceTypeName: '智能窨井',
  859. deviceStatus: 'NORMAL',
  860. statusLabel: '正常',
  861. statusReasons: [],
  862. statusTime: '2026-09-02T09:59:30+08:00',
  863. ...deviceCoordFields(deviceCoord, 1),
  864. },
  865. ]
  866. return {
  867. requestId: 'mock-point-detail',
  868. apiVersion: 'v1',
  869. readOnly: true,
  870. monitoringPointCode: code,
  871. monitoringPointName: name,
  872. pointStatus: status,
  873. statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[status] || '',
  874. statusReasons: [{ status, deviceCode: 'EQ-001', reason: '压力超高' }],
  875. deviceStatistics: {
  876. totalDeviceCount: devices.length,
  877. alarmDeviceCount: 1,
  878. warningDeviceCount: 0,
  879. workOrderDeviceCount: 0,
  880. normalDeviceCount: 1,
  881. offlineDeviceCount: 0,
  882. },
  883. devices,
  884. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  885. statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({
  886. status: item,
  887. label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item],
  888. priority: index + 1,
  889. })),
  890. }
  891. }
  892. // ---------------------------------------------------------------- 设备详情聚合
  893. function aggregateSection(state, reason, data, message) {
  894. return { state, reason, message: message || null, data }
  895. }
  896. const ARCHIVE = {
  897. deviceCode: 'TC-PS-WS-202609020012',
  898. deviceName: '污水环境监测设备',
  899. deviceModel: 'WS-PRO-2000',
  900. deviceSpec: '悬浮物/COD/氨氮/电导率/PH',
  901. manufacturer: '沅陵专用仪表厂',
  902. deviceTypeName: '污水环境监测设备',
  903. assetValue: 128000,
  904. expectedServiceYears: 10,
  905. equipmentLocation: '太常片区',
  906. ownershipUnit: '沅陵县排水管理所',
  907. maintainer: '沅陵运维一组',
  908. maintainerPhone: '0745-0000000',
  909. installTime: '2024-03-01T00:00:00+08:00',
  910. latestImage: { url: '/cockpit/aerial.webp', capturedAt: '2026-08-30T10:20:00+08:00' },
  911. }
  912. const METRICS = {
  913. deviceCode: ARCHIVE.deviceCode,
  914. state: 'CURRENT',
  915. stateReason: 'READY',
  916. metrics: [
  917. { metricCode: 'COD', metricName: 'COD', value: 18.2, unit: 'mg/L', status: 'WARNING', sampledAt: '2026-09-02T09:59:30+08:00', freshness: 'CURRENT' },
  918. // 边界:真实 0 是有效读数,适配层不得当成缺失。
  919. { metricCode: 'FLOW', metricName: '流量', value: 0, unit: 'm³/h', status: 'NORMAL', sampledAt: '2026-09-02T09:59:30+08:00', freshness: 'CURRENT' },
  920. ],
  921. }
  922. 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: '太常片区' }
  923. 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: '太常片区' }
  924. const RELATION = { relationId: 'REL-001', warningNo: WARNING.warningNo, alarmId: ALARM.alarmId, relatedAt: '2026-09-02T10:00:00+08:00' }
  925. 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' } }
  926. /**
  927. * 票 12:默认时间线走**完整一遍状态路径 + 一次回退**。
  928. *
  929. * 为什么要带回退而不是只给一条顺溜的轨迹:票 12 的验收原句就是
  930. * 「验收驳回表现为『待验收回退处理中』的动作轨迹」——样例里没有回退,这条验收在 mock 上
  931. * 根本无法演示(票 11 已经在「分区级失败」上踩过一次同样的坑)。
  932. * 末条 `stateCode: 3` 与 `WORK_ORDER.stateCode` 一致:当前态永远取工单自己的状态。
  933. *
  934. * ⚠️ **票 20:全部样例已按后端 `WorkOrderStatusEnum`(0–7)重排。**
  935. * 旧样例沿用 1–7,于是每条记录的码都比真实语义大一格(例如"待验收"标成 5、实际是 4)。
  936. * 现在:0待派单 / 1待接单 / 2已接单 / 3处理中 / 4待验收 / 5已结案 / 6延期中 / 7已取消。
  937. */
  938. const TIMELINE_ENTRIES = [
  939. { logId: 1, operationName: '创建工单', operationDesc: '故障维修', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00', stateCode: 0, stateName: '待派单' },
  940. { logId: 2, operationName: '派单', operationDesc: '指派沅陵运维一组', operatorName: '调度员', operatedAt: '2026-09-02T09:10:00+08:00', stateCode: 1, stateName: '待接单' },
  941. { logId: 3, operationName: '接单', operationDesc: '已接单', operatorName: '李四', operatedAt: '2026-09-02T09:20:00+08:00', stateCode: 2, stateName: '已接单' },
  942. { logId: 4, operationName: '开始处置', operationDesc: '现场处理', operatorName: '李四', operatedAt: '2026-09-02T09:40:00+08:00', stateCode: 3, stateName: '处理中' },
  943. { logId: 5, operationName: '提交验收', operationDesc: '申请验收', operatorName: '李四', operatedAt: '2026-09-02T11:30:00+08:00', stateCode: 4, stateName: '待验收' },
  944. // 回退动作:状态从 4 回到 3。它是**动作**,不是多出来的一个状态。
  945. { logId: 6, operationName: '验收驳回', operationDesc: '待验收回退处理中', operatorName: '张三', operatedAt: '2026-09-02T12:00:00+08:00', stateCode: 3, stateName: '处理中' },
  946. ]
  947. const TIMELINE = { workOrder: { ...WORK_ORDER }, timelineEntries: TIMELINE_ENTRIES }
  948. /**
  949. * 票 12 的专用场景:**同设备多张工单、每张一条独立时间线**(验收第 2 条)。
  950. * 四张刻意选在四个不同形状上:
  951. * · `WO-TL-FORWARD` 停在待验收(一路推进,走到过 4);
  952. * · `WO-TL-ROLLBACK` 当前处理中、但走到过待验收(回退动作 —— 本票的核心形态);
  953. * · `WO-TL-DELAYED` 延期中(**处理旁路**,票 20 新增:6 不在推进主线上);
  954. * · `WO-TL-CANCELLED` 已取消(**路径外**终态,不能接在已结案后面冒充下一步)。
  955. */
  956. const TIMELINE_ORDERS = [
  957. { 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' } },
  958. { 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' } },
  959. { 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 },
  960. { 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 },
  961. ]
  962. const TIMELINE_BY_CODE = {
  963. 'WO-TL-FORWARD': [
  964. { logId: 11, operationName: '创建工单', operationDesc: '故障维修', operatorName: '系统', operatedAt: '2026-09-01T08:00:00+08:00', stateCode: 0, stateName: '待派单' },
  965. { logId: 12, operationName: '派单', operationDesc: '指派沅陵运维二组', operatorName: '调度员', operatedAt: '2026-09-01T08:15:00+08:00', stateCode: 1, stateName: '待接单' },
  966. { logId: 13, operationName: '接单', operationDesc: '已接单', operatorName: '王五', operatedAt: '2026-09-01T08:30:00+08:00', stateCode: 2, stateName: '已接单' },
  967. { logId: 14, operationName: '开始处置', operationDesc: '现场处理', operatorName: '王五', operatedAt: '2026-09-01T09:00:00+08:00', stateCode: 3, stateName: '处理中' },
  968. { logId: 15, operationName: '提交验收', operationDesc: '申请验收', operatorName: '王五', operatedAt: '2026-09-01T12:00:00+08:00', stateCode: 4, stateName: '待验收' },
  969. ],
  970. 'WO-TL-ROLLBACK': [
  971. { logId: 21, operationName: '创建工单', operationDesc: '故障维修', operatorName: '系统', operatedAt: '2026-09-02T09:00:00+08:00', stateCode: 0, stateName: '待派单' },
  972. { logId: 22, operationName: '派单', operationDesc: '指派沅陵运维一组', operatorName: '调度员', operatedAt: '2026-09-02T09:05:00+08:00', stateCode: 1, stateName: '待接单' },
  973. { logId: 23, operationName: '接单', operationDesc: '已接单', operatorName: '李四', operatedAt: '2026-09-02T09:10:00+08:00', stateCode: 2, stateName: '已接单' },
  974. { logId: 24, operationName: '开始处置', operationDesc: '现场处理', operatorName: '李四', operatedAt: '2026-09-02T09:30:00+08:00', stateCode: 3, stateName: '处理中' },
  975. { logId: 25, operationName: '提交验收', operationDesc: '申请验收', operatorName: '李四', operatedAt: '2026-09-02T11:00:00+08:00', stateCode: 4, stateName: '待验收' },
  976. { logId: 26, operationName: '验收驳回', operationDesc: '待验收回退处理中', operatorName: '张三', operatedAt: '2026-09-02T12:00:00+08:00', stateCode: 3, stateName: '处理中' },
  977. ],
  978. // 票 20 新增:延期审批通过 → 进入 6 延期中(**旁路**),之后再回到 3 处理中。
  979. // 这条样例是 `BYPASS` 动作的演示依据 —— 没有它,"延期中是旁路不是主线"就只写在注释里。
  980. 'WO-TL-DELAYED': [
  981. { logId: 41, operationName: '创建工单', operationDesc: '故障维修', operatorName: '系统', operatedAt: '2026-09-03T08:00:00+08:00', stateCode: 0, stateName: '待派单' },
  982. { logId: 42, operationName: '派单', operationDesc: '指派沅陵运维一组', operatorName: '调度员', operatedAt: '2026-09-03T08:10:00+08:00', stateCode: 1, stateName: '待接单' },
  983. { logId: 43, operationName: '接单', operationDesc: '已接单', operatorName: '李四', operatedAt: '2026-09-03T08:20:00+08:00', stateCode: 2, stateName: '已接单' },
  984. { logId: 44, operationName: '开始处置', operationDesc: '现场处理', operatorName: '李四', operatedAt: '2026-09-03T08:30:00+08:00', stateCode: 3, stateName: '处理中' },
  985. { logId: 45, operationName: '申请延期', operationDesc: '等待备件到货', operatorName: '李四', operatedAt: '2026-09-03T09:00:00+08:00', stateCode: 3, stateName: '处理中' },
  986. { logId: 46, operationName: '延期通过', operationDesc: '备件周期确认', operatorName: '张三', operatedAt: '2026-09-03T10:00:00+08:00', stateCode: 6, stateName: '延期中' },
  987. ],
  988. 'WO-TL-CANCELLED': [
  989. { logId: 31, operationName: '创建工单', operationDesc: '计划检修', operatorName: '系统', operatedAt: '2026-09-02T07:00:00+08:00', stateCode: 0, stateName: '待派单' },
  990. { logId: 32, operationName: '派单', operationDesc: '指派沅陵运维三组', operatorName: '调度员', operatedAt: '2026-09-02T07:10:00+08:00', stateCode: 1, stateName: '待接单' },
  991. { logId: 33, operationName: '取消工单', operationDesc: '重复报单', operatorName: '调度员', operatedAt: '2026-09-02T07:30:00+08:00', stateCode: 7, stateName: '已取消' },
  992. ],
  993. }
  994. /** 某工单编码的时间线载荷(认不出的编码回 null,与真接口的 404 语义一致)。 */
  995. function timelinePayload(workOrderCode) {
  996. if (workOrderCode === WORK_ORDER.workOrderCode) return TIMELINE
  997. const order = TIMELINE_ORDERS.find((item) => item.workOrderCode === workOrderCode)
  998. const entries = TIMELINE_BY_CODE[workOrderCode]
  999. if (!order || !entries) return null
  1000. return { workOrder: { ...order }, timelineEntries: entries }
  1001. }
  1002. function equipmentAggregate(scenario, deviceCode = '') {
  1003. const success = aggregateSection('SUCCESS', 'SUCCESS', null)
  1004. // 请求哪台设备就报哪台(与真接口一致,客户端传错编码能看出来)。
  1005. const code = deviceCode || ARCHIVE.deviceCode
  1006. const archive = { ...ARCHIVE, deviceCode: code }
  1007. if (scenario === 'empty') {
  1008. return {
  1009. deviceCode: code,
  1010. aggregateState: 'EMPTY',
  1011. serverTime: SNAPSHOT_AT,
  1012. archive: aggregateSection('EMPTY', 'NO_ARCHIVE', null),
  1013. metrics: aggregateSection('EMPTY', 'NO_METRICS', null),
  1014. warnings: aggregateSection('EMPTY', 'NO_EVENTS', null),
  1015. alarms: aggregateSection('EMPTY', 'NO_EVENTS', null),
  1016. alarmWarningRelations: aggregateSection('EMPTY', 'NO_EVENTS', null),
  1017. workOrders: aggregateSection('EMPTY', 'NO_WORK_ORDERS', null),
  1018. timelines: aggregateSection('EMPTY', 'NO_WORK_ORDERS', null),
  1019. latestImage: aggregateSection('EMPTY', 'NO_IMAGE', null),
  1020. }
  1021. }
  1022. if (scenario === 'degraded') {
  1023. // 字段缺失:档案只剩编码,指标缺 value/unit,工单缺 stateName(靠状态字典补)。
  1024. return {
  1025. deviceCode: code,
  1026. aggregateState: 'READY',
  1027. serverTime: SNAPSHOT_AT,
  1028. archive: aggregateSection('SUCCESS', 'SUCCESS', { deviceCode: code }),
  1029. metrics: aggregateSection('SUCCESS', 'SUCCESS', { deviceCode: code, state: 'CURRENT', stateReason: 'READY', metrics: [{ metricCode: 'COD', metricName: 'COD', sampledAt: '2026-09-02T09:59:30+08:00' }] }),
  1030. warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
  1031. alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
  1032. alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
  1033. workOrders: aggregateSection('SUCCESS', 'SUCCESS', [{ workOrderCode: 'WO-DEGRADED', stateCode: 2 }]),
  1034. timelines: aggregateSection('SUCCESS', 'SUCCESS', [TIMELINE]),
  1035. latestImage: aggregateSection('EMPTY', 'NO_IMAGE', null),
  1036. }
  1037. }
  1038. if (scenario === 'stale') {
  1039. return {
  1040. deviceCode: code,
  1041. aggregateState: 'PARTIAL',
  1042. serverTime: SNAPSHOT_AT,
  1043. archive: aggregateSection('SUCCESS', 'SUCCESS', archive),
  1044. // 过期但保留旧值(票 16:保留最后已知值 + 过期标识)。
  1045. metrics: aggregateSection('STALE', 'SAMPLE_OUTDATED', { ...METRICS, state: 'STALE', stateReason: 'SAMPLE_OUTDATED' }),
  1046. warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
  1047. alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
  1048. alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
  1049. workOrders: aggregateSection('SUCCESS', 'SUCCESS', [WORK_ORDER]),
  1050. timelines: aggregateSection('SUCCESS', 'SUCCESS', [TIMELINE]),
  1051. latestImage: aggregateSection('SUCCESS', 'SUCCESS', archive.latestImage),
  1052. }
  1053. }
  1054. if (scenario === 'equipment-partial') {
  1055. // 票 11 验收第 3 条:任一分区失败不遮盖其它成功分区。
  1056. //
  1057. // 为什么要一个**独立场景名**而不是复用 `failed`:`failed` 在注册层就抛错
  1058. // (整个端点读不到,`data` 为 null),那是"这份详情根本没取到",
  1059. // 与"取到了、只是其中三块分区各自失败"是两件事。后端契约会把后者原样返回
  1060. // (`aggregateState: PARTIAL` + 每分区自带 state/reason/message),
  1061. // 所以必须有一个能真正走通的样例,否则这条验收在 mock 上无法演示。
  1062. return {
  1063. deviceCode: code,
  1064. aggregateState: 'PARTIAL',
  1065. serverTime: SNAPSHOT_AT,
  1066. archive: aggregateSection('FAILED', 'READ_FAILURE', null, '设备档案数据暂不可用'),
  1067. metrics: aggregateSection('SUCCESS', 'SUCCESS', METRICS),
  1068. warnings: aggregateSection('SUCCESS', 'SUCCESS', [WARNING]),
  1069. alarms: aggregateSection('SUCCESS', 'SUCCESS', [ALARM]),
  1070. alarmWarningRelations: aggregateSection('SUCCESS', 'SUCCESS', [RELATION]),
  1071. workOrders: aggregateSection('FAILED', 'READ_FAILURE', null, '工单数据暂不可用'),
  1072. timelines: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '工单分区失败,时间线不可用'),
  1073. latestImage: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '设备档案失败,图片不可用'),
  1074. }
  1075. }
  1076. if (scenario === 'work-order-timeline') {
  1077. // 票 12 的专用样例:三张工单、各一条独立时间线(推进 / 回退 / 取消三种形状都齐)。
  1078. // 与 `equipment-partial` 一样,它必须是一个**能真正走通**的独立场景 ——
  1079. // 挂在别的分支下就永远演示不到,那等于这条验收没有样例。
  1080. return {
  1081. deviceCode: code,
  1082. aggregateState: 'READY',
  1083. serverTime: SNAPSHOT_AT,
  1084. archive: { ...success, data: archive },
  1085. metrics: { ...success, data: METRICS },
  1086. warnings: { ...success, data: [WARNING] },
  1087. alarms: { ...success, data: [ALARM] },
  1088. alarmWarningRelations: { ...success, data: [RELATION] },
  1089. workOrders: { ...success, data: TIMELINE_ORDERS },
  1090. timelines: {
  1091. ...success,
  1092. data: TIMELINE_ORDERS.map((order) => ({ workOrder: { ...order }, timelineEntries: TIMELINE_BY_CODE[order.workOrderCode] })),
  1093. },
  1094. latestImage: { ...success, data: archive.latestImage },
  1095. }
  1096. }
  1097. if (scenario === 'work-order-timeline-failed') {
  1098. // 票 12 的**分区独立**样例:工单列表照常成功,只有时间线分区失败
  1099. // (后端 `loadTimelines()` 在工单读得到、时间线读不到时正是这个形状:
  1100. // `SOURCE_SECTION_FAILED` + 工单列表 SUCCESS)。界面必须说「轨迹没取到」,
  1101. // 而不是把缺数据画成「这张工单没有动作记录」—— 那是把"没拿到"说成"没有"。
  1102. return {
  1103. deviceCode: code,
  1104. aggregateState: 'PARTIAL',
  1105. serverTime: SNAPSHOT_AT,
  1106. archive: { ...success, data: archive },
  1107. metrics: { ...success, data: METRICS },
  1108. warnings: { ...success, data: [WARNING] },
  1109. alarms: { ...success, data: [ALARM] },
  1110. alarmWarningRelations: { ...success, data: [RELATION] },
  1111. workOrders: { ...success, data: [WORK_ORDER] },
  1112. timelines: aggregateSection('FAILED', 'SOURCE_SECTION_FAILED', null, '工单分区失败,时间线不可用'),
  1113. latestImage: { ...success, data: archive.latestImage },
  1114. }
  1115. }
  1116. return {
  1117. deviceCode: code,
  1118. aggregateState: 'READY',
  1119. serverTime: SNAPSHOT_AT,
  1120. archive: { ...success, data: archive },
  1121. metrics: { ...success, data: METRICS },
  1122. warnings: { ...success, data: [WARNING] },
  1123. alarms: { ...success, data: [ALARM] },
  1124. alarmWarningRelations: { ...success, data: [RELATION] },
  1125. workOrders: { ...success, data: [{ ...WORK_ORDER, associatedWithCurrentEvent: true }, { ...WORK_ORDER, workOrderCode: 'WO-UNLINKED', associatedWithCurrentEvent: false, eventAssociation: null }] },
  1126. timelines: { ...success, data: [TIMELINE] },
  1127. latestImage: { ...success, data: archive.latestImage },
  1128. }
  1129. }
  1130. // ---------------------------------------------------------------- 事件与工单
  1131. function eventRead(scenario, deviceCode = '') {
  1132. const base = {
  1133. requestId: 'mock-event-read',
  1134. apiVersion: 'v1',
  1135. readOnly: true,
  1136. deviceCode: deviceCode || ARCHIVE.deviceCode,
  1137. deviceType: 'ws_sewage_environment',
  1138. deviceTypeName: '污水环境监测设备',
  1139. startTime: null,
  1140. endTime: null,
  1141. timezone: 'Asia/Shanghai',
  1142. page: 1,
  1143. pageSize: 0,
  1144. }
  1145. if (scenario === 'empty') {
  1146. return { ...base, state: 'EMPTY', stateReason: 'NO_EVENTS', warnings: [], alarms: [], alarmWarningRelations: [], total: 0 }
  1147. }
  1148. if (scenario === 'degraded') {
  1149. // 缺 levelColor 与设备编码:适配层不得猜颜色,也不得伪造归属设备。
  1150. return {
  1151. ...base,
  1152. state: 'READY',
  1153. stateReason: 'READY',
  1154. warnings: [{ warningNo: 'WRN-DEGRADED-001', warningName: '字段缺失预警', level: 2, status: 'PROCESSING', eventTime: '2026-09-02T09:59:30+08:00', unit: 'mg/L' }],
  1155. alarms: [],
  1156. alarmWarningRelations: [],
  1157. total: 1,
  1158. }
  1159. }
  1160. if (scenario === 'stale') {
  1161. return {
  1162. ...base,
  1163. state: 'STALE',
  1164. stateReason: 'SAMPLE_OUTDATED',
  1165. warnings: [{ ...WARNING, freshness: 'STALE' }],
  1166. alarms: [],
  1167. alarmWarningRelations: [],
  1168. total: 1,
  1169. }
  1170. }
  1171. return {
  1172. ...base,
  1173. state: 'READY',
  1174. stateReason: 'READY',
  1175. warnings: [WARNING],
  1176. alarms: [ALARM],
  1177. alarmWarningRelations: [RELATION],
  1178. total: 2,
  1179. }
  1180. }
  1181. // ---------------------------------------------------------------- 跨设备当前事件(票 13)
  1182. /**
  1183. * 票 13:跨设备 `/events/current` 的契约化样例(`CockpitCurrentEventsResponse`)。
  1184. *
  1185. * 字段名与嵌套形状照抄后端响应类(`CockpitCurrentEventsResponse` +
  1186. * `CockpitCurrentEventsService`),所以适配层单测证明的是「转换吃得下真载荷」。
  1187. *
  1188. * 五处刻意照抄真实形状的**坑**:
  1189. * 1. **`mode` 必填并校验**(WARNING|ALARM,认不出的值 400)—— mock 收到非法 mode
  1190. * 抛错,逼调用方永远带上合法 mode;
  1191. * 2. **预警与报警永不混合**:`mode` 决定取哪一族,两块样例数据刻意不同,
  1192. * 切换后列表必须真的换内容(否则「预警/报警可切换」是假绿);
  1193. * 3. **`levelColor` 由后端按 level 解析**(`CockpitEventLevelColor`:1→RED …
  1194. * 4→GREEN,认不出→UNKNOWN),mock 照抄同一个映射;
  1195. * 4. **排序**:eventTime 倒序、同时间按 eventNo 升序(`eventOrdering()`),
  1196. * 服务端分页的页序靠它成立(票 61:每页 10 条,翻页由 `page`/`pageSize` 真切片);
  1197. * 5. **内部分区**:`sections.events` 是 `CockpitSectionResult`(空时
  1198. * `{state: EMPTY, reason: NO_EVENTS}`),工单分区独立(样例给空);
  1199. * 顶层不算空 —— 与设备详情聚合同一模式。
  1200. *
  1201. * 样例规模刻意大于一页:预警和报警各 14 条,让 `pageSize=10` 的服务端分页
  1202. * 在 mock 上真能演示第二页(票 61;此前是「仅展示最新 12 条」帽的演示数据)。
  1203. */
  1204. /** mock 里事件等级 → 颜色(照抄后端 `CockpitEventLevelColor.resolve`)。 */
  1205. function eventLevelColorOf(level) {
  1206. if (level === 1) return 'RED'
  1207. if (level === 2) return 'YELLOW'
  1208. if (level === 3) return 'BLUE'
  1209. if (level === 4) return 'GREEN'
  1210. return 'UNKNOWN'
  1211. }
  1212. /** 一条预警样例(字段照抄 `EventItem`;警报/工单摘要按族给)。 */
  1213. function eventItem({ no, name, type, level, status, eventTime, updatedTime, value, unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName, location, industry, min = null, max = null }) {
  1214. return {
  1215. eventNo: no,
  1216. eventName: name,
  1217. eventType: type,
  1218. level,
  1219. levelColor: eventLevelColorOf(level),
  1220. status,
  1221. eventTime,
  1222. updatedTime,
  1223. value,
  1224. unit,
  1225. deviceCode,
  1226. deviceName,
  1227. monitoringPointCode,
  1228. monitoringPointName,
  1229. location,
  1230. industry,
  1231. longitude: 110.14,
  1232. latitude: 28.45,
  1233. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  1234. thresholdMinValue: min,
  1235. thresholdMaxValue: max,
  1236. workOrderSummary: { workOrderCode: `WO-${no}`, stateCode: level === 1 ? 3 : 2, stateName: level === 1 ? '处理中' : '已接单' },
  1237. }
  1238. }
  1239. /** 预警样例(14 条,等级 1-4 都覆盖;状态用后端契约测试见过的取值)。 */
  1240. const CURRENT_WARNINGS = [
  1241. 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' }),
  1242. 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' }),
  1243. 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' }),
  1244. 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' }),
  1245. 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' }),
  1246. 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' }),
  1247. 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' }),
  1248. 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' }),
  1249. 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' }),
  1250. 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' }),
  1251. 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' }),
  1252. 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' }),
  1253. 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' }),
  1254. 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' }),
  1255. ]
  1256. /** 报警样例(14 条,带阈值;状态用后端契约测试见过的取值)。 */
  1257. const CURRENT_ALARMS = [
  1258. 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 }),
  1259. 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 }),
  1260. 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 }),
  1261. 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 }),
  1262. 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 }),
  1263. 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 }),
  1264. 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 }),
  1265. 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 }),
  1266. 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 }),
  1267. 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 }),
  1268. 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 }),
  1269. 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 }),
  1270. 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 }),
  1271. 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 }),
  1272. ]
  1273. /**
  1274. * 按后端语义过滤 + 排序 + 分页(照抄 `CockpitCurrentEventsService` 的
  1275. * `warningRows/alarmRows`、`eventOrdering()` 与分页切片)。
  1276. * @param {string} mode WARNING / ALARM
  1277. * @param {object} query URLSearchParams 解析出的请求查询串
  1278. */
  1279. function currentEventsPayload(scenario, query) {
  1280. const mode = (query && query.mode) || 'WARNING'
  1281. if (mode !== 'WARNING' && mode !== 'ALARM') {
  1282. // 与后端一致:mode 必填且只认两个取值(认不出的 400)。
  1283. // 不声明自定义 reason:非白名单的原因会被统一出口收敛成「不可用」,
  1284. // 而「调用方传了非法 mode」本来就是编程错误、不是可展示的业务态。
  1285. throw new Error('mock: mode must be WARNING or ALARM')
  1286. }
  1287. const source = mode === 'ALARM' ? CURRENT_ALARMS : CURRENT_WARNINGS
  1288. // 空样例:事件分区 EMPTY(`CockpitSectionResult.empty("NO_EVENTS")`),工单分区也空。
  1289. if (scenario === 'empty') {
  1290. return currentEventsEnvelope(mode, [], query, { state: 'EMPTY', reason: 'NO_EVENTS', message: null, data: null })
  1291. }
  1292. // 坐标不可上图样例(票 14 验收第 5 条的真机版):事件数据照常给全,
  1293. // 但**后端判定**坐标不可上图 —— 详情仍然完整可看,只是「位置」一栏给原因、定位点不动。
  1294. // 判据只改 `coordinateValidation`(与后端 `CockpitCoordinateValidator` 同形):
  1295. // 前端只消费结论,所以这里改结论就能复现"无地图位置"的整条路径。
  1296. if (scenario === 'event-detail-no-coordinate') {
  1297. const relocated = source.map((item) => ({
  1298. ...item,
  1299. longitude: null,
  1300. latitude: null,
  1301. coordinateValidation: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' },
  1302. }))
  1303. return currentEventsEnvelope(mode, relocated.slice(0, 12), query, {
  1304. state: 'SUCCESS',
  1305. reason: 'SUCCESS',
  1306. message: null,
  1307. data: relocated.slice(0, 12),
  1308. pagination: { page: 1, pageSize: 100, total: relocated.length, totalPages: 1, hasMore: false },
  1309. deviceCodes: [...new Set(relocated.map((item) => item.deviceCode).filter(Boolean))].sort(),
  1310. })
  1311. }
  1312. // 字段缺失样例:事件行只给编码/名称/等级,其余字段缺失(适配层要记 missing)。
  1313. if (scenario === 'degraded') {
  1314. const degraded = source.slice(0, 3).map((item) => ({
  1315. eventNo: item.eventNo,
  1316. eventName: item.eventName,
  1317. level: item.level,
  1318. status: item.status,
  1319. eventTime: item.eventTime,
  1320. }))
  1321. return currentEventsEnvelope(mode, degraded, query, { state: 'SUCCESS', reason: 'SUCCESS', message: null, data: degraded })
  1322. }
  1323. // 筛选(industry/level/status 与后端同口径;`industry` 只认正式行业码)。
  1324. const industry = (query && query.industry) || ''
  1325. const level = query && query.level ? Number(query.level) : null
  1326. const status = (query && query.status) || ''
  1327. const filtered = source.filter((item) => {
  1328. if (industry && item.industry !== industry) return false
  1329. if (level !== null && item.level !== level) return false
  1330. if (status && item.status !== status) return false
  1331. return true
  1332. })
  1333. // 排序:eventTime 倒序,同时间按 eventNo 升序(后端 eventOrdering())。
  1334. const sorted = [...filtered].sort((a, b) => {
  1335. const at = a.eventTime || ''
  1336. const bt = b.eventTime || ''
  1337. if (at && bt && at !== bt) return at > bt ? -1 : 1
  1338. if (at && !bt) return -1
  1339. if (!at && bt) return 1
  1340. return a.eventNo < b.eventNo ? -1 : a.eventNo > b.eventNo ? 1 : 0
  1341. })
  1342. const page = Number(query && query.page) > 0 ? Number(query.page) : 1
  1343. const pageSize = Number(query && query.pageSize) > 0 ? Number(query.pageSize) : 100
  1344. const from = Math.max(0, (page - 1) * pageSize)
  1345. const to = from + pageSize
  1346. const slice = sorted.slice(from, to)
  1347. const total = sorted.length
  1348. return currentEventsEnvelope(mode, slice, query, {
  1349. state: slice.length ? 'SUCCESS' : 'EMPTY',
  1350. reason: slice.length ? 'SUCCESS' : 'NO_EVENTS',
  1351. message: null,
  1352. data: slice,
  1353. pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0, hasMore: to < total },
  1354. deviceCodes: [...new Set(sorted.map((item) => item.deviceCode).filter(Boolean))].sort(),
  1355. })
  1356. }
  1357. /** 组装 `/events/current` 信封(sections 用内部分区,顶层永远 200 形状)。 */
  1358. function currentEventsEnvelope(mode, items, query, inner) {
  1359. return {
  1360. requestId: 'mock-current-events',
  1361. apiVersion: 'v1',
  1362. readOnly: true,
  1363. displayName: mode === 'ALARM' ? '当前报警' : '当前预警',
  1364. timezone: 'Asia/Shanghai',
  1365. mode,
  1366. updatedAt: SNAPSHOT_AT,
  1367. filters: {
  1368. mode,
  1369. level: query && query.level ? Number(query.level) : null,
  1370. industry: (query && query.industry) || null,
  1371. status: (query && query.status) || null,
  1372. },
  1373. pagination: inner.pagination || { page: 1, pageSize: 100, total: 0, totalPages: 0, hasMore: false },
  1374. mapFilter: {
  1375. mode,
  1376. level: query && query.level ? Number(query.level) : null,
  1377. industry: (query && query.industry) || null,
  1378. status: (query && query.status) || null,
  1379. deviceCodes: inner.deviceCodes || [],
  1380. },
  1381. sections: {
  1382. events: {
  1383. state: inner.state,
  1384. reason: inner.reason,
  1385. message: inner.message,
  1386. data: inner.data,
  1387. },
  1388. workOrders: {
  1389. state: 'EMPTY',
  1390. reason: 'NO_WORK_ORDERS',
  1391. message: null,
  1392. data: null,
  1393. },
  1394. },
  1395. }
  1396. }
  1397. // ---------------------------------------------------------------- 预警处置聚合面板(票 19)
  1398. function warningRange(query) {
  1399. const type = (query && query.range) || 'LAST_7_DAYS'
  1400. const days = type === 'LAST_90_DAYS' ? 90 : type === 'LAST_30_DAYS' ? 30 : 7
  1401. if (query?.startTime && query?.endTime) return { type, startTime: query.startTime, endTime: query.endTime }
  1402. const end = new Date('2026-09-02T10:00:00+08:00')
  1403. const start = new Date(end.getTime() - days * 24 * 3600 * 1000)
  1404. return { type, startTime: start.toISOString(), endTime: end.toISOString() }
  1405. }
  1406. function responseEventSource(query) {
  1407. const mode = (query && (query.eventFamily || query.mode)) === 'ALARM' ? 'ALARM' : 'WARNING'
  1408. const source = mode === 'ALARM' ? CURRENT_ALARMS : CURRENT_WARNINGS
  1409. const industry = (query && query.industry) || ''
  1410. return { mode, items: source.filter((item) => !industry || item.industry === industry) }
  1411. }
  1412. /**
  1413. * 历史分析载荷(票 19;票 61 补逐事件行 + 服务端分页)。
  1414. *
  1415. * 与后端 `CockpitHistoryAnalysisResponse` 同形:`rows[]` 小时桶 + `summary` +
  1416. * **`events[]` 逐事件行**(形状与 `events/current` 的 `EventItem` 同构)+ `pagination`。
  1417. * `page`/`pageSize` 按后端语义真实切片(默认 100、上限 1000),所以 `pageSize=10`
  1418. * 在 mock 上就能演示两页(单窗直取;多窗由前端合并后集合分页)。
  1419. *
  1420. * 一处如实留档的取舍:样例事件时间固定在 `SNAPSHOT_AT` 附近,而面板请求的是
  1421. * 「现在」往前 N 天的窗口 —— 按窗口过滤事件会恒空,多页翻页在 mock 上就演不出来。
  1422. * 与既有 mock「固定快照日」同一取舍:**事件行不按请求窗口过滤**(只按族/行业过滤),
  1423. * 小时桶仍按请求窗口生成(既有行为不变)。真实契约的窗口过滤由闸门注入载荷证明。
  1424. */
  1425. function historyAnalysisPayload(scenario, query) {
  1426. const source = responseEventSource(query)
  1427. const range = warningRange(query)
  1428. const items = source.items.filter((item) => Date.parse(item.eventTime) >= Date.parse(range.startTime) && Date.parse(item.eventTime) <= Date.parse(range.endTime))
  1429. const buckets = []
  1430. const hour = 3600000
  1431. for (let time = Math.floor(Date.parse(range.startTime) / hour) * hour; time <= Date.parse(range.endTime); time += hour) {
  1432. 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 })
  1433. }
  1434. const events = scenario === 'empty' ? [] : sortEventRows(source.items)
  1435. const page = Number(query && query.page) > 0 ? Number(query.page) : 1
  1436. const pageSize = Number(query && query.pageSize) > 0 ? Number(query.pageSize) : 100
  1437. const from = Math.min((page - 1) * pageSize, events.length)
  1438. const to = Math.min(from + pageSize, events.length)
  1439. const total = events.length
  1440. return {
  1441. requestId: 'mock-history-analysis', apiVersion: 'v1', readOnly: true, mode: source.mode,
  1442. range, updatedAt: SNAPSHOT_AT,
  1443. pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0, hasMore: to < total },
  1444. // 票 61:事件行的总数与 `pagination.total` 同源(不由当前页反推)。
  1445. events: events.slice(from, to),
  1446. rows: scenario === 'empty' ? [] : buckets,
  1447. summary: { eventCount: total },
  1448. }
  1449. }
  1450. /**
  1451. * `/events/handling-efficiency`(票 19;票 65 两族语义分流;**票 78 随票 74 契约两族分叉**)。
  1452. *
  1453. * 形状照抄后端 `CockpitHandlingEfficiencyResponse`:`eventFamily` 回显 + `summary`
  1454. * (条数/工单关联实况 + 两族各自的处置口径聚合)+ 状态计数 + `durationTrend`
  1455. * (逐桶 `eventCount` 与结案时长)+ 逐事件行 `events[]`。
  1456. *
  1457. * **两族契约分叉**(票 74 已交付,后端实测见 `verify/artifacts/t74/`):
  1458. * · 预警族:工单口径六字段(`handledCount/inProgressCount/cancelledCount/`
  1459. * `handlingEfficiency/averageDuration/workOrderStateCounts`)显式 `null`;
  1460. * 预警自身流程给 `warningStateCounts`(WarningWorkflow 六态全铺、缺桶计 0,
  1461. * 脏状态入「未知」桶)+ `summary.warningHandlingEfficiency`(已解除 ÷ 窗口内预警数,
  1462. * 分母 0 → EMPTY/NO_WARNINGS)+ `summary.warningDuration`(无样本 → EMPTY/NO_CLOSED_WARNINGS)。
  1463. * · 报警族:工单口径原样,三个 `warning*` 字段为 `null`。
  1464. * 六态计数从样例事件的 `status` 推导(认得出的入对应桶,认不出的一律入 UNKNOWN ——
  1465. * 与后端 `normalizeWarningStatus` 的归一规则同构),六态合计恒等于条数;
  1466. * 不为演示手调分布数字(map Notes 的静态样例裁定)。
  1467. */
  1468. function handlingEfficiencyPayload(scenario, query) {
  1469. const source = responseEventSource(query)
  1470. const range = warningRange(query)
  1471. const blank = scenario === 'empty'
  1472. const denominator = blank ? 0 : 3
  1473. const numerator = blank ? 0 : 2
  1474. const items = blank ? [] : source.items
  1475. const isAlarm = source.mode === 'ALARM'
  1476. // 预警不挂工单(实测),报警按等级 1 的样本给承接工单 —— 两族的关联实况刻意不同。
  1477. const linkedOf = (item) => isAlarm && Number(item.level) === 1
  1478. const linkedCount = items.filter(linkedOf).length
  1479. const events = items.map((item) => {
  1480. const linked = linkedOf(item)
  1481. const stateCode = linked ? 3 : null
  1482. return {
  1483. eventNo: item.eventNo,
  1484. deviceCode: item.deviceCode,
  1485. deviceName: item.deviceName,
  1486. level: item.level,
  1487. levelColor: eventLevelColorOf(item.level),
  1488. eventTime: item.eventTime,
  1489. workOrderStateCode: stateCode,
  1490. workOrderStateName: stateCode === null ? null : '处理中',
  1491. durationHours: null,
  1492. hasWorkOrder: linked,
  1493. }
  1494. })
  1495. // 票 78:WarningWorkflow 六态全铺(缺桶计 0),认不出的状态入 UNKNOWN 桶(后端同构)。
  1496. const warningStatuses = ['DRAFT', 'PENDING', 'RECEIVED', 'PROCESSING', 'HANDLED', 'CLOSED']
  1497. const warningStateCounts = isAlarm
  1498. ? null
  1499. : warningStatuses
  1500. .map((stateCode) => ({
  1501. stateCode,
  1502. stateName: { DRAFT: '草稿', PENDING: '待接收', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }[stateCode],
  1503. count: items.filter((item) => item && item.status === stateCode).length,
  1504. }))
  1505. .concat((() => {
  1506. const unknown = items.filter((item) => item && !warningStatuses.includes(item.status)).length
  1507. return unknown > 0 ? [{ stateCode: 'UNKNOWN', stateName: '未知', count: unknown }] : []
  1508. })())
  1509. // 预警族自身的已解除率/时长:与后端同一算法(CLOSED ÷ 条数;无 CLOSED 样本即 EMPTY)。
  1510. const closedWarnings = isAlarm ? 0 : (warningStateCounts.find((row) => row.stateCode === 'CLOSED') || { count: 0 }).count
  1511. const warningTotal = isAlarm ? 0 : items.length
  1512. const summary = isAlarm ? {
  1513. totalEvents: items.length,
  1514. linkedWorkOrderCount: linkedCount,
  1515. missingWorkOrderCount: items.length - linkedCount,
  1516. handledCount: blank ? 0 : 2,
  1517. inProgressCount: blank ? 0 : 1,
  1518. cancelledCount: blank ? 0 : 1,
  1519. 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' },
  1520. averageDuration: { value: denominator ? 2.4 : 0, unit: 'HOURS', state: denominator ? 'AVAILABLE' : 'EMPTY', sampleCount: denominator ? 2 : 0, reason: denominator ? null : 'NO_CLOSED_WORK_ORDERS' },
  1521. // 票 78:报警族不回预警自身字段(后端显式 null,不缺键)。
  1522. warningHandlingEfficiency: null,
  1523. warningDuration: null,
  1524. } : {
  1525. totalEvents: items.length,
  1526. linkedWorkOrderCount: linkedCount,
  1527. missingWorkOrderCount: items.length - linkedCount,
  1528. // 票 78:工单口径在预警族显式 null(不把「不适用」数成 0)。
  1529. handledCount: null,
  1530. inProgressCount: null,
  1531. cancelledCount: null,
  1532. handlingEfficiency: null,
  1533. averageDuration: null,
  1534. warningHandlingEfficiency: warningTotal > 0
  1535. ? { value: (closedWarnings / warningTotal).toFixed(4), numerator: closedWarnings, denominator: warningTotal, state: 'AVAILABLE', reason: null, basis: 'CLOSED_WARNINGS_OVER_WINDOW_WARNINGS' }
  1536. : { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_WARNINGS', basis: 'CLOSED_WARNINGS_OVER_WINDOW_WARNINGS' },
  1537. warningDuration: closedWarnings > 0
  1538. ? { value: '1.25', unit: 'HOURS', sampleCount: closedWarnings, state: 'AVAILABLE', reason: null }
  1539. : { value: '0.00', unit: 'HOURS', sampleCount: 0, state: 'EMPTY', reason: 'NO_CLOSED_WARNINGS' },
  1540. }
  1541. return {
  1542. requestId: 'mock-handling-efficiency', apiVersion: 'v1', readOnly: true,
  1543. displayName: isAlarm ? '报警效率' : '预警效率',
  1544. eventFamily: source.mode,
  1545. range, updatedAt: SNAPSHOT_AT,
  1546. pagination: { page: 1, pageSize: 100, total: items.length, totalPages: items.length ? 1 : 0, hasMore: false },
  1547. summary,
  1548. // 正式端点仅提供结案平均时长,不编造平均响应时长。
  1549. // 票 20:键为后端 0–7 状态码(待派单…已取消)。旧的 1–7 键已随字典对齐改掉。
  1550. // 票 78:工单状态计数只属报警族;预警族显式 null。
  1551. workOrderStateCounts: isAlarm
  1552. ? 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 }))
  1553. : null,
  1554. warningStateCounts,
  1555. // 票 65:逐桶带 `eventCount`(事件发生量)。票 74 契约:预警族逐桶的
  1556. // `handledCount`/`averageDuration` 为 null(结案时长属工单口径),报警族保留。
  1557. durationTrend: blank
  1558. ? []
  1559. : [1.4, 2.1, 2.4, 1.8, 2.6, 2.2, 2.4].map((value, index) => ({
  1560. timeBucket: `2026-09-${String(index + 1).padStart(2, '0')}`,
  1561. eventCount: 2,
  1562. handledCount: isAlarm ? 0 : null,
  1563. averageDuration: isAlarm ? { value, unit: 'HOURS', state: 'AVAILABLE' } : null,
  1564. })),
  1565. events,
  1566. }
  1567. }
  1568. function multidimensionalAnalysisPayload(scenario, query) {
  1569. const source = responseEventSource(query)
  1570. 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 }))
  1571. const levels = [1, 2, 3, 4].map((level) => ({ level, count: source.items.filter((item) => item.level === level).length }))
  1572. return {
  1573. requestId: 'mock-multidimensional-analysis', apiVersion: 'v1', readOnly: true, mode: source.mode,
  1574. range: warningRange(query), updatedAt: SNAPSHOT_AT,
  1575. dimensions: [
  1576. { dimensionCode: 'EVENT_TYPE', buckets: count('eventType').map((row) => ({ bucketCode: row.code, bucketLabel: row.name, count: row.count })) },
  1577. // 票 66:真实后端**恒定**回 CAUSE 维(缺成因走 `UNKNOWN_CAUSE` 兜底桶,服务端注释原句
  1578. // "The unknown bucket is always present so the cockpit never hides missing causes")。
  1579. // 样例照抄这一维:不抄的话「按成因」块在 mock 下永远不出现,样例比契约少一维是**形状**问题,
  1580. // 不是为了「演示某项行为」补数据(见 map Notes 的静态样例裁定)。
  1581. { dimensionCode: 'CAUSE', buckets: [{ bucketCode: 'UNKNOWN_CAUSE', bucketLabel: '未知/缺失成因', count: source.items.length }] },
  1582. { dimensionCode: 'LEVEL', buckets: levels.map((row) => ({ bucketCode: String(row.level), bucketLabel: `${row.level}级`, count: row.count })) },
  1583. { dimensionCode: 'INDUSTRY', buckets: count('industry').map((row) => ({ bucketCode: row.code, bucketLabel: { GAS: '燃气', WATER_SUPPLY: '供水', DRAINAGE: '排水', MANHOLE: '窨井' }[row.code], count: row.count })) },
  1584. { dimensionCode: 'DISTRICT', buckets: [{ bucketCode: 'UNASSIGNED', bucketLabel: '未分区', count: source.items.length }] },
  1585. ],
  1586. highlights: source.items.length ? [{ title: source.mode === 'ALARM' ? '高等级报警重点' : '高等级预警重点', detail: '按事件等级与数量识别', count: source.items.filter((item) => item.level === 1).length }] : [],
  1587. }
  1588. }
  1589. function freshnessPayload(scenario, deviceCode = '') {
  1590. const base = {
  1591. requestId: 'mock-freshness',
  1592. apiVersion: 'v1',
  1593. readOnly: true,
  1594. deviceCode: deviceCode || ARCHIVE.deviceCode,
  1595. serverUpdatedAt: SNAPSHOT_AT,
  1596. calibrationPeriodSeconds: 60,
  1597. }
  1598. if (scenario === 'empty') {
  1599. return { ...base, updatedAt: null, statusTime: null, freshness: 'EMPTY', staleReason: null, missedCycles: 0, dataAgeSeconds: 0, retained: false, message: null }
  1600. }
  1601. if (scenario === 'degraded') {
  1602. // 只给了 freshness,没给任何时间:界面必须显示占位符而不是「0 秒前」。
  1603. return { ...base, freshness: 'CURRENT' }
  1604. }
  1605. if (scenario === 'stale') {
  1606. 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: '数据暂不可用/已过期' }
  1607. }
  1608. 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 }
  1609. }
  1610. // ---------------------------------------------------------------- 基础设施四面板(票 17)
  1611. /**
  1612. * 票 17:基础设施模式四块面板的契约化样例。
  1613. *
  1614. * 字段名与嵌套形状照抄后端四个响应类(`CockpitInfrastructureBaseArchivesResponse` /
  1615. * `...OperatingInformationResponse` / `...ManagementInformationResponse` / `...EmergencySupportResponse`)
  1616. * 与它们的演示载荷 `docs/cockpit/infrastructure-*-demo.html`,所以适配层单测证明的是
  1617. * 「转换吃得下真载荷」。
  1618. *
  1619. * 三处刻意照抄真实形状的**坑**(不照抄就会漏掉真机才暴露的问题):
  1620. * 1. **BigDecimal 在线上是字符串**(`"0.32"`):三块面板的样例都按字符串给,逼适配层用
  1621. * `numberish` 而不是 `Number()` 直吃;
  1622. * 2. **`Distribution` 是三类维度共用结构**:应急保障的行业分布值在 `industry` 上、区域分布在
  1623. * `area` 上,`code` 一律是 `null`(后端的三个构造函数就是这么填的);
  1624. * 3. **`pointCodes` 按面板各自的真实语义给**(票 49 裁定、票 50 落实):
  1625. * 运行信息指标装的是**源设施编码**(真实载荷恒单元素、与一张图编码空间零交集——
  1626. * 面板因此不产定位按钮,票 50);应急保障样例仍指向 `MAP_POINTS` 真实点位(该面板保留
  1627. * 定位入口,票 50 非目标;真实侧该字段实测恒空、一个按钮都不画,出入见票 49 证据二)。
  1628. */
  1629. /** 四个面板的信封公共段(后端的 `requestId/apiVersion/readOnly/displayName/timezone`)。 */
  1630. function infraEnvelope(displayName, status) {
  1631. return {
  1632. requestId: `mock-${displayName}`,
  1633. apiVersion: 'v1',
  1634. readOnly: true,
  1635. displayName,
  1636. timezone: 'Asia/Shanghai',
  1637. updatedAt: '2026-09-02T09:59:50+08:00',
  1638. 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' },
  1639. }
  1640. }
  1641. /** 该场景下信封的新鲜度:空样例说 EMPTY,过期样例说 EXPIRED,其余 CURRENT。 */
  1642. function infraStatus(scenario) {
  1643. if (scenario === 'empty') return 'EMPTY'
  1644. if (scenario === 'stale') return 'EXPIRED'
  1645. return 'CURRENT'
  1646. }
  1647. /** 基础档案样例(设施规模 / 行业分类 / 区域分布 / 档案摘要)。 */
  1648. function baseArchivesPayload(scenario, query = {}) {
  1649. const status = infraStatus(scenario)
  1650. // Fixture facts only: every aggregate is derived from the same filtered set.
  1651. const facts = [
  1652. { district: 0, industry: 'WATER_SUPPLY', type: 'WATER_PLANT', name: '水厂', age: 'UNDER_5_YEARS', length: 0, commissioned: true },
  1653. { district: 0, industry: 'GAS', type: 'PUMP_STATION', name: '泵站', age: '5_TO_10_YEARS', length: 0, commissioned: true },
  1654. { district: 0, industry: 'GAS', type: 'GAS_PIPE', name: '燃气管线', age: '10_TO_20_YEARS', length: 4.2, commissioned: true },
  1655. { district: 1, industry: 'WATER_SUPPLY', type: 'WATER_PIPE', name: '供水管线', age: '20_YEARS_OR_OVER', length: 5.6, commissioned: false },
  1656. { district: null, industry: 'DRAINAGE', type: 'DRAINAGE_PIPE', name: '排水管线', age: '20_YEARS_OR_OVER', length: 2.6, commissioned: false },
  1657. ]
  1658. const selected = status === 'EMPTY' ? [] : facts.filter((row) =>
  1659. (!query.industry || row.industry === query.industry) &&
  1660. (!query.district || normalizedDistrict(row.district) === query.district))
  1661. const countBy = (field) => [...new Set(selected.map((row) => row[field]))].map((code) => ({ code, count: selected.filter((row) => row[field] === code).length }))
  1662. const selectedFilters = {
  1663. industry: query.industry || null,
  1664. district: query.district && query.district !== 'UNASSIGNED' ? Number(query.district) : null,
  1665. districtFilter: !query.district ? 'NONE' : query.district === 'UNASSIGNED' ? 'UNASSIGNED' : 'ASSIGNED',
  1666. }
  1667. return {
  1668. ...infraEnvelope('基础设施-基础档案', status),
  1669. range: { type: status === 'EXPIRED' ? 'LAST_30_DAYS' : 'CURRENT_BASELINE' },
  1670. // 票 38 mock 同构:真实后端 `totals.scale` **恒 null**(t18 审计实证,五端如此)——
  1671. // mock 若给 'medium',误留的规模消费点在真实下显示 `—`、mock 下却显示「中型」,
  1672. // 走查抓不到(票 08/18 记过的同构纪律)。`facilityTypes[].scale` 给真实形状:
  1673. // 管线行是 "km"(长度单位串,不是规模语义),其余 null。
  1674. totals: { facilityCount: selected.length, lengthKm: selected.length ? selected.reduce((sum, row) => sum + row.length, 0).toFixed(2) : null, scale: null },
  1675. industryDistribution: countBy('industry'),
  1676. facilityTypeDistribution: selected.map((row) => ({ code: row.type, name: row.name, count: 1, lengthKm: row.length || null, scale: row.length ? 'km' : null, range: null })),
  1677. ageDistribution: countBy('age'),
  1678. ageReference: { asOfDate: '2026-09-02', commissionedCount: selected.filter((row) => row.commissioned).length, builtCount: selected.filter((row) => !row.commissioned).length, unknownCount: 0 },
  1679. districtDistribution: countBy('district').map((row) => ({ district: row.code, districtName: row.code === null ? '未分区' : ['城南', '城北', '太常'][row.code], count: row.count })),
  1680. selectedFilters,
  1681. mapFilter: selectedFilters,
  1682. excluded: scenario === 'degraded' ? { unknownTypeCount: null } : { unknownTypeCount: 0, typeKindMismatchCount: 0, smartDeviceCount: 1, futureDateCount: 0 },
  1683. }
  1684. }
  1685. /** 运行信息样例:三个行业块,指标覆盖三类能力 + 边界值(真 0 / 缺能力基准 / 超载)。 */
  1686. function operatingInformationPayload(scenario, query = {}) {
  1687. const status = infraStatus(scenario)
  1688. const envelope = infraEnvelope('基础设施-运行信息', status)
  1689. const blank = status === 'EMPTY'
  1690. // 票 49 裁定:`pointCodes` 实为**源设施编码**(真实载荷 6362 条无一例外恒单元素),不进一张图
  1691. // 对象序列 → 运行信息面板不产定位按钮(票 50)。样例按行业用真实同形的设施码
  1692. // (TCRQ/CNRQ 燃气、TCGC/CNGC 供水、TCWS/TCYS 排水),**不得**用 `MAP_POINTS` 的 MP-*——
  1693. // 否则 mock 下按钮能点、真实下全 MISSING,同构纪律(票 08/18/38)被打破。
  1694. // 票 68:设施点图层从运行信息行上枚举「可上图的设施实体」(facilityId/facilityKind/facilityStatus),
  1695. // 所以 mock 的对应行也带这三个字段(同构纪律:不带上就永远演示不出设施点图层)。
  1696. const metric = (code, name, category, unit, value, capacity, ratio, load, pointCodes, facility = null) => ({
  1697. metricCode: code,
  1698. metricName: name,
  1699. category,
  1700. unit,
  1701. // 照抄线上形状:BigDecimal 是字符串。
  1702. value,
  1703. capacityValue: capacity,
  1704. loadRatio: ratio,
  1705. loadState: load,
  1706. sampledAt: '2026-09-02T09:59:30+08:00',
  1707. freshness: { status: 'CURRENT', reason: 'SAMPLE_CURRENT', ageSeconds: 20 },
  1708. trend: [{ bucket: '2026-09-01', value }, { bucket: '2026-09-02', value }],
  1709. abnormal: load === 'OVERLOAD',
  1710. pointCodes,
  1711. ...(facility || {}),
  1712. })
  1713. const industries = blank
  1714. ? []
  1715. : [
  1716. {
  1717. industry: { code: 'GAS', key: 'gas', label: '燃气' },
  1718. state: 'READY',
  1719. stateReason: 'READY',
  1720. abnormalMetricCount: 0,
  1721. metrics: [
  1722. metric('gas_supply_pressure', '燃气供应压力', 'SUPPLY', 'MPa', '0.32', '0.40', '0.8000', 'WATCH', ['TCRQ202609020001']),
  1723. metric('gas_flow_rate', '燃气流量', 'FLOW', 'm3/h', '1200.5', '2000', '0.6003', 'NORMAL', ['CNRQ202609020001']),
  1724. metric('gas_remaining_capacity', '燃气剩余承载能力', 'CAPACITY', 'm3', '800', '2000', '0.4000', 'NORMAL', ['TCRQ202609020002']),
  1725. ],
  1726. },
  1727. {
  1728. industry: { code: 'WATER_SUPPLY', key: 'waterSupply', label: '供水' },
  1729. state: 'READY',
  1730. stateReason: 'READY',
  1731. abnormalMetricCount: 1,
  1732. metrics: [
  1733. // 边界:真 0 是有效读数;超载项走 lv1 色键。
  1734. // 票 68:带设施实体身份(水厂),设施点图层据此取实体详情坐标上图。
  1735. metric('water_supply_flow', '供水流量', 'SUPPLY', 'm3/h', '0', '4000', '0.0000', 'NORMAL', ['TCGC202609020001'], {
  1736. facilityId: '1', facilityKind: 'WATER_PLANT', facilityStatus: '0',
  1737. }),
  1738. metric('water_supply_load', '供水负荷', 'CAPACITY', 'm3/h', '5000', '4000', '1.2500', 'OVERLOAD', ['CNGC202609020001']),
  1739. // 边界:能力基准缺失 → 负荷率也算不出来(界面显示占位符,不显示 0)。
  1740. metric('water_pressure', '供水压力', 'PRESSURE', 'MPa', '0.45', null, null, 'UNKNOWN', ['TCGC202609020002']),
  1741. ],
  1742. },
  1743. {
  1744. industry: { code: 'DRAINAGE', key: 'drainage', label: '排水' },
  1745. state: 'READY',
  1746. stateReason: 'READY',
  1747. abnormalMetricCount: 0,
  1748. metrics: [
  1749. metric('sewage_cod', '污水 COD', 'QUALITY', 'mg/L', '18.2', '50', '0.3640', 'NORMAL', ['TCWS202609020001']),
  1750. // 票 68:带设施实体身份(泵站),与上面那条水厂一起覆盖「可上图 + 状态色」两条路径。
  1751. metric('rainwater_flow', '雨水流量', 'FLOW', 'm3/h', '880', '1000', '0.8800', 'WATCH', ['TCYS202609020001'], {
  1752. facilityId: '2', facilityKind: 'PUMP_STATION', facilityStatus: '2',
  1753. }),
  1754. ],
  1755. },
  1756. ]
  1757. // 票 51:mock 必须**吃请求查询串**(至少 industry)—— 不吃的话 mock 下永远所有块全非空、
  1758. // 选行业不改变响应,真实缺陷在 mock 上演示不出来(票 08/18/38 记过的同一条纪律)。
  1759. // 照抄线上契约(2026-10-16 实测,载荷落盘 verify/artifacts/t51/):筛选把未选中块置空
  1760. // (`state: EMPTY` / `stateReason: NO_METRICS`,不删块、不改变块序);selectedIndustry 回显
  1761. // 命中块,未筛时回 null(线上未筛载荷同款,不再硬编码 GAS);mock 没有的行业也回显请求码。
  1762. const requested = blank ? '' : query.industry || ''
  1763. const hit = requested ? industries.find((block) => block.industry.code === requested) : null
  1764. const filtered = requested
  1765. ? industries.map((block) =>
  1766. block.industry.code === requested
  1767. ? block
  1768. : { industry: block.industry, state: 'EMPTY', stateReason: 'NO_METRICS', abnormalMetricCount: 0, metrics: [] },
  1769. )
  1770. : industries
  1771. return {
  1772. ...envelope,
  1773. range: { type: 'LAST_24_HOURS', start: '2026-09-01T10:00:00+08:00', end: '2026-09-02T10:00:00+08:00' },
  1774. selectedIndustry: hit ? hit.industry : requested ? { code: requested, key: '', label: '' } : null,
  1775. industries: filtered,
  1776. excluded:
  1777. scenario === 'degraded'
  1778. ? { unknownIndustryCount: null }
  1779. : { unknownIndustryCount: 0, nonFormalIndustryCount: 0 },
  1780. }
  1781. }
  1782. // ---------------------------------------------------------------- 设施点实体详情(票 68)
  1783. /**
  1784. * 票 68:设施点图层补坐标用的实体详情样例(水厂 / 泵站各一条)。
  1785. *
  1786. * 形状照抄真实 `/api/cockpit/v1/entities/{id}/detail`:坐标在 `coordinates` 上
  1787. * (缺坐标即 null,前端据此把该设施进「无地图位置」清单,不补默认值)。
  1788. * 两条样例与运行信息 mock 里带 `facilityId/facilityKind` 的指标行一一对应。
  1789. */
  1790. const FACILITY_ENTITY_FIXTURES = {
  1791. 'WATER_PLANT|1': {
  1792. category: 'WATER_PLANT',
  1793. id: '1',
  1794. code: 'WP001',
  1795. name: 'XX自来水厂',
  1796. status: '0',
  1797. location: '沅陵县城东',
  1798. coordinates: { longitude: 110.392, latitude: 28.437, endLongitude: null, endLatitude: null },
  1799. },
  1800. 'PUMP_STATION|2': {
  1801. category: 'PUMP_STATION',
  1802. id: '2',
  1803. code: 'PS002',
  1804. name: '城东泵站2',
  1805. status: '2',
  1806. location: '沅陵县城东',
  1807. coordinates: { longitude: 110.385, latitude: 28.436, endLongitude: null, endLatitude: null },
  1808. },
  1809. }
  1810. /**
  1811. * 实体详情样例:查询串里的 `category` 决定查哪个设施表(与真接口同形)。
  1812. * 查不到就**抛错**(真接口是 404/业务错误,不是"返回一个空详情")。
  1813. */
  1814. function facilityEntityDetailPayload(scenario, requested, query = {}) {
  1815. if (scenario === 'empty') throw new Error('mock: 实体不存在')
  1816. const category = String(query.category || '').toUpperCase()
  1817. const fixture = FACILITY_ENTITY_FIXTURES[`${category}|${requested}`]
  1818. if (!fixture) throw new Error(`mock: 实体 ${requested || '(空)'} 不存在`)
  1819. return {
  1820. requestId: 'mock-entity-detail',
  1821. apiVersion: 'v1',
  1822. readOnly: true,
  1823. category: fixture.category,
  1824. id: fixture.id,
  1825. code: fixture.code,
  1826. name: fixture.name,
  1827. status: fixture.status,
  1828. location: fixture.location,
  1829. district: null,
  1830. districtName: '未分区',
  1831. coordinates: fixture.coordinates,
  1832. attributes: {},
  1833. updatedAt: '2026-09-02T09:59:50+08:00',
  1834. }
  1835. }
  1836. /** 管理信息样例:四个「片区 × 行业」组合(含「未分区」与「有设备、没有设施」的城北)。 */
  1837. function managementInformationPayload(scenario, query = {}) {
  1838. const status = infraStatus(scenario)
  1839. const blank = status === 'EMPTY'
  1840. const districtIndustry = blank ? [] : [
  1841. { district: 0, districtName: '城南', industry: 'WATER_SUPPLY', facilityCount: 1, equipmentCount: 1 },
  1842. { district: 1, districtName: '城北', industry: 'MANHOLE', facilityCount: 0, equipmentCount: 1 },
  1843. { district: 2, districtName: '太常', industry: 'GAS', facilityCount: 1, equipmentCount: 1 },
  1844. // 排水行带雨污二级分项(维护者 2026-11-02):与真实端点同形,码位 0-污水 1-雨水。
  1845. { district: null, districtName: '未分区', industry: 'DRAINAGE', facilityCount: 1, equipmentCount: 0,
  1846. networkLevelCounts: [{ networkLevel: '0', facilityCount: 1 }] },
  1847. ].filter((row) => (!query.district || normalizedDistrict(row.district) === query.district) && (!query.industry || row.industry === query.industry))
  1848. const selectedFilters = {
  1849. industry: query.industry || null,
  1850. district: query.district && query.district !== 'UNASSIGNED' ? Number(query.district) : null,
  1851. districtFilter: !query.district ? 'NONE' : query.district === 'UNASSIGNED' ? 'UNASSIGNED' : 'ASSIGNED',
  1852. }
  1853. return {
  1854. ...infraEnvelope('基础设施-管理信息', status),
  1855. selectedFilters,
  1856. totals: {
  1857. facilityCount: districtIndustry.reduce((sum, row) => sum + row.facilityCount, 0),
  1858. equipmentCount: districtIndustry.reduce((sum, row) => sum + row.equipmentCount, 0),
  1859. },
  1860. districtIndustry,
  1861. mapFilter: selectedFilters,
  1862. excluded: { unknownIndustryCount: 0, nonFormalIndustryCount: 0 },
  1863. }
  1864. }
  1865. /** 应急保障样例:三段各自成败(一段可用、一段空、一段不可用),三件套齐全。 */
  1866. function emergencySupportPayload(scenario) {
  1867. const status = infraStatus(scenario)
  1868. const envelope = infraEnvelope('基础设施-应急保障', status)
  1869. const blank = status === 'EMPTY'
  1870. const item = (objectCode, objectName, category, industry, area, pointCodes, counts) => ({
  1871. objectCode,
  1872. objectName,
  1873. category,
  1874. industry,
  1875. area,
  1876. pointCodes,
  1877. eventAssociations: { warningCount: counts[0], alarmCount: counts[1], workOrderCount: counts[2] },
  1878. })
  1879. // 空样例:三段都是 EMPTY("确实没有对象")。
  1880. const emptySections = {
  1881. hazardSources: { state: 'EMPTY', stateReason: 'NO_OBJECTS', items: [] },
  1882. protectionTargets: { state: 'EMPTY', stateReason: 'NO_OBJECTS', items: [] },
  1883. emergencyResources: { state: 'EMPTY', stateReason: 'NO_OBJECTS', items: [] },
  1884. }
  1885. const sections = blank
  1886. ? emptySections
  1887. : {
  1888. // 三段**各自成败**:危险源可用、防护目标不可用、应急资源可用 ——
  1889. // 「一段坏了不遮盖其它段」在 mock 上直接可演示(票 17 与票 11 同一条纪律)。
  1890. hazardSources: {
  1891. state: 'READY',
  1892. stateReason: 'READY',
  1893. items: [
  1894. item('HS-WS-001', '太常片区污水管线危险源', 'HAZARD_SOURCE', 'WATER_SUPPLY', 'TC', ['MP-003'], [1, 1, 1]),
  1895. ],
  1896. },
  1897. protectionTargets:
  1898. scenario === 'infrastructure-partial'
  1899. ? { state: 'UNAVAILABLE', stateReason: 'SOURCE_UNAVAILABLE', items: [] }
  1900. : {
  1901. state: 'READY',
  1902. stateReason: 'READY',
  1903. items: [
  1904. // 区域为空 → 后端归一为未分区,界面照实说"未分区"而不是留白。
  1905. item('PT-DR-001', '污水处理厂防护目标', 'PROTECTION_TARGET', 'DRAINAGE', null, ['MP-003'], [0, 0, 1]),
  1906. ],
  1907. },
  1908. emergencyResources: {
  1909. state: 'READY',
  1910. stateReason: 'READY',
  1911. items: [
  1912. item('ER-WS-001', '供水抢修物资库', 'EMERGENCY_RESOURCE', 'WATER_SUPPLY', 'CN', ['MP-001'], [0, 0, 0]),
  1913. item('ER-GAS-001', '燃气应急抢险队', 'EMERGENCY_RESOURCE', 'GAS', 'CN', ['MP-002'], [0, 0, 0]),
  1914. ],
  1915. },
  1916. }
  1917. const allItems = Object.values(sections).flatMap((section) => section.items)
  1918. const countCategory = (category) => allItems.filter((entry) => entry.category === category).length
  1919. return {
  1920. ...envelope,
  1921. selectedFilters: { category: null, industry: null, area: null },
  1922. totals: blank
  1923. ? { emergencyResourceCount: 0, hazardSourceCount: 0, protectionTargetCount: 0, totalObjectCount: 0 }
  1924. : {
  1925. emergencyResourceCount: countCategory('EMERGENCY_RESOURCE'),
  1926. hazardSourceCount: countCategory('HAZARD_SOURCE'),
  1927. protectionTargetCount: countCategory('PROTECTION_TARGET'),
  1928. totalObjectCount: allItems.length,
  1929. },
  1930. sections,
  1931. // 分布结构是三类维度共用:行业分布值在 `industry`、区域分布在 `area`,`code` 一律 null。
  1932. industryDistribution: blank
  1933. ? []
  1934. : [
  1935. { category: null, industry: 'WATER_SUPPLY', area: null, count: 2 },
  1936. { category: null, industry: 'GAS', area: null, count: 1 },
  1937. { category: null, industry: 'DRAINAGE', area: null, count: 1 },
  1938. ],
  1939. areaDistribution: blank
  1940. ? []
  1941. : [
  1942. { category: null, industry: null, area: 'CN', count: 2 },
  1943. { category: null, industry: null, area: 'TC', count: 1 },
  1944. { category: null, industry: null, area: 'UNKNOWN', count: 1 },
  1945. ],
  1946. categoryDistribution: blank
  1947. ? []
  1948. : [
  1949. { category: 'EMERGENCY_RESOURCE', industry: null, area: null, count: 2 },
  1950. { category: 'HAZARD_SOURCE', industry: null, area: null, count: 1 },
  1951. { category: 'PROTECTION_TARGET', industry: null, area: null, count: 1 },
  1952. ],
  1953. eventSummary: blank
  1954. ? { warningCount: 0, alarmCount: 0, workOrderCount: 0 }
  1955. : {
  1956. warningCount: allItems.reduce((sum, entry) => sum + entry.eventAssociations.warningCount, 0),
  1957. alarmCount: allItems.reduce((sum, entry) => sum + entry.eventAssociations.alarmCount, 0),
  1958. workOrderCount: allItems.reduce((sum, entry) => sum + entry.eventAssociations.workOrderCount, 0),
  1959. },
  1960. mapFilter: { category: null, industry: null, area: null, objectCodes: [] },
  1961. excluded:
  1962. scenario === 'degraded'
  1963. ? { unknownCategoryCount: null }
  1964. : { unknownCategoryCount: 0, nonFormalIndustryCount: 0, duplicateObjectCount: 0 },
  1965. }
  1966. }
  1967. // ---------------------------------------------------------------- 运行监测三面板(票 18)
  1968. /**
  1969. * 票 18:运行监测模式三块面板的契约化样例。
  1970. *
  1971. * 字段名与嵌套形状照抄后端三个响应类(`CockpitMonitoringOverviewResponse` /
  1972. * `...DistributionResponse` / `...SituationResponse`)与它们的演示载荷
  1973. * `docs/cockpit/monitoring-{overview,distribution,operating-situation}-demo.html`,
  1974. * 所以适配层单测证明的是「转换吃得下真载荷」。
  1975. *
  1976. * 五处刻意照抄真实形状的**坑**(不照抄就会漏掉真机才暴露的问题):
  1977. * 1. **BigDecimal 在线上是字符串**:`onlineRate.value` 给 `"0.7778"`,逼适配层用 `numberish`;
  1978. * 2. **`district` 空值归一为字面量 `未分区`**(`CockpitMapSnapshotService` 的
  1979. * `UNASSIGNED_REGION`)—— 它是**真实取值**,界面要照实显示,不能读成"没有数据";
  1980. * 3. **状态分布给全六个状态**(含计数为 0 的),与后端 `for (status : values())` 一致;
  1981. * 4. **`metricFreshness` 不在本端点上**(见 `domain/monitoringPanels.js` 文件头第 ① 条):
  1982. * 本样例照真形状只给信封级 `updatedAt` + `freshness`,**不伪造一个逐设备采集时间**;
  1983. * 5. **在线率分母为 0 时**,后端回 `state:"EMPTY"` 且 `value:"0.0000"` —— 那个 0 是
  1984. * "没有分母"的占位,样例里专门给一个 `monitoring-no-devices` 场景把它逼出来。
  1985. */
  1986. /** 三面板信封公共段(后端的 `requestId/apiVersion/readOnly/displayName/timezone/freshness`)。 */
  1987. function monitoringEnvelope(displayName, status) {
  1988. return {
  1989. requestId: `mock-${displayName}`,
  1990. apiVersion: 'v1',
  1991. readOnly: true,
  1992. displayName,
  1993. timezone: 'Asia/Shanghai',
  1994. createdAt: status === 'EMPTY' ? null : '2026-09-01T08:00:00+08:00',
  1995. updatedAt: '2026-09-02T09:59:50+08:00',
  1996. freshness: {
  1997. status,
  1998. reason: status === 'CURRENT' ? 'READY' : status === 'EMPTY' ? 'NO_RESOLVED_DEVICES' : 'STALE_SAMPLES',
  1999. updatedAt: '2026-09-02T09:59:50+08:00',
  2000. serverUpdatedAt: '2026-09-02T10:00:00+08:00',
  2001. },
  2002. }
  2003. }
  2004. /** 该场景下信封的新鲜度(与票 17 的 `infraStatus` 同款口径)。 */
  2005. function monitoringStatus(scenario) {
  2006. if (scenario === 'empty') return 'EMPTY'
  2007. if (scenario === 'stale') return 'STALE'
  2008. return 'CURRENT'
  2009. }
  2010. /**
  2011. * 监测概览样例(票 18 验收第 1 条:监测点/设备规模、在线率、类型分布、最近采集时间)。
  2012. *
  2013. * 类型分布**照后端排序规则**给(服务按 `deviceCount` 降序、再按 `code`)——
  2014. * 界面若自作主张重排,真机走查那条「顺序 = 后端顺序」就会露馅。
  2015. */
  2016. function monitoringOverviewPayload(scenario, query) {
  2017. const status = monitoringStatus(scenario)
  2018. const envelope = monitoringEnvelope('监测概览', status)
  2019. // 票 18 的专项场景:**分母为 0**(后端回 `state=EMPTY` + `value="0.0000"`)。
  2020. // 与 `empty` 不同:信封不是 EMPTY、类型分布仍非空,只有在线率"不适用"——
  2021. // 这种形状最容易骗人(照 value 画就是"在线率 0.0%",而真相是"没有可算的设备")。
  2022. const noDevices = scenario === 'monitoring-no-devices'
  2023. const blank = status === 'EMPTY'
  2024. const blankRate = blank || noDevices
  2025. // **按请求的行业/设备类型真的过滤**(后端在服务端完成过滤,逐行读过
  2026. // `CockpitMonitoringOverviewService` 的三个 `selectedIndustry`/`deviceType` 判定)。
  2027. // 不这么做的话,走查里「切行业 → 面板数字变化」只能证明请求串里有参数,
  2028. // 而画面纹丝不动 —— 真机走查第一轮正是这样红了一条(假绿的反面:请求对了、界面没变)。
  2029. const industry = (query && query.industry) || ''
  2030. const deviceType = (query && query.deviceType) || ''
  2031. // 四个行业的设备账本(照后端:每台设备归一个行业与一个类型)。
  2032. // **票 18 更正**:`MANHOLE`(窨井)现已是第四个正式行业,样例必须带上它 ——
  2033. // 不带的话「窨井」筛选在界面上永远筛出空,看起来像坏了,而真相是样例没有该类数据。
  2034. // 类型码照后端真实形状:`equipment_type.id` 转字符串(后端测试里是 `"24"`)。
  2035. const allTypes = [
  2036. { deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' }, industry: 'GAS', deviceCount: 3, onlineCount: 3, offlineCount: 0, staleCount: 0 },
  2037. { deviceType: { code: 'WATER_SUPPLY_SENSOR', name: '供水压力计' }, industry: 'WATER_SUPPLY', deviceCount: 3, onlineCount: 3, offlineCount: 0, staleCount: 0 },
  2038. { deviceType: { code: 'SEWAGE_ENV', name: '污水环境监测' }, industry: 'DRAINAGE', deviceCount: 3, onlineCount: 2, offlineCount: 0, staleCount: 1 },
  2039. { deviceType: { code: '24', name: '窨井监测' }, industry: 'MANHOLE', deviceCount: 2, onlineCount: 2, offlineCount: 0, staleCount: 0 },
  2040. ]
  2041. // 监测点按行业分:GAS 2 个、WATER_SUPPLY 2 个、DRAINAGE 2 个、MANHOLE 1 个。
  2042. const pointsByIndustry = { GAS: 2, WATER_SUPPLY: 2, DRAINAGE: 2, MANHOLE: 1 }
  2043. const selectedTypes = allTypes.filter(
  2044. (row) => (!industry || row.industry === industry) && (!deviceType || row.deviceType.code === deviceType),
  2045. )
  2046. const types = blank || noDevices ? [] : selectedTypes
  2047. const equipmentCount = blank || noDevices ? 0 : selectedTypes.reduce((sum, row) => sum + row.deviceCount, 0)
  2048. const onlineCount = blankRate ? 0 : selectedTypes.reduce((sum, row) => sum + row.onlineCount, 0)
  2049. const offlineCount = blankRate ? 0 : selectedTypes.reduce((sum, row) => sum + row.offlineCount, 0)
  2050. const staleCount = blankRate ? 0 : selectedTypes.reduce((sum, row) => sum + row.staleCount, 0)
  2051. const denominator = onlineCount + offlineCount + staleCount
  2052. // 监测点数按行业求和;带 `deviceType` 时后端按类型过滤点位,这里同口径。
  2053. const monitoringPointCount =
  2054. blank || noDevices
  2055. ? noDevices
  2056. ? 2
  2057. : 0
  2058. : selectedTypes.reduce((sum, row) => sum + (pointsByIndustry[row.industry] || 0), 0)
  2059. const rate = denominator === 0 ? '0.0000' : (onlineCount / denominator).toFixed(4)
  2060. // 9 台里 8 台在线 1 台滞后 → 全量在线率 8/9(照后端口径:分母 = 在线 + 离线 + 滞后)。
  2061. return {
  2062. ...envelope,
  2063. range: { type: 'CURRENT_BASELINE' },
  2064. counts: blank
  2065. ? { monitoringPointCount: 0, equipmentCount: 0, facilityCount: 0, infrastructureFacilityCount: 0 }
  2066. : { monitoringPointCount, equipmentCount, facilityCount: 0, infrastructureFacilityCount: 0 },
  2067. status: blankRate
  2068. ? { onlineCount: 0, offlineCount: 0, staleCount: 0, unknownOrMissingCount: noDevices ? 2 : 0, denominator: 0 }
  2069. : { onlineCount, offlineCount, staleCount, unknownOrMissingCount: 0, denominator },
  2070. // BigDecimal 是字符串;分母为 0 时 state 是 EMPTY 且 value 是 "0.0000"(**不是**"在线率 0%")。
  2071. onlineRate: blankRate
  2072. ? { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_RESOLVED_DEVICES' }
  2073. : { value: rate, numerator: onlineCount, denominator, state: 'AVAILABLE', reason: null },
  2074. typeDistribution: types,
  2075. // 票 62 追加需求:逐设备台账 `devices[]`(与 `counts.equipmentCount` 同源同口径)。
  2076. // 用与类型分布同一份账本**逐台展开**——行数结构性等于 `equipmentCount`,
  2077. // 不是另写一组固定数字(那样两处读数早晚会漂)。
  2078. devices: blank || noDevices ? [] : overviewDevices(selectedTypes),
  2079. mapFilter: { industry: industry || null, deviceType: deviceType || null, pointCodes: blank ? [] : ['MP-001', 'MP-002', 'MP-003'], deviceCodes: [] },
  2080. // `degraded` 走字段缺失样例:剔除数缺失,适配层要记 missing 而不是当 0。
  2081. excluded:
  2082. scenario === 'degraded'
  2083. ? { unknownTypeCount: null }
  2084. : { unknownTypeCount: 0, typeKindMismatchCount: 0, nonFormalIndustryCount: 0 },
  2085. }
  2086. }
  2087. /**
  2088. * 逐设备台账样例(票 62 追加需求)。
  2089. *
  2090. * **从类型账本逐台展开**,不是另写一组固定行:后端 `build()` 里逐设备行与
  2091. * `counts.equipmentCount` 出自同一次遍历,mock 也照这个结构保证——行数恒等于
  2092. * `equipmentCount`(改账本就同时改两处,不会漂)。
  2093. *
  2094. * 每行的状态按该类型的 online/offline/stale 配比逐台给出(前 N 台在线、接着离线、
  2095. * 再接着滞后),所以「设备台账」在 mock 上能看到三种状态并存,而不是清一色在线。
  2096. */
  2097. function overviewDevices(selectedTypes) {
  2098. const devices = []
  2099. for (const row of selectedTypes || []) {
  2100. const industry = row.industry
  2101. const typeCode = row.deviceType.code
  2102. const typeName = row.deviceType.name
  2103. for (let index = 0; index < row.deviceCount; index += 1) {
  2104. const seq = String(index + 1).padStart(2, '0')
  2105. const deviceCode = `EQ-${industry}-${typeCode}-${seq}`
  2106. const state = index < row.onlineCount ? 'ONLINE'
  2107. : index < row.onlineCount + row.offlineCount ? 'OFFLINE'
  2108. : 'STALE'
  2109. devices.push({
  2110. deviceCode,
  2111. deviceName: `${typeName}-${seq}`,
  2112. deviceType: { code: typeCode, name: typeName },
  2113. industry,
  2114. monitoringState: state,
  2115. // 前半数设备挂到监测点上,后半数留空 —— 台账里"未关联点位"是真实取值,
  2116. // 界面必须照实说(不拿空串冒充点位编码)。
  2117. monitoringPointCode: index % 2 === 0 ? `MP-${industry}-${seq}` : null,
  2118. sampledAt: '2026-09-02T09:59:00+08:00',
  2119. createdAt: '2026-08-31T08:00:00+08:00',
  2120. })
  2121. }
  2122. }
  2123. return devices
  2124. }
  2125. /** 一个分布 marker(监测点或未关联设备;两族字段名只差 `point*` / `device*`)。 */
  2126. function distributionPoint(pointCode, pointName, district, industry, typeCode, typeName, pointStatus, pointCodes, coord) {
  2127. return {
  2128. pointCode,
  2129. pointName,
  2130. createdAt: '2026-08-31T08:00:00+08:00',
  2131. districtName: district === null ? '未分区' : ['城南', '城北', '太常'][district],
  2132. district,
  2133. industry,
  2134. deviceType: { code: typeCode, name: typeName },
  2135. pointStatus,
  2136. statusReasons: pointStatus === 'ALARM' ? [`${pointCodes[0] || 'EQ'} 报警`] : [],
  2137. ...coord,
  2138. }
  2139. }
  2140. /**
  2141. * 监测分布样例(票 18 验收第 2 条)。
  2142. *
  2143. * **分页窗口横跨「监测点 + 未关联设备」一条总序列**(与 `map-snapshot` 同一套后端语义):
  2144. * 这里 6 个监测点 + 2 台未关联设备 = 8 条,页大小由请求给(走查现场可改),
  2145. * 所以「还有下一页」在 mock 上真的能演示(Q4 裁定台账按端点 `pagination` 渐进加载)。
  2146. *
  2147. * 三份分布与状态计数**都从过滤后的集合现算**(照抄后端 `build()` 的顺序:
  2148. * 先按筛选挑 marker,再聚合,最后切片分页)。为什么不写死一组数字:
  2149. * 面板的分布若与筛选无关,「筛选真的作用到面板数据上了吗」这条断言就退化成在看请求串 ——
  2150. * 筛错了、或者根本没传,画面都一样,是**假绿**(票 08 踩过同一个坑)。
  2151. */
  2152. function monitoringDistributionPayload(scenario, query, overrides = {}) {
  2153. const status = monitoringStatus(scenario)
  2154. const envelope = monitoringEnvelope('监测分布', status)
  2155. const blank = status === 'EMPTY'
  2156. const filters = {
  2157. industry: (query && query.industry) || '',
  2158. district: (query && query.district) || '',
  2159. // 票 37:类型筛选真的过滤(后端 `matchesFilters` 逐行读过:类型码精确匹配,
  2160. // 不可推导的对象在带该筛选时被排除)——不这么做,「点类型行 → 分布变窄」就是假绿。
  2161. deviceType: (query && query.deviceType) || '',
  2162. pointStatus: (query && query.pointStatus) || '',
  2163. }
  2164. const pointList = blank
  2165. ? []
  2166. : [
  2167. 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' } }),
  2168. 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' } }),
  2169. // 空区域归一为字面量「未分区」——真实取值,界面必须照实显示。
  2170. 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' } }),
  2171. distributionPoint('MP-004', '排水泵站监测点', 1, 'DRAINAGE', 'SEWAGE_ENV', '污水环境监测', 'NORMAL', [], { longitude: 110.144, latitude: 28.455, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }),
  2172. 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' } }),
  2173. distributionPoint('MP-006', '预留无设备监测点', 2, 'GAS', 'GAS_SENSOR', '燃气浓度计', 'EMPTY', [], { longitude: 110.146, latitude: 28.457, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }),
  2174. // 票 18 更正:窨井(`MANHOLE`)是第四个正式行业,样例带上它 ——
  2175. // 类型码照后端真实形状(`equipment_type.id` 转字符串)。
  2176. distributionPoint('MP-007', '窨井监测点', 1, 'MANHOLE', '24', '窨井监测', 'NORMAL', [], { longitude: 110.147, latitude: 28.458, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }),
  2177. ]
  2178. const equipmentList = blank
  2179. ? []
  2180. : [
  2181. {
  2182. deviceCode: 'EQ-FREE-002',
  2183. deviceName: '智能窨井-002',
  2184. createdAt: null,
  2185. district: null, districtName: '未分区',
  2186. industry: null,
  2187. // 类型不可推导时后端给 null(默认不排除对象)——界面据此照实说"类型未知"。
  2188. deviceType: null,
  2189. pointStatus: 'NORMAL',
  2190. statusReasons: [],
  2191. longitude: 110.155,
  2192. latitude: 28.459,
  2193. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  2194. },
  2195. {
  2196. deviceCode: 'EQ-FREE-004',
  2197. deviceName: '零坐标设备-004',
  2198. createdAt: null,
  2199. district: 2, districtName: '太常',
  2200. industry: 'GAS',
  2201. deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' },
  2202. pointStatus: 'OFFLINE',
  2203. statusReasons: [],
  2204. longitude: 0,
  2205. latitude: 0,
  2206. coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' },
  2207. },
  2208. ]
  2209. // 后端 `matchesMarker()`:行业与状态按值比对,区域同样(`未分区` 是真实取值)。
  2210. // 票 37:`deviceType` 按 **marker 自身类型码**精确匹配(本端点服务逐行读过:
  2211. // `isBlank(deviceType) || type.code === deviceType`——与 map-snapshot 的设备级 OR 归并
  2212. // 是两条路径);类型不可推导(`deviceType` 为 null)的对象在带该筛选时被排除(后端同款)。
  2213. const selected = [...pointList, ...equipmentList].filter((item) => {
  2214. if (filters.industry && item.industry !== filters.industry) return false
  2215. if (filters.pointStatus && !matchesStatusFilter(item, filters.pointStatus.split(','))) return false
  2216. if (filters.district && (normalizedDistrict(item.district)) !== filters.district) return false
  2217. if (filters.deviceType) {
  2218. if (!item.deviceType || item.deviceType.code !== filters.deviceType) return false
  2219. }
  2220. return true
  2221. })
  2222. const points = selected.filter((item) => 'pointCode' in item)
  2223. const devices = selected.filter((item) => 'deviceCode' in item)
  2224. /** 一条分布行(按给定维度键聚合;`pointCount` 只数监测点、`deviceCount` 只数未关联设备)。 */
  2225. const distributionRow = (key, extra = {}) => {
  2226. const bucket = selected.filter((item) => key(item) === extra.value)
  2227. const counts = { alarm: 0, warning: 0, workOrder: 0, normal: 0, offline: 0, empty: 0 }
  2228. for (const item of bucket) {
  2229. const map = { ALARM: 'alarm', WARNING: 'warning', WORK_ORDER: 'workOrder', NORMAL: 'normal', OFFLINE: 'offline', EMPTY: 'empty' }
  2230. const slot = map[item.pointStatus]
  2231. if (slot) counts[slot] += 1
  2232. }
  2233. return {
  2234. ...extra.build,
  2235. pointCount: bucket.filter((item) => 'pointCode' in item).length,
  2236. deviceCount: bucket.filter((item) => 'deviceCode' in item).length,
  2237. statusCounts: counts,
  2238. }
  2239. }
  2240. // 分页:窗口横跨两类对象(一条总序列)。
  2241. const page = Number(query && query.page) > 0 ? Number(query.page) : 1
  2242. // 页大小的**默认值照后端**(500)。但那样 6 个点的样例一页就到底,
  2243. // 「台账自动渐进装载」这条验收在真机上永远演示不出来 —— 所以走查可用
  2244. // `window.__COCKPIT_MOCK_MONITORING__ = { pageSize: 3 }` 现场压小页大小
  2245. // (与票 08 地图摘要的 `__COCKPIT_MOCK_MAP__` 同一套手法)。
  2246. const fallbackPageSize = Number(overrides.pageSize) > 0 ? Number(overrides.pageSize) : 500
  2247. const pageSize = Number(query && query.pageSize) > 0 ? Number(query.pageSize) : fallbackPageSize
  2248. // 票 62(执行票同期后端已补齐回显):快照 token 与 map-snapshot 同一套纪律——
  2249. // · 第 1 页可不带,后端开新快照并**在响应里回显**;
  2250. // · 第 2 页起**必须**带,且要与当前快照一致,否则按后端原样抛(400 / SNAPSHOT_EXPIRED)。
  2251. // 注意 `query` 是**已解析的参数对象**(`context.query`),不是原始路径串。
  2252. const snapshotAt = (query && query.snapshotAt) || ''
  2253. const snapshotToken = overrides.snapshotAt || SNAPSHOT_AT
  2254. if (page > 1 && !snapshotAt) {
  2255. // 后端对「第 2 页没带快照」回 400 + INVALID_REQUEST。mock 侧统一出口只认
  2256. // `REQUEST_REASON` 白名单,所以这里落成 `unavailable`(断言看的是「请求被拒、
  2257. // 台账停链且不回滚已装载内容」,不是具体原因码)。
  2258. const error = new Error('mock: 监测分布第 2 页起必须带 snapshotAt')
  2259. error.reason = REQUEST_REASON.UNAVAILABLE
  2260. throw error
  2261. }
  2262. if (snapshotAt && snapshotAt !== snapshotToken) {
  2263. const error = new Error('mock: 监测分布快照已失效')
  2264. error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED
  2265. throw error
  2266. }
  2267. const from = Math.max(0, (page - 1) * pageSize)
  2268. const to = from + pageSize
  2269. const slice = selected.slice(from, to)
  2270. // 三份分布(**全量口径**:先聚合完再切片,所以与页码无关,只与筛选有关)。
  2271. const districtKeys = [...new Set(selected.map((item) => normalizedDistrict(item.district)))].sort()
  2272. const industryKeys = ['GAS', 'WATER_SUPPLY', 'DRAINAGE', 'MANHOLE'].filter((code) => selected.some((item) => item.industry === code))
  2273. const typeKeys = [...new Map(selected.filter((item) => item.deviceType).map((item) => [item.deviceType.code, item.deviceType])).entries()]
  2274. return {
  2275. ...envelope,
  2276. range: { type: 'CURRENT_BASELINE' },
  2277. // 票 62:快照标记回显(与真实后端执行票同期补齐的字段同名同义),
  2278. // 调用方第 2 页起原样回带。
  2279. snapshotAt: snapshotToken,
  2280. districtDistribution: districtKeys.map((district) => distributionRow((item) => normalizedDistrict(item.district), { value: district, build: { district: district === 'UNASSIGNED' ? null : Number(district), districtName: district === 'UNASSIGNED' ? '未分区' : ['城南', '城北', '太常'][Number(district)] } })),
  2281. industryDistribution: industryKeys.map((industry) => distributionRow((item) => item.industry, { value: industry, build: { industry } })),
  2282. deviceTypeDistribution: typeKeys.map(([code, type]) =>
  2283. distributionRow((item) => (item.deviceType ? item.deviceType.code : null), {
  2284. value: code,
  2285. build: { deviceType: type, industry: (selected.find((item) => item.deviceType && item.deviceType.code === code) || {}).industry || null },
  2286. }),
  2287. ),
  2288. // 状态分布:**全六个状态都给**(含 0),与后端 `for (status : values())` 一致。
  2289. pointStatusDistribution: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((code) => ({
  2290. status: code,
  2291. count: selected.filter((item) => item.pointStatus === code).length,
  2292. })),
  2293. mapFilter: {
  2294. industry: filters.industry || null,
  2295. deviceType: filters.deviceType || null,
  2296. pointStatus: filters.pointStatus || null,
  2297. district: filters.district && filters.district !== 'UNASSIGNED' ? Number(filters.district) : null,
  2298. districtFilter: !filters.district ? 'NONE' : filters.district === 'UNASSIGNED' ? 'UNASSIGNED' : 'ASSIGNED',
  2299. pointCodes: points.map((item) => item.pointCode),
  2300. deviceCodes: devices.map((item) => item.deviceCode),
  2301. },
  2302. pagination: { page, pageSize, total: selected.length, totalPages: selected.length ? Math.ceil(selected.length / pageSize) : 0, hasMore: to < selected.length },
  2303. monitoringPoints: slice.filter((item) => 'pointCode' in item),
  2304. unassociatedEquipment: slice.filter((item) => 'deviceCode' in item),
  2305. excluded:
  2306. scenario === 'degraded'
  2307. ? { unknownTypeCount: null }
  2308. : { unknownTypeCount: 0, typeKindMismatchCount: 0, nonFormalIndustryCount: 0, invalidCoordinateCount: selected.filter((item) => item.coordinateValidation && !item.coordinateValidation.renderable).length },
  2309. }
  2310. }
  2311. /** 趋势桶:按小时铺一串(照后端「业务时区小时分桶」的形状,键是带偏移的 ISO 串)。 */
  2312. function hourlyTrend(counts, startHour = 1) {
  2313. return counts.map((count, index) => ({
  2314. bucketStart: `2026-09-02T${String(startHour + index).padStart(2, '0')}:00:00+08:00`,
  2315. count,
  2316. }))
  2317. }
  2318. /**
  2319. * 运行概况样例(票 18 验收第 3 条:事件趋势、离线设备、异常态势)。
  2320. *
  2321. * 四处照后端语义:
  2322. * 1. **预警与报警分成两条趋势**(服务注释原文:warnings feed warningTrend only,
  2323. * alarms feed alarmTrend only)—— 样例里两条形状不同,合并就会当场露馅;
  2324. * 2. `range` 回显实际生效窗口(服务回 `TREND_WINDOW` + start/end);
  2325. * 3. `metricStatusDistribution` 给全六态(含 0);
  2326. * 4. `abnormalEquipment` 只装**异常**设备(服务定义 WARNING/ALARM/EXPIRED/OFFLINE 四态)——
  2327. * 所以"离线设备清单"是它的子集,界面据此分开说。
  2328. * 票 37:`deviceType` 生效时 3/4 两段(六态计数、异常清单、行业覆盖)一并收窄(假绿防线)。
  2329. */
  2330. function monitoringSituationPayload(scenario, query) {
  2331. const status = monitoringStatus(scenario)
  2332. const envelope = monitoringEnvelope('运行概况', status)
  2333. const blank = status === 'EMPTY'
  2334. // 票 37:`deviceType` 真的过滤异常清单(后端服务同款:类型码精确匹配),
  2335. // 否则「点类型行 → 运行概况变窄」也是假绿。
  2336. const deviceType = (query && query.deviceType) || ''
  2337. // 窗口照后端回显:显式区间原样带出(`resolveWindow`),不传时是最近 24 小时。
  2338. const range = {
  2339. type: 'TREND_WINDOW',
  2340. startTime: (query && query.startTime) || '2026-09-01T10:00:00+08:00',
  2341. endTime: (query && query.endTime) || '2026-09-02T10:00:00+08:00',
  2342. }
  2343. const abnormal = blank
  2344. ? []
  2345. : [
  2346. {
  2347. deviceCode: 'EQ-FREE-001',
  2348. deviceName: '供水压力计-001',
  2349. createdAt: null,
  2350. deviceType: { code: 'WATER_SUPPLY_SENSOR', name: '供水压力计' },
  2351. industry: 'WATER_SUPPLY',
  2352. district: 0, districtName: '城南',
  2353. abnormalType: 'ALARM',
  2354. status: 'ALARM',
  2355. statusReasons: ['压力超高'],
  2356. longitude: 110.141,
  2357. latitude: 28.452,
  2358. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  2359. },
  2360. {
  2361. // 离线设备:它是异常清单的一个子集,界面据此分开报"离线几台"与"异常清单里有什么"。
  2362. deviceCode: 'EQ-FREE-004',
  2363. deviceName: '零坐标设备-004',
  2364. createdAt: null,
  2365. deviceType: { code: 'GAS_SENSOR', name: '燃气浓度计' },
  2366. industry: 'GAS',
  2367. district: 2, districtName: '太常',
  2368. abnormalType: 'OFFLINE',
  2369. status: 'OFFLINE',
  2370. statusReasons: [],
  2371. longitude: 0,
  2372. latitude: 0,
  2373. coordinateValidation: { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false, crs: 'WGS84' },
  2374. },
  2375. ].filter((item) => !deviceType || (item.deviceType && item.deviceType.code === deviceType))
  2376. return {
  2377. ...envelope,
  2378. range,
  2379. metricStatusDistribution: blank
  2380. ? [
  2381. { status: 'NORMAL', count: 0 },
  2382. { status: 'WARNING', count: 0 },
  2383. { status: 'ALARM', count: 0 },
  2384. { status: 'EXPIRED', count: 0 },
  2385. { status: 'OFFLINE', count: 0 },
  2386. { status: 'UNKNOWN', count: 0 },
  2387. ]
  2388. : [
  2389. // 票 37:`deviceType` 生效时六态计数随筛选收窄(与 `abnormalEquipment` 同一集合),
  2390. // 不收窄的话「异常统计」与"清单条数"会对不上 —— 又一处假绿。
  2391. ...(deviceType && deviceType !== 'GAS_SENSOR'
  2392. ? []
  2393. : [
  2394. { status: 'NORMAL', count: 6 },
  2395. { status: 'WARNING', count: 1 },
  2396. { status: 'ALARM', count: 1 },
  2397. { status: 'EXPIRED', count: 0 },
  2398. { status: 'OFFLINE', count: 1 },
  2399. { status: 'UNKNOWN', count: 0 },
  2400. ]),
  2401. ...(deviceType === 'GAS_SENSOR'
  2402. ? [
  2403. { status: 'NORMAL', count: 0 },
  2404. { status: 'WARNING', count: 0 },
  2405. { status: 'ALARM', count: 0 },
  2406. { status: 'EXPIRED', count: 0 },
  2407. { status: 'OFFLINE', count: 1 },
  2408. { status: 'UNKNOWN', count: 0 },
  2409. ]
  2410. : []),
  2411. ],
  2412. // 两条趋势形状不同(预警少、报警多),合并成一条就会露馅。
  2413. warningTrend: blank ? [] : hourlyTrend([0, 1, 0, 0, 2, 1, 0, 0]),
  2414. alarmTrend: blank ? [] : hourlyTrend([1, 0, 3, 2, 0, 1, 4, 2]),
  2415. industryCoverage: blank
  2416. ? []
  2417. : [
  2418. // 票 37:类型生效时行业覆盖随之收窄(本端点唯一类型事实归 GAS_SENSOR)。
  2419. ...(deviceType && deviceType !== 'GAS_SENSOR'
  2420. ? []
  2421. : [{ industry: 'GAS', deviceCount: 3, abnormalCount: 1, statusCounts: statusCountsOf(2, 0, 0, 0, 1, 0) }]),
  2422. ...(!deviceType || deviceType === 'WATER_SUPPLY_SENSOR'
  2423. ? [{ industry: 'WATER_SUPPLY', deviceCount: 3, abnormalCount: 1, statusCounts: statusCountsOf(2, 0, 1, 0, 0, 0) }]
  2424. : []),
  2425. ...(!deviceType ? [{ industry: 'DRAINAGE', deviceCount: 3, abnormalCount: 1, statusCounts: statusCountsOf(2, 1, 0, 0, 0, 0) }] : []),
  2426. ],
  2427. abnormalEquipment: abnormal,
  2428. mapFilter: {
  2429. industry: (query && query.industry) || null,
  2430. deviceType: deviceType || null,
  2431. deviceCodes: abnormal.filter((item) => item.coordinateValidation.renderable).map((item) => item.deviceCode),
  2432. },
  2433. pagination: { page: 1, pageSize: 100, total: abnormal.length, totalPages: abnormal.length ? 1 : 0, hasMore: false },
  2434. excluded:
  2435. scenario === 'degraded'
  2436. ? { unknownTypeCount: null }
  2437. : { unknownTypeCount: 0, typeKindMismatchCount: 0, nonFormalIndustryCount: 0, invalidCoordinateCount: 1 },
  2438. }
  2439. }
  2440. /** 运行概况 `industryCoverage.statusCounts` 的六态构造(与 `MetricStatusCount` 同序)。 */
  2441. function statusCountsOf(normal, warning, alarm, expired, offline, unknown) {
  2442. return { normal, warning, alarm, expired, offline, unknown }
  2443. }
  2444. // ================================================================ 票 20:总体概览三端点
  2445. /**
  2446. * 概览信封(三个端点同款:`requestId/apiVersion/readOnly/timezone/updatedAt/freshness`)。
  2447. *
  2448. * 与 `infraEnvelope`/`monitoringEnvelope` 同形,但**不带 `displayName`** ——
  2449. * 概览的三个响应类里只有 `warning-settings` 有它,另两个没有(逐类读过)。
  2450. */
  2451. function overviewEnvelope(updatedAt) {
  2452. const status = updatedAt ? 'CURRENT' : 'EMPTY'
  2453. return {
  2454. requestId: 'mock-overview',
  2455. apiVersion: 'v1',
  2456. readOnly: true,
  2457. timezone: 'Asia/Shanghai',
  2458. updatedAt: updatedAt || null,
  2459. freshness: { status, reason: updatedAt ? 'READ_ONLY_AGGREGATE' : 'NO_SNAPSHOT', updatedAt: updatedAt || null, serverUpdatedAt: SNAPSHOT_AT },
  2460. }
  2461. }
  2462. // ================================================================ 票 05–08:总体概览四模块端点
  2463. /**
  2464. * 四模块信封(规格 §0:`requestId/apiVersion/readOnly/timezone/updatedAt/freshness`)。
  2465. *
  2466. * 与旧 `overviewEnvelope` 的区别:**空态要能带固定行**。旧 helper 用 `updatedAt` 有无
  2467. * 推 `EMPTY`,而四模块的空态恰恰是「freshness=EMPTY + 仍回 4 类型/3 片区/4 等级/7 天」——
  2468. * 前端靠它画「无数据 0」(规格 §6.7 的例外)。所以这里显式给 `status/reason`。
  2469. */
  2470. function overviewModulesEnvelope(status, updatedAt = SNAPSHOT_AT, reason = null) {
  2471. return {
  2472. requestId: 'mock-overview-modules',
  2473. apiVersion: 'v1',
  2474. readOnly: true,
  2475. timezone: 'Asia/Shanghai',
  2476. updatedAt: updatedAt || null,
  2477. freshness: { status, reason, updatedAt: updatedAt || null, serverUpdatedAt: SNAPSHOT_AT },
  2478. }
  2479. }
  2480. /**
  2481. * `/overview/perception`(票 05;规格 §2)。
  2482. *
  2483. * success 取**后端实测真值**(10 台 / 在线 3 / 离线 7 / 在线率 0.3000;类型 3·5·0·1;
  2484. * 片区 6·3·1;excluded.unknownTypeCount = 1 是那台悬空类型设备,如实保留)。
  2485. * empty **仍回 4 类型 + 3 片区行(全 0)**:固定卡必须画 0,不是"无数据"。
  2486. */
  2487. function overviewPerceptionPayload(scenario) {
  2488. const types = [
  2489. { code: '1', name: '供水设备', deviceCount: 3, onlineCount: 1, offlineCount: 2, monitoringContent: '压力 / 流量 / 漏失', monitoringContentSource: 'DERIVED' },
  2490. { code: '2', name: '排水设备', deviceCount: 5, onlineCount: 2, offlineCount: 3, monitoringContent: '液位 / 流量 / 内涝', monitoringContentSource: 'DERIVED' },
  2491. { code: '3', name: '燃气设备', deviceCount: 0, onlineCount: 0, offlineCount: 0, monitoringContent: '可燃气体 / 压力', monitoringContentSource: 'FALLBACK' },
  2492. { code: '4', name: '窨井设备', deviceCount: 1, onlineCount: 0, offlineCount: 1, monitoringContent: '井盖状态 / 液位', monitoringContentSource: 'FALLBACK' },
  2493. ]
  2494. const districts = [
  2495. { district: 0, name: '城南', deviceCount: 6 },
  2496. { district: 1, name: '城北', deviceCount: 3 },
  2497. { district: 2, name: '太常', deviceCount: 1 },
  2498. ]
  2499. if (scenario === 'empty') {
  2500. return {
  2501. ...overviewModulesEnvelope('EMPTY', null, 'NO_DEVICES'),
  2502. totals: { deviceCount: 0, onlineCount: 0, offlineCount: 0, noStatusCount: 0, onlineRate: { value: '0', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_DEVICES' } },
  2503. byType: types.map((row) => ({ ...row, deviceCount: 0, onlineCount: 0, offlineCount: 0 })),
  2504. byDistrict: districts.map((row) => ({ ...row, deviceCount: 0 })),
  2505. excluded: { unknownTypeCount: 0, unassignedDistrictCount: 0 },
  2506. }
  2507. }
  2508. if (scenario === 'degraded') {
  2509. // 缺监测内容小字与 excluded 段:卡片仍画(计数在),小字不画;界面不伪造小字。
  2510. return {
  2511. ...overviewModulesEnvelope('CURRENT'),
  2512. totals: { deviceCount: 10, onlineCount: 3, offlineCount: 7, noStatusCount: 0, onlineRate: { value: '0.3000', numerator: 3, denominator: 10, state: 'AVAILABLE', reason: null } },
  2513. byType: types.map(({ monitoringContent, monitoringContentSource, ...row }) => row),
  2514. byDistrict: districts,
  2515. }
  2516. }
  2517. return {
  2518. ...overviewModulesEnvelope('CURRENT'),
  2519. totals: { deviceCount: 10, onlineCount: 3, offlineCount: 7, noStatusCount: 0, onlineRate: { value: '0.3000', numerator: 3, denominator: 10, state: 'AVAILABLE', reason: null } },
  2520. byType: types,
  2521. byDistrict: districts,
  2522. excluded: { unknownTypeCount: 1, unassignedDistrictCount: 0 },
  2523. }
  2524. }
  2525. /**
  2526. * `/overview/pipe-network`(票 06;规格 §3)。success 用实测真值(6762 条 / 78.55 km)。
  2527. * empty 回 `pipeCount=0` 且固定 3 行 0 —— 面板按空态画占位,**不画假环**。
  2528. */
  2529. function overviewPipeNetworkPayload(scenario) {
  2530. const types = [
  2531. { code: 'WATER_PIPE', name: '供水管道', count: 2489, lengthKm: '19.10', ratio: '0.3681' },
  2532. { code: 'DRAINAGE_PIPE', name: '排水管道', count: 3574, lengthKm: '52.26', ratio: '0.5285' },
  2533. { code: 'GAS_PIPE', name: '燃气管道', count: 699, lengthKm: '7.18', ratio: '0.1034' },
  2534. ]
  2535. if (scenario === 'empty') {
  2536. return {
  2537. ...overviewModulesEnvelope('EMPTY', null, 'NO_PIPES'),
  2538. totals: { pipeCount: 0, lengthKm: '0.00' },
  2539. byPipeType: types.map((row) => ({ ...row, count: 0, lengthKm: '0.00', ratio: '0' })),
  2540. excluded: { unclassifiedCount: 0 },
  2541. state: 'EMPTY',
  2542. reason: 'NO_PIPES',
  2543. }
  2544. }
  2545. return {
  2546. ...overviewModulesEnvelope('CURRENT'),
  2547. totals: { pipeCount: 6762, lengthKm: '78.55' },
  2548. byPipeType: types,
  2549. excluded: { unclassifiedCount: 0 },
  2550. state: 'AVAILABLE',
  2551. reason: null,
  2552. }
  2553. }
  2554. /**
  2555. * `/overview/warning-study`(票 07;规格 §4)。
  2556. *
  2557. * success:今日 5 / 未解除 18(PENDING 11 / PROCESSING 5 / HANDLED 2),
  2558. * 分级 9·6·2·1(**和 = 18** —— 规格示例曾把全量 42 的分级抄进来,是错的)。
  2559. * 类型排行的 `code` 取本地事件族同值,使「点击筛选」在 mock 上真的命中;
  2560. * 真实后端返回 `WARN-*` 码,前端按精确 code 匹配,不匹配时如实提示覆盖范围(G11)。
  2561. */
  2562. function overviewWarningStudyPayload(scenario) {
  2563. const levels = [
  2564. { level: 1, name: '一级预警', count: 9 },
  2565. { level: 2, name: '二级预警', count: 6 },
  2566. { level: 3, name: '三级预警', count: 2 },
  2567. { level: 4, name: '四级预警', count: 1 },
  2568. ]
  2569. if (scenario === 'empty') {
  2570. return {
  2571. ...overviewModulesEnvelope('EMPTY', null, 'NO_WARNINGS'),
  2572. today: { newCount: 0, yesterdayCount: 0, delta: 0 },
  2573. unresolved: { count: 0, statuses: { PENDING: 0, RECEIVED: 0, PROCESSING: 0, HANDLED: 0 } },
  2574. byLevel: levels.map((row) => ({ ...row, count: 0 })),
  2575. typeRanking: { rangeDays: 30, total: 0, top: [] },
  2576. }
  2577. }
  2578. if (scenario === 'degraded') {
  2579. // 缺排行段:等级卡照画,排行整块不画(不伪造榜单)。
  2580. return {
  2581. ...overviewModulesEnvelope('CURRENT'),
  2582. today: { newCount: 5, yesterdayCount: 0, delta: 5 },
  2583. unresolved: { count: 18, statuses: { PENDING: 11, RECEIVED: 0, PROCESSING: 5, HANDLED: 2 } },
  2584. byLevel: levels,
  2585. }
  2586. }
  2587. return {
  2588. ...overviewModulesEnvelope('CURRENT'),
  2589. today: { newCount: 5, yesterdayCount: 0, delta: 5 },
  2590. unresolved: { count: 18, statuses: { PENDING: 11, RECEIVED: 0, PROCESSING: 5, HANDLED: 2 } },
  2591. byLevel: levels,
  2592. // 排行**列出全部类型**且与 total 对得上(维护者 2026-10-26:页面写「共27起」而三行只凑出 23 起)。
  2593. // 下面 count 之和刻意等于 total(42) —— mock 也要自洽,否则走查看不出「对不上」这类问题。
  2594. typeRanking: {
  2595. rangeDays: 30,
  2596. total: 42,
  2597. top: [
  2598. { code: 'GAS_CONCENTRATION', name: '燃气浓度预警', count: 14 },
  2599. { code: 'WATER_PRESSURE', name: '供水压力预警', count: 9 },
  2600. { code: 'SEWAGE_CONCENTRATION', name: '污水 COD 预警', count: 6 },
  2601. { code: 'WATER_FLOW', name: '供水流量预警', count: 5 },
  2602. { code: 'MANHOLE_COVER', name: '井盖状态预警', count: 4 },
  2603. { code: 'SEWAGE_LEVEL', name: '排水水位预警', count: 4 },
  2604. ],
  2605. },
  2606. }
  2607. }
  2608. /**
  2609. * `/overview/alarm-disposal`(票 08;规格 §5)。success 用**受控样本**(后端实测:
  2610. * 已转 2 / 未转 0、处置率 1/2、平均响应 30.5h、平均处置 219.2h、等级 4 级 2 条)。
  2611. *
  2612. * **7 日折线全 0 是真实形态,不是样例偷懒**(维护者 2026-10-23 反馈第 2 条复核):
  2613. * 库里那 2 条报警的 create_time 是 2026-10-12/13,而窗口是「今天及前 6 天」;
  2614. * 实测全库 90 条报警只分布在 10-10 / 10-12 / 10-16 三天,窗口内三种口径(audit_status、
  2615. * alarm_status、不过滤)都是 0 条。所以「已转 2 + 四级 2」与「折线 0」是**同时成立**的:
  2616. * 上方读数是全量、折线是窗口内新增。面板用 `trendCaliberNote()` 把这层口径写明。
  2617. *
  2618. * empty:固定 4 等级 0 行 + 7 天全 0 点,比例/时长 EMPTY(界面显示占位符,不是 0%)。
  2619. */
  2620. function overviewAlarmDisposalPayload(scenario) {
  2621. const levels = [
  2622. { level: 1, name: '一级报警', count: 0 },
  2623. { level: 2, name: '二级报警', count: 0 },
  2624. { level: 3, name: '三级报警', count: 0 },
  2625. { level: 4, name: '四级报警', count: 2 },
  2626. ]
  2627. const dates = ['2026-10-16', '2026-10-17', '2026-10-18', '2026-10-19', '2026-10-20', '2026-10-21', '2026-10-22']
  2628. // 每天两个读数(后端新字段):count = 当天新增;unhandledTotal = 截至当天未处理的累计。
  2629. // 样例取真实库形态:窗口内当天新增全 0(库里最新一条报警在 10-16),总未处理恒为 2。
  2630. // 不补 non-zero 的假新增 —— mock 要与真实数据同形,否则走查看不出「为什么折线贴底」。
  2631. const trend = (last = 0) =>
  2632. dates.map((date, index) => ({
  2633. date,
  2634. count: index === dates.length - 1 ? last : 0,
  2635. unhandledTotal: 2,
  2636. }))
  2637. if (scenario === 'empty') {
  2638. return {
  2639. ...overviewModulesEnvelope('EMPTY', null, 'NO_ALARMS'),
  2640. today: { newCount: 0, yesterdayCount: 0, delta: 0 },
  2641. workOrderConversion: { convertedCount: 0, unconvertedCount: 0, totalCount: 0 },
  2642. disposal: {
  2643. rate: { numerator: 0, denominator: 0, value: '0', state: 'EMPTY', reason: 'NO_ALARMS', basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' },
  2644. avgResponseDuration: { value: null, unit: 'HOURS', sampleCount: 0, invalidSampleCount: 0, state: 'EMPTY', reason: 'NO_RESPONSE_LOGS' },
  2645. avgDisposalDuration: { value: null, unit: 'HOURS', sampleCount: 0, invalidSampleCount: 0, state: 'EMPTY', reason: 'NO_DISPOSAL_LOGS' },
  2646. },
  2647. byLevel: levels.map((row) => ({ ...row, count: 0 })),
  2648. sevenDayTrend: trend(0),
  2649. }
  2650. }
  2651. if (scenario === 'degraded') {
  2652. // 缺两个时长段:按"无有效样本"显示占位符,不显示 0 小时。
  2653. return {
  2654. ...overviewModulesEnvelope('CURRENT'),
  2655. today: { newCount: 0, yesterdayCount: 0, delta: 0 },
  2656. workOrderConversion: { convertedCount: 2, unconvertedCount: 0, totalCount: 2 },
  2657. disposal: { rate: { numerator: 1, denominator: 2, value: '0.5000', state: 'AVAILABLE', reason: null, basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' } },
  2658. byLevel: levels,
  2659. sevenDayTrend: trend(0),
  2660. }
  2661. }
  2662. return {
  2663. ...overviewModulesEnvelope('CURRENT'),
  2664. today: { newCount: 0, yesterdayCount: 0, delta: 0 },
  2665. workOrderConversion: { convertedCount: 2, unconvertedCount: 0, totalCount: 2 },
  2666. disposal: {
  2667. rate: { numerator: 1, denominator: 2, value: '0.5000', state: 'AVAILABLE', reason: null, basis: 'CLOSED_WORK_ORDERS_OVER_ALL_ALARMS' },
  2668. avgResponseDuration: { value: '30.5', unit: 'HOURS', sampleCount: 1, invalidSampleCount: 0, state: 'AVAILABLE', reason: null },
  2669. avgDisposalDuration: { value: '219.2', unit: 'HOURS', sampleCount: 1, invalidSampleCount: 0, state: 'AVAILABLE', reason: null },
  2670. },
  2671. byLevel: levels,
  2672. sevenDayTrend: trend(0),
  2673. }
  2674. }
  2675. /**
  2676. * `/overview/infrastructure`(票 20 验收第 2 条)。
  2677. *
  2678. * 注意**没有 `districtDistribution`** —— 这正是本端点的契约现状(区域分布走票 17 的
  2679. * `base-archives`)。样例必须照实,
  2680. * 否则"区域分布不在本端点上"这条判断就失去了证据。
  2681. */
  2682. function overviewInfrastructurePayload(scenario) {
  2683. if (scenario === 'empty') {
  2684. return { ...overviewEnvelope(null), totals: { facilityCount: 0, smartDeviceCount: 0 }, industries: [], excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 } }
  2685. }
  2686. if (scenario === 'degraded') {
  2687. // 缺 `excluded`(可选段)与 `smartDeviceCount`:界面显示占位符,不伪造 0。
  2688. return { ...overviewEnvelope(SNAPSHOT_AT), totals: { facilityCount: 5 }, industries: [{ code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2 }] }
  2689. }
  2690. return {
  2691. ...overviewEnvelope(SNAPSHOT_AT),
  2692. totals: { facilityCount: 5, smartDeviceCount: 9 },
  2693. industries: [
  2694. // 票 38 mock 同构:概览端点 `facilityTypes[].scale` 照抄真实形状(管线行是长度单位串 "km",
  2695. // 其余 null)——不再给 'medium' 这种规模语义值(真实后端从不给,见 t18 审计)。
  2696. { code: 'GAS', key: 'gas', label: '燃气', facilityCount: 2, smartDeviceCount: 4, facilityTypes: [{ code: 'PIPE', name: '燃气管线', count: 2, lengthKm: 12.4, scale: 'km' }], deviceTypes: [] },
  2697. { code: 'WATER_SUPPLY', key: 'water', label: '供水', facilityCount: 2, smartDeviceCount: 3, facilityTypes: [], deviceTypes: [] },
  2698. { code: 'DRAINAGE', key: 'drainage', label: '排水', facilityCount: 1, smartDeviceCount: 2, facilityTypes: [], deviceTypes: [] },
  2699. ],
  2700. excluded: { unknownTypeCount: 1, typeKindMismatchCount: 0 },
  2701. }
  2702. }
  2703. /**
  2704. * `/overview/monitoring`(票 20 验收第 3 条)。
  2705. *
  2706. * **状态桶是四态、没有"故障"**(`online/offline/stale/unknownOrMissing`),
  2707. * 与 `CockpitOverviewMonitoringResponse.Status` 逐字段对齐 ——
  2708. * 样例里若凭空多一个"故障"桶,验收第 3 条的缺口说明就自相矛盾了。
  2709. *
  2710. * `industry` 入参**真的过滤并重算**(否则"筛选真的生效"会退化成只看请求串,票 08/18 的教训)。
  2711. */
  2712. function overviewMonitoringPayload(scenario, query) {
  2713. const industry = (query && query.industry) || ''
  2714. const all = [
  2715. { code: 'GAS', key: 'gas', label: '燃气', devices: 4, points: 3, online: 3, offline: 1, stale: 0, unknownOrMissing: 0, types: [['PIPE', '燃气管线', 4]] },
  2716. { code: 'WATER_SUPPLY', key: 'water', label: '供水', devices: 3, points: 2, online: 2, offline: 0, stale: 1, unknownOrMissing: 0, types: [['PUMP', '水泵', 3]] },
  2717. { code: 'DRAINAGE', key: 'drainage', label: '排水', devices: 2, points: 2, online: 1, offline: 0, stale: 0, unknownOrMissing: 1, types: [['FLOW', '流量计', 2]] },
  2718. ]
  2719. // 认不出的行业码回空数组(后端会 400,mock 不模拟报错,但也不假装有数据)。
  2720. const rows = industry ? all.filter((row) => row.code === industry) : all
  2721. if (scenario === 'empty') {
  2722. return {
  2723. ...overviewEnvelope(null),
  2724. 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' } },
  2725. metricFreshness: { status: 'EMPTY', updatedAt: null, serverUpdatedAt: SNAPSHOT_AT },
  2726. selectedIndustry: null,
  2727. formalScope: { industries: [], keys: [] },
  2728. typeDistribution: [],
  2729. excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 },
  2730. }
  2731. }
  2732. const sum = (key) => rows.reduce((total, row) => total + row[key], 0)
  2733. const devices = sum('devices')
  2734. const online = sum('online')
  2735. const offline = sum('offline')
  2736. const stale = sum('stale')
  2737. const unknownOrMissing = sum('unknownOrMissing')
  2738. // 后端分母是 online+offline+stale(**不含** unknownOrMissing,服务逐行读过)。
  2739. const denominator = online + offline + stale
  2740. const typeDistribution = rows.flatMap((row) =>
  2741. row.types.map(([typeCode, typeName, deviceCount]) => ({ industry: { code: row.code, key: row.key, label: row.label }, deviceType: { code: typeCode, name: typeName }, deviceCount })),
  2742. )
  2743. return {
  2744. ...overviewEnvelope(SNAPSHOT_AT),
  2745. counts: {
  2746. deviceCount: devices,
  2747. monitoringPointCount: sum('points'),
  2748. monitoringFacilityCount: rows.length,
  2749. infrastructureFacilityCount: 5,
  2750. status: { online, offline, stale, unknownOrMissing, denominator },
  2751. onlineRate: {
  2752. // 分母为 0 时后端回 value="0.0000" + state=EMPTY(照实模拟,界面负责不画成 0%)。
  2753. value: denominator === 0 ? '0.0000' : (online / denominator).toFixed(4),
  2754. onlineCount: online,
  2755. denominator,
  2756. state: denominator === 0 ? 'EMPTY' : stale === 0 ? 'CURRENT' : 'STALE',
  2757. reason: denominator === 0 ? 'NO_RESOLVED_DEVICES' : null,
  2758. },
  2759. },
  2760. // **本端点独有**:逐设备指标最近采集时间(票 18 的 I2/BT-8 在这里有值)。
  2761. metricFreshness: { status: 'CURRENT', updatedAt: SNAPSHOT_AT, serverUpdatedAt: SNAPSHOT_AT },
  2762. selectedIndustry: industry ? { code: industry, key: (all.find((row) => row.code === industry) || {}).key || '', label: (all.find((row) => row.code === industry) || {}).label || '' } : null,
  2763. formalScope: { industries: rows.map((row) => row.code), keys: rows.map((row) => row.key) },
  2764. typeDistribution,
  2765. excluded: { unknownTypeCount: 0, typeKindMismatchCount: 0 },
  2766. }
  2767. }
  2768. /**
  2769. * `/overview/warning-settings`(票 20 验收第 1、4 条;票 42 起契约两族分叉)。
  2770. *
  2771. * **一次返回预警与报警两族**(`warning` + `alarm`),所以态势总览与预警设置共用这一次取数。
  2772. * `range` 入参真的过滤趋势桶:不声明时后端维持无界(`CURRENT_BASELINE`),
  2773. * 声明 `LAST_7_DAYS` 时只留窗口内的桶 —— 本票固定声明它(验收第 1 条的"近 7 日")。
  2774. *
  2775. * **票 42(后端 `b047a60d`)**:预警族只给**预警自身生命周期**(状态/等级/行业/片区分布 +
  2776. * 已解除率),工单口径六字段(含 `workOrderStates`)恒 `null` —— mock 与 real 同构,
  2777. * **不用 0 冒充「不适用」**;报警族**保留工单口径**(报警靠工单闭环)并**并存**
  2778. * `alarm_status` 生命周期分布。分布数值在自家总数(8/9)内自洽(`sum(桶)==total`),
  2779. * 桶种类与真实契约一一对应(含 UNKNOWN / UNASSIGNED 这类显式桶)。
  2780. */
  2781. function overviewWarningSettingsPayload(scenario, query) {
  2782. const rangeType = (query && query.range) || 'CURRENT_BASELINE'
  2783. const bounded = rangeType === 'LAST_7_DAYS'
  2784. const blank = scenario === 'empty'
  2785. // 预警族:WarningWorkflow 六态(DRAFT/PENDING/RECEIVED/PROCESSING/HANDLED/CLOSED)。
  2786. const warningStatusDistribution = [
  2787. { code: 'DRAFT', label: '草稿', count: 0 },
  2788. { code: 'PENDING', label: '待办', count: 1 },
  2789. { code: 'RECEIVED', label: '已接收', count: 0 },
  2790. { code: 'PROCESSING', label: '处置中', count: 1 },
  2791. { code: 'HANDLED', label: '已处置', count: 1 },
  2792. { code: 'CLOSED', label: '已解除', count: 5 },
  2793. ]
  2794. const warningLevelDistribution = [
  2795. { code: '1', label: '1级', count: 2 },
  2796. { code: '2', label: '2级', count: 3 },
  2797. { code: '3', label: '3级', count: 2 },
  2798. { code: '4', label: '4级', count: 1 },
  2799. ]
  2800. const warningIndustryDistribution = [
  2801. { code: 'GAS', label: '燃气', count: 3 },
  2802. { code: 'WATER_SUPPLY', label: '供水', count: 2 },
  2803. { code: 'DRAINAGE', label: '排水', count: 2 },
  2804. { code: 'MANHOLE', label: '窨井', count: 0 },
  2805. { code: 'UNKNOWN', label: '未知', count: 1 },
  2806. ]
  2807. const warningDistrictDistribution = [
  2808. { code: '0', label: '城南', count: 3 },
  2809. { code: '1', label: '城北', count: 2 },
  2810. { code: '2', label: '太常', count: 1 },
  2811. { code: 'UNASSIGNED', label: '未分区', count: 2 },
  2812. ]
  2813. // 报警族:工单口径 0–7 全键(缺的态是真实计数 0)+ alarm_status 两态。
  2814. const alarmWorkOrderStates = { 0: 0, 1: 1, 2: 2, 3: 1, 4: 1, 5: 3, 6: 1, 7: 0 }
  2815. const alarmStatusDistribution = [
  2816. { code: 'UNHANDLED', label: '未处理', count: 3 },
  2817. { code: 'HANDLED', label: '已处理', count: 6 },
  2818. ]
  2819. const alarmLevelDistribution = [
  2820. { code: '1', label: '1级', count: 1 },
  2821. { code: '2', label: '2级', count: 2 },
  2822. { code: '3', label: '3级', count: 4 },
  2823. { code: '4', label: '4级', count: 2 },
  2824. ]
  2825. const alarmIndustryDistribution = [
  2826. { code: 'GAS', label: '燃气', count: 2 },
  2827. { code: 'WATER_SUPPLY', label: '供水', count: 3 },
  2828. { code: 'DRAINAGE', label: '排水', count: 3 },
  2829. { code: 'MANHOLE', label: '窨井', count: 1 },
  2830. ]
  2831. const alarmDistrictDistribution = [
  2832. { code: '0', label: '城南', count: 4 },
  2833. { code: '1', label: '城北', count: 2 },
  2834. { code: '2', label: '太常', count: 2 },
  2835. { code: 'UNASSIGNED', label: '未分区', count: 1 },
  2836. ]
  2837. // 空场景:分布仍全铺(缺的态是真实计数 0,不是"没有数据")。
  2838. const zeroed = (rows) => rows.map((row) => ({ ...row, count: 0 }))
  2839. const pick = (rows) => (blank ? zeroed(rows) : rows)
  2840. // 趋势桶:`handled` 的含义按族不同 —— 预警=桶内已解除,报警=关联工单已结案(工单口径)。
  2841. const trendOf = (base) => {
  2842. const trend = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((index) => {
  2843. const total = blank ? 0 : base + (index % 3)
  2844. const handled = blank ? 0 : base
  2845. return {
  2846. bucket: `2026-09-${String(index + 1).padStart(2, '0')}`,
  2847. total,
  2848. handled,
  2849. // 分母为 0 时后端回 "0.0000" + EMPTY(照实模拟;界面不得画成 0.0%)。
  2850. handlingEfficiency: { value: total === 0 ? '0.0000' : (handled / total).toFixed(4), state: total === 0 ? 'EMPTY' : 'AVAILABLE' },
  2851. }
  2852. })
  2853. // 桶是业务时区日期;声明窗口后只留窗口内的桶。
  2854. return bounded ? trend.slice(-7) : trend
  2855. }
  2856. return {
  2857. ...overviewEnvelope(blank ? null : SNAPSHOT_AT),
  2858. displayName: '预警设置',
  2859. // `mode` 不传时后端默认 WARNING(本票不传,因为两族都要)。
  2860. selectedMode: (query && query.mode) || 'WARNING',
  2861. range: { type: rangeType, start: bounded ? '2026-09-23T00:00:00+08:00' : null, end: bounded ? '2026-09-30T00:00:00+08:00' : null },
  2862. warning: {
  2863. eventFamily: 'WARNING',
  2864. state: blank ? 'EMPTY' : 'READY',
  2865. stateReason: blank ? 'NO_EVENTS' : 'WARNINGS_RESOLVED',
  2866. total: blank ? 0 : 8,
  2867. // 预警不走工单流程(维护者 2026-10-14 裁定):工单口径六字段恒 null —— 不适用 ≠ 0。
  2868. handled: null,
  2869. pending: null,
  2870. cancelledCount: null,
  2871. unlinkedEventCount: null,
  2872. invalidWorkOrderStateCount: null,
  2873. // 已解除率=已解除 ÷ 预警总数(预警自身口径;分母 0 → EMPTY/NO_WARNINGS)。
  2874. handlingEfficiency: {
  2875. numerator: blank ? 0 : 5,
  2876. denominator: blank ? 0 : 8,
  2877. value: blank ? '0.0000' : '0.6250',
  2878. state: blank ? 'EMPTY' : 'AVAILABLE',
  2879. reason: blank ? 'NO_WARNINGS' : null,
  2880. },
  2881. workOrderStates: null,
  2882. statusDistribution: pick(warningStatusDistribution),
  2883. levelDistribution: pick(warningLevelDistribution),
  2884. industryDistribution: pick(warningIndustryDistribution),
  2885. districtDistribution: pick(warningDistrictDistribution),
  2886. trend: trendOf(1),
  2887. },
  2888. alarm: {
  2889. eventFamily: 'ALARM',
  2890. state: blank ? 'EMPTY' : 'READY',
  2891. stateReason: blank ? 'NO_EVENTS' : 'EVENTS_RESOLVED',
  2892. total: blank ? 0 : 9,
  2893. // 报警族保留工单口径;`handled` 是 alarm_status 自身生命周期(已处理 6)。
  2894. handled: blank ? 0 : 6,
  2895. pending: blank ? 0 : 6,
  2896. cancelledCount: 0,
  2897. unlinkedEventCount: 0,
  2898. invalidWorkOrderStateCount: 0,
  2899. // 处置率仍是**工单口径**(状态 5 ÷ 状态 0–6 = 3/9;分母 0 → NO_ELIGIBLE_WORK_ORDERS)。
  2900. handlingEfficiency: {
  2901. numerator: blank ? 0 : 3,
  2902. denominator: blank ? 0 : 9,
  2903. value: blank ? '0.0000' : '0.3333',
  2904. state: blank ? 'EMPTY' : 'AVAILABLE',
  2905. reason: blank ? 'NO_ELIGIBLE_WORK_ORDERS' : null,
  2906. },
  2907. workOrderStates: blank ? { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0 } : alarmWorkOrderStates,
  2908. statusDistribution: pick(alarmStatusDistribution),
  2909. levelDistribution: pick(alarmLevelDistribution),
  2910. industryDistribution: pick(alarmIndustryDistribution),
  2911. districtDistribution: pick(alarmDistrictDistribution),
  2912. trend: trendOf(2),
  2913. },
  2914. mapFilter: { eventFamily: (query && query.mode) || 'WARNING', label: (query && query.mode) === 'ALARM' ? '报警' : '预警' },
  2915. }
  2916. }
  2917. // ---------------------------------------------------------------- 注册
  2918. /** 各接口在五类样例下的载荷(`failed` 由处理器抛出,不在这里给载荷)。 */
  2919. const PAYLOADS = {
  2920. // 票 08:地图摘要按**真实分页语义**给(分页窗口横跨监测点与未关联设备、第 2 页起要求快照一致)。
  2921. '/map-snapshot': (s, _requested, context) => mapSnapshotPayload(s, context.path, context.mapSnapshot),
  2922. '/monitoring-points/*': (s, requested) => pointDetailPayload(s, requested),
  2923. '/equipments/*/detail-aggregate': (s, requested) => equipmentAggregate(s, requested),
  2924. '/equipments/*/events/current': (s, requested) => eventRead(s, requested),
  2925. '/equipments/*/events/history': (s, requested) => eventRead(s, requested),
  2926. // 票 13:跨设备当前事件(预警/报警切换 + 当前事件列表;`mode` 必填并校验)。
  2927. // 用 `context.query` 真的读 mode/level/industry/status/page/pageSize ——
  2928. // 否则「切到报警列表真的换内容」这条断言会退化成在看请求串(票 08 的教训)。
  2929. '/events/current': (s, _requested, context) => currentEventsPayload(s, context.query),
  2930. '/events/history-analysis': (s, _requested, context) => historyAnalysisPayload(s, context.query),
  2931. '/events/handling-efficiency': (s, _requested, context) => handlingEfficiencyPayload(s, context.query),
  2932. '/events/multidimensional-analysis': (s, _requested, context) => multidimensionalAnalysisPayload(s, context.query),
  2933. // 票 65:全量工单分页(报警效率面板的「当前工单列表」)。
  2934. // 与下面 `/equipments/*/work-orders` 的区别只在**没有设备/事件维度**:
  2935. // 真实后端 `GET /api/cockpit/v1/work-orders` 就是这个形状(含 pagination 四件套)。
  2936. // 分页在这里真的切窗口 —— mock 若总是回全量,前端的翻页与「共 N 张」口径就永远测不到。
  2937. '/work-orders': (s, _requested, context) => {
  2938. const query = (context && context.query) || {}
  2939. const all = s === 'empty' ? [] : TIMELINE_ORDERS
  2940. const page = Math.max(1, Number(query.page) || 1)
  2941. const pageSize = Math.max(1, Number(query.pageSize) || 10)
  2942. const total = all.length
  2943. const totalPages = Math.max(1, Math.ceil(total / pageSize))
  2944. const safePage = Math.min(page, totalPages)
  2945. return {
  2946. requestId: 'mock-work-order-list',
  2947. apiVersion: 'v1',
  2948. readOnly: true,
  2949. state: total === 0 ? 'EMPTY' : 'READY',
  2950. stateReason: total === 0 ? 'NO_WORK_ORDERS' : 'READY',
  2951. // 字典与设备维度同源(`domain/workOrder.js` 的 `WORK_ORDER_STATES`),不登记第二份。
  2952. stateDictionary: WORK_ORDER_STATES.map((state) => ({ code: state.code, name: state.name })),
  2953. workOrders: all.slice((safePage - 1) * pageSize, safePage * pageSize),
  2954. pagination: { page: safePage, pageSize, total, totalPages },
  2955. }
  2956. },
  2957. '/equipments/*/work-orders': (s) => ({
  2958. requestId: 'mock-work-orders',
  2959. apiVersion: 'v1',
  2960. readOnly: true,
  2961. deviceCode: ARCHIVE.deviceCode,
  2962. eventSource: null,
  2963. eventCode: null,
  2964. state: s === 'empty' ? 'EMPTY' : 'READY',
  2965. stateReason: s === 'empty' ? 'NO_WORK_ORDERS' : 'READY',
  2966. // 票 20:字典**从唯一源派生**(`domain/workOrder.js` 的 `WORK_ORDER_STATES`,
  2967. // 其真理是后端 `WorkOrderStatusEnum` 0–7)。这里原先硬编码了一份 1–7 的副本,
  2968. // 是"同一份业务词表登记两处"的典型 —— 后端一改成 0–7,两份就各说各话。
  2969. stateDictionary: WORK_ORDER_STATES.map((state) => ({ code: state.code, name: state.name })),
  2970. // 票 12 的专用场景给多张工单(每张一条轨迹的形状都不同);票 20 加了「延期中」那张。
  2971. workOrders: s === 'empty' ? [] : s === 'work-order-timeline' ? TIMELINE_ORDERS : [WORK_ORDER],
  2972. }),
  2973. // 票 12:按工单编码回显该工单自己的轨迹(与真接口一致 —— 认不出的编码**抛错**,
  2974. // 而不是回一张空轨迹或永远给同一张工单;那会把"没找到"画成"没有动作")。
  2975. '/work-orders/*/timeline': (s, requested) => {
  2976. if (s === 'empty') return { workOrder: null, timelineEntries: [] }
  2977. const payload = timelinePayload(requested)
  2978. if (!payload) throw new Error(`mock: 工单 ${requested || '(空)'} 不存在`)
  2979. return payload
  2980. },
  2981. '/equipments/*/freshness': (s, requested) => freshnessPayload(s, requested),
  2982. '/equipments/*/archive': (s) => (s === 'empty' ? null : ARCHIVE),
  2983. // 票 17:基础设施模式四块面板的面板级聚合读模型(各块独立成败,互不牵连)。
  2984. '/infrastructure/base-archives': (s, _requested, context) => baseArchivesPayload(s, context.query),
  2985. '/infrastructure/operating-information': (s, _requested, context) => operatingInformationPayload(s, context.query),
  2986. '/infrastructure/management-information': (s, _requested, context) => managementInformationPayload(s, context.query),
  2987. '/infrastructure/emergency-support': (s) => emergencySupportPayload(s),
  2988. // 票 18:运行监测模式三块面板(台账与监测分布**共用** distribution 的这一个响应)。
  2989. // 三个 builder 都吃**请求查询串**:分页/筛选/时间窗口在 mock 上必须真的按请求回,
  2990. // 否则「筛选真的下发了」「翻页真的带快照」这些断言全是假绿(票 08 踩过的同一个坑)。
  2991. '/monitoring/overview': (s, _requested, context) => monitoringOverviewPayload(s, context.query),
  2992. '/monitoring/distribution': (s, _requested, context) =>
  2993. monitoringDistributionPayload(s, context.query, context.monitoring),
  2994. '/monitoring/situation': (s, _requested, context) => monitoringSituationPayload(s, context.query),
  2995. // 票 05–08:总体概览四模块端点(四块各一个,互不牵连)。票 89 起前端会下发
  2996. // district/industry(后端已补齐入参);mock 样例仍是**固定读数、不按条件裁剪** ——
  2997. // 参数是否真的进了 URL 由 t67 闸门注入 fetch 捕获判定,不靠 mock 假装筛过。
  2998. '/overview/perception': (s) => overviewPerceptionPayload(s),
  2999. '/overview/pipe-network': (s) => overviewPipeNetworkPayload(s),
  3000. '/overview/warning-study': (s) => overviewWarningStudyPayload(s),
  3001. '/overview/alarm-disposal': (s) => overviewAlarmDisposalPayload(s),
  3002. // 票 20:总体概览模式三块面板的面板级聚合读模型(各块独立成败)。
  3003. // 三个 builder 都吃**请求查询串**:`/overview/monitoring` 的行业筛选与
  3004. // `/overview/warning-settings` 的窗口必须真的按请求回,否则
  3005. // 「筛选真的生效」「近 7 日窗口真的声明了」这些断言全是假绿(票 08/18 的教训)。
  3006. '/overview/infrastructure': (s) => overviewInfrastructurePayload(s),
  3007. '/overview/monitoring': (s, _requested, context) => overviewMonitoringPayload(s, context.query),
  3008. '/overview/warning-settings': (s, _requested, context) => overviewWarningSettingsPayload(s, context.query),
  3009. // 票 24:设施点位与管线几何读模型(票 30 的真实端点 `/infrastructure/geometry`,免鉴权)。
  3010. '/infrastructure/geometry': (s, _requested, context) => pipeNetworkPayload(s, context.query),
  3011. // 票 68:四类设施点的实体详情(设施点图层补坐标;查询串 `category` 与真接口同形)。
  3012. '/entities/*/detail': (s, requested, context) => facilityEntityDetailPayload(s, requested, context.query),
  3013. }
  3014. /**
  3015. * 管线/设施几何样例(票 24)。
  3016. *
  3017. * **形状照抄票 30 的真实契约**(`GET /api/cockpit/v1/infrastructure/geometry`,实测于 2026-10-12):
  3018. * 标准 GeoJSON `FeatureCollection`——`{type:'FeatureCollection', generatedAt, features, coverage}`;
  3019. * feature 是 `{type:'Feature', id, geometry, properties}`,**没有 `kind` 字段**(按 `geometry.type` 判别);
  3020. * 非法坐标的 feature `geometry` 为 **null**,原始坐标留在 `properties.sourceCoordinates`;
  3021. * `coverage` 是**三态行** `{industry, source, geometryType, state, reason, recordCount, geometryCount, renderableCount}`,
  3022. * `state` ∈ `AVAILABLE`/`EMPTY`/`UNAVAILABLE`,同一行业可有管线/构件两行。
  3023. *
  3024. * 样例内容仍照实,不编"完美数据":
  3025. * · **燃气**给构件 —— 真实 `pointType` 是英文(实测 `valve`),样例两种写法都覆盖;
  3026. * · **供水/排水**只有真实的两点直线段(数据底座就是两点,**不编造弯头与三通**);
  3027. * · **窨井**没有管线,只有点位;
  3028. * · 刻意埋"不得上图":越界 / 零坐标 / 未校验 —— 必须进「无地图位置」清单,
  3029. * 既不被悄悄丢掉,也不被"就近挪一下"救回来(票 07 的坐标纪律)。
  3030. *
  3031. * 坐标取沅陵县城一带(与本仓其它 mock 同一区域)。
  3032. */
  3033. function pipeNetworkPayload(s, query) {
  3034. const valid = { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' }
  3035. const invalid = (reason) => ({ state: 'INVALID', reason, renderable: false, crs: 'WGS84' })
  3036. const unverified = { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, crs: 'WGS84' }
  3037. const industry = String((query && query.industry) || '').trim()
  3038. const wanted = industry ? industry.toUpperCase() : ''
  3039. const keep = (code) => !wanted || wanted === code
  3040. const pipeRows = [
  3041. // 燃气:三点样例演示"样条管道"(真实库里多为两点,这里刻意给一条三点以覆盖多段路径)
  3042. ['GAS-P-001', '燃气主干管东段', 'GAS', 'PE', 400, [[110.3900, 28.4430], [110.3960, 28.4455], [110.4020, 28.4440]]],
  3043. ['GAS-P-002', '燃气支管南段', 'GAS', '钢', 200, [[110.3900, 28.4430], [110.3862, 28.4380]]],
  3044. ['GAS-P-003', '燃气主干管西段', 'GAS', 'PE', 300, [[110.3862, 28.4380], [110.3790, 28.4392]]],
  3045. // 供水/排水:真实只有两点直线段
  3046. ['WS-P-001', '供水输水干管', 'WATER_SUPPLY', '球铁', 600, [[110.3950, 28.4500], [110.4050, 28.4520]]],
  3047. ['WS-P-002', '供水配水支管', 'WATER_SUPPLY', 'PE', 150, [[110.4050, 28.4520], [110.4090, 28.4560]]],
  3048. ['DR-P-001', '排水主干管', 'DRAINAGE', '混凝土', 800, [[110.3880, 28.4400], [110.3800, 28.4420]]],
  3049. ].filter((row) => keep(row[2]))
  3050. const componentRows = [
  3051. // 真实 pointType 是英文(实测 valve);中文词表也覆盖,两种都不得漏
  3052. ['GAS-C-001', 'valve', '阀门', 'GAS', 'GAS-P-001', 110.3960, 28.4455],
  3053. ['GAS-C-002', 'elbow', '弯头', 'GAS', 'GAS-P-001', 110.4020, 28.4440],
  3054. ['GAS-C-003', 'tee', '三通', 'GAS', 'GAS-P-002', 110.3862, 28.4380],
  3055. ['GAS-C-004', 'regulator', '调压', 'GAS', 'GAS-P-003', 110.3790, 28.4392],
  3056. ].filter((row) => keep(row[3]))
  3057. const features = [
  3058. ...pipeRows.map(([code, name, ind, material, diameter, coordinates]) => ({
  3059. type: 'Feature',
  3060. id: `pipe_network_base:${code}`,
  3061. geometry: { type: 'LineString', coordinates },
  3062. properties: {
  3063. source: 'pipe_network_base', sourceId: code, code, name, industry: ind, typeCode: 'PIPE',
  3064. networkType: ind === 'GAS' ? '0' : ind === 'DRAINAGE' ? '1' : '2',
  3065. pointType: null, networkId: code, material, diameter, length: 120,
  3066. sourceCoordinates: coordinates, coordinateValidation: valid, coordinateValidations: [valid, valid],
  3067. },
  3068. })),
  3069. ...componentRows.map(([code, pointType, label, ind, networkId, longitude, latitude]) => ({
  3070. type: 'Feature',
  3071. id: `gas_pipe_point:${code}`,
  3072. geometry: { type: 'Point', coordinates: [longitude, latitude] },
  3073. properties: {
  3074. source: 'gas_pipe_point', sourceId: code, code, name: label, industry: ind,
  3075. typeCode: 'GAS_PIPE_POINT', networkType: null, pointType, networkId,
  3076. sourceCoordinates: [[longitude, latitude]], coordinateValidation: valid, coordinateValidations: [valid],
  3077. },
  3078. })),
  3079. // 窨井:有坐标的点位设施,没有管线
  3080. {
  3081. type: 'Feature',
  3082. id: 'manhole_base:MH-001',
  3083. geometry: { type: 'Point', coordinates: [110.3920, 28.4460] },
  3084. properties: {
  3085. source: 'manhole_base', sourceId: 'MH-001', code: 'MH-001', name: '窨井001', industry: 'MANHOLE',
  3086. typeCode: 'MANHOLE', networkType: null, pointType: null, networkId: '',
  3087. sourceCoordinates: [[110.3920, 28.4460]], coordinateValidation: valid, coordinateValidations: [valid],
  3088. },
  3089. },
  3090. // ↓ 三条「不得上图」:真实契约里 geometry 为 null,原始坐标留在 sourceCoordinates
  3091. {
  3092. type: 'Feature',
  3093. id: 'pipe_network_base:GAS-P-004',
  3094. geometry: null,
  3095. properties: {
  3096. source: 'pipe_network_base', sourceId: 'GAS-P-004', code: 'GAS-P-004', name: '燃气管(坐标未校验)',
  3097. industry: 'GAS', typeCode: 'PIPE', networkType: '0', pointType: null, networkId: 'GAS-P-004',
  3098. sourceCoordinates: [[110.3700, 28.4300], [110.3740, 28.4320]],
  3099. coordinateValidation: unverified, coordinateValidations: [unverified, unverified],
  3100. },
  3101. },
  3102. {
  3103. type: 'Feature',
  3104. id: 'pipe_network_base:GAS-P-005',
  3105. geometry: null,
  3106. properties: {
  3107. source: 'pipe_network_base', sourceId: 'GAS-P-005', code: 'GAS-P-005', name: '燃气管(零坐标)',
  3108. industry: 'GAS', typeCode: 'PIPE', networkType: '0', pointType: null, networkId: 'GAS-P-005',
  3109. sourceCoordinates: [[0, 0], [0.0010, 0.0010]],
  3110. coordinateValidation: invalid('ZERO_COORDINATE'), coordinateValidations: [invalid('ZERO_COORDINATE')],
  3111. },
  3112. },
  3113. {
  3114. type: 'Feature',
  3115. id: 'gas_pipe_point:GAS-C-005',
  3116. geometry: null,
  3117. properties: {
  3118. source: 'gas_pipe_point', sourceId: 'GAS-C-005', code: 'GAS-C-005', name: '阀门(主场景区外)',
  3119. industry: 'GAS', typeCode: 'GAS_PIPE_POINT', networkType: null, pointType: 'valve', networkId: 'GAS-P-003',
  3120. sourceCoordinates: [[12.0, 12.0]],
  3121. coordinateValidation: invalid('OUTSIDE_PRIMARY_AREA'), coordinateValidations: [invalid('OUTSIDE_PRIMARY_AREA')],
  3122. },
  3123. },
  3124. ]
  3125. // coverage 是**三态行**:同一行业可有管线/构件两行,缺口用 UNAVAILABLE + reason 如实标注。
  3126. const coverage = [
  3127. { industry: 'GAS', source: 'pipe_network_base', geometryType: 'LineString', state: 'AVAILABLE', reason: 'TWO_ENDPOINTS_ONLY', recordCount: 3, geometryCount: 3, renderableCount: 3 },
  3128. { industry: 'WATER_SUPPLY', source: 'pipe_network_base', geometryType: 'LineString', state: 'AVAILABLE', reason: 'TWO_ENDPOINTS_ONLY', recordCount: 2, geometryCount: 2, renderableCount: 2 },
  3129. { industry: 'DRAINAGE', source: 'pipe_network_base', geometryType: 'LineString', state: 'AVAILABLE', reason: 'TWO_ENDPOINTS_ONLY', recordCount: 1, geometryCount: 1, renderableCount: 1 },
  3130. { industry: 'GAS', source: 'gas_pipe_point', geometryType: 'Point', state: 'AVAILABLE', reason: null, recordCount: 4, geometryCount: 4, renderableCount: 4 },
  3131. { industry: 'WATER_SUPPLY', source: null, geometryType: 'Point', state: 'UNAVAILABLE', reason: 'NO_COMPONENT_SOURCE', recordCount: 0, geometryCount: 0, renderableCount: 0 },
  3132. { industry: 'DRAINAGE', source: null, geometryType: 'Point', state: 'UNAVAILABLE', reason: 'NO_TYPED_COMPONENT_SOURCE', recordCount: 0, geometryCount: 0, renderableCount: 0 },
  3133. { industry: 'MANHOLE', source: 'manhole_base', geometryType: 'Point', state: 'AVAILABLE', reason: 'NO_COMPONENT_TYPE', recordCount: 1, geometryCount: 1, renderableCount: 1 },
  3134. { industry: 'MANHOLE', source: null, geometryType: 'LineString', state: 'UNAVAILABLE', reason: 'NO_PIPELINE_SOURCE', recordCount: 0, geometryCount: 0, renderableCount: 0 },
  3135. ]
  3136. return {
  3137. type: 'FeatureCollection',
  3138. generatedAt: '2026-10-12T11:00:00+08:00',
  3139. coverage,
  3140. features: s === 'empty' ? [] : features,
  3141. }
  3142. }
  3143. /**
  3144. * 从请求路径里取出模板 `*` 命中的那一段(即路径参数)。
  3145. * `('/equipments/TC-1/detail-aggregate', '/equipments/' + '*' + '/detail-aggregate')` → `TC-1`。
  3146. * 模板里有多个 `*` 时只取第一个——当前接口最多一个路径参数,够用且不猜。
  3147. */
  3148. function pathParameter(requestPath, template) {
  3149. const star = template.indexOf('*')
  3150. if (star === -1) return ''
  3151. const prefix = template.slice(0, star)
  3152. const suffix = template.slice(star + 1)
  3153. const bare = String(requestPath || '').split('?')[0]
  3154. if (!bare.startsWith(prefix)) return ''
  3155. const rest = bare.slice(prefix.length)
  3156. const end = suffix ? rest.indexOf(suffix) : rest.length
  3157. if (end === -1) return ''
  3158. return decodeURIComponent(rest.slice(0, end))
  3159. }
  3160. /**
  3161. * 注册全部契约化 mock。
  3162. * @param {object} [options]
  3163. * @param {string} [options.scenario] `success`(默认)/`empty`/`degraded`/`stale`/`failed`/`unauthorized`
  3164. * @param {string} [options.onlyPath] 只让这一个路径走「异常样例」,其余保持 `success`
  3165. * —— 票 05 验收第 6 条要的正是「只把**受影响分区**标为数据不可用」,
  3166. * 所以必须能只让一个分区坏掉、其余照常成功。
  3167. * @param {(path: string) => boolean} [options.only] 同上,函数形式(便于按前缀选中一族接口)
  3168. * @param {string} [options.prefix] 同上,按路径前缀选中一族接口(`/equipments/`)
  3169. * @param {object} [options.mapSnapshot] 地图摘要分页的现场覆盖(票 08 走查用):
  3170. * `{ pageSize, snapshotAt, expireAfterPage }`
  3171. * @param {object} handlers services/cockpitApi.js 的三个注册函数(注入以便单测)
  3172. */
  3173. export function registerCockpitMocks(options = {}) {
  3174. const { scenario = 'success', onlyPath = '', prefix = '', handlers } = options
  3175. const mapSnapshot = options.mapSnapshot || {}
  3176. // 票 18:运行监测的现场覆盖({ pageSize }),让台账渐进加载在样例上演示得出来。
  3177. const monitoring = options.monitoring || {}
  3178. // `prefix` 是 `only` 的便捷写法:两者都给时以 `only` 为准。
  3179. const only = options.only || (prefix ? (path) => path.startsWith(prefix) : null)
  3180. const register = (handlers && handlers.registerMockTransport) || null
  3181. if (!register) {
  3182. throw new Error('registerCockpitMocks 需要注入 registerMockTransport')
  3183. }
  3184. const reset = (handlers && handlers.resetMockTransportsForTest) || null
  3185. if (reset) reset()
  3186. const targets = new Set()
  3187. for (const [path, build] of Object.entries(PAYLOADS)) {
  3188. const applies = !onlyPath && !only ? true : only ? !!only(path) : path === onlyPath
  3189. const active = applies ? scenario : 'success'
  3190. register(path, async (context) => {
  3191. // `failed`:抛出让统一出口归类成「不可用」,模拟分区自己读取失败。
  3192. if (active === 'failed') {
  3193. throw new Error(`mock: ${path} 读取失败`)
  3194. }
  3195. if (active === 'unauthorized') {
  3196. // 401 不是载荷:把凭据类失败原因交给统一出口归类,
  3197. // 由它回 `{ok:false, reason:'unauthorized'}`(服务层只认白名单里的原因)。
  3198. const error = new Error('mock: 凭据未通过校验')
  3199. error.reason = REQUEST_REASON.UNAUTHORIZED
  3200. throw error
  3201. }
  3202. // 带路径参数的接口回显请求里的编码:这样「按编码取数」的行为与真接口一致
  3203. // (客户端传错编码时能看出来,而不是永远拿到同一台设备)。
  3204. const requested = pathParameter(context.path, path)
  3205. // 票 18:把**请求的查询串**解析成对象交给 builder —— 分页 / 筛选 / 时间窗口
  3206. // 必须真的按请求回,否则相关断言会变成假绿(票 08 的教训)。
  3207. const payload = build(active, requested, {
  3208. path: context.path,
  3209. mapSnapshot,
  3210. monitoring,
  3211. query: queryParams(context.path),
  3212. })
  3213. if (!requested) return payload
  3214. if (path === '/monitoring-points/*') {
  3215. // 票 41:名称必须按**请求的编码**回,不能永远回默认载荷那一个名字。
  3216. // 之前 MP-001/MP-002 的样例名与默认名恰好一致,所以这个缺口看不出来;
  3217. // 走查点 MP-001 却显示 MP-002 的名字时才暴露(真机走查抓到的真缺陷)。
  3218. // **只回显名称**:状态/原因仍是本样例手工构造的那一份,不在这里改
  3219. // (否则会出现"状态正常、原因却是压力超高"的自相矛盾组合)。
  3220. const known = MAP_POINTS.find((item) => item.pointCode === requested)
  3221. return {
  3222. ...payload,
  3223. monitoringPointCode: requested,
  3224. ...(known ? { monitoringPointName: known.pointName } : {}),
  3225. }
  3226. }
  3227. // 设备族接口:统一把请求的设备编码写回载荷(设备详情/事件/工单/新鲜度/档案都一样)。
  3228. if (typeof payload === 'object' && payload !== null && 'deviceCode' in payload) {
  3229. return { ...payload, deviceCode: requested }
  3230. }
  3231. return payload
  3232. })
  3233. targets.add(path)
  3234. }
  3235. return { scenario, paths: [...targets], isolated: onlyPath || prefix || (only ? 'predicate' : '') }
  3236. }