api.js 183 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338
  1. (() => {
  2. "use strict";
  3. const API_BASE = "/api/v1";
  4. const REQUEST_TIMEOUT_MS = 4500;
  5. const DEFAULT_MINE_CODE = "shangwan";
  6. const DEMO_MINE = {
  7. id: "demo-shangwan",
  8. code: DEFAULT_MINE_CODE,
  9. name: "上湾煤矿",
  10. short_name: "上湾煤矿"
  11. };
  12. const DEMO_DASHBOARD = {
  13. mine_name: "上湾煤矿",
  14. kpis: [
  15. { key: "sensors", label: "传感器总数", value: 2486, unit: "台" },
  16. { key: "online_rate", label: "设备在线率", value: 97.2, unit: "%" },
  17. { key: "alarms", label: "未闭环告警", value: 18, unit: "条" },
  18. { key: "high_defects", label: "高风险缺陷", value: 3, unit: "项" },
  19. { key: "score", label: "综合评价得分", value: 92.8, unit: "分" }
  20. ],
  21. diagnosis_summary: { high: 3, medium: 7, low: 8, total: 18 },
  22. evaluation_summary: { score: 92.8, grade: "A", run_no: "EV-20261008-001" },
  23. open_work_order_count: 7
  24. };
  25. const DEMO_DOCUMENTS = [
  26. {
  27. id: "n1202",
  28. type: "PDF",
  29. title: "N1202 工作面通风安全技术措施",
  30. category: "通风专业",
  31. code: "TF-CS-2026-086",
  32. owner: "王强",
  33. pages: "42 页",
  34. size: "2.8 MB",
  35. date: "2026-10-07",
  36. status: "抽取完成",
  37. confidence: 92.4,
  38. summary: "已完成工作面通风方式、需风量、瓦斯管理及局部通风参数等关键字段结构化。",
  39. fields: [
  40. ["工作面名称", "N1202 综采工作面", "98.6%"],
  41. ["通风方式", "U 型通风", "96.8%"],
  42. ["设计需风量", "1,404 m³/min", "94.2%"],
  43. ["瓦斯浓度上限", "0.80%", "97.1%"]
  44. ]
  45. },
  46. {
  47. id: "resistance",
  48. type: "DOC",
  49. title: "2026 年 9 月通风阻力测定报告",
  50. category: "通风专业",
  51. code: "TF-BB-2026-091",
  52. owner: "赵磊",
  53. pages: "28 页",
  54. size: "1.6 MB",
  55. date: "2026-09-30",
  56. status: "抽取完成",
  57. confidence: 90.8,
  58. summary: "已抽取通风阻力测点、巷道参数、风机工况和阻力变化趋势。",
  59. fields: [
  60. ["测定路线", "北翼回风系统", "95.7%"],
  61. ["最大阻力值", "2,860 Pa", "93.1%"],
  62. ["主通风机风量", "7,860 m³/min", "96.4%"],
  63. ["主要异常区段", "北翼回风巷", "89.6%"]
  64. ]
  65. },
  66. {
  67. id: "airlog",
  68. type: "XLS",
  69. title: "矿井测风记录日报表",
  70. category: "监测监控",
  71. code: "TF-BB-2026-092",
  72. owner: "刘伟",
  73. pages: "1,240 行",
  74. size: "856 KB",
  75. date: "2026-10-08",
  76. status: "抽取中 76%",
  77. confidence: 88.5,
  78. summary: "正在按测风站、用风地点和日期执行表格结构识别。",
  79. fields: [
  80. ["日报日期", "2026-10-08", "99.1%"],
  81. ["测风站数量", "18 个", "96.3%"],
  82. ["总进风量", "7,920 m³/min", "94.8%"],
  83. ["异常测点", "S1301 测风站", "87.6%"]
  84. ]
  85. },
  86. {
  87. id: "s1302",
  88. type: "PDF",
  89. title: "S1302 工作面综合防尘设计",
  90. category: "粉尘防治",
  91. code: "TF-SJ-2026-034",
  92. owner: "陈晨",
  93. pages: "36 页",
  94. size: "3.1 MB",
  95. date: "2026-10-05",
  96. status: "等待解析",
  97. confidence: 0,
  98. summary: "资料已入库,等待执行版面解析和防尘设施参数抽取。",
  99. fields: [
  100. ["工作面名称", "S1302 综采工作面", "待抽取"],
  101. ["防尘方式", "煤层注水 + 喷雾降尘", "待抽取"],
  102. ["设计负责人", "陈晨", "已识别"]
  103. ]
  104. },
  105. {
  106. id: "regulation",
  107. type: "PDF",
  108. title: "矿井通风管理制度汇编",
  109. category: "制度管理",
  110. code: "TF-GC-2026-012",
  111. owner: "周洋",
  112. pages: "118 页",
  113. size: "5.4 MB",
  114. date: "2026-09-18",
  115. status: "抽取完成",
  116. confidence: 89.6,
  117. summary: "已完成章节结构识别、制度条款切分和责任岗位抽取。",
  118. fields: [
  119. ["制度版本", "2026 版", "98.2%"],
  120. ["制度章节数", "12 章", "96.7%"],
  121. ["测风管理制度", "第 4 章", "94.3%"],
  122. ["责任岗位数", "28 个", "90.4%"]
  123. ]
  124. },
  125. {
  126. id: "acceptance",
  127. type: "DOC",
  128. title: "南翼通风系统调整验收资料",
  129. category: "通风专业",
  130. code: "TF-YS-2026-021",
  131. owner: "孙敏",
  132. pages: "22 页",
  133. size: "1.2 MB",
  134. date: "2026-09-26",
  135. status: "抽取完成",
  136. confidence: 93.1,
  137. summary: "已抽取调整范围、验收指标、现场测风结果和验收结论。",
  138. fields: [
  139. ["验收对象", "南翼通风系统调整工程", "98.4%"],
  140. ["风量验收结果", "合格", "96.8%"],
  141. ["设施验收数量", "36 项", "94.2%"],
  142. ["验收负责人", "孙敏", "97.1%"]
  143. ]
  144. }
  145. ];
  146. const DEMO_MONITORING = {
  147. overview: {
  148. data_source_count: 12,
  149. online_source_count: 11,
  150. sensor_count: 2486,
  151. online_sensor_count: 2417,
  152. facility_count: 486,
  153. abnormal_facility_count: 3,
  154. open_alarm_count: 18,
  155. high_alarm_count: 3
  156. },
  157. sources: [
  158. { id: "demo-source-gas", name: "安全监控系统", source_type: "security", status: "online", point_count: 1286, latency_ms: 2400 },
  159. { id: "demo-source-video", name: "工业视频监控", source_type: "video", status: "online", point_count: 218, latency_ms: 1800 },
  160. { id: "demo-source-facility", name: "通防设施工况", source_type: "facility", status: "online", point_count: 486, latency_ms: 3200 }
  161. ],
  162. sensors: [
  163. { id: "demo-gas", name: "N1202 瓦斯监测点", sensor_type: "gas", unit: "%CH₄", last_value: 0.86, status: "alarm", warning_threshold: 0.72, alarm_threshold: 0.8 },
  164. { id: "demo-airflow", name: "S1301 测风站", sensor_type: "airflow", unit: "m³/min", last_value: 1286, status: "warning", warning_threshold: 1300, alarm_threshold: 1200 },
  165. { id: "demo-dust", name: "S1302 粉尘监测点", sensor_type: "dust", unit: "mg/m³", last_value: 4.2, status: "warning", warning_threshold: 3.6, alarm_threshold: 4.0 }
  166. ],
  167. facilities: [],
  168. alarms: [
  169. { id: "demo-alarm-1", title: "N1202 回风巷瓦斯浓度超限", level: "high", status: "open", value: 0.86, threshold: 0.8 }
  170. ]
  171. };
  172. const SAFETY_METRIC_DEFS = [
  173. {
  174. key: "gas",
  175. name: "甲烷浓度",
  176. shortName: "甲烷",
  177. sensorType: "gas",
  178. icon: "flame",
  179. unit: "%CH₄",
  180. tone: "red",
  181. baseline: 0.54,
  182. amplitude: 0.08,
  183. noise: 0.025,
  184. warningThreshold: 0.6,
  185. alarmThreshold: 0.8,
  186. thresholdMode: "above",
  187. pointCount: 286,
  188. onlineCount: 284,
  189. recommendations: ["复核 N1202 回风巷局部通风机运行状态", "连续跟踪瓦斯浓度变化并准备撤人断电预案"]
  190. },
  191. {
  192. key: "airflow",
  193. name: "工作面风量",
  194. shortName: "风量",
  195. sensorType: "airflow",
  196. icon: "wind",
  197. unit: "m³/min",
  198. tone: "blue",
  199. baseline: 1398,
  200. amplitude: 34,
  201. noise: 16,
  202. warningThreshold: 1300,
  203. alarmThreshold: 1200,
  204. thresholdMode: "below",
  205. pointCount: 218,
  206. onlineCount: 216,
  207. recommendations: ["核对 S1301 测风站与 N1202 工作面风量", "检查调节风窗开度和漏风状况"]
  208. },
  209. {
  210. key: "co",
  211. name: "一氧化碳",
  212. shortName: "CO",
  213. sensorType: "co",
  214. icon: "activity",
  215. unit: "ppm",
  216. tone: "amber",
  217. baseline: 11.5,
  218. amplitude: 2.2,
  219. noise: 1.1,
  220. warningThreshold: 18,
  221. alarmThreshold: 24,
  222. thresholdMode: "above",
  223. pointCount: 164,
  224. onlineCount: 163,
  225. recommendations: ["检查采空区遗煤氧化与密闭状态", "结合烟雾和温度测点进行联合研判"]
  226. },
  227. {
  228. key: "temperature",
  229. name: "环境温度",
  230. shortName: "温度",
  231. sensorType: "temperature",
  232. icon: "thermometer",
  233. unit: "℃",
  234. tone: "purple",
  235. baseline: 24.6,
  236. amplitude: 1.4,
  237. noise: 0.5,
  238. warningThreshold: 30,
  239. alarmThreshold: 35,
  240. thresholdMode: "above",
  241. pointCount: 192,
  242. onlineCount: 191,
  243. recommendations: ["持续监测回风巷温度变化", "关注高温区域设备散热和风流组织"]
  244. },
  245. {
  246. key: "dust",
  247. name: "粉尘浓度",
  248. shortName: "粉尘",
  249. sensorType: "dust",
  250. icon: "cloud-dust",
  251. unit: "mg/m³",
  252. tone: "teal",
  253. baseline: 3.15,
  254. amplitude: 0.36,
  255. noise: 0.16,
  256. warningThreshold: 3.5,
  257. alarmThreshold: 4,
  258. thresholdMode: "above",
  259. pointCount: 176,
  260. onlineCount: 174,
  261. recommendations: ["检查 S1302 喷雾降尘装置运行状态", "核对采煤机割煤期间的粉尘峰值"]
  262. },
  263. {
  264. key: "air_speed",
  265. name: "巷道风速",
  266. shortName: "风速",
  267. sensorType: "air_speed",
  268. icon: "gauge",
  269. unit: "m/s",
  270. tone: "green",
  271. baseline: 4.15,
  272. amplitude: 0.28,
  273. noise: 0.12,
  274. warningThreshold: 0.25,
  275. alarmThreshold: 0.15,
  276. thresholdMode: "below",
  277. pointCount: 250,
  278. onlineCount: 247,
  279. recommendations: ["复核低风速巷道的风量分配", "检查局部巷道变形和堆积物影响"]
  280. }
  281. ];
  282. const DEMO_KNOWLEDGE = [
  283. {
  284. id: "k-reg-001",
  285. title: "《煤矿安全规程》瓦斯超限处置条款",
  286. knowledge_type: "规程",
  287. category: "规范标准",
  288. summary: "瓦斯浓度超限后应立即停止作业、切断电源、撤出人员,并核查通风系统。",
  289. content: "瓦斯浓度超限后,应当立即停止作业、切断电源、撤出人员,并核查局部通风机、风筒和回风系统状态。处置完成后复测瓦斯浓度,形成闭环记录。"
  290. },
  291. {
  292. id: "k-case-026",
  293. title: "通风设施漏风处置案例",
  294. knowledge_type: "事故案例",
  295. category: "事故案例",
  296. summary: "通过风门密封检查、闭锁装置复核和风量复测完成漏风处置。",
  297. content: "风门漏风排查应重点检查门框密封、闭锁装置、门板变形、墙体裂隙和传感器状态,确认漏风后及时封堵并复测风量。"
  298. },
  299. {
  300. id: "k-expert-021",
  301. title: "局部通风机循环风判定经验",
  302. knowledge_type: "专家经验",
  303. category: "专家经验",
  304. summary: "结合吸风口瓦斯浓度、风筒距离、巷道风量和风流方向综合判定。",
  305. content: "判定局部通风机循环风,应比较吸风口与巷道风流瓦斯浓度,并结合风筒出口距离、巷道风量和风流方向综合判断。"
  306. }
  307. ];
  308. const DEMO_DIAGNOSIS = [
  309. { id: "d1", defect_name: "N1202 回风巷瓦斯浓度超限", defect_code: "R-GAS-001", scene: "灾害防治", severity: "high", confidence: 92.6, status: "processing", conclusion: "瓦斯浓度超过报警阈值。", recommendation: "停止作业并核查局部通风机和回风系统。", occurred_at: "10:38:24" },
  310. { id: "d2", defect_name: "T-17 局部通风机停风", defect_code: "R-LOCAL-001", scene: "局部通风", severity: "high", confidence: 91.2, status: "pending", conclusion: "局部通风机连续失电,存在停风风险。", recommendation: "核查供电回路、风筒连接和人员撤离情况。", occurred_at: "10:32:08" },
  311. { id: "d3", defect_name: "2# 调节风门状态不一致", defect_code: "R-FAC-001", scene: "通风设施", severity: "medium", confidence: 88.7, status: "pending", conclusion: "视频与传感器反馈状态不一致。", recommendation: "安排现场复核风门开闭状态。", occurred_at: "10:28:42" },
  312. { id: "d4", defect_name: "S1301 采煤工作面风量偏小", defect_code: "R-AF-001", scene: "风量分配", severity: "medium", confidence: 89.3, status: "processing", conclusion: "实际风量低于计划风量。", recommendation: "调整风窗开度并复测风量。", occurred_at: "09:56:10" },
  313. { id: "d5", defect_name: "北翼通风阻力较上月上升 6.2%", defect_code: "R-SYS-001", scene: "通风系统管理", severity: "low", confidence: 86.5, status: "closed", conclusion: "巷道阻力呈上升趋势。", recommendation: "排查巷道变形和堆积物。", occurred_at: "08:30:00" }
  314. ];
  315. const DEMO_EVALUATION = {
  316. run: {
  317. run_no: "EV-20261008-001",
  318. total_score: 92.8,
  319. grade: "A",
  320. summary: "安全性、可靠性表现稳定,风量分配均衡度和局部通风管理为当前主要提升方向。",
  321. recommendations: [
  322. "北翼回风阻力专项排查",
  323. "主通风机能效优化",
  324. "S1301 风量复测与分配调整"
  325. ],
  326. created_at: "2026-10-08T10:00:00+08:00"
  327. },
  328. dimensions: [
  329. { name: "安全性", score: 96.2, change: 1.8, note: "瓦斯、火灾、粉尘、通风可靠性" },
  330. { name: "可靠性", score: 91.4, change: 0.6, note: "设备可用率、数据连续性、故障恢复" },
  331. { name: "经济性", score: 84.7, change: -0.9, note: "主通风机能耗、漏风率、运行效率" },
  332. { name: "管理合规", score: 94.1, change: 2.1, note: "资料完整、制度执行、整改及时性" },
  333. { name: "数据质量", score: 89.6, change: 3.4, note: "完整性、及时性、一致性与有效性" }
  334. ]
  335. };
  336. const DEMO_WORK_ORDERS = [
  337. { id: "w1", order_no: "WO-20261008-001", title: "N1202 回风巷瓦斯超限处置", content: "核查局部通风机与回风系统,确认撤人断电措施执行情况。", priority: "high", status: "pending", assignee_name: "王强", due_at: "2026-10-08T12:30:00+08:00", created_at: "2026-10-08T10:38:00+08:00" },
  338. { id: "w2", order_no: "WO-20261008-002", title: "2# 调节风门状态复核", content: "视频判断风门未闭合,传感器反馈正常,需现场确认。", priority: "medium", status: "pending", assignee_name: "赵磊", due_at: "2026-10-08T14:20:00+08:00", created_at: "2026-10-08T10:28:00+08:00" },
  339. { id: "w3", order_no: "WO-20261008-003", title: "T-17 局部通风机异常处置", content: "现场检查供电回路与风筒连接状态。", priority: "high", status: "processing", assignee_name: "刘伟", due_at: "2026-10-08T12:00:00+08:00", created_at: "2026-10-08T10:32:00+08:00" },
  340. { id: "w4", order_no: "WO-20261008-004", title: "S1301 风量复测", content: "调整风窗开度后进行第二次测风。", priority: "medium", status: "processing", assignee_name: "周洋", due_at: "2026-10-08T14:00:00+08:00", created_at: "2026-10-08T09:56:00+08:00" },
  341. { id: "w5", order_no: "WO-20261007-012", title: "主通风机运行曲线复核", content: "完成能效分析,等待通防科负责人确认。", priority: "medium", status: "review", assignee_name: "孙敏", due_at: "2026-10-08T17:00:00+08:00", created_at: "2026-10-07T16:20:00+08:00" },
  342. { id: "w6", order_no: "WO-20261008-006", title: "风门传感器更换", content: "更换后数据恢复,复核记录已归档。", priority: "low", status: "closed", assignee_name: "赵磊", due_at: "2026-10-08T11:00:00+08:00", created_at: "2026-10-08T08:30:00+08:00" }
  343. ];
  344. const EMPTY_DOCUMENTS = {
  345. page: { items: [], total: 0 },
  346. categories: []
  347. };
  348. const DEMO_DOCUMENT_STATISTICS = {
  349. total_documents: 7280,
  350. documents_added_this_month: 126,
  351. extracted_fields: 12406,
  352. field_completeness: 96.2,
  353. extraction_accuracy: 85.7,
  354. accuracy_change: 2.1,
  355. pending_tasks: 18,
  356. estimated_minutes: 36,
  357. verified_fields: 0,
  358. edited_fields: 0,
  359. incorrect_fields: 0,
  360. expected_fields: 0,
  361. valid_fields: 0,
  362. invalid_fields: 0,
  363. missing_fields: 0,
  364. pending_judgements: 0,
  365. accuracy_fields: 0,
  366. storage_used_bytes: 0,
  367. storage_capacity_bytes: 1024 * 1024 * 1024 * 1024,
  368. storage_usage_percent: 0,
  369. updated_at: new Date().toISOString()
  370. };
  371. const FACILITY_CATEGORIES = [
  372. { key: "sensors", name: "传感器", icon: "radio-tower", keywords: ["传感器"], total: 223, alarms: 0, offline: 2 },
  373. { key: "main-fan", name: "主通风机", icon: "wind", keywords: ["主扇", "主通风机"], total: 2, alarms: 1, offline: 0 },
  374. { key: "local-fan", name: "局部通风机", icon: "wind", keywords: ["局扇", "局部通风机"], total: 2, alarms: 0, offline: 0 },
  375. { key: "auto-door", name: "自动风门", icon: "door-open", keywords: ["风门"], total: 5, alarms: 0, offline: 1 },
  376. { key: "auto-window", name: "自动风窗", icon: "settings-2", keywords: ["风窗"], total: 6, alarms: 0, offline: 1 },
  377. { key: "airflow-device", name: "测风装置", icon: "gauge", keywords: ["测风", "风速"], total: 80, alarms: 0, offline: 2 },
  378. { key: "compressor", name: "压风机", icon: "settings", keywords: ["压风"], total: 4, alarms: 0, offline: 0 },
  379. { key: "spray", name: "喷雾", icon: "cloud-dust", keywords: ["喷雾"], total: 22, alarms: 0, offline: 0 },
  380. { key: "dust", name: "喷粉", icon: "cloud-dust", keywords: ["喷粉", "粉尘"], total: 2, alarms: 0, offline: 0 },
  381. { key: "nitrogen", name: "制氮", icon: "cpu", keywords: ["制氮"], total: 4, alarms: 0, offline: 0 },
  382. { key: "grout", name: "制浆", icon: "database", keywords: ["制浆", "注浆"], total: 2, alarms: 0, offline: 0 },
  383. { key: "spray-equipment", name: "喷雾设备", icon: "cloud-dust", keywords: ["雾化", "净化水幕"], total: 12, alarms: 0, offline: 0 },
  384. { key: "gas-drainage", name: "瓦斯抽采泵", icon: "plug-zap", keywords: ["瓦斯抽采", "抽采泵"], total: 2, alarms: 0, offline: 0 }
  385. ];
  386. const VIDEO_CHANNELS = [
  387. { id: "v01", name: "五盘区402辅运大巷风门", group: "风门区域", location: "五盘区402辅运大巷", status: "online" },
  388. { id: "v02", name: "六盘区501胶带运输巷风门", group: "风门区域", location: "六盘区501胶带运输巷", status: "online" },
  389. { id: "v03", name: "七盘区602回风联络巷风门", group: "风门区域", location: "七盘区602回风联络巷", status: "online" },
  390. { id: "v04", name: "中央变电所进风通道风门", group: "风门区域", location: "中央变电所", status: "online" },
  391. { id: "v05", name: "采区水泵房专用通道风门", group: "风门区域", location: "采区水泵房", status: "online" },
  392. { id: "v06", name: "南风井绕道处风门", group: "风门区域", location: "南风井绕道", status: "online" },
  393. { id: "v07", name: "12煤四盘区回风入口风门", group: "风门区域", location: "12煤四盘区", status: "online" },
  394. { id: "v08", name: "22107胶运绕道行车风门", group: "风门区域", location: "22107胶运绕道", status: "offline" },
  395. { id: "v09", name: "1号掘进工作面局部风机-变频器区", group: "局扇区域", location: "1号掘进工作面", status: "online" },
  396. { id: "v10", name: "1号掘进工作面局部风机-配电柜区", group: "局扇区域", location: "1号掘进工作面", status: "online" },
  397. { id: "v11", name: "2号掘进工作面局部风机", group: "局扇区域", location: "2号掘进工作面", status: "online" },
  398. { id: "v12", name: "3号掘进工作面局部风机", group: "局扇区域", location: "3号掘进工作面", status: "online" },
  399. { id: "v13", name: "4号局部风机配电点", group: "局扇区域", location: "4号掘进工作面", status: "online" },
  400. { id: "v14", name: "一号主风机-风机主体", group: "主扇区域", location: "一号主通风机", status: "online" },
  401. { id: "v15", name: "一号主风机-配电室", group: "主扇区域", location: "一号主风机配电室", status: "online" },
  402. { id: "v16", name: "二号主风机-风机主体", group: "主扇区域", location: "二号主通风机", status: "online" },
  403. { id: "v17", name: "主扇配电室", group: "主扇区域", location: "主通风机配电室", status: "online" },
  404. { id: "v18", name: "主扇风硐监测点", group: "主扇区域", location: "主通风机风硐", status: "online" }
  405. ];
  406. const VIDEO_MODULES = {
  407. door: {
  408. defaultChannel: "v01",
  409. title: "风门视频图像综合解析系统",
  410. statusTitle: "风门状态总览",
  411. selector: "全部风门",
  412. typeFilterLabel: "告警类型",
  413. typeFilterOptions: ["全部类型", "AI防夹人预警", "异物卡堵预警", "密封失效故障", "违规停留预警", "风门未关严", "车辆撞击故障"],
  414. scopeFilterLabel: "风门选择",
  415. scopeFilterOptions: ["全部风门", "五盘区402辅运大巷风门", "六盘区501胶带运输巷风门", "七盘区602回风联络巷风门", "中央变电所进风通道风门"],
  416. snapshotTypes: ["车辆通行", "人员通行", "风门未关严"],
  417. summary: [
  418. ["正常风门", 3, "正常"],
  419. ["离线设备", 0, "断开"],
  420. ["设备故障", 1, "异常"],
  421. ["实时预警", 1, "告警"],
  422. ["今日车辆", 28, "辆"],
  423. ["今日人员", 15, "人"]
  424. ],
  425. logs: [
  426. ["11:42:31", "七盘区602回风联络巷风门未关严,门板闭合缝隙15mm,超标10mm", "danger"],
  427. ["11:42:26", "中央变电所进风通道风门违规停留,禁停区有人员逗留", "warning"],
  428. ["11:42:20", "中央变电所进风通道风门密封失效,压力值异常", "warning"],
  429. ["16:30:22", "六盘区501胶带运输巷防夹人预警,危险区域有人闯入", "danger"],
  430. ["16:28:15", "中央变电所进风通道风门未关严,门板闭合缝隙15mm", "warning"]
  431. ],
  432. tabs: ["车辆通行记录", "人员通行记录", "风门未关严记录"],
  433. columns: ["序号", "风门名称", "车牌号", "通行时间", "结果"],
  434. rows: [
  435. [1, "五盘区402辅运大巷风门", "3571", "2026-03-27 16:20:18", "通行成功"],
  436. [2, "六盘区501胶带运输巷风门", "6829", "2026-03-27 16:10:25", "通行成功"],
  437. [3, "七盘区602回风联络巷风门", "1122", "2026-03-27 15:50:33", "通行成功"],
  438. [4, "中央变电所进风通道风门", "3344", "2026-03-27 15:30:41", "通行成功"],
  439. [5, "采区水泵房专用通道风门", "5566", "2026-03-27 15:10:50", "通行成功"]
  440. ]
  441. },
  442. "local-fan": {
  443. defaultChannel: "v09",
  444. title: "局部风机无人值守演示系统",
  445. statusTitle: "风机状态总览",
  446. selector: "全部风机",
  447. typeFilterLabel: "操作类型",
  448. typeFilterOptions: ["全部操作", "风机调频", "风机切换", "参数调整", "复位操作"],
  449. scopeFilterLabel: "风机选择",
  450. scopeFilterOptions: ["全部风机", "1号掘进工作面局部风机", "2号掘进工作面局部风机", "3号掘进工作面局部风机", "4号局部风机配电点"],
  451. snapshotTypes: ["风机调频", "风机切换", "参数调整"],
  452. summary: [
  453. ["风机正常", 4, "正常"],
  454. ["风机离线", 0, "断开"],
  455. ["风机故障", 1, "异常"],
  456. ["实时预警", 3, "预警"],
  457. ["实时人数", 2, "人"],
  458. ["今日进入", 15, "人"]
  459. ],
  460. logs: [
  461. ["10:12:20", "1号风机 风机调频 张三|人员准入匹配", "success"],
  462. ["11:05:10", "2号风机 参数调整 李四|人员准入匹配", "success"],
  463. ["14:30:00", "3号风机 切换主备 未知人员|未检测到人员", "danger"]
  464. ],
  465. tabs: ["人员轨迹", "区域统计", "预警记录"],
  466. columns: ["序号", "风机", "区域", "姓名", "区队", "进入", "离开", "时长"],
  467. rows: [
  468. [1, "1号掘进工作面局部风机", "变频器区", "张三", "机运队", "2026-03-28 08:45", "2026-03-28 08:53", "8分20秒"],
  469. [2, "1号掘进工作面局部风机", "配电柜区", "李四", "维修队", "2026-03-28 09:10", "2026-03-28 09:15", "5分10秒"]
  470. ]
  471. },
  472. "main-fan": {
  473. defaultChannel: "v14",
  474. title: "主通风机无人巡检演示系统",
  475. statusTitle: "主风机运行状态",
  476. selector: "全部设备",
  477. typeFilterLabel: "操作类型",
  478. typeFilterOptions: ["全部操作", "启动风机", "停止风机", "参数调整", "巡检操作"],
  479. scopeFilterLabel: "设备选择",
  480. scopeFilterOptions: ["全部设备", "一号主风机", "二号主风机", "主扇配电室", "主扇风硐监测点"],
  481. snapshotTypes: ["人员检测", "设备操作", "设备预警"],
  482. summary: [
  483. ["运行风机", 1, "运行"],
  484. ["停止风机", 1, "停止"],
  485. ["设备故障", 0, "正常"],
  486. ["预警总数", 2, "预警"],
  487. ["人员检测", 1, "人"],
  488. ["今日记录", 26, "条"]
  489. ],
  490. logs: [
  491. ["08:50:10", "一号主风机,启动风机,PLC操作匹配", "success"],
  492. ["10:20:30", "配电室 参数调整 PLC操作匹配", "success"],
  493. ["14:10:00", "二号主风机 停止风机 未检测到人员", "danger"]
  494. ],
  495. tabs: ["人员检测记录", "设备操作日志", "设备预警记录"],
  496. columns: ["序号", "设备", "位置", "检测时间", "检测结果", "状态"],
  497. rows: [
  498. [1, "一号主风机", "风机主体", "2026-04-02 08:45:20", "检测到人员", "正常"],
  499. [2, "配电室", "设备操作区", "2026-04-02 10:18:10", "检测到人员", "正常"],
  500. [3, "二号主风机", "风机主体", "2026-04-02 14:05:00", "检测到人员", "异常"]
  501. ]
  502. }
  503. };
  504. const state = {
  505. initialized: false,
  506. apiAvailable: false,
  507. mine: DEMO_MINE,
  508. documents: new Map(),
  509. templates: [],
  510. documentPage: 1,
  511. documentPageSize: 5,
  512. documentCategory: "all",
  513. documentKeyword: "",
  514. documentSearchSnippets: new Map(),
  515. documentStatistics: DEMO_DOCUMENT_STATISTICS,
  516. monitoring: null,
  517. facilityMonitoring: null,
  518. safetyMetrics: [],
  519. safetySelectedMetric: "gas",
  520. safetyRange: 24,
  521. safetyReadings: new Map(),
  522. safetyLoaded: false,
  523. videoModule: "door",
  524. videoLayout: 2,
  525. videoRecordTab: 0,
  526. videoSelectedChannel: "v01",
  527. videoSearch: "",
  528. knowledgeCategory: null,
  529. knowledgeCategoryPage: 1,
  530. knowledgeCategoryPageSize: 20,
  531. knowledgeCategoryKeyword: "",
  532. knowledgeCategoryTotal: 0,
  533. judgementPolls: new Set(),
  534. errors: []
  535. };
  536. function escapeHtml(value) {
  537. return String(value ?? "")
  538. .replaceAll("&", "&")
  539. .replaceAll("<", "&lt;")
  540. .replaceAll(">", "&gt;")
  541. .replaceAll('"', "&quot;")
  542. .replaceAll("'", "&#039;");
  543. }
  544. function withMine(path) {
  545. if (!state.apiAvailable || !state.mine?.id) return path;
  546. const separator = path.includes("?") ? "&" : "?";
  547. return `${path}${separator}mine_id=${encodeURIComponent(state.mine.id)}`;
  548. }
  549. async function request(path, options = {}) {
  550. const {
  551. timeoutMs = REQUEST_TIMEOUT_MS,
  552. ...fetchOptions
  553. } = options;
  554. const controller = new AbortController();
  555. const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
  556. const isFormData =
  557. typeof FormData !== "undefined" && fetchOptions.body instanceof FormData;
  558. try {
  559. const response = await fetch(`${API_BASE}${path}`, {
  560. ...fetchOptions,
  561. headers: {
  562. Accept: "application/json",
  563. ...(fetchOptions.body && !isFormData ? { "Content-Type": "application/json" } : {}),
  564. ...(fetchOptions.headers || {})
  565. },
  566. signal: controller.signal
  567. });
  568. if (!response.ok) {
  569. let message = `HTTP ${response.status}`;
  570. let payload = null;
  571. try {
  572. payload = await response.json();
  573. message = payload?.message || payload?.detail || message;
  574. } catch {
  575. // Keep the HTTP status when the response is not JSON.
  576. }
  577. const error = new Error(message);
  578. error.status = response.status;
  579. error.code = payload?.code || "";
  580. throw error;
  581. }
  582. if (response.status === 204) return null;
  583. return await response.json();
  584. } finally {
  585. window.clearTimeout(timeoutId);
  586. }
  587. }
  588. async function withFallback(label, loader, fallback) {
  589. try {
  590. const data = await loader();
  591. if (
  592. data == null ||
  593. (Array.isArray(data) && data.length === 0) ||
  594. (Array.isArray(data?.items) && data.items.length === 0)
  595. ) {
  596. throw new Error("empty response");
  597. }
  598. return { data, source: "api" };
  599. } catch (error) {
  600. state.errors.push(`${label}: ${error?.message || error}`);
  601. return { data: fallback, source: "demo" };
  602. }
  603. }
  604. function apiLoader(label, path, fallback, options = {}) {
  605. return withFallback(label, () => request(withMine(path), options), fallback);
  606. }
  607. function formatNumber(value) {
  608. const number = Number(value);
  609. if (!Number.isFinite(number)) return String(value ?? "--");
  610. return number.toLocaleString("zh-CN", { maximumFractionDigits: 1 });
  611. }
  612. function formatBytes(value) {
  613. const bytes = Number(value);
  614. if (!Number.isFinite(bytes) || bytes < 0) return "--";
  615. if (bytes === 0) return "0 B";
  616. if (bytes < 1024) return `${bytes} B`;
  617. if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
  618. if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
  619. if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
  620. return `${(bytes / 1024 ** 4).toFixed(2)} TB`;
  621. }
  622. function formatDate(value) {
  623. if (!value) return "--";
  624. const date = new Date(value);
  625. if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
  626. return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
  627. }
  628. function formatTime(value) {
  629. if (!value) return "--";
  630. const date = new Date(value);
  631. if (Number.isNaN(date.getTime())) return String(value).slice(11, 16) || "--";
  632. return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
  633. }
  634. function formatDuration(seconds) {
  635. const value = Number(seconds);
  636. if (!Number.isFinite(value) || value < 0) return "--";
  637. if (value < 60) {
  638. return `${value < 10 ? value.toFixed(1) : Math.round(value)} 秒`;
  639. }
  640. const minutes = Math.floor(value / 60);
  641. const remaining = Math.round(value % 60);
  642. return `${minutes} 分 ${remaining} 秒`;
  643. }
  644. function formatTaskStartTime(value) {
  645. if (!value) return "等待中";
  646. const raw = String(value);
  647. const utcValue = /(?:z|[+-]\d{2}:\d{2})$/i.test(raw) ? raw : `${raw}Z`;
  648. const date = new Date(utcValue);
  649. if (Number.isNaN(date.getTime())) return raw.slice(0, 16);
  650. const part = (number) => String(number).padStart(2, "0");
  651. return `${part(date.getMonth() + 1)}-${part(date.getDate())} ${part(date.getHours())}:${part(date.getMinutes())}`;
  652. }
  653. function setText(selector, value) {
  654. const element = document.querySelector(selector);
  655. if (element) element.textContent = value;
  656. }
  657. function setMetric(key, value, decimals = 0) {
  658. const element = document.querySelector(`[data-metric="${key}"]`);
  659. if (!element) return;
  660. if (typeof window.animateNumber === "function" && Number.isFinite(Number(value))) {
  661. window.animateNumber(element, Number(value), decimals);
  662. return;
  663. }
  664. element.textContent = decimals ? Number(value).toFixed(decimals) : formatNumber(value);
  665. }
  666. function kpiMap(kpis) {
  667. return new Map((kpis || []).map((item) => [item.key, item]));
  668. }
  669. function updateConnectionBadge(source) {
  670. const badge = document.querySelector(".topbar .status-pill");
  671. if (!badge) return;
  672. const labels = {
  673. api: "实时运行",
  674. demo: "演示数据",
  675. mixed: "部分演示",
  676. loading: "数据连接中"
  677. };
  678. badge.innerHTML = `<span class="live-dot"></span>${labels[source] || labels.demo}`;
  679. badge.dataset.source = source;
  680. }
  681. function renderDashboard(dashboard, monitoring, evaluation) {
  682. const kpis = kpiMap(dashboard.kpis);
  683. const sensors = kpis.get("sensors")?.value ?? monitoring.overview?.sensor_count ?? 0;
  684. const onlineRate = kpis.get("online_rate")?.value ??
  685. ((monitoring.overview?.online_sensor_count || 0) / Math.max(monitoring.overview?.sensor_count || 1, 1) * 100);
  686. const alarms = kpis.get("alarms")?.value ?? monitoring.overview?.open_alarm_count ?? 0;
  687. const highDefects = kpis.get("high_defects")?.value ?? dashboard.diagnosis_summary?.high ?? 0;
  688. const score = dashboard.evaluation_summary?.score ?? evaluation.run?.total_score ?? 0;
  689. const grade = String(dashboard.evaluation_summary?.grade || evaluation.run?.grade || "A").replace("级", "");
  690. setText("#currentMine", dashboard.mine_name || state.mine?.name || "上湾煤矿");
  691. setMetric("points", sensors);
  692. setMetric("alerts", alarms);
  693. setMetric("grade", grade);
  694. setText(".kpi-card .kpi-foot .text-success", formatNumber(score));
  695. const pointsCard = document.querySelector('[data-metric="points"]')?.closest(".kpi-card");
  696. if (pointsCard) {
  697. pointsCard.querySelector(".kpi-foot span")?.replaceChildren(`在线率 ${Number(onlineRate).toFixed(1)}%`);
  698. }
  699. const alertCard = document.querySelector('[data-metric="alerts"]')?.closest(".kpi-card");
  700. if (alertCard) {
  701. const high = alertCard.querySelector(".kpi-foot .text-danger");
  702. if (high) high.textContent = String(highDefects);
  703. const rate = alertCard.querySelectorAll(".kpi-foot span")[1];
  704. if (rate) rate.textContent = `未闭环 ${dashboard.open_work_order_count ?? 0} 项`;
  705. }
  706. const context = document.querySelector(".context-message strong");
  707. const contextText = document.querySelector(".context-message p");
  708. if (context) context.textContent = Number(highDefects) > 0 ? "系统运行总体平稳" : "系统运行安全稳定";
  709. if (contextText) {
  710. contextText.textContent = `当前识别 ${formatNumber(highDefects)} 项高优先级风险,未闭环任务 ${formatNumber(dashboard.open_work_order_count || 0)} 项。`;
  711. }
  712. const ring = document.querySelector(".dashboard-grid .score-ring");
  713. if (ring) {
  714. ring.style.setProperty("--score", score);
  715. const strong = ring.querySelector("strong");
  716. if (strong) strong.textContent = formatNumber(score);
  717. }
  718. const gradeBadge = document.querySelector(".dashboard-grid .grade-badge");
  719. if (gradeBadge) gradeBadge.textContent = `${grade} 级`;
  720. renderScoreRows(evaluation.dimensions || DEMO_EVALUATION.dimensions);
  721. renderSensorMetrics(monitoring.sensors);
  722. const updateTime = document.querySelector(".map-toolbar > span");
  723. if (updateTime) {
  724. updateTime.innerHTML = `<span class="live-dot"></span>数据更新于 ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}`;
  725. }
  726. }
  727. function renderScoreRows(dimensions) {
  728. const list = document.querySelector(".dashboard-grid .score-list");
  729. if (!list || !dimensions?.length) return;
  730. list.innerHTML = dimensions
  731. .slice(0, 5)
  732. .map((item) => {
  733. const score = Number(item.score || 0);
  734. return `
  735. <div class="score-row">
  736. <span>${escapeHtml(item.name)}</span>
  737. <div><i style="width: ${Math.max(0, Math.min(score, 100))}%"></i></div>
  738. <strong>${score.toFixed(1)}</strong>
  739. </div>
  740. `;
  741. })
  742. .join("");
  743. }
  744. function renderSensorMetrics(sensors) {
  745. const items = sensors?.length ? sensors : DEMO_MONITORING.sensors;
  746. const preferred = ["gas", "airflow", "dust", "temperature", "carbon_monoxide"];
  747. const ordered = preferred
  748. .map((type) => items.find((sensor) => sensor.sensor_type === type))
  749. .filter(Boolean);
  750. if (ordered.length < 3) ordered.push(...items.filter((sensor) => !ordered.includes(sensor)));
  751. document.querySelectorAll(".dashboard-grid .sensor-metric").forEach((metric, index) => {
  752. const sensor = ordered[index];
  753. if (!sensor) return;
  754. const strong = metric.querySelector(".sensor-value strong");
  755. const unit = metric.querySelector(".sensor-value small");
  756. const trend = metric.querySelector(".sensor-value .trend");
  757. if (strong) strong.textContent = formatNumber(sensor.last_value ?? sensor.value ?? "--");
  758. if (unit) unit.textContent = sensor.unit || "";
  759. if (trend) {
  760. trend.textContent = sensor.status === "alarm" ? "超限" : sensor.status === "warning" ? "关注" : "稳定";
  761. trend.classList.toggle("down", sensor.status === "alarm");
  762. trend.classList.toggle("up", sensor.status !== "alarm");
  763. }
  764. });
  765. }
  766. function documentStatus(documentData) {
  767. const parseStatus = documentData.parse_status || documentData.status || "";
  768. if (parseStatus === "completed" || parseStatus.includes("完成")) return { label: "抽取完成", className: "success" };
  769. if (parseStatus === "failed") {
  770. return { label: "处理失败", className: "failed" };
  771. }
  772. if (parseStatus === "parsing") {
  773. return { label: `解析中 ${documentData.parse_progress ?? 0}%`, className: "running" };
  774. }
  775. if (
  776. parseStatus === "processing" ||
  777. parseStatus === "extracting" ||
  778. parseStatus.includes("抽取中")
  779. ) {
  780. return { label: `抽取中 ${documentData.parse_progress ?? 76}%`, className: "running" };
  781. }
  782. return { label: "等待解析", className: "pending" };
  783. }
  784. function fallbackDocumentFor(item) {
  785. return DEMO_DOCUMENTS.find(
  786. (demo) => demo.code && demo.code === item.code || demo.title === item.title
  787. );
  788. }
  789. function normalizeDocumentType(extension) {
  790. const value = String(extension || "").replace(".", "").trim().toUpperCase();
  791. if (value.includes("PDF")) return "PDF";
  792. if (value.includes("DOC")) return "DOC";
  793. if (value.includes("XLS") || value.includes("CSV")) return "XLS";
  794. return value || "FILE";
  795. }
  796. function mapDocument(item) {
  797. const fallback = fallbackDocumentFor(item) || {};
  798. const status = documentStatus(item);
  799. const type = normalizeDocumentType(item.file_ext || fallback.type);
  800. const pageText = item.page_count
  801. ? `${formatNumber(item.page_count)} 页`
  802. : item.row_count
  803. ? `${formatNumber(item.row_count)} 行`
  804. : fallback.pages || (documentStatus(item).label === "抽取完成" ? "未提供页数" : "--");
  805. return {
  806. id: String(item.id),
  807. type,
  808. title: item.title || fallback.title || "未命名资料",
  809. category: item.category || fallback.category || "未分类",
  810. code: item.code || fallback.code || "--",
  811. owner: item.owner_name || fallback.owner || "--",
  812. pages: pageText,
  813. size: formatBytes(item.file_size) !== "--" ? formatBytes(item.file_size) : fallback.size || "--",
  814. date: formatDate(item.effective_date || item.created_at) || fallback.date || "--",
  815. status: status.label,
  816. statusClass: status.className,
  817. parseStatus: item.parse_status || "",
  818. parseProgress: Number(item.parse_progress || 0),
  819. parseError: item.parse_error || "",
  820. confidence: item.metadata?.confidence ?? fallback.confidence,
  821. summary: item.summary || fallback.summary || "",
  822. fields: fallback.fields || [],
  823. extractions: item.extractions || []
  824. };
  825. }
  826. function mapDocumentDetail(item) {
  827. const documentData = mapDocument(item);
  828. if (item.extractions?.length) {
  829. const template = state.templates.find(
  830. (candidate) =>
  831. candidate.code === item.document_type ||
  832. candidate.document_type === item.document_type
  833. );
  834. const templateGroups = template?.fields?.groups || [];
  835. const groupOrder = new Map(
  836. templateGroups.map((group, index) => [group.key, group.order ?? index])
  837. );
  838. const fieldOrder = new Map(
  839. templateGroups.flatMap((group) =>
  840. (group.fields || []).map((field, index) => [field.key, field.order ?? index])
  841. )
  842. );
  843. const grouped = new Map();
  844. item.extractions.forEach((field) => {
  845. const metadata = field.metadata || field.metadata_json || {};
  846. const groupName = metadata.group_name || "其他字段";
  847. const groupKey = metadata.group_key || groupName;
  848. if (groupKey === "compliance_analysis") return;
  849. if (!grouped.has(groupName)) {
  850. grouped.set(groupName, {
  851. key: groupKey,
  852. name: groupName,
  853. order: Number(metadata.group_order ?? groupOrder.get(groupKey) ?? 999),
  854. fields: []
  855. });
  856. }
  857. grouped.get(groupName).fields.push({
  858. id: String(field.id || field.field_key),
  859. label: field.field_name || field.field_key,
  860. value: field.normalized_value || field.field_value || "--",
  861. confidence: field.confidence == null ? "已抽取" : `${Number(field.confidence).toFixed(1)}%`,
  862. edited: Boolean(field.edited),
  863. extractionCorrect: field.extraction_correct !== false,
  864. editReason: field.edit_reason || "",
  865. editJudgementStatus: field.edit_judgement_status || "not_required",
  866. order: Number(metadata.field_order ?? fieldOrder.get(field.field_key) ?? 999)
  867. });
  868. });
  869. documentData.fieldGroups = [...grouped.values()]
  870. .sort((left, right) => left.order - right.order)
  871. .map((group) => ({
  872. ...group,
  873. fields: group.fields
  874. .sort((left, right) => left.order - right.order)
  875. }));
  876. documentData.fields = item.extractions.map((field) => [
  877. field.field_name || field.field_key,
  878. field.normalized_value || field.field_value || "--",
  879. field.confidence == null ? "已抽取" : `${Number(field.confidence).toFixed(1)}%`
  880. ]);
  881. const confidences = item.extractions
  882. .map((field) => Number(field.confidence))
  883. .filter(Number.isFinite);
  884. if (confidences.length) {
  885. documentData.confidence = confidences.reduce((sum, value) => sum + value, 0) / confidences.length;
  886. }
  887. }
  888. return documentData;
  889. }
  890. function currentDocumentFallback() {
  891. const activeId = document.querySelector(".document-row.active")?.dataset.documentId;
  892. return state.documents.get(activeId) || DEMO_DOCUMENTS[0];
  893. }
  894. function renderDocumentRows(items, categories, total) {
  895. const list = document.querySelector("#businessDocumentList");
  896. if (!list) return;
  897. const rows = [...list.querySelectorAll(".document-row")];
  898. const documents = items.map(mapDocument);
  899. state.documents.clear();
  900. documents.forEach((documentData, index) => {
  901. let row = rows[index];
  902. if (!row) {
  903. row = document.createElement("button");
  904. row.className = "document-row";
  905. row.type = "button";
  906. list.appendChild(row);
  907. rows.push(row);
  908. }
  909. row.hidden = false;
  910. documentData.id = String(documentData.id || `document-${index}`);
  911. documentData.searchSnippets =
  912. state.documentSearchSnippets.get(String(documentData.id)) || [];
  913. state.documents.set(documentData.id, documentData);
  914. row.dataset.documentId = documentData.id;
  915. row.dataset.category = documentData.category;
  916. row.innerHTML = `
  917. <span class="document-type ${documentData.type.toLowerCase()}">${escapeHtml(documentData.type)}</span>
  918. <span class="document-main">
  919. <strong>${escapeHtml(documentData.title)}</strong>
  920. <small>编号 ${escapeHtml(documentData.code)} · ${escapeHtml(documentData.pages)} · ${escapeHtml(documentData.owner)}</small>
  921. ${documentData.searchSnippets.length
  922. ? `<small class="document-search-snippet"><b>${escapeHtml(documentData.searchSnippets[0].label)}</b>${escapeHtml(documentData.searchSnippets[0].text)}</small>`
  923. : ""}
  924. </span>
  925. <span class="document-extraction ${documentData.statusClass}"><i></i>${escapeHtml(documentData.status)}</span>
  926. <span class="document-fields"><b>${documentData.fields.length}</b><small>字段</small></span>
  927. <span class="document-actions">
  928. <span class="document-edit" data-edit-document role="button" tabindex="0" aria-label="编辑资料" title="编辑资料">
  929. <span data-icon="pencil"></span>
  930. </span>
  931. <span class="document-delete" data-delete-document role="button" tabindex="0" aria-label="删除资料" title="删除资料">
  932. <span data-icon="trash-2"></span>
  933. </span>
  934. </span>
  935. `;
  936. if (typeof window.injectIcons === "function") window.injectIcons(row);
  937. const editAction = row.querySelector("[data-edit-document]");
  938. editAction?.addEventListener("click", (event) => {
  939. event.stopPropagation();
  940. openBusinessEditModal(documentData.id);
  941. });
  942. editAction?.addEventListener("keydown", (event) => {
  943. if (event.key === "Enter" || event.key === " ") {
  944. event.preventDefault();
  945. event.stopPropagation();
  946. openBusinessEditModal(documentData.id);
  947. }
  948. });
  949. const deleteAction = row.querySelector("[data-delete-document]");
  950. deleteAction?.addEventListener("click", (event) => {
  951. event.stopPropagation();
  952. deleteBusinessDocument(documentData.id, documentData.title);
  953. });
  954. deleteAction?.addEventListener("keydown", (event) => {
  955. if (event.key === "Enter" || event.key === " ") {
  956. event.preventDefault();
  957. event.stopPropagation();
  958. deleteBusinessDocument(documentData.id, documentData.title);
  959. }
  960. });
  961. });
  962. rows.slice(documents.length).forEach((row) => row.remove());
  963. if (!documents.length) {
  964. const panel = document.querySelector("#businessDetailPanel");
  965. if (panel) {
  966. panel.innerHTML = `
  967. <div class="business-document-note">
  968. <span data-icon="file-text"></span>
  969. <p>当前业务资料库暂无资料。</p>
  970. </div>
  971. `;
  972. if (typeof window.injectIcons === "function") window.injectIcons(panel);
  973. }
  974. }
  975. const firstRow = rows[0];
  976. if (documents.length && firstRow) {
  977. rows.forEach((row) => row.classList.toggle("active", row === firstRow));
  978. window.renderBusinessDocument?.(firstRow.dataset.documentId);
  979. }
  980. const filteredTotal = Number(total) || documents.length;
  981. const globalTotal = (categories || []).reduce(
  982. (sum, item) => sum + Number(item.count || 0),
  983. 0
  984. ) || filteredTotal;
  985. renderDocumentCategories(documents, categories, filteredTotal);
  986. renderDocumentTotals(filteredTotal, globalTotal);
  987. renderDocumentPagination(filteredTotal);
  988. }
  989. function renderDocumentCategories(documents, categories, total) {
  990. const buttons = [...document.querySelectorAll("#businessCategoryList button")];
  991. const counts = new Map(
  992. (categories || []).map((item) => [item.category, Number(item.count || 0)])
  993. );
  994. if (!counts.size) {
  995. documents.forEach((documentData) => {
  996. counts.set(documentData.category, (counts.get(documentData.category) || 0) + 1);
  997. });
  998. }
  999. buttons.forEach((button) => {
  1000. const key = button.dataset.docCategory;
  1001. const count = key === "all"
  1002. ? [...counts.values()].reduce((sum, value) => sum + value, 0) || Number(total) || documents.length
  1003. : counts.get(key) || 0;
  1004. const badge = button.querySelector("b");
  1005. if (badge) badge.textContent = formatNumber(count);
  1006. });
  1007. }
  1008. function renderDocumentTotals(filteredTotal, globalTotal) {
  1009. const toolbar = document.querySelector("#view-business-data .toolbar-copy p");
  1010. if (toolbar) toolbar.textContent = `已接入 ${formatNumber(globalTotal)} 份资料,关键字段抽取结果与业务对象已建立关联`;
  1011. const firstKpi = document.querySelector("#view-business-data .business-kpi-card strong");
  1012. if (firstKpi) firstKpi.textContent = formatNumber(globalTotal);
  1013. const footer = document.querySelector("#view-business-data .document-list-footer > span");
  1014. if (footer) footer.textContent = `共 ${formatNumber(filteredTotal)} 份资料`;
  1015. }
  1016. function renderDocumentStatistics(statistics) {
  1017. const data = statistics || DEMO_DOCUMENT_STATISTICS;
  1018. state.documentStatistics = data;
  1019. const setKpi = (key, value) => {
  1020. const element = document.querySelector(`[data-business-kpi="${key}"]`);
  1021. if (element) element.textContent = value;
  1022. };
  1023. const setHint = (key, value) => {
  1024. const element = document.querySelector(`[data-business-kpi-hint="${key}"]`);
  1025. if (element) element.textContent = value;
  1026. };
  1027. setKpi("total", formatNumber(data.total_documents));
  1028. setHint("total", `本月新增 ${formatNumber(data.documents_added_this_month)} 份`);
  1029. setKpi("fields", formatNumber(data.extracted_fields));
  1030. setHint("fields", `字段完整率 ${Number(data.field_completeness || 0).toFixed(1)}%`);
  1031. setKpi("accuracy", `${Number(data.extraction_accuracy || 0).toFixed(1)}%`);
  1032. const accuracyChange = Number(data.accuracy_change || 0);
  1033. const pendingJudgements = Number(data.pending_judgements || 0);
  1034. setHint(
  1035. "accuracy",
  1036. pendingJudgements
  1037. ? `待判定 ${formatNumber(pendingJudgements)} 项`
  1038. : Number(data.edited_fields)
  1039. ? `已编辑 ${formatNumber(data.edited_fields)} 项,抽取错误 ${formatNumber(data.incorrect_fields)} 项`
  1040. : accuracyChange
  1041. ? `较上月${accuracyChange > 0 ? "提升" : "下降"} ${Math.abs(accuracyChange).toFixed(1)}%`
  1042. : "暂无上月对比数据"
  1043. );
  1044. setKpi("pending", formatNumber(data.pending_tasks));
  1045. setHint(
  1046. "pending",
  1047. Number(data.pending_tasks)
  1048. ? `预计 ${formatNumber(data.estimated_minutes)} 分钟完成`
  1049. : "当前无待处理任务"
  1050. );
  1051. const toolbar = document.querySelector("#view-business-data .toolbar-copy p");
  1052. if (toolbar) {
  1053. toolbar.textContent = `已接入 ${formatNumber(data.total_documents)} 份资料,字段抽取平均准确率 ${Number(data.extraction_accuracy || 0).toFixed(1)}%`;
  1054. }
  1055. const storagePercent = Number(data.storage_usage_percent || 0);
  1056. const storagePercentElement = document.querySelector("[data-storage-percent]");
  1057. const storageBar = document.querySelector("[data-storage-bar]");
  1058. const storageUsage = document.querySelector("[data-storage-usage]");
  1059. if (storagePercentElement) {
  1060. storagePercentElement.textContent = `${storagePercent.toFixed(1)}%`;
  1061. }
  1062. if (storageBar) {
  1063. storageBar.style.width = `${Math.max(0, Math.min(storagePercent, 100))}%`;
  1064. }
  1065. if (storageUsage) {
  1066. storageUsage.textContent = `已使用 ${formatBytes(data.storage_used_bytes)} / ${formatBytes(data.storage_capacity_bytes)}`;
  1067. }
  1068. const businessCard = document.querySelector(
  1069. "#view-data [data-open-business]"
  1070. );
  1071. if (businessCard) {
  1072. const status = businessCard.querySelector(".source-status");
  1073. const footValues = businessCard.querySelectorAll(".source-foot span");
  1074. const pendingTasks = Number(data.pending_tasks || 0);
  1075. if (status) {
  1076. status.className = `source-status ${pendingTasks ? "sync" : "online"}`;
  1077. status.textContent = pendingTasks ? "同步中" : "已同步";
  1078. }
  1079. if (footValues[0]) {
  1080. footValues[0].textContent = `${formatNumber(data.total_documents)} 份`;
  1081. }
  1082. if (footValues[1]) {
  1083. footValues[1].textContent = pendingTasks
  1084. ? `待处理 ${formatNumber(pendingTasks)} 项`
  1085. : `已抽取 ${formatNumber(data.extracted_fields)} 字段`;
  1086. }
  1087. }
  1088. }
  1089. function renderDocumentPagination(total) {
  1090. const footer = document.querySelector("#view-business-data .document-list-footer");
  1091. if (!footer) return;
  1092. const pageCount = Math.max(1, Math.ceil((Number(total) || 0) / state.documentPageSize));
  1093. const currentPage = Math.min(Math.max(state.documentPage, 1), pageCount);
  1094. state.documentPage = currentPage;
  1095. const pageButtons = Array.from({ length: pageCount }, (_, index) => {
  1096. const page = index + 1;
  1097. return `<button class="${page === currentPage ? "active" : ""}" type="button" data-document-page="${page}">${page}</button>`;
  1098. }).join("");
  1099. footer.innerHTML = `
  1100. <span>共 ${formatNumber(total)} 份资料</span>
  1101. <div>
  1102. <button type="button" data-document-page="${currentPage - 1}" ${currentPage <= 1 ? "disabled" : ""}>上一页</button>
  1103. ${pageButtons}
  1104. <button type="button" data-document-page="${currentPage + 1}" ${currentPage >= pageCount ? "disabled" : ""}>下一页</button>
  1105. </div>
  1106. `;
  1107. footer.querySelectorAll("[data-document-page]").forEach((button) => {
  1108. button.addEventListener("click", () => {
  1109. const page = Number(button.dataset.documentPage);
  1110. if (!Number.isFinite(page) || page < 1 || page > pageCount || page === currentPage) return;
  1111. refreshDocumentsFromApi(page).catch(() => {
  1112. window.showToast?.("资料分页加载失败", "warning");
  1113. });
  1114. });
  1115. });
  1116. }
  1117. async function hydrateDocumentDetails(items) {
  1118. const results = await Promise.all(
  1119. items.slice(0, 6).map(async (item) => {
  1120. try {
  1121. return await request(`/documents/${encodeURIComponent(item.id)}`);
  1122. } catch {
  1123. return null;
  1124. }
  1125. })
  1126. );
  1127. results.filter(Boolean).forEach((item) => {
  1128. state.documents.set(String(item.id), mapDocumentDetail(item));
  1129. });
  1130. state.documents.forEach((documentData, documentId) => {
  1131. const row = findDocumentRow(documentId);
  1132. const fieldCount = row?.querySelector(".document-fields b");
  1133. if (fieldCount) fieldCount.textContent = String(documentData.fields.length);
  1134. });
  1135. const activeId = document.querySelector(".document-row.active")?.dataset.documentId;
  1136. if (activeId && state.documents.has(String(activeId))) {
  1137. window.renderBusinessDocument?.(activeId);
  1138. }
  1139. }
  1140. async function deleteBusinessDocument(documentId, title) {
  1141. const confirmed = window.confirm(`确定删除“${title || "该资料"}”吗?删除后无法恢复。`);
  1142. if (!confirmed) return;
  1143. try {
  1144. await request(`/documents/${encodeURIComponent(documentId)}`, {
  1145. method: "DELETE"
  1146. });
  1147. state.documents.delete(String(documentId));
  1148. const nextPage = document.querySelectorAll("#businessDocumentList .document-row").length <= 1 && state.documentPage > 1
  1149. ? state.documentPage - 1
  1150. : state.documentPage;
  1151. await refreshDocumentsFromApi(nextPage);
  1152. if (!document.querySelector("#businessDocumentList .document-row")) {
  1153. const panel = document.querySelector("#businessDetailPanel");
  1154. if (panel) {
  1155. panel.innerHTML = `
  1156. <div class="business-document-note">
  1157. <span data-icon="file-text"></span>
  1158. <p>当前业务资料库暂无资料。</p>
  1159. </div>
  1160. `;
  1161. window.injectIcons?.(panel);
  1162. }
  1163. }
  1164. window.showToast?.("资料已删除");
  1165. } catch (error) {
  1166. window.showToast?.(`删除失败:${error?.message || error}`, "warning");
  1167. }
  1168. }
  1169. function openBusinessEditModal(documentId) {
  1170. const documentData = state.documents.get(String(documentId));
  1171. const modal = document.querySelector("#businessEditModal");
  1172. const form = document.querySelector("#businessEditForm");
  1173. if (!documentData || !modal || !form) return;
  1174. form.elements.document_id.value = documentData.id;
  1175. form.elements.title.value = documentData.title;
  1176. form.elements.code.value = documentData.code === "--" ? "" : documentData.code;
  1177. form.elements.category.value = documentData.category;
  1178. modal.hidden = false;
  1179. window.setTimeout(() => form.elements.title.focus(), 0);
  1180. }
  1181. function closeBusinessEditModal() {
  1182. const modal = document.querySelector("#businessEditModal");
  1183. if (modal) modal.hidden = true;
  1184. }
  1185. function installBusinessEdit() {
  1186. const modal = document.querySelector("#businessEditModal");
  1187. const form = document.querySelector("#businessEditForm");
  1188. if (!modal || !form || form.dataset.apiBound) return;
  1189. form.dataset.apiBound = "true";
  1190. modal.querySelectorAll("[data-edit-close]").forEach((button) => {
  1191. button.addEventListener("click", closeBusinessEditModal);
  1192. });
  1193. form.addEventListener("submit", async (event) => {
  1194. event.preventDefault();
  1195. const submit = form.querySelector('[type="submit"]');
  1196. const documentId = form.elements.document_id.value;
  1197. if (!documentId) return;
  1198. if (submit) submit.disabled = true;
  1199. try {
  1200. const previousCategory = state.documents.get(String(documentId))?.category;
  1201. await request(`/documents/${encodeURIComponent(documentId)}`, {
  1202. method: "PATCH",
  1203. body: JSON.stringify({
  1204. title: form.elements.title.value.trim(),
  1205. code: form.elements.code.value.trim() || null,
  1206. category: form.elements.category.value
  1207. })
  1208. });
  1209. closeBusinessEditModal();
  1210. const newCategory = form.elements.category.value;
  1211. if (newCategory !== previousCategory) {
  1212. await setBusinessCategory(newCategory);
  1213. } else {
  1214. await refreshDocumentsFromApi();
  1215. }
  1216. window.showToast?.("资料信息已更新");
  1217. } catch (error) {
  1218. window.showToast?.(`保存失败:${error?.message || error}`, "warning");
  1219. } finally {
  1220. if (submit) submit.disabled = false;
  1221. }
  1222. });
  1223. }
  1224. async function updateBusinessDocumentField(documentId, extractionId, value) {
  1225. const updated = await request(
  1226. `/documents/${encodeURIComponent(documentId)}/extractions/${encodeURIComponent(extractionId)}`,
  1227. {
  1228. method: "PATCH",
  1229. body: JSON.stringify({
  1230. field_value: value,
  1231. normalized_value: value,
  1232. verified: true,
  1233. verified_by: "前端用户"
  1234. })
  1235. }
  1236. );
  1237. const documentData = state.documents.get(String(documentId));
  1238. if (documentData?.fieldGroups) {
  1239. documentData.fieldGroups.forEach((group) => {
  1240. group.fields.forEach((field) => {
  1241. if (String(field.id) === String(extractionId)) {
  1242. field.value = updated.normalized_value || updated.field_value || value;
  1243. field.edited = Boolean(updated.edited);
  1244. field.extractionCorrect = updated.extraction_correct !== false;
  1245. field.editReason = updated.edit_reason || "";
  1246. field.editJudgementStatus = updated.edit_judgement_status || "not_required";
  1247. }
  1248. });
  1249. });
  1250. }
  1251. if (updated.edit_judgement_status === "pending") {
  1252. pollExtractionJudgement(documentId, extractionId).catch(() => {});
  1253. }
  1254. return updated;
  1255. }
  1256. async function pollExtractionJudgement(documentId, extractionId) {
  1257. const key = `${documentId}:${extractionId}`;
  1258. if (state.judgementPolls.has(key)) return;
  1259. state.judgementPolls.add(key);
  1260. try {
  1261. for (let attempt = 0; attempt < 20; attempt += 1) {
  1262. await new Promise((resolve) =>
  1263. window.setTimeout(resolve, attempt < 3 ? 700 : 1500)
  1264. );
  1265. const detail = await request(`/documents/${encodeURIComponent(documentId)}`);
  1266. state.documents.set(String(documentId), mapDocumentDetail(detail));
  1267. window.renderBusinessDocument?.(String(documentId));
  1268. const field = (detail.extractions || []).find(
  1269. (item) => String(item.id) === String(extractionId)
  1270. );
  1271. if (!field || field.edit_judgement_status !== "pending") {
  1272. const statisticsResult = await loadDocumentStatistics();
  1273. renderDocumentStatistics(statisticsResult.data);
  1274. if (field?.edit_judgement_status === "completed") {
  1275. window.showToast?.(
  1276. field.extraction_correct === false
  1277. ? "字段判定完成:属于抽取错误"
  1278. : "字段判定完成:不属于抽取错误"
  1279. );
  1280. }
  1281. return;
  1282. }
  1283. }
  1284. } finally {
  1285. state.judgementPolls.delete(key);
  1286. }
  1287. }
  1288. async function refreshBusinessDocument(documentId) {
  1289. const [detail, statistics] = await Promise.all([
  1290. request(`/documents/${encodeURIComponent(documentId)}`),
  1291. loadDocumentStatistics()
  1292. ]);
  1293. state.documents.set(String(documentId), mapDocumentDetail(detail));
  1294. renderDocumentStatistics(statistics.data);
  1295. window.renderBusinessDocument?.(String(documentId));
  1296. }
  1297. function setUploadProgress(stage, percent) {
  1298. const progress = document.querySelector("#businessUploadProgress");
  1299. const stageLabel = document.querySelector("#businessUploadStage");
  1300. const percentLabel = document.querySelector("#businessUploadPercent");
  1301. const bar = document.querySelector("#businessUploadProgressBar");
  1302. if (progress) progress.hidden = false;
  1303. if (stageLabel) stageLabel.textContent = stage;
  1304. if (percentLabel) percentLabel.textContent = `${Math.max(0, Math.min(percent, 100))}%`;
  1305. if (bar) bar.style.width = `${Math.max(0, Math.min(percent, 100))}%`;
  1306. }
  1307. function resetUploadProgress() {
  1308. const progress = document.querySelector("#businessUploadProgress");
  1309. const stageLabel = document.querySelector("#businessUploadStage");
  1310. const percentLabel = document.querySelector("#businessUploadPercent");
  1311. const bar = document.querySelector("#businessUploadProgressBar");
  1312. if (progress) progress.hidden = true;
  1313. if (stageLabel) stageLabel.textContent = "正在上传";
  1314. if (percentLabel) percentLabel.textContent = "0%";
  1315. if (bar) bar.style.width = "0%";
  1316. }
  1317. function clearTemplateSelectionError() {
  1318. const select = document.querySelector("#businessTemplateSelect");
  1319. const error = document.querySelector("#businessTemplateError");
  1320. if (select) select.classList.remove("invalid");
  1321. if (error) error.hidden = true;
  1322. }
  1323. function showTemplateSelectionError() {
  1324. const select = document.querySelector("#businessTemplateSelect");
  1325. const error = document.querySelector("#businessTemplateError");
  1326. if (select) {
  1327. select.classList.add("invalid");
  1328. select.focus();
  1329. }
  1330. if (error) error.hidden = false;
  1331. }
  1332. function openUploadModal() {
  1333. const modal = document.querySelector("#businessUploadModal");
  1334. if (!modal) return;
  1335. modal.hidden = false;
  1336. resetUploadProgress();
  1337. clearTemplateSelectionError();
  1338. window.setTimeout(() => modal.querySelector('select[name="category"]')?.focus(), 0);
  1339. }
  1340. function closeUploadModal() {
  1341. const modal = document.querySelector("#businessUploadModal");
  1342. if (modal) modal.hidden = true;
  1343. }
  1344. function resetUploadFileState(form) {
  1345. const fileField = form?.querySelector(".upload-file-field");
  1346. const fileInput = form?.querySelector('input[type="file"]');
  1347. const fileTitle = document.querySelector("#businessFileTitle");
  1348. const fileDetail = document.querySelector("#businessFileDetail");
  1349. if (fileField) fileField.classList.remove("has-file");
  1350. if (fileInput) fileInput.value = "";
  1351. if (fileTitle) fileTitle.textContent = "选择 Word、PDF 或 Excel 文件";
  1352. if (fileDetail) {
  1353. fileDetail.textContent = "支持 .doc .docx .pdf .xls .xlsx .csv,单个文件不超过 50 MB";
  1354. }
  1355. }
  1356. function updateUploadFileState(fileInput) {
  1357. const file = fileInput?.files?.[0];
  1358. const fileField = fileInput?.closest(".upload-file-field");
  1359. const fileTitle = document.querySelector("#businessFileTitle");
  1360. const fileDetail = document.querySelector("#businessFileDetail");
  1361. if (!file) {
  1362. resetUploadFileState(fileInput?.form);
  1363. return;
  1364. }
  1365. fileField?.classList.add("has-file");
  1366. if (fileTitle) fileTitle.textContent = file.name;
  1367. const extension = `.${file.name.split(".").pop()?.toLowerCase() || ""}`;
  1368. const lightweight = fileInput.form?.querySelector('input[name="parse_mode"][value="lightweight"]');
  1369. const precision = fileInput.form?.querySelector('input[name="parse_mode"][value="precision"]');
  1370. if (extension === ".doc" || extension === ".xls") {
  1371. if (lightweight) lightweight.checked = false;
  1372. if (precision) precision.checked = true;
  1373. }
  1374. if (fileDetail) {
  1375. fileDetail.textContent = extension === ".doc" || extension === ".xls"
  1376. ? `已选择 · ${formatBytes(file.size)} · 旧版格式将使用精准解析`
  1377. : `已选择 · ${formatBytes(file.size)} · 点击可重新选择`;
  1378. }
  1379. }
  1380. function findDocumentRow(documentId) {
  1381. return [...document.querySelectorAll("#businessDocumentList .document-row")].find(
  1382. (row) => row.dataset.documentId === String(documentId)
  1383. );
  1384. }
  1385. function updateDocumentProcessingRow(documentId, run) {
  1386. const row = findDocumentRow(documentId);
  1387. if (!row) return;
  1388. const status = documentStatus({
  1389. parse_status: run.stage || run.status,
  1390. parse_progress: run.progress
  1391. });
  1392. const extraction = row.querySelector(".document-extraction");
  1393. if (extraction) {
  1394. extraction.className = `document-extraction ${status.className}`;
  1395. extraction.innerHTML = `<i></i>${escapeHtml(status.label)}`;
  1396. }
  1397. const documentData = state.documents.get(String(documentId));
  1398. if (documentData) {
  1399. documentData.status = status.label;
  1400. documentData.statusClass = status.className;
  1401. documentData.parseStatus = run.stage || run.status;
  1402. documentData.parseProgress = Number(run.progress || 0);
  1403. documentData.parseError = run.error || "";
  1404. }
  1405. if (row.classList.contains("active")) {
  1406. const detailStatus = document.querySelector("#businessDetailPanel .business-detail-status");
  1407. if (detailStatus) {
  1408. detailStatus.className = `business-detail-status ${status.className}`;
  1409. detailStatus.textContent = status.label;
  1410. }
  1411. }
  1412. }
  1413. async function refreshDocumentsFromApi(page = state.documentPage) {
  1414. state.documentPage = page;
  1415. const [result, statisticsResult] = await Promise.all([
  1416. loadDocuments(),
  1417. loadDocumentStatistics()
  1418. ]);
  1419. const documents = result.data.page?.items || [];
  1420. renderDocumentRows(
  1421. documents,
  1422. result.data.categories,
  1423. result.data.page?.total
  1424. );
  1425. renderDocumentStatistics(statisticsResult.data);
  1426. if (result.source === "api" && documents.length) {
  1427. hydrateDocumentDetails(documents).catch(() => {});
  1428. }
  1429. return documents;
  1430. }
  1431. async function setBusinessCategory(category) {
  1432. state.documentCategory = category || "all";
  1433. state.documentPage = 1;
  1434. document.querySelectorAll("#businessCategoryList button").forEach((button) => {
  1435. button.classList.toggle("active", button.dataset.docCategory === state.documentCategory);
  1436. });
  1437. window.dispatchEvent(
  1438. new CustomEvent("vent:business-category-changed", {
  1439. detail: { category: state.documentCategory }
  1440. })
  1441. );
  1442. try {
  1443. await refreshDocumentsFromApi(1);
  1444. } catch {
  1445. window.showToast?.("资料分类加载失败", "warning");
  1446. }
  1447. }
  1448. async function setBusinessSearch(keyword) {
  1449. state.documentKeyword = String(keyword || "").trim();
  1450. state.documentPage = 1;
  1451. try {
  1452. await refreshDocumentsFromApi(1);
  1453. } catch {
  1454. window.showToast?.("全文检索失败", "warning");
  1455. }
  1456. }
  1457. async function getDocumentProcessing(documentId) {
  1458. try {
  1459. return await request(
  1460. `/documents/${encodeURIComponent(documentId)}/processing`
  1461. );
  1462. } catch (error) {
  1463. if (String(error?.message || "").includes("404")) return null;
  1464. throw error;
  1465. }
  1466. }
  1467. async function reprocessBusinessDocument(documentId, options = {}) {
  1468. const run = await request(`/documents/${encodeURIComponent(documentId)}/process`, {
  1469. method: "POST",
  1470. body: JSON.stringify({
  1471. force: true,
  1472. parse_mode: options.parseMode || "lightweight",
  1473. template_id: options.templateId || null
  1474. })
  1475. });
  1476. updateDocumentProcessingRow(documentId, run);
  1477. refreshDocumentProcessingTasks().catch(() => {});
  1478. pollDocumentProcessing(documentId).catch(() => {});
  1479. return run;
  1480. }
  1481. async function pollDocumentProcessing(documentId) {
  1482. for (let attempt = 0; attempt < 1200; attempt += 1) {
  1483. await new Promise((resolve) => window.setTimeout(resolve, attempt < 3 ? 800 : 1500));
  1484. try {
  1485. const run = await request(
  1486. `/documents/${encodeURIComponent(documentId)}/processing`
  1487. );
  1488. updateDocumentProcessingRow(documentId, run);
  1489. setUploadProgress(
  1490. run.stage === "parsing"
  1491. ? "正在解析文档"
  1492. : run.stage === "extracting"
  1493. ? "正在抽取关键信息"
  1494. : run.stage === "completed"
  1495. ? "处理完成"
  1496. : "正在处理",
  1497. run.progress || 0
  1498. );
  1499. if (run.status === "completed") {
  1500. await refreshDocumentsFromApi();
  1501. await refreshDocumentProcessingTasks();
  1502. const row = findDocumentRow(documentId);
  1503. row?.click();
  1504. window.showToast?.(
  1505. `资料解析完成,共提取 ${run.model_output?.field_count || 0} 个字段`
  1506. );
  1507. return run;
  1508. }
  1509. if (run.status === "failed") {
  1510. await refreshDocumentsFromApi();
  1511. await refreshDocumentProcessingTasks();
  1512. window.showToast?.(run.error || "资料解析失败", "warning");
  1513. return run;
  1514. }
  1515. } catch {
  1516. // The upload may still be committing; keep polling briefly.
  1517. }
  1518. }
  1519. window.showToast?.("资料处理时间较长,请稍后刷新查看结果", "warning");
  1520. return null;
  1521. }
  1522. function installBusinessUpload() {
  1523. const modal = document.querySelector("#businessUploadModal");
  1524. const form = document.querySelector("#businessUploadForm");
  1525. if (!modal || !form || form.dataset.apiBound) return;
  1526. form.dataset.apiBound = "true";
  1527. const templateSelect = document.querySelector("#businessTemplateSelect");
  1528. const fileInput = form.querySelector('input[type="file"]');
  1529. if (templateSelect) {
  1530. templateSelect.innerHTML = state.templates.length
  1531. ? '<option value="">自动识别(根据文件名)</option>' + state.templates
  1532. .map(
  1533. (template) =>
  1534. `<option value="${escapeHtml(template.id)}">${escapeHtml(template.name)}</option>`
  1535. )
  1536. .join("")
  1537. : '<option value="">暂无可用抽取模板</option>';
  1538. }
  1539. document.querySelectorAll("[data-open-upload]").forEach((button) => {
  1540. button.addEventListener("click", openUploadModal);
  1541. });
  1542. modal.querySelectorAll("[data-upload-close]").forEach((button) => {
  1543. button.addEventListener("click", () => {
  1544. resetUploadFileState(form);
  1545. clearTemplateSelectionError();
  1546. resetUploadProgress();
  1547. closeUploadModal();
  1548. });
  1549. });
  1550. fileInput?.addEventListener("change", () => {
  1551. clearTemplateSelectionError();
  1552. updateUploadFileState(fileInput);
  1553. });
  1554. templateSelect?.addEventListener("change", clearTemplateSelectionError);
  1555. form.addEventListener("submit", async (event) => {
  1556. event.preventDefault();
  1557. if (!state.apiAvailable) {
  1558. window.showToast?.("后端不可用,暂时无法上传资料", "warning");
  1559. return;
  1560. }
  1561. const submit = form.querySelector('[type="submit"]');
  1562. const formData = new FormData(form);
  1563. formData.set("mine_id", state.mine.id);
  1564. if (submit) submit.disabled = true;
  1565. setUploadProgress("正在上传文件", 3);
  1566. try {
  1567. const documentData = await request("/documents/upload", {
  1568. method: "POST",
  1569. body: formData,
  1570. timeoutMs: 120000
  1571. });
  1572. setUploadProgress("文件已上传,等待解析", 20);
  1573. window.showToast?.("资料上传成功,已进入解析队列");
  1574. clearTemplateSelectionError();
  1575. resetUploadProgress();
  1576. closeUploadModal();
  1577. form.reset();
  1578. resetUploadFileState(form);
  1579. await refreshDocumentsFromApi(1);
  1580. refreshDocumentProcessingTasks().catch(() => {});
  1581. pollDocumentProcessing(documentData.id).catch(() => {});
  1582. } catch (error) {
  1583. resetUploadProgress();
  1584. if (error?.code === "extraction_template_required") {
  1585. showTemplateSelectionError();
  1586. window.showToast?.(
  1587. "文件名未匹配到抽取模板,请手动选择业务资料类型",
  1588. "warning"
  1589. );
  1590. } else {
  1591. window.showToast?.(`上传失败:${error?.message || error}`, "warning");
  1592. }
  1593. } finally {
  1594. if (submit) submit.disabled = false;
  1595. }
  1596. });
  1597. }
  1598. function sourceVisual(source) {
  1599. const type = String(source.source_type || "").toLowerCase();
  1600. if (type.includes("video")) return { icon: "video", className: "blue", description: "视频监控与智能识别" };
  1601. if (type.includes("facility")) return { icon: "settings-2", className: "amber", description: "风门、风窗、密闭和局部通风机" };
  1602. if (type.includes("document")) return { icon: "file-text", className: "purple", description: "规程、措施、报表与台账" };
  1603. return { icon: "database", className: "teal", description: "瓦斯、风速、温度、CO 等监测点" };
  1604. }
  1605. function processingTaskStatus(task) {
  1606. const status = String(task.status || "pending");
  1607. const stage = String(task.stage || status);
  1608. if (status === "completed" || stage === "completed") {
  1609. return { label: "已完成", className: "success" };
  1610. }
  1611. if (status === "failed" || stage === "failed") {
  1612. return { label: "处理失败", className: "failed" };
  1613. }
  1614. if (["running", "parsing", "extracting"].includes(status) || ["parsing", "extracting"].includes(stage)) {
  1615. const stageLabel = stage === "parsing" ? "解析中" : stage === "extracting" ? "抽取中" : "处理中";
  1616. return { label: `${stageLabel} ${Number(task.progress || 0)}%`, className: "running" };
  1617. }
  1618. return { label: "等待中", className: "pending" };
  1619. }
  1620. function processingTaskDescription(task) {
  1621. const finished = task.status === "completed" || task.stage === "completed" ||
  1622. task.status === "failed" || task.stage === "failed";
  1623. const elapsed = Number.isFinite(Number(task.processing_seconds))
  1624. ? ` · ${finished ? "处理耗时" : "已用时"} ${formatDuration(task.processing_seconds)}`
  1625. : "";
  1626. const startText = `开始时间 ${formatTaskStartTime(task.started_at)}`;
  1627. if (task.status === "failed" || task.stage === "failed") {
  1628. return `${task.error || "资料处理失败,等待重试"} · ${startText}${elapsed}`;
  1629. }
  1630. if (task.status === "completed" || task.stage === "completed") {
  1631. const source = task.page_count
  1632. ? `识别 ${formatNumber(task.page_count)} 页`
  1633. : task.row_count
  1634. ? `识别 ${formatNumber(task.row_count)} 行`
  1635. : "识别完成";
  1636. return `${source} · 抽取 ${formatNumber(task.field_count)} 个关键字段 · ${startText}${elapsed}`;
  1637. }
  1638. if (task.stage === "parsing") return `正在解析文档版面与表格结构 · ${startText}${elapsed}`;
  1639. if (task.stage === "extracting") return `正在调用模型抽取关键字段 · ${startText}${elapsed}`;
  1640. return `等待进入解析队列 · ${startText}${elapsed}`;
  1641. }
  1642. function renderDocumentProcessingTasks(
  1643. tasks,
  1644. targetSelector = "#documentProcessingTaskList",
  1645. limit = 4
  1646. ) {
  1647. const list = document.querySelector(targetSelector);
  1648. if (!list) return;
  1649. if (!tasks?.length) {
  1650. list.innerHTML = `
  1651. <div class="business-document-note">
  1652. <span data-icon="${state.apiAvailable ? "check-circle-2" : "circle-alert"}"></span>
  1653. <p>${state.apiAvailable ? "当前没有资料处理任务。" : "处理任务加载失败,请检查后端服务。"}</p>
  1654. </div>
  1655. `;
  1656. window.injectIcons?.(list);
  1657. return;
  1658. }
  1659. list.innerHTML = tasks
  1660. .slice(0, limit)
  1661. .map((task) => {
  1662. const status = processingTaskStatus(task);
  1663. const type = normalizeDocumentType(task.file_ext);
  1664. return `
  1665. <div class="extraction-row">
  1666. <span class="file-type ${type.toLowerCase()}">${escapeHtml(type)}</span>
  1667. <div>
  1668. <strong>《${escapeHtml(task.title)}》</strong>
  1669. <p>${escapeHtml(processingTaskDescription(task))}</p>
  1670. </div>
  1671. <span class="extraction-status ${status.className}">${escapeHtml(status.label)}</span>
  1672. </div>
  1673. `;
  1674. })
  1675. .join("");
  1676. }
  1677. async function refreshDocumentProcessingTasks() {
  1678. try {
  1679. const tasks = await request(withMine("/documents/processing-tasks?limit=4"));
  1680. renderDocumentProcessingTasks(tasks || [], "#documentProcessingTaskList", 4);
  1681. return tasks || [];
  1682. } catch {
  1683. const list = document.querySelector("#documentProcessingTaskList");
  1684. if (list) {
  1685. list.innerHTML = `
  1686. <div class="business-document-note">
  1687. <span data-icon="circle-alert"></span>
  1688. <p>处理任务加载失败,请稍后重试。</p>
  1689. </div>
  1690. `;
  1691. window.injectIcons?.(list);
  1692. }
  1693. return [];
  1694. }
  1695. }
  1696. async function openProcessingRecords() {
  1697. const modal = document.querySelector("#documentProcessingRecordsModal");
  1698. if (!modal) return;
  1699. modal.hidden = false;
  1700. document.body.style.overflow = "hidden";
  1701. const list = document.querySelector("#documentProcessingRecordsList");
  1702. const count = document.querySelector("#documentProcessingRecordsCount");
  1703. if (list) {
  1704. list.innerHTML = `
  1705. <div class="business-document-note">
  1706. <span data-icon="refresh-cw"></span>
  1707. <p>正在加载处理记录。</p>
  1708. </div>
  1709. `;
  1710. window.injectIcons?.(list);
  1711. }
  1712. try {
  1713. const tasks = await request(withMine("/documents/processing-tasks?limit=50"));
  1714. if (count) count.textContent = `${formatNumber((tasks || []).length)} 条记录`;
  1715. renderDocumentProcessingTasks(
  1716. tasks || [],
  1717. "#documentProcessingRecordsList",
  1718. 50
  1719. );
  1720. } catch {
  1721. if (list) {
  1722. list.innerHTML = `
  1723. <div class="business-document-note">
  1724. <span data-icon="circle-alert"></span>
  1725. <p>处理记录加载失败,请稍后重试。</p>
  1726. </div>
  1727. `;
  1728. window.injectIcons?.(list);
  1729. }
  1730. }
  1731. }
  1732. function closeProcessingRecords() {
  1733. const modal = document.querySelector("#documentProcessingRecordsModal");
  1734. if (modal) modal.hidden = true;
  1735. document.body.style.overflow = "";
  1736. }
  1737. function renderMonitoring(monitoring, documents, documentStatistics = null) {
  1738. state.monitoring = monitoring;
  1739. const documentTotal = Number(
  1740. documentStatistics?.total_documents ?? documents.length
  1741. );
  1742. const extractedFieldTotal = Number(
  1743. documentStatistics?.extracted_fields ?? 0
  1744. );
  1745. const overview = monitoring.overview || DEMO_MONITORING.overview;
  1746. const sources = monitoring.sources?.length
  1747. ? monitoring.sources.slice()
  1748. : DEMO_MONITORING.sources.slice();
  1749. const hasFacilitySource = sources.some((source) =>
  1750. String(source.source_type || "").toLowerCase().includes("facility")
  1751. );
  1752. if (!hasFacilitySource) {
  1753. sources.push({
  1754. id: "fallback-facilities",
  1755. name: "通防设施工况",
  1756. source_type: "facility",
  1757. status: "online",
  1758. point_count: overview.facility_count || DEMO_MONITORING.overview.facility_count,
  1759. latency_ms: 2400
  1760. });
  1761. }
  1762. const sourceGrid = document.querySelector("#view-data .source-grid");
  1763. if (sourceGrid) {
  1764. const cards = sources.slice(0, 3).map((source) => {
  1765. const visual = sourceVisual(source);
  1766. const statusText = source.status === "online" ? "在线" : "异常";
  1767. const isVideoPlatform =
  1768. String(source.source_type || "").toLowerCase().includes("video") ||
  1769. String(source.name || "").includes("视频");
  1770. const isFacilityStatus =
  1771. String(source.source_type || "").toLowerCase().includes("facility") ||
  1772. String(source.name || "").includes("通防设施");
  1773. const isSafetyMonitoring =
  1774. String(source.source_type || "").toLowerCase().includes("monitoring") ||
  1775. String(source.source_type || "").toLowerCase().includes("security") ||
  1776. String(source.name || "").includes("安全监控");
  1777. const pointUnit = String(source.source_type || "").toLowerCase().includes("facility")
  1778. ? "台"
  1779. : "点";
  1780. const tag = isVideoPlatform || isFacilityStatus || isSafetyMonitoring ? "button" : "article";
  1781. const attrs = isVideoPlatform
  1782. ? 'type="button" data-video-platform aria-label="打开工业视频平台"'
  1783. : isFacilityStatus
  1784. ? 'type="button" data-facility-status aria-label="打开通防设施工况"'
  1785. : isSafetyMonitoring
  1786. ? 'type="button" data-safety-monitoring aria-label="打开 KJ 安全监控系统"'
  1787. : "";
  1788. const className = isVideoPlatform || isFacilityStatus || isSafetyMonitoring
  1789. ? "source-card source-card-button"
  1790. : "source-card";
  1791. return `
  1792. <${tag} class="${className}" ${attrs}>
  1793. <div class="source-head"><span class="source-icon ${visual.className}" data-icon="${visual.icon}"></span><span class="source-status online">${statusText}</span></div>
  1794. <strong>${escapeHtml(source.name)}</strong>
  1795. <p>${escapeHtml(visual.description)}</p>
  1796. <div class="source-foot"><span>${formatNumber(source.point_count)} ${pointUnit}</span><span>延迟 ${(Number(source.latency_ms || 0) / 1000).toFixed(1)}s</span></div>
  1797. </${tag}>
  1798. `;
  1799. });
  1800. cards.push(`
  1801. <button class="source-card source-card-button" type="button" data-open-business aria-label="打开业务资料页面">
  1802. <div class="source-head"><span class="source-icon purple" data-icon="file-text"></span><span class="source-status online">已同步</span></div>
  1803. <strong>业务资料</strong>
  1804. <p>规程、措施、报表、台账与验收资料</p>
  1805. <div class="source-foot"><span>${formatNumber(documentTotal)} 份</span><span>已抽取 ${formatNumber(extractedFieldTotal)} 字段</span></div>
  1806. </button>
  1807. `);
  1808. sourceGrid.innerHTML = cards.join("");
  1809. if (typeof window.injectIcons === "function") window.injectIcons(sourceGrid);
  1810. sourceGrid.querySelectorAll("[data-video-platform]").forEach((button) => {
  1811. button.addEventListener("click", () => openVideoPlatform());
  1812. });
  1813. sourceGrid.querySelector("[data-safety-monitoring]")?.addEventListener("click", () => openSafetyMonitoring());
  1814. sourceGrid.querySelector("[data-facility-status]")?.addEventListener("click", () => window.navigateTo?.("facility-status"));
  1815. sourceGrid.querySelector("[data-open-business]")?.addEventListener("click", () => window.navigateTo?.("business-data"));
  1816. }
  1817. const toolbar = document.querySelector("#view-data .toolbar-copy p");
  1818. if (toolbar) {
  1819. toolbar.textContent = `当前接入 ${formatNumber(overview.data_source_count)} 类数据源,在线 ${formatNumber(overview.online_source_count)} 类,告警 ${formatNumber(overview.open_alarm_count)} 条`;
  1820. }
  1821. const objectValues = {
  1822. sensors: overview.sensor_count,
  1823. facilities: overview.facility_count
  1824. };
  1825. document.querySelectorAll("#view-data .object-card").forEach((card) => {
  1826. const label = card.querySelector("strong")?.textContent?.trim();
  1827. if (label === "传感器") card.querySelector("b").textContent = formatNumber(objectValues.sensors);
  1828. if (label === "通风设施") card.querySelector("b").textContent = formatNumber(objectValues.facilities);
  1829. });
  1830. }
  1831. function buildFacilityCategories(monitoring) {
  1832. const sensors = monitoring.sensors || [];
  1833. const facilities = monitoring.facilities || [];
  1834. const overview = monitoring.overview || {};
  1835. return FACILITY_CATEGORIES.map((category) => {
  1836. let actualTotal = 0;
  1837. let actualAlarms = 0;
  1838. let actualOffline = 0;
  1839. let records = [];
  1840. if (category.key === "sensors") {
  1841. actualTotal = Number(overview.sensor_count || sensors.length || 0);
  1842. actualAlarms = sensors.filter((item) => item.status === "alarm").length;
  1843. actualOffline = sensors.filter((item) => item.status === "offline").length;
  1844. records = sensors.map((item) => ({
  1845. name: item.name,
  1846. location: item.location || "未配置地点",
  1847. status: item.status || "normal"
  1848. }));
  1849. } else {
  1850. records = facilities
  1851. .filter((facility) =>
  1852. category.keywords.some((keyword) =>
  1853. String(facility.facility_type || "").includes(keyword)
  1854. )
  1855. )
  1856. .map((facility) => ({
  1857. name: facility.name,
  1858. location: facility.location || "未配置地点",
  1859. status: facility.status || "normal"
  1860. }));
  1861. actualTotal = records.length;
  1862. actualAlarms = records.filter((item) => item.status === "warning" || item.status === "alarm").length;
  1863. actualOffline = records.filter((item) => item.status === "offline").length;
  1864. }
  1865. return {
  1866. ...category,
  1867. total: Math.max(actualTotal, category.total),
  1868. alarms: Math.max(actualAlarms, category.alarms),
  1869. offline: Math.max(actualOffline, category.offline),
  1870. records
  1871. };
  1872. });
  1873. }
  1874. function renderFacilityStatus(monitoring) {
  1875. state.facilityMonitoring = monitoring;
  1876. const categories = buildFacilityCategories(monitoring);
  1877. const summaryGrid = document.querySelector("#facilitySummaryGrid");
  1878. const categoryGrid = document.querySelector("#facilityCategoryGrid");
  1879. if (!summaryGrid || !categoryGrid) return;
  1880. const total = categories.reduce((sum, item) => sum + item.total, 0);
  1881. const alarms = categories.reduce((sum, item) => sum + item.alarms, 0);
  1882. const offline = categories.reduce((sum, item) => sum + item.offline, 0);
  1883. const onlineRate = total ? ((total - offline) / total * 100).toFixed(1) : "0.0";
  1884. const overviewText = document.querySelector("#facilityOverviewText");
  1885. if (overviewText) {
  1886. overviewText.textContent = `当前共纳管 ${formatNumber(total)} 台设备,在线率 ${onlineRate}%,${alarms ? `存在 ${alarms} 项报警` : "暂无报警"},${offline ? `${offline} 台设备处于断开状态` : "设备连接状态正常"}。`;
  1887. }
  1888. const categoryCount = document.querySelector("#facilityCategoryCount");
  1889. if (categoryCount) categoryCount.textContent = `${categories.length} 类`;
  1890. summaryGrid.innerHTML = `
  1891. <article class="facility-summary-card facility-summary-total">
  1892. <span class="facility-summary-icon" data-icon="settings-2"></span>
  1893. <div><span>设备总数</span><strong>${formatNumber(total)}</strong><small>全部通防设备</small></div>
  1894. </article>
  1895. <article class="facility-summary-card facility-summary-online">
  1896. <span class="facility-summary-icon" data-icon="activity"></span>
  1897. <div><span>设备在线率</span><strong>${onlineRate}%</strong><small>${formatNumber(total - offline)} 台在线</small></div>
  1898. </article>
  1899. <article class="facility-summary-card facility-summary-alarm">
  1900. <span class="facility-summary-icon" data-icon="triangle-alert"></span>
  1901. <div><span>报警设备</span><strong>${formatNumber(alarms)}</strong><small>${alarms ? "需要及时确认" : "暂无报警"}</small></div>
  1902. </article>
  1903. <article class="facility-summary-card facility-summary-offline">
  1904. <span class="facility-summary-icon" data-icon="plug-zap"></span>
  1905. <div><span>断开设备</span><strong>${formatNumber(offline)}</strong><small>${offline ? "请检查连接状态" : "连接状态正常"}</small></div>
  1906. </article>
  1907. `;
  1908. if (typeof window.injectIcons === "function") window.injectIcons(summaryGrid);
  1909. categoryGrid.innerHTML = categories
  1910. .map(
  1911. (category) => `
  1912. <button class="facility-category-card" type="button" data-facility-category="${category.key}">
  1913. <span class="facility-category-icon"><span data-icon="${category.icon}"></span></span>
  1914. <span class="facility-category-copy">
  1915. <span class="facility-category-title"><strong>${escapeHtml(category.name)}</strong><i class="${category.alarms ? "warning" : category.offline ? "offline" : "normal"}"></i></span>
  1916. <small>${category.alarms ? `${category.alarms} 项报警` : category.offline ? `${category.offline} 台断开` : "运行状态正常"}</small>
  1917. </span>
  1918. <span class="facility-category-counts">
  1919. <span><b>${formatNumber(category.total)}</b><small>总数</small></span>
  1920. <span><b class="${category.alarms ? "text-danger" : ""}">${formatNumber(category.alarms)}</b><small>报警</small></span>
  1921. <span><b>${formatNumber(category.offline)}</b><small>断开</small></span>
  1922. </span>
  1923. </button>
  1924. `
  1925. )
  1926. .join("");
  1927. if (typeof window.injectIcons === "function") window.injectIcons(categoryGrid);
  1928. categoryGrid.querySelectorAll("[data-facility-category]").forEach((button) => {
  1929. button.addEventListener("click", () => {
  1930. categoryGrid.querySelectorAll("[data-facility-category]").forEach((item) => {
  1931. item.classList.toggle("active", item === button);
  1932. });
  1933. const category = categories.find((item) => item.key === button.dataset.facilityCategory);
  1934. renderFacilityDetail(category);
  1935. });
  1936. });
  1937. renderFacilityDetail(categories[0]);
  1938. }
  1939. function renderFacilityDetail(category) {
  1940. const panel = document.querySelector("#facilityDetailPanel");
  1941. if (!panel || !category) return;
  1942. const records = category.records.slice(0, 12);
  1943. while (records.length < Math.min(category.total, 12)) {
  1944. const index = records.length;
  1945. const status = index < category.offline
  1946. ? "offline"
  1947. : index < category.offline + category.alarms
  1948. ? "warning"
  1949. : "normal";
  1950. records.push({
  1951. name: `${category.name} ${String(index + 1).padStart(2, "0")}`,
  1952. location: index % 2 ? "北翼回风巷" : "22煤一盘区",
  1953. status
  1954. });
  1955. }
  1956. const statusLabel = { normal: "正常", warning: "报警", alarm: "报警", offline: "断开" };
  1957. const onlineCount = Math.max(category.total - category.offline, 0);
  1958. const onlineRate = category.total ? (onlineCount / category.total * 100).toFixed(1) : "0.0";
  1959. panel.innerHTML = `
  1960. <div class="panel-head">
  1961. <div><span class="eyebrow">设备明细</span><h2>${escapeHtml(category.name)}</h2><p class="facility-detail-subtitle">在线率 ${onlineRate}% · 报警 ${category.alarms} · 断开 ${category.offline}</p></div>
  1962. <span class="subtle-badge">${formatNumber(category.total)} 台</span>
  1963. </div>
  1964. <div class="facility-detail-list">
  1965. ${records
  1966. .map(
  1967. (item) => `
  1968. <div class="facility-detail-row">
  1969. <span class="facility-detail-device-icon" data-icon="${category.icon}"></span>
  1970. <div class="facility-detail-copy"><strong>${escapeHtml(item.name)}</strong><p>${escapeHtml(item.location)}</p></div>
  1971. <span class="facility-detail-state ${escapeHtml(item.status)}"><i></i>${statusLabel[item.status] || "正常"}</span>
  1972. </div>
  1973. `
  1974. )
  1975. .join("")}
  1976. </div>
  1977. `;
  1978. if (typeof window.injectIcons === "function") window.injectIcons(panel);
  1979. }
  1980. function installFacilityActions() {
  1981. document.querySelector("[data-refresh-facility]")?.addEventListener("click", async () => {
  1982. const result = await loadMonitoring();
  1983. renderFacilityStatus(result.data);
  1984. window.showToast?.("通防设施工况已刷新");
  1985. });
  1986. }
  1987. function safetyStatusForValue(metric, value) {
  1988. const number = Number(value);
  1989. if (!Number.isFinite(number)) return "offline";
  1990. if (metric.thresholdMode === "below") {
  1991. if (number <= Number(metric.alarmThreshold)) return "alarm";
  1992. if (number <= Number(metric.warningThreshold)) return "warning";
  1993. return "normal";
  1994. }
  1995. if (number >= Number(metric.alarmThreshold)) return "alarm";
  1996. if (number >= Number(metric.warningThreshold)) return "warning";
  1997. return "normal";
  1998. }
  1999. function formatSafetyValue(metric, value) {
  2000. const number = Number(value);
  2001. if (!Number.isFinite(number)) return "--";
  2002. const decimals = metric.key === "airflow" ? 0 : metric.key === "temperature" || metric.key === "co" ? 1 : 2;
  2003. return number.toLocaleString("zh-CN", {
  2004. minimumFractionDigits: decimals,
  2005. maximumFractionDigits: decimals
  2006. });
  2007. }
  2008. function buildDemoMetricSeries(metric) {
  2009. const values = Array.from({ length: 48 }, (_, index) => {
  2010. const wave = Math.sin(index / 4.2 + metric.key.length) * metric.amplitude;
  2011. const secondary = Math.cos(index / 7.4 + metric.baseline) * metric.amplitude * 0.35;
  2012. const noise = Math.sin(index * 1.73 + metric.key.length * 1.8) * metric.noise;
  2013. return metric.baseline + wave + secondary + noise;
  2014. });
  2015. if (metric.key === "gas") {
  2016. values[47] = 0.86;
  2017. values[46] = 0.78;
  2018. values[45] = 0.7;
  2019. } else if (metric.key === "airflow") {
  2020. values[47] = 1286;
  2021. values[46] = 1302;
  2022. values[45] = 1328;
  2023. } else if (metric.key === "dust") {
  2024. values[47] = 4.2;
  2025. values[46] = 3.82;
  2026. values[45] = 3.65;
  2027. } else if (metric.key === "air_speed") {
  2028. values[47] = 0.22;
  2029. values[46] = 0.27;
  2030. values[45] = 0.31;
  2031. } else {
  2032. values[47] = metric.baseline + metric.amplitude * 0.28;
  2033. }
  2034. return values.map((value) => Number(value.toFixed(3)));
  2035. }
  2036. function calculateLinearForecast(values, count = 8) {
  2037. const sample = values.slice(-Math.min(values.length, 16)).map(Number).filter(Number.isFinite);
  2038. if (sample.length < 2) return Array.from({ length: count }, () => sample[0] || 0);
  2039. const meanX = (sample.length - 1) / 2;
  2040. const meanY = sample.reduce((sum, value) => sum + value, 0) / sample.length;
  2041. let numerator = 0;
  2042. let denominator = 0;
  2043. sample.forEach((value, index) => {
  2044. numerator += (index - meanX) * (value - meanY);
  2045. denominator += (index - meanX) ** 2;
  2046. });
  2047. const slope = denominator ? numerator / denominator : 0;
  2048. return Array.from({ length: count }, (_, index) =>
  2049. Number((sample[sample.length - 1] + slope * (index + 1)).toFixed(3))
  2050. );
  2051. }
  2052. function buildSafetyMetrics(monitoring) {
  2053. const sensors = monitoring.sensors || [];
  2054. return SAFETY_METRIC_DEFS.map((definition) => {
  2055. const sensor = sensors.find(
  2056. (item) =>
  2057. String(item.sensor_type || "").toLowerCase() === definition.sensorType ||
  2058. String(item.code || "").toLowerCase().includes(definition.key)
  2059. );
  2060. const readings = sensor ? state.safetyReadings.get(String(sensor.id)) : null;
  2061. let values =
  2062. Array.isArray(readings) && readings.length > 2
  2063. ? readings.map((item) => Number(item.value)).filter(Number.isFinite)
  2064. : buildDemoMetricSeries(definition);
  2065. if (values.length < 3) values = buildDemoMetricSeries(definition);
  2066. const sensorValue = Number(sensor?.last_value);
  2067. if (Number.isFinite(sensorValue)) {
  2068. values[values.length - 1] = sensorValue;
  2069. values[values.length - 2] = values[values.length - 2] * 0.35 + sensorValue * 0.65;
  2070. }
  2071. const currentValue = values[values.length - 1];
  2072. const forecast = calculateLinearForecast(values);
  2073. const predictedValue = forecast[forecast.length - 1];
  2074. const status = sensor?.status === "offline" ? "offline" : safetyStatusForValue(definition, currentValue);
  2075. const distance = definition.thresholdMode === "below"
  2076. ? (currentValue - definition.alarmThreshold) / Math.max(definition.warningThreshold - definition.alarmThreshold, 0.001)
  2077. : (definition.alarmThreshold - currentValue) / Math.max(definition.alarmThreshold - definition.warningThreshold, 0.001);
  2078. const baseRisk = status === "alarm" ? 88 : status === "warning" ? 66 : status === "offline" ? 78 : 24;
  2079. const riskScore = Math.max(8, Math.min(98, Math.round(baseRisk + (1 - Math.max(0, Math.min(1, distance))) * 8)));
  2080. return {
  2081. ...definition,
  2082. sensor,
  2083. unit: sensor?.unit || definition.unit,
  2084. location: sensor?.location || "全矿监测网络",
  2085. values,
  2086. forecast,
  2087. currentValue,
  2088. predictedValue,
  2089. predictedStatus: safetyStatusForValue(definition, predictedValue),
  2090. status,
  2091. riskScore
  2092. };
  2093. });
  2094. }
  2095. function getSafetySource(monitoring) {
  2096. return (
  2097. (monitoring.sources || []).find((source) => {
  2098. const type = String(source.source_type || "").toLowerCase();
  2099. return type.includes("monitoring") || type.includes("security") || String(source.name || "").includes("安全监控");
  2100. }) || DEMO_MONITORING.sources[0]
  2101. );
  2102. }
  2103. function buildSparkline(values, tone = "teal") {
  2104. const series = values.slice(-18).map(Number).filter(Number.isFinite);
  2105. if (series.length < 2) return "";
  2106. const min = Math.min(...series);
  2107. const max = Math.max(...series);
  2108. const range = max - min || 1;
  2109. const points = series
  2110. .map((value, index) => {
  2111. const x = (index / (series.length - 1)) * 84 + 2;
  2112. const y = 28 - ((value - min) / range) * 24;
  2113. return `${x.toFixed(1)},${y.toFixed(1)}`;
  2114. })
  2115. .join(" ");
  2116. return `<svg class="safety-sparkline ${tone}" viewBox="0 0 88 32" aria-hidden="true"><polyline points="${points}"></polyline></svg>`;
  2117. }
  2118. function buildSmoothPath(points, smoothing = 0.16) {
  2119. if (!points.length) return "";
  2120. if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
  2121. return points.reduce((path, point, index) => {
  2122. if (index === 0) return `M ${point.x} ${point.y}`;
  2123. const previous = points[index - 1];
  2124. const beforePrevious = points[index - 2] || previous;
  2125. const next = points[index + 1] || point;
  2126. const control1X = previous.x + (point.x - beforePrevious.x) * smoothing;
  2127. const control1Y = previous.y + (point.y - beforePrevious.y) * smoothing;
  2128. const control2X = point.x - (next.x - previous.x) * smoothing;
  2129. const control2Y = point.y - (next.y - previous.y) * smoothing;
  2130. return `${path} C ${control1X.toFixed(1)} ${control1Y.toFixed(1)}, ${control2X.toFixed(1)} ${control2Y.toFixed(1)}, ${point.x} ${point.y}`;
  2131. }, "");
  2132. }
  2133. function renderSafetyChart(metric) {
  2134. const chart = document.querySelector("#safetyMonitoringChart");
  2135. if (!chart || !metric) return;
  2136. const range = Math.min(state.safetyRange, metric.values.length);
  2137. const values = metric.values.slice(-range);
  2138. const forecast = metric.forecast || [];
  2139. const svgWidth = 820;
  2140. const svgHeight = 290;
  2141. const margin = { top: 22, right: 28, bottom: 38, left: 62 };
  2142. const plotWidth = svgWidth - margin.left - margin.right;
  2143. const plotHeight = svgHeight - margin.top - margin.bottom;
  2144. const allValues = [...values, ...forecast, metric.warningThreshold, metric.alarmThreshold].map(Number).filter(Number.isFinite);
  2145. let minimum = Math.min(...allValues);
  2146. let maximum = Math.max(...allValues);
  2147. if (maximum === minimum) {
  2148. maximum += 1;
  2149. minimum -= 1;
  2150. }
  2151. const padding = (maximum - minimum) * 0.16;
  2152. minimum -= padding;
  2153. maximum += padding;
  2154. const totalSteps = Math.max(values.length + forecast.length - 1, 1);
  2155. const xFor = (index) => margin.left + (index / totalSteps) * plotWidth;
  2156. const yFor = (value) => margin.top + (1 - (Number(value) - minimum) / (maximum - minimum)) * plotHeight;
  2157. const actualPoints = values.map((value, index) => ({ x: Number(xFor(index).toFixed(1)), y: Number(yFor(value).toFixed(1)) }));
  2158. const forecastPoints = [
  2159. actualPoints[actualPoints.length - 1],
  2160. ...forecast.map((value, index) => ({
  2161. x: Number(xFor(values.length + index).toFixed(1)),
  2162. y: Number(yFor(value).toFixed(1))
  2163. }))
  2164. ];
  2165. const actualPath = buildSmoothPath(actualPoints);
  2166. const forecastPath = buildSmoothPath(forecastPoints);
  2167. const areaPath = `${actualPath} L ${actualPoints[actualPoints.length - 1].x} ${margin.top + plotHeight} L ${actualPoints[0].x} ${margin.top + plotHeight} Z`;
  2168. const gridLines = Array.from({ length: 5 }, (_, index) => {
  2169. const y = margin.top + (index / 4) * plotHeight;
  2170. const value = maximum - (index / 4) * (maximum - minimum);
  2171. return `<line x1="${margin.left}" y1="${y}" x2="${svgWidth - margin.right}" y2="${y}" class="safety-chart-grid-line"/><text x="${margin.left - 10}" y="${y + 4}" text-anchor="end" class="safety-chart-axis-label">${formatSafetyValue(metric, value)}</text>`;
  2172. }).join("");
  2173. const thresholdLines = [
  2174. { value: metric.warningThreshold, className: "warning", label: "预警" },
  2175. { value: metric.alarmThreshold, className: "alarm", label: "报警" }
  2176. ]
  2177. .filter((item) => Number.isFinite(Number(item.value)))
  2178. .map((item) => {
  2179. const y = yFor(item.value);
  2180. return `<line x1="${margin.left}" y1="${y}" x2="${svgWidth - margin.right}" y2="${y}" class="safety-chart-threshold ${item.className}"/><text x="${svgWidth - margin.right - 4}" y="${y - 5}" text-anchor="end" class="safety-chart-threshold-label ${item.className}">${item.label} ${formatSafetyValue(metric, item.value)}</text>`;
  2181. })
  2182. .join("");
  2183. const currentPoint = actualPoints[actualPoints.length - 1];
  2184. const gradientId = `safety-gradient-${metric.key}`;
  2185. chart.innerHTML = `
  2186. <svg class="safety-chart-svg" viewBox="0 0 ${svgWidth} ${svgHeight}" role="img" aria-label="${escapeHtml(metric.name)}监测趋势图">
  2187. <defs>
  2188. <linearGradient id="${gradientId}" x1="0" y1="0" x2="0" y2="1">
  2189. <stop offset="0%" stop-color="${metric.tone === "red" ? "#d84d50" : "#0f9f8f"}" stop-opacity="0.22"/>
  2190. <stop offset="100%" stop-color="${metric.tone === "red" ? "#d84d50" : "#0f9f8f"}" stop-opacity="0"/>
  2191. </linearGradient>
  2192. </defs>
  2193. ${gridLines}
  2194. ${thresholdLines}
  2195. <path d="${areaPath}" fill="url(#${gradientId})"/>
  2196. <path d="${actualPath}" class="safety-chart-actual-path ${metric.tone}"/>
  2197. <path d="${forecastPath}" class="safety-chart-forecast-path"/>
  2198. <line x1="${currentPoint.x}" y1="${margin.top}" x2="${currentPoint.x}" y2="${margin.top + plotHeight}" class="safety-chart-now-line"/>
  2199. <circle cx="${currentPoint.x}" cy="${currentPoint.y}" r="5" class="safety-chart-current-point ${metric.tone}"/>
  2200. <circle cx="${currentPoint.x}" cy="${currentPoint.y}" r="10" class="safety-chart-current-halo ${metric.tone}"/>
  2201. <text x="${margin.left}" y="${svgHeight - 12}" class="safety-chart-time-label">${state.safetyRange} 小时前</text>
  2202. <text x="${currentPoint.x}" y="${svgHeight - 12}" text-anchor="middle" class="safety-chart-time-label">当前</text>
  2203. <text x="${svgWidth - margin.right}" y="${svgHeight - 12}" text-anchor="end" class="safety-chart-time-label">预测 +2h</text>
  2204. </svg>
  2205. `;
  2206. }
  2207. function renderSafetyInsights(metric) {
  2208. if (!metric) return;
  2209. const riskRing = document.querySelector("#safetyRiskRing");
  2210. const riskScore = document.querySelector("#safetyRiskScore");
  2211. const riskLabel = document.querySelector("#safetyRiskLabel");
  2212. const riskSummary = document.querySelector("#safetyRiskSummary");
  2213. const predictionText = document.querySelector("#safetyPredictionText");
  2214. const confidence = document.querySelector("#safetyPredictionConfidence");
  2215. const recommendations = document.querySelector("#safetyRecommendations");
  2216. if (riskRing) {
  2217. riskRing.dataset.level = metric.status;
  2218. riskRing.style.setProperty("--risk-progress", `${metric.riskScore}%`);
  2219. }
  2220. if (riskScore) riskScore.textContent = String(metric.riskScore);
  2221. const labels = {
  2222. normal: "运行平稳",
  2223. warning: "趋势关注",
  2224. alarm: "超限预警",
  2225. offline: "测点离线"
  2226. };
  2227. if (riskLabel) {
  2228. riskLabel.textContent = labels[metric.status] || "运行平稳";
  2229. riskLabel.className = `safety-risk-label ${metric.status}`;
  2230. }
  2231. if (riskSummary) {
  2232. if (metric.status === "alarm") {
  2233. riskSummary.textContent = `${metric.name}当前值 ${formatSafetyValue(metric, metric.currentValue)} ${metric.unit},已越过报警阈值,需要立即处置。`;
  2234. } else if (metric.status === "warning") {
  2235. riskSummary.textContent = `${metric.name}当前值靠近控制边界,未来 2 小时仍存在继续偏离的风险。`;
  2236. } else {
  2237. riskSummary.textContent = `${metric.name}处于稳定区间,当前阈值余量充足。`;
  2238. }
  2239. }
  2240. const change = metric.currentValue
  2241. ? ((metric.predictedValue - metric.currentValue) / Math.abs(metric.currentValue)) * 100
  2242. : 0;
  2243. const direction = Math.abs(change) < 0.4 ? "基本持平" : change > 0 ? "缓慢上升" : "缓慢下降";
  2244. if (predictionText) {
  2245. predictionText.textContent = `基于近 ${metric.values.length} 个监测点执行趋势外推,预计未来 2 小时${direction},预测值约 ${formatSafetyValue(metric, metric.predictedValue)} ${metric.unit}。`;
  2246. }
  2247. if (confidence) {
  2248. const value = Math.min(98, Math.round(88 + Math.min(metric.values.length, 48) / 6));
  2249. confidence.textContent = `置信度 ${value}%`;
  2250. }
  2251. if (recommendations) {
  2252. recommendations.innerHTML = metric.recommendations
  2253. .map(
  2254. (item, index) => `
  2255. <div class="safety-recommendation-item">
  2256. <span>${String(index + 1).padStart(2, "0")}</span>
  2257. <p>${escapeHtml(item)}</p>
  2258. </div>
  2259. `
  2260. )
  2261. .join("");
  2262. }
  2263. }
  2264. function renderSafetyMetricTabs(metrics) {
  2265. const container = document.querySelector("#safetyMetricTabs");
  2266. if (!container) return;
  2267. container.innerHTML = metrics
  2268. .map(
  2269. (metric) => `
  2270. <button class="safety-metric-tab ${metric.key === state.safetySelectedMetric ? "active" : ""}" type="button" data-safety-metric="${metric.key}" data-tone="${metric.tone}">
  2271. <span class="safety-metric-tab-icon" data-icon="${metric.icon}"></span>
  2272. <span class="safety-metric-tab-copy">
  2273. <span><strong>${escapeHtml(metric.shortName)}</strong><small>${formatNumber(metric.pointCount)} 测点</small></span>
  2274. <b>${formatSafetyValue(metric, metric.currentValue)} <small>${escapeHtml(metric.unit)}</small></b>
  2275. </span>
  2276. ${buildSparkline(metric.values, metric.tone)}
  2277. </button>
  2278. `
  2279. )
  2280. .join("");
  2281. container.querySelectorAll("[data-safety-metric]").forEach((button) => {
  2282. button.addEventListener("click", () => {
  2283. state.safetySelectedMetric = button.dataset.safetyMetric;
  2284. renderSafetyMonitoring(state.monitoring);
  2285. });
  2286. });
  2287. if (typeof window.injectIcons === "function") window.injectIcons(container);
  2288. }
  2289. function renderSafetySensorList(metrics) {
  2290. const container = document.querySelector("#safetySensorList");
  2291. const count = document.querySelector("#safetySensorCount");
  2292. if (!container) return;
  2293. if (count) count.textContent = `${metrics.length} 类参数`;
  2294. const statusLabel = { normal: "正常", warning: "关注", alarm: "报警", offline: "离线" };
  2295. container.innerHTML = metrics
  2296. .map(
  2297. (metric) => `
  2298. <button class="safety-sensor-row" type="button" data-safety-sensor-metric="${metric.key}">
  2299. <span class="safety-sensor-icon" data-icon="${metric.icon}"></span>
  2300. <span class="safety-sensor-copy">
  2301. <strong>${escapeHtml(metric.sensor?.name || `${metric.name}监测点`)}</strong>
  2302. <small>${escapeHtml(metric.location)}</small>
  2303. </span>
  2304. <span class="safety-sensor-value"><strong>${formatSafetyValue(metric, metric.currentValue)}</strong><small>${escapeHtml(metric.unit)}</small></span>
  2305. <span class="safety-sensor-state ${escapeHtml(metric.status)}"><i></i>${statusLabel[metric.status] || "正常"}</span>
  2306. </button>
  2307. `
  2308. )
  2309. .join("");
  2310. container.querySelectorAll("[data-safety-sensor-metric]").forEach((button) => {
  2311. button.addEventListener("click", () => {
  2312. state.safetySelectedMetric = button.dataset.safetySensorMetric;
  2313. renderSafetyMonitoring(state.monitoring);
  2314. });
  2315. });
  2316. if (typeof window.injectIcons === "function") window.injectIcons(container);
  2317. }
  2318. function renderSafetyAlarmList(monitoring) {
  2319. const container = document.querySelector("#safetyAlarmList");
  2320. const count = document.querySelector("#safetyAlarmCount");
  2321. if (!container) return;
  2322. const alarms = monitoring?.alarms?.length ? monitoring.alarms : DEMO_MONITORING.alarms;
  2323. if (count) count.textContent = `${alarms.length} 条`;
  2324. const levelLabel = { high: "高风险", medium: "中风险", low: "低风险" };
  2325. container.innerHTML = alarms.length
  2326. ? alarms
  2327. .slice(0, 6)
  2328. .map(
  2329. (alarm) => `
  2330. <div class="safety-alarm-row">
  2331. <span class="safety-alarm-level ${escapeHtml(alarm.level || "medium")}">${levelLabel[alarm.level] || "告警"}</span>
  2332. <div>
  2333. <strong>${escapeHtml(alarm.title || "监测数据异常")}</strong>
  2334. <p>${escapeHtml(alarm.content || `当前值 ${formatSafetyValue({ key: "gas", name: "监测值" }, alarm.value)} · 阈值 ${formatSafetyValue({ key: "gas", name: "阈值" }, alarm.threshold)}`)}</p>
  2335. </div>
  2336. <time>${formatTime(alarm.occurred_at)}</time>
  2337. </div>
  2338. `
  2339. )
  2340. .join("")
  2341. : '<div class="safety-empty-state"><span data-icon="check-circle-2"></span><p>当前没有未处置告警</p></div>';
  2342. if (typeof window.injectIcons === "function") window.injectIcons(container);
  2343. }
  2344. function renderSafetyMonitoring(monitoring) {
  2345. if (!monitoring) return;
  2346. state.monitoring = monitoring;
  2347. const summaryGrid = document.querySelector("#safetyMonitoringSummary");
  2348. if (!summaryGrid) return;
  2349. const metrics = buildSafetyMetrics(monitoring);
  2350. state.safetyMetrics = metrics;
  2351. const source = getSafetySource(monitoring);
  2352. const overview = monitoring.overview || DEMO_MONITORING.overview;
  2353. const totalPoints = Number(source.point_count || DEMO_MONITORING.sources[0].point_count || 0);
  2354. const onlineSensors = Number(overview.online_sensor_count || metrics.reduce((sum, item) => sum + item.onlineCount, 0));
  2355. const sensorTotal = Number(overview.sensor_count || metrics.reduce((sum, item) => sum + item.pointCount, 0));
  2356. const onlineRate = sensorTotal ? ((onlineSensors / sensorTotal) * 100).toFixed(1) : "0.0";
  2357. const abnormalCount = metrics.filter((metric) => metric.status !== "normal").length;
  2358. const latency = Number(source.latency_ms || 2400) / 1000;
  2359. const selected = metrics.find((metric) => metric.key === state.safetySelectedMetric) || metrics[0];
  2360. state.safetySelectedMetric = selected?.key || "gas";
  2361. summaryGrid.innerHTML = `
  2362. <article class="safety-summary-card total">
  2363. <span class="safety-summary-icon" data-icon="radio-tower"></span>
  2364. <div><small>接入测点</small><strong>${formatNumber(totalPoints)}</strong><p>安全监控系统接入总量</p></div>
  2365. </article>
  2366. <article class="safety-summary-card online">
  2367. <span class="safety-summary-icon" data-icon="activity"></span>
  2368. <div><small>在线率</small><strong>${onlineRate}%</strong><p>${formatNumber(onlineSensors)} 个测点在线</p></div>
  2369. </article>
  2370. <article class="safety-summary-card warning">
  2371. <span class="safety-summary-icon" data-icon="shield-alert"></span>
  2372. <div><small>异常参数</small><strong>${formatNumber(abnormalCount)}</strong><p>存在报警或关注趋势</p></div>
  2373. </article>
  2374. <article class="safety-summary-card latency">
  2375. <span class="safety-summary-icon" data-icon="clock"></span>
  2376. <div><small>数据延迟</small><strong>${latency.toFixed(1)}s</strong><p>${escapeHtml(source.protocol || "OPC UA")} 实时链路</p></div>
  2377. </article>
  2378. `;
  2379. const toolbarText = document.querySelector("#safetyMonitoringToolbarText");
  2380. if (toolbarText) {
  2381. toolbarText.textContent = `${source.protocol || "OPC UA"} · ${source.endpoint || "实时数据链路"} · 覆盖瓦斯、风量、CO、温度、粉尘、风速`;
  2382. }
  2383. const overviewText = document.querySelector("#safetyOverviewText");
  2384. if (overviewText) {
  2385. overviewText.textContent = `已接入 ${formatNumber(totalPoints)} 个安全监测点,在线率 ${onlineRate}%,当前识别到 ${formatNumber(abnormalCount)} 类参数需要关注。`;
  2386. }
  2387. const sourceName = document.querySelector("#safetyOverviewSource");
  2388. if (sourceName) sourceName.textContent = source.code || source.name || "KJ-SAFETY";
  2389. const sourceProtocol = document.querySelector("#safetyOverviewProtocol");
  2390. if (sourceProtocol) sourceProtocol.textContent = `${source.protocol || "OPC UA"} · 实时接入`;
  2391. renderSafetyMetricTabs(metrics);
  2392. if (selected) {
  2393. const title = document.querySelector("#safetyChartTitle");
  2394. const subtitle = document.querySelector("#safetyChartSubtitle");
  2395. const currentValue = document.querySelector("#safetyCurrentValue");
  2396. const currentUnit = document.querySelector("#safetyCurrentUnit");
  2397. if (title) title.textContent = `${selected.name}趋势`;
  2398. if (subtitle) subtitle.textContent = `${selected.location} · 阈值 ${formatSafetyValue(selected, selected.warningThreshold)} / ${formatSafetyValue(selected, selected.alarmThreshold)} ${selected.unit}`;
  2399. if (currentValue) currentValue.textContent = formatSafetyValue(selected, selected.currentValue);
  2400. if (currentUnit) currentUnit.textContent = selected.unit;
  2401. renderSafetyChart(selected);
  2402. renderSafetyInsights(selected);
  2403. }
  2404. renderSafetySensorList(metrics);
  2405. renderSafetyAlarmList(monitoring);
  2406. if (typeof window.injectIcons === "function") window.injectIcons(summaryGrid);
  2407. }
  2408. function installSafetyMonitoringActions() {
  2409. document.querySelector("[data-refresh-safety-monitoring]")?.addEventListener("click", async () => {
  2410. const result = await loadMonitoring();
  2411. renderSafetyMonitoring(result.data);
  2412. await hydrateSafetyReadings(result.data);
  2413. window.showToast?.("安全监控数据已刷新");
  2414. });
  2415. document.querySelectorAll("[data-safety-range]").forEach((button) => {
  2416. button.addEventListener("click", () => {
  2417. state.safetyRange = Number(button.dataset.safetyRange || 24);
  2418. document.querySelectorAll("[data-safety-range]").forEach((item) => item.classList.toggle("active", item === button));
  2419. const metric = state.safetyMetrics.find((item) => item.key === state.safetySelectedMetric);
  2420. renderSafetyChart(metric);
  2421. });
  2422. });
  2423. }
  2424. function videoStatusLabel(status) {
  2425. return status === "online" ? "在线" : status === "offline" ? "离线" : "异常";
  2426. }
  2427. function videoSourceCount(monitoring) {
  2428. const source = (monitoring?.sources || []).find((item) =>
  2429. String(item.source_type || "").toLowerCase().includes("video")
  2430. );
  2431. return Number(source?.point_count || 218);
  2432. }
  2433. function renderVideoSummary(monitoring) {
  2434. const grid = document.querySelector("#videoSummaryGrid");
  2435. if (!grid) return;
  2436. const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
  2437. const total = videoSourceCount(monitoring);
  2438. const configured = VIDEO_CHANNELS.length;
  2439. const online = VIDEO_CHANNELS.filter((item) => item.status === "online").length;
  2440. grid.innerHTML = `
  2441. <article class="video-summary-card total"><span class="video-summary-icon" data-icon="video"></span><div><small>视频通道</small><strong>${formatNumber(total)}</strong><p>视频平台接入总量</p></div></article>
  2442. <article class="video-summary-card online"><span class="video-summary-icon" data-icon="activity"></span><div><small>已配置通道</small><strong>${formatNumber(configured)}</strong><p>当前页面可管理通道</p></div></article>
  2443. <article class="video-summary-card warning"><span class="video-summary-icon" data-icon="shield-alert"></span><div><small>在线通道</small><strong>${formatNumber(online)}</strong><p>视频连接状态正常</p></div></article>
  2444. <article class="video-summary-card ai"><span class="video-summary-icon" data-icon="scan-search"></span><div><small>识别事件</small><strong>${formatNumber((state.monitoring?.alarms || []).length)}</strong><p>当前智能分析事件</p></div></article>
  2445. `;
  2446. window.injectIcons?.(grid);
  2447. const count = document.querySelector("#videoChannelCount");
  2448. if (count) count.textContent = `${formatNumber(configured)} / ${formatNumber(total)} 路`;
  2449. const toolbar = document.querySelector("#videoPlatformToolbarText");
  2450. if (toolbar) {
  2451. toolbar.textContent = `${moduleData.title}|共接入 ${formatNumber(total)} 路视频,当前已配置 ${formatNumber(configured)} 路,在线 ${formatNumber(online)} 路`;
  2452. }
  2453. }
  2454. function renderVideoStatusOverview() {
  2455. const container = document.querySelector("#videoStatusOverview");
  2456. const title = document.querySelector("#videoStatusTitle");
  2457. if (!container) return;
  2458. const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
  2459. if (title) title.textContent = moduleData.statusTitle;
  2460. container.innerHTML = moduleData.summary
  2461. .map(
  2462. ([label, value, suffix], index) => `
  2463. <div class="video-status-item ${index % 3 === 2 ? "accent" : ""}">
  2464. <strong>${formatNumber(value)}</strong>
  2465. <span>${escapeHtml(label)}</span>
  2466. <small>${escapeHtml(suffix)}</small>
  2467. </div>
  2468. `
  2469. )
  2470. .join("");
  2471. }
  2472. function filteredVideoChannels() {
  2473. const moduleGroup = {
  2474. door: "风门区域",
  2475. "local-fan": "局扇区域",
  2476. "main-fan": "主扇区域"
  2477. }[state.videoModule] || "风门区域";
  2478. const keyword = state.videoSearch.trim().toLowerCase();
  2479. return VIDEO_CHANNELS.filter((channel) => {
  2480. if (channel.group !== moduleGroup) return false;
  2481. if (!keyword) return true;
  2482. return `${channel.name} ${channel.location}`.toLowerCase().includes(keyword);
  2483. });
  2484. }
  2485. function renderVideoDeviceTree() {
  2486. const tree = document.querySelector("#videoDeviceTree");
  2487. if (!tree) return;
  2488. const channels = filteredVideoChannels();
  2489. const count = document.querySelector("#videoChannelCount");
  2490. if (count) {
  2491. count.textContent = `${formatNumber(channels.length)} / ${formatNumber(videoSourceCount(state.monitoring))} 路`;
  2492. }
  2493. if (!channels.length) {
  2494. tree.innerHTML = `<div class="video-empty-state"><span data-icon="search"></span><p>没有匹配的视频通道</p></div>`;
  2495. window.injectIcons?.(tree);
  2496. return;
  2497. }
  2498. tree.innerHTML = `
  2499. <div class="video-tree-channels video-tree-flat">
  2500. ${channels
  2501. .map(
  2502. (channel) => `
  2503. <button class="video-tree-channel ${channel.id === state.videoSelectedChannel ? "active" : ""}" type="button" data-video-channel="${channel.id}">
  2504. <i class="${channel.status}"></i>
  2505. <span><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(channel.location)}</small></span>
  2506. <em>${videoStatusLabel(channel.status)}</em>
  2507. </button>
  2508. `
  2509. )
  2510. .join("")}
  2511. </div>
  2512. `;
  2513. window.injectIcons?.(tree);
  2514. tree.querySelectorAll("[data-video-channel]").forEach((button) => {
  2515. button.addEventListener("click", () => {
  2516. state.videoSelectedChannel = button.dataset.videoChannel;
  2517. renderVideoPlatform(state.monitoring);
  2518. });
  2519. });
  2520. }
  2521. function videoWallChannels() {
  2522. const all = VIDEO_CHANNELS.slice();
  2523. const selectedIndex = all.findIndex((item) => item.id === state.videoSelectedChannel);
  2524. if (selectedIndex > 0) {
  2525. const [selected] = all.splice(selectedIndex, 1);
  2526. all.unshift(selected);
  2527. }
  2528. return all.slice(0, state.videoLayout);
  2529. }
  2530. function renderVideoWall() {
  2531. const wall = document.querySelector("#videoWall");
  2532. const title = document.querySelector("#videoWallTitle");
  2533. if (!wall) return;
  2534. wall.className = `video-wall layout-${state.videoLayout}`;
  2535. const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
  2536. const layoutNames = { 1: "单画面监控", 2: "双画面监控", 4: "四分屏监控", 6: "六分屏监控", 9: "九分屏监控" };
  2537. if (title) title.textContent = `${moduleData.title} · ${layoutNames[state.videoLayout] || "实时监控"}`;
  2538. const channels = videoWallChannels();
  2539. wall.innerHTML = channels
  2540. .map(
  2541. (channel) => `
  2542. <article class="video-tile ${channel.status}" data-video-tile="${channel.id}">
  2543. <div class="video-tile-stage">
  2544. <div class="video-tile-placeholder">
  2545. <span data-icon="video"></span>
  2546. <strong>${escapeHtml(channel.name)}</strong>
  2547. <small>等待视频流接入</small>
  2548. </div>
  2549. <div class="video-tile-status"><i></i>${videoStatusLabel(channel.status)}</div>
  2550. <div class="video-tile-copy"><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(channel.location)}</small></div>
  2551. <div class="video-tile-tools">
  2552. <button type="button" data-video-action="play" data-channel="${channel.id}" aria-label="播放或暂停" title="播放或暂停"><span data-icon="play"></span></button>
  2553. <button type="button" data-video-action="mute" data-channel="${channel.id}" aria-label="静音" title="静音"><span data-icon="volume-2"></span></button>
  2554. <button type="button" data-video-action="snapshot" data-channel="${channel.id}" aria-label="抓图" title="抓图"><span data-icon="camera"></span></button>
  2555. <button type="button" data-video-action="fullscreen" data-channel="${channel.id}" aria-label="全屏" title="全屏"><span data-icon="maximize-2"></span></button>
  2556. </div>
  2557. </div>
  2558. </article>
  2559. `
  2560. )
  2561. .join("");
  2562. window.injectIcons?.(wall);
  2563. wall.querySelectorAll("[data-video-action]").forEach((button) => {
  2564. button.addEventListener("click", (event) => {
  2565. event.stopPropagation();
  2566. handleVideoAction(button.dataset.videoAction, button.dataset.channel);
  2567. });
  2568. });
  2569. }
  2570. function handleVideoAction(action, channelId) {
  2571. const channel = VIDEO_CHANNELS.find((item) => item.id === channelId);
  2572. if (!channel) return;
  2573. if (action === "fullscreen") {
  2574. document.querySelector(`[data-video-tile="${channelId}"]`)?.requestFullscreen?.();
  2575. return;
  2576. }
  2577. const labels = {
  2578. play: "播放/暂停",
  2579. mute: "静音",
  2580. snapshot: "抓图"
  2581. };
  2582. window.showToast?.(
  2583. `${channel.name} 已执行${labels[action] || "操作"};当前视频流接口尚未配置`,
  2584. "warning"
  2585. );
  2586. }
  2587. function renderVideoEvents(monitoring) {
  2588. const list = document.querySelector("#videoEventList");
  2589. const count = document.querySelector("#videoEventCount");
  2590. if (!list) return;
  2591. const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
  2592. const logs = moduleData.logs;
  2593. if (count) count.textContent = `${formatNumber(logs.length)} 条`;
  2594. list.innerHTML = logs
  2595. .map(
  2596. ([time, message, tone]) => `
  2597. <div class="video-event-row ${escapeHtml(tone)}">
  2598. <time>${escapeHtml(time)}</time>
  2599. <p>${escapeHtml(message)}</p>
  2600. </div>
  2601. `
  2602. )
  2603. .join("");
  2604. }
  2605. function renderVideoThumbnails() {
  2606. const grid = document.querySelector("#videoThumbnailGrid");
  2607. const count = document.querySelector("#videoThumbnailCount");
  2608. if (!grid) return;
  2609. const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
  2610. const snapshotTypes = moduleData.snapshotTypes || ["识别抓拍"];
  2611. const channels = VIDEO_CHANNELS.slice(0, 8);
  2612. if (count) count.textContent = `${formatNumber(channels.length)} 张`;
  2613. grid.innerHTML = channels
  2614. .map(
  2615. (channel, index) => `
  2616. <article class="video-thumb-card">
  2617. <div class="video-thumb-stage">
  2618. <span data-icon="video"></span>
  2619. <time>${String(11 + Math.floor(index / 4)).padStart(2, "0")}:${String((index * 7) % 60).padStart(2, "0")}:20</time>
  2620. </div>
  2621. <div class="video-thumb-copy"><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(snapshotTypes[index % snapshotTypes.length])}</small></div>
  2622. </article>
  2623. `
  2624. )
  2625. .join("");
  2626. window.injectIcons?.(grid);
  2627. }
  2628. function renderVideoFilterOptions() {
  2629. const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
  2630. const typeLabel = document.querySelector("#videoRecordFilterLabel");
  2631. const typeSelect = document.querySelector("#videoRecordType");
  2632. const scopeLabel = document.querySelector("#videoScopeFilterLabel");
  2633. const scopeSelect = document.querySelector("#videoDeviceScope");
  2634. if (typeLabel) typeLabel.textContent = moduleData.typeFilterLabel;
  2635. if (typeSelect) {
  2636. typeSelect.innerHTML = moduleData.typeFilterOptions
  2637. .map((option) => `<option>${escapeHtml(option)}</option>`)
  2638. .join("");
  2639. }
  2640. if (scopeLabel) scopeLabel.textContent = moduleData.scopeFilterLabel;
  2641. if (scopeSelect) {
  2642. scopeSelect.innerHTML = moduleData.scopeFilterOptions
  2643. .map((option) => `<option>${escapeHtml(option)}</option>`)
  2644. .join("");
  2645. }
  2646. }
  2647. function renderVideoRecords() {
  2648. const tabs = document.querySelector("#videoRecordTabs");
  2649. const table = document.querySelector("#videoRecordsTable");
  2650. const title = document.querySelector("#videoRecordTitle");
  2651. if (!tabs || !table) return;
  2652. const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
  2653. const activeIndex = Math.min(state.videoRecordTab, moduleData.tabs.length - 1);
  2654. state.videoRecordTab = activeIndex;
  2655. if (title) title.textContent = moduleData.tabs[activeIndex];
  2656. tabs.innerHTML = moduleData.tabs
  2657. .map(
  2658. (label, index) => `<button class="${index === activeIndex ? "active" : ""}" type="button" data-video-record-tab="${index}">${escapeHtml(label)}</button>`
  2659. )
  2660. .join("");
  2661. tabs.querySelectorAll("[data-video-record-tab]").forEach((button) => {
  2662. button.addEventListener("click", () => {
  2663. state.videoRecordTab = Number(button.dataset.videoRecordTab);
  2664. renderVideoRecords();
  2665. });
  2666. });
  2667. table.innerHTML = `
  2668. <table>
  2669. <thead><tr>${moduleData.columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr></thead>
  2670. <tbody>
  2671. ${moduleData.rows
  2672. .map(
  2673. (row) => `<tr>${row.map((value) => `<td>${escapeHtml(value)}</td>`).join("")}</tr>`
  2674. )
  2675. .join("")}
  2676. </tbody>
  2677. </table>
  2678. `;
  2679. }
  2680. function syncVideoPanelHeights() {
  2681. const devicePanel = document.querySelector(".video-device-panel");
  2682. const wallPanel = document.querySelector(".video-wall-panel");
  2683. if (!devicePanel || !wallPanel) return;
  2684. if (window.innerWidth <= 1180) {
  2685. devicePanel.style.height = "";
  2686. wallPanel.style.height = "";
  2687. return;
  2688. }
  2689. devicePanel.style.height = "auto";
  2690. wallPanel.style.height = "auto";
  2691. const targetHeight = Math.ceil(wallPanel.getBoundingClientRect().height);
  2692. if (!targetHeight) return;
  2693. devicePanel.style.height = `${targetHeight}px`;
  2694. wallPanel.style.height = `${targetHeight}px`;
  2695. }
  2696. function renderVideoPlatform(monitoring = state.monitoring) {
  2697. state.monitoring = monitoring || state.monitoring;
  2698. renderVideoSummary(state.monitoring);
  2699. renderVideoStatusOverview();
  2700. renderVideoDeviceTree();
  2701. renderVideoWall();
  2702. renderVideoEvents(state.monitoring);
  2703. renderVideoThumbnails();
  2704. renderVideoRecords();
  2705. renderVideoFilterOptions();
  2706. document.querySelectorAll("[data-video-module]").forEach((button) => {
  2707. button.classList.toggle("active", button.dataset.videoModule === state.videoModule);
  2708. });
  2709. const note = document.querySelector("#videoStreamNote");
  2710. if (note) {
  2711. note.textContent = "当前为原生视频管理工作台;真实视频流地址接入后可直接在当前分屏中播放。";
  2712. }
  2713. window.requestAnimationFrame(syncVideoPanelHeights);
  2714. }
  2715. function installVideoPlatformActions() {
  2716. document.querySelector("[data-refresh-video-platform]")?.addEventListener("click", () => {
  2717. renderVideoPlatform(state.monitoring);
  2718. window.showToast?.("视频通道状态已刷新");
  2719. });
  2720. document.querySelectorAll("[data-video-module]").forEach((button) => {
  2721. button.addEventListener("click", () => {
  2722. state.videoModule = button.dataset.videoModule || "door";
  2723. state.videoRecordTab = 0;
  2724. state.videoSelectedChannel =
  2725. VIDEO_MODULES[state.videoModule]?.defaultChannel ||
  2726. state.videoSelectedChannel;
  2727. renderVideoPlatform(state.monitoring);
  2728. });
  2729. });
  2730. document.querySelectorAll("[data-video-layout]").forEach((button) => {
  2731. button.addEventListener("click", () => {
  2732. state.videoLayout = Number(button.dataset.videoLayout || 4);
  2733. document.querySelectorAll("[data-video-layout]").forEach((item) => {
  2734. item.classList.toggle("active", item === button);
  2735. });
  2736. renderVideoWall();
  2737. window.requestAnimationFrame(syncVideoPanelHeights);
  2738. });
  2739. });
  2740. const search = document.querySelector("#videoChannelSearch");
  2741. search?.addEventListener("input", () => {
  2742. state.videoSearch = search.value.trim();
  2743. renderVideoDeviceTree();
  2744. });
  2745. document.querySelector("[data-video-search]")?.addEventListener("click", () => {
  2746. renderVideoThumbnails();
  2747. renderVideoRecords();
  2748. window.showToast?.("视频记录检索完成");
  2749. });
  2750. document.querySelector("[data-video-reset]")?.addEventListener("click", () => {
  2751. state.videoSearch = "";
  2752. if (search) search.value = "";
  2753. state.videoRecordTab = 0;
  2754. renderVideoPlatform(state.monitoring);
  2755. window.showToast?.("筛选条件已重置");
  2756. });
  2757. document.querySelectorAll("[data-video-quick-range]").forEach((button) => {
  2758. button.addEventListener("click", () => {
  2759. document.querySelectorAll("[data-video-quick-range]").forEach((item) => item.classList.toggle("active", item === button));
  2760. window.showToast?.(button.dataset.videoQuickRange === "today" ? "已切换为今日范围" : "已切换为近7天范围");
  2761. });
  2762. });
  2763. window.addEventListener("resize", () => {
  2764. window.requestAnimationFrame(syncVideoPanelHeights);
  2765. });
  2766. }
  2767. function openVideoPlatform() {
  2768. window.navigateTo?.("video-platform");
  2769. renderVideoPlatform(state.monitoring);
  2770. }
  2771. function installDocumentExport() {
  2772. const button = document.querySelector("[data-export-documents]");
  2773. if (!button || button.dataset.exportBound) return;
  2774. button.dataset.exportBound = "true";
  2775. button.addEventListener("click", async () => {
  2776. if (!state.apiAvailable) {
  2777. window.showToast?.("后端不可用,暂时无法导出资料清单", "warning");
  2778. return;
  2779. }
  2780. const original = button.innerHTML;
  2781. button.disabled = true;
  2782. button.innerHTML = '<span data-icon="refresh-cw"></span>正在导出';
  2783. window.injectIcons?.(button);
  2784. try {
  2785. const params = new URLSearchParams();
  2786. params.set("mine_id", state.mine.id);
  2787. if (state.documentCategory && state.documentCategory !== "all") {
  2788. params.set("category", state.documentCategory);
  2789. }
  2790. const keyword = document
  2791. .querySelector("#businessDocumentSearch")
  2792. ?.value.trim();
  2793. if (keyword) params.set("keyword", keyword);
  2794. const response = await fetch(
  2795. `${API_BASE}/documents/export?${params.toString()}`
  2796. );
  2797. if (!response.ok) {
  2798. let message = `HTTP ${response.status}`;
  2799. try {
  2800. const payload = await response.json();
  2801. message = payload?.message || payload?.detail || message;
  2802. } catch {
  2803. // Keep the HTTP status when the response is not JSON.
  2804. }
  2805. throw new Error(message);
  2806. }
  2807. const blob = await response.blob();
  2808. const disposition = response.headers.get("Content-Disposition") || "";
  2809. const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
  2810. const filename = encodedName
  2811. ? decodeURIComponent(encodedName)
  2812. : `业务资料清单_${new Date().toISOString().slice(0, 10)}.xlsx`;
  2813. const url = URL.createObjectURL(blob);
  2814. const link = document.createElement("a");
  2815. link.href = url;
  2816. link.download = filename;
  2817. document.body.appendChild(link);
  2818. link.click();
  2819. link.remove();
  2820. window.setTimeout(() => URL.revokeObjectURL(url), 1000);
  2821. window.showToast?.("资料清单已导出");
  2822. } catch (error) {
  2823. window.showToast?.(`导出失败:${error?.message || error}`, "warning");
  2824. } finally {
  2825. button.disabled = false;
  2826. button.innerHTML = original;
  2827. window.injectIcons?.(button);
  2828. }
  2829. });
  2830. }
  2831. async function hydrateSafetyReadings(monitoring) {
  2832. const sensors = monitoring?.sensors || [];
  2833. if (!state.apiAvailable || !sensors.length) {
  2834. state.safetyLoaded = true;
  2835. return;
  2836. }
  2837. const entries = await Promise.all(
  2838. sensors.map(async (sensor) => {
  2839. try {
  2840. const readings = await request(`/monitoring/sensors/${encodeURIComponent(sensor.id)}/readings?limit=48`, {
  2841. timeoutMs: 3200
  2842. });
  2843. return [
  2844. String(sensor.id),
  2845. (readings || []).slice().sort((left, right) => new Date(left.measured_at) - new Date(right.measured_at))
  2846. ];
  2847. } catch {
  2848. return [String(sensor.id), []];
  2849. }
  2850. })
  2851. );
  2852. state.safetyReadings = new Map(entries);
  2853. state.safetyLoaded = true;
  2854. if (state.monitoring) renderSafetyMonitoring(state.monitoring);
  2855. }
  2856. function openSafetyMonitoring() {
  2857. window.navigateTo?.("safety-monitoring");
  2858. if (!state.safetyLoaded) hydrateSafetyReadings(state.monitoring).catch(() => {});
  2859. }
  2860. function renderKnowledgeGraph(graph) {
  2861. const canvas = document.querySelector(".graph-canvas");
  2862. const svg = canvas?.querySelector("svg");
  2863. if (!svg || !graph?.nodes?.length) return;
  2864. const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
  2865. const links = (graph.edges || [])
  2866. .filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target))
  2867. .map((edge) => {
  2868. const source = nodeMap.get(edge.source);
  2869. const target = nodeMap.get(edge.target);
  2870. return `<path d="M${source.x} ${source.y} L${target.x} ${target.y}" />`;
  2871. })
  2872. .join("");
  2873. const nodes = graph.nodes
  2874. .map((node) => {
  2875. const center = node.node_type === "risk";
  2876. const radius = center ? 48 : 34;
  2877. const className = center
  2878. ? "graph-center"
  2879. : node.node_type === "evidence"
  2880. ? "graph-node amber-node"
  2881. : "graph-node";
  2882. const value = Number(node.value || 0);
  2883. return `
  2884. <g class="${className}" transform="translate(${Number(node.x) || 0} ${Number(node.y) || 0})">
  2885. <title>${escapeHtml(`${node.label} ${node.sub_label || ""} · ${value} 条关联知识`)}</title>
  2886. <circle r="${radius}" />
  2887. <text y="-4">${escapeHtml(node.label || "")}</text>
  2888. <text y="15">${escapeHtml(node.sub_label || "")}</text>
  2889. </g>
  2890. `;
  2891. })
  2892. .join("");
  2893. svg.innerHTML = `
  2894. <defs>
  2895. <marker id="graphArrow" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
  2896. <path d="M0,0 L8,4 L0,8 z" fill="#93a4b8" />
  2897. </marker>
  2898. </defs>
  2899. <g class="graph-links">${links}</g>
  2900. ${nodes}
  2901. `;
  2902. }
  2903. function renderKnowledge(payload) {
  2904. const items = Array.isArray(payload)
  2905. ? payload
  2906. : payload?.catalog?.items || payload?.items || [];
  2907. const overview = Array.isArray(payload) ? null : payload?.overview || null;
  2908. const graph = Array.isArray(payload) ? null : payload?.graph || null;
  2909. const count = Number(overview?.total_notes || items.length || DEMO_KNOWLEDGE.length);
  2910. const heroStats = document.querySelector(".knowledge-hero-stats");
  2911. if (heroStats) {
  2912. const stats = heroStats.querySelectorAll("div");
  2913. if (stats[0]) {
  2914. stats[0].querySelector("strong").textContent = formatNumber(count);
  2915. stats[0].querySelector("span").textContent = "知识条目";
  2916. }
  2917. if (stats[1]) {
  2918. const semantic = Boolean(overview?.service?.semantic_enabled);
  2919. stats[1].querySelector("strong").textContent = semantic ? "混合" : "关键词";
  2920. stats[1].querySelector("span").textContent = "检索模式";
  2921. }
  2922. if (stats[2]) {
  2923. const responseMs = Number(overview?.average_response_ms || 0);
  2924. stats[2].querySelector("strong").textContent = responseMs
  2925. ? `${(responseMs / 1000).toFixed(1)}s`
  2926. : "--";
  2927. stats[2].querySelector("span").textContent = "平均响应";
  2928. }
  2929. }
  2930. const navBadge = document.querySelector('[data-view="knowledge"] .nav-badge');
  2931. if (navBadge) navBadge.textContent = formatNumber(count);
  2932. const dashboardCount = document.querySelector(".knowledge-count");
  2933. if (dashboardCount) dashboardCount.textContent = `${formatNumber(count)} 条知识`;
  2934. const heroCopy = document.querySelector(".knowledge-hero-copy p");
  2935. if (heroCopy) {
  2936. const chunkCount = Number(overview?.total_chunks || 0);
  2937. heroCopy.textContent = chunkCount
  2938. ? `已接入 ${formatNumber(count)} 篇知识、${formatNumber(chunkCount)} 个检索切片,结合关键词检索与来源追溯回答通防业务问题。`
  2939. : `已接入 ${formatNumber(count)} 篇知识,结合混合检索与来源追溯回答通防业务问题。`;
  2940. }
  2941. const categoryMap = new Map(
  2942. (overview?.category_counts || []).map((item) => [item.name, Number(item.count || 0)])
  2943. );
  2944. if (!categoryMap.size) {
  2945. items.forEach((item) => {
  2946. const key = item.category_name || item.category || item.knowledge_type || "其他";
  2947. categoryMap.set(key, (categoryMap.get(key) || 0) + 1);
  2948. });
  2949. }
  2950. document.querySelectorAll(".knowledge-category-grid .category-card").forEach((card) => {
  2951. const name = card.querySelector("strong")?.textContent?.trim();
  2952. const badge = card.querySelector("b");
  2953. if (badge) badge.textContent = formatNumber(categoryMap.get(name) ?? 0);
  2954. });
  2955. renderKnowledgeGraph(graph);
  2956. }
  2957. const KNOWLEDGE_CATEGORY_META = {
  2958. standards: {
  2959. name: "规范标准",
  2960. description: "规程、标准、办法及技术要求"
  2961. },
  2962. cases: {
  2963. name: "事故案例",
  2964. description: "典型事故与隐患处置案例"
  2965. },
  2966. expert: {
  2967. name: "专家经验",
  2968. description: "专家规则、判识经验与处置建议"
  2969. },
  2970. management: {
  2971. name: "管理流程",
  2972. description: "业务流程、责任岗位与闭环规则"
  2973. }
  2974. };
  2975. function closeKnowledgeCategory() {
  2976. const modal = document.querySelector("#knowledgeCatalogModal");
  2977. if (modal) modal.hidden = true;
  2978. document.body.classList.remove("knowledge-catalog-open");
  2979. }
  2980. async function loadKnowledgeCatalog(page = state.knowledgeCategoryPage) {
  2981. const category = state.knowledgeCategory;
  2982. const modal = document.querySelector("#knowledgeCatalogModal");
  2983. const content = document.querySelector("#knowledgeCatalogContent");
  2984. const pager = document.querySelector("#knowledgeCatalogPager");
  2985. if (!category || !modal || !content || !pager) return;
  2986. state.knowledgeCategoryPage = page;
  2987. content.innerHTML = '<div class="knowledge-catalog-empty">正在加载知识列表...</div>';
  2988. pager.innerHTML = "";
  2989. const params = new URLSearchParams({
  2990. category,
  2991. page: String(page),
  2992. page_size: String(state.knowledgeCategoryPageSize)
  2993. });
  2994. if (state.knowledgeCategoryKeyword) {
  2995. params.set("keyword", state.knowledgeCategoryKeyword);
  2996. }
  2997. try {
  2998. const data = await request(`/knowledge/catalog?${params.toString()}`, {
  2999. timeoutMs: 20000
  3000. });
  3001. state.knowledgeCategoryTotal = Number(data?.total || 0);
  3002. const title = document.querySelector("#knowledgeCatalogTitle");
  3003. const subtitle = document.querySelector("#knowledgeCatalogSubtitle");
  3004. if (title) title.textContent = KNOWLEDGE_CATEGORY_META[category]?.name || "知识列表";
  3005. if (subtitle) {
  3006. subtitle.textContent = `${KNOWLEDGE_CATEGORY_META[category]?.description || ""} · 共 ${formatNumber(state.knowledgeCategoryTotal)} 条`;
  3007. }
  3008. renderKnowledgeCatalog(data?.items || []);
  3009. renderKnowledgeCatalogPager();
  3010. } catch (error) {
  3011. content.innerHTML = `<div class="knowledge-catalog-empty">知识列表加载失败:${escapeHtml(error?.message || error)}</div>`;
  3012. window.showToast?.("知识列表加载失败", "warning");
  3013. }
  3014. }
  3015. function renderKnowledgeCatalog(items) {
  3016. const content = document.querySelector("#knowledgeCatalogContent");
  3017. if (!content) return;
  3018. if (!items.length) {
  3019. content.innerHTML = '<div class="knowledge-catalog-empty">当前分类下没有匹配的知识。</div>';
  3020. return;
  3021. }
  3022. content.innerHTML = `
  3023. <div class="knowledge-catalog-list">
  3024. ${items.map((item) => `
  3025. <button class="knowledge-catalog-item" type="button" data-knowledge-note="${escapeHtml(item.note_id)}">
  3026. <span class="knowledge-catalog-item-icon" data-icon="file-text"></span>
  3027. <span class="knowledge-catalog-item-copy">
  3028. <strong>${escapeHtml(item.title)}</strong>
  3029. <p>${escapeHtml([item.category_name, ...(item.tags || []).slice(0, 3)].filter(Boolean).join(" · "))}</p>
  3030. </span>
  3031. <span class="knowledge-catalog-item-meta">${escapeHtml(item.updated || "")}</span>
  3032. </button>
  3033. `).join("")}
  3034. </div>
  3035. `;
  3036. if (typeof window.injectIcons === "function") window.injectIcons(content);
  3037. }
  3038. function renderKnowledgeCatalogPager() {
  3039. const pager = document.querySelector("#knowledgeCatalogPager");
  3040. if (!pager) return;
  3041. const pages = Math.max(
  3042. 1,
  3043. Math.ceil(state.knowledgeCategoryTotal / state.knowledgeCategoryPageSize)
  3044. );
  3045. const current = state.knowledgeCategoryPage;
  3046. const pageButtons = [];
  3047. const start = Math.max(1, current - 2);
  3048. const end = Math.min(pages, current + 2);
  3049. for (let page = start; page <= end; page += 1) {
  3050. pageButtons.push(
  3051. `<button type="button" class="${page === current ? "active" : ""}" data-knowledge-page="${page}">${page}</button>`
  3052. );
  3053. }
  3054. pager.innerHTML = `
  3055. <span>第 ${current} / ${pages} 页</span>
  3056. <button type="button" data-knowledge-page="${current - 1}" ${current <= 1 ? "disabled" : ""}>上一页</button>
  3057. ${pageButtons.join("")}
  3058. <button type="button" data-knowledge-page="${current + 1}" ${current >= pages ? "disabled" : ""}>下一页</button>
  3059. `;
  3060. }
  3061. async function openKnowledgeDetail(noteId) {
  3062. const content = document.querySelector("#knowledgeCatalogContent");
  3063. const pager = document.querySelector("#knowledgeCatalogPager");
  3064. if (!content) return;
  3065. content.innerHTML = '<div class="knowledge-catalog-empty">正在加载知识正文...</div>';
  3066. if (pager) pager.innerHTML = "";
  3067. try {
  3068. const detail = await request(
  3069. `/knowledge/catalog/${encodeURIComponent(noteId)}`,
  3070. { timeoutMs: 20000 }
  3071. );
  3072. content.innerHTML = `
  3073. <div class="knowledge-catalog-detail-head">
  3074. <button class="secondary-button" type="button" data-knowledge-back>返回列表</button>
  3075. <div><h3>${escapeHtml(detail.title)}</h3></div>
  3076. </div>
  3077. <div class="knowledge-catalog-detail-tags">
  3078. ${[detail.category_name, ...(detail.tags || [])].filter(Boolean).map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")}
  3079. </div>
  3080. <div class="knowledge-catalog-detail-body">${renderMarkdown(detail.full_content || detail.summary || "")}</div>
  3081. `;
  3082. content.scrollTop = 0;
  3083. if (typeof window.injectIcons === "function") window.injectIcons(content);
  3084. } catch (error) {
  3085. content.innerHTML = `<div class="knowledge-catalog-empty">知识正文加载失败:${escapeHtml(error?.message || error)}</div>`;
  3086. }
  3087. }
  3088. async function openKnowledgeCategory(category) {
  3089. const meta = KNOWLEDGE_CATEGORY_META[category];
  3090. const modal = document.querySelector("#knowledgeCatalogModal");
  3091. if (!meta || !modal) return;
  3092. state.knowledgeCategory = category;
  3093. state.knowledgeCategoryPage = 1;
  3094. state.knowledgeCategoryKeyword = "";
  3095. const form = document.querySelector("#knowledgeCatalogSearch");
  3096. if (form) form.reset();
  3097. modal.hidden = false;
  3098. document.body.classList.add("knowledge-catalog-open");
  3099. await loadKnowledgeCatalog(1);
  3100. }
  3101. function installKnowledgeCategoryBrowser() {
  3102. if (window.__ventKnowledgeCategoryBrowserInstalled) return;
  3103. window.__ventKnowledgeCategoryBrowserInstalled = true;
  3104. document.querySelectorAll("[data-knowledge-category]").forEach((card) => {
  3105. const open = () => openKnowledgeCategory(card.dataset.knowledgeCategory);
  3106. card.addEventListener("click", open);
  3107. card.addEventListener("keydown", (event) => {
  3108. if (event.key === "Enter" || event.key === " ") {
  3109. event.preventDefault();
  3110. open();
  3111. }
  3112. });
  3113. });
  3114. document.querySelectorAll("[data-knowledge-catalog-close]").forEach((element) => {
  3115. element.addEventListener("click", closeKnowledgeCategory);
  3116. });
  3117. document.querySelector("#knowledgeCatalogSearch")?.addEventListener("submit", (event) => {
  3118. event.preventDefault();
  3119. const keyword = new FormData(event.currentTarget).get("keyword")?.toString().trim() || "";
  3120. state.knowledgeCategoryKeyword = keyword;
  3121. loadKnowledgeCatalog(1);
  3122. });
  3123. document.querySelector("#knowledgeCatalogContent")?.addEventListener("click", (event) => {
  3124. const back = event.target.closest("[data-knowledge-back]");
  3125. if (back) {
  3126. loadKnowledgeCatalog();
  3127. return;
  3128. }
  3129. const item = event.target.closest("[data-knowledge-note]");
  3130. if (item) openKnowledgeDetail(item.dataset.knowledgeNote);
  3131. });
  3132. document.querySelector("#knowledgeCatalogPager")?.addEventListener("click", (event) => {
  3133. const button = event.target.closest("[data-knowledge-page]");
  3134. if (!button || button.disabled) return;
  3135. loadKnowledgeCatalog(Number(button.dataset.knowledgePage));
  3136. });
  3137. document.addEventListener("keydown", (event) => {
  3138. if (event.key === "Escape" && !document.querySelector("#knowledgeCatalogModal")?.hidden) {
  3139. closeKnowledgeCategory();
  3140. }
  3141. });
  3142. }
  3143. function renderKnowledgeError(container, error, compact = false) {
  3144. const notLoaded =
  3145. Number(error?.status) === 404 ||
  3146. String(error?.message || "").includes("404");
  3147. if (notLoaded) {
  3148. container.innerHTML = compact
  3149. ? `
  3150. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>接口未加载</span></div>
  3151. <p>知识检索接口未在当前后端进程中加载,请重启后端服务后重试。</p>
  3152. `
  3153. : `
  3154. <div class="panel-head">
  3155. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3156. <span class="subtle-badge">接口未加载</span>
  3157. </div>
  3158. <p>知识检索接口未在当前后端进程中加载,请重启后端服务后重试。</p>
  3159. `;
  3160. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3161. window.showToast?.("知识接口未加载,请重启后端服务", "warning");
  3162. return;
  3163. }
  3164. container.innerHTML = compact
  3165. ? `
  3166. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>检索失败</span></div>
  3167. <p>知识库检索失败,请稍后重试。</p>
  3168. `
  3169. : `
  3170. <div class="panel-head">
  3171. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3172. <span class="subtle-badge">检索失败</span>
  3173. </div>
  3174. <p>知识库检索失败,请稍后重试。</p>
  3175. `;
  3176. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3177. window.showToast?.("知识库检索失败,请稍后重试", "warning");
  3178. }
  3179. function sourceView(source) {
  3180. const score = Number(source.score || 0);
  3181. return {
  3182. citationIndex: Number(source.citation_index || 0),
  3183. title: source.title,
  3184. scoreText: score > 0 ? `${Math.round(score * 100)}%` : "相关"
  3185. };
  3186. }
  3187. function renderMarkdown(value) {
  3188. const codeBlocks = [];
  3189. const source = String(value || "")
  3190. .replace(/\r\n/g, "\n")
  3191. .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, language, code) => {
  3192. codeBlocks.push({ language, code });
  3193. return `\n@@CODEBLOCK${codeBlocks.length - 1}@@\n`;
  3194. });
  3195. const lines = escapeHtml(source).split("\n");
  3196. const output = [];
  3197. let listType = null;
  3198. const closeList = () => {
  3199. if (listType) output.push(`</${listType}>`);
  3200. listType = null;
  3201. };
  3202. const inline = (text) =>
  3203. text
  3204. .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
  3205. .replace(/\*(.+?)\*/g, "<em>$1</em>")
  3206. .replace(/`([^`]+)`/g, "<code>$1</code>")
  3207. .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
  3208. for (const rawLine of lines) {
  3209. const line = rawLine.trimEnd();
  3210. const codeMatch = line.trim().match(/^@@CODEBLOCK(\d+)@@$/);
  3211. if (codeMatch) {
  3212. closeList();
  3213. const block = codeBlocks[Number(codeMatch[1])];
  3214. if (block) {
  3215. output.push(
  3216. `<pre><code${block.language ? ` class="language-${escapeHtml(block.language)}"` : ""}>${escapeHtml(block.code.trim())}</code></pre>`
  3217. );
  3218. }
  3219. continue;
  3220. }
  3221. const heading = line.match(/^\s*(#{1,6})\s+(.+)$/);
  3222. if (heading) {
  3223. closeList();
  3224. const level = heading[1].length;
  3225. output.push(`<h${level}>${inline(heading[2].trim())}</h${level}>`);
  3226. continue;
  3227. }
  3228. const bullet = line.match(/^\s*[-*]\s+(.+)$/);
  3229. const ordered = line.match(/^\s*\d+[.、)]\s+(.+)$/);
  3230. if (bullet || ordered) {
  3231. const nextType = bullet ? "ul" : "ol";
  3232. if (listType !== nextType) {
  3233. closeList();
  3234. listType = nextType;
  3235. output.push(`<${listType}>`);
  3236. }
  3237. output.push(`<li>${inline((bullet || ordered)[1].trim())}</li>`);
  3238. continue;
  3239. }
  3240. closeList();
  3241. if (line.trim()) output.push(`<p>${inline(line.trim())}</p>`);
  3242. }
  3243. closeList();
  3244. return output.join("");
  3245. }
  3246. function createKnowledgeStreamView(container, question) {
  3247. let answer = "";
  3248. let thinking = "";
  3249. let answerEl = null;
  3250. let statusEl = null;
  3251. let thinkingPanel = null;
  3252. let thinkingEl = null;
  3253. let thinkingState = null;
  3254. let renderFrame = 0;
  3255. let thinkingFrame = 0;
  3256. const renderMeta = (data = {}) => {
  3257. const sources = (data.sources || []).map(sourceView);
  3258. container.innerHTML = `
  3259. <div class="panel-head">
  3260. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3261. <span class="subtle-badge" data-rag-status>RAG 生成中</span>
  3262. </div>
  3263. <div class="ai-answer">
  3264. <div class="ai-avatar"><span data-icon="bot"></span></div>
  3265. <div class="ai-answer-body">
  3266. <p><strong>问题:</strong>${escapeHtml(question)}</p>
  3267. <details class="rag-thinking" data-rag-thinking hidden>
  3268. <summary><span data-icon="brain"></span><span>思考过程</span><span data-rag-thinking-state>思考中</span></summary>
  3269. <div class="rag-thinking-content" data-rag-thinking-content></div>
  3270. </details>
  3271. <div class="rag-answer-markdown" data-rag-answer></div>
  3272. <div class="source-citations" data-rag-sources hidden>
  3273. ${sources.map((source) => `<button type="button"><span data-icon="file-text"></span>${source.citationIndex ? `[${source.citationIndex}] ` : ""}${escapeHtml(source.title)} <b>${escapeHtml(source.scoreText)}</b></button>`).join("")}
  3274. </div>
  3275. </div>
  3276. </div>
  3277. `;
  3278. answerEl = container.querySelector("[data-rag-answer]");
  3279. statusEl = container.querySelector("[data-rag-status]");
  3280. thinkingPanel = container.querySelector("[data-rag-thinking]");
  3281. thinkingEl = container.querySelector("[data-rag-thinking-content]");
  3282. thinkingState = container.querySelector("[data-rag-thinking-state]");
  3283. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3284. };
  3285. const scrollToBottom = () => {
  3286. const scroller = container.querySelector(".ai-answer");
  3287. if (scroller) scroller.scrollTop = scroller.scrollHeight;
  3288. };
  3289. const renderAnswer = () => {
  3290. renderFrame = 0;
  3291. if (!answerEl) return;
  3292. answerEl.innerHTML = renderMarkdown(answer);
  3293. scrollToBottom();
  3294. };
  3295. const scheduleAnswer = () => {
  3296. if (renderFrame) return;
  3297. renderFrame = window.requestAnimationFrame(renderAnswer);
  3298. };
  3299. const renderThinking = (finished = false) => {
  3300. thinkingFrame = 0;
  3301. if (!thinkingPanel || !thinkingEl) return;
  3302. thinkingPanel.hidden = !thinking;
  3303. thinkingEl.textContent = thinking;
  3304. if (thinkingState) thinkingState.textContent = finished ? "已完成" : "思考中";
  3305. scrollToBottom();
  3306. };
  3307. const scheduleThinking = () => {
  3308. if (thinkingFrame) return;
  3309. thinkingFrame = window.requestAnimationFrame(() => renderThinking(false));
  3310. };
  3311. return {
  3312. get answer() {
  3313. return answer;
  3314. },
  3315. meta: renderMeta,
  3316. thinking(content) {
  3317. if (!answerEl) renderMeta();
  3318. thinking += String(content || "");
  3319. scheduleThinking();
  3320. },
  3321. delta(content) {
  3322. if (!answerEl) renderMeta();
  3323. answer += String(content || "");
  3324. scheduleAnswer();
  3325. },
  3326. done(data = {}) {
  3327. answer = String(data.answer || answer);
  3328. if (data.thinking) thinking = String(data.thinking);
  3329. if (renderFrame) window.cancelAnimationFrame(renderFrame);
  3330. if (thinkingFrame) window.cancelAnimationFrame(thinkingFrame);
  3331. renderAnswer();
  3332. renderThinking(true);
  3333. container.querySelector("[data-rag-sources]")?.removeAttribute("hidden");
  3334. if (statusEl) {
  3335. const status = data.answer_status || "generated";
  3336. const generated = status === "generated";
  3337. const conversational = status === "conversational";
  3338. statusEl.className = `subtle-badge ${generated || conversational ? "success" : ""}`;
  3339. statusEl.textContent = generated
  3340. ? "RAG 大模型生成"
  3341. : conversational
  3342. ? "智能助手"
  3343. : "检索摘要";
  3344. }
  3345. }
  3346. };
  3347. }
  3348. async function streamKnowledgeAnswerFetch(container, question) {
  3349. const controller = new AbortController();
  3350. const timeoutId = window.setTimeout(() => controller.abort(), 120000);
  3351. try {
  3352. const response = await fetch(`${API_BASE}/knowledge/rag/stream`, {
  3353. method: "POST",
  3354. headers: {
  3355. Accept: "text/event-stream",
  3356. "Content-Type": "application/json"
  3357. },
  3358. body: JSON.stringify({
  3359. question,
  3360. top_k: 5,
  3361. generate_answer: true
  3362. }),
  3363. signal: controller.signal
  3364. });
  3365. if (!response.ok) {
  3366. let message = `HTTP ${response.status}`;
  3367. let payload = null;
  3368. try {
  3369. payload = await response.json();
  3370. message = payload?.message || payload?.detail || message;
  3371. } catch {
  3372. // Keep the HTTP status.
  3373. }
  3374. const error = new Error(message);
  3375. error.status = response.status;
  3376. throw error;
  3377. }
  3378. if (!response.body) throw new Error("当前浏览器不支持流式响应");
  3379. const reader = response.body.getReader();
  3380. const decoder = new TextDecoder("utf-8");
  3381. let buffer = "";
  3382. const view = createKnowledgeStreamView(container, question);
  3383. const handleEvent = (eventName, data) => {
  3384. if (eventName === "meta") {
  3385. view.meta(data);
  3386. } else if (eventName === "thinking") {
  3387. view.thinking(data.content || "");
  3388. } else if (eventName === "delta") {
  3389. view.delta(data.content || "");
  3390. } else if (eventName === "warning") {
  3391. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  3392. } else if (eventName === "done") {
  3393. view.done(data);
  3394. }
  3395. };
  3396. const processBlock = (block) => {
  3397. if (!block.trim()) return;
  3398. let eventName = "message";
  3399. const dataLines = [];
  3400. for (const line of block.split(/\r?\n/)) {
  3401. if (line.startsWith("event:")) eventName = line.slice(6).trim();
  3402. if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
  3403. }
  3404. if (!dataLines.length) return;
  3405. handleEvent(eventName, JSON.parse(dataLines.join("\n")));
  3406. };
  3407. while (true) {
  3408. const { done, value } = await reader.read();
  3409. buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
  3410. const blocks = buffer.split(/\r?\n\r?\n/);
  3411. buffer = blocks.pop() || "";
  3412. blocks.forEach(processBlock);
  3413. if (done) break;
  3414. }
  3415. if (buffer.trim()) processBlock(buffer);
  3416. } finally {
  3417. window.clearTimeout(timeoutId);
  3418. }
  3419. }
  3420. function streamKnowledgeAnswerEventSource(container, question) {
  3421. return new Promise((resolve, reject) => {
  3422. const url =
  3423. `${API_BASE}/knowledge/rag/stream?question=${encodeURIComponent(question)}` +
  3424. "&top_k=5";
  3425. const source = new EventSource(url);
  3426. const view = createKnowledgeStreamView(container, question);
  3427. let received = false;
  3428. let settled = false;
  3429. const timeoutId = window.setTimeout(() => {
  3430. if (settled) return;
  3431. source.close();
  3432. settled = true;
  3433. reject(new Error("流式响应超时"));
  3434. }, 120000);
  3435. const finish = () => {
  3436. if (settled) return;
  3437. settled = true;
  3438. window.clearTimeout(timeoutId);
  3439. source.close();
  3440. resolve();
  3441. };
  3442. source.addEventListener("meta", (event) => {
  3443. received = true;
  3444. view.meta(JSON.parse(event.data));
  3445. });
  3446. source.addEventListener("thinking", (event) => {
  3447. received = true;
  3448. const data = JSON.parse(event.data);
  3449. view.thinking(data.content || "");
  3450. });
  3451. source.addEventListener("delta", (event) => {
  3452. received = true;
  3453. const data = JSON.parse(event.data);
  3454. view.delta(data.content || "");
  3455. });
  3456. source.addEventListener("warning", (event) => {
  3457. const data = JSON.parse(event.data);
  3458. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  3459. });
  3460. source.addEventListener("done", (event) => {
  3461. const data = JSON.parse(event.data);
  3462. view.done(data);
  3463. finish();
  3464. });
  3465. source.onerror = () => {
  3466. if (settled) return;
  3467. window.clearTimeout(timeoutId);
  3468. source.close();
  3469. if (received && view.answer) {
  3470. settled = true;
  3471. resolve();
  3472. return;
  3473. }
  3474. settled = true;
  3475. reject(new Error("SSE 连接失败"));
  3476. };
  3477. });
  3478. }
  3479. function streamKnowledgeAnswerWebSocket(container, question) {
  3480. return new Promise((resolve, reject) => {
  3481. const protocol = window.location.protocol === "https:" ? "wss" : "ws";
  3482. const socket = new WebSocket(
  3483. `${protocol}://${window.location.host}${API_BASE}/knowledge/rag/ws`
  3484. );
  3485. const view = createKnowledgeStreamView(container, question);
  3486. let received = false;
  3487. let settled = false;
  3488. const finish = () => {
  3489. if (settled) return;
  3490. settled = true;
  3491. socket.close();
  3492. resolve();
  3493. };
  3494. const fail = (error) => {
  3495. if (settled) return;
  3496. settled = true;
  3497. error.streamStarted = received;
  3498. socket.close();
  3499. reject(error);
  3500. };
  3501. socket.onopen = () => {
  3502. socket.send(
  3503. JSON.stringify({
  3504. question,
  3505. top_k: 5,
  3506. generate_answer: true
  3507. })
  3508. );
  3509. };
  3510. socket.onmessage = (event) => {
  3511. const item = JSON.parse(event.data);
  3512. const data = item.data || {};
  3513. if (item.event === "meta") {
  3514. received = true;
  3515. view.meta(data);
  3516. } else if (item.event === "thinking") {
  3517. received = true;
  3518. view.thinking(data.content || "");
  3519. } else if (item.event === "delta") {
  3520. received = true;
  3521. view.delta(data.content || "");
  3522. } else if (item.event === "warning") {
  3523. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  3524. } else if (item.event === "done") {
  3525. view.done(data);
  3526. finish();
  3527. } else if (item.event === "error") {
  3528. fail(new Error(data.message || "流式问答失败"));
  3529. }
  3530. };
  3531. socket.onerror = () => fail(new Error("WebSocket 连接失败"));
  3532. socket.onclose = () => {
  3533. if (!settled && !received) fail(new Error("WebSocket 连接已关闭"));
  3534. else if (!settled) finish();
  3535. };
  3536. });
  3537. }
  3538. function streamKnowledgeAnswer(container, question) {
  3539. if (typeof WebSocket !== "undefined") {
  3540. return streamKnowledgeAnswerWebSocket(container, question).catch((error) => {
  3541. if (error?.streamStarted) throw error;
  3542. return streamKnowledgeAnswerEventSource(container, question);
  3543. });
  3544. }
  3545. if (typeof EventSource !== "undefined") {
  3546. return streamKnowledgeAnswerEventSource(container, question);
  3547. }
  3548. return streamKnowledgeAnswerFetch(container, question);
  3549. }
  3550. function installKnowledgeRenderer() {
  3551. if (window.__ventApiKnowledgeRendererInstalled) return;
  3552. window.__ventApiKnowledgeRendererInstalled = true;
  3553. const originalRenderAnswer = window.renderAnswer;
  3554. if (typeof originalRenderAnswer !== "function") return;
  3555. window.renderAnswer = function renderAnswerWithApi(container, question, compact = false) {
  3556. if (!state.apiAvailable) {
  3557. originalRenderAnswer(container, question, compact);
  3558. return;
  3559. }
  3560. if (compact) {
  3561. container.innerHTML = `
  3562. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>正在检索知识库</span></div>
  3563. <p>正在检索相关知识,请稍候...</p>
  3564. `;
  3565. } else {
  3566. container.innerHTML = `
  3567. <div class="panel-head">
  3568. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3569. <span class="subtle-badge">检索中</span>
  3570. </div>
  3571. <div class="ai-answer">
  3572. <div class="ai-avatar"><span data-icon="bot"></span></div>
  3573. <div class="ai-answer-body">
  3574. <p><strong>问题:</strong>${escapeHtml(question)}</p>
  3575. <p>正在检索知识库并整理依据,请稍候...</p>
  3576. </div>
  3577. </div>
  3578. `;
  3579. }
  3580. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3581. if (!compact) {
  3582. streamKnowledgeAnswer(container, question).catch((error) => {
  3583. renderKnowledgeError(container, error, false);
  3584. });
  3585. return;
  3586. }
  3587. request("/knowledge/ask", {
  3588. method: "POST",
  3589. body: JSON.stringify({
  3590. question,
  3591. top_k: 3,
  3592. generate_answer: false
  3593. }),
  3594. timeoutMs: 30000
  3595. })
  3596. .then((result) => {
  3597. if (!result?.answer) return;
  3598. const answerText = result.answer;
  3599. const answerHtml = escapeHtml(answerText).replace(/\r?\n/g, "<br>");
  3600. const sources = (result.sources || []).map((source) => ({
  3601. citationIndex: Number(source.citation_index || 0),
  3602. title: source.title,
  3603. score: Number(source.score || 0),
  3604. scoreText:
  3605. Number(source.score || 0) > 0
  3606. ? `${Math.round(Number(source.score || 0) * 100)}%`
  3607. : "相关"
  3608. }));
  3609. const modeLabel = result.search_mode === "hybrid" ? "混合检索" : "关键词检索";
  3610. container.innerHTML = `
  3611. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>${modeLabel} · 命中 ${result.total_candidates || sources.length} 条</span></div>
  3612. <p>${answerHtml}</p>
  3613. <div class="answer-sources">${sources.map((source) => `<span>${escapeHtml(source.title)}</span>`).join("")}</div>
  3614. `;
  3615. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3616. })
  3617. .catch((error) => renderKnowledgeError(container, error, true));
  3618. };
  3619. }
  3620. function severityClass(severity) {
  3621. return severity === "high" ? "high" : severity === "low" ? "low" : "medium";
  3622. }
  3623. function sceneMeta(scene) {
  3624. const keyMap = {
  3625. "局部通风": ["local", "orange"],
  3626. "通风设施": ["facility", "yellow"],
  3627. "通风系统管理": ["system", "teal"],
  3628. "风量分配": ["volume", "blue"],
  3629. "灾害防治": ["disaster", "red"]
  3630. };
  3631. return keyMap[scene] || ["system", "teal"];
  3632. }
  3633. function statusMeta(status) {
  3634. if (["completed", "closed", "verified", "已记录"].includes(status)) return ["closed", "已记录"];
  3635. if (["processing", "处理中"].includes(status)) return ["processing", "处理中"];
  3636. return ["pending", "待处理"];
  3637. }
  3638. function renderDiagnosis(defects) {
  3639. const list = defects?.length ? defects : DEMO_DIAGNOSIS;
  3640. const normalized = list.map((item, index) => {
  3641. const [sceneKey] = sceneMeta(item.scene);
  3642. return {
  3643. ...item,
  3644. id: item.id || `risk-api-${index}`,
  3645. scene_key: sceneKey,
  3646. defect_code: item.defect_code || item.code || `RISK-API-${index + 1}`,
  3647. evidence: item.evidence || (
  3648. sceneKey === "disaster"
  3649. ? ["radio-tower", "video", "file-text"]
  3650. : sceneKey === "volume"
  3651. ? ["gauge", "file-text"]
  3652. : ["radio-tower", "activity", "file-text"]
  3653. )
  3654. };
  3655. });
  3656. window.setHazardRisks?.(normalized);
  3657. }
  3658. function renderEvaluation(evaluation) {
  3659. const run = evaluation.run || DEMO_EVALUATION.run;
  3660. const dimensions = evaluation.dimensions?.length ? evaluation.dimensions : DEMO_EVALUATION.dimensions;
  3661. const score = Number(run.total_score || 0);
  3662. const grade = String(run.grade || "A").replace("级", "");
  3663. const summary = run.summary || "综合评价已完成,运行状态稳定。";
  3664. setText(".evaluation-grade strong", grade);
  3665. setText(".evaluation-grade em", score >= 90 ? "运行状态优良" : score >= 80 ? "运行状态良好" : "需要持续改进");
  3666. setText(".evaluation-summary-copy .eyebrow", `${formatDate(run.created_at || new Date())} · 自动评价`);
  3667. setText(".evaluation-summary-copy h2", `${state.mine?.name || "上湾煤矿"}通防业务综合评价报告`);
  3668. setText(".evaluation-summary-copy p", summary);
  3669. setText(".radar-panel .subtle-badge", `综合得分 ${formatNumber(score)}`);
  3670. const gradeBox = document.querySelector(".evaluation-grade");
  3671. if (gradeBox) gradeBox.dataset.score = String(score);
  3672. const dimensionsList = document.querySelector(".dimension-list");
  3673. const iconMap = ["shield-check", "activity", "circle-dollar-sign", "clipboard-check", "database"];
  3674. const colorMap = ["green", "blue", "amber", "purple", "teal"];
  3675. if (dimensionsList) {
  3676. dimensionsList.innerHTML = dimensions
  3677. .slice(0, 5)
  3678. .map((item, index) => {
  3679. const change = Number(item.change || 0);
  3680. return `
  3681. <div class="dimension-row">
  3682. <span class="dimension-icon ${colorMap[index % colorMap.length]}" data-icon="${iconMap[index % iconMap.length]}"></span>
  3683. <div><strong>${escapeHtml(item.name)}</strong><p>${escapeHtml(item.note || "业务综合指标")}</p></div>
  3684. <div class="dimension-score"><b>${Number(item.score || 0).toFixed(1)}</b><span class="${change >= 0 ? "up" : "down"}"><i data-icon="${change >= 0 ? "arrow-up" : "arrow-down"}"></i>${Math.abs(change).toFixed(1)}</span></div>
  3685. </div>
  3686. `;
  3687. })
  3688. .join("");
  3689. if (typeof window.injectIcons === "function") window.injectIcons(dimensionsList);
  3690. }
  3691. document.querySelectorAll(".radar-labels text").forEach((label, index) => {
  3692. const item = dimensions[index];
  3693. if (item) label.textContent = `${item.name} ${Number(item.score || 0).toFixed(1)}`;
  3694. });
  3695. const agentText = document.querySelector(".evaluation-agent-panel .agent-copy p");
  3696. if (agentText) {
  3697. agentText.textContent = `“${summary}”`;
  3698. }
  3699. const suggestions = document.querySelector(".evaluation-agent-panel .agent-suggestions");
  3700. if (suggestions && run.recommendations?.length) {
  3701. suggestions.innerHTML = run.recommendations
  3702. .slice(0, 3)
  3703. .map((item, index) => `<span><i>${index + 1}</i>${escapeHtml(item)}</span>`)
  3704. .join("");
  3705. }
  3706. }
  3707. function renderWorkOrders(orders) {
  3708. const items = orders?.length ? orders : DEMO_WORK_ORDERS;
  3709. const groups = [
  3710. { statuses: ["pending"], title: "待确认", dot: "pending" },
  3711. { statuses: ["processing"], title: "处理中", dot: "processing" },
  3712. { statuses: ["review"], title: "待复核", dot: "review" },
  3713. { statuses: ["completed", "verified", "closed"], title: "已完成", dot: "closed" }
  3714. ];
  3715. const columns = document.querySelectorAll("#view-closed-loop .kanban-column");
  3716. groups.forEach((group, index) => {
  3717. const column = columns[index];
  3718. if (!column) return;
  3719. const groupItems = items.filter((item) => group.statuses.includes(item.status));
  3720. const head = column.querySelector(".kanban-head span");
  3721. if (head) head.innerHTML = `<i class="column-dot ${group.dot}"></i>${group.title} <b>${groupItems.length}</b>`;
  3722. const cards = groupItems.map((item) => {
  3723. const priority = item.priority || "medium";
  3724. const priorityLabel = priority === "high" ? "高优先级" : priority === "low" ? "常规" : "中优先级";
  3725. return `
  3726. <button class="work-card priority-${priority}" type="button" data-order-id="${escapeHtml(item.id)}">
  3727. <div class="work-card-head"><span class="work-tag ${priority}">${priorityLabel}</span><span>${formatTime(item.created_at)}</span></div>
  3728. <strong>${escapeHtml(item.title)}</strong>
  3729. <p>${escapeHtml(item.content || "待补充处置说明。")}</p>
  3730. <div class="work-meta"><span class="avatar mini">${escapeHtml((item.assignee_name || "系").slice(0, 1))}</span><span>${escapeHtml(item.assignee_name || "待分派")}</span><span>${group.dot === "closed" ? "闭环" : `截止 ${formatTime(item.due_at)}`}</span></div>
  3731. </button>
  3732. `;
  3733. });
  3734. column.innerHTML = head ? `${head.outerHTML}${cards.join("")}` : cards.join("");
  3735. column.querySelectorAll(".work-card").forEach((card) => {
  3736. card.addEventListener("click", () => window.showToast?.(`已打开任务:${card.querySelector("strong")?.textContent || "整改任务"}`));
  3737. });
  3738. });
  3739. const toolbar = document.querySelector("#view-closed-loop .toolbar-copy p");
  3740. const closed = items.filter((item) => ["completed", "verified", "closed"].includes(item.status)).length;
  3741. if (toolbar) {
  3742. const rate = items.length ? (closed / items.length * 100).toFixed(1) : "0.0";
  3743. toolbar.textContent = `当前展示 ${items.length} 项整改任务,已完成 ${closed} 项,闭环率 ${rate}%`;
  3744. }
  3745. }
  3746. async function loadMine() {
  3747. try {
  3748. const mines = await request("/mines");
  3749. const mine = mines.find((item) => item.code === DEFAULT_MINE_CODE) || mines.find((item) => item.is_default) || mines[0];
  3750. if (!mine) throw new Error("no mine returned");
  3751. state.apiAvailable = true;
  3752. state.mine = mine;
  3753. return { data: mine, source: "api" };
  3754. } catch (error) {
  3755. state.errors.push(`mines: ${error?.message || error}`);
  3756. state.apiAvailable = false;
  3757. state.mine = DEMO_MINE;
  3758. return { data: DEMO_MINE, source: "demo" };
  3759. }
  3760. }
  3761. async function loadDocuments() {
  3762. const categoryQuery = state.documentCategory && state.documentCategory !== "all"
  3763. ? `&category=${encodeURIComponent(state.documentCategory)}`
  3764. : "";
  3765. try {
  3766. const keyword = state.documentKeyword.trim();
  3767. const documentRequest = keyword
  3768. ? request(
  3769. withMine(
  3770. `/documents/search?q=${encodeURIComponent(keyword)}&page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}`
  3771. )
  3772. )
  3773. : request(
  3774. withMine(
  3775. `/documents?page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}`
  3776. )
  3777. );
  3778. const [pageResult, categories] = await Promise.all([
  3779. documentRequest,
  3780. request(withMine("/documents/categories"))
  3781. ]);
  3782. state.documentSearchSnippets = new Map();
  3783. const items = keyword
  3784. ? (pageResult.items || []).map((hit) => {
  3785. state.documentSearchSnippets.set(
  3786. String(hit.document?.id),
  3787. hit.snippets || []
  3788. );
  3789. return hit.document;
  3790. })
  3791. : pageResult?.items || [];
  3792. return {
  3793. data: {
  3794. page: {
  3795. items,
  3796. total: Number(pageResult?.total || 0),
  3797. page: Number(pageResult?.page || state.documentPage),
  3798. page_size: Number(pageResult?.page_size || state.documentPageSize)
  3799. },
  3800. categories: categories || []
  3801. },
  3802. source: "api"
  3803. };
  3804. } catch (error) {
  3805. state.errors.push(`documents: ${error?.message || error}`);
  3806. return { data: EMPTY_DOCUMENTS, source: "demo" };
  3807. }
  3808. }
  3809. async function loadDocumentStatistics() {
  3810. return withFallback(
  3811. "document-statistics",
  3812. () => request(withMine("/documents/statistics")),
  3813. DEMO_DOCUMENT_STATISTICS
  3814. );
  3815. }
  3816. async function loadBusinessTemplates() {
  3817. return withFallback(
  3818. "document-templates",
  3819. async () => {
  3820. const templates = await request("/documents/templates");
  3821. if (!templates?.length) throw new Error("empty response");
  3822. return templates;
  3823. },
  3824. []
  3825. );
  3826. }
  3827. async function loadMonitoring() {
  3828. return withFallback(
  3829. "monitoring",
  3830. async () => {
  3831. const [overview, sources, sensors, facilities, alarms] = await Promise.all([
  3832. request(withMine("/monitoring/overview")),
  3833. request(withMine("/monitoring/sources")),
  3834. request(withMine("/monitoring/sensors")),
  3835. request(withMine("/monitoring/facilities")),
  3836. request(withMine("/monitoring/alarms?limit=100"))
  3837. ]);
  3838. return { overview, sources, sensors, facilities, alarms };
  3839. },
  3840. DEMO_MONITORING
  3841. );
  3842. }
  3843. async function loadKnowledge() {
  3844. return withFallback(
  3845. "knowledge",
  3846. async () => {
  3847. const [overview, catalog, graph] = await Promise.all([
  3848. request("/knowledge/overview", { timeoutMs: 15000 }),
  3849. request("/knowledge/catalog?page=1&page_size=200", { timeoutMs: 15000 }),
  3850. request("/knowledge/graph", { timeoutMs: 15000 })
  3851. ]);
  3852. if (!overview?.total_notes && !catalog?.items?.length) {
  3853. throw new Error("empty response");
  3854. }
  3855. return { overview, catalog, graph };
  3856. },
  3857. { overview: null, catalog: { items: DEMO_KNOWLEDGE }, graph: null }
  3858. );
  3859. }
  3860. async function loadDiagnosis() {
  3861. return withFallback(
  3862. "diagnosis",
  3863. async () => {
  3864. const tasks = await request(withMine("/diagnosis/tasks?limit=20"));
  3865. if (!tasks?.length) throw new Error("empty response");
  3866. const details = await Promise.all(
  3867. tasks.slice(0, 5).map((task) => request(`/diagnosis/tasks/${encodeURIComponent(task.id)}`))
  3868. );
  3869. const defects = details.flatMap((task) =>
  3870. (task.results || []).map((result) => ({
  3871. id: result.id,
  3872. defect_name: result.defect_name,
  3873. defect_code: result.defect_code,
  3874. scene: result.scene,
  3875. severity: result.severity,
  3876. confidence: Number(result.confidence || 0) * (Number(result.confidence || 0) <= 1 ? 100 : 1),
  3877. status: result.status,
  3878. conclusion: result.conclusion,
  3879. recommendation: result.recommendation,
  3880. occurred_at: formatTime(result.created_at || task.created_at)
  3881. }))
  3882. );
  3883. if (!defects.length) throw new Error("empty response");
  3884. return defects;
  3885. },
  3886. DEMO_DIAGNOSIS
  3887. );
  3888. }
  3889. async function loadEvaluation() {
  3890. return withFallback(
  3891. "evaluations",
  3892. async () => {
  3893. const [indicators, runs] = await Promise.all([
  3894. request(withMine("/evaluations/indicators")),
  3895. request(withMine("/evaluations/runs?limit=1"))
  3896. ]);
  3897. if (!runs?.length) throw new Error("empty response");
  3898. const run = await request(`/evaluations/runs/${encodeURIComponent(runs[0].id)}`);
  3899. const indicatorMap = new Map((indicators || []).map((item) => [String(item.id), item]));
  3900. const dimensions = (run.scores || [])
  3901. .map((score) => {
  3902. const indicator = indicatorMap.get(String(score.indicator_id));
  3903. return {
  3904. name: indicator?.dimension || indicator?.name,
  3905. score: Number(score.score || 0),
  3906. change: 0,
  3907. note: indicator?.name || "评价指标得分"
  3908. };
  3909. })
  3910. .filter((item) => item.name);
  3911. return { run, dimensions: dimensions.length ? dimensions : DEMO_EVALUATION.dimensions };
  3912. },
  3913. DEMO_EVALUATION
  3914. );
  3915. }
  3916. async function loadWorkOrders() {
  3917. return withFallback(
  3918. "work-orders",
  3919. async () => {
  3920. const page = await request(withMine("/work-orders?page=1&page_size=200"));
  3921. if (!page?.items?.length) throw new Error("empty response");
  3922. return page.items;
  3923. },
  3924. DEMO_WORK_ORDERS
  3925. );
  3926. }
  3927. async function runHazardAgent(agent) {
  3928. const sceneName = {
  3929. local: "局部通风",
  3930. facility: "通风设施",
  3931. system: "通风系统管理",
  3932. volume: "风量分配",
  3933. disaster: "灾害防治"
  3934. }[agent.sceneKey] || "all";
  3935. let source = "demo";
  3936. if (state.apiAvailable && state.mine?.id) {
  3937. try {
  3938. await request("/diagnosis/run", {
  3939. method: "POST",
  3940. body: JSON.stringify({
  3941. mine_id: state.mine.id,
  3942. scene: sceneName,
  3943. requested_by: "隐患识别中心"
  3944. })
  3945. });
  3946. source = "api";
  3947. } catch (error) {
  3948. state.errors.push(`hazard-agent:${agent.id}: ${error?.message || error}`);
  3949. }
  3950. }
  3951. const localFindings = window.getHazardAgentFindings?.(agent) || [];
  3952. await new Promise((resolve) => window.setTimeout(resolve, 650));
  3953. return { findings: localFindings, source };
  3954. }
  3955. function installActionInterceptors() {
  3956. const diagnosisButton = document.querySelector("[data-run-diagnosis]");
  3957. if (diagnosisButton && !diagnosisButton.dataset.apiBound) {
  3958. diagnosisButton.dataset.apiBound = "true";
  3959. diagnosisButton.addEventListener(
  3960. "click",
  3961. async (event) => {
  3962. event.stopImmediatePropagation();
  3963. if (!state.apiAvailable) {
  3964. window.showToast?.("后端不可用,已使用本地演示诊断结果", "warning");
  3965. return;
  3966. }
  3967. const original = diagnosisButton.innerHTML;
  3968. diagnosisButton.disabled = true;
  3969. diagnosisButton.innerHTML = '<span data-icon="refresh-cw"></span>诊断进行中';
  3970. window.injectIcons?.(diagnosisButton);
  3971. try {
  3972. await request("/diagnosis/run", {
  3973. method: "POST",
  3974. body: JSON.stringify({
  3975. mine_id: state.mine.id,
  3976. scene: "all",
  3977. requested_by: "前端用户"
  3978. })
  3979. });
  3980. const result = await loadDiagnosis();
  3981. renderDiagnosis(result.data);
  3982. window.showToast?.(`全量诊断完成,识别到 ${result.data.length} 项缺陷与风险`);
  3983. } catch {
  3984. renderDiagnosis(DEMO_DIAGNOSIS);
  3985. window.showToast?.("诊断接口不可用,已回退到本地演示数据", "warning");
  3986. } finally {
  3987. diagnosisButton.disabled = false;
  3988. diagnosisButton.innerHTML = original;
  3989. window.injectIcons?.(diagnosisButton);
  3990. }
  3991. },
  3992. true
  3993. );
  3994. }
  3995. const evaluationButton = document.querySelector("[data-start-evaluation]");
  3996. if (evaluationButton && !evaluationButton.dataset.apiBound) {
  3997. evaluationButton.dataset.apiBound = "true";
  3998. evaluationButton.addEventListener(
  3999. "click",
  4000. async (event) => {
  4001. event.stopImmediatePropagation();
  4002. if (!state.apiAvailable) {
  4003. renderEvaluation(DEMO_EVALUATION);
  4004. window.showToast?.("后端不可用,已使用本地演示评价结果", "warning");
  4005. return;
  4006. }
  4007. const original = evaluationButton.innerHTML;
  4008. evaluationButton.disabled = true;
  4009. evaluationButton.innerHTML = '<span data-icon="refresh-cw"></span>评价计算中';
  4010. window.injectIcons?.(evaluationButton);
  4011. try {
  4012. await request("/evaluations/run", {
  4013. method: "POST",
  4014. body: JSON.stringify({
  4015. mine_id: state.mine.id,
  4016. requested_by: "前端用户"
  4017. })
  4018. });
  4019. const result = await loadEvaluation();
  4020. renderEvaluation(result.data);
  4021. window.showToast?.(`综合评价已更新,综合得分 ${formatNumber(result.data.run.total_score)},等级 ${result.data.run.grade}`);
  4022. } catch {
  4023. renderEvaluation(DEMO_EVALUATION);
  4024. window.showToast?.("评价接口不可用,已回退到本地演示数据", "warning");
  4025. } finally {
  4026. evaluationButton.disabled = false;
  4027. evaluationButton.innerHTML = original;
  4028. window.injectIcons?.(evaluationButton);
  4029. }
  4030. },
  4031. true
  4032. );
  4033. }
  4034. }
  4035. async function init() {
  4036. if (state.initialized) return;
  4037. state.initialized = true;
  4038. updateConnectionBadge("loading");
  4039. try {
  4040. await loadMine();
  4041. const [dashboardResult, monitoringResult, documentsResult, documentStatisticsResult, templatesResult, knowledgeResult, diagnosisResult, evaluationResult, workOrdersResult] =
  4042. await Promise.all([
  4043. state.apiAvailable
  4044. ? apiLoader("dashboard", "/dashboard/overview", DEMO_DASHBOARD)
  4045. : Promise.resolve({ data: DEMO_DASHBOARD, source: "demo" }),
  4046. state.apiAvailable ? loadMonitoring() : Promise.resolve({ data: DEMO_MONITORING, source: "demo" }),
  4047. state.apiAvailable ? loadDocuments() : Promise.resolve({ data: EMPTY_DOCUMENTS, source: "demo" }),
  4048. state.apiAvailable ? loadDocumentStatistics() : Promise.resolve({ data: DEMO_DOCUMENT_STATISTICS, source: "demo" }),
  4049. state.apiAvailable ? loadBusinessTemplates() : Promise.resolve({ data: [], source: "demo" }),
  4050. state.apiAvailable ? loadKnowledge() : Promise.resolve({ data: DEMO_KNOWLEDGE, source: "demo" }),
  4051. state.apiAvailable ? loadDiagnosis() : Promise.resolve({ data: DEMO_DIAGNOSIS, source: "demo" }),
  4052. state.apiAvailable ? loadEvaluation() : Promise.resolve({ data: DEMO_EVALUATION, source: "demo" }),
  4053. state.apiAvailable ? loadWorkOrders() : Promise.resolve({ data: DEMO_WORK_ORDERS, source: "demo" })
  4054. ]);
  4055. const documents = documentsResult.data.page?.items || [];
  4056. state.templates = templatesResult.data || [];
  4057. renderDashboard(dashboardResult.data, monitoringResult.data, evaluationResult.data);
  4058. renderMonitoring(
  4059. monitoringResult.data,
  4060. documents,
  4061. documentStatisticsResult.data
  4062. );
  4063. refreshDocumentProcessingTasks().catch(() => {});
  4064. renderFacilityStatus(monitoringResult.data);
  4065. renderSafetyMonitoring(monitoringResult.data);
  4066. renderVideoPlatform(monitoringResult.data);
  4067. renderDocumentRows(
  4068. documents,
  4069. documentsResult.data.categories,
  4070. documentsResult.data.page?.total
  4071. );
  4072. renderDocumentStatistics(documentStatisticsResult.data);
  4073. renderKnowledge(knowledgeResult.data);
  4074. renderDiagnosis(diagnosisResult.data);
  4075. renderEvaluation(evaluationResult.data);
  4076. renderWorkOrders(workOrdersResult.data);
  4077. installKnowledgeRenderer();
  4078. installKnowledgeCategoryBrowser();
  4079. installActionInterceptors();
  4080. installBusinessUpload();
  4081. installBusinessEdit();
  4082. installFacilityActions();
  4083. installSafetyMonitoringActions();
  4084. installVideoPlatformActions();
  4085. installDocumentExport();
  4086. hydrateSafetyReadings(monitoringResult.data).catch(() => {});
  4087. if (state.apiAvailable && documentsResult.source === "api") {
  4088. hydrateDocumentDetails(documents).catch(() => {});
  4089. }
  4090. if (!state.apiAvailable) {
  4091. updateConnectionBadge("demo");
  4092. } else if (state.errors.length) {
  4093. updateConnectionBadge("mixed");
  4094. } else {
  4095. updateConnectionBadge("api");
  4096. }
  4097. } finally {
  4098. document.body.classList.remove("api-loading");
  4099. }
  4100. }
  4101. window.VentAPI = {
  4102. init,
  4103. getBusinessDocument(documentId) {
  4104. return state.documents.get(String(documentId));
  4105. },
  4106. updateBusinessDocumentField,
  4107. refreshBusinessDocument,
  4108. getDocumentPreview(documentId) {
  4109. return request(`/documents/${encodeURIComponent(documentId)}/preview`);
  4110. },
  4111. getDocumentFileUrl(documentId, download = false) {
  4112. return `${API_BASE}/documents/${encodeURIComponent(documentId)}/file${download ? "?download=1" : ""}`;
  4113. },
  4114. getDocumentProcessing,
  4115. reprocessBusinessDocument,
  4116. openProcessingRecords,
  4117. closeProcessingRecords,
  4118. openVideoPlatform,
  4119. openKnowledgeCategory,
  4120. setBusinessSearch,
  4121. setBusinessCategory,
  4122. runHazardAgent,
  4123. state
  4124. };
  4125. })();