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