| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338 |
- (() => {
- "use strict";
- const API_BASE = "/api/v1";
- const REQUEST_TIMEOUT_MS = 4500;
- const DEFAULT_MINE_CODE = "shangwan";
- const DEMO_MINE = {
- id: "demo-shangwan",
- code: DEFAULT_MINE_CODE,
- name: "上湾煤矿",
- short_name: "上湾煤矿"
- };
- const DEMO_DASHBOARD = {
- mine_name: "上湾煤矿",
- kpis: [
- { key: "sensors", label: "传感器总数", value: 2486, unit: "台" },
- { key: "online_rate", label: "设备在线率", value: 97.2, unit: "%" },
- { key: "alarms", label: "未闭环告警", value: 18, unit: "条" },
- { key: "high_defects", label: "高风险缺陷", value: 3, unit: "项" },
- { key: "score", label: "综合评价得分", value: 92.8, unit: "分" }
- ],
- diagnosis_summary: { high: 3, medium: 7, low: 8, total: 18 },
- evaluation_summary: { score: 92.8, grade: "A", run_no: "EV-20261008-001" },
- open_work_order_count: 7
- };
- const DEMO_DOCUMENTS = [
- {
- id: "n1202",
- type: "PDF",
- title: "N1202 工作面通风安全技术措施",
- category: "通风专业",
- code: "TF-CS-2026-086",
- owner: "王强",
- pages: "42 页",
- size: "2.8 MB",
- date: "2026-10-07",
- status: "抽取完成",
- confidence: 92.4,
- summary: "已完成工作面通风方式、需风量、瓦斯管理及局部通风参数等关键字段结构化。",
- fields: [
- ["工作面名称", "N1202 综采工作面", "98.6%"],
- ["通风方式", "U 型通风", "96.8%"],
- ["设计需风量", "1,404 m³/min", "94.2%"],
- ["瓦斯浓度上限", "0.80%", "97.1%"]
- ]
- },
- {
- id: "resistance",
- type: "DOC",
- title: "2026 年 9 月通风阻力测定报告",
- category: "通风专业",
- code: "TF-BB-2026-091",
- owner: "赵磊",
- pages: "28 页",
- size: "1.6 MB",
- date: "2026-09-30",
- status: "抽取完成",
- confidence: 90.8,
- summary: "已抽取通风阻力测点、巷道参数、风机工况和阻力变化趋势。",
- fields: [
- ["测定路线", "北翼回风系统", "95.7%"],
- ["最大阻力值", "2,860 Pa", "93.1%"],
- ["主通风机风量", "7,860 m³/min", "96.4%"],
- ["主要异常区段", "北翼回风巷", "89.6%"]
- ]
- },
- {
- id: "airlog",
- type: "XLS",
- title: "矿井测风记录日报表",
- category: "监测监控",
- code: "TF-BB-2026-092",
- owner: "刘伟",
- pages: "1,240 行",
- size: "856 KB",
- date: "2026-10-08",
- status: "抽取中 76%",
- confidence: 88.5,
- summary: "正在按测风站、用风地点和日期执行表格结构识别。",
- fields: [
- ["日报日期", "2026-10-08", "99.1%"],
- ["测风站数量", "18 个", "96.3%"],
- ["总进风量", "7,920 m³/min", "94.8%"],
- ["异常测点", "S1301 测风站", "87.6%"]
- ]
- },
- {
- id: "s1302",
- type: "PDF",
- title: "S1302 工作面综合防尘设计",
- category: "粉尘防治",
- code: "TF-SJ-2026-034",
- owner: "陈晨",
- pages: "36 页",
- size: "3.1 MB",
- date: "2026-10-05",
- status: "等待解析",
- confidence: 0,
- summary: "资料已入库,等待执行版面解析和防尘设施参数抽取。",
- fields: [
- ["工作面名称", "S1302 综采工作面", "待抽取"],
- ["防尘方式", "煤层注水 + 喷雾降尘", "待抽取"],
- ["设计负责人", "陈晨", "已识别"]
- ]
- },
- {
- id: "regulation",
- type: "PDF",
- title: "矿井通风管理制度汇编",
- category: "制度管理",
- code: "TF-GC-2026-012",
- owner: "周洋",
- pages: "118 页",
- size: "5.4 MB",
- date: "2026-09-18",
- status: "抽取完成",
- confidence: 89.6,
- summary: "已完成章节结构识别、制度条款切分和责任岗位抽取。",
- fields: [
- ["制度版本", "2026 版", "98.2%"],
- ["制度章节数", "12 章", "96.7%"],
- ["测风管理制度", "第 4 章", "94.3%"],
- ["责任岗位数", "28 个", "90.4%"]
- ]
- },
- {
- id: "acceptance",
- type: "DOC",
- title: "南翼通风系统调整验收资料",
- category: "通风专业",
- code: "TF-YS-2026-021",
- owner: "孙敏",
- pages: "22 页",
- size: "1.2 MB",
- date: "2026-09-26",
- status: "抽取完成",
- confidence: 93.1,
- summary: "已抽取调整范围、验收指标、现场测风结果和验收结论。",
- fields: [
- ["验收对象", "南翼通风系统调整工程", "98.4%"],
- ["风量验收结果", "合格", "96.8%"],
- ["设施验收数量", "36 项", "94.2%"],
- ["验收负责人", "孙敏", "97.1%"]
- ]
- }
- ];
- const DEMO_MONITORING = {
- overview: {
- data_source_count: 12,
- online_source_count: 11,
- sensor_count: 2486,
- online_sensor_count: 2417,
- facility_count: 486,
- abnormal_facility_count: 3,
- open_alarm_count: 18,
- high_alarm_count: 3
- },
- sources: [
- { id: "demo-source-gas", name: "安全监控系统", source_type: "security", status: "online", point_count: 1286, latency_ms: 2400 },
- { id: "demo-source-video", name: "工业视频监控", source_type: "video", status: "online", point_count: 218, latency_ms: 1800 },
- { id: "demo-source-facility", name: "通防设施工况", source_type: "facility", status: "online", point_count: 486, latency_ms: 3200 }
- ],
- sensors: [
- { id: "demo-gas", name: "N1202 瓦斯监测点", sensor_type: "gas", unit: "%CH₄", last_value: 0.86, status: "alarm", warning_threshold: 0.72, alarm_threshold: 0.8 },
- { id: "demo-airflow", name: "S1301 测风站", sensor_type: "airflow", unit: "m³/min", last_value: 1286, status: "warning", warning_threshold: 1300, alarm_threshold: 1200 },
- { 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 }
- ],
- facilities: [],
- alarms: [
- { id: "demo-alarm-1", title: "N1202 回风巷瓦斯浓度超限", level: "high", status: "open", value: 0.86, threshold: 0.8 }
- ]
- };
- const SAFETY_METRIC_DEFS = [
- {
- key: "gas",
- name: "甲烷浓度",
- shortName: "甲烷",
- sensorType: "gas",
- icon: "flame",
- unit: "%CH₄",
- tone: "red",
- baseline: 0.54,
- amplitude: 0.08,
- noise: 0.025,
- warningThreshold: 0.6,
- alarmThreshold: 0.8,
- thresholdMode: "above",
- pointCount: 286,
- onlineCount: 284,
- recommendations: ["复核 N1202 回风巷局部通风机运行状态", "连续跟踪瓦斯浓度变化并准备撤人断电预案"]
- },
- {
- key: "airflow",
- name: "工作面风量",
- shortName: "风量",
- sensorType: "airflow",
- icon: "wind",
- unit: "m³/min",
- tone: "blue",
- baseline: 1398,
- amplitude: 34,
- noise: 16,
- warningThreshold: 1300,
- alarmThreshold: 1200,
- thresholdMode: "below",
- pointCount: 218,
- onlineCount: 216,
- recommendations: ["核对 S1301 测风站与 N1202 工作面风量", "检查调节风窗开度和漏风状况"]
- },
- {
- key: "co",
- name: "一氧化碳",
- shortName: "CO",
- sensorType: "co",
- icon: "activity",
- unit: "ppm",
- tone: "amber",
- baseline: 11.5,
- amplitude: 2.2,
- noise: 1.1,
- warningThreshold: 18,
- alarmThreshold: 24,
- thresholdMode: "above",
- pointCount: 164,
- onlineCount: 163,
- recommendations: ["检查采空区遗煤氧化与密闭状态", "结合烟雾和温度测点进行联合研判"]
- },
- {
- key: "temperature",
- name: "环境温度",
- shortName: "温度",
- sensorType: "temperature",
- icon: "thermometer",
- unit: "℃",
- tone: "purple",
- baseline: 24.6,
- amplitude: 1.4,
- noise: 0.5,
- warningThreshold: 30,
- alarmThreshold: 35,
- thresholdMode: "above",
- pointCount: 192,
- onlineCount: 191,
- recommendations: ["持续监测回风巷温度变化", "关注高温区域设备散热和风流组织"]
- },
- {
- key: "dust",
- name: "粉尘浓度",
- shortName: "粉尘",
- sensorType: "dust",
- icon: "cloud-dust",
- unit: "mg/m³",
- tone: "teal",
- baseline: 3.15,
- amplitude: 0.36,
- noise: 0.16,
- warningThreshold: 3.5,
- alarmThreshold: 4,
- thresholdMode: "above",
- pointCount: 176,
- onlineCount: 174,
- recommendations: ["检查 S1302 喷雾降尘装置运行状态", "核对采煤机割煤期间的粉尘峰值"]
- },
- {
- key: "air_speed",
- name: "巷道风速",
- shortName: "风速",
- sensorType: "air_speed",
- icon: "gauge",
- unit: "m/s",
- tone: "green",
- baseline: 4.15,
- amplitude: 0.28,
- noise: 0.12,
- warningThreshold: 0.25,
- alarmThreshold: 0.15,
- thresholdMode: "below",
- pointCount: 250,
- onlineCount: 247,
- recommendations: ["复核低风速巷道的风量分配", "检查局部巷道变形和堆积物影响"]
- }
- ];
- const DEMO_KNOWLEDGE = [
- {
- id: "k-reg-001",
- title: "《煤矿安全规程》瓦斯超限处置条款",
- knowledge_type: "规程",
- category: "规范标准",
- summary: "瓦斯浓度超限后应立即停止作业、切断电源、撤出人员,并核查通风系统。",
- content: "瓦斯浓度超限后,应当立即停止作业、切断电源、撤出人员,并核查局部通风机、风筒和回风系统状态。处置完成后复测瓦斯浓度,形成闭环记录。"
- },
- {
- id: "k-case-026",
- title: "通风设施漏风处置案例",
- knowledge_type: "事故案例",
- category: "事故案例",
- summary: "通过风门密封检查、闭锁装置复核和风量复测完成漏风处置。",
- content: "风门漏风排查应重点检查门框密封、闭锁装置、门板变形、墙体裂隙和传感器状态,确认漏风后及时封堵并复测风量。"
- },
- {
- id: "k-expert-021",
- title: "局部通风机循环风判定经验",
- knowledge_type: "专家经验",
- category: "专家经验",
- summary: "结合吸风口瓦斯浓度、风筒距离、巷道风量和风流方向综合判定。",
- content: "判定局部通风机循环风,应比较吸风口与巷道风流瓦斯浓度,并结合风筒出口距离、巷道风量和风流方向综合判断。"
- }
- ];
- const DEMO_DIAGNOSIS = [
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" }
- ];
- const DEMO_EVALUATION = {
- run: {
- run_no: "EV-20261008-001",
- total_score: 92.8,
- grade: "A",
- summary: "安全性、可靠性表现稳定,风量分配均衡度和局部通风管理为当前主要提升方向。",
- recommendations: [
- "北翼回风阻力专项排查",
- "主通风机能效优化",
- "S1301 风量复测与分配调整"
- ],
- created_at: "2026-10-08T10:00:00+08:00"
- },
- dimensions: [
- { name: "安全性", score: 96.2, change: 1.8, note: "瓦斯、火灾、粉尘、通风可靠性" },
- { name: "可靠性", score: 91.4, change: 0.6, note: "设备可用率、数据连续性、故障恢复" },
- { name: "经济性", score: 84.7, change: -0.9, note: "主通风机能耗、漏风率、运行效率" },
- { name: "管理合规", score: 94.1, change: 2.1, note: "资料完整、制度执行、整改及时性" },
- { name: "数据质量", score: 89.6, change: 3.4, note: "完整性、及时性、一致性与有效性" }
- ]
- };
- const DEMO_WORK_ORDERS = [
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" },
- { 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" }
- ];
- const EMPTY_DOCUMENTS = {
- page: { items: [], total: 0 },
- categories: []
- };
- const DEMO_DOCUMENT_STATISTICS = {
- total_documents: 7280,
- documents_added_this_month: 126,
- extracted_fields: 12406,
- field_completeness: 96.2,
- extraction_accuracy: 85.7,
- accuracy_change: 2.1,
- pending_tasks: 18,
- estimated_minutes: 36,
- verified_fields: 0,
- edited_fields: 0,
- incorrect_fields: 0,
- expected_fields: 0,
- valid_fields: 0,
- invalid_fields: 0,
- missing_fields: 0,
- pending_judgements: 0,
- accuracy_fields: 0,
- storage_used_bytes: 0,
- storage_capacity_bytes: 1024 * 1024 * 1024 * 1024,
- storage_usage_percent: 0,
- updated_at: new Date().toISOString()
- };
- const FACILITY_CATEGORIES = [
- { key: "sensors", name: "传感器", icon: "radio-tower", keywords: ["传感器"], total: 223, alarms: 0, offline: 2 },
- { key: "main-fan", name: "主通风机", icon: "wind", keywords: ["主扇", "主通风机"], total: 2, alarms: 1, offline: 0 },
- { key: "local-fan", name: "局部通风机", icon: "wind", keywords: ["局扇", "局部通风机"], total: 2, alarms: 0, offline: 0 },
- { key: "auto-door", name: "自动风门", icon: "door-open", keywords: ["风门"], total: 5, alarms: 0, offline: 1 },
- { key: "auto-window", name: "自动风窗", icon: "settings-2", keywords: ["风窗"], total: 6, alarms: 0, offline: 1 },
- { key: "airflow-device", name: "测风装置", icon: "gauge", keywords: ["测风", "风速"], total: 80, alarms: 0, offline: 2 },
- { key: "compressor", name: "压风机", icon: "settings", keywords: ["压风"], total: 4, alarms: 0, offline: 0 },
- { key: "spray", name: "喷雾", icon: "cloud-dust", keywords: ["喷雾"], total: 22, alarms: 0, offline: 0 },
- { key: "dust", name: "喷粉", icon: "cloud-dust", keywords: ["喷粉", "粉尘"], total: 2, alarms: 0, offline: 0 },
- { key: "nitrogen", name: "制氮", icon: "cpu", keywords: ["制氮"], total: 4, alarms: 0, offline: 0 },
- { key: "grout", name: "制浆", icon: "database", keywords: ["制浆", "注浆"], total: 2, alarms: 0, offline: 0 },
- { key: "spray-equipment", name: "喷雾设备", icon: "cloud-dust", keywords: ["雾化", "净化水幕"], total: 12, alarms: 0, offline: 0 },
- { key: "gas-drainage", name: "瓦斯抽采泵", icon: "plug-zap", keywords: ["瓦斯抽采", "抽采泵"], total: 2, alarms: 0, offline: 0 }
- ];
- const VIDEO_CHANNELS = [
- { id: "v01", name: "五盘区402辅运大巷风门", group: "风门区域", location: "五盘区402辅运大巷", status: "online" },
- { id: "v02", name: "六盘区501胶带运输巷风门", group: "风门区域", location: "六盘区501胶带运输巷", status: "online" },
- { id: "v03", name: "七盘区602回风联络巷风门", group: "风门区域", location: "七盘区602回风联络巷", status: "online" },
- { id: "v04", name: "中央变电所进风通道风门", group: "风门区域", location: "中央变电所", status: "online" },
- { id: "v05", name: "采区水泵房专用通道风门", group: "风门区域", location: "采区水泵房", status: "online" },
- { id: "v06", name: "南风井绕道处风门", group: "风门区域", location: "南风井绕道", status: "online" },
- { id: "v07", name: "12煤四盘区回风入口风门", group: "风门区域", location: "12煤四盘区", status: "online" },
- { id: "v08", name: "22107胶运绕道行车风门", group: "风门区域", location: "22107胶运绕道", status: "offline" },
- { id: "v09", name: "1号掘进工作面局部风机-变频器区", group: "局扇区域", location: "1号掘进工作面", status: "online" },
- { id: "v10", name: "1号掘进工作面局部风机-配电柜区", group: "局扇区域", location: "1号掘进工作面", status: "online" },
- { id: "v11", name: "2号掘进工作面局部风机", group: "局扇区域", location: "2号掘进工作面", status: "online" },
- { id: "v12", name: "3号掘进工作面局部风机", group: "局扇区域", location: "3号掘进工作面", status: "online" },
- { id: "v13", name: "4号局部风机配电点", group: "局扇区域", location: "4号掘进工作面", status: "online" },
- { id: "v14", name: "一号主风机-风机主体", group: "主扇区域", location: "一号主通风机", status: "online" },
- { id: "v15", name: "一号主风机-配电室", group: "主扇区域", location: "一号主风机配电室", status: "online" },
- { id: "v16", name: "二号主风机-风机主体", group: "主扇区域", location: "二号主通风机", status: "online" },
- { id: "v17", name: "主扇配电室", group: "主扇区域", location: "主通风机配电室", status: "online" },
- { id: "v18", name: "主扇风硐监测点", group: "主扇区域", location: "主通风机风硐", status: "online" }
- ];
- const VIDEO_MODULES = {
- door: {
- defaultChannel: "v01",
- title: "风门视频图像综合解析系统",
- statusTitle: "风门状态总览",
- selector: "全部风门",
- typeFilterLabel: "告警类型",
- typeFilterOptions: ["全部类型", "AI防夹人预警", "异物卡堵预警", "密封失效故障", "违规停留预警", "风门未关严", "车辆撞击故障"],
- scopeFilterLabel: "风门选择",
- scopeFilterOptions: ["全部风门", "五盘区402辅运大巷风门", "六盘区501胶带运输巷风门", "七盘区602回风联络巷风门", "中央变电所进风通道风门"],
- snapshotTypes: ["车辆通行", "人员通行", "风门未关严"],
- summary: [
- ["正常风门", 3, "正常"],
- ["离线设备", 0, "断开"],
- ["设备故障", 1, "异常"],
- ["实时预警", 1, "告警"],
- ["今日车辆", 28, "辆"],
- ["今日人员", 15, "人"]
- ],
- logs: [
- ["11:42:31", "七盘区602回风联络巷风门未关严,门板闭合缝隙15mm,超标10mm", "danger"],
- ["11:42:26", "中央变电所进风通道风门违规停留,禁停区有人员逗留", "warning"],
- ["11:42:20", "中央变电所进风通道风门密封失效,压力值异常", "warning"],
- ["16:30:22", "六盘区501胶带运输巷防夹人预警,危险区域有人闯入", "danger"],
- ["16:28:15", "中央变电所进风通道风门未关严,门板闭合缝隙15mm", "warning"]
- ],
- tabs: ["车辆通行记录", "人员通行记录", "风门未关严记录"],
- columns: ["序号", "风门名称", "车牌号", "通行时间", "结果"],
- rows: [
- [1, "五盘区402辅运大巷风门", "3571", "2026-03-27 16:20:18", "通行成功"],
- [2, "六盘区501胶带运输巷风门", "6829", "2026-03-27 16:10:25", "通行成功"],
- [3, "七盘区602回风联络巷风门", "1122", "2026-03-27 15:50:33", "通行成功"],
- [4, "中央变电所进风通道风门", "3344", "2026-03-27 15:30:41", "通行成功"],
- [5, "采区水泵房专用通道风门", "5566", "2026-03-27 15:10:50", "通行成功"]
- ]
- },
- "local-fan": {
- defaultChannel: "v09",
- title: "局部风机无人值守演示系统",
- statusTitle: "风机状态总览",
- selector: "全部风机",
- typeFilterLabel: "操作类型",
- typeFilterOptions: ["全部操作", "风机调频", "风机切换", "参数调整", "复位操作"],
- scopeFilterLabel: "风机选择",
- scopeFilterOptions: ["全部风机", "1号掘进工作面局部风机", "2号掘进工作面局部风机", "3号掘进工作面局部风机", "4号局部风机配电点"],
- snapshotTypes: ["风机调频", "风机切换", "参数调整"],
- summary: [
- ["风机正常", 4, "正常"],
- ["风机离线", 0, "断开"],
- ["风机故障", 1, "异常"],
- ["实时预警", 3, "预警"],
- ["实时人数", 2, "人"],
- ["今日进入", 15, "人"]
- ],
- logs: [
- ["10:12:20", "1号风机 风机调频 张三|人员准入匹配", "success"],
- ["11:05:10", "2号风机 参数调整 李四|人员准入匹配", "success"],
- ["14:30:00", "3号风机 切换主备 未知人员|未检测到人员", "danger"]
- ],
- tabs: ["人员轨迹", "区域统计", "预警记录"],
- columns: ["序号", "风机", "区域", "姓名", "区队", "进入", "离开", "时长"],
- rows: [
- [1, "1号掘进工作面局部风机", "变频器区", "张三", "机运队", "2026-03-28 08:45", "2026-03-28 08:53", "8分20秒"],
- [2, "1号掘进工作面局部风机", "配电柜区", "李四", "维修队", "2026-03-28 09:10", "2026-03-28 09:15", "5分10秒"]
- ]
- },
- "main-fan": {
- defaultChannel: "v14",
- title: "主通风机无人巡检演示系统",
- statusTitle: "主风机运行状态",
- selector: "全部设备",
- typeFilterLabel: "操作类型",
- typeFilterOptions: ["全部操作", "启动风机", "停止风机", "参数调整", "巡检操作"],
- scopeFilterLabel: "设备选择",
- scopeFilterOptions: ["全部设备", "一号主风机", "二号主风机", "主扇配电室", "主扇风硐监测点"],
- snapshotTypes: ["人员检测", "设备操作", "设备预警"],
- summary: [
- ["运行风机", 1, "运行"],
- ["停止风机", 1, "停止"],
- ["设备故障", 0, "正常"],
- ["预警总数", 2, "预警"],
- ["人员检测", 1, "人"],
- ["今日记录", 26, "条"]
- ],
- logs: [
- ["08:50:10", "一号主风机,启动风机,PLC操作匹配", "success"],
- ["10:20:30", "配电室 参数调整 PLC操作匹配", "success"],
- ["14:10:00", "二号主风机 停止风机 未检测到人员", "danger"]
- ],
- tabs: ["人员检测记录", "设备操作日志", "设备预警记录"],
- columns: ["序号", "设备", "位置", "检测时间", "检测结果", "状态"],
- rows: [
- [1, "一号主风机", "风机主体", "2026-04-02 08:45:20", "检测到人员", "正常"],
- [2, "配电室", "设备操作区", "2026-04-02 10:18:10", "检测到人员", "正常"],
- [3, "二号主风机", "风机主体", "2026-04-02 14:05:00", "检测到人员", "异常"]
- ]
- }
- };
- const state = {
- initialized: false,
- apiAvailable: false,
- mine: DEMO_MINE,
- documents: new Map(),
- templates: [],
- documentPage: 1,
- documentPageSize: 5,
- documentCategory: "all",
- documentKeyword: "",
- documentSearchSnippets: new Map(),
- documentStatistics: DEMO_DOCUMENT_STATISTICS,
- monitoring: null,
- facilityMonitoring: null,
- safetyMetrics: [],
- safetySelectedMetric: "gas",
- safetyRange: 24,
- safetyReadings: new Map(),
- safetyLoaded: false,
- videoModule: "door",
- videoLayout: 2,
- videoRecordTab: 0,
- videoSelectedChannel: "v01",
- videoSearch: "",
- knowledgeCategory: null,
- knowledgeCategoryPage: 1,
- knowledgeCategoryPageSize: 20,
- knowledgeCategoryKeyword: "",
- knowledgeCategoryTotal: 0,
- judgementPolls: new Set(),
- errors: []
- };
- function escapeHtml(value) {
- return String(value ?? "")
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
- }
- function withMine(path) {
- if (!state.apiAvailable || !state.mine?.id) return path;
- const separator = path.includes("?") ? "&" : "?";
- return `${path}${separator}mine_id=${encodeURIComponent(state.mine.id)}`;
- }
- async function request(path, options = {}) {
- const {
- timeoutMs = REQUEST_TIMEOUT_MS,
- ...fetchOptions
- } = options;
- const controller = new AbortController();
- const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
- const isFormData =
- typeof FormData !== "undefined" && fetchOptions.body instanceof FormData;
- try {
- const response = await fetch(`${API_BASE}${path}`, {
- ...fetchOptions,
- headers: {
- Accept: "application/json",
- ...(fetchOptions.body && !isFormData ? { "Content-Type": "application/json" } : {}),
- ...(fetchOptions.headers || {})
- },
- signal: controller.signal
- });
- if (!response.ok) {
- let message = `HTTP ${response.status}`;
- let payload = null;
- try {
- payload = await response.json();
- message = payload?.message || payload?.detail || message;
- } catch {
- // Keep the HTTP status when the response is not JSON.
- }
- const error = new Error(message);
- error.status = response.status;
- error.code = payload?.code || "";
- throw error;
- }
- if (response.status === 204) return null;
- return await response.json();
- } finally {
- window.clearTimeout(timeoutId);
- }
- }
- async function withFallback(label, loader, fallback) {
- try {
- const data = await loader();
- if (
- data == null ||
- (Array.isArray(data) && data.length === 0) ||
- (Array.isArray(data?.items) && data.items.length === 0)
- ) {
- throw new Error("empty response");
- }
- return { data, source: "api" };
- } catch (error) {
- state.errors.push(`${label}: ${error?.message || error}`);
- return { data: fallback, source: "demo" };
- }
- }
- function apiLoader(label, path, fallback, options = {}) {
- return withFallback(label, () => request(withMine(path), options), fallback);
- }
- function formatNumber(value) {
- const number = Number(value);
- if (!Number.isFinite(number)) return String(value ?? "--");
- return number.toLocaleString("zh-CN", { maximumFractionDigits: 1 });
- }
- function formatBytes(value) {
- const bytes = Number(value);
- if (!Number.isFinite(bytes) || bytes < 0) return "--";
- if (bytes === 0) return "0 B";
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
- if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
- if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
- return `${(bytes / 1024 ** 4).toFixed(2)} TB`;
- }
- function formatDate(value) {
- if (!value) return "--";
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
- return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
- }
- function formatTime(value) {
- if (!value) return "--";
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return String(value).slice(11, 16) || "--";
- return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
- }
- function formatDuration(seconds) {
- const value = Number(seconds);
- if (!Number.isFinite(value) || value < 0) return "--";
- if (value < 60) {
- return `${value < 10 ? value.toFixed(1) : Math.round(value)} 秒`;
- }
- const minutes = Math.floor(value / 60);
- const remaining = Math.round(value % 60);
- return `${minutes} 分 ${remaining} 秒`;
- }
- function formatTaskStartTime(value) {
- if (!value) return "等待中";
- const raw = String(value);
- const utcValue = /(?:z|[+-]\d{2}:\d{2})$/i.test(raw) ? raw : `${raw}Z`;
- const date = new Date(utcValue);
- if (Number.isNaN(date.getTime())) return raw.slice(0, 16);
- const part = (number) => String(number).padStart(2, "0");
- return `${part(date.getMonth() + 1)}-${part(date.getDate())} ${part(date.getHours())}:${part(date.getMinutes())}`;
- }
- function setText(selector, value) {
- const element = document.querySelector(selector);
- if (element) element.textContent = value;
- }
- function setMetric(key, value, decimals = 0) {
- const element = document.querySelector(`[data-metric="${key}"]`);
- if (!element) return;
- if (typeof window.animateNumber === "function" && Number.isFinite(Number(value))) {
- window.animateNumber(element, Number(value), decimals);
- return;
- }
- element.textContent = decimals ? Number(value).toFixed(decimals) : formatNumber(value);
- }
- function kpiMap(kpis) {
- return new Map((kpis || []).map((item) => [item.key, item]));
- }
- function updateConnectionBadge(source) {
- const badge = document.querySelector(".topbar .status-pill");
- if (!badge) return;
- const labels = {
- api: "实时运行",
- demo: "演示数据",
- mixed: "部分演示",
- loading: "数据连接中"
- };
- badge.innerHTML = `<span class="live-dot"></span>${labels[source] || labels.demo}`;
- badge.dataset.source = source;
- }
- function renderDashboard(dashboard, monitoring, evaluation) {
- const kpis = kpiMap(dashboard.kpis);
- const sensors = kpis.get("sensors")?.value ?? monitoring.overview?.sensor_count ?? 0;
- const onlineRate = kpis.get("online_rate")?.value ??
- ((monitoring.overview?.online_sensor_count || 0) / Math.max(monitoring.overview?.sensor_count || 1, 1) * 100);
- const alarms = kpis.get("alarms")?.value ?? monitoring.overview?.open_alarm_count ?? 0;
- const highDefects = kpis.get("high_defects")?.value ?? dashboard.diagnosis_summary?.high ?? 0;
- const score = dashboard.evaluation_summary?.score ?? evaluation.run?.total_score ?? 0;
- const grade = String(dashboard.evaluation_summary?.grade || evaluation.run?.grade || "A").replace("级", "");
- setText("#currentMine", dashboard.mine_name || state.mine?.name || "上湾煤矿");
- setMetric("points", sensors);
- setMetric("alerts", alarms);
- setMetric("grade", grade);
- setText(".kpi-card .kpi-foot .text-success", formatNumber(score));
- const pointsCard = document.querySelector('[data-metric="points"]')?.closest(".kpi-card");
- if (pointsCard) {
- pointsCard.querySelector(".kpi-foot span")?.replaceChildren(`在线率 ${Number(onlineRate).toFixed(1)}%`);
- }
- const alertCard = document.querySelector('[data-metric="alerts"]')?.closest(".kpi-card");
- if (alertCard) {
- const high = alertCard.querySelector(".kpi-foot .text-danger");
- if (high) high.textContent = String(highDefects);
- const rate = alertCard.querySelectorAll(".kpi-foot span")[1];
- if (rate) rate.textContent = `未闭环 ${dashboard.open_work_order_count ?? 0} 项`;
- }
- const context = document.querySelector(".context-message strong");
- const contextText = document.querySelector(".context-message p");
- if (context) context.textContent = Number(highDefects) > 0 ? "系统运行总体平稳" : "系统运行安全稳定";
- if (contextText) {
- contextText.textContent = `当前识别 ${formatNumber(highDefects)} 项高优先级风险,未闭环任务 ${formatNumber(dashboard.open_work_order_count || 0)} 项。`;
- }
- const ring = document.querySelector(".dashboard-grid .score-ring");
- if (ring) {
- ring.style.setProperty("--score", score);
- const strong = ring.querySelector("strong");
- if (strong) strong.textContent = formatNumber(score);
- }
- const gradeBadge = document.querySelector(".dashboard-grid .grade-badge");
- if (gradeBadge) gradeBadge.textContent = `${grade} 级`;
- renderScoreRows(evaluation.dimensions || DEMO_EVALUATION.dimensions);
- renderSensorMetrics(monitoring.sensors);
- const updateTime = document.querySelector(".map-toolbar > span");
- if (updateTime) {
- updateTime.innerHTML = `<span class="live-dot"></span>数据更新于 ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}`;
- }
- }
- function renderScoreRows(dimensions) {
- const list = document.querySelector(".dashboard-grid .score-list");
- if (!list || !dimensions?.length) return;
- list.innerHTML = dimensions
- .slice(0, 5)
- .map((item) => {
- const score = Number(item.score || 0);
- return `
- <div class="score-row">
- <span>${escapeHtml(item.name)}</span>
- <div><i style="width: ${Math.max(0, Math.min(score, 100))}%"></i></div>
- <strong>${score.toFixed(1)}</strong>
- </div>
- `;
- })
- .join("");
- }
- function renderSensorMetrics(sensors) {
- const items = sensors?.length ? sensors : DEMO_MONITORING.sensors;
- const preferred = ["gas", "airflow", "dust", "temperature", "carbon_monoxide"];
- const ordered = preferred
- .map((type) => items.find((sensor) => sensor.sensor_type === type))
- .filter(Boolean);
- if (ordered.length < 3) ordered.push(...items.filter((sensor) => !ordered.includes(sensor)));
- document.querySelectorAll(".dashboard-grid .sensor-metric").forEach((metric, index) => {
- const sensor = ordered[index];
- if (!sensor) return;
- const strong = metric.querySelector(".sensor-value strong");
- const unit = metric.querySelector(".sensor-value small");
- const trend = metric.querySelector(".sensor-value .trend");
- if (strong) strong.textContent = formatNumber(sensor.last_value ?? sensor.value ?? "--");
- if (unit) unit.textContent = sensor.unit || "";
- if (trend) {
- trend.textContent = sensor.status === "alarm" ? "超限" : sensor.status === "warning" ? "关注" : "稳定";
- trend.classList.toggle("down", sensor.status === "alarm");
- trend.classList.toggle("up", sensor.status !== "alarm");
- }
- });
- }
- function documentStatus(documentData) {
- const parseStatus = documentData.parse_status || documentData.status || "";
- if (parseStatus === "completed" || parseStatus.includes("完成")) return { label: "抽取完成", className: "success" };
- if (parseStatus === "failed") {
- return { label: "处理失败", className: "failed" };
- }
- if (parseStatus === "parsing") {
- return { label: `解析中 ${documentData.parse_progress ?? 0}%`, className: "running" };
- }
- if (
- parseStatus === "processing" ||
- parseStatus === "extracting" ||
- parseStatus.includes("抽取中")
- ) {
- return { label: `抽取中 ${documentData.parse_progress ?? 76}%`, className: "running" };
- }
- return { label: "等待解析", className: "pending" };
- }
- function fallbackDocumentFor(item) {
- return DEMO_DOCUMENTS.find(
- (demo) => demo.code && demo.code === item.code || demo.title === item.title
- );
- }
- function normalizeDocumentType(extension) {
- const value = String(extension || "").replace(".", "").trim().toUpperCase();
- if (value.includes("PDF")) return "PDF";
- if (value.includes("DOC")) return "DOC";
- if (value.includes("XLS") || value.includes("CSV")) return "XLS";
- return value || "FILE";
- }
- function mapDocument(item) {
- const fallback = fallbackDocumentFor(item) || {};
- const status = documentStatus(item);
- const type = normalizeDocumentType(item.file_ext || fallback.type);
- const pageText = item.page_count
- ? `${formatNumber(item.page_count)} 页`
- : item.row_count
- ? `${formatNumber(item.row_count)} 行`
- : fallback.pages || (documentStatus(item).label === "抽取完成" ? "未提供页数" : "--");
- return {
- id: String(item.id),
- type,
- title: item.title || fallback.title || "未命名资料",
- category: item.category || fallback.category || "未分类",
- code: item.code || fallback.code || "--",
- owner: item.owner_name || fallback.owner || "--",
- pages: pageText,
- size: formatBytes(item.file_size) !== "--" ? formatBytes(item.file_size) : fallback.size || "--",
- date: formatDate(item.effective_date || item.created_at) || fallback.date || "--",
- status: status.label,
- statusClass: status.className,
- parseStatus: item.parse_status || "",
- parseProgress: Number(item.parse_progress || 0),
- parseError: item.parse_error || "",
- confidence: item.metadata?.confidence ?? fallback.confidence,
- summary: item.summary || fallback.summary || "",
- fields: fallback.fields || [],
- extractions: item.extractions || []
- };
- }
- function mapDocumentDetail(item) {
- const documentData = mapDocument(item);
- if (item.extractions?.length) {
- const template = state.templates.find(
- (candidate) =>
- candidate.code === item.document_type ||
- candidate.document_type === item.document_type
- );
- const templateGroups = template?.fields?.groups || [];
- const groupOrder = new Map(
- templateGroups.map((group, index) => [group.key, group.order ?? index])
- );
- const fieldOrder = new Map(
- templateGroups.flatMap((group) =>
- (group.fields || []).map((field, index) => [field.key, field.order ?? index])
- )
- );
- const grouped = new Map();
- item.extractions.forEach((field) => {
- const metadata = field.metadata || field.metadata_json || {};
- const groupName = metadata.group_name || "其他字段";
- const groupKey = metadata.group_key || groupName;
- if (groupKey === "compliance_analysis") return;
- if (!grouped.has(groupName)) {
- grouped.set(groupName, {
- key: groupKey,
- name: groupName,
- order: Number(metadata.group_order ?? groupOrder.get(groupKey) ?? 999),
- fields: []
- });
- }
- grouped.get(groupName).fields.push({
- id: String(field.id || field.field_key),
- label: field.field_name || field.field_key,
- value: field.normalized_value || field.field_value || "--",
- confidence: field.confidence == null ? "已抽取" : `${Number(field.confidence).toFixed(1)}%`,
- edited: Boolean(field.edited),
- extractionCorrect: field.extraction_correct !== false,
- editReason: field.edit_reason || "",
- editJudgementStatus: field.edit_judgement_status || "not_required",
- order: Number(metadata.field_order ?? fieldOrder.get(field.field_key) ?? 999)
- });
- });
- documentData.fieldGroups = [...grouped.values()]
- .sort((left, right) => left.order - right.order)
- .map((group) => ({
- ...group,
- fields: group.fields
- .sort((left, right) => left.order - right.order)
- }));
- documentData.fields = item.extractions.map((field) => [
- field.field_name || field.field_key,
- field.normalized_value || field.field_value || "--",
- field.confidence == null ? "已抽取" : `${Number(field.confidence).toFixed(1)}%`
- ]);
- const confidences = item.extractions
- .map((field) => Number(field.confidence))
- .filter(Number.isFinite);
- if (confidences.length) {
- documentData.confidence = confidences.reduce((sum, value) => sum + value, 0) / confidences.length;
- }
- }
- return documentData;
- }
- function currentDocumentFallback() {
- const activeId = document.querySelector(".document-row.active")?.dataset.documentId;
- return state.documents.get(activeId) || DEMO_DOCUMENTS[0];
- }
- function renderDocumentRows(items, categories, total) {
- const list = document.querySelector("#businessDocumentList");
- if (!list) return;
- const rows = [...list.querySelectorAll(".document-row")];
- const documents = items.map(mapDocument);
- state.documents.clear();
- documents.forEach((documentData, index) => {
- let row = rows[index];
- if (!row) {
- row = document.createElement("button");
- row.className = "document-row";
- row.type = "button";
- list.appendChild(row);
- rows.push(row);
- }
- row.hidden = false;
- documentData.id = String(documentData.id || `document-${index}`);
- documentData.searchSnippets =
- state.documentSearchSnippets.get(String(documentData.id)) || [];
- state.documents.set(documentData.id, documentData);
- row.dataset.documentId = documentData.id;
- row.dataset.category = documentData.category;
- row.innerHTML = `
- <span class="document-type ${documentData.type.toLowerCase()}">${escapeHtml(documentData.type)}</span>
- <span class="document-main">
- <strong>${escapeHtml(documentData.title)}</strong>
- <small>编号 ${escapeHtml(documentData.code)} · ${escapeHtml(documentData.pages)} · ${escapeHtml(documentData.owner)}</small>
- ${documentData.searchSnippets.length
- ? `<small class="document-search-snippet"><b>${escapeHtml(documentData.searchSnippets[0].label)}</b>${escapeHtml(documentData.searchSnippets[0].text)}</small>`
- : ""}
- </span>
- <span class="document-extraction ${documentData.statusClass}"><i></i>${escapeHtml(documentData.status)}</span>
- <span class="document-fields"><b>${documentData.fields.length}</b><small>字段</small></span>
- <span class="document-actions">
- <span class="document-edit" data-edit-document role="button" tabindex="0" aria-label="编辑资料" title="编辑资料">
- <span data-icon="pencil"></span>
- </span>
- <span class="document-delete" data-delete-document role="button" tabindex="0" aria-label="删除资料" title="删除资料">
- <span data-icon="trash-2"></span>
- </span>
- </span>
- `;
- if (typeof window.injectIcons === "function") window.injectIcons(row);
- const editAction = row.querySelector("[data-edit-document]");
- editAction?.addEventListener("click", (event) => {
- event.stopPropagation();
- openBusinessEditModal(documentData.id);
- });
- editAction?.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- event.stopPropagation();
- openBusinessEditModal(documentData.id);
- }
- });
- const deleteAction = row.querySelector("[data-delete-document]");
- deleteAction?.addEventListener("click", (event) => {
- event.stopPropagation();
- deleteBusinessDocument(documentData.id, documentData.title);
- });
- deleteAction?.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- event.stopPropagation();
- deleteBusinessDocument(documentData.id, documentData.title);
- }
- });
- });
- rows.slice(documents.length).forEach((row) => row.remove());
- if (!documents.length) {
- const panel = document.querySelector("#businessDetailPanel");
- if (panel) {
- panel.innerHTML = `
- <div class="business-document-note">
- <span data-icon="file-text"></span>
- <p>当前业务资料库暂无资料。</p>
- </div>
- `;
- if (typeof window.injectIcons === "function") window.injectIcons(panel);
- }
- }
- const firstRow = rows[0];
- if (documents.length && firstRow) {
- rows.forEach((row) => row.classList.toggle("active", row === firstRow));
- window.renderBusinessDocument?.(firstRow.dataset.documentId);
- }
- const filteredTotal = Number(total) || documents.length;
- const globalTotal = (categories || []).reduce(
- (sum, item) => sum + Number(item.count || 0),
- 0
- ) || filteredTotal;
- renderDocumentCategories(documents, categories, filteredTotal);
- renderDocumentTotals(filteredTotal, globalTotal);
- renderDocumentPagination(filteredTotal);
- }
- function renderDocumentCategories(documents, categories, total) {
- const buttons = [...document.querySelectorAll("#businessCategoryList button")];
- const counts = new Map(
- (categories || []).map((item) => [item.category, Number(item.count || 0)])
- );
- if (!counts.size) {
- documents.forEach((documentData) => {
- counts.set(documentData.category, (counts.get(documentData.category) || 0) + 1);
- });
- }
- buttons.forEach((button) => {
- const key = button.dataset.docCategory;
- const count = key === "all"
- ? [...counts.values()].reduce((sum, value) => sum + value, 0) || Number(total) || documents.length
- : counts.get(key) || 0;
- const badge = button.querySelector("b");
- if (badge) badge.textContent = formatNumber(count);
- });
- }
- function renderDocumentTotals(filteredTotal, globalTotal) {
- const toolbar = document.querySelector("#view-business-data .toolbar-copy p");
- if (toolbar) toolbar.textContent = `已接入 ${formatNumber(globalTotal)} 份资料,关键字段抽取结果与业务对象已建立关联`;
- const firstKpi = document.querySelector("#view-business-data .business-kpi-card strong");
- if (firstKpi) firstKpi.textContent = formatNumber(globalTotal);
- const footer = document.querySelector("#view-business-data .document-list-footer > span");
- if (footer) footer.textContent = `共 ${formatNumber(filteredTotal)} 份资料`;
- }
- function renderDocumentStatistics(statistics) {
- const data = statistics || DEMO_DOCUMENT_STATISTICS;
- state.documentStatistics = data;
- const setKpi = (key, value) => {
- const element = document.querySelector(`[data-business-kpi="${key}"]`);
- if (element) element.textContent = value;
- };
- const setHint = (key, value) => {
- const element = document.querySelector(`[data-business-kpi-hint="${key}"]`);
- if (element) element.textContent = value;
- };
- setKpi("total", formatNumber(data.total_documents));
- setHint("total", `本月新增 ${formatNumber(data.documents_added_this_month)} 份`);
- setKpi("fields", formatNumber(data.extracted_fields));
- setHint("fields", `字段完整率 ${Number(data.field_completeness || 0).toFixed(1)}%`);
- setKpi("accuracy", `${Number(data.extraction_accuracy || 0).toFixed(1)}%`);
- const accuracyChange = Number(data.accuracy_change || 0);
- const pendingJudgements = Number(data.pending_judgements || 0);
- setHint(
- "accuracy",
- pendingJudgements
- ? `待判定 ${formatNumber(pendingJudgements)} 项`
- : Number(data.edited_fields)
- ? `已编辑 ${formatNumber(data.edited_fields)} 项,抽取错误 ${formatNumber(data.incorrect_fields)} 项`
- : accuracyChange
- ? `较上月${accuracyChange > 0 ? "提升" : "下降"} ${Math.abs(accuracyChange).toFixed(1)}%`
- : "暂无上月对比数据"
- );
- setKpi("pending", formatNumber(data.pending_tasks));
- setHint(
- "pending",
- Number(data.pending_tasks)
- ? `预计 ${formatNumber(data.estimated_minutes)} 分钟完成`
- : "当前无待处理任务"
- );
- const toolbar = document.querySelector("#view-business-data .toolbar-copy p");
- if (toolbar) {
- toolbar.textContent = `已接入 ${formatNumber(data.total_documents)} 份资料,字段抽取平均准确率 ${Number(data.extraction_accuracy || 0).toFixed(1)}%`;
- }
- const storagePercent = Number(data.storage_usage_percent || 0);
- const storagePercentElement = document.querySelector("[data-storage-percent]");
- const storageBar = document.querySelector("[data-storage-bar]");
- const storageUsage = document.querySelector("[data-storage-usage]");
- if (storagePercentElement) {
- storagePercentElement.textContent = `${storagePercent.toFixed(1)}%`;
- }
- if (storageBar) {
- storageBar.style.width = `${Math.max(0, Math.min(storagePercent, 100))}%`;
- }
- if (storageUsage) {
- storageUsage.textContent = `已使用 ${formatBytes(data.storage_used_bytes)} / ${formatBytes(data.storage_capacity_bytes)}`;
- }
- const businessCard = document.querySelector(
- "#view-data [data-open-business]"
- );
- if (businessCard) {
- const status = businessCard.querySelector(".source-status");
- const footValues = businessCard.querySelectorAll(".source-foot span");
- const pendingTasks = Number(data.pending_tasks || 0);
- if (status) {
- status.className = `source-status ${pendingTasks ? "sync" : "online"}`;
- status.textContent = pendingTasks ? "同步中" : "已同步";
- }
- if (footValues[0]) {
- footValues[0].textContent = `${formatNumber(data.total_documents)} 份`;
- }
- if (footValues[1]) {
- footValues[1].textContent = pendingTasks
- ? `待处理 ${formatNumber(pendingTasks)} 项`
- : `已抽取 ${formatNumber(data.extracted_fields)} 字段`;
- }
- }
- }
- function renderDocumentPagination(total) {
- const footer = document.querySelector("#view-business-data .document-list-footer");
- if (!footer) return;
- const pageCount = Math.max(1, Math.ceil((Number(total) || 0) / state.documentPageSize));
- const currentPage = Math.min(Math.max(state.documentPage, 1), pageCount);
- state.documentPage = currentPage;
- const pageButtons = Array.from({ length: pageCount }, (_, index) => {
- const page = index + 1;
- return `<button class="${page === currentPage ? "active" : ""}" type="button" data-document-page="${page}">${page}</button>`;
- }).join("");
- footer.innerHTML = `
- <span>共 ${formatNumber(total)} 份资料</span>
- <div>
- <button type="button" data-document-page="${currentPage - 1}" ${currentPage <= 1 ? "disabled" : ""}>上一页</button>
- ${pageButtons}
- <button type="button" data-document-page="${currentPage + 1}" ${currentPage >= pageCount ? "disabled" : ""}>下一页</button>
- </div>
- `;
- footer.querySelectorAll("[data-document-page]").forEach((button) => {
- button.addEventListener("click", () => {
- const page = Number(button.dataset.documentPage);
- if (!Number.isFinite(page) || page < 1 || page > pageCount || page === currentPage) return;
- refreshDocumentsFromApi(page).catch(() => {
- window.showToast?.("资料分页加载失败", "warning");
- });
- });
- });
- }
- async function hydrateDocumentDetails(items) {
- const results = await Promise.all(
- items.slice(0, 6).map(async (item) => {
- try {
- return await request(`/documents/${encodeURIComponent(item.id)}`);
- } catch {
- return null;
- }
- })
- );
- results.filter(Boolean).forEach((item) => {
- state.documents.set(String(item.id), mapDocumentDetail(item));
- });
- state.documents.forEach((documentData, documentId) => {
- const row = findDocumentRow(documentId);
- const fieldCount = row?.querySelector(".document-fields b");
- if (fieldCount) fieldCount.textContent = String(documentData.fields.length);
- });
- const activeId = document.querySelector(".document-row.active")?.dataset.documentId;
- if (activeId && state.documents.has(String(activeId))) {
- window.renderBusinessDocument?.(activeId);
- }
- }
- async function deleteBusinessDocument(documentId, title) {
- const confirmed = window.confirm(`确定删除“${title || "该资料"}”吗?删除后无法恢复。`);
- if (!confirmed) return;
- try {
- await request(`/documents/${encodeURIComponent(documentId)}`, {
- method: "DELETE"
- });
- state.documents.delete(String(documentId));
- const nextPage = document.querySelectorAll("#businessDocumentList .document-row").length <= 1 && state.documentPage > 1
- ? state.documentPage - 1
- : state.documentPage;
- await refreshDocumentsFromApi(nextPage);
- if (!document.querySelector("#businessDocumentList .document-row")) {
- const panel = document.querySelector("#businessDetailPanel");
- if (panel) {
- panel.innerHTML = `
- <div class="business-document-note">
- <span data-icon="file-text"></span>
- <p>当前业务资料库暂无资料。</p>
- </div>
- `;
- window.injectIcons?.(panel);
- }
- }
- window.showToast?.("资料已删除");
- } catch (error) {
- window.showToast?.(`删除失败:${error?.message || error}`, "warning");
- }
- }
- function openBusinessEditModal(documentId) {
- const documentData = state.documents.get(String(documentId));
- const modal = document.querySelector("#businessEditModal");
- const form = document.querySelector("#businessEditForm");
- if (!documentData || !modal || !form) return;
- form.elements.document_id.value = documentData.id;
- form.elements.title.value = documentData.title;
- form.elements.code.value = documentData.code === "--" ? "" : documentData.code;
- form.elements.category.value = documentData.category;
- modal.hidden = false;
- window.setTimeout(() => form.elements.title.focus(), 0);
- }
- function closeBusinessEditModal() {
- const modal = document.querySelector("#businessEditModal");
- if (modal) modal.hidden = true;
- }
- function installBusinessEdit() {
- const modal = document.querySelector("#businessEditModal");
- const form = document.querySelector("#businessEditForm");
- if (!modal || !form || form.dataset.apiBound) return;
- form.dataset.apiBound = "true";
- modal.querySelectorAll("[data-edit-close]").forEach((button) => {
- button.addEventListener("click", closeBusinessEditModal);
- });
- form.addEventListener("submit", async (event) => {
- event.preventDefault();
- const submit = form.querySelector('[type="submit"]');
- const documentId = form.elements.document_id.value;
- if (!documentId) return;
- if (submit) submit.disabled = true;
- try {
- const previousCategory = state.documents.get(String(documentId))?.category;
- await request(`/documents/${encodeURIComponent(documentId)}`, {
- method: "PATCH",
- body: JSON.stringify({
- title: form.elements.title.value.trim(),
- code: form.elements.code.value.trim() || null,
- category: form.elements.category.value
- })
- });
- closeBusinessEditModal();
- const newCategory = form.elements.category.value;
- if (newCategory !== previousCategory) {
- await setBusinessCategory(newCategory);
- } else {
- await refreshDocumentsFromApi();
- }
- window.showToast?.("资料信息已更新");
- } catch (error) {
- window.showToast?.(`保存失败:${error?.message || error}`, "warning");
- } finally {
- if (submit) submit.disabled = false;
- }
- });
- }
- async function updateBusinessDocumentField(documentId, extractionId, value) {
- const updated = await request(
- `/documents/${encodeURIComponent(documentId)}/extractions/${encodeURIComponent(extractionId)}`,
- {
- method: "PATCH",
- body: JSON.stringify({
- field_value: value,
- normalized_value: value,
- verified: true,
- verified_by: "前端用户"
- })
- }
- );
- const documentData = state.documents.get(String(documentId));
- if (documentData?.fieldGroups) {
- documentData.fieldGroups.forEach((group) => {
- group.fields.forEach((field) => {
- if (String(field.id) === String(extractionId)) {
- field.value = updated.normalized_value || updated.field_value || value;
- field.edited = Boolean(updated.edited);
- field.extractionCorrect = updated.extraction_correct !== false;
- field.editReason = updated.edit_reason || "";
- field.editJudgementStatus = updated.edit_judgement_status || "not_required";
- }
- });
- });
- }
- if (updated.edit_judgement_status === "pending") {
- pollExtractionJudgement(documentId, extractionId).catch(() => {});
- }
- return updated;
- }
- async function pollExtractionJudgement(documentId, extractionId) {
- const key = `${documentId}:${extractionId}`;
- if (state.judgementPolls.has(key)) return;
- state.judgementPolls.add(key);
- try {
- for (let attempt = 0; attempt < 20; attempt += 1) {
- await new Promise((resolve) =>
- window.setTimeout(resolve, attempt < 3 ? 700 : 1500)
- );
- const detail = await request(`/documents/${encodeURIComponent(documentId)}`);
- state.documents.set(String(documentId), mapDocumentDetail(detail));
- window.renderBusinessDocument?.(String(documentId));
- const field = (detail.extractions || []).find(
- (item) => String(item.id) === String(extractionId)
- );
- if (!field || field.edit_judgement_status !== "pending") {
- const statisticsResult = await loadDocumentStatistics();
- renderDocumentStatistics(statisticsResult.data);
- if (field?.edit_judgement_status === "completed") {
- window.showToast?.(
- field.extraction_correct === false
- ? "字段判定完成:属于抽取错误"
- : "字段判定完成:不属于抽取错误"
- );
- }
- return;
- }
- }
- } finally {
- state.judgementPolls.delete(key);
- }
- }
- async function refreshBusinessDocument(documentId) {
- const [detail, statistics] = await Promise.all([
- request(`/documents/${encodeURIComponent(documentId)}`),
- loadDocumentStatistics()
- ]);
- state.documents.set(String(documentId), mapDocumentDetail(detail));
- renderDocumentStatistics(statistics.data);
- window.renderBusinessDocument?.(String(documentId));
- }
- function setUploadProgress(stage, percent) {
- const progress = document.querySelector("#businessUploadProgress");
- const stageLabel = document.querySelector("#businessUploadStage");
- const percentLabel = document.querySelector("#businessUploadPercent");
- const bar = document.querySelector("#businessUploadProgressBar");
- if (progress) progress.hidden = false;
- if (stageLabel) stageLabel.textContent = stage;
- if (percentLabel) percentLabel.textContent = `${Math.max(0, Math.min(percent, 100))}%`;
- if (bar) bar.style.width = `${Math.max(0, Math.min(percent, 100))}%`;
- }
- function resetUploadProgress() {
- const progress = document.querySelector("#businessUploadProgress");
- const stageLabel = document.querySelector("#businessUploadStage");
- const percentLabel = document.querySelector("#businessUploadPercent");
- const bar = document.querySelector("#businessUploadProgressBar");
- if (progress) progress.hidden = true;
- if (stageLabel) stageLabel.textContent = "正在上传";
- if (percentLabel) percentLabel.textContent = "0%";
- if (bar) bar.style.width = "0%";
- }
- function clearTemplateSelectionError() {
- const select = document.querySelector("#businessTemplateSelect");
- const error = document.querySelector("#businessTemplateError");
- if (select) select.classList.remove("invalid");
- if (error) error.hidden = true;
- }
- function showTemplateSelectionError() {
- const select = document.querySelector("#businessTemplateSelect");
- const error = document.querySelector("#businessTemplateError");
- if (select) {
- select.classList.add("invalid");
- select.focus();
- }
- if (error) error.hidden = false;
- }
- function openUploadModal() {
- const modal = document.querySelector("#businessUploadModal");
- if (!modal) return;
- modal.hidden = false;
- resetUploadProgress();
- clearTemplateSelectionError();
- window.setTimeout(() => modal.querySelector('select[name="category"]')?.focus(), 0);
- }
- function closeUploadModal() {
- const modal = document.querySelector("#businessUploadModal");
- if (modal) modal.hidden = true;
- }
- function resetUploadFileState(form) {
- const fileField = form?.querySelector(".upload-file-field");
- const fileInput = form?.querySelector('input[type="file"]');
- const fileTitle = document.querySelector("#businessFileTitle");
- const fileDetail = document.querySelector("#businessFileDetail");
- if (fileField) fileField.classList.remove("has-file");
- if (fileInput) fileInput.value = "";
- if (fileTitle) fileTitle.textContent = "选择 Word、PDF 或 Excel 文件";
- if (fileDetail) {
- fileDetail.textContent = "支持 .doc .docx .pdf .xls .xlsx .csv,单个文件不超过 50 MB";
- }
- }
- function updateUploadFileState(fileInput) {
- const file = fileInput?.files?.[0];
- const fileField = fileInput?.closest(".upload-file-field");
- const fileTitle = document.querySelector("#businessFileTitle");
- const fileDetail = document.querySelector("#businessFileDetail");
- if (!file) {
- resetUploadFileState(fileInput?.form);
- return;
- }
- fileField?.classList.add("has-file");
- if (fileTitle) fileTitle.textContent = file.name;
- const extension = `.${file.name.split(".").pop()?.toLowerCase() || ""}`;
- const lightweight = fileInput.form?.querySelector('input[name="parse_mode"][value="lightweight"]');
- const precision = fileInput.form?.querySelector('input[name="parse_mode"][value="precision"]');
- if (extension === ".doc" || extension === ".xls") {
- if (lightweight) lightweight.checked = false;
- if (precision) precision.checked = true;
- }
- if (fileDetail) {
- fileDetail.textContent = extension === ".doc" || extension === ".xls"
- ? `已选择 · ${formatBytes(file.size)} · 旧版格式将使用精准解析`
- : `已选择 · ${formatBytes(file.size)} · 点击可重新选择`;
- }
- }
- function findDocumentRow(documentId) {
- return [...document.querySelectorAll("#businessDocumentList .document-row")].find(
- (row) => row.dataset.documentId === String(documentId)
- );
- }
- function updateDocumentProcessingRow(documentId, run) {
- const row = findDocumentRow(documentId);
- if (!row) return;
- const status = documentStatus({
- parse_status: run.stage || run.status,
- parse_progress: run.progress
- });
- const extraction = row.querySelector(".document-extraction");
- if (extraction) {
- extraction.className = `document-extraction ${status.className}`;
- extraction.innerHTML = `<i></i>${escapeHtml(status.label)}`;
- }
- const documentData = state.documents.get(String(documentId));
- if (documentData) {
- documentData.status = status.label;
- documentData.statusClass = status.className;
- documentData.parseStatus = run.stage || run.status;
- documentData.parseProgress = Number(run.progress || 0);
- documentData.parseError = run.error || "";
- }
- if (row.classList.contains("active")) {
- const detailStatus = document.querySelector("#businessDetailPanel .business-detail-status");
- if (detailStatus) {
- detailStatus.className = `business-detail-status ${status.className}`;
- detailStatus.textContent = status.label;
- }
- }
- }
- async function refreshDocumentsFromApi(page = state.documentPage) {
- state.documentPage = page;
- const [result, statisticsResult] = await Promise.all([
- loadDocuments(),
- loadDocumentStatistics()
- ]);
- const documents = result.data.page?.items || [];
- renderDocumentRows(
- documents,
- result.data.categories,
- result.data.page?.total
- );
- renderDocumentStatistics(statisticsResult.data);
- if (result.source === "api" && documents.length) {
- hydrateDocumentDetails(documents).catch(() => {});
- }
- return documents;
- }
- async function setBusinessCategory(category) {
- state.documentCategory = category || "all";
- state.documentPage = 1;
- document.querySelectorAll("#businessCategoryList button").forEach((button) => {
- button.classList.toggle("active", button.dataset.docCategory === state.documentCategory);
- });
- window.dispatchEvent(
- new CustomEvent("vent:business-category-changed", {
- detail: { category: state.documentCategory }
- })
- );
- try {
- await refreshDocumentsFromApi(1);
- } catch {
- window.showToast?.("资料分类加载失败", "warning");
- }
- }
- async function setBusinessSearch(keyword) {
- state.documentKeyword = String(keyword || "").trim();
- state.documentPage = 1;
- try {
- await refreshDocumentsFromApi(1);
- } catch {
- window.showToast?.("全文检索失败", "warning");
- }
- }
- async function getDocumentProcessing(documentId) {
- try {
- return await request(
- `/documents/${encodeURIComponent(documentId)}/processing`
- );
- } catch (error) {
- if (String(error?.message || "").includes("404")) return null;
- throw error;
- }
- }
- async function reprocessBusinessDocument(documentId, options = {}) {
- const run = await request(`/documents/${encodeURIComponent(documentId)}/process`, {
- method: "POST",
- body: JSON.stringify({
- force: true,
- parse_mode: options.parseMode || "lightweight",
- template_id: options.templateId || null
- })
- });
- updateDocumentProcessingRow(documentId, run);
- refreshDocumentProcessingTasks().catch(() => {});
- pollDocumentProcessing(documentId).catch(() => {});
- return run;
- }
- async function pollDocumentProcessing(documentId) {
- for (let attempt = 0; attempt < 1200; attempt += 1) {
- await new Promise((resolve) => window.setTimeout(resolve, attempt < 3 ? 800 : 1500));
- try {
- const run = await request(
- `/documents/${encodeURIComponent(documentId)}/processing`
- );
- updateDocumentProcessingRow(documentId, run);
- setUploadProgress(
- run.stage === "parsing"
- ? "正在解析文档"
- : run.stage === "extracting"
- ? "正在抽取关键信息"
- : run.stage === "completed"
- ? "处理完成"
- : "正在处理",
- run.progress || 0
- );
- if (run.status === "completed") {
- await refreshDocumentsFromApi();
- await refreshDocumentProcessingTasks();
- const row = findDocumentRow(documentId);
- row?.click();
- window.showToast?.(
- `资料解析完成,共提取 ${run.model_output?.field_count || 0} 个字段`
- );
- return run;
- }
- if (run.status === "failed") {
- await refreshDocumentsFromApi();
- await refreshDocumentProcessingTasks();
- window.showToast?.(run.error || "资料解析失败", "warning");
- return run;
- }
- } catch {
- // The upload may still be committing; keep polling briefly.
- }
- }
- window.showToast?.("资料处理时间较长,请稍后刷新查看结果", "warning");
- return null;
- }
- function installBusinessUpload() {
- const modal = document.querySelector("#businessUploadModal");
- const form = document.querySelector("#businessUploadForm");
- if (!modal || !form || form.dataset.apiBound) return;
- form.dataset.apiBound = "true";
- const templateSelect = document.querySelector("#businessTemplateSelect");
- const fileInput = form.querySelector('input[type="file"]');
- if (templateSelect) {
- templateSelect.innerHTML = state.templates.length
- ? '<option value="">自动识别(根据文件名)</option>' + state.templates
- .map(
- (template) =>
- `<option value="${escapeHtml(template.id)}">${escapeHtml(template.name)}</option>`
- )
- .join("")
- : '<option value="">暂无可用抽取模板</option>';
- }
- document.querySelectorAll("[data-open-upload]").forEach((button) => {
- button.addEventListener("click", openUploadModal);
- });
- modal.querySelectorAll("[data-upload-close]").forEach((button) => {
- button.addEventListener("click", () => {
- resetUploadFileState(form);
- clearTemplateSelectionError();
- resetUploadProgress();
- closeUploadModal();
- });
- });
- fileInput?.addEventListener("change", () => {
- clearTemplateSelectionError();
- updateUploadFileState(fileInput);
- });
- templateSelect?.addEventListener("change", clearTemplateSelectionError);
- form.addEventListener("submit", async (event) => {
- event.preventDefault();
- if (!state.apiAvailable) {
- window.showToast?.("后端不可用,暂时无法上传资料", "warning");
- return;
- }
- const submit = form.querySelector('[type="submit"]');
- const formData = new FormData(form);
- formData.set("mine_id", state.mine.id);
- if (submit) submit.disabled = true;
- setUploadProgress("正在上传文件", 3);
- try {
- const documentData = await request("/documents/upload", {
- method: "POST",
- body: formData,
- timeoutMs: 120000
- });
- setUploadProgress("文件已上传,等待解析", 20);
- window.showToast?.("资料上传成功,已进入解析队列");
- clearTemplateSelectionError();
- resetUploadProgress();
- closeUploadModal();
- form.reset();
- resetUploadFileState(form);
- await refreshDocumentsFromApi(1);
- refreshDocumentProcessingTasks().catch(() => {});
- pollDocumentProcessing(documentData.id).catch(() => {});
- } catch (error) {
- resetUploadProgress();
- if (error?.code === "extraction_template_required") {
- showTemplateSelectionError();
- window.showToast?.(
- "文件名未匹配到抽取模板,请手动选择业务资料类型",
- "warning"
- );
- } else {
- window.showToast?.(`上传失败:${error?.message || error}`, "warning");
- }
- } finally {
- if (submit) submit.disabled = false;
- }
- });
- }
- function sourceVisual(source) {
- const type = String(source.source_type || "").toLowerCase();
- if (type.includes("video")) return { icon: "video", className: "blue", description: "视频监控与智能识别" };
- if (type.includes("facility")) return { icon: "settings-2", className: "amber", description: "风门、风窗、密闭和局部通风机" };
- if (type.includes("document")) return { icon: "file-text", className: "purple", description: "规程、措施、报表与台账" };
- return { icon: "database", className: "teal", description: "瓦斯、风速、温度、CO 等监测点" };
- }
- function processingTaskStatus(task) {
- const status = String(task.status || "pending");
- const stage = String(task.stage || status);
- if (status === "completed" || stage === "completed") {
- return { label: "已完成", className: "success" };
- }
- if (status === "failed" || stage === "failed") {
- return { label: "处理失败", className: "failed" };
- }
- if (["running", "parsing", "extracting"].includes(status) || ["parsing", "extracting"].includes(stage)) {
- const stageLabel = stage === "parsing" ? "解析中" : stage === "extracting" ? "抽取中" : "处理中";
- return { label: `${stageLabel} ${Number(task.progress || 0)}%`, className: "running" };
- }
- return { label: "等待中", className: "pending" };
- }
- function processingTaskDescription(task) {
- const finished = task.status === "completed" || task.stage === "completed" ||
- task.status === "failed" || task.stage === "failed";
- const elapsed = Number.isFinite(Number(task.processing_seconds))
- ? ` · ${finished ? "处理耗时" : "已用时"} ${formatDuration(task.processing_seconds)}`
- : "";
- const startText = `开始时间 ${formatTaskStartTime(task.started_at)}`;
- if (task.status === "failed" || task.stage === "failed") {
- return `${task.error || "资料处理失败,等待重试"} · ${startText}${elapsed}`;
- }
- if (task.status === "completed" || task.stage === "completed") {
- const source = task.page_count
- ? `识别 ${formatNumber(task.page_count)} 页`
- : task.row_count
- ? `识别 ${formatNumber(task.row_count)} 行`
- : "识别完成";
- return `${source} · 抽取 ${formatNumber(task.field_count)} 个关键字段 · ${startText}${elapsed}`;
- }
- if (task.stage === "parsing") return `正在解析文档版面与表格结构 · ${startText}${elapsed}`;
- if (task.stage === "extracting") return `正在调用模型抽取关键字段 · ${startText}${elapsed}`;
- return `等待进入解析队列 · ${startText}${elapsed}`;
- }
- function renderDocumentProcessingTasks(
- tasks,
- targetSelector = "#documentProcessingTaskList",
- limit = 4
- ) {
- const list = document.querySelector(targetSelector);
- if (!list) return;
- if (!tasks?.length) {
- list.innerHTML = `
- <div class="business-document-note">
- <span data-icon="${state.apiAvailable ? "check-circle-2" : "circle-alert"}"></span>
- <p>${state.apiAvailable ? "当前没有资料处理任务。" : "处理任务加载失败,请检查后端服务。"}</p>
- </div>
- `;
- window.injectIcons?.(list);
- return;
- }
- list.innerHTML = tasks
- .slice(0, limit)
- .map((task) => {
- const status = processingTaskStatus(task);
- const type = normalizeDocumentType(task.file_ext);
- return `
- <div class="extraction-row">
- <span class="file-type ${type.toLowerCase()}">${escapeHtml(type)}</span>
- <div>
- <strong>《${escapeHtml(task.title)}》</strong>
- <p>${escapeHtml(processingTaskDescription(task))}</p>
- </div>
- <span class="extraction-status ${status.className}">${escapeHtml(status.label)}</span>
- </div>
- `;
- })
- .join("");
- }
- async function refreshDocumentProcessingTasks() {
- try {
- const tasks = await request(withMine("/documents/processing-tasks?limit=4"));
- renderDocumentProcessingTasks(tasks || [], "#documentProcessingTaskList", 4);
- return tasks || [];
- } catch {
- const list = document.querySelector("#documentProcessingTaskList");
- if (list) {
- list.innerHTML = `
- <div class="business-document-note">
- <span data-icon="circle-alert"></span>
- <p>处理任务加载失败,请稍后重试。</p>
- </div>
- `;
- window.injectIcons?.(list);
- }
- return [];
- }
- }
- async function openProcessingRecords() {
- const modal = document.querySelector("#documentProcessingRecordsModal");
- if (!modal) return;
- modal.hidden = false;
- document.body.style.overflow = "hidden";
- const list = document.querySelector("#documentProcessingRecordsList");
- const count = document.querySelector("#documentProcessingRecordsCount");
- if (list) {
- list.innerHTML = `
- <div class="business-document-note">
- <span data-icon="refresh-cw"></span>
- <p>正在加载处理记录。</p>
- </div>
- `;
- window.injectIcons?.(list);
- }
- try {
- const tasks = await request(withMine("/documents/processing-tasks?limit=50"));
- if (count) count.textContent = `${formatNumber((tasks || []).length)} 条记录`;
- renderDocumentProcessingTasks(
- tasks || [],
- "#documentProcessingRecordsList",
- 50
- );
- } catch {
- if (list) {
- list.innerHTML = `
- <div class="business-document-note">
- <span data-icon="circle-alert"></span>
- <p>处理记录加载失败,请稍后重试。</p>
- </div>
- `;
- window.injectIcons?.(list);
- }
- }
- }
- function closeProcessingRecords() {
- const modal = document.querySelector("#documentProcessingRecordsModal");
- if (modal) modal.hidden = true;
- document.body.style.overflow = "";
- }
- function renderMonitoring(monitoring, documents, documentStatistics = null) {
- state.monitoring = monitoring;
- const documentTotal = Number(
- documentStatistics?.total_documents ?? documents.length
- );
- const extractedFieldTotal = Number(
- documentStatistics?.extracted_fields ?? 0
- );
- const overview = monitoring.overview || DEMO_MONITORING.overview;
- const sources = monitoring.sources?.length
- ? monitoring.sources.slice()
- : DEMO_MONITORING.sources.slice();
- const hasFacilitySource = sources.some((source) =>
- String(source.source_type || "").toLowerCase().includes("facility")
- );
- if (!hasFacilitySource) {
- sources.push({
- id: "fallback-facilities",
- name: "通防设施工况",
- source_type: "facility",
- status: "online",
- point_count: overview.facility_count || DEMO_MONITORING.overview.facility_count,
- latency_ms: 2400
- });
- }
- const sourceGrid = document.querySelector("#view-data .source-grid");
- if (sourceGrid) {
- const cards = sources.slice(0, 3).map((source) => {
- const visual = sourceVisual(source);
- const statusText = source.status === "online" ? "在线" : "异常";
- const isVideoPlatform =
- String(source.source_type || "").toLowerCase().includes("video") ||
- String(source.name || "").includes("视频");
- const isFacilityStatus =
- String(source.source_type || "").toLowerCase().includes("facility") ||
- String(source.name || "").includes("通防设施");
- const isSafetyMonitoring =
- String(source.source_type || "").toLowerCase().includes("monitoring") ||
- String(source.source_type || "").toLowerCase().includes("security") ||
- String(source.name || "").includes("安全监控");
- const pointUnit = String(source.source_type || "").toLowerCase().includes("facility")
- ? "台"
- : "点";
- const tag = isVideoPlatform || isFacilityStatus || isSafetyMonitoring ? "button" : "article";
- const attrs = isVideoPlatform
- ? 'type="button" data-video-platform aria-label="打开工业视频平台"'
- : isFacilityStatus
- ? 'type="button" data-facility-status aria-label="打开通防设施工况"'
- : isSafetyMonitoring
- ? 'type="button" data-safety-monitoring aria-label="打开 KJ 安全监控系统"'
- : "";
- const className = isVideoPlatform || isFacilityStatus || isSafetyMonitoring
- ? "source-card source-card-button"
- : "source-card";
- return `
- <${tag} class="${className}" ${attrs}>
- <div class="source-head"><span class="source-icon ${visual.className}" data-icon="${visual.icon}"></span><span class="source-status online">${statusText}</span></div>
- <strong>${escapeHtml(source.name)}</strong>
- <p>${escapeHtml(visual.description)}</p>
- <div class="source-foot"><span>${formatNumber(source.point_count)} ${pointUnit}</span><span>延迟 ${(Number(source.latency_ms || 0) / 1000).toFixed(1)}s</span></div>
- </${tag}>
- `;
- });
- cards.push(`
- <button class="source-card source-card-button" type="button" data-open-business aria-label="打开业务资料页面">
- <div class="source-head"><span class="source-icon purple" data-icon="file-text"></span><span class="source-status online">已同步</span></div>
- <strong>业务资料</strong>
- <p>规程、措施、报表、台账与验收资料</p>
- <div class="source-foot"><span>${formatNumber(documentTotal)} 份</span><span>已抽取 ${formatNumber(extractedFieldTotal)} 字段</span></div>
- </button>
- `);
- sourceGrid.innerHTML = cards.join("");
- if (typeof window.injectIcons === "function") window.injectIcons(sourceGrid);
- sourceGrid.querySelectorAll("[data-video-platform]").forEach((button) => {
- button.addEventListener("click", () => openVideoPlatform());
- });
- sourceGrid.querySelector("[data-safety-monitoring]")?.addEventListener("click", () => openSafetyMonitoring());
- sourceGrid.querySelector("[data-facility-status]")?.addEventListener("click", () => window.navigateTo?.("facility-status"));
- sourceGrid.querySelector("[data-open-business]")?.addEventListener("click", () => window.navigateTo?.("business-data"));
- }
- const toolbar = document.querySelector("#view-data .toolbar-copy p");
- if (toolbar) {
- toolbar.textContent = `当前接入 ${formatNumber(overview.data_source_count)} 类数据源,在线 ${formatNumber(overview.online_source_count)} 类,告警 ${formatNumber(overview.open_alarm_count)} 条`;
- }
- const objectValues = {
- sensors: overview.sensor_count,
- facilities: overview.facility_count
- };
- document.querySelectorAll("#view-data .object-card").forEach((card) => {
- const label = card.querySelector("strong")?.textContent?.trim();
- if (label === "传感器") card.querySelector("b").textContent = formatNumber(objectValues.sensors);
- if (label === "通风设施") card.querySelector("b").textContent = formatNumber(objectValues.facilities);
- });
- }
- function buildFacilityCategories(monitoring) {
- const sensors = monitoring.sensors || [];
- const facilities = monitoring.facilities || [];
- const overview = monitoring.overview || {};
- return FACILITY_CATEGORIES.map((category) => {
- let actualTotal = 0;
- let actualAlarms = 0;
- let actualOffline = 0;
- let records = [];
- if (category.key === "sensors") {
- actualTotal = Number(overview.sensor_count || sensors.length || 0);
- actualAlarms = sensors.filter((item) => item.status === "alarm").length;
- actualOffline = sensors.filter((item) => item.status === "offline").length;
- records = sensors.map((item) => ({
- name: item.name,
- location: item.location || "未配置地点",
- status: item.status || "normal"
- }));
- } else {
- records = facilities
- .filter((facility) =>
- category.keywords.some((keyword) =>
- String(facility.facility_type || "").includes(keyword)
- )
- )
- .map((facility) => ({
- name: facility.name,
- location: facility.location || "未配置地点",
- status: facility.status || "normal"
- }));
- actualTotal = records.length;
- actualAlarms = records.filter((item) => item.status === "warning" || item.status === "alarm").length;
- actualOffline = records.filter((item) => item.status === "offline").length;
- }
- return {
- ...category,
- total: Math.max(actualTotal, category.total),
- alarms: Math.max(actualAlarms, category.alarms),
- offline: Math.max(actualOffline, category.offline),
- records
- };
- });
- }
- function renderFacilityStatus(monitoring) {
- state.facilityMonitoring = monitoring;
- const categories = buildFacilityCategories(monitoring);
- const summaryGrid = document.querySelector("#facilitySummaryGrid");
- const categoryGrid = document.querySelector("#facilityCategoryGrid");
- if (!summaryGrid || !categoryGrid) return;
- const total = categories.reduce((sum, item) => sum + item.total, 0);
- const alarms = categories.reduce((sum, item) => sum + item.alarms, 0);
- const offline = categories.reduce((sum, item) => sum + item.offline, 0);
- const onlineRate = total ? ((total - offline) / total * 100).toFixed(1) : "0.0";
- const overviewText = document.querySelector("#facilityOverviewText");
- if (overviewText) {
- overviewText.textContent = `当前共纳管 ${formatNumber(total)} 台设备,在线率 ${onlineRate}%,${alarms ? `存在 ${alarms} 项报警` : "暂无报警"},${offline ? `${offline} 台设备处于断开状态` : "设备连接状态正常"}。`;
- }
- const categoryCount = document.querySelector("#facilityCategoryCount");
- if (categoryCount) categoryCount.textContent = `${categories.length} 类`;
- summaryGrid.innerHTML = `
- <article class="facility-summary-card facility-summary-total">
- <span class="facility-summary-icon" data-icon="settings-2"></span>
- <div><span>设备总数</span><strong>${formatNumber(total)}</strong><small>全部通防设备</small></div>
- </article>
- <article class="facility-summary-card facility-summary-online">
- <span class="facility-summary-icon" data-icon="activity"></span>
- <div><span>设备在线率</span><strong>${onlineRate}%</strong><small>${formatNumber(total - offline)} 台在线</small></div>
- </article>
- <article class="facility-summary-card facility-summary-alarm">
- <span class="facility-summary-icon" data-icon="triangle-alert"></span>
- <div><span>报警设备</span><strong>${formatNumber(alarms)}</strong><small>${alarms ? "需要及时确认" : "暂无报警"}</small></div>
- </article>
- <article class="facility-summary-card facility-summary-offline">
- <span class="facility-summary-icon" data-icon="plug-zap"></span>
- <div><span>断开设备</span><strong>${formatNumber(offline)}</strong><small>${offline ? "请检查连接状态" : "连接状态正常"}</small></div>
- </article>
- `;
- if (typeof window.injectIcons === "function") window.injectIcons(summaryGrid);
- categoryGrid.innerHTML = categories
- .map(
- (category) => `
- <button class="facility-category-card" type="button" data-facility-category="${category.key}">
- <span class="facility-category-icon"><span data-icon="${category.icon}"></span></span>
- <span class="facility-category-copy">
- <span class="facility-category-title"><strong>${escapeHtml(category.name)}</strong><i class="${category.alarms ? "warning" : category.offline ? "offline" : "normal"}"></i></span>
- <small>${category.alarms ? `${category.alarms} 项报警` : category.offline ? `${category.offline} 台断开` : "运行状态正常"}</small>
- </span>
- <span class="facility-category-counts">
- <span><b>${formatNumber(category.total)}</b><small>总数</small></span>
- <span><b class="${category.alarms ? "text-danger" : ""}">${formatNumber(category.alarms)}</b><small>报警</small></span>
- <span><b>${formatNumber(category.offline)}</b><small>断开</small></span>
- </span>
- </button>
- `
- )
- .join("");
- if (typeof window.injectIcons === "function") window.injectIcons(categoryGrid);
- categoryGrid.querySelectorAll("[data-facility-category]").forEach((button) => {
- button.addEventListener("click", () => {
- categoryGrid.querySelectorAll("[data-facility-category]").forEach((item) => {
- item.classList.toggle("active", item === button);
- });
- const category = categories.find((item) => item.key === button.dataset.facilityCategory);
- renderFacilityDetail(category);
- });
- });
- renderFacilityDetail(categories[0]);
- }
- function renderFacilityDetail(category) {
- const panel = document.querySelector("#facilityDetailPanel");
- if (!panel || !category) return;
- const records = category.records.slice(0, 12);
- while (records.length < Math.min(category.total, 12)) {
- const index = records.length;
- const status = index < category.offline
- ? "offline"
- : index < category.offline + category.alarms
- ? "warning"
- : "normal";
- records.push({
- name: `${category.name} ${String(index + 1).padStart(2, "0")}`,
- location: index % 2 ? "北翼回风巷" : "22煤一盘区",
- status
- });
- }
- const statusLabel = { normal: "正常", warning: "报警", alarm: "报警", offline: "断开" };
- const onlineCount = Math.max(category.total - category.offline, 0);
- const onlineRate = category.total ? (onlineCount / category.total * 100).toFixed(1) : "0.0";
- panel.innerHTML = `
- <div class="panel-head">
- <div><span class="eyebrow">设备明细</span><h2>${escapeHtml(category.name)}</h2><p class="facility-detail-subtitle">在线率 ${onlineRate}% · 报警 ${category.alarms} · 断开 ${category.offline}</p></div>
- <span class="subtle-badge">${formatNumber(category.total)} 台</span>
- </div>
- <div class="facility-detail-list">
- ${records
- .map(
- (item) => `
- <div class="facility-detail-row">
- <span class="facility-detail-device-icon" data-icon="${category.icon}"></span>
- <div class="facility-detail-copy"><strong>${escapeHtml(item.name)}</strong><p>${escapeHtml(item.location)}</p></div>
- <span class="facility-detail-state ${escapeHtml(item.status)}"><i></i>${statusLabel[item.status] || "正常"}</span>
- </div>
- `
- )
- .join("")}
- </div>
- `;
- if (typeof window.injectIcons === "function") window.injectIcons(panel);
- }
- function installFacilityActions() {
- document.querySelector("[data-refresh-facility]")?.addEventListener("click", async () => {
- const result = await loadMonitoring();
- renderFacilityStatus(result.data);
- window.showToast?.("通防设施工况已刷新");
- });
- }
- function safetyStatusForValue(metric, value) {
- const number = Number(value);
- if (!Number.isFinite(number)) return "offline";
- if (metric.thresholdMode === "below") {
- if (number <= Number(metric.alarmThreshold)) return "alarm";
- if (number <= Number(metric.warningThreshold)) return "warning";
- return "normal";
- }
- if (number >= Number(metric.alarmThreshold)) return "alarm";
- if (number >= Number(metric.warningThreshold)) return "warning";
- return "normal";
- }
- function formatSafetyValue(metric, value) {
- const number = Number(value);
- if (!Number.isFinite(number)) return "--";
- const decimals = metric.key === "airflow" ? 0 : metric.key === "temperature" || metric.key === "co" ? 1 : 2;
- return number.toLocaleString("zh-CN", {
- minimumFractionDigits: decimals,
- maximumFractionDigits: decimals
- });
- }
- function buildDemoMetricSeries(metric) {
- const values = Array.from({ length: 48 }, (_, index) => {
- const wave = Math.sin(index / 4.2 + metric.key.length) * metric.amplitude;
- const secondary = Math.cos(index / 7.4 + metric.baseline) * metric.amplitude * 0.35;
- const noise = Math.sin(index * 1.73 + metric.key.length * 1.8) * metric.noise;
- return metric.baseline + wave + secondary + noise;
- });
- if (metric.key === "gas") {
- values[47] = 0.86;
- values[46] = 0.78;
- values[45] = 0.7;
- } else if (metric.key === "airflow") {
- values[47] = 1286;
- values[46] = 1302;
- values[45] = 1328;
- } else if (metric.key === "dust") {
- values[47] = 4.2;
- values[46] = 3.82;
- values[45] = 3.65;
- } else if (metric.key === "air_speed") {
- values[47] = 0.22;
- values[46] = 0.27;
- values[45] = 0.31;
- } else {
- values[47] = metric.baseline + metric.amplitude * 0.28;
- }
- return values.map((value) => Number(value.toFixed(3)));
- }
- function calculateLinearForecast(values, count = 8) {
- const sample = values.slice(-Math.min(values.length, 16)).map(Number).filter(Number.isFinite);
- if (sample.length < 2) return Array.from({ length: count }, () => sample[0] || 0);
- const meanX = (sample.length - 1) / 2;
- const meanY = sample.reduce((sum, value) => sum + value, 0) / sample.length;
- let numerator = 0;
- let denominator = 0;
- sample.forEach((value, index) => {
- numerator += (index - meanX) * (value - meanY);
- denominator += (index - meanX) ** 2;
- });
- const slope = denominator ? numerator / denominator : 0;
- return Array.from({ length: count }, (_, index) =>
- Number((sample[sample.length - 1] + slope * (index + 1)).toFixed(3))
- );
- }
- function buildSafetyMetrics(monitoring) {
- const sensors = monitoring.sensors || [];
- return SAFETY_METRIC_DEFS.map((definition) => {
- const sensor = sensors.find(
- (item) =>
- String(item.sensor_type || "").toLowerCase() === definition.sensorType ||
- String(item.code || "").toLowerCase().includes(definition.key)
- );
- const readings = sensor ? state.safetyReadings.get(String(sensor.id)) : null;
- let values =
- Array.isArray(readings) && readings.length > 2
- ? readings.map((item) => Number(item.value)).filter(Number.isFinite)
- : buildDemoMetricSeries(definition);
- if (values.length < 3) values = buildDemoMetricSeries(definition);
- const sensorValue = Number(sensor?.last_value);
- if (Number.isFinite(sensorValue)) {
- values[values.length - 1] = sensorValue;
- values[values.length - 2] = values[values.length - 2] * 0.35 + sensorValue * 0.65;
- }
- const currentValue = values[values.length - 1];
- const forecast = calculateLinearForecast(values);
- const predictedValue = forecast[forecast.length - 1];
- const status = sensor?.status === "offline" ? "offline" : safetyStatusForValue(definition, currentValue);
- const distance = definition.thresholdMode === "below"
- ? (currentValue - definition.alarmThreshold) / Math.max(definition.warningThreshold - definition.alarmThreshold, 0.001)
- : (definition.alarmThreshold - currentValue) / Math.max(definition.alarmThreshold - definition.warningThreshold, 0.001);
- const baseRisk = status === "alarm" ? 88 : status === "warning" ? 66 : status === "offline" ? 78 : 24;
- const riskScore = Math.max(8, Math.min(98, Math.round(baseRisk + (1 - Math.max(0, Math.min(1, distance))) * 8)));
- return {
- ...definition,
- sensor,
- unit: sensor?.unit || definition.unit,
- location: sensor?.location || "全矿监测网络",
- values,
- forecast,
- currentValue,
- predictedValue,
- predictedStatus: safetyStatusForValue(definition, predictedValue),
- status,
- riskScore
- };
- });
- }
- function getSafetySource(monitoring) {
- return (
- (monitoring.sources || []).find((source) => {
- const type = String(source.source_type || "").toLowerCase();
- return type.includes("monitoring") || type.includes("security") || String(source.name || "").includes("安全监控");
- }) || DEMO_MONITORING.sources[0]
- );
- }
- function buildSparkline(values, tone = "teal") {
- const series = values.slice(-18).map(Number).filter(Number.isFinite);
- if (series.length < 2) return "";
- const min = Math.min(...series);
- const max = Math.max(...series);
- const range = max - min || 1;
- const points = series
- .map((value, index) => {
- const x = (index / (series.length - 1)) * 84 + 2;
- const y = 28 - ((value - min) / range) * 24;
- return `${x.toFixed(1)},${y.toFixed(1)}`;
- })
- .join(" ");
- return `<svg class="safety-sparkline ${tone}" viewBox="0 0 88 32" aria-hidden="true"><polyline points="${points}"></polyline></svg>`;
- }
- function buildSmoothPath(points, smoothing = 0.16) {
- if (!points.length) return "";
- if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
- return points.reduce((path, point, index) => {
- if (index === 0) return `M ${point.x} ${point.y}`;
- const previous = points[index - 1];
- const beforePrevious = points[index - 2] || previous;
- const next = points[index + 1] || point;
- const control1X = previous.x + (point.x - beforePrevious.x) * smoothing;
- const control1Y = previous.y + (point.y - beforePrevious.y) * smoothing;
- const control2X = point.x - (next.x - previous.x) * smoothing;
- const control2Y = point.y - (next.y - previous.y) * smoothing;
- return `${path} C ${control1X.toFixed(1)} ${control1Y.toFixed(1)}, ${control2X.toFixed(1)} ${control2Y.toFixed(1)}, ${point.x} ${point.y}`;
- }, "");
- }
- function renderSafetyChart(metric) {
- const chart = document.querySelector("#safetyMonitoringChart");
- if (!chart || !metric) return;
- const range = Math.min(state.safetyRange, metric.values.length);
- const values = metric.values.slice(-range);
- const forecast = metric.forecast || [];
- const svgWidth = 820;
- const svgHeight = 290;
- const margin = { top: 22, right: 28, bottom: 38, left: 62 };
- const plotWidth = svgWidth - margin.left - margin.right;
- const plotHeight = svgHeight - margin.top - margin.bottom;
- const allValues = [...values, ...forecast, metric.warningThreshold, metric.alarmThreshold].map(Number).filter(Number.isFinite);
- let minimum = Math.min(...allValues);
- let maximum = Math.max(...allValues);
- if (maximum === minimum) {
- maximum += 1;
- minimum -= 1;
- }
- const padding = (maximum - minimum) * 0.16;
- minimum -= padding;
- maximum += padding;
- const totalSteps = Math.max(values.length + forecast.length - 1, 1);
- const xFor = (index) => margin.left + (index / totalSteps) * plotWidth;
- const yFor = (value) => margin.top + (1 - (Number(value) - minimum) / (maximum - minimum)) * plotHeight;
- const actualPoints = values.map((value, index) => ({ x: Number(xFor(index).toFixed(1)), y: Number(yFor(value).toFixed(1)) }));
- const forecastPoints = [
- actualPoints[actualPoints.length - 1],
- ...forecast.map((value, index) => ({
- x: Number(xFor(values.length + index).toFixed(1)),
- y: Number(yFor(value).toFixed(1))
- }))
- ];
- const actualPath = buildSmoothPath(actualPoints);
- const forecastPath = buildSmoothPath(forecastPoints);
- const areaPath = `${actualPath} L ${actualPoints[actualPoints.length - 1].x} ${margin.top + plotHeight} L ${actualPoints[0].x} ${margin.top + plotHeight} Z`;
- const gridLines = Array.from({ length: 5 }, (_, index) => {
- const y = margin.top + (index / 4) * plotHeight;
- const value = maximum - (index / 4) * (maximum - minimum);
- 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>`;
- }).join("");
- const thresholdLines = [
- { value: metric.warningThreshold, className: "warning", label: "预警" },
- { value: metric.alarmThreshold, className: "alarm", label: "报警" }
- ]
- .filter((item) => Number.isFinite(Number(item.value)))
- .map((item) => {
- const y = yFor(item.value);
- 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>`;
- })
- .join("");
- const currentPoint = actualPoints[actualPoints.length - 1];
- const gradientId = `safety-gradient-${metric.key}`;
- chart.innerHTML = `
- <svg class="safety-chart-svg" viewBox="0 0 ${svgWidth} ${svgHeight}" role="img" aria-label="${escapeHtml(metric.name)}监测趋势图">
- <defs>
- <linearGradient id="${gradientId}" x1="0" y1="0" x2="0" y2="1">
- <stop offset="0%" stop-color="${metric.tone === "red" ? "#d84d50" : "#0f9f8f"}" stop-opacity="0.22"/>
- <stop offset="100%" stop-color="${metric.tone === "red" ? "#d84d50" : "#0f9f8f"}" stop-opacity="0"/>
- </linearGradient>
- </defs>
- ${gridLines}
- ${thresholdLines}
- <path d="${areaPath}" fill="url(#${gradientId})"/>
- <path d="${actualPath}" class="safety-chart-actual-path ${metric.tone}"/>
- <path d="${forecastPath}" class="safety-chart-forecast-path"/>
- <line x1="${currentPoint.x}" y1="${margin.top}" x2="${currentPoint.x}" y2="${margin.top + plotHeight}" class="safety-chart-now-line"/>
- <circle cx="${currentPoint.x}" cy="${currentPoint.y}" r="5" class="safety-chart-current-point ${metric.tone}"/>
- <circle cx="${currentPoint.x}" cy="${currentPoint.y}" r="10" class="safety-chart-current-halo ${metric.tone}"/>
- <text x="${margin.left}" y="${svgHeight - 12}" class="safety-chart-time-label">${state.safetyRange} 小时前</text>
- <text x="${currentPoint.x}" y="${svgHeight - 12}" text-anchor="middle" class="safety-chart-time-label">当前</text>
- <text x="${svgWidth - margin.right}" y="${svgHeight - 12}" text-anchor="end" class="safety-chart-time-label">预测 +2h</text>
- </svg>
- `;
- }
- function renderSafetyInsights(metric) {
- if (!metric) return;
- const riskRing = document.querySelector("#safetyRiskRing");
- const riskScore = document.querySelector("#safetyRiskScore");
- const riskLabel = document.querySelector("#safetyRiskLabel");
- const riskSummary = document.querySelector("#safetyRiskSummary");
- const predictionText = document.querySelector("#safetyPredictionText");
- const confidence = document.querySelector("#safetyPredictionConfidence");
- const recommendations = document.querySelector("#safetyRecommendations");
- if (riskRing) {
- riskRing.dataset.level = metric.status;
- riskRing.style.setProperty("--risk-progress", `${metric.riskScore}%`);
- }
- if (riskScore) riskScore.textContent = String(metric.riskScore);
- const labels = {
- normal: "运行平稳",
- warning: "趋势关注",
- alarm: "超限预警",
- offline: "测点离线"
- };
- if (riskLabel) {
- riskLabel.textContent = labels[metric.status] || "运行平稳";
- riskLabel.className = `safety-risk-label ${metric.status}`;
- }
- if (riskSummary) {
- if (metric.status === "alarm") {
- riskSummary.textContent = `${metric.name}当前值 ${formatSafetyValue(metric, metric.currentValue)} ${metric.unit},已越过报警阈值,需要立即处置。`;
- } else if (metric.status === "warning") {
- riskSummary.textContent = `${metric.name}当前值靠近控制边界,未来 2 小时仍存在继续偏离的风险。`;
- } else {
- riskSummary.textContent = `${metric.name}处于稳定区间,当前阈值余量充足。`;
- }
- }
- const change = metric.currentValue
- ? ((metric.predictedValue - metric.currentValue) / Math.abs(metric.currentValue)) * 100
- : 0;
- const direction = Math.abs(change) < 0.4 ? "基本持平" : change > 0 ? "缓慢上升" : "缓慢下降";
- if (predictionText) {
- predictionText.textContent = `基于近 ${metric.values.length} 个监测点执行趋势外推,预计未来 2 小时${direction},预测值约 ${formatSafetyValue(metric, metric.predictedValue)} ${metric.unit}。`;
- }
- if (confidence) {
- const value = Math.min(98, Math.round(88 + Math.min(metric.values.length, 48) / 6));
- confidence.textContent = `置信度 ${value}%`;
- }
- if (recommendations) {
- recommendations.innerHTML = metric.recommendations
- .map(
- (item, index) => `
- <div class="safety-recommendation-item">
- <span>${String(index + 1).padStart(2, "0")}</span>
- <p>${escapeHtml(item)}</p>
- </div>
- `
- )
- .join("");
- }
- }
- function renderSafetyMetricTabs(metrics) {
- const container = document.querySelector("#safetyMetricTabs");
- if (!container) return;
- container.innerHTML = metrics
- .map(
- (metric) => `
- <button class="safety-metric-tab ${metric.key === state.safetySelectedMetric ? "active" : ""}" type="button" data-safety-metric="${metric.key}" data-tone="${metric.tone}">
- <span class="safety-metric-tab-icon" data-icon="${metric.icon}"></span>
- <span class="safety-metric-tab-copy">
- <span><strong>${escapeHtml(metric.shortName)}</strong><small>${formatNumber(metric.pointCount)} 测点</small></span>
- <b>${formatSafetyValue(metric, metric.currentValue)} <small>${escapeHtml(metric.unit)}</small></b>
- </span>
- ${buildSparkline(metric.values, metric.tone)}
- </button>
- `
- )
- .join("");
- container.querySelectorAll("[data-safety-metric]").forEach((button) => {
- button.addEventListener("click", () => {
- state.safetySelectedMetric = button.dataset.safetyMetric;
- renderSafetyMonitoring(state.monitoring);
- });
- });
- if (typeof window.injectIcons === "function") window.injectIcons(container);
- }
- function renderSafetySensorList(metrics) {
- const container = document.querySelector("#safetySensorList");
- const count = document.querySelector("#safetySensorCount");
- if (!container) return;
- if (count) count.textContent = `${metrics.length} 类参数`;
- const statusLabel = { normal: "正常", warning: "关注", alarm: "报警", offline: "离线" };
- container.innerHTML = metrics
- .map(
- (metric) => `
- <button class="safety-sensor-row" type="button" data-safety-sensor-metric="${metric.key}">
- <span class="safety-sensor-icon" data-icon="${metric.icon}"></span>
- <span class="safety-sensor-copy">
- <strong>${escapeHtml(metric.sensor?.name || `${metric.name}监测点`)}</strong>
- <small>${escapeHtml(metric.location)}</small>
- </span>
- <span class="safety-sensor-value"><strong>${formatSafetyValue(metric, metric.currentValue)}</strong><small>${escapeHtml(metric.unit)}</small></span>
- <span class="safety-sensor-state ${escapeHtml(metric.status)}"><i></i>${statusLabel[metric.status] || "正常"}</span>
- </button>
- `
- )
- .join("");
- container.querySelectorAll("[data-safety-sensor-metric]").forEach((button) => {
- button.addEventListener("click", () => {
- state.safetySelectedMetric = button.dataset.safetySensorMetric;
- renderSafetyMonitoring(state.monitoring);
- });
- });
- if (typeof window.injectIcons === "function") window.injectIcons(container);
- }
- function renderSafetyAlarmList(monitoring) {
- const container = document.querySelector("#safetyAlarmList");
- const count = document.querySelector("#safetyAlarmCount");
- if (!container) return;
- const alarms = monitoring?.alarms?.length ? monitoring.alarms : DEMO_MONITORING.alarms;
- if (count) count.textContent = `${alarms.length} 条`;
- const levelLabel = { high: "高风险", medium: "中风险", low: "低风险" };
- container.innerHTML = alarms.length
- ? alarms
- .slice(0, 6)
- .map(
- (alarm) => `
- <div class="safety-alarm-row">
- <span class="safety-alarm-level ${escapeHtml(alarm.level || "medium")}">${levelLabel[alarm.level] || "告警"}</span>
- <div>
- <strong>${escapeHtml(alarm.title || "监测数据异常")}</strong>
- <p>${escapeHtml(alarm.content || `当前值 ${formatSafetyValue({ key: "gas", name: "监测值" }, alarm.value)} · 阈值 ${formatSafetyValue({ key: "gas", name: "阈值" }, alarm.threshold)}`)}</p>
- </div>
- <time>${formatTime(alarm.occurred_at)}</time>
- </div>
- `
- )
- .join("")
- : '<div class="safety-empty-state"><span data-icon="check-circle-2"></span><p>当前没有未处置告警</p></div>';
- if (typeof window.injectIcons === "function") window.injectIcons(container);
- }
- function renderSafetyMonitoring(monitoring) {
- if (!monitoring) return;
- state.monitoring = monitoring;
- const summaryGrid = document.querySelector("#safetyMonitoringSummary");
- if (!summaryGrid) return;
- const metrics = buildSafetyMetrics(monitoring);
- state.safetyMetrics = metrics;
- const source = getSafetySource(monitoring);
- const overview = monitoring.overview || DEMO_MONITORING.overview;
- const totalPoints = Number(source.point_count || DEMO_MONITORING.sources[0].point_count || 0);
- const onlineSensors = Number(overview.online_sensor_count || metrics.reduce((sum, item) => sum + item.onlineCount, 0));
- const sensorTotal = Number(overview.sensor_count || metrics.reduce((sum, item) => sum + item.pointCount, 0));
- const onlineRate = sensorTotal ? ((onlineSensors / sensorTotal) * 100).toFixed(1) : "0.0";
- const abnormalCount = metrics.filter((metric) => metric.status !== "normal").length;
- const latency = Number(source.latency_ms || 2400) / 1000;
- const selected = metrics.find((metric) => metric.key === state.safetySelectedMetric) || metrics[0];
- state.safetySelectedMetric = selected?.key || "gas";
- summaryGrid.innerHTML = `
- <article class="safety-summary-card total">
- <span class="safety-summary-icon" data-icon="radio-tower"></span>
- <div><small>接入测点</small><strong>${formatNumber(totalPoints)}</strong><p>安全监控系统接入总量</p></div>
- </article>
- <article class="safety-summary-card online">
- <span class="safety-summary-icon" data-icon="activity"></span>
- <div><small>在线率</small><strong>${onlineRate}%</strong><p>${formatNumber(onlineSensors)} 个测点在线</p></div>
- </article>
- <article class="safety-summary-card warning">
- <span class="safety-summary-icon" data-icon="shield-alert"></span>
- <div><small>异常参数</small><strong>${formatNumber(abnormalCount)}</strong><p>存在报警或关注趋势</p></div>
- </article>
- <article class="safety-summary-card latency">
- <span class="safety-summary-icon" data-icon="clock"></span>
- <div><small>数据延迟</small><strong>${latency.toFixed(1)}s</strong><p>${escapeHtml(source.protocol || "OPC UA")} 实时链路</p></div>
- </article>
- `;
- const toolbarText = document.querySelector("#safetyMonitoringToolbarText");
- if (toolbarText) {
- toolbarText.textContent = `${source.protocol || "OPC UA"} · ${source.endpoint || "实时数据链路"} · 覆盖瓦斯、风量、CO、温度、粉尘、风速`;
- }
- const overviewText = document.querySelector("#safetyOverviewText");
- if (overviewText) {
- overviewText.textContent = `已接入 ${formatNumber(totalPoints)} 个安全监测点,在线率 ${onlineRate}%,当前识别到 ${formatNumber(abnormalCount)} 类参数需要关注。`;
- }
- const sourceName = document.querySelector("#safetyOverviewSource");
- if (sourceName) sourceName.textContent = source.code || source.name || "KJ-SAFETY";
- const sourceProtocol = document.querySelector("#safetyOverviewProtocol");
- if (sourceProtocol) sourceProtocol.textContent = `${source.protocol || "OPC UA"} · 实时接入`;
- renderSafetyMetricTabs(metrics);
- if (selected) {
- const title = document.querySelector("#safetyChartTitle");
- const subtitle = document.querySelector("#safetyChartSubtitle");
- const currentValue = document.querySelector("#safetyCurrentValue");
- const currentUnit = document.querySelector("#safetyCurrentUnit");
- if (title) title.textContent = `${selected.name}趋势`;
- if (subtitle) subtitle.textContent = `${selected.location} · 阈值 ${formatSafetyValue(selected, selected.warningThreshold)} / ${formatSafetyValue(selected, selected.alarmThreshold)} ${selected.unit}`;
- if (currentValue) currentValue.textContent = formatSafetyValue(selected, selected.currentValue);
- if (currentUnit) currentUnit.textContent = selected.unit;
- renderSafetyChart(selected);
- renderSafetyInsights(selected);
- }
- renderSafetySensorList(metrics);
- renderSafetyAlarmList(monitoring);
- if (typeof window.injectIcons === "function") window.injectIcons(summaryGrid);
- }
- function installSafetyMonitoringActions() {
- document.querySelector("[data-refresh-safety-monitoring]")?.addEventListener("click", async () => {
- const result = await loadMonitoring();
- renderSafetyMonitoring(result.data);
- await hydrateSafetyReadings(result.data);
- window.showToast?.("安全监控数据已刷新");
- });
- document.querySelectorAll("[data-safety-range]").forEach((button) => {
- button.addEventListener("click", () => {
- state.safetyRange = Number(button.dataset.safetyRange || 24);
- document.querySelectorAll("[data-safety-range]").forEach((item) => item.classList.toggle("active", item === button));
- const metric = state.safetyMetrics.find((item) => item.key === state.safetySelectedMetric);
- renderSafetyChart(metric);
- });
- });
- }
- function videoStatusLabel(status) {
- return status === "online" ? "在线" : status === "offline" ? "离线" : "异常";
- }
- function videoSourceCount(monitoring) {
- const source = (monitoring?.sources || []).find((item) =>
- String(item.source_type || "").toLowerCase().includes("video")
- );
- return Number(source?.point_count || 218);
- }
- function renderVideoSummary(monitoring) {
- const grid = document.querySelector("#videoSummaryGrid");
- if (!grid) return;
- const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
- const total = videoSourceCount(monitoring);
- const configured = VIDEO_CHANNELS.length;
- const online = VIDEO_CHANNELS.filter((item) => item.status === "online").length;
- grid.innerHTML = `
- <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>
- <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>
- <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>
- <article class="video-summary-card ai"><span class="video-summary-icon" data-icon="scan-search"></span><div><small>识别事件</small><strong>${formatNumber((state.monitoring?.alarms || []).length)}</strong><p>当前智能分析事件</p></div></article>
- `;
- window.injectIcons?.(grid);
- const count = document.querySelector("#videoChannelCount");
- if (count) count.textContent = `${formatNumber(configured)} / ${formatNumber(total)} 路`;
- const toolbar = document.querySelector("#videoPlatformToolbarText");
- if (toolbar) {
- toolbar.textContent = `${moduleData.title}|共接入 ${formatNumber(total)} 路视频,当前已配置 ${formatNumber(configured)} 路,在线 ${formatNumber(online)} 路`;
- }
- }
- function renderVideoStatusOverview() {
- const container = document.querySelector("#videoStatusOverview");
- const title = document.querySelector("#videoStatusTitle");
- if (!container) return;
- const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
- if (title) title.textContent = moduleData.statusTitle;
- container.innerHTML = moduleData.summary
- .map(
- ([label, value, suffix], index) => `
- <div class="video-status-item ${index % 3 === 2 ? "accent" : ""}">
- <strong>${formatNumber(value)}</strong>
- <span>${escapeHtml(label)}</span>
- <small>${escapeHtml(suffix)}</small>
- </div>
- `
- )
- .join("");
- }
- function filteredVideoChannels() {
- const moduleGroup = {
- door: "风门区域",
- "local-fan": "局扇区域",
- "main-fan": "主扇区域"
- }[state.videoModule] || "风门区域";
- const keyword = state.videoSearch.trim().toLowerCase();
- return VIDEO_CHANNELS.filter((channel) => {
- if (channel.group !== moduleGroup) return false;
- if (!keyword) return true;
- return `${channel.name} ${channel.location}`.toLowerCase().includes(keyword);
- });
- }
- function renderVideoDeviceTree() {
- const tree = document.querySelector("#videoDeviceTree");
- if (!tree) return;
- const channels = filteredVideoChannels();
- const count = document.querySelector("#videoChannelCount");
- if (count) {
- count.textContent = `${formatNumber(channels.length)} / ${formatNumber(videoSourceCount(state.monitoring))} 路`;
- }
- if (!channels.length) {
- tree.innerHTML = `<div class="video-empty-state"><span data-icon="search"></span><p>没有匹配的视频通道</p></div>`;
- window.injectIcons?.(tree);
- return;
- }
- tree.innerHTML = `
- <div class="video-tree-channels video-tree-flat">
- ${channels
- .map(
- (channel) => `
- <button class="video-tree-channel ${channel.id === state.videoSelectedChannel ? "active" : ""}" type="button" data-video-channel="${channel.id}">
- <i class="${channel.status}"></i>
- <span><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(channel.location)}</small></span>
- <em>${videoStatusLabel(channel.status)}</em>
- </button>
- `
- )
- .join("")}
- </div>
- `;
- window.injectIcons?.(tree);
- tree.querySelectorAll("[data-video-channel]").forEach((button) => {
- button.addEventListener("click", () => {
- state.videoSelectedChannel = button.dataset.videoChannel;
- renderVideoPlatform(state.monitoring);
- });
- });
- }
- function videoWallChannels() {
- const all = VIDEO_CHANNELS.slice();
- const selectedIndex = all.findIndex((item) => item.id === state.videoSelectedChannel);
- if (selectedIndex > 0) {
- const [selected] = all.splice(selectedIndex, 1);
- all.unshift(selected);
- }
- return all.slice(0, state.videoLayout);
- }
- function renderVideoWall() {
- const wall = document.querySelector("#videoWall");
- const title = document.querySelector("#videoWallTitle");
- if (!wall) return;
- wall.className = `video-wall layout-${state.videoLayout}`;
- const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
- const layoutNames = { 1: "单画面监控", 2: "双画面监控", 4: "四分屏监控", 6: "六分屏监控", 9: "九分屏监控" };
- if (title) title.textContent = `${moduleData.title} · ${layoutNames[state.videoLayout] || "实时监控"}`;
- const channels = videoWallChannels();
- wall.innerHTML = channels
- .map(
- (channel) => `
- <article class="video-tile ${channel.status}" data-video-tile="${channel.id}">
- <div class="video-tile-stage">
- <div class="video-tile-placeholder">
- <span data-icon="video"></span>
- <strong>${escapeHtml(channel.name)}</strong>
- <small>等待视频流接入</small>
- </div>
- <div class="video-tile-status"><i></i>${videoStatusLabel(channel.status)}</div>
- <div class="video-tile-copy"><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(channel.location)}</small></div>
- <div class="video-tile-tools">
- <button type="button" data-video-action="play" data-channel="${channel.id}" aria-label="播放或暂停" title="播放或暂停"><span data-icon="play"></span></button>
- <button type="button" data-video-action="mute" data-channel="${channel.id}" aria-label="静音" title="静音"><span data-icon="volume-2"></span></button>
- <button type="button" data-video-action="snapshot" data-channel="${channel.id}" aria-label="抓图" title="抓图"><span data-icon="camera"></span></button>
- <button type="button" data-video-action="fullscreen" data-channel="${channel.id}" aria-label="全屏" title="全屏"><span data-icon="maximize-2"></span></button>
- </div>
- </div>
- </article>
- `
- )
- .join("");
- window.injectIcons?.(wall);
- wall.querySelectorAll("[data-video-action]").forEach((button) => {
- button.addEventListener("click", (event) => {
- event.stopPropagation();
- handleVideoAction(button.dataset.videoAction, button.dataset.channel);
- });
- });
- }
- function handleVideoAction(action, channelId) {
- const channel = VIDEO_CHANNELS.find((item) => item.id === channelId);
- if (!channel) return;
- if (action === "fullscreen") {
- document.querySelector(`[data-video-tile="${channelId}"]`)?.requestFullscreen?.();
- return;
- }
- const labels = {
- play: "播放/暂停",
- mute: "静音",
- snapshot: "抓图"
- };
- window.showToast?.(
- `${channel.name} 已执行${labels[action] || "操作"};当前视频流接口尚未配置`,
- "warning"
- );
- }
- function renderVideoEvents(monitoring) {
- const list = document.querySelector("#videoEventList");
- const count = document.querySelector("#videoEventCount");
- if (!list) return;
- const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
- const logs = moduleData.logs;
- if (count) count.textContent = `${formatNumber(logs.length)} 条`;
- list.innerHTML = logs
- .map(
- ([time, message, tone]) => `
- <div class="video-event-row ${escapeHtml(tone)}">
- <time>${escapeHtml(time)}</time>
- <p>${escapeHtml(message)}</p>
- </div>
- `
- )
- .join("");
- }
- function renderVideoThumbnails() {
- const grid = document.querySelector("#videoThumbnailGrid");
- const count = document.querySelector("#videoThumbnailCount");
- if (!grid) return;
- const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
- const snapshotTypes = moduleData.snapshotTypes || ["识别抓拍"];
- const channels = VIDEO_CHANNELS.slice(0, 8);
- if (count) count.textContent = `${formatNumber(channels.length)} 张`;
- grid.innerHTML = channels
- .map(
- (channel, index) => `
- <article class="video-thumb-card">
- <div class="video-thumb-stage">
- <span data-icon="video"></span>
- <time>${String(11 + Math.floor(index / 4)).padStart(2, "0")}:${String((index * 7) % 60).padStart(2, "0")}:20</time>
- </div>
- <div class="video-thumb-copy"><strong>${escapeHtml(channel.name)}</strong><small>${escapeHtml(snapshotTypes[index % snapshotTypes.length])}</small></div>
- </article>
- `
- )
- .join("");
- window.injectIcons?.(grid);
- }
- function renderVideoFilterOptions() {
- const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
- const typeLabel = document.querySelector("#videoRecordFilterLabel");
- const typeSelect = document.querySelector("#videoRecordType");
- const scopeLabel = document.querySelector("#videoScopeFilterLabel");
- const scopeSelect = document.querySelector("#videoDeviceScope");
- if (typeLabel) typeLabel.textContent = moduleData.typeFilterLabel;
- if (typeSelect) {
- typeSelect.innerHTML = moduleData.typeFilterOptions
- .map((option) => `<option>${escapeHtml(option)}</option>`)
- .join("");
- }
- if (scopeLabel) scopeLabel.textContent = moduleData.scopeFilterLabel;
- if (scopeSelect) {
- scopeSelect.innerHTML = moduleData.scopeFilterOptions
- .map((option) => `<option>${escapeHtml(option)}</option>`)
- .join("");
- }
- }
- function renderVideoRecords() {
- const tabs = document.querySelector("#videoRecordTabs");
- const table = document.querySelector("#videoRecordsTable");
- const title = document.querySelector("#videoRecordTitle");
- if (!tabs || !table) return;
- const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door;
- const activeIndex = Math.min(state.videoRecordTab, moduleData.tabs.length - 1);
- state.videoRecordTab = activeIndex;
- if (title) title.textContent = moduleData.tabs[activeIndex];
- tabs.innerHTML = moduleData.tabs
- .map(
- (label, index) => `<button class="${index === activeIndex ? "active" : ""}" type="button" data-video-record-tab="${index}">${escapeHtml(label)}</button>`
- )
- .join("");
- tabs.querySelectorAll("[data-video-record-tab]").forEach((button) => {
- button.addEventListener("click", () => {
- state.videoRecordTab = Number(button.dataset.videoRecordTab);
- renderVideoRecords();
- });
- });
- table.innerHTML = `
- <table>
- <thead><tr>${moduleData.columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr></thead>
- <tbody>
- ${moduleData.rows
- .map(
- (row) => `<tr>${row.map((value) => `<td>${escapeHtml(value)}</td>`).join("")}</tr>`
- )
- .join("")}
- </tbody>
- </table>
- `;
- }
- function syncVideoPanelHeights() {
- const devicePanel = document.querySelector(".video-device-panel");
- const wallPanel = document.querySelector(".video-wall-panel");
- if (!devicePanel || !wallPanel) return;
- if (window.innerWidth <= 1180) {
- devicePanel.style.height = "";
- wallPanel.style.height = "";
- return;
- }
- devicePanel.style.height = "auto";
- wallPanel.style.height = "auto";
- const targetHeight = Math.ceil(wallPanel.getBoundingClientRect().height);
- if (!targetHeight) return;
- devicePanel.style.height = `${targetHeight}px`;
- wallPanel.style.height = `${targetHeight}px`;
- }
- function renderVideoPlatform(monitoring = state.monitoring) {
- state.monitoring = monitoring || state.monitoring;
- renderVideoSummary(state.monitoring);
- renderVideoStatusOverview();
- renderVideoDeviceTree();
- renderVideoWall();
- renderVideoEvents(state.monitoring);
- renderVideoThumbnails();
- renderVideoRecords();
- renderVideoFilterOptions();
- document.querySelectorAll("[data-video-module]").forEach((button) => {
- button.classList.toggle("active", button.dataset.videoModule === state.videoModule);
- });
- const note = document.querySelector("#videoStreamNote");
- if (note) {
- note.textContent = "当前为原生视频管理工作台;真实视频流地址接入后可直接在当前分屏中播放。";
- }
- window.requestAnimationFrame(syncVideoPanelHeights);
- }
- function installVideoPlatformActions() {
- document.querySelector("[data-refresh-video-platform]")?.addEventListener("click", () => {
- renderVideoPlatform(state.monitoring);
- window.showToast?.("视频通道状态已刷新");
- });
- document.querySelectorAll("[data-video-module]").forEach((button) => {
- button.addEventListener("click", () => {
- state.videoModule = button.dataset.videoModule || "door";
- state.videoRecordTab = 0;
- state.videoSelectedChannel =
- VIDEO_MODULES[state.videoModule]?.defaultChannel ||
- state.videoSelectedChannel;
- renderVideoPlatform(state.monitoring);
- });
- });
- document.querySelectorAll("[data-video-layout]").forEach((button) => {
- button.addEventListener("click", () => {
- state.videoLayout = Number(button.dataset.videoLayout || 4);
- document.querySelectorAll("[data-video-layout]").forEach((item) => {
- item.classList.toggle("active", item === button);
- });
- renderVideoWall();
- window.requestAnimationFrame(syncVideoPanelHeights);
- });
- });
- const search = document.querySelector("#videoChannelSearch");
- search?.addEventListener("input", () => {
- state.videoSearch = search.value.trim();
- renderVideoDeviceTree();
- });
- document.querySelector("[data-video-search]")?.addEventListener("click", () => {
- renderVideoThumbnails();
- renderVideoRecords();
- window.showToast?.("视频记录检索完成");
- });
- document.querySelector("[data-video-reset]")?.addEventListener("click", () => {
- state.videoSearch = "";
- if (search) search.value = "";
- state.videoRecordTab = 0;
- renderVideoPlatform(state.monitoring);
- window.showToast?.("筛选条件已重置");
- });
- document.querySelectorAll("[data-video-quick-range]").forEach((button) => {
- button.addEventListener("click", () => {
- document.querySelectorAll("[data-video-quick-range]").forEach((item) => item.classList.toggle("active", item === button));
- window.showToast?.(button.dataset.videoQuickRange === "today" ? "已切换为今日范围" : "已切换为近7天范围");
- });
- });
- window.addEventListener("resize", () => {
- window.requestAnimationFrame(syncVideoPanelHeights);
- });
- }
- function openVideoPlatform() {
- window.navigateTo?.("video-platform");
- renderVideoPlatform(state.monitoring);
- }
- function installDocumentExport() {
- const button = document.querySelector("[data-export-documents]");
- if (!button || button.dataset.exportBound) return;
- button.dataset.exportBound = "true";
- button.addEventListener("click", async () => {
- if (!state.apiAvailable) {
- window.showToast?.("后端不可用,暂时无法导出资料清单", "warning");
- return;
- }
- const original = button.innerHTML;
- button.disabled = true;
- button.innerHTML = '<span data-icon="refresh-cw"></span>正在导出';
- window.injectIcons?.(button);
- try {
- const params = new URLSearchParams();
- params.set("mine_id", state.mine.id);
- if (state.documentCategory && state.documentCategory !== "all") {
- params.set("category", state.documentCategory);
- }
- const keyword = document
- .querySelector("#businessDocumentSearch")
- ?.value.trim();
- if (keyword) params.set("keyword", keyword);
- const response = await fetch(
- `${API_BASE}/documents/export?${params.toString()}`
- );
- if (!response.ok) {
- let message = `HTTP ${response.status}`;
- try {
- const payload = await response.json();
- message = payload?.message || payload?.detail || message;
- } catch {
- // Keep the HTTP status when the response is not JSON.
- }
- throw new Error(message);
- }
- const blob = await response.blob();
- const disposition = response.headers.get("Content-Disposition") || "";
- const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
- const filename = encodedName
- ? decodeURIComponent(encodedName)
- : `业务资料清单_${new Date().toISOString().slice(0, 10)}.xlsx`;
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = filename;
- document.body.appendChild(link);
- link.click();
- link.remove();
- window.setTimeout(() => URL.revokeObjectURL(url), 1000);
- window.showToast?.("资料清单已导出");
- } catch (error) {
- window.showToast?.(`导出失败:${error?.message || error}`, "warning");
- } finally {
- button.disabled = false;
- button.innerHTML = original;
- window.injectIcons?.(button);
- }
- });
- }
- async function hydrateSafetyReadings(monitoring) {
- const sensors = monitoring?.sensors || [];
- if (!state.apiAvailable || !sensors.length) {
- state.safetyLoaded = true;
- return;
- }
- const entries = await Promise.all(
- sensors.map(async (sensor) => {
- try {
- const readings = await request(`/monitoring/sensors/${encodeURIComponent(sensor.id)}/readings?limit=48`, {
- timeoutMs: 3200
- });
- return [
- String(sensor.id),
- (readings || []).slice().sort((left, right) => new Date(left.measured_at) - new Date(right.measured_at))
- ];
- } catch {
- return [String(sensor.id), []];
- }
- })
- );
- state.safetyReadings = new Map(entries);
- state.safetyLoaded = true;
- if (state.monitoring) renderSafetyMonitoring(state.monitoring);
- }
- function openSafetyMonitoring() {
- window.navigateTo?.("safety-monitoring");
- if (!state.safetyLoaded) hydrateSafetyReadings(state.monitoring).catch(() => {});
- }
- function renderKnowledgeGraph(graph) {
- const canvas = document.querySelector(".graph-canvas");
- const svg = canvas?.querySelector("svg");
- if (!svg || !graph?.nodes?.length) return;
- const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
- const links = (graph.edges || [])
- .filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target))
- .map((edge) => {
- const source = nodeMap.get(edge.source);
- const target = nodeMap.get(edge.target);
- return `<path d="M${source.x} ${source.y} L${target.x} ${target.y}" />`;
- })
- .join("");
- const nodes = graph.nodes
- .map((node) => {
- const center = node.node_type === "risk";
- const radius = center ? 48 : 34;
- const className = center
- ? "graph-center"
- : node.node_type === "evidence"
- ? "graph-node amber-node"
- : "graph-node";
- const value = Number(node.value || 0);
- return `
- <g class="${className}" transform="translate(${Number(node.x) || 0} ${Number(node.y) || 0})">
- <title>${escapeHtml(`${node.label} ${node.sub_label || ""} · ${value} 条关联知识`)}</title>
- <circle r="${radius}" />
- <text y="-4">${escapeHtml(node.label || "")}</text>
- <text y="15">${escapeHtml(node.sub_label || "")}</text>
- </g>
- `;
- })
- .join("");
- svg.innerHTML = `
- <defs>
- <marker id="graphArrow" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
- <path d="M0,0 L8,4 L0,8 z" fill="#93a4b8" />
- </marker>
- </defs>
- <g class="graph-links">${links}</g>
- ${nodes}
- `;
- }
- function renderKnowledge(payload) {
- const items = Array.isArray(payload)
- ? payload
- : payload?.catalog?.items || payload?.items || [];
- const overview = Array.isArray(payload) ? null : payload?.overview || null;
- const graph = Array.isArray(payload) ? null : payload?.graph || null;
- const count = Number(overview?.total_notes || items.length || DEMO_KNOWLEDGE.length);
- const heroStats = document.querySelector(".knowledge-hero-stats");
- if (heroStats) {
- const stats = heroStats.querySelectorAll("div");
- if (stats[0]) {
- stats[0].querySelector("strong").textContent = formatNumber(count);
- stats[0].querySelector("span").textContent = "知识条目";
- }
- if (stats[1]) {
- const semantic = Boolean(overview?.service?.semantic_enabled);
- stats[1].querySelector("strong").textContent = semantic ? "混合" : "关键词";
- stats[1].querySelector("span").textContent = "检索模式";
- }
- if (stats[2]) {
- const responseMs = Number(overview?.average_response_ms || 0);
- stats[2].querySelector("strong").textContent = responseMs
- ? `${(responseMs / 1000).toFixed(1)}s`
- : "--";
- stats[2].querySelector("span").textContent = "平均响应";
- }
- }
- const navBadge = document.querySelector('[data-view="knowledge"] .nav-badge');
- if (navBadge) navBadge.textContent = formatNumber(count);
- const dashboardCount = document.querySelector(".knowledge-count");
- if (dashboardCount) dashboardCount.textContent = `${formatNumber(count)} 条知识`;
- const heroCopy = document.querySelector(".knowledge-hero-copy p");
- if (heroCopy) {
- const chunkCount = Number(overview?.total_chunks || 0);
- heroCopy.textContent = chunkCount
- ? `已接入 ${formatNumber(count)} 篇知识、${formatNumber(chunkCount)} 个检索切片,结合关键词检索与来源追溯回答通防业务问题。`
- : `已接入 ${formatNumber(count)} 篇知识,结合混合检索与来源追溯回答通防业务问题。`;
- }
- const categoryMap = new Map(
- (overview?.category_counts || []).map((item) => [item.name, Number(item.count || 0)])
- );
- if (!categoryMap.size) {
- items.forEach((item) => {
- const key = item.category_name || item.category || item.knowledge_type || "其他";
- categoryMap.set(key, (categoryMap.get(key) || 0) + 1);
- });
- }
- document.querySelectorAll(".knowledge-category-grid .category-card").forEach((card) => {
- const name = card.querySelector("strong")?.textContent?.trim();
- const badge = card.querySelector("b");
- if (badge) badge.textContent = formatNumber(categoryMap.get(name) ?? 0);
- });
- renderKnowledgeGraph(graph);
- }
- const KNOWLEDGE_CATEGORY_META = {
- standards: {
- name: "规范标准",
- description: "规程、标准、办法及技术要求"
- },
- cases: {
- name: "事故案例",
- description: "典型事故与隐患处置案例"
- },
- expert: {
- name: "专家经验",
- description: "专家规则、判识经验与处置建议"
- },
- management: {
- name: "管理流程",
- description: "业务流程、责任岗位与闭环规则"
- }
- };
- function closeKnowledgeCategory() {
- const modal = document.querySelector("#knowledgeCatalogModal");
- if (modal) modal.hidden = true;
- document.body.classList.remove("knowledge-catalog-open");
- }
- async function loadKnowledgeCatalog(page = state.knowledgeCategoryPage) {
- const category = state.knowledgeCategory;
- const modal = document.querySelector("#knowledgeCatalogModal");
- const content = document.querySelector("#knowledgeCatalogContent");
- const pager = document.querySelector("#knowledgeCatalogPager");
- if (!category || !modal || !content || !pager) return;
- state.knowledgeCategoryPage = page;
- content.innerHTML = '<div class="knowledge-catalog-empty">正在加载知识列表...</div>';
- pager.innerHTML = "";
- const params = new URLSearchParams({
- category,
- page: String(page),
- page_size: String(state.knowledgeCategoryPageSize)
- });
- if (state.knowledgeCategoryKeyword) {
- params.set("keyword", state.knowledgeCategoryKeyword);
- }
- try {
- const data = await request(`/knowledge/catalog?${params.toString()}`, {
- timeoutMs: 20000
- });
- state.knowledgeCategoryTotal = Number(data?.total || 0);
- const title = document.querySelector("#knowledgeCatalogTitle");
- const subtitle = document.querySelector("#knowledgeCatalogSubtitle");
- if (title) title.textContent = KNOWLEDGE_CATEGORY_META[category]?.name || "知识列表";
- if (subtitle) {
- subtitle.textContent = `${KNOWLEDGE_CATEGORY_META[category]?.description || ""} · 共 ${formatNumber(state.knowledgeCategoryTotal)} 条`;
- }
- renderKnowledgeCatalog(data?.items || []);
- renderKnowledgeCatalogPager();
- } catch (error) {
- content.innerHTML = `<div class="knowledge-catalog-empty">知识列表加载失败:${escapeHtml(error?.message || error)}</div>`;
- window.showToast?.("知识列表加载失败", "warning");
- }
- }
- function renderKnowledgeCatalog(items) {
- const content = document.querySelector("#knowledgeCatalogContent");
- if (!content) return;
- if (!items.length) {
- content.innerHTML = '<div class="knowledge-catalog-empty">当前分类下没有匹配的知识。</div>';
- return;
- }
- content.innerHTML = `
- <div class="knowledge-catalog-list">
- ${items.map((item) => `
- <button class="knowledge-catalog-item" type="button" data-knowledge-note="${escapeHtml(item.note_id)}">
- <span class="knowledge-catalog-item-icon" data-icon="file-text"></span>
- <span class="knowledge-catalog-item-copy">
- <strong>${escapeHtml(item.title)}</strong>
- <p>${escapeHtml([item.category_name, ...(item.tags || []).slice(0, 3)].filter(Boolean).join(" · "))}</p>
- </span>
- <span class="knowledge-catalog-item-meta">${escapeHtml(item.updated || "")}</span>
- </button>
- `).join("")}
- </div>
- `;
- if (typeof window.injectIcons === "function") window.injectIcons(content);
- }
- function renderKnowledgeCatalogPager() {
- const pager = document.querySelector("#knowledgeCatalogPager");
- if (!pager) return;
- const pages = Math.max(
- 1,
- Math.ceil(state.knowledgeCategoryTotal / state.knowledgeCategoryPageSize)
- );
- const current = state.knowledgeCategoryPage;
- const pageButtons = [];
- const start = Math.max(1, current - 2);
- const end = Math.min(pages, current + 2);
- for (let page = start; page <= end; page += 1) {
- pageButtons.push(
- `<button type="button" class="${page === current ? "active" : ""}" data-knowledge-page="${page}">${page}</button>`
- );
- }
- pager.innerHTML = `
- <span>第 ${current} / ${pages} 页</span>
- <button type="button" data-knowledge-page="${current - 1}" ${current <= 1 ? "disabled" : ""}>上一页</button>
- ${pageButtons.join("")}
- <button type="button" data-knowledge-page="${current + 1}" ${current >= pages ? "disabled" : ""}>下一页</button>
- `;
- }
- async function openKnowledgeDetail(noteId) {
- const content = document.querySelector("#knowledgeCatalogContent");
- const pager = document.querySelector("#knowledgeCatalogPager");
- if (!content) return;
- content.innerHTML = '<div class="knowledge-catalog-empty">正在加载知识正文...</div>';
- if (pager) pager.innerHTML = "";
- try {
- const detail = await request(
- `/knowledge/catalog/${encodeURIComponent(noteId)}`,
- { timeoutMs: 20000 }
- );
- content.innerHTML = `
- <div class="knowledge-catalog-detail-head">
- <button class="secondary-button" type="button" data-knowledge-back>返回列表</button>
- <div><h3>${escapeHtml(detail.title)}</h3></div>
- </div>
- <div class="knowledge-catalog-detail-tags">
- ${[detail.category_name, ...(detail.tags || [])].filter(Boolean).map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")}
- </div>
- <div class="knowledge-catalog-detail-body">${renderMarkdown(detail.full_content || detail.summary || "")}</div>
- `;
- content.scrollTop = 0;
- if (typeof window.injectIcons === "function") window.injectIcons(content);
- } catch (error) {
- content.innerHTML = `<div class="knowledge-catalog-empty">知识正文加载失败:${escapeHtml(error?.message || error)}</div>`;
- }
- }
- async function openKnowledgeCategory(category) {
- const meta = KNOWLEDGE_CATEGORY_META[category];
- const modal = document.querySelector("#knowledgeCatalogModal");
- if (!meta || !modal) return;
- state.knowledgeCategory = category;
- state.knowledgeCategoryPage = 1;
- state.knowledgeCategoryKeyword = "";
- const form = document.querySelector("#knowledgeCatalogSearch");
- if (form) form.reset();
- modal.hidden = false;
- document.body.classList.add("knowledge-catalog-open");
- await loadKnowledgeCatalog(1);
- }
- function installKnowledgeCategoryBrowser() {
- if (window.__ventKnowledgeCategoryBrowserInstalled) return;
- window.__ventKnowledgeCategoryBrowserInstalled = true;
- document.querySelectorAll("[data-knowledge-category]").forEach((card) => {
- const open = () => openKnowledgeCategory(card.dataset.knowledgeCategory);
- card.addEventListener("click", open);
- card.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- open();
- }
- });
- });
- document.querySelectorAll("[data-knowledge-catalog-close]").forEach((element) => {
- element.addEventListener("click", closeKnowledgeCategory);
- });
- document.querySelector("#knowledgeCatalogSearch")?.addEventListener("submit", (event) => {
- event.preventDefault();
- const keyword = new FormData(event.currentTarget).get("keyword")?.toString().trim() || "";
- state.knowledgeCategoryKeyword = keyword;
- loadKnowledgeCatalog(1);
- });
- document.querySelector("#knowledgeCatalogContent")?.addEventListener("click", (event) => {
- const back = event.target.closest("[data-knowledge-back]");
- if (back) {
- loadKnowledgeCatalog();
- return;
- }
- const item = event.target.closest("[data-knowledge-note]");
- if (item) openKnowledgeDetail(item.dataset.knowledgeNote);
- });
- document.querySelector("#knowledgeCatalogPager")?.addEventListener("click", (event) => {
- const button = event.target.closest("[data-knowledge-page]");
- if (!button || button.disabled) return;
- loadKnowledgeCatalog(Number(button.dataset.knowledgePage));
- });
- document.addEventListener("keydown", (event) => {
- if (event.key === "Escape" && !document.querySelector("#knowledgeCatalogModal")?.hidden) {
- closeKnowledgeCategory();
- }
- });
- }
- function renderKnowledgeError(container, error, compact = false) {
- const notLoaded =
- Number(error?.status) === 404 ||
- String(error?.message || "").includes("404");
- if (notLoaded) {
- container.innerHTML = compact
- ? `
- <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>接口未加载</span></div>
- <p>知识检索接口未在当前后端进程中加载,请重启后端服务后重试。</p>
- `
- : `
- <div class="panel-head">
- <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
- <span class="subtle-badge">接口未加载</span>
- </div>
- <p>知识检索接口未在当前后端进程中加载,请重启后端服务后重试。</p>
- `;
- if (typeof window.injectIcons === "function") window.injectIcons(container);
- window.showToast?.("知识接口未加载,请重启后端服务", "warning");
- return;
- }
- container.innerHTML = compact
- ? `
- <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>检索失败</span></div>
- <p>知识库检索失败,请稍后重试。</p>
- `
- : `
- <div class="panel-head">
- <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
- <span class="subtle-badge">检索失败</span>
- </div>
- <p>知识库检索失败,请稍后重试。</p>
- `;
- if (typeof window.injectIcons === "function") window.injectIcons(container);
- window.showToast?.("知识库检索失败,请稍后重试", "warning");
- }
- function sourceView(source) {
- const score = Number(source.score || 0);
- return {
- citationIndex: Number(source.citation_index || 0),
- title: source.title,
- scoreText: score > 0 ? `${Math.round(score * 100)}%` : "相关"
- };
- }
- function renderMarkdown(value) {
- const codeBlocks = [];
- const source = String(value || "")
- .replace(/\r\n/g, "\n")
- .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, language, code) => {
- codeBlocks.push({ language, code });
- return `\n@@CODEBLOCK${codeBlocks.length - 1}@@\n`;
- });
- const lines = escapeHtml(source).split("\n");
- const output = [];
- let listType = null;
- const closeList = () => {
- if (listType) output.push(`</${listType}>`);
- listType = null;
- };
- const inline = (text) =>
- text
- .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
- .replace(/\*(.+?)\*/g, "<em>$1</em>")
- .replace(/`([^`]+)`/g, "<code>$1</code>")
- .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
- for (const rawLine of lines) {
- const line = rawLine.trimEnd();
- const codeMatch = line.trim().match(/^@@CODEBLOCK(\d+)@@$/);
- if (codeMatch) {
- closeList();
- const block = codeBlocks[Number(codeMatch[1])];
- if (block) {
- output.push(
- `<pre><code${block.language ? ` class="language-${escapeHtml(block.language)}"` : ""}>${escapeHtml(block.code.trim())}</code></pre>`
- );
- }
- continue;
- }
- const heading = line.match(/^\s*(#{1,6})\s+(.+)$/);
- if (heading) {
- closeList();
- const level = heading[1].length;
- output.push(`<h${level}>${inline(heading[2].trim())}</h${level}>`);
- continue;
- }
- const bullet = line.match(/^\s*[-*]\s+(.+)$/);
- const ordered = line.match(/^\s*\d+[.、)]\s+(.+)$/);
- if (bullet || ordered) {
- const nextType = bullet ? "ul" : "ol";
- if (listType !== nextType) {
- closeList();
- listType = nextType;
- output.push(`<${listType}>`);
- }
- output.push(`<li>${inline((bullet || ordered)[1].trim())}</li>`);
- continue;
- }
- closeList();
- if (line.trim()) output.push(`<p>${inline(line.trim())}</p>`);
- }
- closeList();
- return output.join("");
- }
- function createKnowledgeStreamView(container, question) {
- let answer = "";
- let thinking = "";
- let answerEl = null;
- let statusEl = null;
- let thinkingPanel = null;
- let thinkingEl = null;
- let thinkingState = null;
- let renderFrame = 0;
- let thinkingFrame = 0;
- const renderMeta = (data = {}) => {
- const sources = (data.sources || []).map(sourceView);
- container.innerHTML = `
- <div class="panel-head">
- <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
- <span class="subtle-badge" data-rag-status>RAG 生成中</span>
- </div>
- <div class="ai-answer">
- <div class="ai-avatar"><span data-icon="bot"></span></div>
- <div class="ai-answer-body">
- <p><strong>问题:</strong>${escapeHtml(question)}</p>
- <details class="rag-thinking" data-rag-thinking hidden>
- <summary><span data-icon="brain"></span><span>思考过程</span><span data-rag-thinking-state>思考中</span></summary>
- <div class="rag-thinking-content" data-rag-thinking-content></div>
- </details>
- <div class="rag-answer-markdown" data-rag-answer></div>
- <div class="source-citations" data-rag-sources hidden>
- ${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("")}
- </div>
- </div>
- </div>
- `;
- answerEl = container.querySelector("[data-rag-answer]");
- statusEl = container.querySelector("[data-rag-status]");
- thinkingPanel = container.querySelector("[data-rag-thinking]");
- thinkingEl = container.querySelector("[data-rag-thinking-content]");
- thinkingState = container.querySelector("[data-rag-thinking-state]");
- if (typeof window.injectIcons === "function") window.injectIcons(container);
- };
- const scrollToBottom = () => {
- const scroller = container.querySelector(".ai-answer");
- if (scroller) scroller.scrollTop = scroller.scrollHeight;
- };
- const renderAnswer = () => {
- renderFrame = 0;
- if (!answerEl) return;
- answerEl.innerHTML = renderMarkdown(answer);
- scrollToBottom();
- };
- const scheduleAnswer = () => {
- if (renderFrame) return;
- renderFrame = window.requestAnimationFrame(renderAnswer);
- };
- const renderThinking = (finished = false) => {
- thinkingFrame = 0;
- if (!thinkingPanel || !thinkingEl) return;
- thinkingPanel.hidden = !thinking;
- thinkingEl.textContent = thinking;
- if (thinkingState) thinkingState.textContent = finished ? "已完成" : "思考中";
- scrollToBottom();
- };
- const scheduleThinking = () => {
- if (thinkingFrame) return;
- thinkingFrame = window.requestAnimationFrame(() => renderThinking(false));
- };
- return {
- get answer() {
- return answer;
- },
- meta: renderMeta,
- thinking(content) {
- if (!answerEl) renderMeta();
- thinking += String(content || "");
- scheduleThinking();
- },
- delta(content) {
- if (!answerEl) renderMeta();
- answer += String(content || "");
- scheduleAnswer();
- },
- done(data = {}) {
- answer = String(data.answer || answer);
- if (data.thinking) thinking = String(data.thinking);
- if (renderFrame) window.cancelAnimationFrame(renderFrame);
- if (thinkingFrame) window.cancelAnimationFrame(thinkingFrame);
- renderAnswer();
- renderThinking(true);
- container.querySelector("[data-rag-sources]")?.removeAttribute("hidden");
- if (statusEl) {
- const status = data.answer_status || "generated";
- const generated = status === "generated";
- const conversational = status === "conversational";
- statusEl.className = `subtle-badge ${generated || conversational ? "success" : ""}`;
- statusEl.textContent = generated
- ? "RAG 大模型生成"
- : conversational
- ? "智能助手"
- : "检索摘要";
- }
- }
- };
- }
- async function streamKnowledgeAnswerFetch(container, question) {
- const controller = new AbortController();
- const timeoutId = window.setTimeout(() => controller.abort(), 120000);
- try {
- const response = await fetch(`${API_BASE}/knowledge/rag/stream`, {
- method: "POST",
- headers: {
- Accept: "text/event-stream",
- "Content-Type": "application/json"
- },
- body: JSON.stringify({
- question,
- top_k: 5,
- generate_answer: true
- }),
- signal: controller.signal
- });
- if (!response.ok) {
- let message = `HTTP ${response.status}`;
- let payload = null;
- try {
- payload = await response.json();
- message = payload?.message || payload?.detail || message;
- } catch {
- // Keep the HTTP status.
- }
- const error = new Error(message);
- error.status = response.status;
- throw error;
- }
- if (!response.body) throw new Error("当前浏览器不支持流式响应");
- const reader = response.body.getReader();
- const decoder = new TextDecoder("utf-8");
- let buffer = "";
- const view = createKnowledgeStreamView(container, question);
- const handleEvent = (eventName, data) => {
- if (eventName === "meta") {
- view.meta(data);
- } else if (eventName === "thinking") {
- view.thinking(data.content || "");
- } else if (eventName === "delta") {
- view.delta(data.content || "");
- } else if (eventName === "warning") {
- window.showToast?.(data.message || "大模型流式生成异常", "warning");
- } else if (eventName === "done") {
- view.done(data);
- }
- };
- const processBlock = (block) => {
- if (!block.trim()) return;
- let eventName = "message";
- const dataLines = [];
- for (const line of block.split(/\r?\n/)) {
- if (line.startsWith("event:")) eventName = line.slice(6).trim();
- if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
- }
- if (!dataLines.length) return;
- handleEvent(eventName, JSON.parse(dataLines.join("\n")));
- };
- while (true) {
- const { done, value } = await reader.read();
- buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
- const blocks = buffer.split(/\r?\n\r?\n/);
- buffer = blocks.pop() || "";
- blocks.forEach(processBlock);
- if (done) break;
- }
- if (buffer.trim()) processBlock(buffer);
- } finally {
- window.clearTimeout(timeoutId);
- }
- }
- function streamKnowledgeAnswerEventSource(container, question) {
- return new Promise((resolve, reject) => {
- const url =
- `${API_BASE}/knowledge/rag/stream?question=${encodeURIComponent(question)}` +
- "&top_k=5";
- const source = new EventSource(url);
- const view = createKnowledgeStreamView(container, question);
- let received = false;
- let settled = false;
- const timeoutId = window.setTimeout(() => {
- if (settled) return;
- source.close();
- settled = true;
- reject(new Error("流式响应超时"));
- }, 120000);
- const finish = () => {
- if (settled) return;
- settled = true;
- window.clearTimeout(timeoutId);
- source.close();
- resolve();
- };
- source.addEventListener("meta", (event) => {
- received = true;
- view.meta(JSON.parse(event.data));
- });
- source.addEventListener("thinking", (event) => {
- received = true;
- const data = JSON.parse(event.data);
- view.thinking(data.content || "");
- });
- source.addEventListener("delta", (event) => {
- received = true;
- const data = JSON.parse(event.data);
- view.delta(data.content || "");
- });
- source.addEventListener("warning", (event) => {
- const data = JSON.parse(event.data);
- window.showToast?.(data.message || "大模型流式生成异常", "warning");
- });
- source.addEventListener("done", (event) => {
- const data = JSON.parse(event.data);
- view.done(data);
- finish();
- });
- source.onerror = () => {
- if (settled) return;
- window.clearTimeout(timeoutId);
- source.close();
- if (received && view.answer) {
- settled = true;
- resolve();
- return;
- }
- settled = true;
- reject(new Error("SSE 连接失败"));
- };
- });
- }
- function streamKnowledgeAnswerWebSocket(container, question) {
- return new Promise((resolve, reject) => {
- const protocol = window.location.protocol === "https:" ? "wss" : "ws";
- const socket = new WebSocket(
- `${protocol}://${window.location.host}${API_BASE}/knowledge/rag/ws`
- );
- const view = createKnowledgeStreamView(container, question);
- let received = false;
- let settled = false;
- const finish = () => {
- if (settled) return;
- settled = true;
- socket.close();
- resolve();
- };
- const fail = (error) => {
- if (settled) return;
- settled = true;
- error.streamStarted = received;
- socket.close();
- reject(error);
- };
- socket.onopen = () => {
- socket.send(
- JSON.stringify({
- question,
- top_k: 5,
- generate_answer: true
- })
- );
- };
- socket.onmessage = (event) => {
- const item = JSON.parse(event.data);
- const data = item.data || {};
- if (item.event === "meta") {
- received = true;
- view.meta(data);
- } else if (item.event === "thinking") {
- received = true;
- view.thinking(data.content || "");
- } else if (item.event === "delta") {
- received = true;
- view.delta(data.content || "");
- } else if (item.event === "warning") {
- window.showToast?.(data.message || "大模型流式生成异常", "warning");
- } else if (item.event === "done") {
- view.done(data);
- finish();
- } else if (item.event === "error") {
- fail(new Error(data.message || "流式问答失败"));
- }
- };
- socket.onerror = () => fail(new Error("WebSocket 连接失败"));
- socket.onclose = () => {
- if (!settled && !received) fail(new Error("WebSocket 连接已关闭"));
- else if (!settled) finish();
- };
- });
- }
- function streamKnowledgeAnswer(container, question) {
- if (typeof WebSocket !== "undefined") {
- return streamKnowledgeAnswerWebSocket(container, question).catch((error) => {
- if (error?.streamStarted) throw error;
- return streamKnowledgeAnswerEventSource(container, question);
- });
- }
- if (typeof EventSource !== "undefined") {
- return streamKnowledgeAnswerEventSource(container, question);
- }
- return streamKnowledgeAnswerFetch(container, question);
- }
- function installKnowledgeRenderer() {
- if (window.__ventApiKnowledgeRendererInstalled) return;
- window.__ventApiKnowledgeRendererInstalled = true;
- const originalRenderAnswer = window.renderAnswer;
- if (typeof originalRenderAnswer !== "function") return;
- window.renderAnswer = function renderAnswerWithApi(container, question, compact = false) {
- if (!state.apiAvailable) {
- originalRenderAnswer(container, question, compact);
- return;
- }
- if (compact) {
- container.innerHTML = `
- <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>正在检索知识库</span></div>
- <p>正在检索相关知识,请稍候...</p>
- `;
- } else {
- container.innerHTML = `
- <div class="panel-head">
- <div><span class="eyebrow">智能问答</span><h2>回答与依据</h2></div>
- <span class="subtle-badge">检索中</span>
- </div>
- <div class="ai-answer">
- <div class="ai-avatar"><span data-icon="bot"></span></div>
- <div class="ai-answer-body">
- <p><strong>问题:</strong>${escapeHtml(question)}</p>
- <p>正在检索知识库并整理依据,请稍候...</p>
- </div>
- </div>
- `;
- }
- if (typeof window.injectIcons === "function") window.injectIcons(container);
- if (!compact) {
- streamKnowledgeAnswer(container, question).catch((error) => {
- renderKnowledgeError(container, error, false);
- });
- return;
- }
- request("/knowledge/ask", {
- method: "POST",
- body: JSON.stringify({
- question,
- top_k: 3,
- generate_answer: false
- }),
- timeoutMs: 30000
- })
- .then((result) => {
- if (!result?.answer) return;
- const answerText = result.answer;
- const answerHtml = escapeHtml(answerText).replace(/\r?\n/g, "<br>");
- const sources = (result.sources || []).map((source) => ({
- citationIndex: Number(source.citation_index || 0),
- title: source.title,
- score: Number(source.score || 0),
- scoreText:
- Number(source.score || 0) > 0
- ? `${Math.round(Number(source.score || 0) * 100)}%`
- : "相关"
- }));
- const modeLabel = result.search_mode === "hybrid" ? "混合检索" : "关键词检索";
- container.innerHTML = `
- <div class="answer-head"><span data-icon="bot"></span><strong>通防知识助手</strong><span>${modeLabel} · 命中 ${result.total_candidates || sources.length} 条</span></div>
- <p>${answerHtml}</p>
- <div class="answer-sources">${sources.map((source) => `<span>${escapeHtml(source.title)}</span>`).join("")}</div>
- `;
- if (typeof window.injectIcons === "function") window.injectIcons(container);
- })
- .catch((error) => renderKnowledgeError(container, error, true));
- };
- }
- function severityClass(severity) {
- return severity === "high" ? "high" : severity === "low" ? "low" : "medium";
- }
- function sceneMeta(scene) {
- const keyMap = {
- "局部通风": ["local", "orange"],
- "通风设施": ["facility", "yellow"],
- "通风系统管理": ["system", "teal"],
- "风量分配": ["volume", "blue"],
- "灾害防治": ["disaster", "red"]
- };
- return keyMap[scene] || ["system", "teal"];
- }
- function statusMeta(status) {
- if (["completed", "closed", "verified", "已记录"].includes(status)) return ["closed", "已记录"];
- if (["processing", "处理中"].includes(status)) return ["processing", "处理中"];
- return ["pending", "待处理"];
- }
- function renderDiagnosis(defects) {
- const list = defects?.length ? defects : DEMO_DIAGNOSIS;
- const normalized = list.map((item, index) => {
- const [sceneKey] = sceneMeta(item.scene);
- return {
- ...item,
- id: item.id || `risk-api-${index}`,
- scene_key: sceneKey,
- defect_code: item.defect_code || item.code || `RISK-API-${index + 1}`,
- evidence: item.evidence || (
- sceneKey === "disaster"
- ? ["radio-tower", "video", "file-text"]
- : sceneKey === "volume"
- ? ["gauge", "file-text"]
- : ["radio-tower", "activity", "file-text"]
- )
- };
- });
- window.setHazardRisks?.(normalized);
- }
- function renderEvaluation(evaluation) {
- const run = evaluation.run || DEMO_EVALUATION.run;
- const dimensions = evaluation.dimensions?.length ? evaluation.dimensions : DEMO_EVALUATION.dimensions;
- const score = Number(run.total_score || 0);
- const grade = String(run.grade || "A").replace("级", "");
- const summary = run.summary || "综合评价已完成,运行状态稳定。";
- setText(".evaluation-grade strong", grade);
- setText(".evaluation-grade em", score >= 90 ? "运行状态优良" : score >= 80 ? "运行状态良好" : "需要持续改进");
- setText(".evaluation-summary-copy .eyebrow", `${formatDate(run.created_at || new Date())} · 自动评价`);
- setText(".evaluation-summary-copy h2", `${state.mine?.name || "上湾煤矿"}通防业务综合评价报告`);
- setText(".evaluation-summary-copy p", summary);
- setText(".radar-panel .subtle-badge", `综合得分 ${formatNumber(score)}`);
- const gradeBox = document.querySelector(".evaluation-grade");
- if (gradeBox) gradeBox.dataset.score = String(score);
- const dimensionsList = document.querySelector(".dimension-list");
- const iconMap = ["shield-check", "activity", "circle-dollar-sign", "clipboard-check", "database"];
- const colorMap = ["green", "blue", "amber", "purple", "teal"];
- if (dimensionsList) {
- dimensionsList.innerHTML = dimensions
- .slice(0, 5)
- .map((item, index) => {
- const change = Number(item.change || 0);
- return `
- <div class="dimension-row">
- <span class="dimension-icon ${colorMap[index % colorMap.length]}" data-icon="${iconMap[index % iconMap.length]}"></span>
- <div><strong>${escapeHtml(item.name)}</strong><p>${escapeHtml(item.note || "业务综合指标")}</p></div>
- <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>
- </div>
- `;
- })
- .join("");
- if (typeof window.injectIcons === "function") window.injectIcons(dimensionsList);
- }
- document.querySelectorAll(".radar-labels text").forEach((label, index) => {
- const item = dimensions[index];
- if (item) label.textContent = `${item.name} ${Number(item.score || 0).toFixed(1)}`;
- });
- const agentText = document.querySelector(".evaluation-agent-panel .agent-copy p");
- if (agentText) {
- agentText.textContent = `“${summary}”`;
- }
- const suggestions = document.querySelector(".evaluation-agent-panel .agent-suggestions");
- if (suggestions && run.recommendations?.length) {
- suggestions.innerHTML = run.recommendations
- .slice(0, 3)
- .map((item, index) => `<span><i>${index + 1}</i>${escapeHtml(item)}</span>`)
- .join("");
- }
- }
- function renderWorkOrders(orders) {
- const items = orders?.length ? orders : DEMO_WORK_ORDERS;
- const groups = [
- { statuses: ["pending"], title: "待确认", dot: "pending" },
- { statuses: ["processing"], title: "处理中", dot: "processing" },
- { statuses: ["review"], title: "待复核", dot: "review" },
- { statuses: ["completed", "verified", "closed"], title: "已完成", dot: "closed" }
- ];
- const columns = document.querySelectorAll("#view-closed-loop .kanban-column");
- groups.forEach((group, index) => {
- const column = columns[index];
- if (!column) return;
- const groupItems = items.filter((item) => group.statuses.includes(item.status));
- const head = column.querySelector(".kanban-head span");
- if (head) head.innerHTML = `<i class="column-dot ${group.dot}"></i>${group.title} <b>${groupItems.length}</b>`;
- const cards = groupItems.map((item) => {
- const priority = item.priority || "medium";
- const priorityLabel = priority === "high" ? "高优先级" : priority === "low" ? "常规" : "中优先级";
- return `
- <button class="work-card priority-${priority}" type="button" data-order-id="${escapeHtml(item.id)}">
- <div class="work-card-head"><span class="work-tag ${priority}">${priorityLabel}</span><span>${formatTime(item.created_at)}</span></div>
- <strong>${escapeHtml(item.title)}</strong>
- <p>${escapeHtml(item.content || "待补充处置说明。")}</p>
- <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>
- </button>
- `;
- });
- column.innerHTML = head ? `${head.outerHTML}${cards.join("")}` : cards.join("");
- column.querySelectorAll(".work-card").forEach((card) => {
- card.addEventListener("click", () => window.showToast?.(`已打开任务:${card.querySelector("strong")?.textContent || "整改任务"}`));
- });
- });
- const toolbar = document.querySelector("#view-closed-loop .toolbar-copy p");
- const closed = items.filter((item) => ["completed", "verified", "closed"].includes(item.status)).length;
- if (toolbar) {
- const rate = items.length ? (closed / items.length * 100).toFixed(1) : "0.0";
- toolbar.textContent = `当前展示 ${items.length} 项整改任务,已完成 ${closed} 项,闭环率 ${rate}%`;
- }
- }
- async function loadMine() {
- try {
- const mines = await request("/mines");
- const mine = mines.find((item) => item.code === DEFAULT_MINE_CODE) || mines.find((item) => item.is_default) || mines[0];
- if (!mine) throw new Error("no mine returned");
- state.apiAvailable = true;
- state.mine = mine;
- return { data: mine, source: "api" };
- } catch (error) {
- state.errors.push(`mines: ${error?.message || error}`);
- state.apiAvailable = false;
- state.mine = DEMO_MINE;
- return { data: DEMO_MINE, source: "demo" };
- }
- }
- async function loadDocuments() {
- const categoryQuery = state.documentCategory && state.documentCategory !== "all"
- ? `&category=${encodeURIComponent(state.documentCategory)}`
- : "";
- try {
- const keyword = state.documentKeyword.trim();
- const documentRequest = keyword
- ? request(
- withMine(
- `/documents/search?q=${encodeURIComponent(keyword)}&page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}`
- )
- )
- : request(
- withMine(
- `/documents?page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}`
- )
- );
- const [pageResult, categories] = await Promise.all([
- documentRequest,
- request(withMine("/documents/categories"))
- ]);
- state.documentSearchSnippets = new Map();
- const items = keyword
- ? (pageResult.items || []).map((hit) => {
- state.documentSearchSnippets.set(
- String(hit.document?.id),
- hit.snippets || []
- );
- return hit.document;
- })
- : pageResult?.items || [];
- return {
- data: {
- page: {
- items,
- total: Number(pageResult?.total || 0),
- page: Number(pageResult?.page || state.documentPage),
- page_size: Number(pageResult?.page_size || state.documentPageSize)
- },
- categories: categories || []
- },
- source: "api"
- };
- } catch (error) {
- state.errors.push(`documents: ${error?.message || error}`);
- return { data: EMPTY_DOCUMENTS, source: "demo" };
- }
- }
- async function loadDocumentStatistics() {
- return withFallback(
- "document-statistics",
- () => request(withMine("/documents/statistics")),
- DEMO_DOCUMENT_STATISTICS
- );
- }
- async function loadBusinessTemplates() {
- return withFallback(
- "document-templates",
- async () => {
- const templates = await request("/documents/templates");
- if (!templates?.length) throw new Error("empty response");
- return templates;
- },
- []
- );
- }
- async function loadMonitoring() {
- return withFallback(
- "monitoring",
- async () => {
- const [overview, sources, sensors, facilities, alarms] = await Promise.all([
- request(withMine("/monitoring/overview")),
- request(withMine("/monitoring/sources")),
- request(withMine("/monitoring/sensors")),
- request(withMine("/monitoring/facilities")),
- request(withMine("/monitoring/alarms?limit=100"))
- ]);
- return { overview, sources, sensors, facilities, alarms };
- },
- DEMO_MONITORING
- );
- }
- async function loadKnowledge() {
- return withFallback(
- "knowledge",
- async () => {
- const [overview, catalog, graph] = await Promise.all([
- request("/knowledge/overview", { timeoutMs: 15000 }),
- request("/knowledge/catalog?page=1&page_size=200", { timeoutMs: 15000 }),
- request("/knowledge/graph", { timeoutMs: 15000 })
- ]);
- if (!overview?.total_notes && !catalog?.items?.length) {
- throw new Error("empty response");
- }
- return { overview, catalog, graph };
- },
- { overview: null, catalog: { items: DEMO_KNOWLEDGE }, graph: null }
- );
- }
- async function loadDiagnosis() {
- return withFallback(
- "diagnosis",
- async () => {
- const tasks = await request(withMine("/diagnosis/tasks?limit=20"));
- if (!tasks?.length) throw new Error("empty response");
- const details = await Promise.all(
- tasks.slice(0, 5).map((task) => request(`/diagnosis/tasks/${encodeURIComponent(task.id)}`))
- );
- const defects = details.flatMap((task) =>
- (task.results || []).map((result) => ({
- id: result.id,
- defect_name: result.defect_name,
- defect_code: result.defect_code,
- scene: result.scene,
- severity: result.severity,
- confidence: Number(result.confidence || 0) * (Number(result.confidence || 0) <= 1 ? 100 : 1),
- status: result.status,
- conclusion: result.conclusion,
- recommendation: result.recommendation,
- occurred_at: formatTime(result.created_at || task.created_at)
- }))
- );
- if (!defects.length) throw new Error("empty response");
- return defects;
- },
- DEMO_DIAGNOSIS
- );
- }
- async function loadEvaluation() {
- return withFallback(
- "evaluations",
- async () => {
- const [indicators, runs] = await Promise.all([
- request(withMine("/evaluations/indicators")),
- request(withMine("/evaluations/runs?limit=1"))
- ]);
- if (!runs?.length) throw new Error("empty response");
- const run = await request(`/evaluations/runs/${encodeURIComponent(runs[0].id)}`);
- const indicatorMap = new Map((indicators || []).map((item) => [String(item.id), item]));
- const dimensions = (run.scores || [])
- .map((score) => {
- const indicator = indicatorMap.get(String(score.indicator_id));
- return {
- name: indicator?.dimension || indicator?.name,
- score: Number(score.score || 0),
- change: 0,
- note: indicator?.name || "评价指标得分"
- };
- })
- .filter((item) => item.name);
- return { run, dimensions: dimensions.length ? dimensions : DEMO_EVALUATION.dimensions };
- },
- DEMO_EVALUATION
- );
- }
- async function loadWorkOrders() {
- return withFallback(
- "work-orders",
- async () => {
- const page = await request(withMine("/work-orders?page=1&page_size=200"));
- if (!page?.items?.length) throw new Error("empty response");
- return page.items;
- },
- DEMO_WORK_ORDERS
- );
- }
- async function runHazardAgent(agent) {
- const sceneName = {
- local: "局部通风",
- facility: "通风设施",
- system: "通风系统管理",
- volume: "风量分配",
- disaster: "灾害防治"
- }[agent.sceneKey] || "all";
- let source = "demo";
- if (state.apiAvailable && state.mine?.id) {
- try {
- await request("/diagnosis/run", {
- method: "POST",
- body: JSON.stringify({
- mine_id: state.mine.id,
- scene: sceneName,
- requested_by: "隐患识别中心"
- })
- });
- source = "api";
- } catch (error) {
- state.errors.push(`hazard-agent:${agent.id}: ${error?.message || error}`);
- }
- }
- const localFindings = window.getHazardAgentFindings?.(agent) || [];
- await new Promise((resolve) => window.setTimeout(resolve, 650));
- return { findings: localFindings, source };
- }
- function installActionInterceptors() {
- const diagnosisButton = document.querySelector("[data-run-diagnosis]");
- if (diagnosisButton && !diagnosisButton.dataset.apiBound) {
- diagnosisButton.dataset.apiBound = "true";
- diagnosisButton.addEventListener(
- "click",
- async (event) => {
- event.stopImmediatePropagation();
- if (!state.apiAvailable) {
- window.showToast?.("后端不可用,已使用本地演示诊断结果", "warning");
- return;
- }
- const original = diagnosisButton.innerHTML;
- diagnosisButton.disabled = true;
- diagnosisButton.innerHTML = '<span data-icon="refresh-cw"></span>诊断进行中';
- window.injectIcons?.(diagnosisButton);
- try {
- await request("/diagnosis/run", {
- method: "POST",
- body: JSON.stringify({
- mine_id: state.mine.id,
- scene: "all",
- requested_by: "前端用户"
- })
- });
- const result = await loadDiagnosis();
- renderDiagnosis(result.data);
- window.showToast?.(`全量诊断完成,识别到 ${result.data.length} 项缺陷与风险`);
- } catch {
- renderDiagnosis(DEMO_DIAGNOSIS);
- window.showToast?.("诊断接口不可用,已回退到本地演示数据", "warning");
- } finally {
- diagnosisButton.disabled = false;
- diagnosisButton.innerHTML = original;
- window.injectIcons?.(diagnosisButton);
- }
- },
- true
- );
- }
- const evaluationButton = document.querySelector("[data-start-evaluation]");
- if (evaluationButton && !evaluationButton.dataset.apiBound) {
- evaluationButton.dataset.apiBound = "true";
- evaluationButton.addEventListener(
- "click",
- async (event) => {
- event.stopImmediatePropagation();
- if (!state.apiAvailable) {
- renderEvaluation(DEMO_EVALUATION);
- window.showToast?.("后端不可用,已使用本地演示评价结果", "warning");
- return;
- }
- const original = evaluationButton.innerHTML;
- evaluationButton.disabled = true;
- evaluationButton.innerHTML = '<span data-icon="refresh-cw"></span>评价计算中';
- window.injectIcons?.(evaluationButton);
- try {
- await request("/evaluations/run", {
- method: "POST",
- body: JSON.stringify({
- mine_id: state.mine.id,
- requested_by: "前端用户"
- })
- });
- const result = await loadEvaluation();
- renderEvaluation(result.data);
- window.showToast?.(`综合评价已更新,综合得分 ${formatNumber(result.data.run.total_score)},等级 ${result.data.run.grade}`);
- } catch {
- renderEvaluation(DEMO_EVALUATION);
- window.showToast?.("评价接口不可用,已回退到本地演示数据", "warning");
- } finally {
- evaluationButton.disabled = false;
- evaluationButton.innerHTML = original;
- window.injectIcons?.(evaluationButton);
- }
- },
- true
- );
- }
- }
- async function init() {
- if (state.initialized) return;
- state.initialized = true;
- updateConnectionBadge("loading");
- try {
- await loadMine();
- const [dashboardResult, monitoringResult, documentsResult, documentStatisticsResult, templatesResult, knowledgeResult, diagnosisResult, evaluationResult, workOrdersResult] =
- await Promise.all([
- state.apiAvailable
- ? apiLoader("dashboard", "/dashboard/overview", DEMO_DASHBOARD)
- : Promise.resolve({ data: DEMO_DASHBOARD, source: "demo" }),
- state.apiAvailable ? loadMonitoring() : Promise.resolve({ data: DEMO_MONITORING, source: "demo" }),
- state.apiAvailable ? loadDocuments() : Promise.resolve({ data: EMPTY_DOCUMENTS, source: "demo" }),
- state.apiAvailable ? loadDocumentStatistics() : Promise.resolve({ data: DEMO_DOCUMENT_STATISTICS, source: "demo" }),
- state.apiAvailable ? loadBusinessTemplates() : Promise.resolve({ data: [], source: "demo" }),
- state.apiAvailable ? loadKnowledge() : Promise.resolve({ data: DEMO_KNOWLEDGE, source: "demo" }),
- state.apiAvailable ? loadDiagnosis() : Promise.resolve({ data: DEMO_DIAGNOSIS, source: "demo" }),
- state.apiAvailable ? loadEvaluation() : Promise.resolve({ data: DEMO_EVALUATION, source: "demo" }),
- state.apiAvailable ? loadWorkOrders() : Promise.resolve({ data: DEMO_WORK_ORDERS, source: "demo" })
- ]);
- const documents = documentsResult.data.page?.items || [];
- state.templates = templatesResult.data || [];
- renderDashboard(dashboardResult.data, monitoringResult.data, evaluationResult.data);
- renderMonitoring(
- monitoringResult.data,
- documents,
- documentStatisticsResult.data
- );
- refreshDocumentProcessingTasks().catch(() => {});
- renderFacilityStatus(monitoringResult.data);
- renderSafetyMonitoring(monitoringResult.data);
- renderVideoPlatform(monitoringResult.data);
- renderDocumentRows(
- documents,
- documentsResult.data.categories,
- documentsResult.data.page?.total
- );
- renderDocumentStatistics(documentStatisticsResult.data);
- renderKnowledge(knowledgeResult.data);
- renderDiagnosis(diagnosisResult.data);
- renderEvaluation(evaluationResult.data);
- renderWorkOrders(workOrdersResult.data);
- installKnowledgeRenderer();
- installKnowledgeCategoryBrowser();
- installActionInterceptors();
- installBusinessUpload();
- installBusinessEdit();
- installFacilityActions();
- installSafetyMonitoringActions();
- installVideoPlatformActions();
- installDocumentExport();
- hydrateSafetyReadings(monitoringResult.data).catch(() => {});
- if (state.apiAvailable && documentsResult.source === "api") {
- hydrateDocumentDetails(documents).catch(() => {});
- }
- if (!state.apiAvailable) {
- updateConnectionBadge("demo");
- } else if (state.errors.length) {
- updateConnectionBadge("mixed");
- } else {
- updateConnectionBadge("api");
- }
- } finally {
- document.body.classList.remove("api-loading");
- }
- }
- window.VentAPI = {
- init,
- getBusinessDocument(documentId) {
- return state.documents.get(String(documentId));
- },
- updateBusinessDocumentField,
- refreshBusinessDocument,
- getDocumentPreview(documentId) {
- return request(`/documents/${encodeURIComponent(documentId)}/preview`);
- },
- getDocumentFileUrl(documentId, download = false) {
- return `${API_BASE}/documents/${encodeURIComponent(documentId)}/file${download ? "?download=1" : ""}`;
- },
- getDocumentProcessing,
- reprocessBusinessDocument,
- openProcessingRecords,
- closeProcessingRecords,
- openVideoPlatform,
- openKnowledgeCategory,
- setBusinessSearch,
- setBusinessCategory,
- runHazardAgent,
- state
- };
- })();
|