api.js 211 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052
  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. videoPlatform: createVideoPlatformState(),
  524. videoModule: "door",
  525. videoLayout: 2,
  526. videoRecordTab: 0,
  527. videoSelectedChannel: "v01",
  528. videoSearch: "",
  529. knowledgeCategory: null,
  530. knowledgeCategoryPage: 1,
  531. knowledgeCategoryPageSize: 20,
  532. knowledgeCategoryKeyword: "",
  533. knowledgeCategoryTotal: 0,
  534. judgementPolls: new Set(),
  535. errors: []
  536. };
  537. function escapeHtml(value) {
  538. return String(value ?? "")
  539. .replaceAll("&", "&")
  540. .replaceAll("<", "&lt;")
  541. .replaceAll(">", "&gt;")
  542. .replaceAll('"', "&quot;")
  543. .replaceAll("'", "&#039;");
  544. }
  545. function withMine(path) {
  546. if (!state.apiAvailable || !state.mine?.id) return path;
  547. const separator = path.includes("?") ? "&" : "?";
  548. return `${path}${separator}mine_id=${encodeURIComponent(state.mine.id)}`;
  549. }
  550. async function request(path, options = {}) {
  551. const {
  552. timeoutMs = REQUEST_TIMEOUT_MS,
  553. ...fetchOptions
  554. } = options;
  555. const controller = new AbortController();
  556. const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
  557. const isFormData =
  558. typeof FormData !== "undefined" && fetchOptions.body instanceof FormData;
  559. try {
  560. const response = await fetch(`${API_BASE}${path}`, {
  561. ...fetchOptions,
  562. headers: {
  563. Accept: "application/json",
  564. ...(fetchOptions.body && !isFormData ? { "Content-Type": "application/json" } : {}),
  565. ...(fetchOptions.headers || {})
  566. },
  567. signal: controller.signal
  568. });
  569. if (!response.ok) {
  570. let message = `HTTP ${response.status}`;
  571. let payload = null;
  572. try {
  573. payload = await response.json();
  574. message = payload?.message || payload?.detail || message;
  575. } catch {
  576. // Keep the HTTP status when the response is not JSON.
  577. }
  578. const error = new Error(message);
  579. error.status = response.status;
  580. error.code = payload?.code || "";
  581. throw error;
  582. }
  583. if (response.status === 204) return null;
  584. return await response.json();
  585. } finally {
  586. window.clearTimeout(timeoutId);
  587. }
  588. }
  589. async function withFallback(label, loader, fallback) {
  590. try {
  591. const data = await loader();
  592. if (
  593. data == null ||
  594. (Array.isArray(data) && data.length === 0) ||
  595. (Array.isArray(data?.items) && data.items.length === 0)
  596. ) {
  597. throw new Error("empty response");
  598. }
  599. return { data, source: "api" };
  600. } catch (error) {
  601. state.errors.push(`${label}: ${error?.message || error}`);
  602. return { data: fallback, source: "demo" };
  603. }
  604. }
  605. function apiLoader(label, path, fallback, options = {}) {
  606. return withFallback(label, () => request(withMine(path), options), fallback);
  607. }
  608. function formatNumber(value) {
  609. const number = Number(value);
  610. if (!Number.isFinite(number)) return String(value ?? "--");
  611. return number.toLocaleString("zh-CN", { maximumFractionDigits: 1 });
  612. }
  613. function formatBytes(value) {
  614. const bytes = Number(value);
  615. if (!Number.isFinite(bytes) || bytes < 0) return "--";
  616. if (bytes === 0) return "0 B";
  617. if (bytes < 1024) return `${bytes} B`;
  618. if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
  619. if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
  620. if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
  621. return `${(bytes / 1024 ** 4).toFixed(2)} TB`;
  622. }
  623. function formatDate(value) {
  624. if (!value) return "--";
  625. const date = new Date(value);
  626. if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
  627. return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
  628. }
  629. function formatTime(value) {
  630. if (!value) return "--";
  631. const date = new Date(value);
  632. if (Number.isNaN(date.getTime())) return String(value).slice(11, 16) || "--";
  633. return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
  634. }
  635. function formatDuration(seconds) {
  636. const value = Number(seconds);
  637. if (!Number.isFinite(value) || value < 0) return "--";
  638. if (value < 60) {
  639. return `${value < 10 ? value.toFixed(1) : Math.round(value)} 秒`;
  640. }
  641. const minutes = Math.floor(value / 60);
  642. const remaining = Math.round(value % 60);
  643. return `${minutes} 分 ${remaining} 秒`;
  644. }
  645. function formatTaskStartTime(value) {
  646. if (!value) return "等待中";
  647. const raw = String(value);
  648. const utcValue = /(?:z|[+-]\d{2}:\d{2})$/i.test(raw) ? raw : `${raw}Z`;
  649. const date = new Date(utcValue);
  650. if (Number.isNaN(date.getTime())) return raw.slice(0, 16);
  651. const part = (number) => String(number).padStart(2, "0");
  652. return `${part(date.getMonth() + 1)}-${part(date.getDate())} ${part(date.getHours())}:${part(date.getMinutes())}`;
  653. }
  654. function setText(selector, value) {
  655. const element = document.querySelector(selector);
  656. if (element) element.textContent = value;
  657. }
  658. function setMetric(key, value, decimals = 0) {
  659. const element = document.querySelector(`[data-metric="${key}"]`);
  660. if (!element) return;
  661. if (typeof window.animateNumber === "function" && Number.isFinite(Number(value))) {
  662. window.animateNumber(element, Number(value), decimals);
  663. return;
  664. }
  665. element.textContent = decimals ? Number(value).toFixed(decimals) : formatNumber(value);
  666. }
  667. function kpiMap(kpis) {
  668. return new Map((kpis || []).map((item) => [item.key, item]));
  669. }
  670. function updateConnectionBadge(source) {
  671. const badge = document.querySelector(".topbar .status-pill");
  672. if (!badge) return;
  673. const labels = {
  674. api: "实时运行",
  675. demo: "演示数据",
  676. mixed: "部分演示",
  677. loading: "数据连接中"
  678. };
  679. badge.innerHTML = `<span class="live-dot"></span>${labels[source] || labels.demo}`;
  680. badge.dataset.source = source;
  681. }
  682. function renderDashboard(dashboard, monitoring, evaluation) {
  683. const kpis = kpiMap(dashboard.kpis);
  684. const sensors = kpis.get("sensors")?.value ?? monitoring.overview?.sensor_count ?? 0;
  685. const onlineRate = kpis.get("online_rate")?.value ??
  686. ((monitoring.overview?.online_sensor_count || 0) / Math.max(monitoring.overview?.sensor_count || 1, 1) * 100);
  687. const alarms = kpis.get("alarms")?.value ?? monitoring.overview?.open_alarm_count ?? 0;
  688. const highDefects = kpis.get("high_defects")?.value ?? dashboard.diagnosis_summary?.high ?? 0;
  689. const score = dashboard.evaluation_summary?.score ?? evaluation.run?.total_score ?? 0;
  690. const grade = String(dashboard.evaluation_summary?.grade || evaluation.run?.grade || "A").replace("级", "");
  691. setText("#currentMine", dashboard.mine_name || state.mine?.name || "上湾煤矿");
  692. setMetric("points", sensors);
  693. setMetric("alerts", alarms);
  694. setMetric("grade", grade);
  695. setText(".kpi-card .kpi-foot .text-success", formatNumber(score));
  696. const pointsCard = document.querySelector('[data-metric="points"]')?.closest(".kpi-card");
  697. if (pointsCard) {
  698. pointsCard.querySelector(".kpi-foot span")?.replaceChildren(`在线率 ${Number(onlineRate).toFixed(1)}%`);
  699. }
  700. const alertCard = document.querySelector('[data-metric="alerts"]')?.closest(".kpi-card");
  701. if (alertCard) {
  702. const high = alertCard.querySelector(".kpi-foot .text-danger");
  703. if (high) high.textContent = String(highDefects);
  704. const rate = alertCard.querySelectorAll(".kpi-foot span")[1];
  705. if (rate) rate.textContent = `未闭环 ${dashboard.open_work_order_count ?? 0} 项`;
  706. }
  707. const context = document.querySelector(".context-message strong");
  708. const contextText = document.querySelector(".context-message p");
  709. if (context) context.textContent = Number(highDefects) > 0 ? "系统运行总体平稳" : "系统运行安全稳定";
  710. if (contextText) {
  711. contextText.textContent = `当前识别 ${formatNumber(highDefects)} 项高优先级风险,未闭环任务 ${formatNumber(dashboard.open_work_order_count || 0)} 项。`;
  712. }
  713. const ring = document.querySelector(".dashboard-grid .score-ring");
  714. if (ring) {
  715. ring.style.setProperty("--score", score);
  716. const strong = ring.querySelector("strong");
  717. if (strong) strong.textContent = formatNumber(score);
  718. }
  719. const gradeBadge = document.querySelector(".dashboard-grid .grade-badge");
  720. if (gradeBadge) gradeBadge.textContent = `${grade} 级`;
  721. renderScoreRows(evaluation.dimensions || DEMO_EVALUATION.dimensions);
  722. renderSensorMetrics(monitoring.sensors);
  723. const updateTime = document.querySelector(".map-toolbar > span");
  724. if (updateTime) {
  725. updateTime.innerHTML = `<span class="live-dot"></span>数据更新于 ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}`;
  726. }
  727. }
  728. function renderScoreRows(dimensions) {
  729. const list = document.querySelector(".dashboard-grid .score-list");
  730. if (!list || !dimensions?.length) return;
  731. list.innerHTML = dimensions
  732. .slice(0, 5)
  733. .map((item) => {
  734. const score = Number(item.score || 0);
  735. return `
  736. <div class="score-row">
  737. <span>${escapeHtml(item.name)}</span>
  738. <div><i style="width: ${Math.max(0, Math.min(score, 100))}%"></i></div>
  739. <strong>${score.toFixed(1)}</strong>
  740. </div>
  741. `;
  742. })
  743. .join("");
  744. }
  745. function renderSensorMetrics(sensors) {
  746. const items = sensors?.length ? sensors : DEMO_MONITORING.sensors;
  747. const preferred = ["gas", "airflow", "dust", "temperature", "carbon_monoxide"];
  748. const ordered = preferred
  749. .map((type) => items.find((sensor) => sensor.sensor_type === type))
  750. .filter(Boolean);
  751. if (ordered.length < 3) ordered.push(...items.filter((sensor) => !ordered.includes(sensor)));
  752. document.querySelectorAll(".dashboard-grid .sensor-metric").forEach((metric, index) => {
  753. const sensor = ordered[index];
  754. if (!sensor) return;
  755. const strong = metric.querySelector(".sensor-value strong");
  756. const unit = metric.querySelector(".sensor-value small");
  757. const trend = metric.querySelector(".sensor-value .trend");
  758. if (strong) strong.textContent = formatNumber(sensor.last_value ?? sensor.value ?? "--");
  759. if (unit) unit.textContent = sensor.unit || "";
  760. if (trend) {
  761. trend.textContent = sensor.status === "alarm" ? "超限" : sensor.status === "warning" ? "关注" : "稳定";
  762. trend.classList.toggle("down", sensor.status === "alarm");
  763. trend.classList.toggle("up", sensor.status !== "alarm");
  764. }
  765. });
  766. }
  767. function documentStatus(documentData) {
  768. const parseStatus = documentData.parse_status || documentData.status || "";
  769. if (parseStatus === "completed" || parseStatus.includes("完成")) return { label: "抽取完成", className: "success" };
  770. if (parseStatus === "failed") {
  771. return { label: "处理失败", className: "failed" };
  772. }
  773. if (parseStatus === "parsing") {
  774. return { label: `解析中 ${documentData.parse_progress ?? 0}%`, className: "running" };
  775. }
  776. if (
  777. parseStatus === "processing" ||
  778. parseStatus === "extracting" ||
  779. parseStatus.includes("抽取中")
  780. ) {
  781. return { label: `抽取中 ${documentData.parse_progress ?? 76}%`, className: "running" };
  782. }
  783. return { label: "等待解析", className: "pending" };
  784. }
  785. function fallbackDocumentFor(item) {
  786. return DEMO_DOCUMENTS.find(
  787. (demo) => demo.code && demo.code === item.code || demo.title === item.title
  788. );
  789. }
  790. function normalizeDocumentType(extension) {
  791. const value = String(extension || "").replace(".", "").trim().toUpperCase();
  792. if (value.includes("PDF")) return "PDF";
  793. if (value.includes("DOC")) return "DOC";
  794. if (value.includes("XLS") || value.includes("CSV")) return "XLS";
  795. return value || "FILE";
  796. }
  797. function mapDocument(item) {
  798. const fallback = fallbackDocumentFor(item) || {};
  799. const status = documentStatus(item);
  800. const type = normalizeDocumentType(item.file_ext || fallback.type);
  801. const pageText = item.page_count
  802. ? `${formatNumber(item.page_count)} 页`
  803. : item.row_count
  804. ? `${formatNumber(item.row_count)} 行`
  805. : fallback.pages || (documentStatus(item).label === "抽取完成" ? "未提供页数" : "--");
  806. return {
  807. id: String(item.id),
  808. type,
  809. title: item.title || fallback.title || "未命名资料",
  810. category: item.category || fallback.category || "未分类",
  811. code: item.code || fallback.code || "--",
  812. owner: item.owner_name || fallback.owner || "--",
  813. pages: pageText,
  814. size: formatBytes(item.file_size) !== "--" ? formatBytes(item.file_size) : fallback.size || "--",
  815. date: formatDate(item.effective_date || item.created_at) || fallback.date || "--",
  816. status: status.label,
  817. statusClass: status.className,
  818. parseStatus: item.parse_status || "",
  819. parseProgress: Number(item.parse_progress || 0),
  820. parseError: item.parse_error || "",
  821. confidence: item.metadata?.confidence ?? fallback.confidence,
  822. summary: item.summary || fallback.summary || "",
  823. fields: fallback.fields || [],
  824. extractions: item.extractions || []
  825. };
  826. }
  827. function mapDocumentDetail(item) {
  828. const documentData = mapDocument(item);
  829. if (item.extractions?.length) {
  830. const template = state.templates.find(
  831. (candidate) =>
  832. candidate.code === item.document_type ||
  833. candidate.document_type === item.document_type
  834. );
  835. const templateGroups = template?.fields?.groups || [];
  836. const groupOrder = new Map(
  837. templateGroups.map((group, index) => [group.key, group.order ?? index])
  838. );
  839. const fieldOrder = new Map(
  840. templateGroups.flatMap((group) =>
  841. (group.fields || []).map((field, index) => [field.key, field.order ?? index])
  842. )
  843. );
  844. const grouped = new Map();
  845. item.extractions.forEach((field) => {
  846. const metadata = field.metadata || field.metadata_json || {};
  847. const groupName = metadata.group_name || "其他字段";
  848. const groupKey = metadata.group_key || groupName;
  849. if (groupKey === "compliance_analysis") return;
  850. if (!grouped.has(groupName)) {
  851. grouped.set(groupName, {
  852. key: groupKey,
  853. name: groupName,
  854. order: Number(metadata.group_order ?? groupOrder.get(groupKey) ?? 999),
  855. fields: []
  856. });
  857. }
  858. grouped.get(groupName).fields.push({
  859. id: String(field.id || field.field_key),
  860. label: field.field_name || field.field_key,
  861. value: field.normalized_value || field.field_value || "--",
  862. confidence: field.confidence == null ? "已抽取" : `${Number(field.confidence).toFixed(1)}%`,
  863. edited: Boolean(field.edited),
  864. extractionCorrect: field.extraction_correct !== false,
  865. editReason: field.edit_reason || "",
  866. editJudgementStatus: field.edit_judgement_status || "not_required",
  867. order: Number(metadata.field_order ?? fieldOrder.get(field.field_key) ?? 999)
  868. });
  869. });
  870. documentData.fieldGroups = [...grouped.values()]
  871. .sort((left, right) => left.order - right.order)
  872. .map((group) => ({
  873. ...group,
  874. fields: group.fields
  875. .sort((left, right) => left.order - right.order)
  876. }));
  877. documentData.fields = item.extractions.map((field) => [
  878. field.field_name || field.field_key,
  879. field.normalized_value || field.field_value || "--",
  880. field.confidence == null ? "已抽取" : `${Number(field.confidence).toFixed(1)}%`
  881. ]);
  882. const confidences = item.extractions
  883. .map((field) => Number(field.confidence))
  884. .filter(Number.isFinite);
  885. if (confidences.length) {
  886. documentData.confidence = confidences.reduce((sum, value) => sum + value, 0) / confidences.length;
  887. }
  888. }
  889. return documentData;
  890. }
  891. function currentDocumentFallback() {
  892. const activeId = document.querySelector(".document-row.active")?.dataset.documentId;
  893. return state.documents.get(activeId) || DEMO_DOCUMENTS[0];
  894. }
  895. function renderDocumentRows(items, categories, total) {
  896. const list = document.querySelector("#businessDocumentList");
  897. if (!list) return;
  898. const rows = [...list.querySelectorAll(".document-row")];
  899. const documents = items.map(mapDocument);
  900. state.documents.clear();
  901. documents.forEach((documentData, index) => {
  902. let row = rows[index];
  903. if (!row) {
  904. row = document.createElement("button");
  905. row.className = "document-row";
  906. row.type = "button";
  907. list.appendChild(row);
  908. rows.push(row);
  909. }
  910. row.hidden = false;
  911. documentData.id = String(documentData.id || `document-${index}`);
  912. documentData.searchSnippets =
  913. state.documentSearchSnippets.get(String(documentData.id)) || [];
  914. state.documents.set(documentData.id, documentData);
  915. row.dataset.documentId = documentData.id;
  916. row.dataset.category = documentData.category;
  917. row.innerHTML = `
  918. <span class="document-type ${documentData.type.toLowerCase()}">${escapeHtml(documentData.type)}</span>
  919. <span class="document-main">
  920. <strong>${escapeHtml(documentData.title)}</strong>
  921. <small>编号 ${escapeHtml(documentData.code)} · ${escapeHtml(documentData.pages)} · ${escapeHtml(documentData.owner)}</small>
  922. ${documentData.searchSnippets.length
  923. ? `<small class="document-search-snippet"><b>${escapeHtml(documentData.searchSnippets[0].label)}</b>${escapeHtml(documentData.searchSnippets[0].text)}</small>`
  924. : ""}
  925. </span>
  926. <span class="document-extraction ${documentData.statusClass}"><i></i>${escapeHtml(documentData.status)}</span>
  927. <span class="document-fields"><b>${documentData.fields.length}</b><small>字段</small></span>
  928. <span class="document-actions">
  929. <span class="document-edit" data-edit-document role="button" tabindex="0" aria-label="编辑资料" title="编辑资料">
  930. <span data-icon="pencil"></span>
  931. </span>
  932. <span class="document-delete" data-delete-document role="button" tabindex="0" aria-label="删除资料" title="删除资料">
  933. <span data-icon="trash-2"></span>
  934. </span>
  935. </span>
  936. `;
  937. if (typeof window.injectIcons === "function") window.injectIcons(row);
  938. const editAction = row.querySelector("[data-edit-document]");
  939. editAction?.addEventListener("click", (event) => {
  940. event.stopPropagation();
  941. openBusinessEditModal(documentData.id);
  942. });
  943. editAction?.addEventListener("keydown", (event) => {
  944. if (event.key === "Enter" || event.key === " ") {
  945. event.preventDefault();
  946. event.stopPropagation();
  947. openBusinessEditModal(documentData.id);
  948. }
  949. });
  950. const deleteAction = row.querySelector("[data-delete-document]");
  951. deleteAction?.addEventListener("click", (event) => {
  952. event.stopPropagation();
  953. deleteBusinessDocument(documentData.id, documentData.title);
  954. });
  955. deleteAction?.addEventListener("keydown", (event) => {
  956. if (event.key === "Enter" || event.key === " ") {
  957. event.preventDefault();
  958. event.stopPropagation();
  959. deleteBusinessDocument(documentData.id, documentData.title);
  960. }
  961. });
  962. });
  963. rows.slice(documents.length).forEach((row) => row.remove());
  964. if (!documents.length) {
  965. const panel = document.querySelector("#businessDetailPanel");
  966. if (panel) {
  967. panel.innerHTML = `
  968. <div class="business-document-note">
  969. <span data-icon="file-text"></span>
  970. <p>当前业务资料库暂无资料。</p>
  971. </div>
  972. `;
  973. if (typeof window.injectIcons === "function") window.injectIcons(panel);
  974. }
  975. }
  976. const firstRow = rows[0];
  977. if (documents.length && firstRow) {
  978. rows.forEach((row) => row.classList.toggle("active", row === firstRow));
  979. window.renderBusinessDocument?.(firstRow.dataset.documentId);
  980. }
  981. const filteredTotal = Number(total) || documents.length;
  982. const globalTotal = (categories || []).reduce(
  983. (sum, item) => sum + Number(item.count || 0),
  984. 0
  985. ) || filteredTotal;
  986. renderDocumentCategories(documents, categories, filteredTotal);
  987. renderDocumentTotals(filteredTotal, globalTotal);
  988. renderDocumentPagination(filteredTotal);
  989. }
  990. function renderDocumentCategories(documents, categories, total) {
  991. const buttons = [...document.querySelectorAll("#businessCategoryList button")];
  992. const counts = new Map(
  993. (categories || []).map((item) => [item.category, Number(item.count || 0)])
  994. );
  995. if (!counts.size) {
  996. documents.forEach((documentData) => {
  997. counts.set(documentData.category, (counts.get(documentData.category) || 0) + 1);
  998. });
  999. }
  1000. buttons.forEach((button) => {
  1001. const key = button.dataset.docCategory;
  1002. const count = key === "all"
  1003. ? [...counts.values()].reduce((sum, value) => sum + value, 0) || Number(total) || documents.length
  1004. : counts.get(key) || 0;
  1005. const badge = button.querySelector("b");
  1006. if (badge) badge.textContent = formatNumber(count);
  1007. });
  1008. }
  1009. function renderDocumentTotals(filteredTotal, globalTotal) {
  1010. const toolbar = document.querySelector("#view-business-data .toolbar-copy p");
  1011. if (toolbar) toolbar.textContent = `已接入 ${formatNumber(globalTotal)} 份资料,关键字段抽取结果与业务对象已建立关联`;
  1012. const firstKpi = document.querySelector("#view-business-data .business-kpi-card strong");
  1013. if (firstKpi) firstKpi.textContent = formatNumber(globalTotal);
  1014. const footer = document.querySelector("#view-business-data .document-list-footer > span");
  1015. if (footer) footer.textContent = `共 ${formatNumber(filteredTotal)} 份资料`;
  1016. }
  1017. function renderDocumentStatistics(statistics) {
  1018. const data = statistics || DEMO_DOCUMENT_STATISTICS;
  1019. state.documentStatistics = data;
  1020. const setKpi = (key, value) => {
  1021. const element = document.querySelector(`[data-business-kpi="${key}"]`);
  1022. if (element) element.textContent = value;
  1023. };
  1024. const setHint = (key, value) => {
  1025. const element = document.querySelector(`[data-business-kpi-hint="${key}"]`);
  1026. if (element) element.textContent = value;
  1027. };
  1028. setKpi("total", formatNumber(data.total_documents));
  1029. setHint("total", `本月新增 ${formatNumber(data.documents_added_this_month)} 份`);
  1030. setKpi("fields", formatNumber(data.extracted_fields));
  1031. setHint("fields", `字段完整率 ${Number(data.field_completeness || 0).toFixed(1)}%`);
  1032. setKpi("accuracy", `${Number(data.extraction_accuracy || 0).toFixed(1)}%`);
  1033. const accuracyChange = Number(data.accuracy_change || 0);
  1034. const pendingJudgements = Number(data.pending_judgements || 0);
  1035. setHint(
  1036. "accuracy",
  1037. pendingJudgements
  1038. ? `待判定 ${formatNumber(pendingJudgements)} 项`
  1039. : Number(data.edited_fields)
  1040. ? `已编辑 ${formatNumber(data.edited_fields)} 项,抽取错误 ${formatNumber(data.incorrect_fields)} 项`
  1041. : accuracyChange
  1042. ? `较上月${accuracyChange > 0 ? "提升" : "下降"} ${Math.abs(accuracyChange).toFixed(1)}%`
  1043. : "暂无上月对比数据"
  1044. );
  1045. setKpi("pending", formatNumber(data.pending_tasks));
  1046. setHint(
  1047. "pending",
  1048. Number(data.pending_tasks)
  1049. ? `预计 ${formatNumber(data.estimated_minutes)} 分钟完成`
  1050. : "当前无待处理任务"
  1051. );
  1052. const toolbar = document.querySelector("#view-business-data .toolbar-copy p");
  1053. if (toolbar) {
  1054. toolbar.textContent = `已接入 ${formatNumber(data.total_documents)} 份资料,字段抽取平均准确率 ${Number(data.extraction_accuracy || 0).toFixed(1)}%`;
  1055. }
  1056. const storagePercent = Number(data.storage_usage_percent || 0);
  1057. const storagePercentElement = document.querySelector("[data-storage-percent]");
  1058. const storageBar = document.querySelector("[data-storage-bar]");
  1059. const storageUsage = document.querySelector("[data-storage-usage]");
  1060. if (storagePercentElement) {
  1061. storagePercentElement.textContent = `${storagePercent.toFixed(1)}%`;
  1062. }
  1063. if (storageBar) {
  1064. storageBar.style.width = `${Math.max(0, Math.min(storagePercent, 100))}%`;
  1065. }
  1066. if (storageUsage) {
  1067. storageUsage.textContent = `已使用 ${formatBytes(data.storage_used_bytes)} / ${formatBytes(data.storage_capacity_bytes)}`;
  1068. }
  1069. const businessCard = document.querySelector(
  1070. "#view-data [data-open-business]"
  1071. );
  1072. if (businessCard) {
  1073. const status = businessCard.querySelector(".source-status");
  1074. const footValues = businessCard.querySelectorAll(".source-foot span");
  1075. const pendingTasks = Number(data.pending_tasks || 0);
  1076. if (status) {
  1077. status.className = `source-status ${pendingTasks ? "sync" : "online"}`;
  1078. status.textContent = pendingTasks ? "同步中" : "已同步";
  1079. }
  1080. if (footValues[0]) {
  1081. footValues[0].textContent = `${formatNumber(data.total_documents)} 份`;
  1082. }
  1083. if (footValues[1]) {
  1084. footValues[1].textContent = pendingTasks
  1085. ? `待处理 ${formatNumber(pendingTasks)} 项`
  1086. : `已抽取 ${formatNumber(data.extracted_fields)} 字段`;
  1087. }
  1088. }
  1089. }
  1090. function renderDocumentPagination(total) {
  1091. const footer = document.querySelector("#view-business-data .document-list-footer");
  1092. if (!footer) return;
  1093. const pageCount = Math.max(1, Math.ceil((Number(total) || 0) / state.documentPageSize));
  1094. const currentPage = Math.min(Math.max(state.documentPage, 1), pageCount);
  1095. state.documentPage = currentPage;
  1096. const pageButtons = Array.from({ length: pageCount }, (_, index) => {
  1097. const page = index + 1;
  1098. return `<button class="${page === currentPage ? "active" : ""}" type="button" data-document-page="${page}">${page}</button>`;
  1099. }).join("");
  1100. footer.innerHTML = `
  1101. <span>共 ${formatNumber(total)} 份资料</span>
  1102. <div>
  1103. <button type="button" data-document-page="${currentPage - 1}" ${currentPage <= 1 ? "disabled" : ""}>上一页</button>
  1104. ${pageButtons}
  1105. <button type="button" data-document-page="${currentPage + 1}" ${currentPage >= pageCount ? "disabled" : ""}>下一页</button>
  1106. </div>
  1107. `;
  1108. footer.querySelectorAll("[data-document-page]").forEach((button) => {
  1109. button.addEventListener("click", () => {
  1110. const page = Number(button.dataset.documentPage);
  1111. if (!Number.isFinite(page) || page < 1 || page > pageCount || page === currentPage) return;
  1112. refreshDocumentsFromApi(page).catch(() => {
  1113. window.showToast?.("资料分页加载失败", "warning");
  1114. });
  1115. });
  1116. });
  1117. }
  1118. async function hydrateDocumentDetails(items) {
  1119. const results = await Promise.all(
  1120. items.slice(0, 6).map(async (item) => {
  1121. try {
  1122. return await request(`/documents/${encodeURIComponent(item.id)}`);
  1123. } catch {
  1124. return null;
  1125. }
  1126. })
  1127. );
  1128. results.filter(Boolean).forEach((item) => {
  1129. state.documents.set(String(item.id), mapDocumentDetail(item));
  1130. });
  1131. state.documents.forEach((documentData, documentId) => {
  1132. const row = findDocumentRow(documentId);
  1133. const fieldCount = row?.querySelector(".document-fields b");
  1134. if (fieldCount) fieldCount.textContent = String(documentData.fields.length);
  1135. });
  1136. const activeId = document.querySelector(".document-row.active")?.dataset.documentId;
  1137. if (activeId && state.documents.has(String(activeId))) {
  1138. window.renderBusinessDocument?.(activeId);
  1139. }
  1140. }
  1141. async function deleteBusinessDocument(documentId, title) {
  1142. const confirmed = window.confirm(`确定删除“${title || "该资料"}”吗?删除后无法恢复。`);
  1143. if (!confirmed) return;
  1144. try {
  1145. await request(`/documents/${encodeURIComponent(documentId)}`, {
  1146. method: "DELETE"
  1147. });
  1148. state.documents.delete(String(documentId));
  1149. const nextPage = document.querySelectorAll("#businessDocumentList .document-row").length <= 1 && state.documentPage > 1
  1150. ? state.documentPage - 1
  1151. : state.documentPage;
  1152. await refreshDocumentsFromApi(nextPage);
  1153. if (!document.querySelector("#businessDocumentList .document-row")) {
  1154. const panel = document.querySelector("#businessDetailPanel");
  1155. if (panel) {
  1156. panel.innerHTML = `
  1157. <div class="business-document-note">
  1158. <span data-icon="file-text"></span>
  1159. <p>当前业务资料库暂无资料。</p>
  1160. </div>
  1161. `;
  1162. window.injectIcons?.(panel);
  1163. }
  1164. }
  1165. window.showToast?.("资料已删除");
  1166. } catch (error) {
  1167. window.showToast?.(`删除失败:${error?.message || error}`, "warning");
  1168. }
  1169. }
  1170. function openBusinessEditModal(documentId) {
  1171. const documentData = state.documents.get(String(documentId));
  1172. const modal = document.querySelector("#businessEditModal");
  1173. const form = document.querySelector("#businessEditForm");
  1174. if (!documentData || !modal || !form) return;
  1175. form.elements.document_id.value = documentData.id;
  1176. form.elements.title.value = documentData.title;
  1177. form.elements.code.value = documentData.code === "--" ? "" : documentData.code;
  1178. form.elements.category.value = documentData.category;
  1179. modal.hidden = false;
  1180. window.setTimeout(() => form.elements.title.focus(), 0);
  1181. }
  1182. function closeBusinessEditModal() {
  1183. const modal = document.querySelector("#businessEditModal");
  1184. if (modal) modal.hidden = true;
  1185. }
  1186. function installBusinessEdit() {
  1187. const modal = document.querySelector("#businessEditModal");
  1188. const form = document.querySelector("#businessEditForm");
  1189. if (!modal || !form || form.dataset.apiBound) return;
  1190. form.dataset.apiBound = "true";
  1191. modal.querySelectorAll("[data-edit-close]").forEach((button) => {
  1192. button.addEventListener("click", closeBusinessEditModal);
  1193. });
  1194. form.addEventListener("submit", async (event) => {
  1195. event.preventDefault();
  1196. const submit = form.querySelector('[type="submit"]');
  1197. const documentId = form.elements.document_id.value;
  1198. if (!documentId) return;
  1199. if (submit) submit.disabled = true;
  1200. try {
  1201. const previousCategory = state.documents.get(String(documentId))?.category;
  1202. await request(`/documents/${encodeURIComponent(documentId)}`, {
  1203. method: "PATCH",
  1204. body: JSON.stringify({
  1205. title: form.elements.title.value.trim(),
  1206. code: form.elements.code.value.trim() || null,
  1207. category: form.elements.category.value
  1208. })
  1209. });
  1210. closeBusinessEditModal();
  1211. const newCategory = form.elements.category.value;
  1212. if (newCategory !== previousCategory) {
  1213. await setBusinessCategory(newCategory);
  1214. } else {
  1215. await refreshDocumentsFromApi();
  1216. }
  1217. window.showToast?.("资料信息已更新");
  1218. } catch (error) {
  1219. window.showToast?.(`保存失败:${error?.message || error}`, "warning");
  1220. } finally {
  1221. if (submit) submit.disabled = false;
  1222. }
  1223. });
  1224. }
  1225. async function updateBusinessDocumentField(documentId, extractionId, value) {
  1226. const updated = await request(
  1227. `/documents/${encodeURIComponent(documentId)}/extractions/${encodeURIComponent(extractionId)}`,
  1228. {
  1229. method: "PATCH",
  1230. body: JSON.stringify({
  1231. field_value: value,
  1232. normalized_value: value,
  1233. verified: true,
  1234. verified_by: "前端用户"
  1235. })
  1236. }
  1237. );
  1238. const documentData = state.documents.get(String(documentId));
  1239. if (documentData?.fieldGroups) {
  1240. documentData.fieldGroups.forEach((group) => {
  1241. group.fields.forEach((field) => {
  1242. if (String(field.id) === String(extractionId)) {
  1243. field.value = updated.normalized_value || updated.field_value || value;
  1244. field.edited = Boolean(updated.edited);
  1245. field.extractionCorrect = updated.extraction_correct !== false;
  1246. field.editReason = updated.edit_reason || "";
  1247. field.editJudgementStatus = updated.edit_judgement_status || "not_required";
  1248. }
  1249. });
  1250. });
  1251. }
  1252. if (updated.edit_judgement_status === "pending") {
  1253. pollExtractionJudgement(documentId, extractionId).catch(() => {});
  1254. }
  1255. return updated;
  1256. }
  1257. async function pollExtractionJudgement(documentId, extractionId) {
  1258. const key = `${documentId}:${extractionId}`;
  1259. if (state.judgementPolls.has(key)) return;
  1260. state.judgementPolls.add(key);
  1261. try {
  1262. for (let attempt = 0; attempt < 20; attempt += 1) {
  1263. await new Promise((resolve) =>
  1264. window.setTimeout(resolve, attempt < 3 ? 700 : 1500)
  1265. );
  1266. const detail = await request(`/documents/${encodeURIComponent(documentId)}`);
  1267. state.documents.set(String(documentId), mapDocumentDetail(detail));
  1268. window.renderBusinessDocument?.(String(documentId));
  1269. const field = (detail.extractions || []).find(
  1270. (item) => String(item.id) === String(extractionId)
  1271. );
  1272. if (!field || field.edit_judgement_status !== "pending") {
  1273. const statisticsResult = await loadDocumentStatistics();
  1274. renderDocumentStatistics(statisticsResult.data);
  1275. if (field?.edit_judgement_status === "completed") {
  1276. window.showToast?.(
  1277. field.extraction_correct === false
  1278. ? "字段判定完成:属于抽取错误"
  1279. : "字段判定完成:不属于抽取错误"
  1280. );
  1281. }
  1282. return;
  1283. }
  1284. }
  1285. } finally {
  1286. state.judgementPolls.delete(key);
  1287. }
  1288. }
  1289. async function refreshBusinessDocument(documentId) {
  1290. const [detail, statistics] = await Promise.all([
  1291. request(`/documents/${encodeURIComponent(documentId)}`),
  1292. loadDocumentStatistics()
  1293. ]);
  1294. state.documents.set(String(documentId), mapDocumentDetail(detail));
  1295. renderDocumentStatistics(statistics.data);
  1296. window.renderBusinessDocument?.(String(documentId));
  1297. }
  1298. function setUploadProgress(stage, percent) {
  1299. const progress = document.querySelector("#businessUploadProgress");
  1300. const stageLabel = document.querySelector("#businessUploadStage");
  1301. const percentLabel = document.querySelector("#businessUploadPercent");
  1302. const bar = document.querySelector("#businessUploadProgressBar");
  1303. if (progress) progress.hidden = false;
  1304. if (stageLabel) stageLabel.textContent = stage;
  1305. if (percentLabel) percentLabel.textContent = `${Math.max(0, Math.min(percent, 100))}%`;
  1306. if (bar) bar.style.width = `${Math.max(0, Math.min(percent, 100))}%`;
  1307. }
  1308. function resetUploadProgress() {
  1309. const progress = document.querySelector("#businessUploadProgress");
  1310. const stageLabel = document.querySelector("#businessUploadStage");
  1311. const percentLabel = document.querySelector("#businessUploadPercent");
  1312. const bar = document.querySelector("#businessUploadProgressBar");
  1313. if (progress) progress.hidden = true;
  1314. if (stageLabel) stageLabel.textContent = "正在上传";
  1315. if (percentLabel) percentLabel.textContent = "0%";
  1316. if (bar) bar.style.width = "0%";
  1317. }
  1318. function clearTemplateSelectionError() {
  1319. const select = document.querySelector("#businessTemplateSelect");
  1320. const error = document.querySelector("#businessTemplateError");
  1321. if (select) select.classList.remove("invalid");
  1322. if (error) error.hidden = true;
  1323. }
  1324. function showTemplateSelectionError() {
  1325. const select = document.querySelector("#businessTemplateSelect");
  1326. const error = document.querySelector("#businessTemplateError");
  1327. if (select) {
  1328. select.classList.add("invalid");
  1329. select.focus();
  1330. }
  1331. if (error) error.hidden = false;
  1332. }
  1333. function openUploadModal() {
  1334. const modal = document.querySelector("#businessUploadModal");
  1335. if (!modal) return;
  1336. modal.hidden = false;
  1337. resetUploadProgress();
  1338. clearTemplateSelectionError();
  1339. window.setTimeout(() => modal.querySelector('select[name="category"]')?.focus(), 0);
  1340. }
  1341. function closeUploadModal() {
  1342. const modal = document.querySelector("#businessUploadModal");
  1343. if (modal) modal.hidden = true;
  1344. }
  1345. function resetUploadFileState(form) {
  1346. const fileField = form?.querySelector(".upload-file-field");
  1347. const fileInput = form?.querySelector('input[type="file"]');
  1348. const fileTitle = document.querySelector("#businessFileTitle");
  1349. const fileDetail = document.querySelector("#businessFileDetail");
  1350. if (fileField) fileField.classList.remove("has-file");
  1351. if (fileInput) fileInput.value = "";
  1352. if (fileTitle) fileTitle.textContent = "选择 Word、PDF 或 Excel 文件";
  1353. if (fileDetail) {
  1354. fileDetail.textContent = "支持 .doc .docx .pdf .xls .xlsx .csv,单个文件不超过 50 MB";
  1355. }
  1356. }
  1357. function updateUploadFileState(fileInput) {
  1358. const file = fileInput?.files?.[0];
  1359. const fileField = fileInput?.closest(".upload-file-field");
  1360. const fileTitle = document.querySelector("#businessFileTitle");
  1361. const fileDetail = document.querySelector("#businessFileDetail");
  1362. if (!file) {
  1363. resetUploadFileState(fileInput?.form);
  1364. return;
  1365. }
  1366. fileField?.classList.add("has-file");
  1367. if (fileTitle) fileTitle.textContent = file.name;
  1368. const extension = `.${file.name.split(".").pop()?.toLowerCase() || ""}`;
  1369. const lightweight = fileInput.form?.querySelector('input[name="parse_mode"][value="lightweight"]');
  1370. const precision = fileInput.form?.querySelector('input[name="parse_mode"][value="precision"]');
  1371. if (extension === ".doc" || extension === ".xls") {
  1372. if (lightweight) lightweight.checked = false;
  1373. if (precision) precision.checked = true;
  1374. }
  1375. if (fileDetail) {
  1376. fileDetail.textContent = extension === ".doc" || extension === ".xls"
  1377. ? `已选择 · ${formatBytes(file.size)} · 旧版格式将使用精准解析`
  1378. : `已选择 · ${formatBytes(file.size)} · 点击可重新选择`;
  1379. }
  1380. }
  1381. function findDocumentRow(documentId) {
  1382. return [...document.querySelectorAll("#businessDocumentList .document-row")].find(
  1383. (row) => row.dataset.documentId === String(documentId)
  1384. );
  1385. }
  1386. function updateDocumentProcessingRow(documentId, run) {
  1387. const row = findDocumentRow(documentId);
  1388. if (!row) return;
  1389. const status = documentStatus({
  1390. parse_status: run.stage || run.status,
  1391. parse_progress: run.progress
  1392. });
  1393. const extraction = row.querySelector(".document-extraction");
  1394. if (extraction) {
  1395. extraction.className = `document-extraction ${status.className}`;
  1396. extraction.innerHTML = `<i></i>${escapeHtml(status.label)}`;
  1397. }
  1398. const documentData = state.documents.get(String(documentId));
  1399. if (documentData) {
  1400. documentData.status = status.label;
  1401. documentData.statusClass = status.className;
  1402. documentData.parseStatus = run.stage || run.status;
  1403. documentData.parseProgress = Number(run.progress || 0);
  1404. documentData.parseError = run.error || "";
  1405. }
  1406. if (row.classList.contains("active")) {
  1407. const detailStatus = document.querySelector("#businessDetailPanel .business-detail-status");
  1408. if (detailStatus) {
  1409. detailStatus.className = `business-detail-status ${status.className}`;
  1410. detailStatus.textContent = status.label;
  1411. }
  1412. }
  1413. }
  1414. async function refreshDocumentsFromApi(page = state.documentPage) {
  1415. state.documentPage = page;
  1416. const [result, statisticsResult] = await Promise.all([
  1417. loadDocuments(),
  1418. loadDocumentStatistics()
  1419. ]);
  1420. const documents = result.data.page?.items || [];
  1421. renderDocumentRows(
  1422. documents,
  1423. result.data.categories,
  1424. result.data.page?.total
  1425. );
  1426. renderDocumentStatistics(statisticsResult.data);
  1427. if (result.source === "api" && documents.length) {
  1428. hydrateDocumentDetails(documents).catch(() => {});
  1429. }
  1430. return documents;
  1431. }
  1432. async function setBusinessCategory(category) {
  1433. state.documentCategory = category || "all";
  1434. state.documentPage = 1;
  1435. document.querySelectorAll("#businessCategoryList button").forEach((button) => {
  1436. button.classList.toggle("active", button.dataset.docCategory === state.documentCategory);
  1437. });
  1438. window.dispatchEvent(
  1439. new CustomEvent("vent:business-category-changed", {
  1440. detail: { category: state.documentCategory }
  1441. })
  1442. );
  1443. try {
  1444. await refreshDocumentsFromApi(1);
  1445. } catch {
  1446. window.showToast?.("资料分类加载失败", "warning");
  1447. }
  1448. }
  1449. async function setBusinessSearch(keyword) {
  1450. state.documentKeyword = String(keyword || "").trim();
  1451. state.documentPage = 1;
  1452. try {
  1453. await refreshDocumentsFromApi(1);
  1454. } catch {
  1455. window.showToast?.("全文检索失败", "warning");
  1456. }
  1457. }
  1458. async function getDocumentProcessing(documentId) {
  1459. try {
  1460. return await request(
  1461. `/documents/${encodeURIComponent(documentId)}/processing`
  1462. );
  1463. } catch (error) {
  1464. if (String(error?.message || "").includes("404")) return null;
  1465. throw error;
  1466. }
  1467. }
  1468. async function reprocessBusinessDocument(documentId, options = {}) {
  1469. const run = await request(`/documents/${encodeURIComponent(documentId)}/process`, {
  1470. method: "POST",
  1471. body: JSON.stringify({
  1472. force: true,
  1473. parse_mode: options.parseMode || "lightweight",
  1474. template_id: options.templateId || null
  1475. })
  1476. });
  1477. updateDocumentProcessingRow(documentId, run);
  1478. refreshDocumentProcessingTasks().catch(() => {});
  1479. pollDocumentProcessing(documentId).catch(() => {});
  1480. return run;
  1481. }
  1482. async function pollDocumentProcessing(documentId) {
  1483. for (let attempt = 0; attempt < 1200; attempt += 1) {
  1484. await new Promise((resolve) => window.setTimeout(resolve, attempt < 3 ? 800 : 1500));
  1485. try {
  1486. const run = await request(
  1487. `/documents/${encodeURIComponent(documentId)}/processing`
  1488. );
  1489. updateDocumentProcessingRow(documentId, run);
  1490. setUploadProgress(
  1491. run.stage === "parsing"
  1492. ? "正在解析文档"
  1493. : run.stage === "extracting"
  1494. ? "正在抽取关键信息"
  1495. : run.stage === "completed"
  1496. ? "处理完成"
  1497. : "正在处理",
  1498. run.progress || 0
  1499. );
  1500. if (run.status === "completed") {
  1501. await refreshDocumentsFromApi();
  1502. await refreshDocumentProcessingTasks();
  1503. const row = findDocumentRow(documentId);
  1504. row?.click();
  1505. window.showToast?.(
  1506. `资料解析完成,共提取 ${run.model_output?.field_count || 0} 个字段`
  1507. );
  1508. return run;
  1509. }
  1510. if (run.status === "failed") {
  1511. await refreshDocumentsFromApi();
  1512. await refreshDocumentProcessingTasks();
  1513. window.showToast?.(run.error || "资料解析失败", "warning");
  1514. return run;
  1515. }
  1516. } catch {
  1517. // The upload may still be committing; keep polling briefly.
  1518. }
  1519. }
  1520. window.showToast?.("资料处理时间较长,请稍后刷新查看结果", "warning");
  1521. return null;
  1522. }
  1523. function installBusinessUpload() {
  1524. const modal = document.querySelector("#businessUploadModal");
  1525. const form = document.querySelector("#businessUploadForm");
  1526. if (!modal || !form || form.dataset.apiBound) return;
  1527. form.dataset.apiBound = "true";
  1528. const templateSelect = document.querySelector("#businessTemplateSelect");
  1529. const fileInput = form.querySelector('input[type="file"]');
  1530. if (templateSelect) {
  1531. templateSelect.innerHTML = state.templates.length
  1532. ? '<option value="">自动识别(根据文件名)</option>' + state.templates
  1533. .map(
  1534. (template) =>
  1535. `<option value="${escapeHtml(template.id)}">${escapeHtml(template.name)}</option>`
  1536. )
  1537. .join("")
  1538. : '<option value="">暂无可用抽取模板</option>';
  1539. }
  1540. document.querySelectorAll("[data-open-upload]").forEach((button) => {
  1541. button.addEventListener("click", openUploadModal);
  1542. });
  1543. modal.querySelectorAll("[data-upload-close]").forEach((button) => {
  1544. button.addEventListener("click", () => {
  1545. resetUploadFileState(form);
  1546. clearTemplateSelectionError();
  1547. resetUploadProgress();
  1548. closeUploadModal();
  1549. });
  1550. });
  1551. fileInput?.addEventListener("change", () => {
  1552. clearTemplateSelectionError();
  1553. updateUploadFileState(fileInput);
  1554. });
  1555. templateSelect?.addEventListener("change", clearTemplateSelectionError);
  1556. form.addEventListener("submit", async (event) => {
  1557. event.preventDefault();
  1558. if (!state.apiAvailable) {
  1559. window.showToast?.("后端不可用,暂时无法上传资料", "warning");
  1560. return;
  1561. }
  1562. const submit = form.querySelector('[type="submit"]');
  1563. const formData = new FormData(form);
  1564. formData.set("mine_id", state.mine.id);
  1565. if (submit) submit.disabled = true;
  1566. setUploadProgress("正在上传文件", 3);
  1567. try {
  1568. const documentData = await request("/documents/upload", {
  1569. method: "POST",
  1570. body: formData,
  1571. timeoutMs: 120000
  1572. });
  1573. setUploadProgress("文件已上传,等待解析", 20);
  1574. window.showToast?.("资料上传成功,已进入解析队列");
  1575. clearTemplateSelectionError();
  1576. resetUploadProgress();
  1577. closeUploadModal();
  1578. form.reset();
  1579. resetUploadFileState(form);
  1580. await refreshDocumentsFromApi(1);
  1581. refreshDocumentProcessingTasks().catch(() => {});
  1582. pollDocumentProcessing(documentData.id).catch(() => {});
  1583. } catch (error) {
  1584. resetUploadProgress();
  1585. if (error?.code === "extraction_template_required") {
  1586. showTemplateSelectionError();
  1587. window.showToast?.(
  1588. "文件名未匹配到抽取模板,请手动选择业务资料类型",
  1589. "warning"
  1590. );
  1591. } else {
  1592. window.showToast?.(`上传失败:${error?.message || error}`, "warning");
  1593. }
  1594. } finally {
  1595. if (submit) submit.disabled = false;
  1596. }
  1597. });
  1598. }
  1599. function sourceVisual(source) {
  1600. const type = String(source.source_type || "").toLowerCase();
  1601. if (type.includes("video")) return { icon: "video", className: "blue", description: "视频监控与智能识别" };
  1602. if (type.includes("facility")) return { icon: "settings-2", className: "amber", description: "风门、风窗、密闭和局部通风机" };
  1603. if (type.includes("document")) return { icon: "file-text", className: "purple", description: "规程、措施、报表与台账" };
  1604. return { icon: "database", className: "teal", description: "瓦斯、风速、温度、CO 等监测点" };
  1605. }
  1606. function processingTaskStatus(task) {
  1607. const status = String(task.status || "pending");
  1608. const stage = String(task.stage || status);
  1609. if (status === "completed" || stage === "completed") {
  1610. return { label: "已完成", className: "success" };
  1611. }
  1612. if (status === "failed" || stage === "failed") {
  1613. return { label: "处理失败", className: "failed" };
  1614. }
  1615. if (["running", "parsing", "extracting"].includes(status) || ["parsing", "extracting"].includes(stage)) {
  1616. const stageLabel = stage === "parsing" ? "解析中" : stage === "extracting" ? "抽取中" : "处理中";
  1617. return { label: `${stageLabel} ${Number(task.progress || 0)}%`, className: "running" };
  1618. }
  1619. return { label: "等待中", className: "pending" };
  1620. }
  1621. function processingTaskDescription(task) {
  1622. const finished = task.status === "completed" || task.stage === "completed" ||
  1623. task.status === "failed" || task.stage === "failed";
  1624. const elapsed = Number.isFinite(Number(task.processing_seconds))
  1625. ? ` · ${finished ? "处理耗时" : "已用时"} ${formatDuration(task.processing_seconds)}`
  1626. : "";
  1627. const startText = `开始时间 ${formatTaskStartTime(task.started_at)}`;
  1628. if (task.status === "failed" || task.stage === "failed") {
  1629. return `${task.error || "资料处理失败,等待重试"} · ${startText}${elapsed}`;
  1630. }
  1631. if (task.status === "completed" || task.stage === "completed") {
  1632. const source = task.page_count
  1633. ? `识别 ${formatNumber(task.page_count)} 页`
  1634. : task.row_count
  1635. ? `识别 ${formatNumber(task.row_count)} 行`
  1636. : "识别完成";
  1637. return `${source} · 抽取 ${formatNumber(task.field_count)} 个关键字段 · ${startText}${elapsed}`;
  1638. }
  1639. if (task.stage === "parsing") return `正在解析文档版面与表格结构 · ${startText}${elapsed}`;
  1640. if (task.stage === "extracting") return `正在调用模型抽取关键字段 · ${startText}${elapsed}`;
  1641. return `等待进入解析队列 · ${startText}${elapsed}`;
  1642. }
  1643. function renderDocumentProcessingTasks(
  1644. tasks,
  1645. targetSelector = "#documentProcessingTaskList",
  1646. limit = 4
  1647. ) {
  1648. const list = document.querySelector(targetSelector);
  1649. if (!list) return;
  1650. if (!tasks?.length) {
  1651. list.innerHTML = `
  1652. <div class="business-document-note">
  1653. <span data-icon="${state.apiAvailable ? "check-circle-2" : "circle-alert"}"></span>
  1654. <p>${state.apiAvailable ? "当前没有资料处理任务。" : "处理任务加载失败,请检查后端服务。"}</p>
  1655. </div>
  1656. `;
  1657. window.injectIcons?.(list);
  1658. return;
  1659. }
  1660. list.innerHTML = tasks
  1661. .slice(0, limit)
  1662. .map((task) => {
  1663. const status = processingTaskStatus(task);
  1664. const type = normalizeDocumentType(task.file_ext);
  1665. return `
  1666. <div class="extraction-row">
  1667. <span class="file-type ${type.toLowerCase()}">${escapeHtml(type)}</span>
  1668. <div>
  1669. <strong>《${escapeHtml(task.title)}》</strong>
  1670. <p>${escapeHtml(processingTaskDescription(task))}</p>
  1671. </div>
  1672. <span class="extraction-status ${status.className}">${escapeHtml(status.label)}</span>
  1673. </div>
  1674. `;
  1675. })
  1676. .join("");
  1677. }
  1678. async function refreshDocumentProcessingTasks() {
  1679. try {
  1680. const tasks = await request(withMine("/documents/processing-tasks?limit=4"));
  1681. renderDocumentProcessingTasks(tasks || [], "#documentProcessingTaskList", 4);
  1682. return tasks || [];
  1683. } catch {
  1684. const list = document.querySelector("#documentProcessingTaskList");
  1685. if (list) {
  1686. list.innerHTML = `
  1687. <div class="business-document-note">
  1688. <span data-icon="circle-alert"></span>
  1689. <p>处理任务加载失败,请稍后重试。</p>
  1690. </div>
  1691. `;
  1692. window.injectIcons?.(list);
  1693. }
  1694. return [];
  1695. }
  1696. }
  1697. async function openProcessingRecords() {
  1698. const modal = document.querySelector("#documentProcessingRecordsModal");
  1699. if (!modal) return;
  1700. modal.hidden = false;
  1701. document.body.style.overflow = "hidden";
  1702. const list = document.querySelector("#documentProcessingRecordsList");
  1703. const count = document.querySelector("#documentProcessingRecordsCount");
  1704. if (list) {
  1705. list.innerHTML = `
  1706. <div class="business-document-note">
  1707. <span data-icon="refresh-cw"></span>
  1708. <p>正在加载处理记录。</p>
  1709. </div>
  1710. `;
  1711. window.injectIcons?.(list);
  1712. }
  1713. try {
  1714. const tasks = await request(withMine("/documents/processing-tasks?limit=50"));
  1715. if (count) count.textContent = `${formatNumber((tasks || []).length)} 条记录`;
  1716. renderDocumentProcessingTasks(
  1717. tasks || [],
  1718. "#documentProcessingRecordsList",
  1719. 50
  1720. );
  1721. } catch {
  1722. if (list) {
  1723. list.innerHTML = `
  1724. <div class="business-document-note">
  1725. <span data-icon="circle-alert"></span>
  1726. <p>处理记录加载失败,请稍后重试。</p>
  1727. </div>
  1728. `;
  1729. window.injectIcons?.(list);
  1730. }
  1731. }
  1732. }
  1733. function closeProcessingRecords() {
  1734. const modal = document.querySelector("#documentProcessingRecordsModal");
  1735. if (modal) modal.hidden = true;
  1736. document.body.style.overflow = "";
  1737. }
  1738. function renderMonitoring(monitoring, documents, documentStatistics = null) {
  1739. state.monitoring = monitoring;
  1740. const documentTotal = Number(
  1741. documentStatistics?.total_documents ?? documents.length
  1742. );
  1743. const extractedFieldTotal = Number(
  1744. documentStatistics?.extracted_fields ?? 0
  1745. );
  1746. const overview = monitoring.overview || DEMO_MONITORING.overview;
  1747. const sources = monitoring.sources?.length
  1748. ? monitoring.sources.slice()
  1749. : DEMO_MONITORING.sources.slice();
  1750. const hasFacilitySource = sources.some((source) =>
  1751. String(source.source_type || "").toLowerCase().includes("facility")
  1752. );
  1753. if (!hasFacilitySource) {
  1754. sources.push({
  1755. id: "fallback-facilities",
  1756. name: "通防设施工况",
  1757. source_type: "facility",
  1758. status: "online",
  1759. point_count: overview.facility_count || DEMO_MONITORING.overview.facility_count,
  1760. latency_ms: 2400
  1761. });
  1762. }
  1763. const sourceGrid = document.querySelector("#view-data .source-grid");
  1764. if (sourceGrid) {
  1765. const cards = sources.slice(0, 3).map((source) => {
  1766. const visual = sourceVisual(source);
  1767. const statusText = source.status === "online" ? "在线" : "异常";
  1768. const isVideoPlatform =
  1769. String(source.source_type || "").toLowerCase().includes("video") ||
  1770. String(source.name || "").includes("视频");
  1771. const isFacilityStatus =
  1772. String(source.source_type || "").toLowerCase().includes("facility") ||
  1773. String(source.name || "").includes("通防设施");
  1774. const isSafetyMonitoring =
  1775. String(source.source_type || "").toLowerCase().includes("monitoring") ||
  1776. String(source.source_type || "").toLowerCase().includes("security") ||
  1777. String(source.name || "").includes("安全监控");
  1778. const pointUnit = String(source.source_type || "").toLowerCase().includes("facility")
  1779. ? "台"
  1780. : "点";
  1781. const tag = isVideoPlatform || isFacilityStatus || isSafetyMonitoring ? "button" : "article";
  1782. const attrs = isVideoPlatform
  1783. ? 'type="button" data-video-platform aria-label="打开工业视频平台"'
  1784. : isFacilityStatus
  1785. ? 'type="button" data-facility-status aria-label="打开通防设施工况"'
  1786. : isSafetyMonitoring
  1787. ? 'type="button" data-safety-monitoring aria-label="打开 KJ 安全监控系统"'
  1788. : "";
  1789. const className = isVideoPlatform || isFacilityStatus || isSafetyMonitoring
  1790. ? "source-card source-card-button"
  1791. : "source-card";
  1792. return `
  1793. <${tag} class="${className}" ${attrs}>
  1794. <div class="source-head"><span class="source-icon ${visual.className}" data-icon="${visual.icon}"></span><span class="source-status online">${statusText}</span></div>
  1795. <strong>${escapeHtml(source.name)}</strong>
  1796. <p>${escapeHtml(visual.description)}</p>
  1797. <div class="source-foot"><span>${formatNumber(source.point_count)} ${pointUnit}</span><span>延迟 ${(Number(source.latency_ms || 0) / 1000).toFixed(1)}s</span></div>
  1798. </${tag}>
  1799. `;
  1800. });
  1801. cards.push(`
  1802. <button class="source-card source-card-button" type="button" data-open-business aria-label="打开业务资料页面">
  1803. <div class="source-head"><span class="source-icon purple" data-icon="file-text"></span><span class="source-status online">已同步</span></div>
  1804. <strong>业务资料</strong>
  1805. <p>规程、措施、报表、台账与验收资料</p>
  1806. <div class="source-foot"><span>${formatNumber(documentTotal)} 份</span><span>已抽取 ${formatNumber(extractedFieldTotal)} 字段</span></div>
  1807. </button>
  1808. `);
  1809. sourceGrid.innerHTML = cards.join("");
  1810. if (typeof window.injectIcons === "function") window.injectIcons(sourceGrid);
  1811. sourceGrid.querySelectorAll("[data-video-platform]").forEach((button) => {
  1812. button.addEventListener("click", () => openVideoPlatform());
  1813. });
  1814. sourceGrid.querySelector("[data-safety-monitoring]")?.addEventListener("click", () => openSafetyMonitoring());
  1815. sourceGrid.querySelector("[data-facility-status]")?.addEventListener("click", () => window.navigateTo?.("facility-status"));
  1816. sourceGrid.querySelector("[data-open-business]")?.addEventListener("click", () => window.navigateTo?.("business-data"));
  1817. }
  1818. const toolbar = document.querySelector("#view-data .toolbar-copy p");
  1819. if (toolbar) {
  1820. toolbar.textContent = `当前接入 ${formatNumber(overview.data_source_count)} 类数据源,在线 ${formatNumber(overview.online_source_count)} 类,告警 ${formatNumber(overview.open_alarm_count)} 条`;
  1821. }
  1822. const objectValues = {
  1823. sensors: overview.sensor_count,
  1824. facilities: overview.facility_count
  1825. };
  1826. document.querySelectorAll("#view-data .object-card").forEach((card) => {
  1827. const label = card.querySelector("strong")?.textContent?.trim();
  1828. if (label === "传感器") card.querySelector("b").textContent = formatNumber(objectValues.sensors);
  1829. if (label === "通风设施") card.querySelector("b").textContent = formatNumber(objectValues.facilities);
  1830. });
  1831. }
  1832. function buildFacilityCategories(monitoring) {
  1833. const sensors = monitoring.sensors || [];
  1834. const facilities = monitoring.facilities || [];
  1835. const overview = monitoring.overview || {};
  1836. return FACILITY_CATEGORIES.map((category) => {
  1837. let actualTotal = 0;
  1838. let actualAlarms = 0;
  1839. let actualOffline = 0;
  1840. let records = [];
  1841. if (category.key === "sensors") {
  1842. actualTotal = Number(overview.sensor_count || sensors.length || 0);
  1843. actualAlarms = sensors.filter((item) => item.status === "alarm").length;
  1844. actualOffline = sensors.filter((item) => item.status === "offline").length;
  1845. records = sensors.map((item) => ({
  1846. name: item.name,
  1847. location: item.location || "未配置地点",
  1848. status: item.status || "normal"
  1849. }));
  1850. } else {
  1851. records = facilities
  1852. .filter((facility) =>
  1853. category.keywords.some((keyword) =>
  1854. String(facility.facility_type || "").includes(keyword)
  1855. )
  1856. )
  1857. .map((facility) => ({
  1858. name: facility.name,
  1859. location: facility.location || "未配置地点",
  1860. status: facility.status || "normal"
  1861. }));
  1862. actualTotal = records.length;
  1863. actualAlarms = records.filter((item) => item.status === "warning" || item.status === "alarm").length;
  1864. actualOffline = records.filter((item) => item.status === "offline").length;
  1865. }
  1866. return {
  1867. ...category,
  1868. total: Math.max(actualTotal, category.total),
  1869. alarms: Math.max(actualAlarms, category.alarms),
  1870. offline: Math.max(actualOffline, category.offline),
  1871. records
  1872. };
  1873. });
  1874. }
  1875. function renderFacilityStatus(monitoring) {
  1876. state.facilityMonitoring = monitoring;
  1877. const categories = buildFacilityCategories(monitoring);
  1878. const summaryGrid = document.querySelector("#facilitySummaryGrid");
  1879. const categoryGrid = document.querySelector("#facilityCategoryGrid");
  1880. if (!summaryGrid || !categoryGrid) return;
  1881. const total = categories.reduce((sum, item) => sum + item.total, 0);
  1882. const alarms = categories.reduce((sum, item) => sum + item.alarms, 0);
  1883. const offline = categories.reduce((sum, item) => sum + item.offline, 0);
  1884. const onlineRate = total ? ((total - offline) / total * 100).toFixed(1) : "0.0";
  1885. const overviewText = document.querySelector("#facilityOverviewText");
  1886. if (overviewText) {
  1887. overviewText.textContent = `当前共纳管 ${formatNumber(total)} 台设备,在线率 ${onlineRate}%,${alarms ? `存在 ${alarms} 项报警` : "暂无报警"},${offline ? `${offline} 台设备处于断开状态` : "设备连接状态正常"}。`;
  1888. }
  1889. const categoryCount = document.querySelector("#facilityCategoryCount");
  1890. if (categoryCount) categoryCount.textContent = `${categories.length} 类`;
  1891. summaryGrid.innerHTML = `
  1892. <article class="facility-summary-card facility-summary-total">
  1893. <span class="facility-summary-icon" data-icon="settings-2"></span>
  1894. <div><span>设备总数</span><strong>${formatNumber(total)}</strong><small>全部通防设备</small></div>
  1895. </article>
  1896. <article class="facility-summary-card facility-summary-online">
  1897. <span class="facility-summary-icon" data-icon="activity"></span>
  1898. <div><span>设备在线率</span><strong>${onlineRate}%</strong><small>${formatNumber(total - offline)} 台在线</small></div>
  1899. </article>
  1900. <article class="facility-summary-card facility-summary-alarm">
  1901. <span class="facility-summary-icon" data-icon="triangle-alert"></span>
  1902. <div><span>报警设备</span><strong>${formatNumber(alarms)}</strong><small>${alarms ? "需要及时确认" : "暂无报警"}</small></div>
  1903. </article>
  1904. <article class="facility-summary-card facility-summary-offline">
  1905. <span class="facility-summary-icon" data-icon="plug-zap"></span>
  1906. <div><span>断开设备</span><strong>${formatNumber(offline)}</strong><small>${offline ? "请检查连接状态" : "连接状态正常"}</small></div>
  1907. </article>
  1908. `;
  1909. if (typeof window.injectIcons === "function") window.injectIcons(summaryGrid);
  1910. categoryGrid.innerHTML = categories
  1911. .map(
  1912. (category) => `
  1913. <button class="facility-category-card" type="button" data-facility-category="${category.key}">
  1914. <span class="facility-category-icon"><span data-icon="${category.icon}"></span></span>
  1915. <span class="facility-category-copy">
  1916. <span class="facility-category-title"><strong>${escapeHtml(category.name)}</strong><i class="${category.alarms ? "warning" : category.offline ? "offline" : "normal"}"></i></span>
  1917. <small>${category.alarms ? `${category.alarms} 项报警` : category.offline ? `${category.offline} 台断开` : "运行状态正常"}</small>
  1918. </span>
  1919. <span class="facility-category-counts">
  1920. <span><b>${formatNumber(category.total)}</b><small>总数</small></span>
  1921. <span><b class="${category.alarms ? "text-danger" : ""}">${formatNumber(category.alarms)}</b><small>报警</small></span>
  1922. <span><b>${formatNumber(category.offline)}</b><small>断开</small></span>
  1923. </span>
  1924. </button>
  1925. `
  1926. )
  1927. .join("");
  1928. if (typeof window.injectIcons === "function") window.injectIcons(categoryGrid);
  1929. categoryGrid.querySelectorAll("[data-facility-category]").forEach((button) => {
  1930. button.addEventListener("click", () => {
  1931. categoryGrid.querySelectorAll("[data-facility-category]").forEach((item) => {
  1932. item.classList.toggle("active", item === button);
  1933. });
  1934. const category = categories.find((item) => item.key === button.dataset.facilityCategory);
  1935. renderFacilityDetail(category);
  1936. });
  1937. });
  1938. renderFacilityDetail(categories[0]);
  1939. }
  1940. function renderFacilityDetail(category) {
  1941. const panel = document.querySelector("#facilityDetailPanel");
  1942. if (!panel || !category) return;
  1943. const records = category.records.slice(0, 12);
  1944. while (records.length < Math.min(category.total, 12)) {
  1945. const index = records.length;
  1946. const status = index < category.offline
  1947. ? "offline"
  1948. : index < category.offline + category.alarms
  1949. ? "warning"
  1950. : "normal";
  1951. records.push({
  1952. name: `${category.name} ${String(index + 1).padStart(2, "0")}`,
  1953. location: index % 2 ? "北翼回风巷" : "22煤一盘区",
  1954. status
  1955. });
  1956. }
  1957. const statusLabel = { normal: "正常", warning: "报警", alarm: "报警", offline: "断开" };
  1958. const onlineCount = Math.max(category.total - category.offline, 0);
  1959. const onlineRate = category.total ? (onlineCount / category.total * 100).toFixed(1) : "0.0";
  1960. panel.innerHTML = `
  1961. <div class="panel-head">
  1962. <div><span class="eyebrow">设备明细</span><h2>${escapeHtml(category.name)}</h2><p class="facility-detail-subtitle">在线率 ${onlineRate}% · 报警 ${category.alarms} · 断开 ${category.offline}</p></div>
  1963. <span class="subtle-badge">${formatNumber(category.total)} 台</span>
  1964. </div>
  1965. <div class="facility-detail-list">
  1966. ${records
  1967. .map(
  1968. (item) => `
  1969. <div class="facility-detail-row">
  1970. <span class="facility-detail-device-icon" data-icon="${category.icon}"></span>
  1971. <div class="facility-detail-copy"><strong>${escapeHtml(item.name)}</strong><p>${escapeHtml(item.location)}</p></div>
  1972. <span class="facility-detail-state ${escapeHtml(item.status)}"><i></i>${statusLabel[item.status] || "正常"}</span>
  1973. </div>
  1974. `
  1975. )
  1976. .join("")}
  1977. </div>
  1978. `;
  1979. if (typeof window.injectIcons === "function") window.injectIcons(panel);
  1980. }
  1981. function installFacilityActions() {
  1982. document.querySelector("[data-refresh-facility]")?.addEventListener("click", async () => {
  1983. const result = await loadMonitoring();
  1984. renderFacilityStatus(result.data);
  1985. window.showToast?.("通防设施工况已刷新");
  1986. });
  1987. }
  1988. function safetyStatusForValue(metric, value) {
  1989. const number = Number(value);
  1990. if (!Number.isFinite(number)) return "offline";
  1991. if (metric.thresholdMode === "below") {
  1992. if (number <= Number(metric.alarmThreshold)) return "alarm";
  1993. if (number <= Number(metric.warningThreshold)) return "warning";
  1994. return "normal";
  1995. }
  1996. if (number >= Number(metric.alarmThreshold)) return "alarm";
  1997. if (number >= Number(metric.warningThreshold)) return "warning";
  1998. return "normal";
  1999. }
  2000. function formatSafetyValue(metric, value) {
  2001. const number = Number(value);
  2002. if (!Number.isFinite(number)) return "--";
  2003. const decimals = metric.key === "airflow" ? 0 : metric.key === "temperature" || metric.key === "co" ? 1 : 2;
  2004. return number.toLocaleString("zh-CN", {
  2005. minimumFractionDigits: decimals,
  2006. maximumFractionDigits: decimals
  2007. });
  2008. }
  2009. function buildDemoMetricSeries(metric) {
  2010. const values = Array.from({ length: 48 }, (_, index) => {
  2011. const wave = Math.sin(index / 4.2 + metric.key.length) * metric.amplitude;
  2012. const secondary = Math.cos(index / 7.4 + metric.baseline) * metric.amplitude * 0.35;
  2013. const noise = Math.sin(index * 1.73 + metric.key.length * 1.8) * metric.noise;
  2014. return metric.baseline + wave + secondary + noise;
  2015. });
  2016. if (metric.key === "gas") {
  2017. values[47] = 0.86;
  2018. values[46] = 0.78;
  2019. values[45] = 0.7;
  2020. } else if (metric.key === "airflow") {
  2021. values[47] = 1286;
  2022. values[46] = 1302;
  2023. values[45] = 1328;
  2024. } else if (metric.key === "dust") {
  2025. values[47] = 4.2;
  2026. values[46] = 3.82;
  2027. values[45] = 3.65;
  2028. } else if (metric.key === "air_speed") {
  2029. values[47] = 0.22;
  2030. values[46] = 0.27;
  2031. values[45] = 0.31;
  2032. } else {
  2033. values[47] = metric.baseline + metric.amplitude * 0.28;
  2034. }
  2035. return values.map((value) => Number(value.toFixed(3)));
  2036. }
  2037. function calculateLinearForecast(values, count = 8) {
  2038. const sample = values.slice(-Math.min(values.length, 16)).map(Number).filter(Number.isFinite);
  2039. if (sample.length < 2) return Array.from({ length: count }, () => sample[0] || 0);
  2040. const meanX = (sample.length - 1) / 2;
  2041. const meanY = sample.reduce((sum, value) => sum + value, 0) / sample.length;
  2042. let numerator = 0;
  2043. let denominator = 0;
  2044. sample.forEach((value, index) => {
  2045. numerator += (index - meanX) * (value - meanY);
  2046. denominator += (index - meanX) ** 2;
  2047. });
  2048. const slope = denominator ? numerator / denominator : 0;
  2049. return Array.from({ length: count }, (_, index) =>
  2050. Number((sample[sample.length - 1] + slope * (index + 1)).toFixed(3))
  2051. );
  2052. }
  2053. function buildSafetyMetrics(monitoring) {
  2054. const sensors = monitoring.sensors || [];
  2055. return SAFETY_METRIC_DEFS.map((definition) => {
  2056. const sensor = sensors.find(
  2057. (item) =>
  2058. String(item.sensor_type || "").toLowerCase() === definition.sensorType ||
  2059. String(item.code || "").toLowerCase().includes(definition.key)
  2060. );
  2061. const readings = sensor ? state.safetyReadings.get(String(sensor.id)) : null;
  2062. let values =
  2063. Array.isArray(readings) && readings.length > 2
  2064. ? readings.map((item) => Number(item.value)).filter(Number.isFinite)
  2065. : buildDemoMetricSeries(definition);
  2066. if (values.length < 3) values = buildDemoMetricSeries(definition);
  2067. const sensorValue = Number(sensor?.last_value);
  2068. if (Number.isFinite(sensorValue)) {
  2069. values[values.length - 1] = sensorValue;
  2070. values[values.length - 2] = values[values.length - 2] * 0.35 + sensorValue * 0.65;
  2071. }
  2072. const currentValue = values[values.length - 1];
  2073. const forecast = calculateLinearForecast(values);
  2074. const predictedValue = forecast[forecast.length - 1];
  2075. const status = sensor?.status === "offline" ? "offline" : safetyStatusForValue(definition, currentValue);
  2076. const distance = definition.thresholdMode === "below"
  2077. ? (currentValue - definition.alarmThreshold) / Math.max(definition.warningThreshold - definition.alarmThreshold, 0.001)
  2078. : (definition.alarmThreshold - currentValue) / Math.max(definition.alarmThreshold - definition.warningThreshold, 0.001);
  2079. const baseRisk = status === "alarm" ? 88 : status === "warning" ? 66 : status === "offline" ? 78 : 24;
  2080. const riskScore = Math.max(8, Math.min(98, Math.round(baseRisk + (1 - Math.max(0, Math.min(1, distance))) * 8)));
  2081. return {
  2082. ...definition,
  2083. sensor,
  2084. unit: sensor?.unit || definition.unit,
  2085. location: sensor?.location || "全矿监测网络",
  2086. values,
  2087. forecast,
  2088. currentValue,
  2089. predictedValue,
  2090. predictedStatus: safetyStatusForValue(definition, predictedValue),
  2091. status,
  2092. riskScore
  2093. };
  2094. });
  2095. }
  2096. function getSafetySource(monitoring) {
  2097. return (
  2098. (monitoring.sources || []).find((source) => {
  2099. const type = String(source.source_type || "").toLowerCase();
  2100. return type.includes("monitoring") || type.includes("security") || String(source.name || "").includes("安全监控");
  2101. }) || DEMO_MONITORING.sources[0]
  2102. );
  2103. }
  2104. function buildSparkline(values, tone = "teal") {
  2105. const series = values.slice(-18).map(Number).filter(Number.isFinite);
  2106. if (series.length < 2) return "";
  2107. const min = Math.min(...series);
  2108. const max = Math.max(...series);
  2109. const range = max - min || 1;
  2110. const points = series
  2111. .map((value, index) => {
  2112. const x = (index / (series.length - 1)) * 84 + 2;
  2113. const y = 28 - ((value - min) / range) * 24;
  2114. return `${x.toFixed(1)},${y.toFixed(1)}`;
  2115. })
  2116. .join(" ");
  2117. return `<svg class="safety-sparkline ${tone}" viewBox="0 0 88 32" aria-hidden="true"><polyline points="${points}"></polyline></svg>`;
  2118. }
  2119. function buildSmoothPath(points, smoothing = 0.16) {
  2120. if (!points.length) return "";
  2121. if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
  2122. return points.reduce((path, point, index) => {
  2123. if (index === 0) return `M ${point.x} ${point.y}`;
  2124. const previous = points[index - 1];
  2125. const beforePrevious = points[index - 2] || previous;
  2126. const next = points[index + 1] || point;
  2127. const control1X = previous.x + (point.x - beforePrevious.x) * smoothing;
  2128. const control1Y = previous.y + (point.y - beforePrevious.y) * smoothing;
  2129. const control2X = point.x - (next.x - previous.x) * smoothing;
  2130. const control2Y = point.y - (next.y - previous.y) * smoothing;
  2131. return `${path} C ${control1X.toFixed(1)} ${control1Y.toFixed(1)}, ${control2X.toFixed(1)} ${control2Y.toFixed(1)}, ${point.x} ${point.y}`;
  2132. }, "");
  2133. }
  2134. function renderSafetyChart(metric) {
  2135. const chart = document.querySelector("#safetyMonitoringChart");
  2136. if (!chart || !metric) return;
  2137. const range = Math.min(state.safetyRange, metric.values.length);
  2138. const values = metric.values.slice(-range);
  2139. const forecast = metric.forecast || [];
  2140. const svgWidth = 820;
  2141. const svgHeight = 290;
  2142. const margin = { top: 22, right: 28, bottom: 38, left: 62 };
  2143. const plotWidth = svgWidth - margin.left - margin.right;
  2144. const plotHeight = svgHeight - margin.top - margin.bottom;
  2145. const allValues = [...values, ...forecast, metric.warningThreshold, metric.alarmThreshold].map(Number).filter(Number.isFinite);
  2146. let minimum = Math.min(...allValues);
  2147. let maximum = Math.max(...allValues);
  2148. if (maximum === minimum) {
  2149. maximum += 1;
  2150. minimum -= 1;
  2151. }
  2152. const padding = (maximum - minimum) * 0.16;
  2153. minimum -= padding;
  2154. maximum += padding;
  2155. const totalSteps = Math.max(values.length + forecast.length - 1, 1);
  2156. const xFor = (index) => margin.left + (index / totalSteps) * plotWidth;
  2157. const yFor = (value) => margin.top + (1 - (Number(value) - minimum) / (maximum - minimum)) * plotHeight;
  2158. const actualPoints = values.map((value, index) => ({ x: Number(xFor(index).toFixed(1)), y: Number(yFor(value).toFixed(1)) }));
  2159. const forecastPoints = [
  2160. actualPoints[actualPoints.length - 1],
  2161. ...forecast.map((value, index) => ({
  2162. x: Number(xFor(values.length + index).toFixed(1)),
  2163. y: Number(yFor(value).toFixed(1))
  2164. }))
  2165. ];
  2166. const actualPath = buildSmoothPath(actualPoints);
  2167. const forecastPath = buildSmoothPath(forecastPoints);
  2168. const areaPath = `${actualPath} L ${actualPoints[actualPoints.length - 1].x} ${margin.top + plotHeight} L ${actualPoints[0].x} ${margin.top + plotHeight} Z`;
  2169. const gridLines = Array.from({ length: 5 }, (_, index) => {
  2170. const y = margin.top + (index / 4) * plotHeight;
  2171. const value = maximum - (index / 4) * (maximum - minimum);
  2172. 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>`;
  2173. }).join("");
  2174. const thresholdLines = [
  2175. { value: metric.warningThreshold, className: "warning", label: "预警" },
  2176. { value: metric.alarmThreshold, className: "alarm", label: "报警" }
  2177. ]
  2178. .filter((item) => Number.isFinite(Number(item.value)))
  2179. .map((item) => {
  2180. const y = yFor(item.value);
  2181. 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>`;
  2182. })
  2183. .join("");
  2184. const currentPoint = actualPoints[actualPoints.length - 1];
  2185. const gradientId = `safety-gradient-${metric.key}`;
  2186. chart.innerHTML = `
  2187. <svg class="safety-chart-svg" viewBox="0 0 ${svgWidth} ${svgHeight}" role="img" aria-label="${escapeHtml(metric.name)}监测趋势图">
  2188. <defs>
  2189. <linearGradient id="${gradientId}" x1="0" y1="0" x2="0" y2="1">
  2190. <stop offset="0%" stop-color="${metric.tone === "red" ? "#d84d50" : "#0f9f8f"}" stop-opacity="0.22"/>
  2191. <stop offset="100%" stop-color="${metric.tone === "red" ? "#d84d50" : "#0f9f8f"}" stop-opacity="0"/>
  2192. </linearGradient>
  2193. </defs>
  2194. ${gridLines}
  2195. ${thresholdLines}
  2196. <path d="${areaPath}" fill="url(#${gradientId})"/>
  2197. <path d="${actualPath}" class="safety-chart-actual-path ${metric.tone}"/>
  2198. <path d="${forecastPath}" class="safety-chart-forecast-path"/>
  2199. <line x1="${currentPoint.x}" y1="${margin.top}" x2="${currentPoint.x}" y2="${margin.top + plotHeight}" class="safety-chart-now-line"/>
  2200. <circle cx="${currentPoint.x}" cy="${currentPoint.y}" r="5" class="safety-chart-current-point ${metric.tone}"/>
  2201. <circle cx="${currentPoint.x}" cy="${currentPoint.y}" r="10" class="safety-chart-current-halo ${metric.tone}"/>
  2202. <text x="${margin.left}" y="${svgHeight - 12}" class="safety-chart-time-label">${state.safetyRange} 小时前</text>
  2203. <text x="${currentPoint.x}" y="${svgHeight - 12}" text-anchor="middle" class="safety-chart-time-label">当前</text>
  2204. <text x="${svgWidth - margin.right}" y="${svgHeight - 12}" text-anchor="end" class="safety-chart-time-label">预测 +2h</text>
  2205. </svg>
  2206. `;
  2207. }
  2208. function renderSafetyInsights(metric) {
  2209. if (!metric) return;
  2210. const riskRing = document.querySelector("#safetyRiskRing");
  2211. const riskScore = document.querySelector("#safetyRiskScore");
  2212. const riskLabel = document.querySelector("#safetyRiskLabel");
  2213. const riskSummary = document.querySelector("#safetyRiskSummary");
  2214. const predictionText = document.querySelector("#safetyPredictionText");
  2215. const confidence = document.querySelector("#safetyPredictionConfidence");
  2216. const recommendations = document.querySelector("#safetyRecommendations");
  2217. if (riskRing) {
  2218. riskRing.dataset.level = metric.status;
  2219. riskRing.style.setProperty("--risk-progress", `${metric.riskScore}%`);
  2220. }
  2221. if (riskScore) riskScore.textContent = String(metric.riskScore);
  2222. const labels = {
  2223. normal: "运行平稳",
  2224. warning: "趋势关注",
  2225. alarm: "超限预警",
  2226. offline: "测点离线"
  2227. };
  2228. if (riskLabel) {
  2229. riskLabel.textContent = labels[metric.status] || "运行平稳";
  2230. riskLabel.className = `safety-risk-label ${metric.status}`;
  2231. }
  2232. if (riskSummary) {
  2233. if (metric.status === "alarm") {
  2234. riskSummary.textContent = `${metric.name}当前值 ${formatSafetyValue(metric, metric.currentValue)} ${metric.unit},已越过报警阈值,需要立即处置。`;
  2235. } else if (metric.status === "warning") {
  2236. riskSummary.textContent = `${metric.name}当前值靠近控制边界,未来 2 小时仍存在继续偏离的风险。`;
  2237. } else {
  2238. riskSummary.textContent = `${metric.name}处于稳定区间,当前阈值余量充足。`;
  2239. }
  2240. }
  2241. const change = metric.currentValue
  2242. ? ((metric.predictedValue - metric.currentValue) / Math.abs(metric.currentValue)) * 100
  2243. : 0;
  2244. const direction = Math.abs(change) < 0.4 ? "基本持平" : change > 0 ? "缓慢上升" : "缓慢下降";
  2245. if (predictionText) {
  2246. predictionText.textContent = `基于近 ${metric.values.length} 个监测点执行趋势外推,预计未来 2 小时${direction},预测值约 ${formatSafetyValue(metric, metric.predictedValue)} ${metric.unit}。`;
  2247. }
  2248. if (confidence) {
  2249. const value = Math.min(98, Math.round(88 + Math.min(metric.values.length, 48) / 6));
  2250. confidence.textContent = `置信度 ${value}%`;
  2251. }
  2252. if (recommendations) {
  2253. recommendations.innerHTML = metric.recommendations
  2254. .map(
  2255. (item, index) => `
  2256. <div class="safety-recommendation-item">
  2257. <span>${String(index + 1).padStart(2, "0")}</span>
  2258. <p>${escapeHtml(item)}</p>
  2259. </div>
  2260. `
  2261. )
  2262. .join("");
  2263. }
  2264. }
  2265. function renderSafetyMetricTabs(metrics) {
  2266. const container = document.querySelector("#safetyMetricTabs");
  2267. if (!container) return;
  2268. container.innerHTML = metrics
  2269. .map(
  2270. (metric) => `
  2271. <button class="safety-metric-tab ${metric.key === state.safetySelectedMetric ? "active" : ""}" type="button" data-safety-metric="${metric.key}" data-tone="${metric.tone}">
  2272. <span class="safety-metric-tab-icon" data-icon="${metric.icon}"></span>
  2273. <span class="safety-metric-tab-copy">
  2274. <span><strong>${escapeHtml(metric.shortName)}</strong><small>${formatNumber(metric.pointCount)} 测点</small></span>
  2275. <b>${formatSafetyValue(metric, metric.currentValue)} <small>${escapeHtml(metric.unit)}</small></b>
  2276. </span>
  2277. ${buildSparkline(metric.values, metric.tone)}
  2278. </button>
  2279. `
  2280. )
  2281. .join("");
  2282. container.querySelectorAll("[data-safety-metric]").forEach((button) => {
  2283. button.addEventListener("click", () => {
  2284. state.safetySelectedMetric = button.dataset.safetyMetric;
  2285. renderSafetyMonitoring(state.monitoring);
  2286. });
  2287. });
  2288. if (typeof window.injectIcons === "function") window.injectIcons(container);
  2289. }
  2290. function renderSafetySensorList(metrics) {
  2291. const container = document.querySelector("#safetySensorList");
  2292. const count = document.querySelector("#safetySensorCount");
  2293. if (!container) return;
  2294. if (count) count.textContent = `${metrics.length} 类参数`;
  2295. const statusLabel = { normal: "正常", warning: "关注", alarm: "报警", offline: "离线" };
  2296. container.innerHTML = metrics
  2297. .map(
  2298. (metric) => `
  2299. <button class="safety-sensor-row" type="button" data-safety-sensor-metric="${metric.key}">
  2300. <span class="safety-sensor-icon" data-icon="${metric.icon}"></span>
  2301. <span class="safety-sensor-copy">
  2302. <strong>${escapeHtml(metric.sensor?.name || `${metric.name}监测点`)}</strong>
  2303. <small>${escapeHtml(metric.location)}</small>
  2304. </span>
  2305. <span class="safety-sensor-value"><strong>${formatSafetyValue(metric, metric.currentValue)}</strong><small>${escapeHtml(metric.unit)}</small></span>
  2306. <span class="safety-sensor-state ${escapeHtml(metric.status)}"><i></i>${statusLabel[metric.status] || "正常"}</span>
  2307. </button>
  2308. `
  2309. )
  2310. .join("");
  2311. container.querySelectorAll("[data-safety-sensor-metric]").forEach((button) => {
  2312. button.addEventListener("click", () => {
  2313. state.safetySelectedMetric = button.dataset.safetySensorMetric;
  2314. renderSafetyMonitoring(state.monitoring);
  2315. });
  2316. });
  2317. if (typeof window.injectIcons === "function") window.injectIcons(container);
  2318. }
  2319. function renderSafetyAlarmList(monitoring) {
  2320. const container = document.querySelector("#safetyAlarmList");
  2321. const count = document.querySelector("#safetyAlarmCount");
  2322. if (!container) return;
  2323. const alarms = monitoring?.alarms?.length ? monitoring.alarms : DEMO_MONITORING.alarms;
  2324. if (count) count.textContent = `${alarms.length} 条`;
  2325. const levelLabel = { high: "高风险", medium: "中风险", low: "低风险" };
  2326. container.innerHTML = alarms.length
  2327. ? alarms
  2328. .slice(0, 6)
  2329. .map(
  2330. (alarm) => `
  2331. <div class="safety-alarm-row">
  2332. <span class="safety-alarm-level ${escapeHtml(alarm.level || "medium")}">${levelLabel[alarm.level] || "告警"}</span>
  2333. <div>
  2334. <strong>${escapeHtml(alarm.title || "监测数据异常")}</strong>
  2335. <p>${escapeHtml(alarm.content || `当前值 ${formatSafetyValue({ key: "gas", name: "监测值" }, alarm.value)} · 阈值 ${formatSafetyValue({ key: "gas", name: "阈值" }, alarm.threshold)}`)}</p>
  2336. </div>
  2337. <time>${formatTime(alarm.occurred_at)}</time>
  2338. </div>
  2339. `
  2340. )
  2341. .join("")
  2342. : '<div class="safety-empty-state"><span data-icon="check-circle-2"></span><p>当前没有未处置告警</p></div>';
  2343. if (typeof window.injectIcons === "function") window.injectIcons(container);
  2344. }
  2345. function renderSafetyMonitoring(monitoring) {
  2346. if (!monitoring) return;
  2347. state.monitoring = monitoring;
  2348. const summaryGrid = document.querySelector("#safetyMonitoringSummary");
  2349. if (!summaryGrid) return;
  2350. const metrics = buildSafetyMetrics(monitoring);
  2351. state.safetyMetrics = metrics;
  2352. const source = getSafetySource(monitoring);
  2353. const overview = monitoring.overview || DEMO_MONITORING.overview;
  2354. const totalPoints = Number(source.point_count || DEMO_MONITORING.sources[0].point_count || 0);
  2355. const onlineSensors = Number(overview.online_sensor_count || metrics.reduce((sum, item) => sum + item.onlineCount, 0));
  2356. const sensorTotal = Number(overview.sensor_count || metrics.reduce((sum, item) => sum + item.pointCount, 0));
  2357. const onlineRate = sensorTotal ? ((onlineSensors / sensorTotal) * 100).toFixed(1) : "0.0";
  2358. const abnormalCount = metrics.filter((metric) => metric.status !== "normal").length;
  2359. const latency = Number(source.latency_ms || 2400) / 1000;
  2360. const selected = metrics.find((metric) => metric.key === state.safetySelectedMetric) || metrics[0];
  2361. state.safetySelectedMetric = selected?.key || "gas";
  2362. summaryGrid.innerHTML = `
  2363. <article class="safety-summary-card total">
  2364. <span class="safety-summary-icon" data-icon="radio-tower"></span>
  2365. <div><small>接入测点</small><strong>${formatNumber(totalPoints)}</strong><p>安全监控系统接入总量</p></div>
  2366. </article>
  2367. <article class="safety-summary-card online">
  2368. <span class="safety-summary-icon" data-icon="activity"></span>
  2369. <div><small>在线率</small><strong>${onlineRate}%</strong><p>${formatNumber(onlineSensors)} 个测点在线</p></div>
  2370. </article>
  2371. <article class="safety-summary-card warning">
  2372. <span class="safety-summary-icon" data-icon="shield-alert"></span>
  2373. <div><small>异常参数</small><strong>${formatNumber(abnormalCount)}</strong><p>存在报警或关注趋势</p></div>
  2374. </article>
  2375. <article class="safety-summary-card latency">
  2376. <span class="safety-summary-icon" data-icon="clock"></span>
  2377. <div><small>数据延迟</small><strong>${latency.toFixed(1)}s</strong><p>${escapeHtml(source.protocol || "OPC UA")} 实时链路</p></div>
  2378. </article>
  2379. `;
  2380. const toolbarText = document.querySelector("#safetyMonitoringToolbarText");
  2381. if (toolbarText) {
  2382. toolbarText.textContent = `${source.protocol || "OPC UA"} · ${source.endpoint || "实时数据链路"} · 覆盖瓦斯、风量、CO、温度、粉尘、风速`;
  2383. }
  2384. const overviewText = document.querySelector("#safetyOverviewText");
  2385. if (overviewText) {
  2386. overviewText.textContent = `已接入 ${formatNumber(totalPoints)} 个安全监测点,在线率 ${onlineRate}%,当前识别到 ${formatNumber(abnormalCount)} 类参数需要关注。`;
  2387. }
  2388. const sourceName = document.querySelector("#safetyOverviewSource");
  2389. if (sourceName) sourceName.textContent = source.code || source.name || "KJ-SAFETY";
  2390. const sourceProtocol = document.querySelector("#safetyOverviewProtocol");
  2391. if (sourceProtocol) sourceProtocol.textContent = `${source.protocol || "OPC UA"} · 实时接入`;
  2392. renderSafetyMetricTabs(metrics);
  2393. if (selected) {
  2394. const title = document.querySelector("#safetyChartTitle");
  2395. const subtitle = document.querySelector("#safetyChartSubtitle");
  2396. const currentValue = document.querySelector("#safetyCurrentValue");
  2397. const currentUnit = document.querySelector("#safetyCurrentUnit");
  2398. if (title) title.textContent = `${selected.name}趋势`;
  2399. if (subtitle) subtitle.textContent = `${selected.location} · 阈值 ${formatSafetyValue(selected, selected.warningThreshold)} / ${formatSafetyValue(selected, selected.alarmThreshold)} ${selected.unit}`;
  2400. if (currentValue) currentValue.textContent = formatSafetyValue(selected, selected.currentValue);
  2401. if (currentUnit) currentUnit.textContent = selected.unit;
  2402. renderSafetyChart(selected);
  2403. renderSafetyInsights(selected);
  2404. }
  2405. renderSafetySensorList(metrics);
  2406. renderSafetyAlarmList(monitoring);
  2407. if (typeof window.injectIcons === "function") window.injectIcons(summaryGrid);
  2408. }
  2409. function installSafetyMonitoringActions() {
  2410. document.querySelector("[data-refresh-safety-monitoring]")?.addEventListener("click", async () => {
  2411. const result = await loadMonitoring();
  2412. renderSafetyMonitoring(result.data);
  2413. await hydrateSafetyReadings(result.data);
  2414. window.showToast?.("安全监控数据已刷新");
  2415. });
  2416. document.querySelectorAll("[data-safety-range]").forEach((button) => {
  2417. button.addEventListener("click", () => {
  2418. state.safetyRange = Number(button.dataset.safetyRange || 24);
  2419. document.querySelectorAll("[data-safety-range]").forEach((item) => item.classList.toggle("active", item === button));
  2420. const metric = state.safetyMetrics.find((item) => item.key === state.safetySelectedMetric);
  2421. renderSafetyChart(metric);
  2422. });
  2423. });
  2424. }
  2425. // -------------------------------------------------------------------------
  2426. // 工业视频平台:对接 /api/v1/video-platform,后端不可用时回退内置演示数据
  2427. // -------------------------------------------------------------------------
  2428. const VIDEO_MODULE_KEYS = {
  2429. door: "door",
  2430. "local-fan": "local_fan",
  2431. "main-fan": "main_fan"
  2432. };
  2433. const VIDEO_MODULE_VIEW_KEYS = {
  2434. door: "door",
  2435. local_fan: "local-fan",
  2436. main_fan: "main-fan"
  2437. };
  2438. const VIDEO_MODULE_GROUPS = {
  2439. door: "风门区域",
  2440. "local-fan": "局扇区域",
  2441. "main-fan": "主扇区域"
  2442. };
  2443. const VIDEO_DEMO_RECORD_TYPES = {
  2444. door: ["vehicle_passage", "person_passage", "door_unclosed"],
  2445. "local-fan": ["person_track", "area_stay", "person_warn"],
  2446. "main-fan": ["person_detect", "device_operation", "device_warn"]
  2447. };
  2448. const VIDEO_EVENT_TONES = { danger: "danger", warning: "warning", info: "gray" };
  2449. const VIDEO_EVENT_STATUS_LABELS = {
  2450. pending: "待处理",
  2451. processing: "处理中",
  2452. handled: "已处理",
  2453. ignored: "已忽略"
  2454. };
  2455. function createVideoPlatformState() {
  2456. return {
  2457. source: "demo",
  2458. loaded: false,
  2459. loadingModule: "",
  2460. mineId: "",
  2461. overview: null,
  2462. modules: {},
  2463. channels: [],
  2464. status: {},
  2465. events: {},
  2466. eventPage: 1,
  2467. eventPageSize: 5,
  2468. snapshots: {},
  2469. tables: {},
  2470. filters: { quick: "today", start: "", end: "", type: "", scope: "" }
  2471. };
  2472. }
  2473. function videoPlatformState() {
  2474. if (!state.videoPlatform) state.videoPlatform = createVideoPlatformState();
  2475. return state.videoPlatform;
  2476. }
  2477. function formatDateTime(value) {
  2478. if (!value) return "--";
  2479. const date = new Date(value);
  2480. if (Number.isNaN(date.getTime())) return String(value).replace("T", " ").slice(0, 19);
  2481. const pad = (number) => String(number).padStart(2, "0");
  2482. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
  2483. }
  2484. function formatClock(value) {
  2485. if (!value) return "--:--:--";
  2486. const date = new Date(value);
  2487. if (Number.isNaN(date.getTime())) return String(value).slice(11, 19) || "--:--:--";
  2488. const pad = (number) => String(number).padStart(2, "0");
  2489. return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
  2490. }
  2491. function toLocalInputValue(date) {
  2492. const pad = (number) => String(number).padStart(2, "0");
  2493. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
  2494. }
  2495. function videoModuleKeyFor(viewKey) {
  2496. return VIDEO_MODULE_KEYS[viewKey] || "door";
  2497. }
  2498. function videoModuleViewKey(moduleKey) {
  2499. return VIDEO_MODULE_VIEW_KEYS[moduleKey] || "door";
  2500. }
  2501. function videoStatusLabel(status) {
  2502. return status === "online" ? "在线" : status === "offline" ? "离线" : "异常";
  2503. }
  2504. function videoSourceCount(monitoring, fallback = 0) {
  2505. const source = (monitoring?.sources || []).find((item) =>
  2506. String(item.source_type || "").toLowerCase().includes("video")
  2507. );
  2508. return Number(source?.point_count || fallback || 0);
  2509. }
  2510. function videoPlatformChannels(viewKey = state.videoModule) {
  2511. const view = videoPlatformState();
  2512. const moduleKey = videoModuleKeyFor(viewKey);
  2513. return view.channels.filter((channel) => channel.module === moduleKey);
  2514. }
  2515. // --- 后端数据归一化 -------------------------------------------------------
  2516. function normalizeVideoChannel(item) {
  2517. const status = String(item.status || "online");
  2518. return {
  2519. id: String(item.id),
  2520. code: item.code || "",
  2521. name: item.name || "",
  2522. module: item.module || "",
  2523. group: item.area_group || "",
  2524. location: item.location || "",
  2525. status,
  2526. streamUrl: item.stream_url || "",
  2527. playbackUrl: item.media_available && item.playback_url ? item.playback_url : "",
  2528. snapshotUrl: item.snapshot_url || "",
  2529. mediaAvailable: Boolean(item.media_available),
  2530. resolution: item.resolution || "",
  2531. isOnline: status === "online"
  2532. };
  2533. }
  2534. function normalizeVideoModuleConfig(item) {
  2535. return {
  2536. key: item.key || "",
  2537. viewKey: videoModuleViewKey(item.key),
  2538. title: item.title || "",
  2539. statusTitle: item.status_title || "",
  2540. typeFilterLabel: item.type_filter_label || "类型",
  2541. typeFilterOptions: item.type_filter_options?.length ? item.type_filter_options : ["全部类型"],
  2542. scopeFilterLabel: item.scope_filter_label || "设备",
  2543. scopeFilterOptions: item.scope_filter_options?.length ? item.scope_filter_options : ["全部设备"],
  2544. tabs: (item.record_tabs || []).map((tab) => ({
  2545. recordType: tab.record_type,
  2546. label: tab.label,
  2547. columns: tab.columns || []
  2548. }))
  2549. };
  2550. }
  2551. function normalizeVideoStatus(payload) {
  2552. return (payload?.metrics || []).map((item) => ({
  2553. label: item.label || "",
  2554. value: Number(item.value || 0),
  2555. unit: item.unit || "",
  2556. tone: item.tone || ""
  2557. }));
  2558. }
  2559. function normalizeVideoEvent(item) {
  2560. return {
  2561. id: String(item.id),
  2562. time: formatClock(item.occurred_at),
  2563. eventType: item.event_type || "分析事件",
  2564. description: item.description || item.title || "",
  2565. tone: VIDEO_EVENT_TONES[String(item.level || "info")] || "gray",
  2566. status: VIDEO_EVENT_STATUS_LABELS[String(item.status || "")] || "",
  2567. channelName: item.channel_name || "",
  2568. imageUrl: item.snapshot_image_url || ""
  2569. };
  2570. }
  2571. function normalizeVideoSnapshot(item) {
  2572. const available = Boolean(item.media_available);
  2573. return {
  2574. id: String(item.id),
  2575. name: item.channel_name || item.title || "",
  2576. type: item.event_type || "识别抓拍",
  2577. detail: [
  2578. item.plate_no,
  2579. item.person_name,
  2580. item.gap_mm != null ? `缝隙${item.gap_mm}mm` : ""
  2581. ]
  2582. .filter(Boolean)
  2583. .join(" · "),
  2584. time: formatClock(item.captured_at),
  2585. imageUrl: available ? item.image_url : "",
  2586. thumbUrl: available ? item.thumbnail_url || item.image_url : ""
  2587. };
  2588. }
  2589. function videoPersonText(item) {
  2590. const count = Number(item.person_count || 0);
  2591. const name = item.person_name || "";
  2592. if (name && count > 1) return `${name}(${count}人)`;
  2593. if (name) return `${name}(1人)`;
  2594. return count ? `${count}人` : "--";
  2595. }
  2596. function videoStayText(item) {
  2597. const text = item?.properties?.stay_text;
  2598. if (text) return text;
  2599. return item?.stay_seconds ? formatDuration(item.stay_seconds) : "--";
  2600. }
  2601. function videoRecordRow(recordType, item, index) {
  2602. const order = index + 1;
  2603. switch (recordType) {
  2604. case "vehicle_passage":
  2605. return [order, item.device_name, item.plate_no || "--", formatDateTime(item.occurred_at), item.result || "--"];
  2606. case "person_passage":
  2607. return [order, item.device_name, videoPersonText(item), formatDateTime(item.occurred_at), item.result || "--"];
  2608. case "door_unclosed":
  2609. return [order, item.device_name, formatDateTime(item.occurred_at), item.gap_mm != null ? `${item.gap_mm} mm` : "--", item.level || "--"];
  2610. case "person_track":
  2611. return [
  2612. order,
  2613. item.device_name,
  2614. item.area_name || "--",
  2615. item.person_name || "--",
  2616. item.team_name || "--",
  2617. formatDateTime(item.enter_time),
  2618. item.leave_time ? formatDateTime(item.leave_time) : "在场",
  2619. videoStayText(item)
  2620. ];
  2621. case "area_stay":
  2622. return [
  2623. order,
  2624. item.area_name || "--",
  2625. item.person_count ?? 0,
  2626. item.properties?.max_stay || "--",
  2627. item.properties?.min_stay || "--",
  2628. item.properties?.avg_stay || "--",
  2629. item.properties?.total_stay || "--"
  2630. ];
  2631. case "person_warn":
  2632. return [order, item.device_name, item.area_name || "--", item.person_name || "--", item.warn_type || "--", formatDateTime(item.occurred_at), item.handle_status || "--"];
  2633. case "person_detect":
  2634. return [order, item.device_name, item.location || "--", formatDateTime(item.occurred_at), item.result || "--", item.device_status || "--"];
  2635. case "device_operation":
  2636. return [order, item.device_name, item.operation_type || "--", formatDateTime(item.occurred_at), item.compare_result || "--", item.device_status || "--"];
  2637. case "device_warn":
  2638. return [order, item.device_name, item.location || "--", item.warn_type || "--", formatDateTime(item.occurred_at), item.handle_status || "--"];
  2639. default:
  2640. return [order, item.device_name || "--", item.record_type_label || item.record_type || "--", formatDateTime(item.occurred_at)];
  2641. }
  2642. }
  2643. // --- 演示数据视图(后端不可用时与后端结构保持一致) -----------------------
  2644. function buildVideoDemoPlatform() {
  2645. const view = createVideoPlatformState();
  2646. view.source = "demo";
  2647. view.channels = VIDEO_CHANNELS.map((channel) => {
  2648. const viewKey = Object.keys(VIDEO_MODULE_GROUPS).find(
  2649. (key) => VIDEO_MODULE_GROUPS[key] === channel.group
  2650. ) || "door";
  2651. return {
  2652. id: channel.id,
  2653. code: channel.id,
  2654. name: channel.name,
  2655. module: videoModuleKeyFor(viewKey),
  2656. group: channel.group,
  2657. location: channel.location,
  2658. status: channel.status,
  2659. streamUrl: "",
  2660. playbackUrl: "",
  2661. snapshotUrl: "",
  2662. mediaAvailable: false,
  2663. resolution: "",
  2664. isOnline: channel.status === "online"
  2665. };
  2666. });
  2667. Object.keys(VIDEO_MODULES).forEach((viewKey) => {
  2668. const moduleData = VIDEO_MODULES[viewKey];
  2669. const moduleKey = videoModuleKeyFor(viewKey);
  2670. const channels = view.channels.filter((channel) => channel.module === moduleKey);
  2671. const recordTypes = VIDEO_DEMO_RECORD_TYPES[viewKey] || [];
  2672. view.modules[viewKey] = {
  2673. key: moduleKey,
  2674. viewKey,
  2675. title: moduleData.title,
  2676. statusTitle: moduleData.statusTitle,
  2677. typeFilterLabel: moduleData.typeFilterLabel,
  2678. typeFilterOptions: moduleData.typeFilterOptions,
  2679. scopeFilterLabel: moduleData.scopeFilterLabel,
  2680. scopeFilterOptions: moduleData.scopeFilterOptions,
  2681. tabs: moduleData.tabs.map((label, index) => ({
  2682. recordType: recordTypes[index] || `demo-${index}`,
  2683. label,
  2684. columns: moduleData.columns
  2685. }))
  2686. };
  2687. view.status[viewKey] = moduleData.summary.map(([label, value, unit]) => ({
  2688. label,
  2689. value,
  2690. unit,
  2691. tone: ""
  2692. }));
  2693. view.events[viewKey] = moduleData.logs.map(([time, message, tone], index) => ({
  2694. id: `${viewKey}-event-${index}`,
  2695. time,
  2696. eventType: "",
  2697. description: message,
  2698. tone,
  2699. status: "",
  2700. channelName: "",
  2701. imageUrl: ""
  2702. }));
  2703. view.snapshots[viewKey] = {
  2704. items: channels.slice(0, 8).map((channel, index) => ({
  2705. id: `demo-snapshot-${viewKey}-${index}`,
  2706. name: channel.name,
  2707. type: moduleData.snapshotTypes[index % moduleData.snapshotTypes.length],
  2708. detail: channel.location,
  2709. time: `${String(11 + Math.floor(index / 4)).padStart(2, "0")}:${String((index * 7) % 60).padStart(2, "0")}:20`,
  2710. imageUrl: "",
  2711. thumbUrl: ""
  2712. })),
  2713. total: channels.length
  2714. };
  2715. view.tables[viewKey] = {};
  2716. view.modules[viewKey].tabs.forEach((tab) => {
  2717. view.tables[viewKey][tab.recordType] = {
  2718. columns: moduleData.columns,
  2719. rows: moduleData.rows,
  2720. total: moduleData.rows.length,
  2721. loaded: true
  2722. };
  2723. });
  2724. });
  2725. view.overview = {
  2726. channel_total: view.channels.length,
  2727. channel_configured: view.channels.length,
  2728. channel_online: view.channels.filter((item) => item.isOnline).length,
  2729. event_open_count: Object.values(view.events).reduce((sum, list) => sum + list.length, 0)
  2730. };
  2731. view.loaded = true;
  2732. return view;
  2733. }
  2734. // --- 数据加载 -------------------------------------------------------------
  2735. function videoFilterQuery() {
  2736. const view = videoPlatformState();
  2737. const params = new URLSearchParams();
  2738. const start = view.filters.start ? new Date(view.filters.start) : null;
  2739. const end = view.filters.end ? new Date(view.filters.end) : null;
  2740. if (start && !Number.isNaN(start.getTime())) params.set("start", start.toISOString());
  2741. if (end && !Number.isNaN(end.getTime())) params.set("end", end.toISOString());
  2742. return params;
  2743. }
  2744. function applyVideoDefaultRange(options = {}) {
  2745. const view = videoPlatformState();
  2746. if (view.filters.start && view.filters.end && !options.reset) return;
  2747. const now = new Date();
  2748. const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
  2749. if (view.filters.quick === "week") start.setDate(start.getDate() - 6);
  2750. view.filters.start = toLocalInputValue(start);
  2751. view.filters.end = toLocalInputValue(now);
  2752. }
  2753. async function fetchVideoRecords(viewKey, recordType) {
  2754. const view = videoPlatformState();
  2755. const config = view.modules[viewKey];
  2756. const tab = (config?.tabs || []).find((item) => item.recordType === recordType);
  2757. const params = videoFilterQuery();
  2758. params.set("module", videoModuleKeyFor(viewKey));
  2759. params.set("record_type", recordType);
  2760. params.set("page", "1");
  2761. params.set("page_size", "50");
  2762. const page = await request(withMine(`/video-platform/records?${params.toString()}`));
  2763. view.tables[viewKey] = view.tables[viewKey] || {};
  2764. view.tables[viewKey][recordType] = {
  2765. columns: tab?.columns || [],
  2766. rows: (page?.items || []).map((item, index) => videoRecordRow(recordType, item, index)),
  2767. total: Number(page?.total || 0),
  2768. loaded: true
  2769. };
  2770. }
  2771. async function loadVideoPlatformModule(viewKey) {
  2772. const view = videoPlatformState();
  2773. const moduleKey = videoModuleKeyFor(viewKey);
  2774. const config = view.modules[viewKey];
  2775. const params = videoFilterQuery();
  2776. params.set("module", moduleKey);
  2777. params.set("page", "1");
  2778. params.set("page_size", "12");
  2779. const [status, events, snapshots] = await Promise.all([
  2780. request(withMine(`/video-platform/modules/${moduleKey}/summary`)),
  2781. request(withMine(`/video-platform/events?module=${moduleKey}&limit=50`)),
  2782. request(withMine(`/video-platform/snapshots?${params.toString()}`))
  2783. ]);
  2784. view.status[viewKey] = normalizeVideoStatus(status);
  2785. view.events[viewKey] = (events || []).map(normalizeVideoEvent);
  2786. view.eventPage = 1;
  2787. view.snapshots[viewKey] = {
  2788. items: (snapshots?.items || []).map(normalizeVideoSnapshot),
  2789. total: Number(snapshots?.total || 0)
  2790. };
  2791. const firstTab = config?.tabs?.[0]?.recordType;
  2792. if (firstTab) await fetchVideoRecords(viewKey, firstTab);
  2793. }
  2794. async function ensureVideoModuleLoaded(viewKey = state.videoModule) {
  2795. const view = videoPlatformState();
  2796. if (view.source !== "api" || view.status[viewKey]) return;
  2797. view.loadingModule = viewKey;
  2798. renderVideoPlatform();
  2799. try {
  2800. await loadVideoPlatformModule(viewKey);
  2801. } catch (error) {
  2802. state.errors.push(`video-platform:${viewKey}: ${error?.message || error}`);
  2803. window.showToast?.("视频平台数据读取失败,请稍后重试", "warning");
  2804. } finally {
  2805. view.loadingModule = "";
  2806. renderVideoPlatform();
  2807. }
  2808. }
  2809. async function loadVideoPlatform(viewKey = state.videoModule) {
  2810. const view = videoPlatformState();
  2811. renderVideoPlatform();
  2812. try {
  2813. if (!state.apiAvailable || !state.mine?.id) throw new Error("api unavailable");
  2814. const [overview, modules, channels] = await Promise.all([
  2815. request(withMine("/video-platform/overview")),
  2816. request(withMine("/video-platform/modules")),
  2817. request(withMine("/video-platform/channels?limit=200"))
  2818. ]);
  2819. const moduleConfigs = (modules || []).map(normalizeVideoModuleConfig);
  2820. if (!moduleConfigs.length || !(channels || []).length) throw new Error("empty response");
  2821. applyVideoDefaultRange();
  2822. view.overview = {
  2823. channel_total: Number(overview?.channel_total || channels.length),
  2824. channel_configured: Number(overview?.channel_configured || channels.length),
  2825. channel_online: Number(overview?.channel_online || 0),
  2826. event_open_count: Number(overview?.event_open_count || 0)
  2827. };
  2828. view.modules = {};
  2829. moduleConfigs.forEach((config) => {
  2830. view.modules[config.viewKey] = config;
  2831. });
  2832. view.channels = (channels || []).map(normalizeVideoChannel);
  2833. view.mineId = state.mine.id;
  2834. view.source = "api";
  2835. view.loaded = true;
  2836. const activeViewKey = view.modules[viewKey] ? viewKey : Object.keys(view.modules)[0];
  2837. state.videoModule = activeViewKey;
  2838. await loadVideoPlatformModule(activeViewKey);
  2839. state.videoSelectedChannel = videoPlatformChannels(activeViewKey)[0]?.id || state.videoSelectedChannel;
  2840. renderVideoPlatform();
  2841. } catch (error) {
  2842. state.errors.push(`video-platform: ${error?.message || error}`);
  2843. state.videoPlatform = buildVideoDemoPlatform();
  2844. renderVideoPlatform();
  2845. }
  2846. }
  2847. async function ensureVideoRecordTab(index) {
  2848. const view = videoPlatformState();
  2849. const config = view.modules[state.videoModule];
  2850. const tab = config?.tabs?.[index];
  2851. if (!tab || view.source !== "api") return;
  2852. if (view.tables[state.videoModule]?.[tab.recordType]?.loaded) return;
  2853. try {
  2854. await fetchVideoRecords(state.videoModule, tab.recordType);
  2855. } catch (error) {
  2856. state.errors.push(`video-platform:records: ${error?.message || error}`);
  2857. }
  2858. }
  2859. async function searchVideoPlatform(options = {}) {
  2860. const view = videoPlatformState();
  2861. if (options.fromInputs !== false) {
  2862. const startInput = document.querySelector("#videoFilterStart");
  2863. const endInput = document.querySelector("#videoFilterEnd");
  2864. const typeSelect = document.querySelector("#videoRecordType");
  2865. const scopeSelect = document.querySelector("#videoDeviceScope");
  2866. if (startInput?.value) view.filters.start = startInput.value;
  2867. if (endInput?.value) view.filters.end = endInput.value;
  2868. if (typeSelect?.value) view.filters.type = typeSelect.value;
  2869. if (scopeSelect?.value) view.filters.scope = scopeSelect.value;
  2870. }
  2871. if (view.source !== "api") {
  2872. renderVideoPlatform();
  2873. window.showToast?.("后端不可用,已展示本地演示抓拍", "warning");
  2874. return;
  2875. }
  2876. const config = view.modules[state.videoModule];
  2877. const tabs = config?.tabs || [];
  2878. const tab = tabs[Math.min(state.videoRecordTab, Math.max(tabs.length - 1, 0))];
  2879. view.loadingModule = state.videoModule;
  2880. renderVideoPlatform();
  2881. try {
  2882. const params = videoFilterQuery();
  2883. params.set("module", videoModuleKeyFor(state.videoModule));
  2884. if (view.filters.type && view.filters.type !== (config?.typeFilterOptions?.[0] || "全部类型")) {
  2885. params.set("event_type", view.filters.type);
  2886. }
  2887. const scopeChannel = view.channels.find((item) => item.name === view.filters.scope);
  2888. if (scopeChannel) params.set("channel_id", scopeChannel.id);
  2889. params.set("page", "1");
  2890. params.set("page_size", "12");
  2891. const snapshots = await request(withMine(`/video-platform/snapshots?${params.toString()}`));
  2892. view.snapshots[state.videoModule] = {
  2893. items: (snapshots?.items || []).map(normalizeVideoSnapshot),
  2894. total: Number(snapshots?.total || 0)
  2895. };
  2896. if (tab) await fetchVideoRecords(state.videoModule, tab.recordType);
  2897. if (!options.silent) {
  2898. window.showToast?.(`检索完成,命中 ${formatNumber(view.snapshots[state.videoModule].total)} 张抓拍图像`);
  2899. }
  2900. } catch (error) {
  2901. state.errors.push(`video-platform:search: ${error?.message || error}`);
  2902. window.showToast?.("视频检索失败,请稍后重试", "warning");
  2903. } finally {
  2904. view.loadingModule = "";
  2905. renderVideoPlatform();
  2906. }
  2907. }
  2908. // --- 页面渲染 -------------------------------------------------------------
  2909. function videoEmptyState(icon, message) {
  2910. return `<div class="video-empty-state"><span data-icon="${icon}"></span><p>${escapeHtml(message)}</p></div>`;
  2911. }
  2912. function videoLoadingText(viewKey, text, emptyText) {
  2913. return videoPlatformState().loadingModule === viewKey ? text : emptyText;
  2914. }
  2915. function renderVideoSummary() {
  2916. const grid = document.querySelector("#videoSummaryGrid");
  2917. if (!grid) return;
  2918. const view = videoPlatformState();
  2919. const moduleData = view.modules[state.videoModule];
  2920. const configured = view.overview?.channel_configured ?? videoPlatformChannels().length;
  2921. const total = Math.max(videoSourceCount(state.monitoring, configured), configured);
  2922. const online = view.overview?.channel_online
  2923. ?? videoPlatformChannels().filter((item) => item.isOnline).length;
  2924. const eventCount = view.overview?.event_open_count
  2925. ?? (view.events[state.videoModule] || []).length;
  2926. grid.innerHTML = `
  2927. <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>
  2928. <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>
  2929. <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>
  2930. <article class="video-summary-card ai"><span class="video-summary-icon" data-icon="scan-search"></span><div><small>识别事件</small><strong>${formatNumber(eventCount)}</strong><p>当前智能分析事件</p></div></article>
  2931. `;
  2932. window.injectIcons?.(grid);
  2933. const count = document.querySelector("#videoChannelCount");
  2934. if (count) count.textContent = `${formatNumber(configured)} / ${formatNumber(total)} 路`;
  2935. const toolbar = document.querySelector("#videoPlatformToolbarText");
  2936. if (toolbar) {
  2937. toolbar.textContent = `${moduleData?.title || "工业视频平台"}|共接入 ${formatNumber(total)} 路视频,当前已配置 ${formatNumber(configured)} 路,在线 ${formatNumber(online)} 路`;
  2938. }
  2939. const badge = document.querySelector("#view-video-platform .toolbar-actions .subtle-badge");
  2940. if (badge) {
  2941. const isApi = view.source === "api";
  2942. badge.classList.toggle("success", isApi);
  2943. badge.innerHTML = `<span class="live-dot"></span>${isApi ? "视频服务在线" : "演示数据"}`;
  2944. }
  2945. }
  2946. function renderVideoStatusOverview() {
  2947. const container = document.querySelector("#videoStatusOverview");
  2948. if (!container) return;
  2949. const view = videoPlatformState();
  2950. const moduleData = view.modules[state.videoModule];
  2951. const title = document.querySelector("#videoStatusTitle");
  2952. if (title) title.textContent = moduleData?.statusTitle || "状态总览";
  2953. const metrics = view.status[state.videoModule] || [];
  2954. if (!metrics.length) {
  2955. container.innerHTML = videoEmptyState(
  2956. "activity",
  2957. videoLoadingText(state.videoModule, "正在读取状态数据", "暂无状态数据")
  2958. );
  2959. window.injectIcons?.(container);
  2960. return;
  2961. }
  2962. container.innerHTML = metrics
  2963. .map(
  2964. (metric, index) => `
  2965. <div class="video-status-item ${index % 3 === 2 ? "accent" : ""}">
  2966. <strong>${formatNumber(metric.value)}</strong>
  2967. <span>${escapeHtml(metric.label)}</span>
  2968. <small>${escapeHtml(metric.unit || "")}</small>
  2969. </div>
  2970. `
  2971. )
  2972. .join("");
  2973. }
  2974. function filteredVideoChannels() {
  2975. const keyword = state.videoSearch.trim().toLowerCase();
  2976. const channels = videoPlatformChannels();
  2977. if (!keyword) return channels;
  2978. return channels.filter((channel) =>
  2979. `${channel.name} ${channel.location} ${channel.code}`.toLowerCase().includes(keyword)
  2980. );
  2981. }
  2982. function renderVideoDeviceTree() {
  2983. const tree = document.querySelector("#videoDeviceTree");
  2984. if (!tree) return;
  2985. const view = videoPlatformState();
  2986. const channels = filteredVideoChannels();
  2987. const count = document.querySelector("#videoChannelCount");
  2988. const configured = view.overview?.channel_configured ?? videoPlatformChannels().length;
  2989. const total = Math.max(videoSourceCount(state.monitoring, configured), configured);
  2990. if (count) count.textContent = `${formatNumber(channels.length)} / ${formatNumber(total)} 路`;
  2991. if (!channels.length) {
  2992. tree.innerHTML = videoEmptyState(
  2993. "search",
  2994. videoLoadingText(state.videoModule, "正在读取视频通道", "没有匹配的视频通道")
  2995. );
  2996. window.injectIcons?.(tree);
  2997. return;
  2998. }
  2999. tree.innerHTML = `
  3000. <div class="video-tree-channels video-tree-flat">
  3001. ${channels
  3002. .map(
  3003. (channel) => `
  3004. <button class="video-tree-channel ${channel.id === state.videoSelectedChannel ? "active" : ""}" type="button" data-video-channel="${escapeHtml(channel.id)}">
  3005. <i class="${escapeHtml(channel.status)}"></i>
  3006. <span><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(channel.location)}</small></span>
  3007. <em>${videoStatusLabel(channel.status)}</em>
  3008. </button>
  3009. `
  3010. )
  3011. .join("")}
  3012. </div>
  3013. `;
  3014. window.injectIcons?.(tree);
  3015. tree.querySelectorAll("[data-video-channel]").forEach((button) => {
  3016. button.addEventListener("click", () => {
  3017. state.videoSelectedChannel = button.dataset.videoChannel;
  3018. renderVideoPlatform();
  3019. });
  3020. });
  3021. }
  3022. function videoWallChannels() {
  3023. const all = videoPlatformChannels().slice();
  3024. const selectedIndex = all.findIndex((item) => item.id === state.videoSelectedChannel);
  3025. if (selectedIndex > 0) {
  3026. const [selected] = all.splice(selectedIndex, 1);
  3027. all.unshift(selected);
  3028. }
  3029. return all.slice(0, state.videoLayout);
  3030. }
  3031. function renderVideoWall() {
  3032. const wall = document.querySelector("#videoWall");
  3033. const title = document.querySelector("#videoWallTitle");
  3034. if (!wall) return;
  3035. wall.className = `video-wall layout-${state.videoLayout}`;
  3036. const moduleData = videoPlatformState().modules[state.videoModule];
  3037. const layoutNames = { 1: "单画面监控", 2: "双画面监控", 4: "四分屏监控", 6: "六分屏监控", 9: "九分屏监控" };
  3038. if (title) title.textContent = `${moduleData?.title || "工业视频平台"} · ${layoutNames[state.videoLayout] || "实时监控"}`;
  3039. const channels = videoWallChannels();
  3040. if (!channels.length) {
  3041. wall.innerHTML = videoEmptyState(
  3042. "video",
  3043. videoLoadingText(state.videoModule, "正在读取视频通道", "当前模块暂无视频通道")
  3044. );
  3045. window.injectIcons?.(wall);
  3046. return;
  3047. }
  3048. wall.innerHTML = channels
  3049. .map(
  3050. (channel) => `
  3051. <article class="video-tile ${escapeHtml(channel.status)}" data-video-tile="${escapeHtml(channel.id)}">
  3052. <div class="video-tile-stage">
  3053. ${
  3054. channel.playbackUrl
  3055. ? `<video class="video-tile-player" src="${escapeHtml(channel.playbackUrl)}" muted autoplay loop playsinline preload="metadata"></video>`
  3056. : `<div class="video-tile-placeholder">
  3057. <span data-icon="video"></span>
  3058. <strong>${escapeHtml(channel.name)}</strong>
  3059. <small>${channel.mediaAvailable ? "视频文件不可用" : "等待视频流接入"}</small>
  3060. </div>`
  3061. }
  3062. <div class="video-tile-status"><i></i>${videoStatusLabel(channel.status)}</div>
  3063. <div class="video-tile-copy"><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(channel.location)}</small></div>
  3064. <div class="video-tile-tools">
  3065. <button type="button" data-video-action="play" data-channel="${escapeHtml(channel.id)}" aria-label="播放或暂停" title="播放或暂停"><span data-icon="play"></span></button>
  3066. <button type="button" data-video-action="mute" data-channel="${escapeHtml(channel.id)}" aria-label="静音" title="静音"><span data-icon="volume-2"></span></button>
  3067. <button type="button" data-video-action="snapshot" data-channel="${escapeHtml(channel.id)}" aria-label="抓图" title="抓图"><span data-icon="camera"></span></button>
  3068. <button type="button" data-video-action="fullscreen" data-channel="${escapeHtml(channel.id)}" aria-label="全屏" title="全屏"><span data-icon="maximize-2"></span></button>
  3069. </div>
  3070. </div>
  3071. </article>
  3072. `
  3073. )
  3074. .join("");
  3075. window.injectIcons?.(wall);
  3076. wall.querySelectorAll("[data-video-action]").forEach((button) => {
  3077. button.addEventListener("click", (event) => {
  3078. event.stopPropagation();
  3079. handleVideoAction(button.dataset.videoAction, button.dataset.channel);
  3080. });
  3081. });
  3082. }
  3083. function handleVideoAction(action, channelId) {
  3084. const channel = videoPlatformState().channels.find((item) => item.id === channelId);
  3085. if (!channel) return;
  3086. const tile = document.querySelector(`[data-video-tile="${channelId}"]`);
  3087. if (action === "fullscreen") {
  3088. tile?.requestFullscreen?.();
  3089. return;
  3090. }
  3091. const player = tile?.querySelector("video");
  3092. if (action === "play") {
  3093. if (!player) {
  3094. window.showToast?.(`${channel.name} 未配置可播放的视频流`, "warning");
  3095. return;
  3096. }
  3097. if (player.paused) {
  3098. player.play().catch(() => {});
  3099. } else {
  3100. player.pause();
  3101. }
  3102. return;
  3103. }
  3104. if (action === "mute") {
  3105. if (!player) {
  3106. window.showToast?.(`${channel.name} 未配置可播放的视频流`, "warning");
  3107. return;
  3108. }
  3109. player.muted = !player.muted;
  3110. window.showToast?.(`${channel.name} 已${player.muted ? "静音" : "取消静音"}`);
  3111. return;
  3112. }
  3113. if (action === "snapshot") {
  3114. if (!channel.snapshotUrl) {
  3115. window.showToast?.(`${channel.name} 暂无识别抓拍图像`, "warning");
  3116. return;
  3117. }
  3118. window.open(channel.snapshotUrl, "_blank", "noopener");
  3119. }
  3120. }
  3121. function renderVideoEventPagination(total) {
  3122. const footer = document.querySelector("#videoEventPagination");
  3123. if (!footer) return;
  3124. const view = videoPlatformState();
  3125. const pageCount = Math.max(1, Math.ceil(total / view.eventPageSize));
  3126. if (!total) {
  3127. footer.innerHTML = "";
  3128. return;
  3129. }
  3130. const current = Math.min(Math.max(view.eventPage, 1), pageCount);
  3131. view.eventPage = current;
  3132. const pageButtons = Array.from({ length: pageCount }, (_, index) => {
  3133. const page = index + 1;
  3134. return `<button class="${page === current ? "active" : ""}" type="button" data-video-event-page="${page}">${page}</button>`;
  3135. }).join("");
  3136. footer.innerHTML = `
  3137. <span>共 ${formatNumber(total)} 条 · 第 ${current}/${pageCount} 页</span>
  3138. <div>
  3139. <button type="button" data-video-event-page="${current - 1}" ${current <= 1 ? "disabled" : ""}>上一页</button>
  3140. ${pageButtons}
  3141. <button type="button" data-video-event-page="${current + 1}" ${current >= pageCount ? "disabled" : ""}>下一页</button>
  3142. </div>
  3143. `;
  3144. footer.querySelectorAll("[data-video-event-page]").forEach((button) => {
  3145. button.addEventListener("click", () => {
  3146. const page = Number(button.dataset.videoEventPage);
  3147. if (!Number.isFinite(page) || page < 1 || page > pageCount || page === current) return;
  3148. videoPlatformState().eventPage = page;
  3149. renderVideoEvents();
  3150. });
  3151. });
  3152. }
  3153. function renderVideoEvents() {
  3154. const list = document.querySelector("#videoEventList");
  3155. const count = document.querySelector("#videoEventCount");
  3156. if (!list) return;
  3157. const view = videoPlatformState();
  3158. const events = view.events[state.videoModule] || [];
  3159. if (count) count.textContent = `${formatNumber(events.length)} 条`;
  3160. renderVideoEventPagination(events.length);
  3161. if (!events.length) {
  3162. list.innerHTML = videoEmptyState(
  3163. "shield-alert",
  3164. videoLoadingText(state.videoModule, "正在读取分析事件", "暂无视频分析事件")
  3165. );
  3166. window.injectIcons?.(list);
  3167. return;
  3168. }
  3169. const pageCount = Math.max(1, Math.ceil(events.length / view.eventPageSize));
  3170. const current = Math.min(Math.max(view.eventPage, 1), pageCount);
  3171. view.eventPage = current;
  3172. const pageEvents = events.slice(
  3173. (current - 1) * view.eventPageSize,
  3174. current * view.eventPageSize
  3175. );
  3176. list.scrollTop = 0;
  3177. list.innerHTML = pageEvents
  3178. .map(
  3179. (item) => `
  3180. <div class="video-event-row ${escapeHtml(item.tone)}">
  3181. <time>${escapeHtml(item.time)}</time>
  3182. <div>
  3183. <strong>${escapeHtml(item.eventType)}${item.status ? ` · ${escapeHtml(item.status)}` : ""}</strong>
  3184. <p>${escapeHtml(item.channelName ? `${item.channelName}:` : "")}${escapeHtml(item.description)}</p>
  3185. </div>
  3186. </div>
  3187. `
  3188. )
  3189. .join("");
  3190. window.injectIcons?.(list);
  3191. }
  3192. function renderVideoThumbnails() {
  3193. const grid = document.querySelector("#videoThumbnailGrid");
  3194. const count = document.querySelector("#videoThumbnailCount");
  3195. if (!grid) return;
  3196. const view = videoPlatformState();
  3197. const snapshot = view.snapshots[state.videoModule] || { items: [], total: 0 };
  3198. if (count) count.textContent = `${formatNumber(snapshot.total || snapshot.items.length)} 张`;
  3199. if (!snapshot.items.length) {
  3200. grid.innerHTML = videoEmptyState(
  3201. "camera",
  3202. videoLoadingText(state.videoModule, "正在读取识别抓拍", "当前筛选条件下暂无抓拍图像")
  3203. );
  3204. window.injectIcons?.(grid);
  3205. return;
  3206. }
  3207. grid.innerHTML = snapshot.items
  3208. .map(
  3209. (item) => `
  3210. <article class="video-thumb-card">
  3211. <a class="video-thumb-link" ${item.imageUrl ? `href="${escapeHtml(item.imageUrl)}" target="_blank" rel="noopener"` : `href="javascript:void(0)"`} title="${escapeHtml([item.name, item.type, item.detail].filter(Boolean).join("|"))}">
  3212. <div class="video-thumb-stage">
  3213. ${item.thumbUrl ? `<img src="${escapeHtml(item.thumbUrl)}" alt="${escapeHtml(item.type)}" loading="lazy" />` : `<span data-icon="video"></span>`}
  3214. <time>${escapeHtml(item.time)}</time>
  3215. </div>
  3216. <div class="video-thumb-copy">
  3217. <strong>${escapeHtml(item.name)}</strong>
  3218. <small>${escapeHtml(item.type)}${item.detail ? `|${escapeHtml(item.detail)}` : ""}</small>
  3219. </div>
  3220. </a>
  3221. </article>
  3222. `
  3223. )
  3224. .join("");
  3225. window.injectIcons?.(grid);
  3226. }
  3227. function renderVideoFilterOptions() {
  3228. const view = videoPlatformState();
  3229. const config = view.modules[state.videoModule];
  3230. const typeLabel = document.querySelector("#videoRecordFilterLabel");
  3231. const typeSelect = document.querySelector("#videoRecordType");
  3232. const scopeLabel = document.querySelector("#videoScopeFilterLabel");
  3233. const scopeSelect = document.querySelector("#videoDeviceScope");
  3234. if (typeLabel) typeLabel.textContent = config?.typeFilterLabel || "类型";
  3235. if (typeSelect) {
  3236. const options = config?.typeFilterOptions?.length ? config.typeFilterOptions : ["全部类型"];
  3237. if (!options.includes(view.filters.type)) view.filters.type = options[0];
  3238. typeSelect.innerHTML = options
  3239. .map((option) => `<option>${escapeHtml(option)}</option>`)
  3240. .join("");
  3241. typeSelect.value = view.filters.type;
  3242. }
  3243. if (scopeLabel) scopeLabel.textContent = config?.scopeFilterLabel || "设备";
  3244. if (scopeSelect) {
  3245. const options = config?.scopeFilterOptions?.length ? config.scopeFilterOptions : ["全部设备"];
  3246. if (!options.includes(view.filters.scope)) view.filters.scope = options[0];
  3247. scopeSelect.innerHTML = options
  3248. .map((option) => `<option>${escapeHtml(option)}</option>`)
  3249. .join("");
  3250. scopeSelect.value = view.filters.scope;
  3251. }
  3252. const startInput = document.querySelector("#videoFilterStart");
  3253. const endInput = document.querySelector("#videoFilterEnd");
  3254. if (startInput && view.filters.start) startInput.value = view.filters.start;
  3255. if (endInput && view.filters.end) endInput.value = view.filters.end;
  3256. document.querySelectorAll("[data-video-quick-range]").forEach((button) => {
  3257. button.classList.toggle(
  3258. "active",
  3259. button.dataset.videoQuickRange === view.filters.quick
  3260. );
  3261. });
  3262. }
  3263. function renderVideoRecords() {
  3264. const tabs = document.querySelector("#videoRecordTabs");
  3265. const table = document.querySelector("#videoRecordsTable");
  3266. const title = document.querySelector("#videoRecordTitle");
  3267. if (!tabs || !table) return;
  3268. const view = videoPlatformState();
  3269. const config = view.modules[state.videoModule];
  3270. const moduleTabs = config?.tabs || [];
  3271. if (!moduleTabs.length) {
  3272. tabs.innerHTML = "";
  3273. table.innerHTML = videoEmptyState(
  3274. "list",
  3275. videoLoadingText(state.videoModule, "正在读取记录定义", "暂无记录定义")
  3276. );
  3277. window.injectIcons?.(table);
  3278. return;
  3279. }
  3280. const activeIndex = Math.min(state.videoRecordTab, moduleTabs.length - 1);
  3281. state.videoRecordTab = activeIndex;
  3282. const tab = moduleTabs[activeIndex];
  3283. if (title) title.textContent = tab.label;
  3284. tabs.innerHTML = moduleTabs
  3285. .map(
  3286. (item, index) => `<button class="${index === activeIndex ? "active" : ""}" type="button" data-video-record-tab="${index}">${escapeHtml(item.label)}</button>`
  3287. )
  3288. .join("");
  3289. tabs.querySelectorAll("[data-video-record-tab]").forEach((button) => {
  3290. button.addEventListener("click", async () => {
  3291. state.videoRecordTab = Number(button.dataset.videoRecordTab);
  3292. renderVideoRecords();
  3293. await ensureVideoRecordTab(state.videoRecordTab);
  3294. renderVideoRecords();
  3295. });
  3296. });
  3297. const data = view.tables[state.videoModule]?.[tab.recordType];
  3298. const columns = data?.columns?.length ? data.columns : tab.columns;
  3299. if (!data?.rows?.length) {
  3300. table.innerHTML = videoEmptyState(
  3301. "list",
  3302. videoLoadingText(state.videoModule, "正在读取记录", "当前条件下暂无记录")
  3303. );
  3304. window.injectIcons?.(table);
  3305. return;
  3306. }
  3307. table.innerHTML = `
  3308. <table>
  3309. <thead><tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr></thead>
  3310. <tbody>
  3311. ${data.rows
  3312. .map(
  3313. (row) => `<tr>${row.map((value) => `<td>${escapeHtml(value)}</td>`).join("")}</tr>`
  3314. )
  3315. .join("")}
  3316. </tbody>
  3317. </table>
  3318. `;
  3319. }
  3320. function syncVideoPanelHeights() {
  3321. const devicePanel = document.querySelector(".video-device-panel");
  3322. const wallPanel = document.querySelector(".video-wall-panel");
  3323. if (!devicePanel || !wallPanel) return;
  3324. if (window.innerWidth <= 1180) {
  3325. devicePanel.style.height = "";
  3326. wallPanel.style.height = "";
  3327. return;
  3328. }
  3329. devicePanel.style.height = "auto";
  3330. wallPanel.style.height = "auto";
  3331. const targetHeight = Math.ceil(wallPanel.getBoundingClientRect().height);
  3332. if (!targetHeight) return;
  3333. devicePanel.style.height = `${targetHeight}px`;
  3334. wallPanel.style.height = `${targetHeight}px`;
  3335. }
  3336. function renderVideoPlatform() {
  3337. renderVideoSummary();
  3338. renderVideoFilterOptions();
  3339. renderVideoStatusOverview();
  3340. renderVideoDeviceTree();
  3341. renderVideoWall();
  3342. renderVideoEvents();
  3343. renderVideoThumbnails();
  3344. renderVideoRecords();
  3345. document.querySelectorAll("[data-video-module]").forEach((button) => {
  3346. button.classList.toggle("active", button.dataset.videoModule === state.videoModule);
  3347. });
  3348. window.requestAnimationFrame(syncVideoPanelHeights);
  3349. }
  3350. function installVideoPlatformActions() {
  3351. document.querySelector("[data-refresh-video-platform]")?.addEventListener("click", async () => {
  3352. await loadVideoPlatform(state.videoModule);
  3353. const isApi = videoPlatformState().source === "api";
  3354. window.showToast?.(
  3355. isApi ? "视频通道与识别数据已刷新" : "后端不可用,已使用本地演示数据",
  3356. isApi ? undefined : "warning"
  3357. );
  3358. });
  3359. document.querySelectorAll("[data-video-module]").forEach((button) => {
  3360. button.addEventListener("click", async () => {
  3361. state.videoModule = button.dataset.videoModule || "door";
  3362. state.videoRecordTab = 0;
  3363. videoPlatformState().eventPage = 1;
  3364. state.videoSelectedChannel = videoPlatformChannels()[0]?.id || state.videoSelectedChannel;
  3365. renderVideoPlatform();
  3366. await ensureVideoModuleLoaded(state.videoModule);
  3367. state.videoSelectedChannel = videoPlatformChannels()[0]?.id || state.videoSelectedChannel;
  3368. renderVideoPlatform();
  3369. });
  3370. });
  3371. document.querySelectorAll("[data-video-layout]").forEach((button) => {
  3372. button.addEventListener("click", () => {
  3373. state.videoLayout = Number(button.dataset.videoLayout || 4);
  3374. document.querySelectorAll("[data-video-layout]").forEach((item) => {
  3375. item.classList.toggle("active", item === button);
  3376. });
  3377. renderVideoWall();
  3378. window.requestAnimationFrame(syncVideoPanelHeights);
  3379. });
  3380. });
  3381. const search = document.querySelector("#videoChannelSearch");
  3382. search?.addEventListener("input", () => {
  3383. state.videoSearch = search.value.trim();
  3384. renderVideoDeviceTree();
  3385. });
  3386. const startInput = document.querySelector("#videoFilterStart");
  3387. const endInput = document.querySelector("#videoFilterEnd");
  3388. [startInput, endInput].forEach((input) => {
  3389. input?.addEventListener("change", () => {
  3390. const view = videoPlatformState();
  3391. if (startInput?.value) view.filters.start = startInput.value;
  3392. if (endInput?.value) view.filters.end = endInput.value;
  3393. view.filters.quick = "";
  3394. renderVideoFilterOptions();
  3395. });
  3396. });
  3397. document.querySelectorAll("[data-video-quick-range]").forEach((button) => {
  3398. button.addEventListener("click", async () => {
  3399. const view = videoPlatformState();
  3400. view.filters.quick = button.dataset.videoQuickRange === "week" ? "week" : "today";
  3401. applyVideoDefaultRange({ reset: true });
  3402. await searchVideoPlatform({ silent: true });
  3403. window.showToast?.(
  3404. view.filters.quick === "week" ? "已切换为近 7 天范围" : "已切换为今日范围"
  3405. );
  3406. });
  3407. });
  3408. document.querySelector("[data-video-search]")?.addEventListener("click", () => {
  3409. searchVideoPlatform();
  3410. });
  3411. document.querySelector("[data-video-reset]")?.addEventListener("click", async () => {
  3412. const view = videoPlatformState();
  3413. state.videoSearch = "";
  3414. if (search) search.value = "";
  3415. state.videoRecordTab = 0;
  3416. view.eventPage = 1;
  3417. view.filters = { quick: "today", start: "", end: "", type: "", scope: "" };
  3418. applyVideoDefaultRange({ reset: true });
  3419. await searchVideoPlatform({ silent: true, fromInputs: false });
  3420. window.showToast?.("筛选条件已重置");
  3421. });
  3422. window.addEventListener("resize", () => {
  3423. window.requestAnimationFrame(syncVideoPanelHeights);
  3424. });
  3425. }
  3426. async function openVideoPlatform() {
  3427. window.navigateTo?.("video-platform");
  3428. renderVideoPlatform();
  3429. const view = videoPlatformState();
  3430. if (!view.loaded) {
  3431. await loadVideoPlatform(state.videoModule);
  3432. return;
  3433. }
  3434. await ensureVideoModuleLoaded(state.videoModule);
  3435. renderVideoPlatform();
  3436. }
  3437. function installDocumentExport() {
  3438. const button = document.querySelector("[data-export-documents]");
  3439. if (!button || button.dataset.exportBound) return;
  3440. button.dataset.exportBound = "true";
  3441. button.addEventListener("click", async () => {
  3442. if (!state.apiAvailable) {
  3443. window.showToast?.("后端不可用,暂时无法导出资料清单", "warning");
  3444. return;
  3445. }
  3446. const original = button.innerHTML;
  3447. button.disabled = true;
  3448. button.innerHTML = '<span data-icon="refresh-cw"></span>正在导出';
  3449. window.injectIcons?.(button);
  3450. try {
  3451. const params = new URLSearchParams();
  3452. params.set("mine_id", state.mine.id);
  3453. if (state.documentCategory && state.documentCategory !== "all") {
  3454. params.set("category", state.documentCategory);
  3455. }
  3456. const keyword = document
  3457. .querySelector("#businessDocumentSearch")
  3458. ?.value.trim();
  3459. if (keyword) params.set("keyword", keyword);
  3460. const response = await fetch(
  3461. `${API_BASE}/documents/export?${params.toString()}`
  3462. );
  3463. if (!response.ok) {
  3464. let message = `HTTP ${response.status}`;
  3465. try {
  3466. const payload = await response.json();
  3467. message = payload?.message || payload?.detail || message;
  3468. } catch {
  3469. // Keep the HTTP status when the response is not JSON.
  3470. }
  3471. throw new Error(message);
  3472. }
  3473. const blob = await response.blob();
  3474. const disposition = response.headers.get("Content-Disposition") || "";
  3475. const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
  3476. const filename = encodedName
  3477. ? decodeURIComponent(encodedName)
  3478. : `业务资料清单_${new Date().toISOString().slice(0, 10)}.xlsx`;
  3479. const url = URL.createObjectURL(blob);
  3480. const link = document.createElement("a");
  3481. link.href = url;
  3482. link.download = filename;
  3483. document.body.appendChild(link);
  3484. link.click();
  3485. link.remove();
  3486. window.setTimeout(() => URL.revokeObjectURL(url), 1000);
  3487. window.showToast?.("资料清单已导出");
  3488. } catch (error) {
  3489. window.showToast?.(`导出失败:${error?.message || error}`, "warning");
  3490. } finally {
  3491. button.disabled = false;
  3492. button.innerHTML = original;
  3493. window.injectIcons?.(button);
  3494. }
  3495. });
  3496. }
  3497. async function hydrateSafetyReadings(monitoring) {
  3498. const sensors = monitoring?.sensors || [];
  3499. if (!state.apiAvailable || !sensors.length) {
  3500. state.safetyLoaded = true;
  3501. return;
  3502. }
  3503. const entries = await Promise.all(
  3504. sensors.map(async (sensor) => {
  3505. try {
  3506. const readings = await request(`/monitoring/sensors/${encodeURIComponent(sensor.id)}/readings?limit=48`, {
  3507. timeoutMs: 3200
  3508. });
  3509. return [
  3510. String(sensor.id),
  3511. (readings || []).slice().sort((left, right) => new Date(left.measured_at) - new Date(right.measured_at))
  3512. ];
  3513. } catch {
  3514. return [String(sensor.id), []];
  3515. }
  3516. })
  3517. );
  3518. state.safetyReadings = new Map(entries);
  3519. state.safetyLoaded = true;
  3520. if (state.monitoring) renderSafetyMonitoring(state.monitoring);
  3521. }
  3522. function openSafetyMonitoring() {
  3523. window.navigateTo?.("safety-monitoring");
  3524. if (!state.safetyLoaded) hydrateSafetyReadings(state.monitoring).catch(() => {});
  3525. }
  3526. function renderKnowledgeGraph(graph) {
  3527. const canvas = document.querySelector(".graph-canvas");
  3528. const svg = canvas?.querySelector("svg");
  3529. if (!svg || !graph?.nodes?.length) return;
  3530. const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
  3531. const links = (graph.edges || [])
  3532. .filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target))
  3533. .map((edge) => {
  3534. const source = nodeMap.get(edge.source);
  3535. const target = nodeMap.get(edge.target);
  3536. return `<path d="M${source.x} ${source.y} L${target.x} ${target.y}" />`;
  3537. })
  3538. .join("");
  3539. const nodes = graph.nodes
  3540. .map((node) => {
  3541. const center = node.node_type === "risk";
  3542. const radius = center ? 48 : 34;
  3543. const className = center
  3544. ? "graph-center"
  3545. : node.node_type === "evidence"
  3546. ? "graph-node amber-node"
  3547. : "graph-node";
  3548. const value = Number(node.value || 0);
  3549. return `
  3550. <g class="${className}" transform="translate(${Number(node.x) || 0} ${Number(node.y) || 0})">
  3551. <title>${escapeHtml(`${node.label} ${node.sub_label || ""} · ${value} 条关联知识`)}</title>
  3552. <circle r="${radius}" />
  3553. <text y="-4">${escapeHtml(node.label || "")}</text>
  3554. <text y="15">${escapeHtml(node.sub_label || "")}</text>
  3555. </g>
  3556. `;
  3557. })
  3558. .join("");
  3559. svg.innerHTML = `
  3560. <defs>
  3561. <marker id="graphArrow" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
  3562. <path d="M0,0 L8,4 L0,8 z" fill="#93a4b8" />
  3563. </marker>
  3564. </defs>
  3565. <g class="graph-links">${links}</g>
  3566. ${nodes}
  3567. `;
  3568. }
  3569. function renderKnowledge(payload) {
  3570. const items = Array.isArray(payload)
  3571. ? payload
  3572. : payload?.catalog?.items || payload?.items || [];
  3573. const overview = Array.isArray(payload) ? null : payload?.overview || null;
  3574. const graph = Array.isArray(payload) ? null : payload?.graph || null;
  3575. const count = Number(overview?.total_notes || items.length || DEMO_KNOWLEDGE.length);
  3576. const heroStats = document.querySelector(".knowledge-hero-stats");
  3577. if (heroStats) {
  3578. const stats = heroStats.querySelectorAll("div");
  3579. if (stats[0]) {
  3580. stats[0].querySelector("strong").textContent = formatNumber(count);
  3581. stats[0].querySelector("span").textContent = "知识条目";
  3582. }
  3583. if (stats[1]) {
  3584. const semantic = Boolean(overview?.service?.semantic_enabled);
  3585. stats[1].querySelector("strong").textContent = semantic ? "混合" : "关键词";
  3586. stats[1].querySelector("span").textContent = "检索模式";
  3587. }
  3588. if (stats[2]) {
  3589. const responseMs = Number(overview?.average_response_ms || 0);
  3590. stats[2].querySelector("strong").textContent = responseMs
  3591. ? `${(responseMs / 1000).toFixed(1)}s`
  3592. : "--";
  3593. stats[2].querySelector("span").textContent = "平均响应";
  3594. }
  3595. }
  3596. const navBadge = document.querySelector('[data-view="knowledge"] .nav-badge');
  3597. if (navBadge) navBadge.textContent = formatNumber(count);
  3598. const dashboardCount = document.querySelector(".knowledge-count");
  3599. if (dashboardCount) dashboardCount.textContent = `${formatNumber(count)} 条知识`;
  3600. const heroCopy = document.querySelector(".knowledge-hero-copy p");
  3601. if (heroCopy) {
  3602. const chunkCount = Number(overview?.total_chunks || 0);
  3603. heroCopy.textContent = chunkCount
  3604. ? `已接入 ${formatNumber(count)} 篇知识、${formatNumber(chunkCount)} 个检索切片,结合关键词检索与来源追溯回答通防业务问题。`
  3605. : `已接入 ${formatNumber(count)} 篇知识,结合混合检索与来源追溯回答通防业务问题。`;
  3606. }
  3607. const categoryMap = new Map(
  3608. (overview?.category_counts || []).map((item) => [item.name, Number(item.count || 0)])
  3609. );
  3610. if (!categoryMap.size) {
  3611. items.forEach((item) => {
  3612. const key = item.category_name || item.category || item.knowledge_type || "其他";
  3613. categoryMap.set(key, (categoryMap.get(key) || 0) + 1);
  3614. });
  3615. }
  3616. document.querySelectorAll(".knowledge-category-grid .category-card").forEach((card) => {
  3617. const name = card.querySelector("strong")?.textContent?.trim();
  3618. const badge = card.querySelector("b");
  3619. if (badge) badge.textContent = formatNumber(categoryMap.get(name) ?? 0);
  3620. });
  3621. renderKnowledgeGraph(graph);
  3622. }
  3623. const KNOWLEDGE_CATEGORY_META = {
  3624. standards: {
  3625. name: "规范标准",
  3626. description: "规程、标准、办法及技术要求"
  3627. },
  3628. cases: {
  3629. name: "事故案例",
  3630. description: "典型事故与隐患处置案例"
  3631. },
  3632. expert: {
  3633. name: "专家经验",
  3634. description: "专家规则、判识经验与处置建议"
  3635. },
  3636. management: {
  3637. name: "管理流程",
  3638. description: "业务流程、责任岗位与闭环规则"
  3639. }
  3640. };
  3641. function closeKnowledgeCategory() {
  3642. const modal = document.querySelector("#knowledgeCatalogModal");
  3643. if (modal) modal.hidden = true;
  3644. document.body.classList.remove("knowledge-catalog-open");
  3645. }
  3646. async function loadKnowledgeCatalog(page = state.knowledgeCategoryPage) {
  3647. const category = state.knowledgeCategory;
  3648. const modal = document.querySelector("#knowledgeCatalogModal");
  3649. const content = document.querySelector("#knowledgeCatalogContent");
  3650. const pager = document.querySelector("#knowledgeCatalogPager");
  3651. if (!category || !modal || !content || !pager) return;
  3652. state.knowledgeCategoryPage = page;
  3653. content.innerHTML = '<div class="knowledge-catalog-empty">正在加载知识列表...</div>';
  3654. pager.innerHTML = "";
  3655. const params = new URLSearchParams({
  3656. category,
  3657. page: String(page),
  3658. page_size: String(state.knowledgeCategoryPageSize)
  3659. });
  3660. if (state.knowledgeCategoryKeyword) {
  3661. params.set("keyword", state.knowledgeCategoryKeyword);
  3662. }
  3663. try {
  3664. const data = await request(`/knowledge/catalog?${params.toString()}`, {
  3665. timeoutMs: 20000
  3666. });
  3667. state.knowledgeCategoryTotal = Number(data?.total || 0);
  3668. const title = document.querySelector("#knowledgeCatalogTitle");
  3669. const subtitle = document.querySelector("#knowledgeCatalogSubtitle");
  3670. if (title) title.textContent = KNOWLEDGE_CATEGORY_META[category]?.name || "知识列表";
  3671. if (subtitle) {
  3672. subtitle.textContent = `${KNOWLEDGE_CATEGORY_META[category]?.description || ""} · 共 ${formatNumber(state.knowledgeCategoryTotal)} 条`;
  3673. }
  3674. renderKnowledgeCatalog(data?.items || []);
  3675. renderKnowledgeCatalogPager();
  3676. } catch (error) {
  3677. content.innerHTML = `<div class="knowledge-catalog-empty">知识列表加载失败:${escapeHtml(error?.message || error)}</div>`;
  3678. window.showToast?.("知识列表加载失败", "warning");
  3679. }
  3680. }
  3681. function renderKnowledgeCatalog(items) {
  3682. const content = document.querySelector("#knowledgeCatalogContent");
  3683. if (!content) return;
  3684. if (!items.length) {
  3685. content.innerHTML = '<div class="knowledge-catalog-empty">当前分类下没有匹配的知识。</div>';
  3686. return;
  3687. }
  3688. content.innerHTML = `
  3689. <div class="knowledge-catalog-list">
  3690. ${items.map((item) => `
  3691. <button class="knowledge-catalog-item" type="button" data-knowledge-note="${escapeHtml(item.note_id)}">
  3692. <span class="knowledge-catalog-item-icon" data-icon="file-text"></span>
  3693. <span class="knowledge-catalog-item-copy">
  3694. <strong>${escapeHtml(item.title)}</strong>
  3695. <p>${escapeHtml([item.category_name, ...(item.tags || []).slice(0, 3)].filter(Boolean).join(" · "))}</p>
  3696. </span>
  3697. <span class="knowledge-catalog-item-meta">${escapeHtml(item.updated || "")}</span>
  3698. </button>
  3699. `).join("")}
  3700. </div>
  3701. `;
  3702. if (typeof window.injectIcons === "function") window.injectIcons(content);
  3703. }
  3704. function renderKnowledgeCatalogPager() {
  3705. const pager = document.querySelector("#knowledgeCatalogPager");
  3706. if (!pager) return;
  3707. const pages = Math.max(
  3708. 1,
  3709. Math.ceil(state.knowledgeCategoryTotal / state.knowledgeCategoryPageSize)
  3710. );
  3711. const current = state.knowledgeCategoryPage;
  3712. const pageButtons = [];
  3713. const start = Math.max(1, current - 2);
  3714. const end = Math.min(pages, current + 2);
  3715. for (let page = start; page <= end; page += 1) {
  3716. pageButtons.push(
  3717. `<button type="button" class="${page === current ? "active" : ""}" data-knowledge-page="${page}">${page}</button>`
  3718. );
  3719. }
  3720. pager.innerHTML = `
  3721. <span>第 ${current} / ${pages} 页</span>
  3722. <button type="button" data-knowledge-page="${current - 1}" ${current <= 1 ? "disabled" : ""}>上一页</button>
  3723. ${pageButtons.join("")}
  3724. <button type="button" data-knowledge-page="${current + 1}" ${current >= pages ? "disabled" : ""}>下一页</button>
  3725. `;
  3726. }
  3727. async function openKnowledgeDetail(noteId) {
  3728. const content = document.querySelector("#knowledgeCatalogContent");
  3729. const pager = document.querySelector("#knowledgeCatalogPager");
  3730. if (!content) return;
  3731. content.innerHTML = '<div class="knowledge-catalog-empty">正在加载知识正文...</div>';
  3732. if (pager) pager.innerHTML = "";
  3733. try {
  3734. const detail = await request(
  3735. `/knowledge/catalog/${encodeURIComponent(noteId)}`,
  3736. { timeoutMs: 20000 }
  3737. );
  3738. content.innerHTML = `
  3739. <div class="knowledge-catalog-detail-head">
  3740. <button class="secondary-button" type="button" data-knowledge-back>返回列表</button>
  3741. <div><h3>${escapeHtml(detail.title)}</h3></div>
  3742. </div>
  3743. <div class="knowledge-catalog-detail-tags">
  3744. ${[detail.category_name, ...(detail.tags || [])].filter(Boolean).map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")}
  3745. </div>
  3746. <div class="knowledge-catalog-detail-body">${renderMarkdown(detail.full_content || detail.summary || "")}</div>
  3747. `;
  3748. content.scrollTop = 0;
  3749. if (typeof window.injectIcons === "function") window.injectIcons(content);
  3750. } catch (error) {
  3751. content.innerHTML = `<div class="knowledge-catalog-empty">知识正文加载失败:${escapeHtml(error?.message || error)}</div>`;
  3752. }
  3753. }
  3754. async function openKnowledgeCategory(category) {
  3755. const meta = KNOWLEDGE_CATEGORY_META[category];
  3756. const modal = document.querySelector("#knowledgeCatalogModal");
  3757. if (!meta || !modal) return;
  3758. state.knowledgeCategory = category;
  3759. state.knowledgeCategoryPage = 1;
  3760. state.knowledgeCategoryKeyword = "";
  3761. const form = document.querySelector("#knowledgeCatalogSearch");
  3762. if (form) form.reset();
  3763. modal.hidden = false;
  3764. document.body.classList.add("knowledge-catalog-open");
  3765. await loadKnowledgeCatalog(1);
  3766. }
  3767. function installKnowledgeCategoryBrowser() {
  3768. if (window.__ventKnowledgeCategoryBrowserInstalled) return;
  3769. window.__ventKnowledgeCategoryBrowserInstalled = true;
  3770. document.querySelectorAll("[data-knowledge-category]").forEach((card) => {
  3771. const open = () => openKnowledgeCategory(card.dataset.knowledgeCategory);
  3772. card.addEventListener("click", open);
  3773. card.addEventListener("keydown", (event) => {
  3774. if (event.key === "Enter" || event.key === " ") {
  3775. event.preventDefault();
  3776. open();
  3777. }
  3778. });
  3779. });
  3780. document.querySelectorAll("[data-knowledge-catalog-close]").forEach((element) => {
  3781. element.addEventListener("click", closeKnowledgeCategory);
  3782. });
  3783. document.querySelector("#knowledgeCatalogSearch")?.addEventListener("submit", (event) => {
  3784. event.preventDefault();
  3785. const keyword = new FormData(event.currentTarget).get("keyword")?.toString().trim() || "";
  3786. state.knowledgeCategoryKeyword = keyword;
  3787. loadKnowledgeCatalog(1);
  3788. });
  3789. document.querySelector("#knowledgeCatalogContent")?.addEventListener("click", (event) => {
  3790. const back = event.target.closest("[data-knowledge-back]");
  3791. if (back) {
  3792. loadKnowledgeCatalog();
  3793. return;
  3794. }
  3795. const item = event.target.closest("[data-knowledge-note]");
  3796. if (item) openKnowledgeDetail(item.dataset.knowledgeNote);
  3797. });
  3798. document.querySelector("#knowledgeCatalogPager")?.addEventListener("click", (event) => {
  3799. const button = event.target.closest("[data-knowledge-page]");
  3800. if (!button || button.disabled) return;
  3801. loadKnowledgeCatalog(Number(button.dataset.knowledgePage));
  3802. });
  3803. document.addEventListener("keydown", (event) => {
  3804. if (event.key === "Escape" && !document.querySelector("#knowledgeCatalogModal")?.hidden) {
  3805. closeKnowledgeCategory();
  3806. }
  3807. });
  3808. }
  3809. function renderKnowledgeError(container, error, compact = false) {
  3810. const notLoaded =
  3811. Number(error?.status) === 404 ||
  3812. String(error?.message || "").includes("404");
  3813. if (notLoaded) {
  3814. container.innerHTML = compact
  3815. ? `
  3816. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>接口未加载</span></div>
  3817. <p>知识检索接口未在当前后端进程中加载,请重启后端服务后重试。</p>
  3818. `
  3819. : `
  3820. <div class="panel-head">
  3821. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3822. <span class="subtle-badge">接口未加载</span>
  3823. </div>
  3824. <p>知识检索接口未在当前后端进程中加载,请重启后端服务后重试。</p>
  3825. `;
  3826. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3827. window.showToast?.("知识接口未加载,请重启后端服务", "warning");
  3828. return;
  3829. }
  3830. container.innerHTML = compact
  3831. ? `
  3832. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>检索失败</span></div>
  3833. <p>知识库检索失败,请稍后重试。</p>
  3834. `
  3835. : `
  3836. <div class="panel-head">
  3837. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3838. <span class="subtle-badge">检索失败</span>
  3839. </div>
  3840. <p>知识库检索失败,请稍后重试。</p>
  3841. `;
  3842. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3843. window.showToast?.("知识库检索失败,请稍后重试", "warning");
  3844. }
  3845. function sourceView(source) {
  3846. const score = Number(source.score || 0);
  3847. return {
  3848. citationIndex: Number(source.citation_index || 0),
  3849. title: source.title,
  3850. scoreText: score > 0 ? `${Math.round(score * 100)}%` : "相关"
  3851. };
  3852. }
  3853. function renderMarkdown(value) {
  3854. const codeBlocks = [];
  3855. const source = String(value || "")
  3856. .replace(/\r\n/g, "\n")
  3857. .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, language, code) => {
  3858. codeBlocks.push({ language, code });
  3859. return `\n@@CODEBLOCK${codeBlocks.length - 1}@@\n`;
  3860. });
  3861. const lines = escapeHtml(source).split("\n");
  3862. const output = [];
  3863. let listType = null;
  3864. const closeList = () => {
  3865. if (listType) output.push(`</${listType}>`);
  3866. listType = null;
  3867. };
  3868. const inline = (text) =>
  3869. text
  3870. .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
  3871. .replace(/\*(.+?)\*/g, "<em>$1</em>")
  3872. .replace(/`([^`]+)`/g, "<code>$1</code>")
  3873. .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
  3874. for (const rawLine of lines) {
  3875. const line = rawLine.trimEnd();
  3876. const codeMatch = line.trim().match(/^@@CODEBLOCK(\d+)@@$/);
  3877. if (codeMatch) {
  3878. closeList();
  3879. const block = codeBlocks[Number(codeMatch[1])];
  3880. if (block) {
  3881. output.push(
  3882. `<pre><code${block.language ? ` class="language-${escapeHtml(block.language)}"` : ""}>${escapeHtml(block.code.trim())}</code></pre>`
  3883. );
  3884. }
  3885. continue;
  3886. }
  3887. const heading = line.match(/^\s*(#{1,6})\s+(.+)$/);
  3888. if (heading) {
  3889. closeList();
  3890. const level = heading[1].length;
  3891. output.push(`<h${level}>${inline(heading[2].trim())}</h${level}>`);
  3892. continue;
  3893. }
  3894. const bullet = line.match(/^\s*[-*]\s+(.+)$/);
  3895. const ordered = line.match(/^\s*\d+[.、)]\s+(.+)$/);
  3896. if (bullet || ordered) {
  3897. const nextType = bullet ? "ul" : "ol";
  3898. if (listType !== nextType) {
  3899. closeList();
  3900. listType = nextType;
  3901. output.push(`<${listType}>`);
  3902. }
  3903. output.push(`<li>${inline((bullet || ordered)[1].trim())}</li>`);
  3904. continue;
  3905. }
  3906. closeList();
  3907. if (line.trim()) output.push(`<p>${inline(line.trim())}</p>`);
  3908. }
  3909. closeList();
  3910. return output.join("");
  3911. }
  3912. function createKnowledgeStreamView(container, question) {
  3913. let answer = "";
  3914. let thinking = "";
  3915. let answerEl = null;
  3916. let statusEl = null;
  3917. let thinkingPanel = null;
  3918. let thinkingEl = null;
  3919. let thinkingState = null;
  3920. let renderFrame = 0;
  3921. let thinkingFrame = 0;
  3922. const renderMeta = (data = {}) => {
  3923. const sources = (data.sources || []).map(sourceView);
  3924. container.innerHTML = `
  3925. <div class="panel-head">
  3926. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3927. <span class="subtle-badge" data-rag-status>RAG 生成中</span>
  3928. </div>
  3929. <div class="ai-answer">
  3930. <div class="ai-avatar"><span data-icon="bot"></span></div>
  3931. <div class="ai-answer-body">
  3932. <p><strong>问题:</strong>${escapeHtml(question)}</p>
  3933. <details class="rag-thinking" data-rag-thinking hidden>
  3934. <summary><span data-icon="brain"></span><span>思考过程</span><span data-rag-thinking-state>思考中</span></summary>
  3935. <div class="rag-thinking-content" data-rag-thinking-content></div>
  3936. </details>
  3937. <div class="rag-answer-markdown" data-rag-answer></div>
  3938. <div class="source-citations" data-rag-sources hidden>
  3939. ${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("")}
  3940. </div>
  3941. </div>
  3942. </div>
  3943. `;
  3944. answerEl = container.querySelector("[data-rag-answer]");
  3945. statusEl = container.querySelector("[data-rag-status]");
  3946. thinkingPanel = container.querySelector("[data-rag-thinking]");
  3947. thinkingEl = container.querySelector("[data-rag-thinking-content]");
  3948. thinkingState = container.querySelector("[data-rag-thinking-state]");
  3949. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3950. };
  3951. const scrollToBottom = () => {
  3952. const scroller = container.querySelector(".ai-answer");
  3953. if (scroller) scroller.scrollTop = scroller.scrollHeight;
  3954. };
  3955. const renderAnswer = () => {
  3956. renderFrame = 0;
  3957. if (!answerEl) return;
  3958. answerEl.innerHTML = renderMarkdown(answer);
  3959. scrollToBottom();
  3960. };
  3961. const scheduleAnswer = () => {
  3962. if (renderFrame) return;
  3963. renderFrame = window.requestAnimationFrame(renderAnswer);
  3964. };
  3965. const renderThinking = (finished = false) => {
  3966. thinkingFrame = 0;
  3967. if (!thinkingPanel || !thinkingEl) return;
  3968. thinkingPanel.hidden = !thinking;
  3969. thinkingEl.textContent = thinking;
  3970. if (thinkingState) thinkingState.textContent = finished ? "已完成" : "思考中";
  3971. scrollToBottom();
  3972. };
  3973. const scheduleThinking = () => {
  3974. if (thinkingFrame) return;
  3975. thinkingFrame = window.requestAnimationFrame(() => renderThinking(false));
  3976. };
  3977. return {
  3978. get answer() {
  3979. return answer;
  3980. },
  3981. meta: renderMeta,
  3982. thinking(content) {
  3983. if (!answerEl) renderMeta();
  3984. thinking += String(content || "");
  3985. scheduleThinking();
  3986. },
  3987. delta(content) {
  3988. if (!answerEl) renderMeta();
  3989. answer += String(content || "");
  3990. scheduleAnswer();
  3991. },
  3992. done(data = {}) {
  3993. answer = String(data.answer || answer);
  3994. if (data.thinking) thinking = String(data.thinking);
  3995. if (renderFrame) window.cancelAnimationFrame(renderFrame);
  3996. if (thinkingFrame) window.cancelAnimationFrame(thinkingFrame);
  3997. renderAnswer();
  3998. renderThinking(true);
  3999. container.querySelector("[data-rag-sources]")?.removeAttribute("hidden");
  4000. if (statusEl) {
  4001. const status = data.answer_status || "generated";
  4002. const generated = status === "generated";
  4003. const conversational = status === "conversational";
  4004. statusEl.className = `subtle-badge ${generated || conversational ? "success" : ""}`;
  4005. statusEl.textContent = generated
  4006. ? "RAG 大模型生成"
  4007. : conversational
  4008. ? "智能助手"
  4009. : "检索摘要";
  4010. }
  4011. }
  4012. };
  4013. }
  4014. async function streamKnowledgeAnswerFetch(container, question) {
  4015. const controller = new AbortController();
  4016. const timeoutId = window.setTimeout(() => controller.abort(), 120000);
  4017. try {
  4018. const response = await fetch(`${API_BASE}/knowledge/rag/stream`, {
  4019. method: "POST",
  4020. headers: {
  4021. Accept: "text/event-stream",
  4022. "Content-Type": "application/json"
  4023. },
  4024. body: JSON.stringify({
  4025. question,
  4026. top_k: 5,
  4027. generate_answer: true
  4028. }),
  4029. signal: controller.signal
  4030. });
  4031. if (!response.ok) {
  4032. let message = `HTTP ${response.status}`;
  4033. let payload = null;
  4034. try {
  4035. payload = await response.json();
  4036. message = payload?.message || payload?.detail || message;
  4037. } catch {
  4038. // Keep the HTTP status.
  4039. }
  4040. const error = new Error(message);
  4041. error.status = response.status;
  4042. throw error;
  4043. }
  4044. if (!response.body) throw new Error("当前浏览器不支持流式响应");
  4045. const reader = response.body.getReader();
  4046. const decoder = new TextDecoder("utf-8");
  4047. let buffer = "";
  4048. const view = createKnowledgeStreamView(container, question);
  4049. const handleEvent = (eventName, data) => {
  4050. if (eventName === "meta") {
  4051. view.meta(data);
  4052. } else if (eventName === "thinking") {
  4053. view.thinking(data.content || "");
  4054. } else if (eventName === "delta") {
  4055. view.delta(data.content || "");
  4056. } else if (eventName === "warning") {
  4057. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  4058. } else if (eventName === "done") {
  4059. view.done(data);
  4060. }
  4061. };
  4062. const processBlock = (block) => {
  4063. if (!block.trim()) return;
  4064. let eventName = "message";
  4065. const dataLines = [];
  4066. for (const line of block.split(/\r?\n/)) {
  4067. if (line.startsWith("event:")) eventName = line.slice(6).trim();
  4068. if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
  4069. }
  4070. if (!dataLines.length) return;
  4071. handleEvent(eventName, JSON.parse(dataLines.join("\n")));
  4072. };
  4073. while (true) {
  4074. const { done, value } = await reader.read();
  4075. buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
  4076. const blocks = buffer.split(/\r?\n\r?\n/);
  4077. buffer = blocks.pop() || "";
  4078. blocks.forEach(processBlock);
  4079. if (done) break;
  4080. }
  4081. if (buffer.trim()) processBlock(buffer);
  4082. } finally {
  4083. window.clearTimeout(timeoutId);
  4084. }
  4085. }
  4086. function streamKnowledgeAnswerEventSource(container, question) {
  4087. return new Promise((resolve, reject) => {
  4088. const url =
  4089. `${API_BASE}/knowledge/rag/stream?question=${encodeURIComponent(question)}` +
  4090. "&top_k=5";
  4091. const source = new EventSource(url);
  4092. const view = createKnowledgeStreamView(container, question);
  4093. let received = false;
  4094. let settled = false;
  4095. const timeoutId = window.setTimeout(() => {
  4096. if (settled) return;
  4097. source.close();
  4098. settled = true;
  4099. reject(new Error("流式响应超时"));
  4100. }, 120000);
  4101. const finish = () => {
  4102. if (settled) return;
  4103. settled = true;
  4104. window.clearTimeout(timeoutId);
  4105. source.close();
  4106. resolve();
  4107. };
  4108. source.addEventListener("meta", (event) => {
  4109. received = true;
  4110. view.meta(JSON.parse(event.data));
  4111. });
  4112. source.addEventListener("thinking", (event) => {
  4113. received = true;
  4114. const data = JSON.parse(event.data);
  4115. view.thinking(data.content || "");
  4116. });
  4117. source.addEventListener("delta", (event) => {
  4118. received = true;
  4119. const data = JSON.parse(event.data);
  4120. view.delta(data.content || "");
  4121. });
  4122. source.addEventListener("warning", (event) => {
  4123. const data = JSON.parse(event.data);
  4124. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  4125. });
  4126. source.addEventListener("done", (event) => {
  4127. const data = JSON.parse(event.data);
  4128. view.done(data);
  4129. finish();
  4130. });
  4131. source.onerror = () => {
  4132. if (settled) return;
  4133. window.clearTimeout(timeoutId);
  4134. source.close();
  4135. if (received && view.answer) {
  4136. settled = true;
  4137. resolve();
  4138. return;
  4139. }
  4140. settled = true;
  4141. reject(new Error("SSE 连接失败"));
  4142. };
  4143. });
  4144. }
  4145. function streamKnowledgeAnswerWebSocket(container, question) {
  4146. return new Promise((resolve, reject) => {
  4147. const protocol = window.location.protocol === "https:" ? "wss" : "ws";
  4148. const socket = new WebSocket(
  4149. `${protocol}://${window.location.host}${API_BASE}/knowledge/rag/ws`
  4150. );
  4151. const view = createKnowledgeStreamView(container, question);
  4152. let received = false;
  4153. let settled = false;
  4154. const finish = () => {
  4155. if (settled) return;
  4156. settled = true;
  4157. socket.close();
  4158. resolve();
  4159. };
  4160. const fail = (error) => {
  4161. if (settled) return;
  4162. settled = true;
  4163. error.streamStarted = received;
  4164. socket.close();
  4165. reject(error);
  4166. };
  4167. socket.onopen = () => {
  4168. socket.send(
  4169. JSON.stringify({
  4170. question,
  4171. top_k: 5,
  4172. generate_answer: true
  4173. })
  4174. );
  4175. };
  4176. socket.onmessage = (event) => {
  4177. const item = JSON.parse(event.data);
  4178. const data = item.data || {};
  4179. if (item.event === "meta") {
  4180. received = true;
  4181. view.meta(data);
  4182. } else if (item.event === "thinking") {
  4183. received = true;
  4184. view.thinking(data.content || "");
  4185. } else if (item.event === "delta") {
  4186. received = true;
  4187. view.delta(data.content || "");
  4188. } else if (item.event === "warning") {
  4189. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  4190. } else if (item.event === "done") {
  4191. view.done(data);
  4192. finish();
  4193. } else if (item.event === "error") {
  4194. fail(new Error(data.message || "流式问答失败"));
  4195. }
  4196. };
  4197. socket.onerror = () => fail(new Error("WebSocket 连接失败"));
  4198. socket.onclose = () => {
  4199. if (!settled && !received) fail(new Error("WebSocket 连接已关闭"));
  4200. else if (!settled) finish();
  4201. };
  4202. });
  4203. }
  4204. function streamKnowledgeAnswer(container, question) {
  4205. if (typeof WebSocket !== "undefined") {
  4206. return streamKnowledgeAnswerWebSocket(container, question).catch((error) => {
  4207. if (error?.streamStarted) throw error;
  4208. return streamKnowledgeAnswerEventSource(container, question);
  4209. });
  4210. }
  4211. if (typeof EventSource !== "undefined") {
  4212. return streamKnowledgeAnswerEventSource(container, question);
  4213. }
  4214. return streamKnowledgeAnswerFetch(container, question);
  4215. }
  4216. function installKnowledgeRenderer() {
  4217. if (window.__ventApiKnowledgeRendererInstalled) return;
  4218. window.__ventApiKnowledgeRendererInstalled = true;
  4219. const originalRenderAnswer = window.renderAnswer;
  4220. if (typeof originalRenderAnswer !== "function") return;
  4221. window.renderAnswer = function renderAnswerWithApi(container, question, compact = false) {
  4222. if (!state.apiAvailable) {
  4223. originalRenderAnswer(container, question, compact);
  4224. return;
  4225. }
  4226. if (compact) {
  4227. container.innerHTML = `
  4228. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>正在检索知识库</span></div>
  4229. <p>正在检索相关知识,请稍候...</p>
  4230. `;
  4231. } else {
  4232. container.innerHTML = `
  4233. <div class="panel-head">
  4234. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  4235. <span class="subtle-badge">检索中</span>
  4236. </div>
  4237. <div class="ai-answer">
  4238. <div class="ai-avatar"><span data-icon="bot"></span></div>
  4239. <div class="ai-answer-body">
  4240. <p><strong>问题:</strong>${escapeHtml(question)}</p>
  4241. <p>正在检索知识库并整理依据,请稍候...</p>
  4242. </div>
  4243. </div>
  4244. `;
  4245. }
  4246. if (typeof window.injectIcons === "function") window.injectIcons(container);
  4247. if (!compact) {
  4248. streamKnowledgeAnswer(container, question).catch((error) => {
  4249. renderKnowledgeError(container, error, false);
  4250. });
  4251. return;
  4252. }
  4253. request("/knowledge/ask", {
  4254. method: "POST",
  4255. body: JSON.stringify({
  4256. question,
  4257. top_k: 3,
  4258. generate_answer: false
  4259. }),
  4260. timeoutMs: 30000
  4261. })
  4262. .then((result) => {
  4263. if (!result?.answer) return;
  4264. const answerText = result.answer;
  4265. const answerHtml = escapeHtml(answerText).replace(/\r?\n/g, "<br>");
  4266. const sources = (result.sources || []).map((source) => ({
  4267. citationIndex: Number(source.citation_index || 0),
  4268. title: source.title,
  4269. score: Number(source.score || 0),
  4270. scoreText:
  4271. Number(source.score || 0) > 0
  4272. ? `${Math.round(Number(source.score || 0) * 100)}%`
  4273. : "相关"
  4274. }));
  4275. const modeLabel = result.search_mode === "hybrid" ? "混合检索" : "关键词检索";
  4276. container.innerHTML = `
  4277. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>${modeLabel} · 命中 ${result.total_candidates || sources.length} 条</span></div>
  4278. <p>${answerHtml}</p>
  4279. <div class="answer-sources">${sources.map((source) => `<span>${escapeHtml(source.title)}</span>`).join("")}</div>
  4280. `;
  4281. if (typeof window.injectIcons === "function") window.injectIcons(container);
  4282. })
  4283. .catch((error) => renderKnowledgeError(container, error, true));
  4284. };
  4285. }
  4286. function severityClass(severity) {
  4287. return severity === "high" ? "high" : severity === "low" ? "low" : "medium";
  4288. }
  4289. function sceneMeta(scene) {
  4290. const keyMap = {
  4291. "局部通风": ["local", "orange"],
  4292. "通风设施": ["facility", "yellow"],
  4293. "通风系统管理": ["system", "teal"],
  4294. "风量分配": ["volume", "blue"],
  4295. "灾害防治": ["disaster", "red"]
  4296. };
  4297. return keyMap[scene] || ["system", "teal"];
  4298. }
  4299. function statusMeta(status) {
  4300. if (["completed", "closed", "verified", "已记录"].includes(status)) return ["closed", "已记录"];
  4301. if (["processing", "处理中"].includes(status)) return ["processing", "处理中"];
  4302. return ["pending", "待处理"];
  4303. }
  4304. function renderDiagnosis(defects) {
  4305. const list = defects?.length ? defects : DEMO_DIAGNOSIS;
  4306. const normalized = list.map((item, index) => {
  4307. const [sceneKey] = sceneMeta(item.scene);
  4308. return {
  4309. ...item,
  4310. id: item.id || `risk-api-${index}`,
  4311. scene_key: sceneKey,
  4312. defect_code: item.defect_code || item.code || `RISK-API-${index + 1}`,
  4313. evidence: item.evidence || (
  4314. sceneKey === "disaster"
  4315. ? ["radio-tower", "video", "file-text"]
  4316. : sceneKey === "volume"
  4317. ? ["gauge", "file-text"]
  4318. : ["radio-tower", "activity", "file-text"]
  4319. )
  4320. };
  4321. });
  4322. window.setHazardRisks?.(normalized);
  4323. }
  4324. function renderEvaluation(evaluation) {
  4325. const run = evaluation.run || DEMO_EVALUATION.run;
  4326. const dimensions = evaluation.dimensions?.length ? evaluation.dimensions : DEMO_EVALUATION.dimensions;
  4327. const score = Number(run.total_score || 0);
  4328. const grade = String(run.grade || "A").replace("级", "");
  4329. const summary = run.summary || "综合评价已完成,运行状态稳定。";
  4330. setText(".evaluation-grade strong", grade);
  4331. setText(".evaluation-grade em", score >= 90 ? "运行状态优良" : score >= 80 ? "运行状态良好" : "需要持续改进");
  4332. setText(".evaluation-summary-copy .eyebrow", `${formatDate(run.created_at || new Date())} · 自动评价`);
  4333. setText(".evaluation-summary-copy h2", `${state.mine?.name || "上湾煤矿"}通防业务综合评价报告`);
  4334. setText(".evaluation-summary-copy p", summary);
  4335. setText(".radar-panel .subtle-badge", `综合得分 ${formatNumber(score)}`);
  4336. const gradeBox = document.querySelector(".evaluation-grade");
  4337. if (gradeBox) gradeBox.dataset.score = String(score);
  4338. const dimensionsList = document.querySelector(".dimension-list");
  4339. const iconMap = ["shield-check", "activity", "circle-dollar-sign", "clipboard-check", "database"];
  4340. const colorMap = ["green", "blue", "amber", "purple", "teal"];
  4341. if (dimensionsList) {
  4342. dimensionsList.innerHTML = dimensions
  4343. .slice(0, 5)
  4344. .map((item, index) => {
  4345. const change = Number(item.change || 0);
  4346. return `
  4347. <div class="dimension-row">
  4348. <span class="dimension-icon ${colorMap[index % colorMap.length]}" data-icon="${iconMap[index % iconMap.length]}"></span>
  4349. <div><strong>${escapeHtml(item.name)}</strong><p>${escapeHtml(item.note || "业务综合指标")}</p></div>
  4350. <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>
  4351. </div>
  4352. `;
  4353. })
  4354. .join("");
  4355. if (typeof window.injectIcons === "function") window.injectIcons(dimensionsList);
  4356. }
  4357. document.querySelectorAll(".radar-labels text").forEach((label, index) => {
  4358. const item = dimensions[index];
  4359. if (item) label.textContent = `${item.name} ${Number(item.score || 0).toFixed(1)}`;
  4360. });
  4361. const agentText = document.querySelector(".evaluation-agent-panel .agent-copy p");
  4362. if (agentText) {
  4363. agentText.textContent = `“${summary}”`;
  4364. }
  4365. const suggestions = document.querySelector(".evaluation-agent-panel .agent-suggestions");
  4366. if (suggestions && run.recommendations?.length) {
  4367. suggestions.innerHTML = run.recommendations
  4368. .slice(0, 3)
  4369. .map((item, index) => `<span><i>${index + 1}</i>${escapeHtml(item)}</span>`)
  4370. .join("");
  4371. }
  4372. }
  4373. function renderWorkOrders(orders) {
  4374. const items = orders?.length ? orders : DEMO_WORK_ORDERS;
  4375. const groups = [
  4376. { statuses: ["pending"], title: "待确认", dot: "pending" },
  4377. { statuses: ["processing"], title: "处理中", dot: "processing" },
  4378. { statuses: ["review"], title: "待复核", dot: "review" },
  4379. { statuses: ["completed", "verified", "closed"], title: "已完成", dot: "closed" }
  4380. ];
  4381. const columns = document.querySelectorAll("#view-closed-loop .kanban-column");
  4382. groups.forEach((group, index) => {
  4383. const column = columns[index];
  4384. if (!column) return;
  4385. const groupItems = items.filter((item) => group.statuses.includes(item.status));
  4386. const head = column.querySelector(".kanban-head span");
  4387. if (head) head.innerHTML = `<i class="column-dot ${group.dot}"></i>${group.title} <b>${groupItems.length}</b>`;
  4388. const cards = groupItems.map((item) => {
  4389. const priority = item.priority || "medium";
  4390. const priorityLabel = priority === "high" ? "高优先级" : priority === "low" ? "常规" : "中优先级";
  4391. return `
  4392. <button class="work-card priority-${priority}" type="button" data-order-id="${escapeHtml(item.id)}">
  4393. <div class="work-card-head"><span class="work-tag ${priority}">${priorityLabel}</span><span>${formatTime(item.created_at)}</span></div>
  4394. <strong>${escapeHtml(item.title)}</strong>
  4395. <p>${escapeHtml(item.content || "待补充处置说明。")}</p>
  4396. <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>
  4397. </button>
  4398. `;
  4399. });
  4400. column.innerHTML = head ? `${head.outerHTML}${cards.join("")}` : cards.join("");
  4401. column.querySelectorAll(".work-card").forEach((card) => {
  4402. card.addEventListener("click", () => window.showToast?.(`已打开任务:${card.querySelector("strong")?.textContent || "整改任务"}`));
  4403. });
  4404. });
  4405. const toolbar = document.querySelector("#view-closed-loop .toolbar-copy p");
  4406. const closed = items.filter((item) => ["completed", "verified", "closed"].includes(item.status)).length;
  4407. if (toolbar) {
  4408. const rate = items.length ? (closed / items.length * 100).toFixed(1) : "0.0";
  4409. toolbar.textContent = `当前展示 ${items.length} 项整改任务,已完成 ${closed} 项,闭环率 ${rate}%`;
  4410. }
  4411. }
  4412. async function loadMine() {
  4413. try {
  4414. const mines = await request("/mines");
  4415. const mine = mines.find((item) => item.code === DEFAULT_MINE_CODE) || mines.find((item) => item.is_default) || mines[0];
  4416. if (!mine) throw new Error("no mine returned");
  4417. state.apiAvailable = true;
  4418. state.mine = mine;
  4419. return { data: mine, source: "api" };
  4420. } catch (error) {
  4421. state.errors.push(`mines: ${error?.message || error}`);
  4422. state.apiAvailable = false;
  4423. state.mine = DEMO_MINE;
  4424. return { data: DEMO_MINE, source: "demo" };
  4425. }
  4426. }
  4427. async function loadDocuments() {
  4428. const categoryQuery = state.documentCategory && state.documentCategory !== "all"
  4429. ? `&category=${encodeURIComponent(state.documentCategory)}`
  4430. : "";
  4431. try {
  4432. const keyword = state.documentKeyword.trim();
  4433. const documentRequest = keyword
  4434. ? request(
  4435. withMine(
  4436. `/documents/search?q=${encodeURIComponent(keyword)}&page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}`
  4437. )
  4438. )
  4439. : request(
  4440. withMine(
  4441. `/documents?page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}`
  4442. )
  4443. );
  4444. const [pageResult, categories] = await Promise.all([
  4445. documentRequest,
  4446. request(withMine("/documents/categories"))
  4447. ]);
  4448. state.documentSearchSnippets = new Map();
  4449. const items = keyword
  4450. ? (pageResult.items || []).map((hit) => {
  4451. state.documentSearchSnippets.set(
  4452. String(hit.document?.id),
  4453. hit.snippets || []
  4454. );
  4455. return hit.document;
  4456. })
  4457. : pageResult?.items || [];
  4458. return {
  4459. data: {
  4460. page: {
  4461. items,
  4462. total: Number(pageResult?.total || 0),
  4463. page: Number(pageResult?.page || state.documentPage),
  4464. page_size: Number(pageResult?.page_size || state.documentPageSize)
  4465. },
  4466. categories: categories || []
  4467. },
  4468. source: "api"
  4469. };
  4470. } catch (error) {
  4471. state.errors.push(`documents: ${error?.message || error}`);
  4472. return { data: EMPTY_DOCUMENTS, source: "demo" };
  4473. }
  4474. }
  4475. async function loadDocumentStatistics() {
  4476. return withFallback(
  4477. "document-statistics",
  4478. () => request(withMine("/documents/statistics")),
  4479. DEMO_DOCUMENT_STATISTICS
  4480. );
  4481. }
  4482. async function loadBusinessTemplates() {
  4483. return withFallback(
  4484. "document-templates",
  4485. async () => {
  4486. const templates = await request("/documents/templates");
  4487. if (!templates?.length) throw new Error("empty response");
  4488. return templates;
  4489. },
  4490. []
  4491. );
  4492. }
  4493. async function loadMonitoring() {
  4494. return withFallback(
  4495. "monitoring",
  4496. async () => {
  4497. const [overview, sources, sensors, facilities, alarms] = await Promise.all([
  4498. request(withMine("/monitoring/overview")),
  4499. request(withMine("/monitoring/sources")),
  4500. request(withMine("/monitoring/sensors")),
  4501. request(withMine("/monitoring/facilities")),
  4502. request(withMine("/monitoring/alarms?limit=100"))
  4503. ]);
  4504. return { overview, sources, sensors, facilities, alarms };
  4505. },
  4506. DEMO_MONITORING
  4507. );
  4508. }
  4509. async function loadKnowledge() {
  4510. return withFallback(
  4511. "knowledge",
  4512. async () => {
  4513. const [overview, catalog, graph] = await Promise.all([
  4514. request("/knowledge/overview", { timeoutMs: 15000 }),
  4515. request("/knowledge/catalog?page=1&page_size=200", { timeoutMs: 15000 }),
  4516. request("/knowledge/graph", { timeoutMs: 15000 })
  4517. ]);
  4518. if (!overview?.total_notes && !catalog?.items?.length) {
  4519. throw new Error("empty response");
  4520. }
  4521. return { overview, catalog, graph };
  4522. },
  4523. { overview: null, catalog: { items: DEMO_KNOWLEDGE }, graph: null }
  4524. );
  4525. }
  4526. async function loadDiagnosis() {
  4527. return withFallback(
  4528. "diagnosis",
  4529. async () => {
  4530. const tasks = await request(withMine("/diagnosis/tasks?limit=20"));
  4531. if (!tasks?.length) throw new Error("empty response");
  4532. const details = await Promise.all(
  4533. tasks.slice(0, 5).map((task) => request(`/diagnosis/tasks/${encodeURIComponent(task.id)}`))
  4534. );
  4535. const defects = details.flatMap((task) =>
  4536. (task.results || []).map((result) => ({
  4537. id: result.id,
  4538. defect_name: result.defect_name,
  4539. defect_code: result.defect_code,
  4540. scene: result.scene,
  4541. severity: result.severity,
  4542. confidence: Number(result.confidence || 0) * (Number(result.confidence || 0) <= 1 ? 100 : 1),
  4543. status: result.status,
  4544. conclusion: result.conclusion,
  4545. recommendation: result.recommendation,
  4546. occurred_at: formatTime(result.created_at || task.created_at)
  4547. }))
  4548. );
  4549. if (!defects.length) throw new Error("empty response");
  4550. return defects;
  4551. },
  4552. DEMO_DIAGNOSIS
  4553. );
  4554. }
  4555. async function loadEvaluation() {
  4556. return withFallback(
  4557. "evaluations",
  4558. async () => {
  4559. const [indicators, runs] = await Promise.all([
  4560. request(withMine("/evaluations/indicators")),
  4561. request(withMine("/evaluations/runs?limit=1"))
  4562. ]);
  4563. if (!runs?.length) throw new Error("empty response");
  4564. const run = await request(`/evaluations/runs/${encodeURIComponent(runs[0].id)}`);
  4565. const indicatorMap = new Map((indicators || []).map((item) => [String(item.id), item]));
  4566. const dimensions = (run.scores || [])
  4567. .map((score) => {
  4568. const indicator = indicatorMap.get(String(score.indicator_id));
  4569. return {
  4570. name: indicator?.dimension || indicator?.name,
  4571. score: Number(score.score || 0),
  4572. change: 0,
  4573. note: indicator?.name || "评价指标得分"
  4574. };
  4575. })
  4576. .filter((item) => item.name);
  4577. return { run, dimensions: dimensions.length ? dimensions : DEMO_EVALUATION.dimensions };
  4578. },
  4579. DEMO_EVALUATION
  4580. );
  4581. }
  4582. async function loadWorkOrders() {
  4583. return withFallback(
  4584. "work-orders",
  4585. async () => {
  4586. const page = await request(withMine("/work-orders?page=1&page_size=200"));
  4587. if (!page?.items?.length) throw new Error("empty response");
  4588. return page.items;
  4589. },
  4590. DEMO_WORK_ORDERS
  4591. );
  4592. }
  4593. async function runHazardAgent(agent) {
  4594. const sceneName = {
  4595. local: "局部通风",
  4596. facility: "通风设施",
  4597. system: "通风系统管理",
  4598. volume: "风量分配",
  4599. disaster: "灾害防治"
  4600. }[agent.sceneKey] || "all";
  4601. let source = "demo";
  4602. if (state.apiAvailable && state.mine?.id) {
  4603. try {
  4604. await request("/diagnosis/run", {
  4605. method: "POST",
  4606. body: JSON.stringify({
  4607. mine_id: state.mine.id,
  4608. scene: sceneName,
  4609. requested_by: "隐患识别中心"
  4610. })
  4611. });
  4612. source = "api";
  4613. } catch (error) {
  4614. state.errors.push(`hazard-agent:${agent.id}: ${error?.message || error}`);
  4615. }
  4616. }
  4617. const localFindings = window.getHazardAgentFindings?.(agent) || [];
  4618. await new Promise((resolve) => window.setTimeout(resolve, 650));
  4619. return { findings: localFindings, source };
  4620. }
  4621. function installActionInterceptors() {
  4622. const diagnosisButton = document.querySelector("[data-run-diagnosis]");
  4623. if (diagnosisButton && !diagnosisButton.dataset.apiBound) {
  4624. diagnosisButton.dataset.apiBound = "true";
  4625. diagnosisButton.addEventListener(
  4626. "click",
  4627. async (event) => {
  4628. event.stopImmediatePropagation();
  4629. if (!state.apiAvailable) {
  4630. window.showToast?.("后端不可用,已使用本地演示诊断结果", "warning");
  4631. return;
  4632. }
  4633. const original = diagnosisButton.innerHTML;
  4634. diagnosisButton.disabled = true;
  4635. diagnosisButton.innerHTML = '<span data-icon="refresh-cw"></span>诊断进行中';
  4636. window.injectIcons?.(diagnosisButton);
  4637. try {
  4638. await request("/diagnosis/run", {
  4639. method: "POST",
  4640. body: JSON.stringify({
  4641. mine_id: state.mine.id,
  4642. scene: "all",
  4643. requested_by: "前端用户"
  4644. })
  4645. });
  4646. const result = await loadDiagnosis();
  4647. renderDiagnosis(result.data);
  4648. window.showToast?.(`全量诊断完成,识别到 ${result.data.length} 项缺陷与风险`);
  4649. } catch {
  4650. renderDiagnosis(DEMO_DIAGNOSIS);
  4651. window.showToast?.("诊断接口不可用,已回退到本地演示数据", "warning");
  4652. } finally {
  4653. diagnosisButton.disabled = false;
  4654. diagnosisButton.innerHTML = original;
  4655. window.injectIcons?.(diagnosisButton);
  4656. }
  4657. },
  4658. true
  4659. );
  4660. }
  4661. const evaluationButton = document.querySelector("[data-start-evaluation]");
  4662. if (evaluationButton && !evaluationButton.dataset.apiBound) {
  4663. evaluationButton.dataset.apiBound = "true";
  4664. evaluationButton.addEventListener(
  4665. "click",
  4666. async (event) => {
  4667. event.stopImmediatePropagation();
  4668. if (!state.apiAvailable) {
  4669. renderEvaluation(DEMO_EVALUATION);
  4670. window.showToast?.("后端不可用,已使用本地演示评价结果", "warning");
  4671. return;
  4672. }
  4673. const original = evaluationButton.innerHTML;
  4674. evaluationButton.disabled = true;
  4675. evaluationButton.innerHTML = '<span data-icon="refresh-cw"></span>评价计算中';
  4676. window.injectIcons?.(evaluationButton);
  4677. try {
  4678. await request("/evaluations/run", {
  4679. method: "POST",
  4680. body: JSON.stringify({
  4681. mine_id: state.mine.id,
  4682. requested_by: "前端用户"
  4683. })
  4684. });
  4685. const result = await loadEvaluation();
  4686. renderEvaluation(result.data);
  4687. window.showToast?.(`综合评价已更新,综合得分 ${formatNumber(result.data.run.total_score)},等级 ${result.data.run.grade}`);
  4688. } catch {
  4689. renderEvaluation(DEMO_EVALUATION);
  4690. window.showToast?.("评价接口不可用,已回退到本地演示数据", "warning");
  4691. } finally {
  4692. evaluationButton.disabled = false;
  4693. evaluationButton.innerHTML = original;
  4694. window.injectIcons?.(evaluationButton);
  4695. }
  4696. },
  4697. true
  4698. );
  4699. }
  4700. }
  4701. async function init() {
  4702. if (state.initialized) return;
  4703. state.initialized = true;
  4704. updateConnectionBadge("loading");
  4705. try {
  4706. await loadMine();
  4707. const [dashboardResult, monitoringResult, documentsResult, documentStatisticsResult, templatesResult, knowledgeResult, diagnosisResult, evaluationResult, workOrdersResult] =
  4708. await Promise.all([
  4709. state.apiAvailable
  4710. ? apiLoader("dashboard", "/dashboard/overview", DEMO_DASHBOARD)
  4711. : Promise.resolve({ data: DEMO_DASHBOARD, source: "demo" }),
  4712. state.apiAvailable ? loadMonitoring() : Promise.resolve({ data: DEMO_MONITORING, source: "demo" }),
  4713. state.apiAvailable ? loadDocuments() : Promise.resolve({ data: EMPTY_DOCUMENTS, source: "demo" }),
  4714. state.apiAvailable ? loadDocumentStatistics() : Promise.resolve({ data: DEMO_DOCUMENT_STATISTICS, source: "demo" }),
  4715. state.apiAvailable ? loadBusinessTemplates() : Promise.resolve({ data: [], source: "demo" }),
  4716. state.apiAvailable ? loadKnowledge() : Promise.resolve({ data: DEMO_KNOWLEDGE, source: "demo" }),
  4717. state.apiAvailable ? loadDiagnosis() : Promise.resolve({ data: DEMO_DIAGNOSIS, source: "demo" }),
  4718. state.apiAvailable ? loadEvaluation() : Promise.resolve({ data: DEMO_EVALUATION, source: "demo" }),
  4719. state.apiAvailable ? loadWorkOrders() : Promise.resolve({ data: DEMO_WORK_ORDERS, source: "demo" })
  4720. ]);
  4721. const documents = documentsResult.data.page?.items || [];
  4722. state.templates = templatesResult.data || [];
  4723. renderDashboard(dashboardResult.data, monitoringResult.data, evaluationResult.data);
  4724. renderMonitoring(
  4725. monitoringResult.data,
  4726. documents,
  4727. documentStatisticsResult.data
  4728. );
  4729. refreshDocumentProcessingTasks().catch(() => {});
  4730. renderFacilityStatus(monitoringResult.data);
  4731. renderSafetyMonitoring(monitoringResult.data);
  4732. applyVideoDefaultRange({ reset: true });
  4733. if (state.apiAvailable) {
  4734. loadVideoPlatform(state.videoModule);
  4735. } else {
  4736. state.videoPlatform = buildVideoDemoPlatform();
  4737. renderVideoPlatform();
  4738. }
  4739. renderDocumentRows(
  4740. documents,
  4741. documentsResult.data.categories,
  4742. documentsResult.data.page?.total
  4743. );
  4744. renderDocumentStatistics(documentStatisticsResult.data);
  4745. renderKnowledge(knowledgeResult.data);
  4746. renderDiagnosis(diagnosisResult.data);
  4747. renderEvaluation(evaluationResult.data);
  4748. renderWorkOrders(workOrdersResult.data);
  4749. installKnowledgeRenderer();
  4750. installKnowledgeCategoryBrowser();
  4751. installActionInterceptors();
  4752. installBusinessUpload();
  4753. installBusinessEdit();
  4754. installFacilityActions();
  4755. installSafetyMonitoringActions();
  4756. installVideoPlatformActions();
  4757. installDocumentExport();
  4758. hydrateSafetyReadings(monitoringResult.data).catch(() => {});
  4759. if (state.apiAvailable && documentsResult.source === "api") {
  4760. hydrateDocumentDetails(documents).catch(() => {});
  4761. }
  4762. if (!state.apiAvailable) {
  4763. updateConnectionBadge("demo");
  4764. } else if (state.errors.length) {
  4765. updateConnectionBadge("mixed");
  4766. } else {
  4767. updateConnectionBadge("api");
  4768. }
  4769. } finally {
  4770. document.body.classList.remove("api-loading");
  4771. }
  4772. }
  4773. window.VentAPI = {
  4774. init,
  4775. getBusinessDocument(documentId) {
  4776. return state.documents.get(String(documentId));
  4777. },
  4778. updateBusinessDocumentField,
  4779. refreshBusinessDocument,
  4780. getDocumentPreview(documentId) {
  4781. return request(`/documents/${encodeURIComponent(documentId)}/preview`);
  4782. },
  4783. getDocumentFileUrl(documentId, download = false) {
  4784. return `${API_BASE}/documents/${encodeURIComponent(documentId)}/file${download ? "?download=1" : ""}`;
  4785. },
  4786. getDocumentProcessing,
  4787. reprocessBusinessDocument,
  4788. openProcessingRecords,
  4789. closeProcessingRecords,
  4790. openVideoPlatform,
  4791. openKnowledgeCategory,
  4792. setBusinessSearch,
  4793. setBusinessCategory,
  4794. runHazardAgent,
  4795. state
  4796. };
  4797. })();