api.js 212 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059
  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. const note = document.querySelector("#videoStreamNote");
  3349. if (note) {
  3350. const playable = videoPlatformChannels().some((channel) => channel.playbackUrl);
  3351. note.textContent = playable
  3352. ? "视频文件按后端登记的磁盘路径实时读取,分屏内可播放/暂停、静音、抓图与全屏;抓拍图像点击可查看原图。"
  3353. : "视频流地址尚未配置,当前展示通道状态与控制界面。";
  3354. }
  3355. window.requestAnimationFrame(syncVideoPanelHeights);
  3356. }
  3357. function installVideoPlatformActions() {
  3358. document.querySelector("[data-refresh-video-platform]")?.addEventListener("click", async () => {
  3359. await loadVideoPlatform(state.videoModule);
  3360. const isApi = videoPlatformState().source === "api";
  3361. window.showToast?.(
  3362. isApi ? "视频通道与识别数据已刷新" : "后端不可用,已使用本地演示数据",
  3363. isApi ? undefined : "warning"
  3364. );
  3365. });
  3366. document.querySelectorAll("[data-video-module]").forEach((button) => {
  3367. button.addEventListener("click", async () => {
  3368. state.videoModule = button.dataset.videoModule || "door";
  3369. state.videoRecordTab = 0;
  3370. videoPlatformState().eventPage = 1;
  3371. state.videoSelectedChannel = videoPlatformChannels()[0]?.id || state.videoSelectedChannel;
  3372. renderVideoPlatform();
  3373. await ensureVideoModuleLoaded(state.videoModule);
  3374. state.videoSelectedChannel = videoPlatformChannels()[0]?.id || state.videoSelectedChannel;
  3375. renderVideoPlatform();
  3376. });
  3377. });
  3378. document.querySelectorAll("[data-video-layout]").forEach((button) => {
  3379. button.addEventListener("click", () => {
  3380. state.videoLayout = Number(button.dataset.videoLayout || 4);
  3381. document.querySelectorAll("[data-video-layout]").forEach((item) => {
  3382. item.classList.toggle("active", item === button);
  3383. });
  3384. renderVideoWall();
  3385. window.requestAnimationFrame(syncVideoPanelHeights);
  3386. });
  3387. });
  3388. const search = document.querySelector("#videoChannelSearch");
  3389. search?.addEventListener("input", () => {
  3390. state.videoSearch = search.value.trim();
  3391. renderVideoDeviceTree();
  3392. });
  3393. const startInput = document.querySelector("#videoFilterStart");
  3394. const endInput = document.querySelector("#videoFilterEnd");
  3395. [startInput, endInput].forEach((input) => {
  3396. input?.addEventListener("change", () => {
  3397. const view = videoPlatformState();
  3398. if (startInput?.value) view.filters.start = startInput.value;
  3399. if (endInput?.value) view.filters.end = endInput.value;
  3400. view.filters.quick = "";
  3401. renderVideoFilterOptions();
  3402. });
  3403. });
  3404. document.querySelectorAll("[data-video-quick-range]").forEach((button) => {
  3405. button.addEventListener("click", async () => {
  3406. const view = videoPlatformState();
  3407. view.filters.quick = button.dataset.videoQuickRange === "week" ? "week" : "today";
  3408. applyVideoDefaultRange({ reset: true });
  3409. await searchVideoPlatform({ silent: true });
  3410. window.showToast?.(
  3411. view.filters.quick === "week" ? "已切换为近 7 天范围" : "已切换为今日范围"
  3412. );
  3413. });
  3414. });
  3415. document.querySelector("[data-video-search]")?.addEventListener("click", () => {
  3416. searchVideoPlatform();
  3417. });
  3418. document.querySelector("[data-video-reset]")?.addEventListener("click", async () => {
  3419. const view = videoPlatformState();
  3420. state.videoSearch = "";
  3421. if (search) search.value = "";
  3422. state.videoRecordTab = 0;
  3423. view.eventPage = 1;
  3424. view.filters = { quick: "today", start: "", end: "", type: "", scope: "" };
  3425. applyVideoDefaultRange({ reset: true });
  3426. await searchVideoPlatform({ silent: true, fromInputs: false });
  3427. window.showToast?.("筛选条件已重置");
  3428. });
  3429. window.addEventListener("resize", () => {
  3430. window.requestAnimationFrame(syncVideoPanelHeights);
  3431. });
  3432. }
  3433. async function openVideoPlatform() {
  3434. window.navigateTo?.("video-platform");
  3435. renderVideoPlatform();
  3436. const view = videoPlatformState();
  3437. if (!view.loaded) {
  3438. await loadVideoPlatform(state.videoModule);
  3439. return;
  3440. }
  3441. await ensureVideoModuleLoaded(state.videoModule);
  3442. renderVideoPlatform();
  3443. }
  3444. function installDocumentExport() {
  3445. const button = document.querySelector("[data-export-documents]");
  3446. if (!button || button.dataset.exportBound) return;
  3447. button.dataset.exportBound = "true";
  3448. button.addEventListener("click", async () => {
  3449. if (!state.apiAvailable) {
  3450. window.showToast?.("后端不可用,暂时无法导出资料清单", "warning");
  3451. return;
  3452. }
  3453. const original = button.innerHTML;
  3454. button.disabled = true;
  3455. button.innerHTML = '<span data-icon="refresh-cw"></span>正在导出';
  3456. window.injectIcons?.(button);
  3457. try {
  3458. const params = new URLSearchParams();
  3459. params.set("mine_id", state.mine.id);
  3460. if (state.documentCategory && state.documentCategory !== "all") {
  3461. params.set("category", state.documentCategory);
  3462. }
  3463. const keyword = document
  3464. .querySelector("#businessDocumentSearch")
  3465. ?.value.trim();
  3466. if (keyword) params.set("keyword", keyword);
  3467. const response = await fetch(
  3468. `${API_BASE}/documents/export?${params.toString()}`
  3469. );
  3470. if (!response.ok) {
  3471. let message = `HTTP ${response.status}`;
  3472. try {
  3473. const payload = await response.json();
  3474. message = payload?.message || payload?.detail || message;
  3475. } catch {
  3476. // Keep the HTTP status when the response is not JSON.
  3477. }
  3478. throw new Error(message);
  3479. }
  3480. const blob = await response.blob();
  3481. const disposition = response.headers.get("Content-Disposition") || "";
  3482. const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
  3483. const filename = encodedName
  3484. ? decodeURIComponent(encodedName)
  3485. : `业务资料清单_${new Date().toISOString().slice(0, 10)}.xlsx`;
  3486. const url = URL.createObjectURL(blob);
  3487. const link = document.createElement("a");
  3488. link.href = url;
  3489. link.download = filename;
  3490. document.body.appendChild(link);
  3491. link.click();
  3492. link.remove();
  3493. window.setTimeout(() => URL.revokeObjectURL(url), 1000);
  3494. window.showToast?.("资料清单已导出");
  3495. } catch (error) {
  3496. window.showToast?.(`导出失败:${error?.message || error}`, "warning");
  3497. } finally {
  3498. button.disabled = false;
  3499. button.innerHTML = original;
  3500. window.injectIcons?.(button);
  3501. }
  3502. });
  3503. }
  3504. async function hydrateSafetyReadings(monitoring) {
  3505. const sensors = monitoring?.sensors || [];
  3506. if (!state.apiAvailable || !sensors.length) {
  3507. state.safetyLoaded = true;
  3508. return;
  3509. }
  3510. const entries = await Promise.all(
  3511. sensors.map(async (sensor) => {
  3512. try {
  3513. const readings = await request(`/monitoring/sensors/${encodeURIComponent(sensor.id)}/readings?limit=48`, {
  3514. timeoutMs: 3200
  3515. });
  3516. return [
  3517. String(sensor.id),
  3518. (readings || []).slice().sort((left, right) => new Date(left.measured_at) - new Date(right.measured_at))
  3519. ];
  3520. } catch {
  3521. return [String(sensor.id), []];
  3522. }
  3523. })
  3524. );
  3525. state.safetyReadings = new Map(entries);
  3526. state.safetyLoaded = true;
  3527. if (state.monitoring) renderSafetyMonitoring(state.monitoring);
  3528. }
  3529. function openSafetyMonitoring() {
  3530. window.navigateTo?.("safety-monitoring");
  3531. if (!state.safetyLoaded) hydrateSafetyReadings(state.monitoring).catch(() => {});
  3532. }
  3533. function renderKnowledgeGraph(graph) {
  3534. const canvas = document.querySelector(".graph-canvas");
  3535. const svg = canvas?.querySelector("svg");
  3536. if (!svg || !graph?.nodes?.length) return;
  3537. const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
  3538. const links = (graph.edges || [])
  3539. .filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target))
  3540. .map((edge) => {
  3541. const source = nodeMap.get(edge.source);
  3542. const target = nodeMap.get(edge.target);
  3543. return `<path d="M${source.x} ${source.y} L${target.x} ${target.y}" />`;
  3544. })
  3545. .join("");
  3546. const nodes = graph.nodes
  3547. .map((node) => {
  3548. const center = node.node_type === "risk";
  3549. const radius = center ? 48 : 34;
  3550. const className = center
  3551. ? "graph-center"
  3552. : node.node_type === "evidence"
  3553. ? "graph-node amber-node"
  3554. : "graph-node";
  3555. const value = Number(node.value || 0);
  3556. return `
  3557. <g class="${className}" transform="translate(${Number(node.x) || 0} ${Number(node.y) || 0})">
  3558. <title>${escapeHtml(`${node.label} ${node.sub_label || ""} · ${value} 条关联知识`)}</title>
  3559. <circle r="${radius}" />
  3560. <text y="-4">${escapeHtml(node.label || "")}</text>
  3561. <text y="15">${escapeHtml(node.sub_label || "")}</text>
  3562. </g>
  3563. `;
  3564. })
  3565. .join("");
  3566. svg.innerHTML = `
  3567. <defs>
  3568. <marker id="graphArrow" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
  3569. <path d="M0,0 L8,4 L0,8 z" fill="#93a4b8" />
  3570. </marker>
  3571. </defs>
  3572. <g class="graph-links">${links}</g>
  3573. ${nodes}
  3574. `;
  3575. }
  3576. function renderKnowledge(payload) {
  3577. const items = Array.isArray(payload)
  3578. ? payload
  3579. : payload?.catalog?.items || payload?.items || [];
  3580. const overview = Array.isArray(payload) ? null : payload?.overview || null;
  3581. const graph = Array.isArray(payload) ? null : payload?.graph || null;
  3582. const count = Number(overview?.total_notes || items.length || DEMO_KNOWLEDGE.length);
  3583. const heroStats = document.querySelector(".knowledge-hero-stats");
  3584. if (heroStats) {
  3585. const stats = heroStats.querySelectorAll("div");
  3586. if (stats[0]) {
  3587. stats[0].querySelector("strong").textContent = formatNumber(count);
  3588. stats[0].querySelector("span").textContent = "知识条目";
  3589. }
  3590. if (stats[1]) {
  3591. const semantic = Boolean(overview?.service?.semantic_enabled);
  3592. stats[1].querySelector("strong").textContent = semantic ? "混合" : "关键词";
  3593. stats[1].querySelector("span").textContent = "检索模式";
  3594. }
  3595. if (stats[2]) {
  3596. const responseMs = Number(overview?.average_response_ms || 0);
  3597. stats[2].querySelector("strong").textContent = responseMs
  3598. ? `${(responseMs / 1000).toFixed(1)}s`
  3599. : "--";
  3600. stats[2].querySelector("span").textContent = "平均响应";
  3601. }
  3602. }
  3603. const navBadge = document.querySelector('[data-view="knowledge"] .nav-badge');
  3604. if (navBadge) navBadge.textContent = formatNumber(count);
  3605. const dashboardCount = document.querySelector(".knowledge-count");
  3606. if (dashboardCount) dashboardCount.textContent = `${formatNumber(count)} 条知识`;
  3607. const heroCopy = document.querySelector(".knowledge-hero-copy p");
  3608. if (heroCopy) {
  3609. const chunkCount = Number(overview?.total_chunks || 0);
  3610. heroCopy.textContent = chunkCount
  3611. ? `已接入 ${formatNumber(count)} 篇知识、${formatNumber(chunkCount)} 个检索切片,结合关键词检索与来源追溯回答通防业务问题。`
  3612. : `已接入 ${formatNumber(count)} 篇知识,结合混合检索与来源追溯回答通防业务问题。`;
  3613. }
  3614. const categoryMap = new Map(
  3615. (overview?.category_counts || []).map((item) => [item.name, Number(item.count || 0)])
  3616. );
  3617. if (!categoryMap.size) {
  3618. items.forEach((item) => {
  3619. const key = item.category_name || item.category || item.knowledge_type || "其他";
  3620. categoryMap.set(key, (categoryMap.get(key) || 0) + 1);
  3621. });
  3622. }
  3623. document.querySelectorAll(".knowledge-category-grid .category-card").forEach((card) => {
  3624. const name = card.querySelector("strong")?.textContent?.trim();
  3625. const badge = card.querySelector("b");
  3626. if (badge) badge.textContent = formatNumber(categoryMap.get(name) ?? 0);
  3627. });
  3628. renderKnowledgeGraph(graph);
  3629. }
  3630. const KNOWLEDGE_CATEGORY_META = {
  3631. standards: {
  3632. name: "规范标准",
  3633. description: "规程、标准、办法及技术要求"
  3634. },
  3635. cases: {
  3636. name: "事故案例",
  3637. description: "典型事故与隐患处置案例"
  3638. },
  3639. expert: {
  3640. name: "专家经验",
  3641. description: "专家规则、判识经验与处置建议"
  3642. },
  3643. management: {
  3644. name: "管理流程",
  3645. description: "业务流程、责任岗位与闭环规则"
  3646. }
  3647. };
  3648. function closeKnowledgeCategory() {
  3649. const modal = document.querySelector("#knowledgeCatalogModal");
  3650. if (modal) modal.hidden = true;
  3651. document.body.classList.remove("knowledge-catalog-open");
  3652. }
  3653. async function loadKnowledgeCatalog(page = state.knowledgeCategoryPage) {
  3654. const category = state.knowledgeCategory;
  3655. const modal = document.querySelector("#knowledgeCatalogModal");
  3656. const content = document.querySelector("#knowledgeCatalogContent");
  3657. const pager = document.querySelector("#knowledgeCatalogPager");
  3658. if (!category || !modal || !content || !pager) return;
  3659. state.knowledgeCategoryPage = page;
  3660. content.innerHTML = '<div class="knowledge-catalog-empty">正在加载知识列表...</div>';
  3661. pager.innerHTML = "";
  3662. const params = new URLSearchParams({
  3663. category,
  3664. page: String(page),
  3665. page_size: String(state.knowledgeCategoryPageSize)
  3666. });
  3667. if (state.knowledgeCategoryKeyword) {
  3668. params.set("keyword", state.knowledgeCategoryKeyword);
  3669. }
  3670. try {
  3671. const data = await request(`/knowledge/catalog?${params.toString()}`, {
  3672. timeoutMs: 20000
  3673. });
  3674. state.knowledgeCategoryTotal = Number(data?.total || 0);
  3675. const title = document.querySelector("#knowledgeCatalogTitle");
  3676. const subtitle = document.querySelector("#knowledgeCatalogSubtitle");
  3677. if (title) title.textContent = KNOWLEDGE_CATEGORY_META[category]?.name || "知识列表";
  3678. if (subtitle) {
  3679. subtitle.textContent = `${KNOWLEDGE_CATEGORY_META[category]?.description || ""} · 共 ${formatNumber(state.knowledgeCategoryTotal)} 条`;
  3680. }
  3681. renderKnowledgeCatalog(data?.items || []);
  3682. renderKnowledgeCatalogPager();
  3683. } catch (error) {
  3684. content.innerHTML = `<div class="knowledge-catalog-empty">知识列表加载失败:${escapeHtml(error?.message || error)}</div>`;
  3685. window.showToast?.("知识列表加载失败", "warning");
  3686. }
  3687. }
  3688. function renderKnowledgeCatalog(items) {
  3689. const content = document.querySelector("#knowledgeCatalogContent");
  3690. if (!content) return;
  3691. if (!items.length) {
  3692. content.innerHTML = '<div class="knowledge-catalog-empty">当前分类下没有匹配的知识。</div>';
  3693. return;
  3694. }
  3695. content.innerHTML = `
  3696. <div class="knowledge-catalog-list">
  3697. ${items.map((item) => `
  3698. <button class="knowledge-catalog-item" type="button" data-knowledge-note="${escapeHtml(item.note_id)}">
  3699. <span class="knowledge-catalog-item-icon" data-icon="file-text"></span>
  3700. <span class="knowledge-catalog-item-copy">
  3701. <strong>${escapeHtml(item.title)}</strong>
  3702. <p>${escapeHtml([item.category_name, ...(item.tags || []).slice(0, 3)].filter(Boolean).join(" · "))}</p>
  3703. </span>
  3704. <span class="knowledge-catalog-item-meta">${escapeHtml(item.updated || "")}</span>
  3705. </button>
  3706. `).join("")}
  3707. </div>
  3708. `;
  3709. if (typeof window.injectIcons === "function") window.injectIcons(content);
  3710. }
  3711. function renderKnowledgeCatalogPager() {
  3712. const pager = document.querySelector("#knowledgeCatalogPager");
  3713. if (!pager) return;
  3714. const pages = Math.max(
  3715. 1,
  3716. Math.ceil(state.knowledgeCategoryTotal / state.knowledgeCategoryPageSize)
  3717. );
  3718. const current = state.knowledgeCategoryPage;
  3719. const pageButtons = [];
  3720. const start = Math.max(1, current - 2);
  3721. const end = Math.min(pages, current + 2);
  3722. for (let page = start; page <= end; page += 1) {
  3723. pageButtons.push(
  3724. `<button type="button" class="${page === current ? "active" : ""}" data-knowledge-page="${page}">${page}</button>`
  3725. );
  3726. }
  3727. pager.innerHTML = `
  3728. <span>第 ${current} / ${pages} 页</span>
  3729. <button type="button" data-knowledge-page="${current - 1}" ${current <= 1 ? "disabled" : ""}>上一页</button>
  3730. ${pageButtons.join("")}
  3731. <button type="button" data-knowledge-page="${current + 1}" ${current >= pages ? "disabled" : ""}>下一页</button>
  3732. `;
  3733. }
  3734. async function openKnowledgeDetail(noteId) {
  3735. const content = document.querySelector("#knowledgeCatalogContent");
  3736. const pager = document.querySelector("#knowledgeCatalogPager");
  3737. if (!content) return;
  3738. content.innerHTML = '<div class="knowledge-catalog-empty">正在加载知识正文...</div>';
  3739. if (pager) pager.innerHTML = "";
  3740. try {
  3741. const detail = await request(
  3742. `/knowledge/catalog/${encodeURIComponent(noteId)}`,
  3743. { timeoutMs: 20000 }
  3744. );
  3745. content.innerHTML = `
  3746. <div class="knowledge-catalog-detail-head">
  3747. <button class="secondary-button" type="button" data-knowledge-back>返回列表</button>
  3748. <div><h3>${escapeHtml(detail.title)}</h3></div>
  3749. </div>
  3750. <div class="knowledge-catalog-detail-tags">
  3751. ${[detail.category_name, ...(detail.tags || [])].filter(Boolean).map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")}
  3752. </div>
  3753. <div class="knowledge-catalog-detail-body">${renderMarkdown(detail.full_content || detail.summary || "")}</div>
  3754. `;
  3755. content.scrollTop = 0;
  3756. if (typeof window.injectIcons === "function") window.injectIcons(content);
  3757. } catch (error) {
  3758. content.innerHTML = `<div class="knowledge-catalog-empty">知识正文加载失败:${escapeHtml(error?.message || error)}</div>`;
  3759. }
  3760. }
  3761. async function openKnowledgeCategory(category) {
  3762. const meta = KNOWLEDGE_CATEGORY_META[category];
  3763. const modal = document.querySelector("#knowledgeCatalogModal");
  3764. if (!meta || !modal) return;
  3765. state.knowledgeCategory = category;
  3766. state.knowledgeCategoryPage = 1;
  3767. state.knowledgeCategoryKeyword = "";
  3768. const form = document.querySelector("#knowledgeCatalogSearch");
  3769. if (form) form.reset();
  3770. modal.hidden = false;
  3771. document.body.classList.add("knowledge-catalog-open");
  3772. await loadKnowledgeCatalog(1);
  3773. }
  3774. function installKnowledgeCategoryBrowser() {
  3775. if (window.__ventKnowledgeCategoryBrowserInstalled) return;
  3776. window.__ventKnowledgeCategoryBrowserInstalled = true;
  3777. document.querySelectorAll("[data-knowledge-category]").forEach((card) => {
  3778. const open = () => openKnowledgeCategory(card.dataset.knowledgeCategory);
  3779. card.addEventListener("click", open);
  3780. card.addEventListener("keydown", (event) => {
  3781. if (event.key === "Enter" || event.key === " ") {
  3782. event.preventDefault();
  3783. open();
  3784. }
  3785. });
  3786. });
  3787. document.querySelectorAll("[data-knowledge-catalog-close]").forEach((element) => {
  3788. element.addEventListener("click", closeKnowledgeCategory);
  3789. });
  3790. document.querySelector("#knowledgeCatalogSearch")?.addEventListener("submit", (event) => {
  3791. event.preventDefault();
  3792. const keyword = new FormData(event.currentTarget).get("keyword")?.toString().trim() || "";
  3793. state.knowledgeCategoryKeyword = keyword;
  3794. loadKnowledgeCatalog(1);
  3795. });
  3796. document.querySelector("#knowledgeCatalogContent")?.addEventListener("click", (event) => {
  3797. const back = event.target.closest("[data-knowledge-back]");
  3798. if (back) {
  3799. loadKnowledgeCatalog();
  3800. return;
  3801. }
  3802. const item = event.target.closest("[data-knowledge-note]");
  3803. if (item) openKnowledgeDetail(item.dataset.knowledgeNote);
  3804. });
  3805. document.querySelector("#knowledgeCatalogPager")?.addEventListener("click", (event) => {
  3806. const button = event.target.closest("[data-knowledge-page]");
  3807. if (!button || button.disabled) return;
  3808. loadKnowledgeCatalog(Number(button.dataset.knowledgePage));
  3809. });
  3810. document.addEventListener("keydown", (event) => {
  3811. if (event.key === "Escape" && !document.querySelector("#knowledgeCatalogModal")?.hidden) {
  3812. closeKnowledgeCategory();
  3813. }
  3814. });
  3815. }
  3816. function renderKnowledgeError(container, error, compact = false) {
  3817. const notLoaded =
  3818. Number(error?.status) === 404 ||
  3819. String(error?.message || "").includes("404");
  3820. if (notLoaded) {
  3821. container.innerHTML = compact
  3822. ? `
  3823. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>接口未加载</span></div>
  3824. <p>知识检索接口未在当前后端进程中加载,请重启后端服务后重试。</p>
  3825. `
  3826. : `
  3827. <div class="panel-head">
  3828. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3829. <span class="subtle-badge">接口未加载</span>
  3830. </div>
  3831. <p>知识检索接口未在当前后端进程中加载,请重启后端服务后重试。</p>
  3832. `;
  3833. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3834. window.showToast?.("知识接口未加载,请重启后端服务", "warning");
  3835. return;
  3836. }
  3837. container.innerHTML = compact
  3838. ? `
  3839. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>检索失败</span></div>
  3840. <p>知识库检索失败,请稍后重试。</p>
  3841. `
  3842. : `
  3843. <div class="panel-head">
  3844. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3845. <span class="subtle-badge">检索失败</span>
  3846. </div>
  3847. <p>知识库检索失败,请稍后重试。</p>
  3848. `;
  3849. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3850. window.showToast?.("知识库检索失败,请稍后重试", "warning");
  3851. }
  3852. function sourceView(source) {
  3853. const score = Number(source.score || 0);
  3854. return {
  3855. citationIndex: Number(source.citation_index || 0),
  3856. title: source.title,
  3857. scoreText: score > 0 ? `${Math.round(score * 100)}%` : "相关"
  3858. };
  3859. }
  3860. function renderMarkdown(value) {
  3861. const codeBlocks = [];
  3862. const source = String(value || "")
  3863. .replace(/\r\n/g, "\n")
  3864. .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, language, code) => {
  3865. codeBlocks.push({ language, code });
  3866. return `\n@@CODEBLOCK${codeBlocks.length - 1}@@\n`;
  3867. });
  3868. const lines = escapeHtml(source).split("\n");
  3869. const output = [];
  3870. let listType = null;
  3871. const closeList = () => {
  3872. if (listType) output.push(`</${listType}>`);
  3873. listType = null;
  3874. };
  3875. const inline = (text) =>
  3876. text
  3877. .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
  3878. .replace(/\*(.+?)\*/g, "<em>$1</em>")
  3879. .replace(/`([^`]+)`/g, "<code>$1</code>")
  3880. .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
  3881. for (const rawLine of lines) {
  3882. const line = rawLine.trimEnd();
  3883. const codeMatch = line.trim().match(/^@@CODEBLOCK(\d+)@@$/);
  3884. if (codeMatch) {
  3885. closeList();
  3886. const block = codeBlocks[Number(codeMatch[1])];
  3887. if (block) {
  3888. output.push(
  3889. `<pre><code${block.language ? ` class="language-${escapeHtml(block.language)}"` : ""}>${escapeHtml(block.code.trim())}</code></pre>`
  3890. );
  3891. }
  3892. continue;
  3893. }
  3894. const heading = line.match(/^\s*(#{1,6})\s+(.+)$/);
  3895. if (heading) {
  3896. closeList();
  3897. const level = heading[1].length;
  3898. output.push(`<h${level}>${inline(heading[2].trim())}</h${level}>`);
  3899. continue;
  3900. }
  3901. const bullet = line.match(/^\s*[-*]\s+(.+)$/);
  3902. const ordered = line.match(/^\s*\d+[.、)]\s+(.+)$/);
  3903. if (bullet || ordered) {
  3904. const nextType = bullet ? "ul" : "ol";
  3905. if (listType !== nextType) {
  3906. closeList();
  3907. listType = nextType;
  3908. output.push(`<${listType}>`);
  3909. }
  3910. output.push(`<li>${inline((bullet || ordered)[1].trim())}</li>`);
  3911. continue;
  3912. }
  3913. closeList();
  3914. if (line.trim()) output.push(`<p>${inline(line.trim())}</p>`);
  3915. }
  3916. closeList();
  3917. return output.join("");
  3918. }
  3919. function createKnowledgeStreamView(container, question) {
  3920. let answer = "";
  3921. let thinking = "";
  3922. let answerEl = null;
  3923. let statusEl = null;
  3924. let thinkingPanel = null;
  3925. let thinkingEl = null;
  3926. let thinkingState = null;
  3927. let renderFrame = 0;
  3928. let thinkingFrame = 0;
  3929. const renderMeta = (data = {}) => {
  3930. const sources = (data.sources || []).map(sourceView);
  3931. container.innerHTML = `
  3932. <div class="panel-head">
  3933. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  3934. <span class="subtle-badge" data-rag-status>RAG 生成中</span>
  3935. </div>
  3936. <div class="ai-answer">
  3937. <div class="ai-avatar"><span data-icon="bot"></span></div>
  3938. <div class="ai-answer-body">
  3939. <p><strong>问题:</strong>${escapeHtml(question)}</p>
  3940. <details class="rag-thinking" data-rag-thinking hidden>
  3941. <summary><span data-icon="brain"></span><span>思考过程</span><span data-rag-thinking-state>思考中</span></summary>
  3942. <div class="rag-thinking-content" data-rag-thinking-content></div>
  3943. </details>
  3944. <div class="rag-answer-markdown" data-rag-answer></div>
  3945. <div class="source-citations" data-rag-sources hidden>
  3946. ${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("")}
  3947. </div>
  3948. </div>
  3949. </div>
  3950. `;
  3951. answerEl = container.querySelector("[data-rag-answer]");
  3952. statusEl = container.querySelector("[data-rag-status]");
  3953. thinkingPanel = container.querySelector("[data-rag-thinking]");
  3954. thinkingEl = container.querySelector("[data-rag-thinking-content]");
  3955. thinkingState = container.querySelector("[data-rag-thinking-state]");
  3956. if (typeof window.injectIcons === "function") window.injectIcons(container);
  3957. };
  3958. const scrollToBottom = () => {
  3959. const scroller = container.querySelector(".ai-answer");
  3960. if (scroller) scroller.scrollTop = scroller.scrollHeight;
  3961. };
  3962. const renderAnswer = () => {
  3963. renderFrame = 0;
  3964. if (!answerEl) return;
  3965. answerEl.innerHTML = renderMarkdown(answer);
  3966. scrollToBottom();
  3967. };
  3968. const scheduleAnswer = () => {
  3969. if (renderFrame) return;
  3970. renderFrame = window.requestAnimationFrame(renderAnswer);
  3971. };
  3972. const renderThinking = (finished = false) => {
  3973. thinkingFrame = 0;
  3974. if (!thinkingPanel || !thinkingEl) return;
  3975. thinkingPanel.hidden = !thinking;
  3976. thinkingEl.textContent = thinking;
  3977. if (thinkingState) thinkingState.textContent = finished ? "已完成" : "思考中";
  3978. scrollToBottom();
  3979. };
  3980. const scheduleThinking = () => {
  3981. if (thinkingFrame) return;
  3982. thinkingFrame = window.requestAnimationFrame(() => renderThinking(false));
  3983. };
  3984. return {
  3985. get answer() {
  3986. return answer;
  3987. },
  3988. meta: renderMeta,
  3989. thinking(content) {
  3990. if (!answerEl) renderMeta();
  3991. thinking += String(content || "");
  3992. scheduleThinking();
  3993. },
  3994. delta(content) {
  3995. if (!answerEl) renderMeta();
  3996. answer += String(content || "");
  3997. scheduleAnswer();
  3998. },
  3999. done(data = {}) {
  4000. answer = String(data.answer || answer);
  4001. if (data.thinking) thinking = String(data.thinking);
  4002. if (renderFrame) window.cancelAnimationFrame(renderFrame);
  4003. if (thinkingFrame) window.cancelAnimationFrame(thinkingFrame);
  4004. renderAnswer();
  4005. renderThinking(true);
  4006. container.querySelector("[data-rag-sources]")?.removeAttribute("hidden");
  4007. if (statusEl) {
  4008. const status = data.answer_status || "generated";
  4009. const generated = status === "generated";
  4010. const conversational = status === "conversational";
  4011. statusEl.className = `subtle-badge ${generated || conversational ? "success" : ""}`;
  4012. statusEl.textContent = generated
  4013. ? "RAG 大模型生成"
  4014. : conversational
  4015. ? "智能助手"
  4016. : "检索摘要";
  4017. }
  4018. }
  4019. };
  4020. }
  4021. async function streamKnowledgeAnswerFetch(container, question) {
  4022. const controller = new AbortController();
  4023. const timeoutId = window.setTimeout(() => controller.abort(), 120000);
  4024. try {
  4025. const response = await fetch(`${API_BASE}/knowledge/rag/stream`, {
  4026. method: "POST",
  4027. headers: {
  4028. Accept: "text/event-stream",
  4029. "Content-Type": "application/json"
  4030. },
  4031. body: JSON.stringify({
  4032. question,
  4033. top_k: 5,
  4034. generate_answer: true
  4035. }),
  4036. signal: controller.signal
  4037. });
  4038. if (!response.ok) {
  4039. let message = `HTTP ${response.status}`;
  4040. let payload = null;
  4041. try {
  4042. payload = await response.json();
  4043. message = payload?.message || payload?.detail || message;
  4044. } catch {
  4045. // Keep the HTTP status.
  4046. }
  4047. const error = new Error(message);
  4048. error.status = response.status;
  4049. throw error;
  4050. }
  4051. if (!response.body) throw new Error("当前浏览器不支持流式响应");
  4052. const reader = response.body.getReader();
  4053. const decoder = new TextDecoder("utf-8");
  4054. let buffer = "";
  4055. const view = createKnowledgeStreamView(container, question);
  4056. const handleEvent = (eventName, data) => {
  4057. if (eventName === "meta") {
  4058. view.meta(data);
  4059. } else if (eventName === "thinking") {
  4060. view.thinking(data.content || "");
  4061. } else if (eventName === "delta") {
  4062. view.delta(data.content || "");
  4063. } else if (eventName === "warning") {
  4064. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  4065. } else if (eventName === "done") {
  4066. view.done(data);
  4067. }
  4068. };
  4069. const processBlock = (block) => {
  4070. if (!block.trim()) return;
  4071. let eventName = "message";
  4072. const dataLines = [];
  4073. for (const line of block.split(/\r?\n/)) {
  4074. if (line.startsWith("event:")) eventName = line.slice(6).trim();
  4075. if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
  4076. }
  4077. if (!dataLines.length) return;
  4078. handleEvent(eventName, JSON.parse(dataLines.join("\n")));
  4079. };
  4080. while (true) {
  4081. const { done, value } = await reader.read();
  4082. buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
  4083. const blocks = buffer.split(/\r?\n\r?\n/);
  4084. buffer = blocks.pop() || "";
  4085. blocks.forEach(processBlock);
  4086. if (done) break;
  4087. }
  4088. if (buffer.trim()) processBlock(buffer);
  4089. } finally {
  4090. window.clearTimeout(timeoutId);
  4091. }
  4092. }
  4093. function streamKnowledgeAnswerEventSource(container, question) {
  4094. return new Promise((resolve, reject) => {
  4095. const url =
  4096. `${API_BASE}/knowledge/rag/stream?question=${encodeURIComponent(question)}` +
  4097. "&top_k=5";
  4098. const source = new EventSource(url);
  4099. const view = createKnowledgeStreamView(container, question);
  4100. let received = false;
  4101. let settled = false;
  4102. const timeoutId = window.setTimeout(() => {
  4103. if (settled) return;
  4104. source.close();
  4105. settled = true;
  4106. reject(new Error("流式响应超时"));
  4107. }, 120000);
  4108. const finish = () => {
  4109. if (settled) return;
  4110. settled = true;
  4111. window.clearTimeout(timeoutId);
  4112. source.close();
  4113. resolve();
  4114. };
  4115. source.addEventListener("meta", (event) => {
  4116. received = true;
  4117. view.meta(JSON.parse(event.data));
  4118. });
  4119. source.addEventListener("thinking", (event) => {
  4120. received = true;
  4121. const data = JSON.parse(event.data);
  4122. view.thinking(data.content || "");
  4123. });
  4124. source.addEventListener("delta", (event) => {
  4125. received = true;
  4126. const data = JSON.parse(event.data);
  4127. view.delta(data.content || "");
  4128. });
  4129. source.addEventListener("warning", (event) => {
  4130. const data = JSON.parse(event.data);
  4131. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  4132. });
  4133. source.addEventListener("done", (event) => {
  4134. const data = JSON.parse(event.data);
  4135. view.done(data);
  4136. finish();
  4137. });
  4138. source.onerror = () => {
  4139. if (settled) return;
  4140. window.clearTimeout(timeoutId);
  4141. source.close();
  4142. if (received && view.answer) {
  4143. settled = true;
  4144. resolve();
  4145. return;
  4146. }
  4147. settled = true;
  4148. reject(new Error("SSE 连接失败"));
  4149. };
  4150. });
  4151. }
  4152. function streamKnowledgeAnswerWebSocket(container, question) {
  4153. return new Promise((resolve, reject) => {
  4154. const protocol = window.location.protocol === "https:" ? "wss" : "ws";
  4155. const socket = new WebSocket(
  4156. `${protocol}://${window.location.host}${API_BASE}/knowledge/rag/ws`
  4157. );
  4158. const view = createKnowledgeStreamView(container, question);
  4159. let received = false;
  4160. let settled = false;
  4161. const finish = () => {
  4162. if (settled) return;
  4163. settled = true;
  4164. socket.close();
  4165. resolve();
  4166. };
  4167. const fail = (error) => {
  4168. if (settled) return;
  4169. settled = true;
  4170. error.streamStarted = received;
  4171. socket.close();
  4172. reject(error);
  4173. };
  4174. socket.onopen = () => {
  4175. socket.send(
  4176. JSON.stringify({
  4177. question,
  4178. top_k: 5,
  4179. generate_answer: true
  4180. })
  4181. );
  4182. };
  4183. socket.onmessage = (event) => {
  4184. const item = JSON.parse(event.data);
  4185. const data = item.data || {};
  4186. if (item.event === "meta") {
  4187. received = true;
  4188. view.meta(data);
  4189. } else if (item.event === "thinking") {
  4190. received = true;
  4191. view.thinking(data.content || "");
  4192. } else if (item.event === "delta") {
  4193. received = true;
  4194. view.delta(data.content || "");
  4195. } else if (item.event === "warning") {
  4196. window.showToast?.(data.message || "大模型流式生成异常", "warning");
  4197. } else if (item.event === "done") {
  4198. view.done(data);
  4199. finish();
  4200. } else if (item.event === "error") {
  4201. fail(new Error(data.message || "流式问答失败"));
  4202. }
  4203. };
  4204. socket.onerror = () => fail(new Error("WebSocket 连接失败"));
  4205. socket.onclose = () => {
  4206. if (!settled && !received) fail(new Error("WebSocket 连接已关闭"));
  4207. else if (!settled) finish();
  4208. };
  4209. });
  4210. }
  4211. function streamKnowledgeAnswer(container, question) {
  4212. if (typeof WebSocket !== "undefined") {
  4213. return streamKnowledgeAnswerWebSocket(container, question).catch((error) => {
  4214. if (error?.streamStarted) throw error;
  4215. return streamKnowledgeAnswerEventSource(container, question);
  4216. });
  4217. }
  4218. if (typeof EventSource !== "undefined") {
  4219. return streamKnowledgeAnswerEventSource(container, question);
  4220. }
  4221. return streamKnowledgeAnswerFetch(container, question);
  4222. }
  4223. function installKnowledgeRenderer() {
  4224. if (window.__ventApiKnowledgeRendererInstalled) return;
  4225. window.__ventApiKnowledgeRendererInstalled = true;
  4226. const originalRenderAnswer = window.renderAnswer;
  4227. if (typeof originalRenderAnswer !== "function") return;
  4228. window.renderAnswer = function renderAnswerWithApi(container, question, compact = false) {
  4229. if (!state.apiAvailable) {
  4230. originalRenderAnswer(container, question, compact);
  4231. return;
  4232. }
  4233. if (compact) {
  4234. container.innerHTML = `
  4235. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>正在检索知识库</span></div>
  4236. <p>正在检索相关知识,请稍候...</p>
  4237. `;
  4238. } else {
  4239. container.innerHTML = `
  4240. <div class="panel-head">
  4241. <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
  4242. <span class="subtle-badge">检索中</span>
  4243. </div>
  4244. <div class="ai-answer">
  4245. <div class="ai-avatar"><span data-icon="bot"></span></div>
  4246. <div class="ai-answer-body">
  4247. <p><strong>问题:</strong>${escapeHtml(question)}</p>
  4248. <p>正在检索知识库并整理依据,请稍候...</p>
  4249. </div>
  4250. </div>
  4251. `;
  4252. }
  4253. if (typeof window.injectIcons === "function") window.injectIcons(container);
  4254. if (!compact) {
  4255. streamKnowledgeAnswer(container, question).catch((error) => {
  4256. renderKnowledgeError(container, error, false);
  4257. });
  4258. return;
  4259. }
  4260. request("/knowledge/ask", {
  4261. method: "POST",
  4262. body: JSON.stringify({
  4263. question,
  4264. top_k: 3,
  4265. generate_answer: false
  4266. }),
  4267. timeoutMs: 30000
  4268. })
  4269. .then((result) => {
  4270. if (!result?.answer) return;
  4271. const answerText = result.answer;
  4272. const answerHtml = escapeHtml(answerText).replace(/\r?\n/g, "<br>");
  4273. const sources = (result.sources || []).map((source) => ({
  4274. citationIndex: Number(source.citation_index || 0),
  4275. title: source.title,
  4276. score: Number(source.score || 0),
  4277. scoreText:
  4278. Number(source.score || 0) > 0
  4279. ? `${Math.round(Number(source.score || 0) * 100)}%`
  4280. : "相关"
  4281. }));
  4282. const modeLabel = result.search_mode === "hybrid" ? "混合检索" : "关键词检索";
  4283. container.innerHTML = `
  4284. <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>${modeLabel} · 命中 ${result.total_candidates || sources.length} 条</span></div>
  4285. <p>${answerHtml}</p>
  4286. <div class="answer-sources">${sources.map((source) => `<span>${escapeHtml(source.title)}</span>`).join("")}</div>
  4287. `;
  4288. if (typeof window.injectIcons === "function") window.injectIcons(container);
  4289. })
  4290. .catch((error) => renderKnowledgeError(container, error, true));
  4291. };
  4292. }
  4293. function severityClass(severity) {
  4294. return severity === "high" ? "high" : severity === "low" ? "low" : "medium";
  4295. }
  4296. function sceneMeta(scene) {
  4297. const keyMap = {
  4298. "局部通风": ["local", "orange"],
  4299. "通风设施": ["facility", "yellow"],
  4300. "通风系统管理": ["system", "teal"],
  4301. "风量分配": ["volume", "blue"],
  4302. "灾害防治": ["disaster", "red"]
  4303. };
  4304. return keyMap[scene] || ["system", "teal"];
  4305. }
  4306. function statusMeta(status) {
  4307. if (["completed", "closed", "verified", "已记录"].includes(status)) return ["closed", "已记录"];
  4308. if (["processing", "处理中"].includes(status)) return ["processing", "处理中"];
  4309. return ["pending", "待处理"];
  4310. }
  4311. function renderDiagnosis(defects) {
  4312. const list = defects?.length ? defects : DEMO_DIAGNOSIS;
  4313. const normalized = list.map((item, index) => {
  4314. const [sceneKey] = sceneMeta(item.scene);
  4315. return {
  4316. ...item,
  4317. id: item.id || `risk-api-${index}`,
  4318. scene_key: sceneKey,
  4319. defect_code: item.defect_code || item.code || `RISK-API-${index + 1}`,
  4320. evidence: item.evidence || (
  4321. sceneKey === "disaster"
  4322. ? ["radio-tower", "video", "file-text"]
  4323. : sceneKey === "volume"
  4324. ? ["gauge", "file-text"]
  4325. : ["radio-tower", "activity", "file-text"]
  4326. )
  4327. };
  4328. });
  4329. window.setHazardRisks?.(normalized);
  4330. }
  4331. function renderEvaluation(evaluation) {
  4332. const run = evaluation.run || DEMO_EVALUATION.run;
  4333. const dimensions = evaluation.dimensions?.length ? evaluation.dimensions : DEMO_EVALUATION.dimensions;
  4334. const score = Number(run.total_score || 0);
  4335. const grade = String(run.grade || "A").replace("级", "");
  4336. const summary = run.summary || "综合评价已完成,运行状态稳定。";
  4337. setText(".evaluation-grade strong", grade);
  4338. setText(".evaluation-grade em", score >= 90 ? "运行状态优良" : score >= 80 ? "运行状态良好" : "需要持续改进");
  4339. setText(".evaluation-summary-copy .eyebrow", `${formatDate(run.created_at || new Date())} · 自动评价`);
  4340. setText(".evaluation-summary-copy h2", `${state.mine?.name || "上湾煤矿"}通防业务综合评价报告`);
  4341. setText(".evaluation-summary-copy p", summary);
  4342. setText(".radar-panel .subtle-badge", `综合得分 ${formatNumber(score)}`);
  4343. const gradeBox = document.querySelector(".evaluation-grade");
  4344. if (gradeBox) gradeBox.dataset.score = String(score);
  4345. const dimensionsList = document.querySelector(".dimension-list");
  4346. const iconMap = ["shield-check", "activity", "circle-dollar-sign", "clipboard-check", "database"];
  4347. const colorMap = ["green", "blue", "amber", "purple", "teal"];
  4348. if (dimensionsList) {
  4349. dimensionsList.innerHTML = dimensions
  4350. .slice(0, 5)
  4351. .map((item, index) => {
  4352. const change = Number(item.change || 0);
  4353. return `
  4354. <div class="dimension-row">
  4355. <span class="dimension-icon ${colorMap[index % colorMap.length]}" data-icon="${iconMap[index % iconMap.length]}"></span>
  4356. <div><strong>${escapeHtml(item.name)}</strong><p>${escapeHtml(item.note || "业务综合指标")}</p></div>
  4357. <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>
  4358. </div>
  4359. `;
  4360. })
  4361. .join("");
  4362. if (typeof window.injectIcons === "function") window.injectIcons(dimensionsList);
  4363. }
  4364. document.querySelectorAll(".radar-labels text").forEach((label, index) => {
  4365. const item = dimensions[index];
  4366. if (item) label.textContent = `${item.name} ${Number(item.score || 0).toFixed(1)}`;
  4367. });
  4368. const agentText = document.querySelector(".evaluation-agent-panel .agent-copy p");
  4369. if (agentText) {
  4370. agentText.textContent = `“${summary}”`;
  4371. }
  4372. const suggestions = document.querySelector(".evaluation-agent-panel .agent-suggestions");
  4373. if (suggestions && run.recommendations?.length) {
  4374. suggestions.innerHTML = run.recommendations
  4375. .slice(0, 3)
  4376. .map((item, index) => `<span><i>${index + 1}</i>${escapeHtml(item)}</span>`)
  4377. .join("");
  4378. }
  4379. }
  4380. function renderWorkOrders(orders) {
  4381. const items = orders?.length ? orders : DEMO_WORK_ORDERS;
  4382. const groups = [
  4383. { statuses: ["pending"], title: "待确认", dot: "pending" },
  4384. { statuses: ["processing"], title: "处理中", dot: "processing" },
  4385. { statuses: ["review"], title: "待复核", dot: "review" },
  4386. { statuses: ["completed", "verified", "closed"], title: "已完成", dot: "closed" }
  4387. ];
  4388. const columns = document.querySelectorAll("#view-closed-loop .kanban-column");
  4389. groups.forEach((group, index) => {
  4390. const column = columns[index];
  4391. if (!column) return;
  4392. const groupItems = items.filter((item) => group.statuses.includes(item.status));
  4393. const head = column.querySelector(".kanban-head span");
  4394. if (head) head.innerHTML = `<i class="column-dot ${group.dot}"></i>${group.title} <b>${groupItems.length}</b>`;
  4395. const cards = groupItems.map((item) => {
  4396. const priority = item.priority || "medium";
  4397. const priorityLabel = priority === "high" ? "高优先级" : priority === "low" ? "常规" : "中优先级";
  4398. return `
  4399. <button class="work-card priority-${priority}" type="button" data-order-id="${escapeHtml(item.id)}">
  4400. <div class="work-card-head"><span class="work-tag ${priority}">${priorityLabel}</span><span>${formatTime(item.created_at)}</span></div>
  4401. <strong>${escapeHtml(item.title)}</strong>
  4402. <p>${escapeHtml(item.content || "待补充处置说明。")}</p>
  4403. <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>
  4404. </button>
  4405. `;
  4406. });
  4407. column.innerHTML = head ? `${head.outerHTML}${cards.join("")}` : cards.join("");
  4408. column.querySelectorAll(".work-card").forEach((card) => {
  4409. card.addEventListener("click", () => window.showToast?.(`已打开任务:${card.querySelector("strong")?.textContent || "整改任务"}`));
  4410. });
  4411. });
  4412. const toolbar = document.querySelector("#view-closed-loop .toolbar-copy p");
  4413. const closed = items.filter((item) => ["completed", "verified", "closed"].includes(item.status)).length;
  4414. if (toolbar) {
  4415. const rate = items.length ? (closed / items.length * 100).toFixed(1) : "0.0";
  4416. toolbar.textContent = `当前展示 ${items.length} 项整改任务,已完成 ${closed} 项,闭环率 ${rate}%`;
  4417. }
  4418. }
  4419. async function loadMine() {
  4420. try {
  4421. const mines = await request("/mines");
  4422. const mine = mines.find((item) => item.code === DEFAULT_MINE_CODE) || mines.find((item) => item.is_default) || mines[0];
  4423. if (!mine) throw new Error("no mine returned");
  4424. state.apiAvailable = true;
  4425. state.mine = mine;
  4426. return { data: mine, source: "api" };
  4427. } catch (error) {
  4428. state.errors.push(`mines: ${error?.message || error}`);
  4429. state.apiAvailable = false;
  4430. state.mine = DEMO_MINE;
  4431. return { data: DEMO_MINE, source: "demo" };
  4432. }
  4433. }
  4434. async function loadDocuments() {
  4435. const categoryQuery = state.documentCategory && state.documentCategory !== "all"
  4436. ? `&category=${encodeURIComponent(state.documentCategory)}`
  4437. : "";
  4438. try {
  4439. const keyword = state.documentKeyword.trim();
  4440. const documentRequest = keyword
  4441. ? request(
  4442. withMine(
  4443. `/documents/search?q=${encodeURIComponent(keyword)}&page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}`
  4444. )
  4445. )
  4446. : request(
  4447. withMine(
  4448. `/documents?page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}`
  4449. )
  4450. );
  4451. const [pageResult, categories] = await Promise.all([
  4452. documentRequest,
  4453. request(withMine("/documents/categories"))
  4454. ]);
  4455. state.documentSearchSnippets = new Map();
  4456. const items = keyword
  4457. ? (pageResult.items || []).map((hit) => {
  4458. state.documentSearchSnippets.set(
  4459. String(hit.document?.id),
  4460. hit.snippets || []
  4461. );
  4462. return hit.document;
  4463. })
  4464. : pageResult?.items || [];
  4465. return {
  4466. data: {
  4467. page: {
  4468. items,
  4469. total: Number(pageResult?.total || 0),
  4470. page: Number(pageResult?.page || state.documentPage),
  4471. page_size: Number(pageResult?.page_size || state.documentPageSize)
  4472. },
  4473. categories: categories || []
  4474. },
  4475. source: "api"
  4476. };
  4477. } catch (error) {
  4478. state.errors.push(`documents: ${error?.message || error}`);
  4479. return { data: EMPTY_DOCUMENTS, source: "demo" };
  4480. }
  4481. }
  4482. async function loadDocumentStatistics() {
  4483. return withFallback(
  4484. "document-statistics",
  4485. () => request(withMine("/documents/statistics")),
  4486. DEMO_DOCUMENT_STATISTICS
  4487. );
  4488. }
  4489. async function loadBusinessTemplates() {
  4490. return withFallback(
  4491. "document-templates",
  4492. async () => {
  4493. const templates = await request("/documents/templates");
  4494. if (!templates?.length) throw new Error("empty response");
  4495. return templates;
  4496. },
  4497. []
  4498. );
  4499. }
  4500. async function loadMonitoring() {
  4501. return withFallback(
  4502. "monitoring",
  4503. async () => {
  4504. const [overview, sources, sensors, facilities, alarms] = await Promise.all([
  4505. request(withMine("/monitoring/overview")),
  4506. request(withMine("/monitoring/sources")),
  4507. request(withMine("/monitoring/sensors")),
  4508. request(withMine("/monitoring/facilities")),
  4509. request(withMine("/monitoring/alarms?limit=100"))
  4510. ]);
  4511. return { overview, sources, sensors, facilities, alarms };
  4512. },
  4513. DEMO_MONITORING
  4514. );
  4515. }
  4516. async function loadKnowledge() {
  4517. return withFallback(
  4518. "knowledge",
  4519. async () => {
  4520. const [overview, catalog, graph] = await Promise.all([
  4521. request("/knowledge/overview", { timeoutMs: 15000 }),
  4522. request("/knowledge/catalog?page=1&page_size=200", { timeoutMs: 15000 }),
  4523. request("/knowledge/graph", { timeoutMs: 15000 })
  4524. ]);
  4525. if (!overview?.total_notes && !catalog?.items?.length) {
  4526. throw new Error("empty response");
  4527. }
  4528. return { overview, catalog, graph };
  4529. },
  4530. { overview: null, catalog: { items: DEMO_KNOWLEDGE }, graph: null }
  4531. );
  4532. }
  4533. async function loadDiagnosis() {
  4534. return withFallback(
  4535. "diagnosis",
  4536. async () => {
  4537. const tasks = await request(withMine("/diagnosis/tasks?limit=20"));
  4538. if (!tasks?.length) throw new Error("empty response");
  4539. const details = await Promise.all(
  4540. tasks.slice(0, 5).map((task) => request(`/diagnosis/tasks/${encodeURIComponent(task.id)}`))
  4541. );
  4542. const defects = details.flatMap((task) =>
  4543. (task.results || []).map((result) => ({
  4544. id: result.id,
  4545. defect_name: result.defect_name,
  4546. defect_code: result.defect_code,
  4547. scene: result.scene,
  4548. severity: result.severity,
  4549. confidence: Number(result.confidence || 0) * (Number(result.confidence || 0) <= 1 ? 100 : 1),
  4550. status: result.status,
  4551. conclusion: result.conclusion,
  4552. recommendation: result.recommendation,
  4553. occurred_at: formatTime(result.created_at || task.created_at)
  4554. }))
  4555. );
  4556. if (!defects.length) throw new Error("empty response");
  4557. return defects;
  4558. },
  4559. DEMO_DIAGNOSIS
  4560. );
  4561. }
  4562. async function loadEvaluation() {
  4563. return withFallback(
  4564. "evaluations",
  4565. async () => {
  4566. const [indicators, runs] = await Promise.all([
  4567. request(withMine("/evaluations/indicators")),
  4568. request(withMine("/evaluations/runs?limit=1"))
  4569. ]);
  4570. if (!runs?.length) throw new Error("empty response");
  4571. const run = await request(`/evaluations/runs/${encodeURIComponent(runs[0].id)}`);
  4572. const indicatorMap = new Map((indicators || []).map((item) => [String(item.id), item]));
  4573. const dimensions = (run.scores || [])
  4574. .map((score) => {
  4575. const indicator = indicatorMap.get(String(score.indicator_id));
  4576. return {
  4577. name: indicator?.dimension || indicator?.name,
  4578. score: Number(score.score || 0),
  4579. change: 0,
  4580. note: indicator?.name || "评价指标得分"
  4581. };
  4582. })
  4583. .filter((item) => item.name);
  4584. return { run, dimensions: dimensions.length ? dimensions : DEMO_EVALUATION.dimensions };
  4585. },
  4586. DEMO_EVALUATION
  4587. );
  4588. }
  4589. async function loadWorkOrders() {
  4590. return withFallback(
  4591. "work-orders",
  4592. async () => {
  4593. const page = await request(withMine("/work-orders?page=1&page_size=200"));
  4594. if (!page?.items?.length) throw new Error("empty response");
  4595. return page.items;
  4596. },
  4597. DEMO_WORK_ORDERS
  4598. );
  4599. }
  4600. async function runHazardAgent(agent) {
  4601. const sceneName = {
  4602. local: "局部通风",
  4603. facility: "通风设施",
  4604. system: "通风系统管理",
  4605. volume: "风量分配",
  4606. disaster: "灾害防治"
  4607. }[agent.sceneKey] || "all";
  4608. let source = "demo";
  4609. if (state.apiAvailable && state.mine?.id) {
  4610. try {
  4611. await request("/diagnosis/run", {
  4612. method: "POST",
  4613. body: JSON.stringify({
  4614. mine_id: state.mine.id,
  4615. scene: sceneName,
  4616. requested_by: "隐患识别中心"
  4617. })
  4618. });
  4619. source = "api";
  4620. } catch (error) {
  4621. state.errors.push(`hazard-agent:${agent.id}: ${error?.message || error}`);
  4622. }
  4623. }
  4624. const localFindings = window.getHazardAgentFindings?.(agent) || [];
  4625. await new Promise((resolve) => window.setTimeout(resolve, 650));
  4626. return { findings: localFindings, source };
  4627. }
  4628. function installActionInterceptors() {
  4629. const diagnosisButton = document.querySelector("[data-run-diagnosis]");
  4630. if (diagnosisButton && !diagnosisButton.dataset.apiBound) {
  4631. diagnosisButton.dataset.apiBound = "true";
  4632. diagnosisButton.addEventListener(
  4633. "click",
  4634. async (event) => {
  4635. event.stopImmediatePropagation();
  4636. if (!state.apiAvailable) {
  4637. window.showToast?.("后端不可用,已使用本地演示诊断结果", "warning");
  4638. return;
  4639. }
  4640. const original = diagnosisButton.innerHTML;
  4641. diagnosisButton.disabled = true;
  4642. diagnosisButton.innerHTML = '<span data-icon="refresh-cw"></span>诊断进行中';
  4643. window.injectIcons?.(diagnosisButton);
  4644. try {
  4645. await request("/diagnosis/run", {
  4646. method: "POST",
  4647. body: JSON.stringify({
  4648. mine_id: state.mine.id,
  4649. scene: "all",
  4650. requested_by: "前端用户"
  4651. })
  4652. });
  4653. const result = await loadDiagnosis();
  4654. renderDiagnosis(result.data);
  4655. window.showToast?.(`全量诊断完成,识别到 ${result.data.length} 项缺陷与风险`);
  4656. } catch {
  4657. renderDiagnosis(DEMO_DIAGNOSIS);
  4658. window.showToast?.("诊断接口不可用,已回退到本地演示数据", "warning");
  4659. } finally {
  4660. diagnosisButton.disabled = false;
  4661. diagnosisButton.innerHTML = original;
  4662. window.injectIcons?.(diagnosisButton);
  4663. }
  4664. },
  4665. true
  4666. );
  4667. }
  4668. const evaluationButton = document.querySelector("[data-start-evaluation]");
  4669. if (evaluationButton && !evaluationButton.dataset.apiBound) {
  4670. evaluationButton.dataset.apiBound = "true";
  4671. evaluationButton.addEventListener(
  4672. "click",
  4673. async (event) => {
  4674. event.stopImmediatePropagation();
  4675. if (!state.apiAvailable) {
  4676. renderEvaluation(DEMO_EVALUATION);
  4677. window.showToast?.("后端不可用,已使用本地演示评价结果", "warning");
  4678. return;
  4679. }
  4680. const original = evaluationButton.innerHTML;
  4681. evaluationButton.disabled = true;
  4682. evaluationButton.innerHTML = '<span data-icon="refresh-cw"></span>评价计算中';
  4683. window.injectIcons?.(evaluationButton);
  4684. try {
  4685. await request("/evaluations/run", {
  4686. method: "POST",
  4687. body: JSON.stringify({
  4688. mine_id: state.mine.id,
  4689. requested_by: "前端用户"
  4690. })
  4691. });
  4692. const result = await loadEvaluation();
  4693. renderEvaluation(result.data);
  4694. window.showToast?.(`综合评价已更新,综合得分 ${formatNumber(result.data.run.total_score)},等级 ${result.data.run.grade}`);
  4695. } catch {
  4696. renderEvaluation(DEMO_EVALUATION);
  4697. window.showToast?.("评价接口不可用,已回退到本地演示数据", "warning");
  4698. } finally {
  4699. evaluationButton.disabled = false;
  4700. evaluationButton.innerHTML = original;
  4701. window.injectIcons?.(evaluationButton);
  4702. }
  4703. },
  4704. true
  4705. );
  4706. }
  4707. }
  4708. async function init() {
  4709. if (state.initialized) return;
  4710. state.initialized = true;
  4711. updateConnectionBadge("loading");
  4712. try {
  4713. await loadMine();
  4714. const [dashboardResult, monitoringResult, documentsResult, documentStatisticsResult, templatesResult, knowledgeResult, diagnosisResult, evaluationResult, workOrdersResult] =
  4715. await Promise.all([
  4716. state.apiAvailable
  4717. ? apiLoader("dashboard", "/dashboard/overview", DEMO_DASHBOARD)
  4718. : Promise.resolve({ data: DEMO_DASHBOARD, source: "demo" }),
  4719. state.apiAvailable ? loadMonitoring() : Promise.resolve({ data: DEMO_MONITORING, source: "demo" }),
  4720. state.apiAvailable ? loadDocuments() : Promise.resolve({ data: EMPTY_DOCUMENTS, source: "demo" }),
  4721. state.apiAvailable ? loadDocumentStatistics() : Promise.resolve({ data: DEMO_DOCUMENT_STATISTICS, source: "demo" }),
  4722. state.apiAvailable ? loadBusinessTemplates() : Promise.resolve({ data: [], source: "demo" }),
  4723. state.apiAvailable ? loadKnowledge() : Promise.resolve({ data: DEMO_KNOWLEDGE, source: "demo" }),
  4724. state.apiAvailable ? loadDiagnosis() : Promise.resolve({ data: DEMO_DIAGNOSIS, source: "demo" }),
  4725. state.apiAvailable ? loadEvaluation() : Promise.resolve({ data: DEMO_EVALUATION, source: "demo" }),
  4726. state.apiAvailable ? loadWorkOrders() : Promise.resolve({ data: DEMO_WORK_ORDERS, source: "demo" })
  4727. ]);
  4728. const documents = documentsResult.data.page?.items || [];
  4729. state.templates = templatesResult.data || [];
  4730. renderDashboard(dashboardResult.data, monitoringResult.data, evaluationResult.data);
  4731. renderMonitoring(
  4732. monitoringResult.data,
  4733. documents,
  4734. documentStatisticsResult.data
  4735. );
  4736. refreshDocumentProcessingTasks().catch(() => {});
  4737. renderFacilityStatus(monitoringResult.data);
  4738. renderSafetyMonitoring(monitoringResult.data);
  4739. applyVideoDefaultRange({ reset: true });
  4740. if (state.apiAvailable) {
  4741. loadVideoPlatform(state.videoModule);
  4742. } else {
  4743. state.videoPlatform = buildVideoDemoPlatform();
  4744. renderVideoPlatform();
  4745. }
  4746. renderDocumentRows(
  4747. documents,
  4748. documentsResult.data.categories,
  4749. documentsResult.data.page?.total
  4750. );
  4751. renderDocumentStatistics(documentStatisticsResult.data);
  4752. renderKnowledge(knowledgeResult.data);
  4753. renderDiagnosis(diagnosisResult.data);
  4754. renderEvaluation(evaluationResult.data);
  4755. renderWorkOrders(workOrdersResult.data);
  4756. installKnowledgeRenderer();
  4757. installKnowledgeCategoryBrowser();
  4758. installActionInterceptors();
  4759. installBusinessUpload();
  4760. installBusinessEdit();
  4761. installFacilityActions();
  4762. installSafetyMonitoringActions();
  4763. installVideoPlatformActions();
  4764. installDocumentExport();
  4765. hydrateSafetyReadings(monitoringResult.data).catch(() => {});
  4766. if (state.apiAvailable && documentsResult.source === "api") {
  4767. hydrateDocumentDetails(documents).catch(() => {});
  4768. }
  4769. if (!state.apiAvailable) {
  4770. updateConnectionBadge("demo");
  4771. } else if (state.errors.length) {
  4772. updateConnectionBadge("mixed");
  4773. } else {
  4774. updateConnectionBadge("api");
  4775. }
  4776. } finally {
  4777. document.body.classList.remove("api-loading");
  4778. }
  4779. }
  4780. window.VentAPI = {
  4781. init,
  4782. getBusinessDocument(documentId) {
  4783. return state.documents.get(String(documentId));
  4784. },
  4785. updateBusinessDocumentField,
  4786. refreshBusinessDocument,
  4787. getDocumentPreview(documentId) {
  4788. return request(`/documents/${encodeURIComponent(documentId)}/preview`);
  4789. },
  4790. getDocumentFileUrl(documentId, download = false) {
  4791. return `${API_BASE}/documents/${encodeURIComponent(documentId)}/file${download ? "?download=1" : ""}`;
  4792. },
  4793. getDocumentProcessing,
  4794. reprocessBusinessDocument,
  4795. openProcessingRecords,
  4796. closeProcessingRecords,
  4797. openVideoPlatform,
  4798. openKnowledgeCategory,
  4799. setBusinessSearch,
  4800. setBusinessCategory,
  4801. runHazardAgent,
  4802. state
  4803. };
  4804. })();