DeviceTree.vue 106 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795
  1. <template>
  2. <!-- 设备树组件:左侧面板,包含设备分组树、Tab切换、高级功能等 -->
  3. <div class="device-tree">
  4. <!-- 顶部 Tab -->
  5. <div class="tree-tabs">
  6. <div v-for="tab in tabs" :key="tab.key" :class="['tab-item', { active: activeTab === tab.key }]" @click="changeTab(tab.key)">
  7. {{ tab.label }}
  8. </div>
  9. </div>
  10. <!-- 设备树列表 -->
  11. <div v-show="activeTab === 'device'" class="tree-body">
  12. <div v-for="group in treeData" :key="group.id" class="tree-group">
  13. <div class="group-row" @click="toggleGroup(group.id)">
  14. <span :class="['expand-icon', { collapsed: !isGroupExpanded(group.id) }]">
  15. <svg viewBox="0 0 10 10" width="10" height="10">
  16. <path d="M1 3.2 L5 7.2 L9 3.2" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
  17. </svg>
  18. </span>
  19. <span class="group-icon">
  20. <SvgIcon class="icon-style2" size="18" name="jg-icon" />
  21. </span>
  22. <span class="group-title">{{ group.title }}</span>
  23. <!-- 位于设备勾选列正上方:一键全选 -->
  24. <div class="group-checks" @click.stop>
  25. <a-checkbox
  26. class="check-item select-all-check"
  27. :checked="isAllChecked"
  28. :indeterminate="isPartialChecked"
  29. @update:checked="(val) => toggleSelectAll(!!val)"
  30. >
  31. 一键全选
  32. </a-checkbox>
  33. </div>
  34. </div>
  35. <div v-show="isGroupExpanded(group.id)" class="group-children">
  36. <div
  37. v-for="(device, index) in group.children || []"
  38. :key="device.id"
  39. :class="[
  40. 'device-row',
  41. {
  42. active: selectedId === device.id,
  43. 'is-last': index === (group.children?.length || 0) - 1,
  44. },
  45. ]"
  46. @click="selectDevice(device)"
  47. >
  48. <span class="tree-line"></span>
  49. <span class="device-icon">
  50. <SvgIcon class="icon-style2" size="18" name="video-icon" />
  51. </span>
  52. <span class="device-name" :title="device.name">{{ device.name }}</span>
  53. <div class="device-checks" @click.stop>
  54. <a-checkbox
  55. v-for="(checked, cIndex) in device.checks"
  56. :key="cIndex"
  57. class="check-item"
  58. :checked="!!checked"
  59. @update:checked="(val) => toggleCheck(device, cIndex, !!val)"
  60. >
  61. {{ checkTips[cIndex] }}
  62. </a-checkbox>
  63. </div>
  64. </div>
  65. </div>
  66. </div>
  67. </div>
  68. <!-- 高级功能面板 -->
  69. <div v-show="activeTab === 'advanced'" class="advanced-panel">
  70. <div class="adv-section">
  71. <a-button class="adv-btn" @click="onAdvancedAction('snapshot')">一键抓拍</a-button>
  72. <a-button class="adv-btn" @click="onAdvancedAction('viewImages')">查看图片</a-button>
  73. </div>
  74. <div class="adv-section">
  75. <a-button class="adv-btn" :disabled="advancedState.recording" @click="setRecording(true)"> 开始录像 </a-button>
  76. <a-button class="adv-btn" :disabled="!advancedState.recording" @click="setRecording(false)"> 停止录像 </a-button>
  77. <a-button class="adv-btn" @click="onAdvancedAction('viewRecords')">查看录像</a-button>
  78. </div>
  79. <div class="adv-section adv-section-last">
  80. <a-checkbox class="adv-check" :checked="advancedState.alarmPopup" @update:checked="(val) => onAlarmPopupChange(!!val)">
  81. 报警自动弹窗
  82. </a-checkbox>
  83. <!-- <a-checkbox v-model:checked="advancedState.fillLight1" class="adv-check">补光灯1</a-checkbox>
  84. <a-checkbox v-model:checked="advancedState.fillLight2" class="adv-check">补光灯2</a-checkbox> -->
  85. </div>
  86. </div>
  87. <!-- 角度看板 -->
  88. <div v-show="activeTab === 'angle'" class="angle-panel">
  89. <div class="angle-fields">
  90. <div v-for="field in angleFields" :key="field.label" class="angle-group">
  91. <div class="angle-label">{{ field.label }}</div>
  92. <div class="angle-range">
  93. <div class="angle-input-wrap">
  94. <a-input-number v-model:value="angleState[field.minKey]" v-bind="angleInputProps" class="angle-input" />
  95. </div>
  96. <span class="angle-sep">~</span>
  97. <div class="angle-input-wrap">
  98. <a-input-number v-model:value="angleState[field.maxKey]" v-bind="angleInputProps" class="angle-input" />
  99. </div>
  100. </div>
  101. </div>
  102. </div>
  103. <div class="angle-actions">
  104. <a-button class="angle-btn angle-btn-refresh" @click="onAngleAction('refresh')">刷新</a-button>
  105. <a-button class="angle-btn angle-btn-save" @click="onAngleAction('save')">保存</a-button>
  106. </div>
  107. </div>
  108. <!-- 统计看板 -->
  109. <div v-show="activeTab === 'stats'" class="stats-panel">
  110. <div class="stats-header">
  111. <div class="stats-total-left">
  112. <SvgIcon class="stats-cam-icon" size="22" name="video-icon" />
  113. <span class="stats-total-label">设备总数</span>
  114. </div>
  115. <span class="stats-total-num">{{ statsTotal }}</span>
  116. </div>
  117. <div class="stats-divider"></div>
  118. <div ref="statsChartRef" class="stats-chart"></div>
  119. </div>
  120. <!-- 预置位看板 -->
  121. <div v-show="activeTab === 'preset'" class="preset-panel">
  122. <div class="preset-toolbar">
  123. <button
  124. v-for="item in presetToolbarItems"
  125. :key="item.key"
  126. type="button"
  127. class="preset-tool-btn"
  128. :title="item.title"
  129. @click="onPresetAction(item.key)"
  130. >
  131. <SvgIcon :name="item.icon" :size="18" />
  132. </button>
  133. </div>
  134. <div class="preset-table">
  135. <div class="preset-thead">
  136. <span class="col-id">编号</span>
  137. <span class="col-name">名称</span>
  138. <span class="col-action">操作</span>
  139. </div>
  140. <div class="preset-tbody">
  141. <div
  142. v-for="(row, index) in presetDisplayRows"
  143. :key="row?.id || `empty-${index}`"
  144. :class="['preset-row', { 'is-odd': index % 2 === 1, empty: !row }]"
  145. >
  146. <template v-if="row">
  147. <span class="col-id">{{ row.id }}</span>
  148. <span class="col-name" :title="row.name">{{ row.name }}</span>
  149. <span class="col-action">
  150. <button type="button" class="preset-zoom-btn" title="操作" @click.stop="onRegionPreview(row)">
  151. <SvgIcon name="3" size="16" />
  152. </button>
  153. <button type="button" class="preset-del-btn" title="删除" @click.stop="onDeletePreset(row)">
  154. <SvgIcon name="6" size="16" />
  155. </button>
  156. </span>
  157. </template>
  158. </div>
  159. </div>
  160. </div>
  161. </div>
  162. <!-- 其它 Tab 占位 -->
  163. <div v-show="showPlaceholder" class="tab-placeholder">
  164. {{ placeholderText }}
  165. </div>
  166. <!-- 未选摄像头提示弹窗(风格对齐报警弹窗) -->
  167. <Teleport to="body">
  168. <div v-if="deviceTipVisible" class="device-tip-mask" @click.self="closeDeviceTip">
  169. <div class="device-tip-panel">
  170. <div class="device-tip-frame">
  171. <div class="device-tip-title-tab">提示</div>
  172. <div class="device-tip-body">请选择设备!</div>
  173. <div class="device-tip-footer">
  174. <button type="button" class="device-tip-btn" @click="closeDeviceTip">确定</button>
  175. </div>
  176. </div>
  177. </div>
  178. </div>
  179. </Teleport>
  180. <!-- 查看图片弹窗:picVideoList 分页展示抓拍图 -->
  181. <Teleport to="body">
  182. <div v-if="picViewVisible" class="pic-view-mask" @click.self="closePicView">
  183. <div class="pic-view-panel">
  184. <div class="pic-view-header">
  185. <span class="pic-view-title">查看图片</span>
  186. <button type="button" class="alarm-icon-btn" title="关闭" @click="closePicView">
  187. <svg viewBox="0 0 16 16" width="14" height="14">
  188. <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
  189. </svg>
  190. </button>
  191. </div>
  192. <div class="pic-view-body">
  193. <div v-if="picViewLoading" class="pic-view-empty">图片加载中...</div>
  194. <div v-else-if="!picViewList.length" class="pic-view-empty">暂无抓拍图片</div>
  195. <div v-else class="pic-view-grid">
  196. <div v-for="item in picViewList" :key="item.id" class="pic-card">
  197. <div class="pic-card-images">
  198. <div v-if="item.visibleUrl" class="pic-thumb" title="点击放大预览" @click="openPicPreview(item.visibleUrl, '可见光', item)">
  199. <img :src="item.visibleUrl" alt="可见光" />
  200. <span class="pic-thumb-tag">可见光</span>
  201. </div>
  202. <div v-if="item.infraredUrl" class="pic-thumb" title="点击放大预览" @click="openPicPreview(item.infraredUrl, '红外', item)">
  203. <img :src="item.infraredUrl" alt="红外" />
  204. <span class="pic-thumb-tag">红外</span>
  205. </div>
  206. <div v-if="!item.visibleUrl && !item.infraredUrl" class="pic-thumb pic-thumb-empty">暂无图像</div>
  207. </div>
  208. <div class="pic-card-meta">
  209. <span class="pic-card-name" :title="item.deviceName">{{ item.deviceName || '--' }}</span>
  210. <span class="pic-card-time">{{ item.createTime || '--' }}</span>
  211. </div>
  212. </div>
  213. </div>
  214. </div>
  215. <div class="pic-view-footer">
  216. <a-pagination
  217. size="small"
  218. :current="picPage.current"
  219. :page-size="picPage.pageSize"
  220. :total="picPage.total"
  221. :show-size-changer="false"
  222. :show-less-items="true"
  223. @change="onPicPageChange"
  224. />
  225. </div>
  226. </div>
  227. </div>
  228. </Teleport>
  229. <!-- 查看图片放大预览 -->
  230. <Teleport to="body">
  231. <div v-if="picPreviewVisible" class="pic-preview-mask" @click.self="closePicPreview">
  232. <div class="pic-preview-panel">
  233. <div class="pic-preview-header">
  234. <span class="pic-preview-title">{{ picPreviewTitle }}</span>
  235. <button type="button" class="alarm-icon-btn" title="关闭" @click="closePicPreview">
  236. <svg viewBox="0 0 16 16" width="14" height="14">
  237. <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
  238. </svg>
  239. </button>
  240. </div>
  241. <div class="pic-preview-body">
  242. <img v-if="picPreviewUrl" :src="picPreviewUrl" :alt="picPreviewTitle" class="pic-preview-img" />
  243. </div>
  244. </div>
  245. </div>
  246. </Teleport>
  247. <!-- 一键抓拍结果弹窗 -->
  248. <Teleport to="body">
  249. <div v-if="snapshotViewVisible" class="snapshot-view-mask" @click.self="closeSnapshotView">
  250. <div class="snapshot-view-panel">
  251. <div class="snapshot-view-header">
  252. <span class="snapshot-view-title">一键抓拍</span>
  253. <button type="button" class="alarm-icon-btn" title="关闭" @click="closeSnapshotView">
  254. <svg viewBox="0 0 16 16" width="14" height="14">
  255. <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
  256. </svg>
  257. </button>
  258. </div>
  259. <div class="snapshot-view-body">
  260. <div class="snapshot-images">
  261. <div
  262. v-if="snapshotViewData.visibleUrl"
  263. class="snapshot-thumb"
  264. title="点击放大预览"
  265. @click="openPicPreview(snapshotViewData.visibleUrl, '可见光', snapshotViewData)"
  266. >
  267. <img :src="snapshotViewData.visibleUrl" alt="可见光" />
  268. <span class="pic-thumb-tag">可见光</span>
  269. </div>
  270. <div
  271. v-if="snapshotViewData.infraredUrl"
  272. class="snapshot-thumb"
  273. title="点击放大预览"
  274. @click="openPicPreview(snapshotViewData.infraredUrl, '红外', snapshotViewData)"
  275. >
  276. <img :src="snapshotViewData.infraredUrl" alt="红外" />
  277. <span class="pic-thumb-tag">红外</span>
  278. </div>
  279. <div v-if="!snapshotViewData.visibleUrl && !snapshotViewData.infraredUrl" class="snapshot-thumb snapshot-thumb-empty">
  280. 暂无抓拍图像
  281. </div>
  282. </div>
  283. <div class="snapshot-meta">
  284. <span class="snapshot-meta-name" :title="snapshotViewData.deviceName">
  285. {{ snapshotViewData.deviceName || '--' }}
  286. </span>
  287. <span class="snapshot-meta-time">{{ snapshotViewData.createTime || '--' }}</span>
  288. </div>
  289. </div>
  290. </div>
  291. </div>
  292. </Teleport>
  293. <!-- 停止录像结果弹窗 -->
  294. <Teleport to="body">
  295. <div v-if="stopRecordViewVisible" class="stop-record-view-mask" @click.self="closeStopRecordView">
  296. <div class="stop-record-view-panel">
  297. <div class="stop-record-view-header">
  298. <span class="stop-record-view-title">录像结果</span>
  299. <button type="button" class="alarm-icon-btn" title="关闭" @click="closeStopRecordView">
  300. <svg viewBox="0 0 16 16" width="14" height="14">
  301. <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
  302. </svg>
  303. </button>
  304. </div>
  305. <div class="stop-record-view-body">
  306. <div class="stop-record-videos">
  307. <div v-if="stopRecordViewData.visibleUrl" class="stop-record-video-item">
  308. <div class="stop-record-video-label">可见光</div>
  309. <video :key="stopRecordViewData.visibleUrl" class="stop-record-video" controls autoplay playsinline>
  310. <source :src="stopRecordViewData.visibleUrl" :type="stopRecordViewData.visibleMime" />
  311. </video>
  312. </div>
  313. <div v-if="stopRecordViewData.infraredUrl" class="stop-record-video-item">
  314. <div class="stop-record-video-label">红外</div>
  315. <video :key="stopRecordViewData.infraredUrl" class="stop-record-video" controls playsinline>
  316. <source :src="stopRecordViewData.infraredUrl" :type="stopRecordViewData.infraredMime" />
  317. </video>
  318. </div>
  319. <div v-if="!stopRecordViewData.visibleUrl && !stopRecordViewData.infraredUrl" class="stop-record-video-empty"> 暂无录像 </div>
  320. </div>
  321. <div class="stop-record-meta">
  322. <span class="stop-record-meta-name" :title="stopRecordViewData.deviceName">
  323. {{ stopRecordViewData.deviceName || '--' }}
  324. </span>
  325. <span class="stop-record-meta-time">{{ stopRecordViewData.recordTime || '--' }}</span>
  326. </div>
  327. </div>
  328. </div>
  329. </div>
  330. </Teleport>
  331. <!-- 查看录像弹窗:picVideoList type=1,分页列表内播放 MP4 -->
  332. <Teleport to="body">
  333. <div v-if="recordViewVisible" class="record-view-mask" @click.self="closeRecordView">
  334. <div class="record-view-panel">
  335. <div class="record-view-header">
  336. <span class="record-view-title">查看录像</span>
  337. <button type="button" class="alarm-icon-btn" title="关闭" @click="closeRecordView">
  338. <svg viewBox="0 0 16 16" width="14" height="14">
  339. <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
  340. </svg>
  341. </button>
  342. </div>
  343. <div class="record-view-body">
  344. <div class="record-list">
  345. <div v-if="recordViewLoading" class="record-empty">录像加载中...</div>
  346. <div v-else-if="!videoViewList.length" class="record-empty">暂无录像</div>
  347. <template v-else>
  348. <div
  349. v-for="(item, index) in videoViewList"
  350. :key="item.id"
  351. :class="['record-item', { active: currentRecordIndex === index }]"
  352. @click="playRecord(index)"
  353. >
  354. <div class="record-item-name" :title="item.fileName">{{ item.fileName }}</div>
  355. <div class="record-item-path" :title="item.deviceName">{{ item.deviceName || '--' }}</div>
  356. <div class="record-item-time">{{ item.recordTime || '--' }}</div>
  357. </div>
  358. </template>
  359. </div>
  360. <div class="record-player">
  361. <div v-if="currentRecordHasBoth" class="record-channel">
  362. <button type="button" :class="{ active: recordChannel === 'visible' }" @click="recordChannel = 'visible'">可见光</button>
  363. <button type="button" :class="{ active: recordChannel === 'infrared' }" @click="recordChannel = 'infrared'">红外</button>
  364. </div>
  365. <video v-if="currentRecordPlayUrl" :key="currentRecordPlayUrl" class="record-video" controls autoplay playsinline>
  366. <source :src="currentRecordPlayUrl" :type="currentRecordMime" />
  367. </video>
  368. <div v-else class="record-player-empty">请选择左侧录像进行播放</div>
  369. </div>
  370. </div>
  371. <div class="pic-view-footer">
  372. <a-pagination
  373. size="small"
  374. :current="recordPage.current"
  375. :page-size="recordPage.pageSize"
  376. :total="recordPage.total"
  377. :show-size-changer="false"
  378. :show-less-items="true"
  379. @change="onRecordPageChange"
  380. />
  381. </div>
  382. </div>
  383. </div>
  384. </Teleport>
  385. <!-- 报警自动弹窗:推送报警图片 -->
  386. <Teleport to="body">
  387. <div v-if="autoAlarmVisible" class="auto-alarm-mask" @click.self="closeAutoAlarmPopup">
  388. <div
  389. :class="[
  390. 'auto-alarm-panel',
  391. {
  392. expanded: autoAlarmExpanded,
  393. dual: autoAlarmData.imageUrls.length > 1,
  394. },
  395. ]"
  396. >
  397. <div class="auto-alarm-frame">
  398. <div class="auto-alarm-title-tab">{{ autoAlarmTitle }}</div>
  399. <div class="auto-alarm-toolbar">
  400. <button type="button" class="auto-alarm-icon-btn" title="放大" @click="toggleAutoAlarmExpand">
  401. <svg viewBox="0 0 16 16" width="14" height="14">
  402. <rect x="3" y="3" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1.3" />
  403. <path
  404. d="M9 3 H13 V7 M13 3 L9.5 6.5 M7 9 L3 13 M3 9 V13 H7"
  405. fill="none"
  406. stroke="currentColor"
  407. stroke-width="1.3"
  408. stroke-linecap="round"
  409. stroke-linejoin="round"
  410. />
  411. </svg>
  412. </button>
  413. <button type="button" class="auto-alarm-icon-btn" title="关闭" @click="closeAutoAlarmPopup">
  414. <svg viewBox="0 0 16 16" width="14" height="14">
  415. <rect x="2.5" y="2.5" width="8" height="8" fill="none" stroke="currentColor" stroke-width="1.3" />
  416. <path
  417. d="M8 8 L13.5 13.5 M10.5 13.5 H13.5 V10.5"
  418. fill="none"
  419. stroke="currentColor"
  420. stroke-width="1.3"
  421. stroke-linecap="round"
  422. stroke-linejoin="round"
  423. />
  424. </svg>
  425. </button>
  426. </div>
  427. <div class="auto-alarm-images" :class="{ dual: autoAlarmData.imageUrls.length > 1, single: autoAlarmData.imageUrls.length <= 1 }">
  428. <div
  429. v-for="(url, index) in autoAlarmData.imageUrls"
  430. :key="`${url}-${index}`"
  431. class="auto-alarm-image-wrap"
  432. title="点击放大预览"
  433. @click="openAutoAlarmPreview(url)"
  434. >
  435. <img class="auto-alarm-image" :src="url" :alt="`${autoAlarmTitle}-${index + 1}`" />
  436. <div v-if="index === 0 && autoAlarmData.maxTemp" class="auto-alarm-overlay top-left"> Max: {{ autoAlarmData.maxTemp }} ℃ </div>
  437. <div v-if="index === 0 && autoAlarmData.overlayTime" class="auto-alarm-overlay top-right">
  438. {{ autoAlarmData.overlayTime }}
  439. </div>
  440. <div v-if="index === 0 && autoAlarmData.hotspotValue" class="auto-alarm-hotspot">
  441. <span class="hotspot-box"></span>
  442. <span class="hotspot-value">{{ autoAlarmData.hotspotValue }}</span>
  443. </div>
  444. <div v-if="index === 0 && autoAlarmData.deviceLabel" class="auto-alarm-overlay bottom-right">
  445. {{ autoAlarmData.deviceLabel }}
  446. </div>
  447. </div>
  448. <div v-if="!autoAlarmData.imageUrls.length" class="auto-alarm-image-wrap">
  449. <div class="auto-alarm-image-empty">暂无告警图像</div>
  450. </div>
  451. </div>
  452. <div class="auto-alarm-footer">
  453. <span v-if="autoAlarmData.msgTxt">{{ autoAlarmData.msgTxt }}</span>
  454. <template v-else>
  455. <span>告警时间:{{ autoAlarmData.alarmTime || '--' }}</span>
  456. <span class="auto-alarm-sep">|</span>
  457. <span>告警设备:{{ autoAlarmData.alarmDevice || '--' }}</span>
  458. <span class="auto-alarm-sep">|</span>
  459. <span>告警类型:{{ autoAlarmData.alarmType || '--' }}</span>
  460. </template>
  461. <template v-if="autoAlarmData.msgTxt && (autoAlarmData.alarmDevice || autoAlarmData.alarmType)">
  462. <span class="auto-alarm-sep">|</span>
  463. <span>告警设备:{{ autoAlarmData.alarmDevice || '--' }}</span>
  464. <span class="auto-alarm-sep">|</span>
  465. <span>告警类型:{{ autoAlarmData.alarmType || '--' }}</span>
  466. </template>
  467. </div>
  468. </div>
  469. </div>
  470. </div>
  471. </Teleport>
  472. <!-- 报警图片点击放大预览 -->
  473. <Teleport to="body">
  474. <div v-if="autoAlarmPreviewVisible" class="auto-alarm-preview-mask" @click.self="closeAutoAlarmPreview">
  475. <div class="auto-alarm-preview-panel">
  476. <div class="auto-alarm-preview-header">
  477. <span>图片预览</span>
  478. <div class="auto-alarm-preview-close" @click="closeAutoAlarmPreview">×</div>
  479. </div>
  480. <div class="auto-alarm-preview-body">
  481. <img v-if="autoAlarmPreviewSrc" :src="autoAlarmPreviewSrc" alt="报警图片预览" />
  482. </div>
  483. </div>
  484. </div>
  485. </Teleport>
  486. </div>
  487. </template>
  488. <script setup lang="ts">
  489. import { computed, h, nextTick, onUnmounted, reactive, ref, watch, type PropType, type Ref } from 'vue';
  490. import type { EChartsOption } from 'echarts';
  491. import { Modal } from 'ant-design-vue';
  492. import { SvgIcon } from '/@/components/Icon';
  493. import { useECharts } from '/@/hooks/web/useECharts';
  494. import { useMessage } from '/@/hooks/web/useMessage';
  495. import { useUserStore } from '/@/store/modules/user';
  496. import { getToken } from '/@/utils/auth';
  497. import {
  498. // captureToFile,
  499. // recordMP4File,
  500. // stopRecordMP4File,
  501. getSysCfg,
  502. setSysCfg,
  503. // alarmAutoCapture,
  504. hsqComControl,
  505. picVideoList,
  506. presetList,
  507. presetSet,
  508. presetDelete,
  509. } from '../hsqHome.api';
  510. import type {
  511. DeviceNode,
  512. DeviceGroup,
  513. AngleState,
  514. StatsCount,
  515. PresetItem,
  516. RecordFileItem,
  517. AutoAlarmPopupData,
  518. PicVideoRecord,
  519. PicViewItem,
  520. VideoViewItem,
  521. } from './type';
  522. import { tabs, ANGLE_DEFAULT, angleFields, checkTips, presetToolbarItems, PRESET_EMPTY_ROW_COUNT } from '../hsqHome.data';
  523. import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
  524. const props = defineProps<{
  525. treeData: DeviceGroup[];
  526. selectedCamera: Record<string, any> | null;
  527. }>();
  528. const emit = defineEmits<{
  529. (e: 'select', device: DeviceNode): void;
  530. (e: 'checkChange', payload: { device: DeviceNode; index: number; checked: boolean }): void;
  531. (e: 'tabChange', key: string): void;
  532. (e: 'advancedAction', payload: { action: string; state: Record<string, unknown> }): void;
  533. (e: 'angleAction', payload: { action: string; state: AngleState }): void;
  534. (e: 'presetAction', key: string): void;
  535. /** 预置位操作列点击放大:放大当前选中摄像头并显示左侧区域选择,preset 为选中预置位编号 */
  536. (e: 'regionPreview', preset: number): void;
  537. /** 勾选设备 ID 列表(任一取流勾选即计入,随勾选动态更新) */
  538. (e: 'checkedDeviceIdsChange', ids: string[]): void;
  539. }>();
  540. const { createMessage } = useMessage();
  541. const userStore = useUserStore();
  542. /** 已实现内容的 Tab(其余走占位) */
  543. const CONTENT_TABS = new Set(['device', 'advanced', 'angle', 'stats', 'preset']);
  544. const activeTab = ref('device');
  545. const selectedId = ref('');
  546. const expandedMap = reactive<Record<string, boolean>>({});
  547. /** 无设备状态字段时的示意统计(对齐设计稿) */
  548. const STATS_FALLBACK: StatsCount = { online: 0, offline: 0 };
  549. const angleInputProps = {
  550. precision: 2,
  551. controls: false,
  552. bordered: false,
  553. } as const;
  554. /** 未选摄像头提示弹窗 */
  555. const deviceTipVisible = ref(false);
  556. /** 查看图片弹窗 */
  557. const picViewVisible = ref(false);
  558. const picViewLoading = ref(false);
  559. const picViewList = ref<PicViewItem[]>([]);
  560. const picPage = reactive({
  561. current: 1,
  562. pageSize: 8,
  563. total: 0,
  564. });
  565. /** 图片放大预览 */
  566. const picPreviewVisible = ref(false);
  567. const picPreviewUrl = ref('');
  568. const picPreviewTitle = ref('图片预览');
  569. /** 一键抓拍结果弹窗 */
  570. const snapshotViewVisible = ref(false);
  571. const snapshotViewData = reactive<PicViewItem>({
  572. id: '',
  573. deviceName: '',
  574. createTime: '',
  575. visibleUrl: '',
  576. infraredUrl: '',
  577. });
  578. /** 查看录像弹窗 */
  579. const recordViewVisible = ref(false);
  580. const recordViewLoading = ref(false);
  581. /** 录像文件列表(开始/停止录像仍写入本地缓存,查看录像弹窗不使用) */
  582. const recordFileList = ref<RecordFileItem[]>([]);
  583. /** 查看录像弹窗中的服务端录像列表 */
  584. const videoViewList = ref<VideoViewItem[]>([]);
  585. /** 当前播放的录像下标 */
  586. const currentRecordIndex = ref(-1);
  587. /** 当前录像通道:可见光 path1 / 红外 path2 */
  588. const recordChannel = ref<'visible' | 'infrared'>('visible');
  589. const recordPage = reactive({
  590. current: 1,
  591. pageSize: 8,
  592. total: 0,
  593. });
  594. /** 开始录像时使用的文件名(接口未返回 fileName 时回退) */
  595. const pendingRecordFileName = ref('');
  596. /** 停止录像结果弹窗 */
  597. const stopRecordViewVisible = ref(false);
  598. const stopRecordViewData = reactive({
  599. id: '',
  600. deviceName: '',
  601. recordTime: '',
  602. visibleUrl: '',
  603. infraredUrl: '',
  604. visibleMime: 'video/mp4',
  605. infraredMime: 'video/mp4',
  606. });
  607. /** 报警自动弹窗 */
  608. const autoAlarmVisible = ref(false);
  609. const autoAlarmExpanded = ref(false);
  610. const autoAlarmTitle = ref('最新告警图');
  611. const autoAlarmData = reactive<AutoAlarmPopupData>({
  612. imageUrl: '',
  613. imageUrls: [],
  614. maxTemp: '',
  615. overlayTime: '',
  616. deviceLabel: '',
  617. alarmTime: '',
  618. alarmDevice: '',
  619. alarmType: '',
  620. hotspotValue: '',
  621. msgTxt: '',
  622. });
  623. /** 报警图片放大预览 */
  624. const autoAlarmPreviewVisible = ref(false);
  625. const autoAlarmPreviewSrc = ref('');
  626. /** 报警自动弹窗专用 WebSocket(独立连接,避免影响全局消息推送) */
  627. let alarmAutoWs: WebSocket | null = null;
  628. const ALARM_WS_HOST = '192.168.247.85';
  629. const ALARM_WS_PORT = 8065;
  630. const advancedState = reactive({
  631. alarmPopup: false,
  632. fillLight1: false,
  633. fillLight2: false,
  634. recording: false,
  635. });
  636. const angleState = reactive<AngleState>({ ...ANGLE_DEFAULT });
  637. /** 进入角度看板时缓存的完整系统配置,保存时在此基础上覆盖角度字段 */
  638. const cachedSysCfg = ref<Record<string, any>>({});
  639. /** 预置位列表(数据来源:presetList 接口 POST /api/zc/device/getPresetInfo) */
  640. const presetData = ref<PresetItem[]>([]);
  641. const statsChartRef = ref<HTMLDivElement | null>(null);
  642. const { setOptions, resize: resizeStatsChart } = useECharts(statsChartRef as Ref<HTMLDivElement>);
  643. /** 当前选中的录像 */
  644. const currentRecordItem = computed(() => videoViewList.value[currentRecordIndex.value]);
  645. /** 同时存在可见光与红外时才显示通道切换 */
  646. const currentRecordHasBoth = computed(() => {
  647. const item = currentRecordItem.value;
  648. return !!(item?.visibleUrl && item?.infraredUrl);
  649. });
  650. /** 当前播放地址:优先当前通道,缺省时回退另一路 */
  651. const currentRecordPlayUrl = computed(() => {
  652. const item = currentRecordItem.value;
  653. if (!item) return '';
  654. if (recordChannel.value === 'infrared') return item.infraredUrl || item.visibleUrl;
  655. return item.visibleUrl || item.infraredUrl;
  656. });
  657. /** 当前录像 MIME,MP4 使用 video/mp4 */
  658. const currentRecordMime = computed(() => {
  659. const item = currentRecordItem.value;
  660. if (!item) return 'video/mp4';
  661. if (recordChannel.value === 'infrared' && item.infraredUrl) return item.infraredMime;
  662. if (item.visibleUrl) return item.visibleMime;
  663. return item.infraredMime || 'video/mp4';
  664. });
  665. /** 统计在线/离线:优先 netStatus,否则回退示意数据 */
  666. const statsCount = computed<StatsCount>(() => {
  667. let online = 0;
  668. let offline = 0;
  669. let hasStatus = false;
  670. for (const group of props.treeData || []) {
  671. for (const device of group.children || []) {
  672. hasStatus = true;
  673. if (device.netStatus == 1 || device.netStatus === true) online += 1;
  674. else offline += 1;
  675. }
  676. }
  677. return hasStatus ? { online, offline } : { ...STATS_FALLBACK };
  678. });
  679. const statsTotal = computed(() => statsCount.value.online + statsCount.value.offline);
  680. /** 预置位展示行:有数据用真实行,不足时补齐空行以还原斑马纹区域 */
  681. const presetDisplayRows = computed<(PresetItem | null)[]>(() => {
  682. const list = presetData.value || [];
  683. const rows: (PresetItem | null)[] = [...list];
  684. const minRows = Math.max(PRESET_EMPTY_ROW_COUNT, list.length);
  685. while (rows.length < minRows) rows.push(null);
  686. return rows;
  687. });
  688. const showPlaceholder = computed(() => !CONTENT_TABS.has(activeTab.value));
  689. const placeholderText = computed(() => '');
  690. /** 展平设备树中的全部设备节点 */
  691. const allDevices = computed(() => {
  692. const list: DeviceNode[] = [];
  693. for (const group of props.treeData || []) {
  694. for (const device of group.children || []) {
  695. list.push(device);
  696. }
  697. }
  698. return list;
  699. });
  700. /** 是否所有设备的可见光/红外均已勾选 */
  701. const isAllChecked = computed(() => {
  702. const list = allDevices.value;
  703. if (!list.length) return false;
  704. return list.every((device) => !!device.checks?.[0] && !!device.checks?.[1]);
  705. });
  706. /** 是否存在部分勾选(半选态) */
  707. const isPartialChecked = computed(() => {
  708. const list = allDevices.value;
  709. if (!list.length || isAllChecked.value) return false;
  710. return list.some((device) => !!device.checks?.[0] || !!device.checks?.[1]);
  711. });
  712. /** 当前勾选中的设备 ID 数组(可见光或红外任一勾选即计入) */
  713. const checkedDeviceIds = computed(() =>
  714. allDevices.value.filter((device) => !!device.checks?.[0] || !!device.checks?.[1]).map((device) => String(device.id))
  715. );
  716. /** 向父组件同步勾选设备 ID 列表 */
  717. function emitCheckedDeviceIds() {
  718. emit('checkedDeviceIdsChange', [...checkedDeviceIds.value]);
  719. }
  720. /** 分组是否展开(默认展开) */
  721. function isGroupExpanded(id: string) {
  722. return expandedMap[id] !== false;
  723. }
  724. /** 未选摄像头时弹出提示,已选中则返回摄像头对象 */
  725. function ensureSelectedCamera() {
  726. if (!props.selectedCamera) {
  727. deviceTipVisible.value = true;
  728. return null;
  729. }
  730. return props.selectedCamera;
  731. }
  732. /** SDK 设备操作公共参数(抓拍 / 录像 / 系统配置等) */
  733. function buildSdkDeviceParams(paramcode: string, camera = props.selectedCamera) {
  734. const deviceid = resolveCameraDeviceId(camera);
  735. return {
  736. deviceid,
  737. paramcode,
  738. noCheckPassword: true,
  739. };
  740. }
  741. /** 从选中摄像头解析设备 ID(兼容多种字段名) */
  742. function resolveCameraDeviceId(camera: Record<string, any> | null | undefined) {
  743. if (!camera) return '';
  744. const raw = camera.deviceid ?? camera.deviceId ?? camera.deviceID ?? camera.devId ?? '';
  745. if (raw == null) return '';
  746. return String(raw).trim();
  747. }
  748. /** 解析数值,非法时回退默认值 */
  749. function toFiniteNumber(value: unknown, fallback: number) {
  750. const num = Number(value);
  751. return Number.isFinite(num) ? num : fallback;
  752. }
  753. /** 构建饼图扇区配置 */
  754. function buildPieSlice(
  755. name: string,
  756. value: number,
  757. options: {
  758. color: string;
  759. labelLineColor: string;
  760. labelLineWidth?: number;
  761. length?: number;
  762. length2?: number;
  763. }
  764. ) {
  765. const show = value > 0;
  766. return {
  767. name,
  768. value,
  769. itemStyle: {
  770. color: options.color,
  771. borderColor: 'transparent',
  772. },
  773. label: {
  774. show,
  775. color: '#e8f4ff',
  776. fontSize: 13,
  777. formatter: `${name}${value}`,
  778. },
  779. labelLine: {
  780. show,
  781. length: options.length ?? 12,
  782. length2: options.length2 ?? 30,
  783. lineStyle: {
  784. color: options.labelLineColor,
  785. width: options.labelLineWidth ?? 1,
  786. },
  787. },
  788. };
  789. }
  790. /** 渲染统计饼图 */
  791. function renderStatsChart() {
  792. const { online, offline } = statsCount.value;
  793. const total = online + offline || 1;
  794. const startAngle = 232;
  795. const offlineSpan = (offline / total) * 360;
  796. const offlineMidAngle = startAngle - offlineSpan / 2;
  797. const rad = (offlineMidAngle * Math.PI) / 180;
  798. const arrowDist = 0.58 * 0.72;
  799. const arrowX = 50 + Math.cos(rad) * arrowDist * 50;
  800. const arrowY = 52 - Math.sin(rad) * arrowDist * 50;
  801. const option = {
  802. backgroundColor: 'transparent',
  803. animationDuration: 400,
  804. series: [
  805. {
  806. type: 'pie',
  807. radius: ['0%', '58%'],
  808. center: ['50%', '52%'],
  809. startAngle,
  810. clockwise: true,
  811. avoidLabelOverlap: true,
  812. silent: true,
  813. data: [
  814. buildPieSlice('离线', offline, {
  815. color: 'rgba(7, 21, 40, 0)',
  816. labelLineColor: '#01fefc',
  817. labelLineWidth: 1.5,
  818. length: 14,
  819. length2: 40,
  820. }),
  821. buildPieSlice('在线', online, {
  822. color: '#2f6fb8',
  823. labelLineColor: 'rgba(232, 244, 255, 0.85)',
  824. }),
  825. ],
  826. },
  827. ],
  828. graphic:
  829. offline > 0
  830. ? [
  831. {
  832. type: 'polygon',
  833. z: 10,
  834. rotation: -rad,
  835. left: `${arrowX}%`,
  836. top: `${arrowY}%`,
  837. bounding: 'raw',
  838. origin: [0, 0],
  839. shape: {
  840. points: [
  841. [8, 0],
  842. [0, -4.5],
  843. [0, 4.5],
  844. ],
  845. },
  846. style: { fill: '#01fefc' },
  847. },
  848. ]
  849. : [],
  850. } as EChartsOption;
  851. setOptions(option);
  852. }
  853. function changeTab(key: string) {
  854. if (activeTab.value === key) return;
  855. activeTab.value = key;
  856. }
  857. function toggleGroup(id: string) {
  858. expandedMap[id] = !isGroupExpanded(id);
  859. }
  860. function selectDevice(device: DeviceNode) {
  861. selectedId.value = device.id;
  862. emit('select', device);
  863. }
  864. function toggleCheck(device: DeviceNode, index: number, checked: boolean) {
  865. device.checks[index] = checked;
  866. emit('checkChange', { device, index, checked });
  867. emitCheckedDeviceIds();
  868. }
  869. /** 一键全选 / 取消全选:同步所有设备的可见光与红外勾选 */
  870. function toggleSelectAll(checked: boolean) {
  871. for (const device of allDevices.value) {
  872. if (!device.checks) {
  873. device.checks = [false, false];
  874. }
  875. for (let index = 0; index < device.checks.length; index++) {
  876. if (!!device.checks[index] === checked) continue;
  877. device.checks[index] = checked;
  878. emit('checkChange', { device, index, checked });
  879. }
  880. }
  881. emitCheckedDeviceIds();
  882. }
  883. function onAdvancedAction(action: string) {
  884. emit('advancedAction', { action, state: { ...advancedState } });
  885. const actionMap: Record<string, () => void> = {
  886. snapshot: trySnapshot,
  887. viewImages: tryViewImages,
  888. viewRecords: tryViewRecords,
  889. };
  890. actionMap[action]?.();
  891. }
  892. /** 关闭未选设备提示 */
  893. function closeDeviceTip() {
  894. deviceTipVisible.value = false;
  895. }
  896. /** 有选中摄像头且 deviceid 非空则调用抓拍接口,否则提示选择设备 */
  897. async function trySnapshot() {
  898. const camera = ensureSelectedCamera();
  899. if (!camera) return;
  900. const deviceid = resolveCameraDeviceId(camera);
  901. if (!deviceid) {
  902. deviceTipVisible.value = true;
  903. return;
  904. }
  905. try {
  906. const res = await hsqComControl({
  907. ...buildSdkDeviceParams('captureToFile', camera),
  908. deviceid,
  909. });
  910. const ok = openSnapshotView(res as Record<string, any>);
  911. if (!ok) {
  912. createMessage.warning('抓拍成功,但未返回可展示图片');
  913. return;
  914. }
  915. createMessage.success('抓拍成功');
  916. } catch (e) {
  917. console.error('一键抓拍失败', e);
  918. }
  919. }
  920. /** 解析一键抓拍返回(兼容 result 包裹与直接对象) */
  921. function normalizeSnapshotResult(res: Record<string, any> | null | undefined) {
  922. if (!res || typeof res !== 'object') return null;
  923. if (res.success === false) return null;
  924. const data = (res.result && typeof res.result === 'object' ? res.result : res) as Record<string, any>;
  925. const path1 = data.path1;
  926. const path2 = data.path2;
  927. if (path1 == null && path2 == null && !data.id) return null;
  928. return data;
  929. }
  930. /** 打开一键抓拍结果弹窗;无可展示数据时返回 false */
  931. function openSnapshotView(res: Record<string, any> | null | undefined) {
  932. const data = normalizeSnapshotResult(res);
  933. if (!data) return false;
  934. snapshotViewData.id = String(data.id || `snapshot-${Date.now()}`);
  935. snapshotViewData.deviceName = String(data.deviceName || data.devicePos || '');
  936. snapshotViewData.createTime = String(data.createTime || '');
  937. snapshotViewData.visibleUrl = resolvePicUrl(data.path1);
  938. snapshotViewData.infraredUrl = resolvePicUrl(data.path2);
  939. if (!snapshotViewData.visibleUrl && !snapshotViewData.infraredUrl) return false;
  940. snapshotViewVisible.value = true;
  941. return true;
  942. }
  943. /** 关闭一键抓拍结果弹窗 */
  944. function closeSnapshotView() {
  945. snapshotViewVisible.value = false;
  946. closePicPreview();
  947. }
  948. /** 拼接录像完整路径(兼容 Windows / Unix 分隔符) */
  949. function buildRecordFullPath(filePath: string, fileName: string) {
  950. if (!filePath) return fileName;
  951. if (!fileName) return filePath;
  952. const normalized = filePath.replace(/[/\\]+$/, '');
  953. const sep = filePath.includes('\\') ? '\\' : '/';
  954. return `${normalized}${sep}${fileName}`;
  955. }
  956. /** 将存储路径转为可播放的 HTTP 地址 */
  957. function buildRecordPlayUrl(fullPath: string) {
  958. if (!fullPath) return '';
  959. if (/^https?:\/\//i.test(fullPath)) return fullPath;
  960. return getFileAccessHttpUrl(fullPath.replace(/\\/g, '/'));
  961. }
  962. /** 根据 recordMP4File 返回写入录像列表 */
  963. function appendRecordFile(res: Record<string, any> | null | undefined, fallbackName: string) {
  964. const data = res || {};
  965. const fileName = String(data.fileName || fallbackName || '');
  966. const filePath = String(data.filePath || '');
  967. if (!fileName) return;
  968. const fullPath = buildRecordFullPath(filePath, fileName);
  969. const item: RecordFileItem = {
  970. fileName,
  971. filePath,
  972. fullPath,
  973. playUrl: buildRecordPlayUrl(fullPath),
  974. recordTime: new Date().toLocaleString(),
  975. };
  976. recordFileList.value = [item, ...recordFileList.value.filter((r) => r.fullPath !== fullPath)];
  977. }
  978. /** 将抓拍路径转为可访问图片地址 */
  979. function resolvePicUrl(raw: unknown) {
  980. if (raw == null || raw === '') return '';
  981. const text = String(raw);
  982. if (/^https?:\/\//i.test(text) || text.startsWith('data:')) return text;
  983. const path = text.includes('h') ? text.substring(text.indexOf('h')) : text;
  984. return getFileAccessHttpUrl(path.replace(/\\/g, '/'));
  985. }
  986. /** 解析 picVideoList 分页结果(兼容 result 包裹与直接分页对象) */
  987. function normalizePicPage(res: Record<string, any> | null | undefined) {
  988. const data = (res?.result && typeof res.result === 'object' ? res.result : res) || {};
  989. const records = Array.isArray(data.records) ? (data.records as PicVideoRecord[]) : [];
  990. return {
  991. records,
  992. total: Number(data.total) || 0,
  993. };
  994. }
  995. function mapPicRecords(records: PicVideoRecord[]): PicViewItem[] {
  996. return records.map((item, index) => ({
  997. id: String(item.id || `${item.deviceId || 'pic'}-${index}`),
  998. deviceName: String(item.deviceName || item.devicePos || ''),
  999. createTime: String(item.createTime || ''),
  1000. visibleUrl: resolvePicUrl(item.path1),
  1001. infraredUrl: resolvePicUrl(item.path2),
  1002. }));
  1003. }
  1004. /** 按当前分页拉取抓拍图片 */
  1005. async function fetchPicList() {
  1006. const camera = props.selectedCamera;
  1007. const deviceId = resolveCameraDeviceId(camera);
  1008. if (!deviceId) return;
  1009. picViewLoading.value = true;
  1010. try {
  1011. const res = await picVideoList({
  1012. deviceId,
  1013. pageNo: picPage.current,
  1014. pageSize: picPage.pageSize,
  1015. type: 0,
  1016. });
  1017. const page = normalizePicPage(res as Record<string, any>);
  1018. picViewList.value = mapPicRecords(page.records);
  1019. picPage.total = page.total;
  1020. } catch (e) {
  1021. console.error('获取抓拍图片失败', e);
  1022. picViewList.value = [];
  1023. createMessage.error('获取抓拍图片失败');
  1024. } finally {
  1025. picViewLoading.value = false;
  1026. }
  1027. }
  1028. /** 查看图片:未选设备提示;已选则打开分页图片弹窗 */
  1029. function tryViewImages() {
  1030. const camera = ensureSelectedCamera();
  1031. if (!camera) return;
  1032. const deviceId = resolveCameraDeviceId(camera);
  1033. if (!deviceId) {
  1034. deviceTipVisible.value = true;
  1035. return;
  1036. }
  1037. picPage.current = 1;
  1038. picViewList.value = [];
  1039. picViewVisible.value = true;
  1040. fetchPicList();
  1041. }
  1042. function closePicView() {
  1043. picViewVisible.value = false;
  1044. closePicPreview();
  1045. }
  1046. /** 打开图片放大预览 */
  1047. function openPicPreview(url: string, channelLabel: string, item?: PicViewItem) {
  1048. if (!url) return;
  1049. const deviceName = item?.deviceName ? ` - ${item.deviceName}` : '';
  1050. picPreviewUrl.value = url;
  1051. picPreviewTitle.value = `${channelLabel}预览${deviceName}`;
  1052. picPreviewVisible.value = true;
  1053. }
  1054. /** 关闭图片放大预览 */
  1055. function closePicPreview() {
  1056. picPreviewVisible.value = false;
  1057. picPreviewUrl.value = '';
  1058. picPreviewTitle.value = '图片预览';
  1059. }
  1060. function onPicPageChange(page: number) {
  1061. picPage.current = page;
  1062. fetchPicList();
  1063. }
  1064. /** 录像地址转可播放 URL,并按扩展名识别 MIME(默认 MP4) */
  1065. function resolveVideoPlay(raw: unknown) {
  1066. const text = String(raw || '').trim();
  1067. if (!text) return { playUrl: '', mimeType: 'video/mp4', fileName: '' };
  1068. const fileName = text.split(/[/\\]/).pop() || '录像.mp4';
  1069. const ext = (fileName.split('.').pop() || 'mp4').toLowerCase();
  1070. const mimeMap: Record<string, string> = {
  1071. mp4: 'video/mp4',
  1072. m4v: 'video/mp4',
  1073. webm: 'video/webm',
  1074. ogg: 'video/ogg',
  1075. mov: 'video/quicktime',
  1076. };
  1077. return {
  1078. playUrl: resolvePicUrl(text),
  1079. mimeType: mimeMap[ext] || 'video/mp4',
  1080. fileName,
  1081. };
  1082. }
  1083. /** 将 picVideoList 录像记录转为可播放列表(path1 可见光,path2 红外) */
  1084. function mapVideoRecords(records: PicVideoRecord[]): VideoViewItem[] {
  1085. return records.map((item, index) => {
  1086. const visible = resolveVideoPlay(item.path1);
  1087. const infrared = resolveVideoPlay(item.path2);
  1088. const fileName = visible.fileName || infrared.fileName || item.deviceName || '录像.mp4';
  1089. return {
  1090. id: String(item.id || `${item.deviceId || 'video'}-${index}`),
  1091. fileName,
  1092. deviceName: String(item.deviceName || item.devicePos || ''),
  1093. recordTime: String(item.createTime || ''),
  1094. visibleUrl: visible.playUrl,
  1095. infraredUrl: infrared.playUrl,
  1096. visibleMime: visible.mimeType,
  1097. infraredMime: infrared.mimeType,
  1098. };
  1099. });
  1100. }
  1101. /** 拉取录像分页;成功返回 true */
  1102. async function fetchRecordList() {
  1103. const deviceId = resolveCameraDeviceId(props.selectedCamera);
  1104. if (!deviceId) return false;
  1105. recordViewLoading.value = true;
  1106. try {
  1107. const res = await picVideoList({
  1108. deviceId,
  1109. pageNo: recordPage.current,
  1110. pageSize: recordPage.pageSize,
  1111. type: 1,
  1112. });
  1113. const page = normalizePicPage(res as Record<string, any>);
  1114. videoViewList.value = mapVideoRecords(page.records);
  1115. recordPage.total = page.total;
  1116. return true;
  1117. } catch (e) {
  1118. console.error('获取录像列表失败', e);
  1119. createMessage.error('获取录像列表失败');
  1120. return false;
  1121. } finally {
  1122. recordViewLoading.value = false;
  1123. }
  1124. }
  1125. /** 查看录像:未选设备提示;已选则请求成功后再打开弹窗 */
  1126. async function tryViewRecords() {
  1127. const camera = ensureSelectedCamera();
  1128. if (!camera) return;
  1129. const deviceId = resolveCameraDeviceId(camera);
  1130. if (!deviceId) {
  1131. deviceTipVisible.value = true;
  1132. return;
  1133. }
  1134. recordPage.current = 1;
  1135. const ok = await fetchRecordList();
  1136. if (!ok) return;
  1137. recordViewVisible.value = true;
  1138. playRecord(videoViewList.value.length ? 0 : -1);
  1139. }
  1140. function onRecordPageChange(page: number) {
  1141. recordPage.current = page;
  1142. fetchRecordList().then((ok) => {
  1143. if (!ok) return;
  1144. playRecord(videoViewList.value.length ? 0 : -1);
  1145. });
  1146. }
  1147. /** 关闭查看录像弹窗 */
  1148. function closeRecordView() {
  1149. recordViewVisible.value = false;
  1150. currentRecordIndex.value = -1;
  1151. }
  1152. /** 播放指定录像,默认可见光,没有可见光时播红外 */
  1153. function playRecord(index: number) {
  1154. if (index < 0 || index >= videoViewList.value.length) {
  1155. currentRecordIndex.value = -1;
  1156. return;
  1157. }
  1158. currentRecordIndex.value = index;
  1159. const item = videoViewList.value[index];
  1160. recordChannel.value = item?.visibleUrl ? 'visible' : 'infrared';
  1161. }
  1162. /** 切换录像状态:开始/停止均走 SDK 接口,成功后再切换本地录像态 */
  1163. async function setRecording(recording: boolean) {
  1164. const camera = ensureSelectedCamera();
  1165. if (!camera) return;
  1166. const paramcode = recording ? 'recordMP4File' : 'stopRecordMP4File';
  1167. const request = hsqComControl;
  1168. const fileName = recording ? `record_${Date.now()}.mp4` : pendingRecordFileName.value;
  1169. if (recording) pendingRecordFileName.value = fileName;
  1170. try {
  1171. const params = {
  1172. ...buildSdkDeviceParams(paramcode, camera),
  1173. ...(recording ? { s1: fileName } : {}),
  1174. };
  1175. const res = await request(params);
  1176. if (recording) {
  1177. if (res && ((res as any).fileName || (res as any).filePath)) {
  1178. appendRecordFile(res as Record<string, any>, fileName);
  1179. }
  1180. } else {
  1181. const opened = openStopRecordView(res as Record<string, any>);
  1182. if (!opened && res && ((res as any).fileName || (res as any).filePath)) {
  1183. appendRecordFile(res as Record<string, any>, fileName);
  1184. }
  1185. }
  1186. advancedState.recording = recording;
  1187. onAdvancedAction(recording ? 'startRecord' : 'stopRecord');
  1188. createMessage.success(recording ? '开始录像成功' : '停止录像成功');
  1189. } catch (e) {
  1190. console.error(recording ? '开始录像失败' : '停止录像失败', e);
  1191. }
  1192. }
  1193. /** 解析停止录像返回(兼容 result 包裹与直接对象) */
  1194. function normalizeStopRecordResult(res: Record<string, any> | null | undefined) {
  1195. if (!res || typeof res !== 'object') return null;
  1196. if (res.success === false) return null;
  1197. const data = (res.result && typeof res.result === 'object' ? res.result : res) as Record<string, any>;
  1198. if (data.path1 == null && data.path2 == null) return null;
  1199. return data;
  1200. }
  1201. /** 打开停止录像结果弹窗;无可播放地址时返回 false */
  1202. function openStopRecordView(res: Record<string, any> | null | undefined) {
  1203. const data = normalizeStopRecordResult(res);
  1204. if (!data) return false;
  1205. const visible = resolveVideoPlay(data.path1);
  1206. const infrared = resolveVideoPlay(data.path2);
  1207. if (!visible.playUrl && !infrared.playUrl) return false;
  1208. stopRecordViewData.id = String(data.id || `stop-record-${Date.now()}`);
  1209. stopRecordViewData.deviceName = String(data.deviceName || data.devicePos || '');
  1210. stopRecordViewData.recordTime = String(data.createTime || '');
  1211. stopRecordViewData.visibleUrl = visible.playUrl;
  1212. stopRecordViewData.infraredUrl = infrared.playUrl;
  1213. stopRecordViewData.visibleMime = visible.mimeType;
  1214. stopRecordViewData.infraredMime = infrared.mimeType;
  1215. stopRecordViewVisible.value = true;
  1216. return true;
  1217. }
  1218. /** 关闭停止录像结果弹窗 */
  1219. function closeStopRecordView() {
  1220. stopRecordViewVisible.value = false;
  1221. stopRecordViewData.visibleUrl = '';
  1222. stopRecordViewData.infraredUrl = '';
  1223. }
  1224. /** 解析报警推送中的图片地址(兼容 path1/path2、IRPic、picName 等) */
  1225. function resolveAutoAlarmImageUrl(data: Record<string, any>) {
  1226. const urls = collectAutoAlarmImageUrls(data);
  1227. return urls[0] || '';
  1228. }
  1229. /** 收集推送中的全部可展示图片地址(支持 picName + picName2) */
  1230. function collectAutoAlarmImageUrls(data: Record<string, any>): string[] {
  1231. const rawList: unknown[] = [];
  1232. if (data.picName || data.picName2) {
  1233. if (data.picName) rawList.push(data.picName);
  1234. if (data.picName2) rawList.push(data.picName2);
  1235. } else {
  1236. const single = data.IRPic || data.irPic || data.path2 || data.normalPic || data.path1 || data.imageUrl || data.url || data.fileName || '';
  1237. if (single) rawList.push(single);
  1238. else if (data.filePath) {
  1239. const fullPath = buildRecordFullPath(String(data.filePath), String(data.fileName || ''));
  1240. const playUrl = buildRecordPlayUrl(fullPath);
  1241. if (playUrl) return [playUrl];
  1242. }
  1243. }
  1244. const urls: string[] = [];
  1245. const seen = new Set<string>();
  1246. rawList.forEach((raw) => {
  1247. const url = resolvePicUrl(raw);
  1248. if (url && !seen.has(url)) {
  1249. seen.add(url);
  1250. urls.push(url);
  1251. }
  1252. });
  1253. return urls;
  1254. }
  1255. /** 从 msgTxt / warndata 文案解析设备名与告警类型,如「采场西侧2-热源告警」 */
  1256. function parseAlarmMsgText(text: unknown) {
  1257. const raw = String(text || '').trim();
  1258. if (!raw) return { device: '', type: '' };
  1259. const sepIdx = raw.indexOf('-');
  1260. if (sepIdx <= 0) return { device: raw, type: '' };
  1261. const device = raw.slice(0, sepIdx).trim();
  1262. const typePart = raw.slice(sepIdx + 1).trim();
  1263. let type = typePart.replace(/告警$/u, '').trim();
  1264. if (typePart.includes('热源')) type = '热源';
  1265. else if (typePart.includes('闯入')) type = '闯入';
  1266. else if (typePart.includes('烟雾')) type = '烟雾';
  1267. return { device, type: type || typePart };
  1268. }
  1269. /** 判断 WebSocket 消息是否为可展示的报警信息 */
  1270. function hasAutoAlarmPayload(res: unknown) {
  1271. if (res == null || res === false || res === '') return false;
  1272. if (typeof res === 'number') return false;
  1273. if (typeof res === 'string') return res.trim().length > 0;
  1274. if (typeof res === 'object') {
  1275. const data = res as Record<string, any>;
  1276. const detail = data.msgDetail && typeof data.msgDetail === 'object' ? (data.msgDetail as Record<string, any>) : null;
  1277. return !!(
  1278. data.IRPic ||
  1279. data.irPic ||
  1280. data.normalPic ||
  1281. data.path1 ||
  1282. data.path2 ||
  1283. data.picName ||
  1284. data.picName2 ||
  1285. data.imageUrl ||
  1286. data.url ||
  1287. data.filePath ||
  1288. data.fileName ||
  1289. data.alarmTime ||
  1290. data.createTime ||
  1291. data.deviceName ||
  1292. data.maxTemp ||
  1293. data.max ||
  1294. data.eventType ||
  1295. data.topic === 'warn' ||
  1296. data.cmd === 'user' ||
  1297. data.cmd === 'alarm' ||
  1298. data.msgTxt ||
  1299. data.warndata ||
  1300. data.msgDetail ||
  1301. detail?.picName ||
  1302. detail?.picName2
  1303. );
  1304. }
  1305. return false;
  1306. }
  1307. /** 从 WebSocket 原始消息中提取报警数据对象 */
  1308. function extractAlarmPayload(message: Record<string, any>) {
  1309. if (!message || typeof message !== 'object') return null;
  1310. const nested =
  1311. (message.msgDetail && typeof message.msgDetail === 'object' ? message.msgDetail : null) ||
  1312. (message.result && typeof message.result === 'object' ? message.result : null) ||
  1313. (message.data && typeof message.data === 'object' ? message.data : null) ||
  1314. (message.warndata && typeof message.warndata === 'object' ? message.warndata : null) ||
  1315. (message.payload && typeof message.payload === 'object' ? message.payload : null);
  1316. const candidate = (nested || message) as Record<string, any>;
  1317. if (!hasAutoAlarmPayload(candidate) && !hasAutoAlarmPayload(message)) return null;
  1318. return {
  1319. ...message,
  1320. ...candidate,
  1321. };
  1322. }
  1323. /** 将报警数据映射到弹窗字段 */
  1324. function applyAutoAlarmData(res: Record<string, any>) {
  1325. const msgTxt = String(res.msgTxt || (typeof res.warndata === 'string' ? res.warndata : '') || '');
  1326. const parsedText = parseAlarmMsgText(msgTxt);
  1327. const maxTemp = res.maxTemp ?? res.max ?? res.maxTemperature ?? '';
  1328. const alarmTime = String(res.alarmTime || res.createTime || res.time || '');
  1329. const alarmDevice = String(res.alarmDevice || res.deviceName || res.devName || res.strname || parsedText.device || '');
  1330. const alarmTypeRaw = res.alarmType ?? res.eventType ?? res.type ?? parsedText.type;
  1331. let alarmType = String(alarmTypeRaw ?? '');
  1332. if (alarmTypeRaw == '10001' || alarmTypeRaw === 10001 || String(alarmType).includes('热源')) {
  1333. alarmType = '热源';
  1334. autoAlarmTitle.value = String(res.msgTitle || '热源最新告警图');
  1335. } else if (alarmTypeRaw == '10002' || alarmTypeRaw === 10002 || String(alarmType).includes('闯入')) {
  1336. alarmType = '闯入';
  1337. autoAlarmTitle.value = String(res.msgTitle || '闯入最新告警图');
  1338. } else {
  1339. autoAlarmTitle.value = String(res.msgTitle || '最新告警图');
  1340. if (!alarmType) alarmType = '热源';
  1341. }
  1342. const imageUrls = collectAutoAlarmImageUrls(res);
  1343. autoAlarmData.imageUrls = imageUrls;
  1344. autoAlarmData.imageUrl = imageUrls[0] || resolveAutoAlarmImageUrl(res);
  1345. autoAlarmData.maxTemp = maxTemp === '' || maxTemp == null ? '' : String(maxTemp);
  1346. autoAlarmData.overlayTime = String(res.overlayTime || res.captureTime || alarmTime || '');
  1347. autoAlarmData.deviceLabel = String(res.deviceLabel || res.channelName || `${alarmDevice}${alarmDevice ? '红外' : ''}`);
  1348. autoAlarmData.alarmTime = alarmTime;
  1349. autoAlarmData.alarmDevice = alarmDevice;
  1350. autoAlarmData.alarmType = alarmType;
  1351. autoAlarmData.hotspotValue = String(res.hotspotValue ?? res.maxTemp ?? res.max ?? '');
  1352. autoAlarmData.msgTxt = msgTxt;
  1353. }
  1354. /** 构建报警 WebSocket 连接地址 */
  1355. function buildAlarmWebSocketUrl() {
  1356. const token = (getToken() || '') as string;
  1357. const userId = String((userStore.getUserInfo as any)?.id || '');
  1358. const base = `ws://${ALARM_WS_HOST}:${ALARM_WS_PORT}/websocket/${userId}`;
  1359. return token ? `${base}?token=${encodeURIComponent(token)}` : base;
  1360. }
  1361. /** 断开报警自动弹窗 WebSocket */
  1362. function disconnectAlarmWebSocket() {
  1363. if (!alarmAutoWs) return;
  1364. try {
  1365. alarmAutoWs.onopen = null;
  1366. alarmAutoWs.onmessage = null;
  1367. alarmAutoWs.onerror = null;
  1368. alarmAutoWs.onclose = null;
  1369. if (alarmAutoWs.readyState === WebSocket.OPEN || alarmAutoWs.readyState === WebSocket.CONNECTING) {
  1370. alarmAutoWs.close();
  1371. }
  1372. } catch (e) {
  1373. console.error('关闭报警 WebSocket 失败', e);
  1374. } finally {
  1375. alarmAutoWs = null;
  1376. }
  1377. }
  1378. /** 处理报警 WebSocket 消息:有报警信息则打开弹窗 */
  1379. function handleAlarmWebSocketMessage(raw: MessageEvent | string | Record<string, any>) {
  1380. if (!advancedState.alarmPopup) return;
  1381. try {
  1382. let parsed: any = raw;
  1383. if (raw && typeof raw === 'object' && 'data' in (raw as MessageEvent)) {
  1384. parsed = (raw as MessageEvent).data;
  1385. }
  1386. if (typeof parsed === 'string') {
  1387. const text = parsed.trim();
  1388. if (!text) return;
  1389. parsed = JSON.parse(text);
  1390. }
  1391. if (!parsed || typeof parsed !== 'object') return;
  1392. const payload = extractAlarmPayload(parsed as Record<string, any>);
  1393. if (!payload) return;
  1394. applyAutoAlarmData(payload);
  1395. autoAlarmExpanded.value = false;
  1396. autoAlarmPreviewVisible.value = false;
  1397. autoAlarmPreviewSrc.value = '';
  1398. autoAlarmVisible.value = true;
  1399. } catch (e) {
  1400. console.error('解析报警 WebSocket 消息失败', e);
  1401. }
  1402. }
  1403. /** 连接报警自动弹窗 WebSocket */
  1404. function connectAlarmWebSocket() {
  1405. disconnectAlarmWebSocket();
  1406. const url = buildAlarmWebSocketUrl();
  1407. try {
  1408. const ws = new WebSocket(url);
  1409. alarmAutoWs = ws;
  1410. ws.onopen = () => {
  1411. console.log('[报警WebSocket] 连接成功', url);
  1412. };
  1413. ws.onmessage = (event) => {
  1414. handleAlarmWebSocketMessage(event);
  1415. };
  1416. ws.onerror = (event) => {
  1417. console.error('[报警WebSocket] 连接错误', event);
  1418. };
  1419. ws.onclose = () => {
  1420. console.log('[报警WebSocket] 连接关闭');
  1421. if (alarmAutoWs === ws) alarmAutoWs = null;
  1422. };
  1423. } catch (e) {
  1424. console.error('[报警WebSocket] 创建连接失败', e);
  1425. createMessage.error('报警 WebSocket 连接失败');
  1426. }
  1427. }
  1428. /** 勾选「报警自动弹窗」:连接 WebSocket;取消则断开 */
  1429. function onAlarmPopupChange(checked: boolean) {
  1430. advancedState.alarmPopup = checked;
  1431. if (checked) {
  1432. connectAlarmWebSocket();
  1433. return;
  1434. }
  1435. disconnectAlarmWebSocket();
  1436. autoAlarmVisible.value = false;
  1437. autoAlarmExpanded.value = false;
  1438. closeAutoAlarmPreview();
  1439. }
  1440. function toggleAutoAlarmExpand() {
  1441. autoAlarmExpanded.value = !autoAlarmExpanded.value;
  1442. }
  1443. /** 打开报警图片放大预览 */
  1444. function openAutoAlarmPreview(src: string) {
  1445. if (!src) return;
  1446. autoAlarmPreviewSrc.value = src;
  1447. autoAlarmPreviewVisible.value = true;
  1448. }
  1449. /** 关闭报警图片放大预览 */
  1450. function closeAutoAlarmPreview() {
  1451. autoAlarmPreviewVisible.value = false;
  1452. autoAlarmPreviewSrc.value = '';
  1453. }
  1454. /** 关闭弹窗(仍勾选时继续监听 WebSocket) */
  1455. function closeAutoAlarmPopup() {
  1456. autoAlarmVisible.value = false;
  1457. autoAlarmExpanded.value = false;
  1458. closeAutoAlarmPreview();
  1459. }
  1460. /** 解析 getSysCfg 返回的系统配置对象 */
  1461. function normalizeSysCfg(res: Record<string, any> | null | undefined) {
  1462. if (!res || typeof res !== 'object') return {};
  1463. if (res.data && typeof res.data === 'object' && !Array.isArray(res.data)) {
  1464. return { ...res.data };
  1465. }
  1466. return { ...res };
  1467. }
  1468. /** 将系统配置中的扫描角度同步到角度看板表单 */
  1469. function applyAngleFromSysCfg(cfg: Record<string, any>) {
  1470. angleState.hMin = toFiniteNumber(cfg.horStartAngle, ANGLE_DEFAULT.hMin);
  1471. angleState.hMax = toFiniteNumber(cfg.horEndAngle, ANGLE_DEFAULT.hMax);
  1472. angleState.vMin = toFiniteNumber(cfg.verStartAngle, ANGLE_DEFAULT.vMin);
  1473. angleState.vMax = toFiniteNumber(cfg.verEndAngle, ANGLE_DEFAULT.vMax);
  1474. }
  1475. /** 切换到角度看板时拉取系统配置,回填扫描角度并缓存完整配置 */
  1476. async function loadAngleSysCfg() {
  1477. const camera = props.selectedCamera;
  1478. const deviceid = resolveCameraDeviceId(camera);
  1479. if (!deviceid) {
  1480. cachedSysCfg.value = {};
  1481. return;
  1482. }
  1483. try {
  1484. const cfgRes = await getSysCfg({
  1485. deviceid,
  1486. paramcode: 'getSysCfg',
  1487. noCheckPassword: true,
  1488. });
  1489. const cfg = normalizeSysCfg(cfgRes as Record<string, any>);
  1490. cachedSysCfg.value = cfg;
  1491. applyAngleFromSysCfg(cfg);
  1492. } catch (e) {
  1493. console.error('获取系统配置失败', e);
  1494. cachedSysCfg.value = {};
  1495. }
  1496. }
  1497. /** 校验并读取当前角度看板四个角度值 */
  1498. function readAngleRange() {
  1499. const horStartAngle = Number(angleState.hMin);
  1500. const horEndAngle = Number(angleState.hMax);
  1501. const verStartAngle = Number(angleState.vMin);
  1502. const verEndAngle = Number(angleState.vMax);
  1503. if ([horStartAngle, horEndAngle, verStartAngle, verEndAngle].some((n) => Number.isNaN(n))) {
  1504. createMessage.error('请输入有效的扫描角度');
  1505. return null;
  1506. }
  1507. if (horStartAngle > horEndAngle || verStartAngle > verEndAngle) {
  1508. createMessage.error('起始角度不能大于结束角度');
  1509. return null;
  1510. }
  1511. return { horStartAngle, horEndAngle, verStartAngle, verEndAngle };
  1512. }
  1513. /** 构建 setSysCfg 的 data:在缓存的完整配置上覆盖扫描角度字段 */
  1514. function buildAngleSysCfgData(angleRange: { horStartAngle: number; horEndAngle: number; verStartAngle: number; verEndAngle: number }) {
  1515. return {
  1516. ...cachedSysCfg.value,
  1517. ...angleRange,
  1518. };
  1519. }
  1520. /** 保存扫描角度:未选设备提示;已选则调用 setSysCfg,成功后重新 getSysCfg 刷新 */
  1521. async function saveAngleCfg() {
  1522. const camera = ensureSelectedCamera();
  1523. if (!camera) return;
  1524. const deviceid = resolveCameraDeviceId(camera);
  1525. if (!deviceid) {
  1526. deviceTipVisible.value = true;
  1527. return;
  1528. }
  1529. const angleRange = readAngleRange();
  1530. if (!angleRange) return;
  1531. try {
  1532. await hsqComControl({
  1533. deviceid,
  1534. paramcode: 'setSysCfg',
  1535. noCheckPassword: true,
  1536. data: buildAngleSysCfgData(angleRange),
  1537. });
  1538. createMessage.success('角度配置保存成功,设备将按设置范围扫描');
  1539. emit('angleAction', { action: 'save', state: { ...angleState } });
  1540. await loadAngleSysCfg();
  1541. } catch (e) {
  1542. console.error('保存角度配置失败', e);
  1543. }
  1544. }
  1545. /** 将水平 / 垂直扫描角度恢复为默认初始值 */
  1546. function resetAngleToDefault() {
  1547. angleState.hMin = ANGLE_DEFAULT.hMin;
  1548. angleState.hMax = ANGLE_DEFAULT.hMax;
  1549. angleState.vMin = ANGLE_DEFAULT.vMin;
  1550. angleState.vMax = ANGLE_DEFAULT.vMax;
  1551. }
  1552. function onAngleAction(action: 'save' | 'refresh') {
  1553. if (action === 'refresh') {
  1554. resetAngleToDefault();
  1555. emit('angleAction', { action, state: { ...angleState } });
  1556. return;
  1557. }
  1558. saveAngleCfg();
  1559. }
  1560. /** 从预置位接口返回中取出列表数组(兼容多种返回结构) */
  1561. function pickPresetArray(res: any): any[] {
  1562. if (!res) return [];
  1563. if (Array.isArray(res)) return res;
  1564. if (typeof res !== 'object') return [];
  1565. const payload = res.data ?? res.result ?? res;
  1566. if (Array.isArray(payload)) return payload;
  1567. if (payload && typeof payload === 'object') {
  1568. for (const key of ['presetList', 'presets', 'preset', 'list', 'records', 'data']) {
  1569. const value = (payload as Record<string, any>)[key];
  1570. if (Array.isArray(value)) return value;
  1571. }
  1572. }
  1573. return [];
  1574. }
  1575. /** 预置位名称兼容 C 结构体 byte[] */
  1576. function decodePresetName(raw: unknown) {
  1577. if (Array.isArray(raw)) {
  1578. const codes = raw.map((n) => Number(n)).filter((n) => Number.isFinite(n) && n > 0);
  1579. return codes.length ? String.fromCharCode(...codes) : '';
  1580. }
  1581. return raw == null ? '' : String(raw);
  1582. }
  1583. /** 预置位接口数据 → 表格行(编号 / 名称) */
  1584. function normalizePresetList(res: any): PresetItem[] {
  1585. const rows: PresetItem[] = [];
  1586. for (const item of pickPresetArray(res)) {
  1587. if (item == null) continue;
  1588. if (typeof item !== 'object') {
  1589. rows.push({ id: String(item), name: '' });
  1590. continue;
  1591. }
  1592. const no = item.no ?? item.index ?? item.idx ?? item.preset ?? item.presetNo ?? item.i1 ?? item.id ?? '';
  1593. const name = decodePresetName(item.name ?? item.presetName ?? item.strname ?? item.title);
  1594. if (no === '' && !name) continue;
  1595. const presetNo = Number(no);
  1596. rows.push({ id: no === '' ? '' : String(no), preset: Number.isFinite(presetNo) ? presetNo : undefined, name });
  1597. }
  1598. return rows;
  1599. }
  1600. /**
  1601. * 拉取当前选中摄像头的预置位列表
  1602. * 请求参数:{ deviceid: 选中摄像头id, paramcode: 'getPresetInfo', noCheckPassword: true }
  1603. * 未选中摄像头时置空列表,仅保留斑马纹占位行
  1604. */
  1605. async function loadPresetList() {
  1606. const camera = props.selectedCamera;
  1607. if (!camera || !resolveCameraDeviceId(camera)) {
  1608. presetData.value = [];
  1609. return;
  1610. }
  1611. try {
  1612. const res = await presetList(buildSdkDeviceParams('getAllPreset', camera));
  1613. console.log(res, '预置位列表返回值');
  1614. presetData.value = normalizePresetList(res);
  1615. } catch (e) {
  1616. console.error('获取预置位列表失败', e);
  1617. presetData.value = [];
  1618. }
  1619. }
  1620. /** 预置位工具栏操作:刷新重新拉取列表;添加新增一行;其余仅抛事件 */
  1621. function onPresetAction(key: string) {
  1622. emit('presetAction', key);
  1623. if (key === 'refresh') {
  1624. loadPresetList();
  1625. return;
  1626. }
  1627. if (key === 'add') {
  1628. onAddPreset();
  1629. }
  1630. }
  1631. /** 解析预置位编号:优先取 id,其次从名称中提取数字 */
  1632. function parsePresetNo(row: PresetItem) {
  1633. const fromId = Number(row.id);
  1634. if (row.id !== '' && Number.isFinite(fromId)) return fromId;
  1635. const matched = String(row.name ?? '').match(/\d+/);
  1636. return matched ? Number(matched[0]) : NaN;
  1637. }
  1638. /** 预置位编号上限 */
  1639. const PRESET_NO_MAX = 255;
  1640. /**
  1641. * 新增预置位
  1642. * 1)遍历当前列表取最大编号(最大不超过 255),新增编号 = 最大编号 + 1
  1643. * 例:已有预置位18(编号18)→ 新增预置位19(编号19)
  1644. * 2)调用新增预置位接口:paramcode = setPtzPreset,多携带 i1 = 预置位编号
  1645. * 3)接口成功后再追加到列表
  1646. */
  1647. async function onAddPreset() {
  1648. const camera = ensureSelectedCamera();
  1649. if (!camera) return;
  1650. const maxNo = presetData.value.reduce<number>((max, row) => {
  1651. const no = parsePresetNo(row);
  1652. return Number.isFinite(no) && no > max ? no : max;
  1653. }, 0);
  1654. if (maxNo >= PRESET_NO_MAX) {
  1655. createMessage.warning(`预置位编号已达上限 ${PRESET_NO_MAX}`);
  1656. return;
  1657. }
  1658. const next = maxNo + 1;
  1659. Modal.confirm({
  1660. title: '新增确认',
  1661. content: h('div', { style: { color: '#fff' } }, `确定新增预置位${next}吗?`),
  1662. iconType: 'info',
  1663. okText: '确定',
  1664. cancelText: '取消',
  1665. zIndex: 4000,
  1666. onOk: async () => {
  1667. try {
  1668. const res = await presetSet({
  1669. ...buildSdkDeviceParams('setPtzPreset', camera),
  1670. i1: next,
  1671. });
  1672. console.log(res, '新增预置位返回值');
  1673. const code = (res as any)?.code;
  1674. if (code !== undefined && code !== 0 && code !== 200) {
  1675. createMessage.error((res as any)?.message || '新增预置位失败');
  1676. return;
  1677. }
  1678. presetData.value = [...presetData.value, { id: String(next), preset: next, name: `预置位${next}` }];
  1679. createMessage.success(`预置位${next} 添加成功`);
  1680. } catch (e) {
  1681. console.error('新增预置位失败', e);
  1682. createMessage.error('新增预置位失败');
  1683. }
  1684. },
  1685. });
  1686. }
  1687. /**
  1688. * 删除预置位
  1689. * 1)解析当前行预置位编号(优先 preset,其次 id / 名称中的数字)
  1690. * 2)调用删除预置位接口:paramcode = delPtzPreset,多携带 i1 = 预置位编号
  1691. * 3)接口成功后再从列表移除该行
  1692. */
  1693. async function onDeletePreset(row: PresetItem) {
  1694. const camera = ensureSelectedCamera();
  1695. if (!camera) return;
  1696. const presetNo = Number(row.preset ?? parsePresetNo(row));
  1697. if (!Number.isFinite(presetNo) || presetNo <= 0) {
  1698. createMessage.warning('未识别到预置位编号,无法删除');
  1699. return;
  1700. }
  1701. Modal.confirm({
  1702. title: '删除确认',
  1703. content: h('div', { style: { color: '#fff' } }, `确定删除预置位「${row.name || row.id}」吗?`),
  1704. iconType: 'warning',
  1705. okText: '删除',
  1706. okType: 'danger',
  1707. cancelText: '取消',
  1708. zIndex: 4000,
  1709. onOk: async () => {
  1710. try {
  1711. const res = await presetDelete({
  1712. ...buildSdkDeviceParams('delPtzPreset', camera),
  1713. i1: presetNo,
  1714. });
  1715. console.log(res, '删除预置位返回值');
  1716. const code = (res as any)?.code;
  1717. if (code !== undefined && code !== 0 && code !== 200) {
  1718. createMessage.error((res as any)?.message || '删除预置位失败');
  1719. return;
  1720. }
  1721. presetData.value = presetData.value.filter((item) => item !== row);
  1722. createMessage.success('删除预置位成功');
  1723. } catch (e) {
  1724. console.error('删除预置位失败', e);
  1725. createMessage.error('删除预置位失败');
  1726. }
  1727. },
  1728. });
  1729. }
  1730. /** 预置位操作列「放大预览」:通知父组件放大当前选中摄像头并显示左侧区域选择,携带选中预置位编号 */
  1731. function onRegionPreview(row: PresetItem) {
  1732. const preset = row?.preset ?? (Number(row?.id) || 1);
  1733. emit('regionPreview', preset);
  1734. }
  1735. /** 同步分组展开状态:新增分组默认展开;树数据变化时同步勾选设备 ID */
  1736. watch(
  1737. () => props.treeData,
  1738. (list) => {
  1739. (list || []).forEach((group) => {
  1740. if (expandedMap[group.id] === undefined) {
  1741. expandedMap[group.id] = true;
  1742. }
  1743. });
  1744. emitCheckedDeviceIds();
  1745. },
  1746. { immediate: true, deep: true }
  1747. );
  1748. /** Tab 切换:通知外部;进入角度拉取配置;进入预置位拉取列表;进入统计渲染图表 */
  1749. watch(activeTab, async (key) => {
  1750. emit('tabChange', key);
  1751. if (key === 'angle') {
  1752. await loadAngleSysCfg();
  1753. return;
  1754. }
  1755. if (key === 'preset') {
  1756. await loadPresetList();
  1757. return;
  1758. }
  1759. if (key === 'stats') {
  1760. await nextTick();
  1761. renderStatsChart();
  1762. resizeStatsChart();
  1763. }
  1764. });
  1765. /** 选中摄像头变化:预置位看板可见时同步刷新列表 */
  1766. watch(
  1767. () => props.selectedCamera,
  1768. () => {
  1769. if (activeTab.value === 'preset') loadPresetList();
  1770. }
  1771. );
  1772. /** 统计数据变化时同步刷新图表 */
  1773. watch(statsCount, () => {
  1774. if (activeTab.value === 'stats') renderStatsChart();
  1775. });
  1776. onUnmounted(() => {
  1777. disconnectAlarmWebSocket();
  1778. });
  1779. </script>
  1780. <style lang="less" scoped>
  1781. // 兼容项目 zxm 前缀与默认 ant 前缀
  1782. @cb: ~'.ant-checkbox, .zxm-checkbox';
  1783. @cb-inner: ~'.ant-checkbox-inner, .zxm-checkbox-inner';
  1784. @cb-checked-inner: ~'.ant-checkbox-checked .ant-checkbox-inner, .zxm-checkbox-checked .zxm-checkbox-inner';
  1785. @cb-checked-after: ~'.ant-checkbox-checked .ant-checkbox-inner::after, .zxm-checkbox-checked .zxm-checkbox-inner::after';
  1786. @in-num: ~'.ant-input-number, .zxm-input-number';
  1787. @in-num-input: ~'.ant-input-number-input-wrap, .zxm-input-number-input-wrap, .ant-input-number-input, .zxm-input-number-input';
  1788. .checkbox-box(@size: 14px) {
  1789. :deep(@{cb}) {
  1790. top: 0;
  1791. }
  1792. :deep(@{cb-inner}) {
  1793. width: @size;
  1794. height: @size;
  1795. box-sizing: border-box;
  1796. border: 1px solid rgba(0, 183, 255, 0.75);
  1797. border-radius: 2px;
  1798. background: rgba(0, 30, 55, 0.85);
  1799. }
  1800. :deep(@{cb-checked-inner}) {
  1801. background: var(--dt-accent);
  1802. border-color: var(--dt-accent);
  1803. }
  1804. :deep(@{cb-checked-after}) {
  1805. border-color: #fff;
  1806. border-width: 1.5px;
  1807. }
  1808. }
  1809. .device-tree {
  1810. --dt-bg-row: transparent;
  1811. --dt-bg-active: rgba(22, 90, 150, 0.55);
  1812. --dt-accent: #00b7ff;
  1813. --dt-text: #e8f4ff;
  1814. --dt-text-muted: #8aa0b8;
  1815. --dt-line: rgba(120, 150, 180, 0.55);
  1816. height: 100%;
  1817. display: flex;
  1818. flex-direction: column;
  1819. box-sizing: border-box;
  1820. color: var(--dt-text);
  1821. font-size: 13px;
  1822. overflow: hidden;
  1823. padding: 15px;
  1824. }
  1825. .tree-tabs {
  1826. display: flex;
  1827. align-items: stretch;
  1828. height: 30px;
  1829. flex-shrink: 0;
  1830. border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  1831. background: #15517b;
  1832. margin-bottom: 10px;
  1833. .tab-item {
  1834. flex: 1;
  1835. display: flex;
  1836. align-items: center;
  1837. justify-content: center;
  1838. cursor: pointer;
  1839. color: var(--dt-text-muted);
  1840. position: relative;
  1841. transition: color 0.2s;
  1842. &:hover {
  1843. color: #cfe9ff;
  1844. }
  1845. &.active {
  1846. color: #fff;
  1847. font-weight: 600;
  1848. background-color: #1d73a8;
  1849. &::after {
  1850. content: '';
  1851. position: absolute;
  1852. left: 0;
  1853. right: 0;
  1854. bottom: 0;
  1855. height: 2px;
  1856. background: #36c7ff;
  1857. border-radius: 1px;
  1858. box-shadow: 0 0 6px rgba(0, 183, 255, 0.6);
  1859. }
  1860. }
  1861. }
  1862. }
  1863. .tree-body {
  1864. flex: 1;
  1865. overflow: auto;
  1866. padding: 6px 0;
  1867. box-sizing: border-box;
  1868. &::-webkit-scrollbar {
  1869. width: 4px;
  1870. }
  1871. &::-webkit-scrollbar-thumb {
  1872. background: rgba(0, 183, 255, 0.35);
  1873. border-radius: 2px;
  1874. }
  1875. }
  1876. .group-row {
  1877. display: flex;
  1878. align-items: center;
  1879. height: 30px;
  1880. padding: 0 10px 0 8px;
  1881. cursor: pointer;
  1882. user-select: none;
  1883. &:hover {
  1884. background: rgba(255, 255, 255, 0.03);
  1885. }
  1886. .expand-icon {
  1887. width: 14px;
  1888. height: 14px;
  1889. display: inline-flex;
  1890. align-items: center;
  1891. justify-content: center;
  1892. color: #d8e6f5;
  1893. margin-right: 4px;
  1894. transition: transform 0.2s;
  1895. &.collapsed {
  1896. transform: rotate(-90deg);
  1897. }
  1898. }
  1899. .group-icon {
  1900. display: inline-flex;
  1901. margin-right: 6px;
  1902. }
  1903. .group-title {
  1904. flex: 1;
  1905. min-width: 0;
  1906. font-size: 13px;
  1907. color: var(--dt-text);
  1908. overflow: hidden;
  1909. text-overflow: ellipsis;
  1910. white-space: nowrap;
  1911. }
  1912. .group-checks {
  1913. display: flex;
  1914. align-items: center;
  1915. justify-content: flex-end;
  1916. margin-left: 8px;
  1917. flex-shrink: 0;
  1918. }
  1919. }
  1920. .group-children {
  1921. position: relative;
  1922. }
  1923. .device-row {
  1924. position: relative;
  1925. display: flex;
  1926. align-items: center;
  1927. height: 30px;
  1928. padding: 0 10px 0 28px;
  1929. cursor: pointer;
  1930. background: var(--dt-bg-row);
  1931. &:hover {
  1932. background: rgba(255, 255, 255, 0.04);
  1933. }
  1934. &.active {
  1935. background: var(--dt-bg-active);
  1936. }
  1937. .tree-line {
  1938. position: absolute;
  1939. left: 14px;
  1940. top: 0;
  1941. bottom: 0;
  1942. width: 0;
  1943. border-left: 1px dashed var(--dt-line);
  1944. &::after {
  1945. content: '';
  1946. position: absolute;
  1947. left: 0;
  1948. top: 15px;
  1949. width: 10px;
  1950. border-top: 1px dashed var(--dt-line);
  1951. }
  1952. }
  1953. &.is-last .tree-line {
  1954. bottom: 50%;
  1955. }
  1956. .device-icon {
  1957. display: inline-flex;
  1958. margin-right: 6px;
  1959. flex-shrink: 0;
  1960. }
  1961. .device-name {
  1962. flex: 1;
  1963. min-width: 0;
  1964. overflow: hidden;
  1965. text-overflow: ellipsis;
  1966. white-space: nowrap;
  1967. color: #d7e9fa;
  1968. font-size: 12px;
  1969. }
  1970. .device-checks {
  1971. display: flex;
  1972. align-items: center;
  1973. gap: 10px;
  1974. margin-left: 8px;
  1975. flex-shrink: 0;
  1976. }
  1977. }
  1978. .check-item {
  1979. .checkbox-box(14px);
  1980. position: relative;
  1981. display: inline-flex !important;
  1982. align-items: center !important;
  1983. cursor: pointer;
  1984. margin: 0 !important;
  1985. color: #d7e9fa;
  1986. user-select: none;
  1987. line-height: 1;
  1988. white-space: nowrap;
  1989. height: auto;
  1990. :deep(.ant-checkbox),
  1991. :deep(.zxm-checkbox) {
  1992. top: 0 !important;
  1993. display: inline-flex !important;
  1994. align-items: center;
  1995. align-self: center;
  1996. margin-inline-end: 0 !important;
  1997. line-height: 1;
  1998. }
  1999. :deep(@{cb-checked-after}) {
  2000. width: 4px;
  2001. height: 7px;
  2002. }
  2003. :deep(.ant-checkbox-input:focus + .ant-checkbox-inner),
  2004. :deep(.zxm-checkbox-input:focus + .zxm-checkbox-inner),
  2005. :deep(.ant-checkbox:hover .ant-checkbox-inner),
  2006. :deep(.zxm-checkbox:hover .zxm-checkbox-inner) {
  2007. border-color: rgba(0, 183, 255, 0.95);
  2008. }
  2009. /* 勾选框在前,文字描述在后 */
  2010. :deep(.ant-checkbox + span),
  2011. :deep(.zxm-checkbox + span) {
  2012. display: inline-block;
  2013. padding-inline-start: 6px !important;
  2014. padding-inline-end: 0 !important;
  2015. color: #d7e9fa;
  2016. font-size: 12px;
  2017. line-height: 1.2;
  2018. }
  2019. &:hover {
  2020. :deep(.ant-checkbox + span),
  2021. :deep(.zxm-checkbox + span) {
  2022. color: #01fefc;
  2023. }
  2024. }
  2025. }
  2026. .advanced-panel {
  2027. flex: 1;
  2028. min-height: 0;
  2029. overflow: hidden;
  2030. padding: 8px 6px;
  2031. box-sizing: border-box;
  2032. display: flex;
  2033. flex-direction: column;
  2034. gap: 8px;
  2035. }
  2036. .adv-section {
  2037. // flex: 1;
  2038. height: 36px;
  2039. display: flex;
  2040. flex-direction: row;
  2041. align-items: center;
  2042. justify-content: flex-start;
  2043. flex-wrap: nowrap;
  2044. gap: 12px;
  2045. padding: 0px 12px;
  2046. min-height: 0;
  2047. box-sizing: border-box;
  2048. background: url('../../../../../assets/images//home-container/configurable/hsq/7-2.png') no-repeat center;
  2049. background-size: 100% 100%;
  2050. // background: rgba(8, 36, 64, 0.55);
  2051. // border: 1px solid rgba(58, 225, 255, 0.12);
  2052. border-radius: 2px;
  2053. &.adv-section-last {
  2054. border-bottom-color: rgba(58, 225, 255, 0.12);
  2055. }
  2056. .adv-check-wrap {
  2057. display: inline-flex;
  2058. align-items: center;
  2059. }
  2060. }
  2061. /* 高级看板按钮 / 角度看板保存:双框青蓝渐变(外框 + 深色缝隙 + 内亮边) */
  2062. .advanced-panel .adv-btn,
  2063. .angle-panel .angle-btn-save {
  2064. position: relative;
  2065. flex: 0 0 auto;
  2066. min-width: 78px;
  2067. height: 30px !important;
  2068. min-height: 30px;
  2069. max-height: 30px;
  2070. padding: 0 18px !important;
  2071. margin: 0 !important;
  2072. border: 1px solid #22babd !important;
  2073. border-radius: 3px !important;
  2074. background: linear-gradient(180deg, #3ad8e8 0%, #22c4d6 28%, #1498ac 62%, #0d6f82 100%) !important;
  2075. color: #fff !important;
  2076. font-size: 13px !important;
  2077. font-weight: 500 !important;
  2078. letter-spacing: 1px;
  2079. line-height: 1 !important;
  2080. cursor: pointer;
  2081. white-space: nowrap;
  2082. display: inline-flex !important;
  2083. align-items: center;
  2084. justify-content: center;
  2085. box-shadow: inset 0 0 0 2px #06182d, inset 0 0 0 3px #21afb3, inset 0 1px 0 rgba(210, 255, 255, 0.35), 0 0 6px rgba(0, 180, 200, 0.28) !important;
  2086. text-shadow: 0 1px 1px rgba(0, 30, 45, 0.45);
  2087. transition: filter 0.2s, box-shadow 0.2s, opacity 0.2s;
  2088. &:hover:not(:disabled),
  2089. &:focus:not(:disabled) {
  2090. color: #fff !important;
  2091. filter: brightness(1.08);
  2092. border-color: #0e8a9e !important;
  2093. background: linear-gradient(180deg, #48e4f2 0%, #2ad0e2 28%, #18a8bc 62%, #0f7d90 100%) !important;
  2094. box-shadow: inset 0 0 0 2px #06182d, inset 0 0 0 3px #7af5ff, inset 0 1px 0 rgba(230, 255, 255, 0.4), 0 0 10px rgba(0, 220, 240, 0.4) !important;
  2095. }
  2096. &:disabled {
  2097. opacity: 0.42;
  2098. cursor: not-allowed;
  2099. filter: grayscale(0.35);
  2100. border-color: rgba(80, 120, 140, 0.55) !important;
  2101. color: rgba(220, 235, 245, 0.6) !important;
  2102. background: linear-gradient(180deg, rgba(40, 100, 120, 0.55) 0%, rgba(20, 60, 80, 0.55) 100%) !important;
  2103. box-shadow: inset 0 0 0 2px rgba(6, 24, 45, 0.7), inset 0 0 0 3px rgba(120, 160, 175, 0.45) !important;
  2104. text-shadow: none;
  2105. }
  2106. :deep(span) {
  2107. color: inherit;
  2108. position: relative;
  2109. z-index: 1;
  2110. }
  2111. }
  2112. /* 角度看板等共用按钮基础态 */
  2113. .adv-btn {
  2114. flex: 0 0 auto;
  2115. min-width: 76px;
  2116. height: 28px !important;
  2117. min-height: 28px;
  2118. max-height: 28px;
  2119. padding: 0 14px !important;
  2120. border: 1px solid rgba(1, 220, 255, 0.85) !important;
  2121. border-radius: 3px !important;
  2122. background: linear-gradient(180deg, rgba(18, 120, 150, 0.95) 0%, rgba(8, 70, 100, 0.95) 100%) !important;
  2123. color: #fff !important;
  2124. font-size: 12px !important;
  2125. letter-spacing: 0.5px;
  2126. cursor: pointer;
  2127. box-shadow: 0 0 8px rgba(1, 254, 252, 0.22);
  2128. transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, opacity 0.2s;
  2129. white-space: nowrap;
  2130. display: inline-flex !important;
  2131. align-items: center;
  2132. justify-content: center;
  2133. &:hover:not(:disabled),
  2134. &:focus:not(:disabled) {
  2135. background: linear-gradient(180deg, rgba(24, 150, 180, 0.98) 0%, rgba(10, 90, 120, 0.98) 100%) !important;
  2136. border-color: #01fefc !important;
  2137. color: #fff !important;
  2138. }
  2139. &:disabled {
  2140. opacity: 0.4;
  2141. cursor: not-allowed;
  2142. }
  2143. :deep(span) {
  2144. color: inherit;
  2145. }
  2146. }
  2147. .adv-check {
  2148. .checkbox-box(13px);
  2149. flex: 0 0 auto;
  2150. display: inline-flex !important;
  2151. align-items: center !important;
  2152. gap: 0;
  2153. cursor: pointer;
  2154. color: #fff;
  2155. user-select: none;
  2156. line-height: 1;
  2157. margin: 0 !important;
  2158. white-space: nowrap;
  2159. height: auto;
  2160. &.ant-checkbox-wrapper-disabled,
  2161. &.zxm-checkbox-wrapper-disabled {
  2162. cursor: not-allowed;
  2163. opacity: 0.45;
  2164. }
  2165. /* 勾选框本体与文字垂直居中 */
  2166. :deep(.ant-checkbox),
  2167. :deep(.zxm-checkbox) {
  2168. top: 0 !important;
  2169. display: inline-flex !important;
  2170. align-items: center;
  2171. align-self: center;
  2172. margin-inline-end: 0 !important;
  2173. line-height: 1;
  2174. }
  2175. :deep(.ant-checkbox-inner),
  2176. :deep(.zxm-checkbox-inner) {
  2177. display: block;
  2178. position: relative;
  2179. top: 0;
  2180. }
  2181. :deep(@{cb-checked-after}) {
  2182. width: 3.5px;
  2183. height: 6.5px;
  2184. top: 45%;
  2185. inset-inline-start: 22%;
  2186. }
  2187. :deep(.ant-checkbox + span),
  2188. :deep(.zxm-checkbox + span) {
  2189. padding-inline-start: 6px;
  2190. padding-inline-end: 0;
  2191. font-size: 12px;
  2192. line-height: 13px;
  2193. color: #fff;
  2194. display: inline-flex;
  2195. align-items: center;
  2196. }
  2197. :deep(.ant-checkbox:hover .ant-checkbox-inner),
  2198. :deep(.zxm-checkbox:hover .zxm-checkbox-inner) {
  2199. border-color: rgba(0, 183, 255, 0.95);
  2200. }
  2201. }
  2202. /* 预置位看板 */
  2203. .preset-panel {
  2204. flex: 1;
  2205. min-height: 0;
  2206. overflow: hidden;
  2207. display: flex;
  2208. flex-direction: column;
  2209. gap: 10px;
  2210. padding: 4px 2px 2px;
  2211. box-sizing: border-box;
  2212. }
  2213. .preset-toolbar {
  2214. display: flex;
  2215. align-items: center;
  2216. justify-content: space-around;
  2217. flex-shrink: 0;
  2218. gap: 8px;
  2219. padding: 2px 0 4px;
  2220. background: url('../../../../../assets/images//home-container/configurable/hsq/7-2.png') no-repeat center;
  2221. background-size: 100% 100%;
  2222. }
  2223. .preset-tool-btn {
  2224. width: 32px;
  2225. height: 32px;
  2226. padding: 0;
  2227. display: inline-flex;
  2228. align-items: center;
  2229. justify-content: center;
  2230. border: 1px solid rgba(54, 199, 255, 0.45);
  2231. border-radius: 2px;
  2232. background: linear-gradient(180deg, rgba(18, 70, 110, 0.95) 0%, rgba(8, 36, 64, 0.95) 100%);
  2233. color: #36c7ff;
  2234. cursor: pointer;
  2235. box-shadow: inset 0 0 0 1px rgba(0, 40, 70, 0.55);
  2236. transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
  2237. &:hover {
  2238. border-color: #36c7ff;
  2239. box-shadow: inset 0 0 0 1px rgba(0, 40, 70, 0.45), 0 0 8px rgba(54, 199, 255, 0.35);
  2240. background: linear-gradient(180deg, rgba(28, 100, 150, 0.98) 0%, rgba(12, 50, 85, 0.98) 100%);
  2241. }
  2242. :deep(svg) {
  2243. display: block;
  2244. color: #36c7ff;
  2245. fill: #36c7ff;
  2246. }
  2247. }
  2248. .preset-table {
  2249. flex: 1;
  2250. min-height: 0;
  2251. display: flex;
  2252. flex-direction: column;
  2253. overflow: hidden;
  2254. }
  2255. .preset-thead {
  2256. position: relative;
  2257. z-index: 1;
  2258. flex-shrink: 0;
  2259. height: 34px;
  2260. display: grid;
  2261. grid-template-columns: 1fr 1.4fr 1fr;
  2262. align-items: center;
  2263. padding: 0 18px;
  2264. color: #36c7ff;
  2265. font-size: 13px;
  2266. letter-spacing: 1px;
  2267. // background: linear-gradient(
  2268. // 90deg,
  2269. // rgba(10, 60, 100, 0.98) 0%,
  2270. // rgba(20, 95, 145, 0.88) 50%,
  2271. // rgba(10, 60, 100, 0.98) 100%
  2272. // );
  2273. // border: 1px solid rgba(54, 199, 255, 0.9);
  2274. // border-bottom-color: rgba(54, 199, 255, 0.45);
  2275. /* 顶部左右倒角,贴近设计稿表头框 */
  2276. // clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%, 0 10px);
  2277. // box-shadow:
  2278. // 0 0 12px rgba(54, 199, 255, 0.35),
  2279. // inset 0 1px 0 rgba(160, 235, 255, 0.28);
  2280. background: url('../../../../../assets/images//home-container/configurable/hsq/table-head.png') no-repeat center;
  2281. background-size: 100% 100%;
  2282. margin-bottom: 10px;
  2283. .col-id {
  2284. text-align: left;
  2285. }
  2286. .col-name {
  2287. text-align: center;
  2288. }
  2289. .col-action {
  2290. text-align: right;
  2291. }
  2292. }
  2293. .preset-tbody {
  2294. flex: 1;
  2295. min-height: 0;
  2296. overflow: auto;
  2297. border: 1px solid rgba(54, 199, 255, 0.55);
  2298. border-top: none;
  2299. box-sizing: border-box;
  2300. background: rgba(4, 20, 40, 0.55);
  2301. &::-webkit-scrollbar {
  2302. width: 4px;
  2303. }
  2304. &::-webkit-scrollbar-thumb {
  2305. background: rgba(54, 199, 255, 0.35);
  2306. border-radius: 2px;
  2307. }
  2308. }
  2309. .preset-row {
  2310. display: grid;
  2311. grid-template-columns: 1fr 1.4fr 1fr;
  2312. align-items: center;
  2313. min-height: 34px;
  2314. height: 34px;
  2315. padding: 0 16px;
  2316. box-sizing: border-box;
  2317. color: #e8f4ff;
  2318. font-size: 12px;
  2319. background: rgba(6, 28, 52, 0.92);
  2320. &.is-odd {
  2321. background: rgba(18, 58, 95, 0.72);
  2322. }
  2323. &.empty {
  2324. pointer-events: none;
  2325. }
  2326. .col-id {
  2327. text-align: left;
  2328. }
  2329. .col-name {
  2330. text-align: center;
  2331. overflow: hidden;
  2332. text-overflow: ellipsis;
  2333. white-space: nowrap;
  2334. }
  2335. .col-action {
  2336. text-align: right;
  2337. }
  2338. }
  2339. /* 操作列按钮(放大 / 删除) */
  2340. .preset-del-btn,
  2341. .preset-zoom-btn {
  2342. width: 24px;
  2343. height: 22px;
  2344. padding: 0;
  2345. display: inline-flex;
  2346. align-items: center;
  2347. justify-content: center;
  2348. border: 1px solid #1c668f;
  2349. border-radius: 2px;
  2350. background: rgba(15, 62, 97, 0.55);
  2351. cursor: pointer;
  2352. transition: border-color 0.2s, box-shadow 0.2s, color 0.2s, background 0.2s;
  2353. :deep(svg) {
  2354. display: block;
  2355. color: inherit;
  2356. fill: currentColor;
  2357. }
  2358. &:hover {
  2359. color: #fff;
  2360. border-color: #6a8dff;
  2361. background: rgba(26, 93, 140, 0.7);
  2362. box-shadow: 0 0 6px rgba(26, 93, 140, 0.45);
  2363. }
  2364. }
  2365. .preset-zoom-btn {
  2366. margin-right: 6px;
  2367. }
  2368. .tab-placeholder {
  2369. flex: 1;
  2370. display: flex;
  2371. align-items: center;
  2372. justify-content: center;
  2373. color: var(--dt-text-muted);
  2374. font-size: 13px;
  2375. }
  2376. .stats-panel {
  2377. flex: 1;
  2378. min-height: 0;
  2379. overflow: hidden;
  2380. display: flex;
  2381. flex-direction: column;
  2382. padding: 10px 8px 4px;
  2383. box-sizing: border-box;
  2384. }
  2385. .stats-header {
  2386. display: flex;
  2387. align-items: flex-end;
  2388. justify-content: space-between;
  2389. flex-shrink: 0;
  2390. padding: 0 6px 8px;
  2391. }
  2392. .stats-total-left {
  2393. display: flex;
  2394. flex-direction: column;
  2395. align-items: flex-start;
  2396. gap: 6px;
  2397. }
  2398. .stats-cam-icon {
  2399. color: #e8f4ff;
  2400. filter: brightness(1.15);
  2401. }
  2402. .stats-total-label {
  2403. color: #e8f4ff;
  2404. font-size: 13px;
  2405. line-height: 1.2;
  2406. letter-spacing: 0.5px;
  2407. }
  2408. .stats-total-num {
  2409. color: #fff;
  2410. font-size: 36px;
  2411. font-weight: 500;
  2412. line-height: 1;
  2413. letter-spacing: 1px;
  2414. }
  2415. .stats-divider {
  2416. flex-shrink: 0;
  2417. height: 1px;
  2418. margin: 0 4px 4px;
  2419. background: linear-gradient(
  2420. 90deg,
  2421. transparent 0%,
  2422. rgba(200, 210, 230, 0.55) 15%,
  2423. rgba(232, 244, 255, 0.75) 50%,
  2424. rgba(200, 210, 230, 0.55) 85%,
  2425. transparent 100%
  2426. );
  2427. }
  2428. .stats-chart {
  2429. flex: 1;
  2430. width: 100%;
  2431. min-height: 0;
  2432. }
  2433. .angle-panel {
  2434. flex: 1;
  2435. min-height: 0;
  2436. overflow: hidden;
  2437. padding: 14px 8px 12px;
  2438. box-sizing: border-box;
  2439. display: flex;
  2440. flex-direction: column;
  2441. gap: 14px;
  2442. }
  2443. .angle-fields {
  2444. display: flex;
  2445. flex-direction: column;
  2446. gap: 12px;
  2447. flex-shrink: 0;
  2448. }
  2449. .angle-group {
  2450. display: flex;
  2451. flex-direction: row;
  2452. align-items: center;
  2453. justify-content: space-between;
  2454. gap: 12px;
  2455. min-height: 44px;
  2456. padding: 8px 12px;
  2457. box-sizing: border-box;
  2458. background: rgba(19, 47, 80, 0.88);
  2459. border-radius: 2px;
  2460. }
  2461. .angle-label {
  2462. flex-shrink: 0;
  2463. color: #fff;
  2464. font-size: 13px;
  2465. line-height: 1.2;
  2466. letter-spacing: 0.5px;
  2467. white-space: nowrap;
  2468. }
  2469. .angle-range {
  2470. display: flex;
  2471. align-items: center;
  2472. justify-content: flex-end;
  2473. gap: 8px;
  2474. flex: 1;
  2475. min-width: 0;
  2476. }
  2477. .angle-input-wrap {
  2478. position: relative;
  2479. display: flex;
  2480. align-items: center;
  2481. width: 88px;
  2482. flex: 0 0 88px;
  2483. min-width: 72px;
  2484. height: 28px;
  2485. padding: 0 8px;
  2486. box-sizing: border-box;
  2487. background: rgba(8, 24, 44, 0.95);
  2488. border: 1px solid #1883b5;
  2489. border-radius: 4px;
  2490. }
  2491. .angle-input {
  2492. flex: 1;
  2493. min-width: 0;
  2494. width: 100%;
  2495. :deep(@{in-num-input}) {
  2496. height: 26px;
  2497. padding: 0 !important;
  2498. background: transparent !important;
  2499. color: #fff !important;
  2500. font-size: 13px;
  2501. box-shadow: none !important;
  2502. text-align: left;
  2503. }
  2504. &:hover,
  2505. &.ant-input-number-focused,
  2506. &.zxm-input-number-focused {
  2507. :deep(.ant-input-number-input),
  2508. :deep(.zxm-input-number-input) {
  2509. color: #fff !important;
  2510. }
  2511. }
  2512. /* 隐藏数字输入框增减按钮,保持纯手动输入 */
  2513. :deep(.ant-input-number-handler-wrap),
  2514. :deep(.zxm-input-number-handler-wrap) {
  2515. display: none !important;
  2516. }
  2517. }
  2518. :deep(.angle-input.ant-input-number),
  2519. :deep(.angle-input.zxm-input-number) {
  2520. width: 100%;
  2521. background: transparent !important;
  2522. border: none !important;
  2523. box-shadow: none !important;
  2524. }
  2525. .angle-sep {
  2526. flex-shrink: 0;
  2527. color: #fff;
  2528. font-size: 14px;
  2529. line-height: 1;
  2530. padding: 0 2px;
  2531. }
  2532. .angle-actions {
  2533. display: flex;
  2534. flex-direction: row;
  2535. align-items: center;
  2536. justify-content: flex-end;
  2537. gap: 12px;
  2538. padding: 2px 0 0;
  2539. flex-shrink: 0;
  2540. }
  2541. .angle-btn {
  2542. min-width: 72px !important;
  2543. height: 28px !important;
  2544. padding: 0 18px !important;
  2545. border-radius: 4px !important;
  2546. font-size: 13px !important;
  2547. letter-spacing: 1px;
  2548. line-height: 1 !important;
  2549. display: inline-flex !important;
  2550. align-items: center;
  2551. justify-content: center;
  2552. box-shadow: none !important;
  2553. :deep(span) {
  2554. color: inherit;
  2555. }
  2556. }
  2557. .angle-btn-refresh {
  2558. background: transparent !important;
  2559. border: 1px solid #1883b5 !important;
  2560. color: #fff !important;
  2561. &:hover,
  2562. &:focus {
  2563. background: rgba(24, 131, 181, 0.15) !important;
  2564. border-color: #36c7ff !important;
  2565. color: #fff !important;
  2566. }
  2567. }
  2568. ::v-deep .zxm-input-number-input {
  2569. color: #e8f4ff !important;
  2570. }
  2571. ::v-deep .zxm-input {
  2572. color: #fff !important;
  2573. }
  2574. ::-webkit-scrollbar {
  2575. display: none;
  2576. }
  2577. </style>
  2578. <!-- 弹窗等挂载到 body,需非 scoped 样式 -->
  2579. <style lang="less">
  2580. /* 未选设备提示弹窗(风格对齐报警自动弹窗) */
  2581. .device-tip-mask {
  2582. position: fixed;
  2583. inset: 0;
  2584. z-index: 2200;
  2585. display: flex;
  2586. align-items: center;
  2587. justify-content: center;
  2588. background: rgba(0, 8, 20, 0.55);
  2589. }
  2590. .device-tip-panel {
  2591. width: min(360px, calc(100vw - 48px));
  2592. }
  2593. .device-tip-frame {
  2594. position: relative;
  2595. padding: 36px 20px 18px;
  2596. box-sizing: border-box;
  2597. background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
  2598. border: 1px solid rgba(0, 210, 255, 0.85);
  2599. box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35), inset 0 0 0 1px rgba(0, 180, 255, 0.35);
  2600. clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px), 0 10px);
  2601. display: flex;
  2602. flex-direction: column;
  2603. align-items: center;
  2604. }
  2605. .device-tip-title-tab {
  2606. position: absolute;
  2607. top: -1px;
  2608. left: 50%;
  2609. transform: translateX(-50%);
  2610. min-width: 112px;
  2611. height: 28px;
  2612. padding: 0 28px;
  2613. display: flex;
  2614. align-items: center;
  2615. justify-content: center;
  2616. color: #fff;
  2617. font-size: 14px;
  2618. font-weight: 600;
  2619. letter-spacing: 2px;
  2620. background: linear-gradient(180deg, #2ad4e8 0%, #1498b8 55%, #0c6f8a 100%);
  2621. clip-path: polygon(12px 0, calc(100% - 12px) 0, 100% 100%, 0 100%);
  2622. box-shadow: 0 0 10px rgba(0, 200, 255, 0.45);
  2623. z-index: 2;
  2624. }
  2625. .device-tip-body {
  2626. min-height: 72px;
  2627. display: flex;
  2628. align-items: center;
  2629. justify-content: center;
  2630. color: #e8f4ff;
  2631. font-size: 16px;
  2632. letter-spacing: 1px;
  2633. padding: 12px 8px 8px;
  2634. text-align: center;
  2635. }
  2636. .device-tip-footer {
  2637. display: flex;
  2638. justify-content: center;
  2639. padding: 4px 0 4px;
  2640. }
  2641. .device-tip-btn {
  2642. min-width: 96px;
  2643. height: 32px;
  2644. padding: 0 22px;
  2645. border: 1px solid rgba(0, 210, 255, 0.75);
  2646. border-radius: 2px;
  2647. background: linear-gradient(180deg, rgba(20, 100, 140, 0.85) 0%, rgba(10, 60, 95, 0.9) 100%);
  2648. color: #fff;
  2649. font-size: 14px;
  2650. letter-spacing: 2px;
  2651. cursor: pointer;
  2652. box-shadow: 0 0 8px rgba(0, 183, 255, 0.25);
  2653. transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s;
  2654. &:hover,
  2655. &:focus {
  2656. border-color: #01fefc;
  2657. color: #01fefc;
  2658. background: rgba(0, 90, 140, 0.55);
  2659. box-shadow: 0 0 10px rgba(1, 254, 252, 0.35);
  2660. outline: none;
  2661. }
  2662. &:active {
  2663. transform: translateY(1px);
  2664. }
  2665. }
  2666. /* 查看图片弹窗 */
  2667. .pic-view-mask {
  2668. position: fixed;
  2669. inset: 0;
  2670. z-index: 2200;
  2671. background: rgba(0, 8, 20, 0.55);
  2672. display: flex;
  2673. align-items: center;
  2674. justify-content: center;
  2675. }
  2676. .pic-view-panel {
  2677. width: min(960px, calc(100vw - 48px));
  2678. height: min(640px, calc(100vh - 64px));
  2679. box-sizing: border-box;
  2680. background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
  2681. border: 1px solid rgba(0, 210, 255, 0.85);
  2682. box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35);
  2683. display: flex;
  2684. flex-direction: column;
  2685. overflow: hidden;
  2686. }
  2687. .pic-view-header {
  2688. flex-shrink: 0;
  2689. height: 42px;
  2690. padding: 0 14px;
  2691. display: flex;
  2692. align-items: center;
  2693. justify-content: space-between;
  2694. border-bottom: 1px solid rgba(1, 254, 252, 0.25);
  2695. background: linear-gradient(to right, #134c77, transparent);
  2696. }
  2697. .pic-view-title {
  2698. color: #fff;
  2699. font-size: 15px;
  2700. letter-spacing: 1px;
  2701. }
  2702. .pic-view-body {
  2703. flex: 1;
  2704. min-height: 0;
  2705. padding: 12px;
  2706. box-sizing: border-box;
  2707. overflow: auto;
  2708. }
  2709. .pic-view-empty {
  2710. height: 100%;
  2711. display: flex;
  2712. align-items: center;
  2713. justify-content: center;
  2714. color: rgba(200, 220, 235, 0.65);
  2715. font-size: 14px;
  2716. }
  2717. .pic-view-grid {
  2718. display: grid;
  2719. grid-template-columns: repeat(4, minmax(0, 1fr));
  2720. gap: 12px;
  2721. }
  2722. .pic-card {
  2723. min-width: 0;
  2724. border: 1px solid rgba(0, 183, 255, 0.4);
  2725. background: rgba(4, 20, 40, 0.65);
  2726. overflow: hidden;
  2727. }
  2728. .pic-card-images {
  2729. display: flex;
  2730. gap: 4px;
  2731. height: 120px;
  2732. background: #000;
  2733. }
  2734. .pic-thumb {
  2735. position: relative;
  2736. flex: 1;
  2737. min-width: 0;
  2738. height: 100%;
  2739. cursor: zoom-in;
  2740. img {
  2741. width: 100%;
  2742. height: 100%;
  2743. object-fit: cover;
  2744. display: block;
  2745. pointer-events: none;
  2746. }
  2747. &:hover {
  2748. outline: 1px solid rgba(1, 254, 252, 0.7);
  2749. }
  2750. }
  2751. .pic-thumb-empty {
  2752. display: flex;
  2753. align-items: center;
  2754. justify-content: center;
  2755. color: rgba(200, 220, 235, 0.45);
  2756. font-size: 12px;
  2757. cursor: default;
  2758. &:hover {
  2759. outline: none;
  2760. }
  2761. }
  2762. .pic-thumb-tag {
  2763. position: absolute;
  2764. left: 4px;
  2765. top: 4px;
  2766. padding: 0 6px;
  2767. height: 18px;
  2768. line-height: 18px;
  2769. font-size: 11px;
  2770. color: #fff;
  2771. background: rgba(8, 40, 70, 0.75);
  2772. border: 1px solid rgba(1, 254, 252, 0.45);
  2773. }
  2774. .pic-card-meta {
  2775. display: flex;
  2776. flex-direction: column;
  2777. gap: 2px;
  2778. padding: 6px 8px 8px;
  2779. }
  2780. .pic-card-name,
  2781. .pic-card-time {
  2782. overflow: hidden;
  2783. text-overflow: ellipsis;
  2784. white-space: nowrap;
  2785. }
  2786. .pic-card-name {
  2787. color: #e8f4ff;
  2788. font-size: 13px;
  2789. }
  2790. .pic-card-time {
  2791. color: rgba(180, 210, 230, 0.75);
  2792. font-size: 12px;
  2793. }
  2794. .pic-view-footer {
  2795. flex-shrink: 0;
  2796. display: flex;
  2797. justify-content: flex-end;
  2798. padding: 8px 14px 12px;
  2799. }
  2800. /* 查看图片放大预览 */
  2801. .pic-preview-mask {
  2802. position: fixed;
  2803. inset: 0;
  2804. z-index: 2300;
  2805. background: rgba(0, 8, 20, 0.72);
  2806. display: flex;
  2807. align-items: center;
  2808. justify-content: center;
  2809. }
  2810. .pic-preview-panel {
  2811. width: min(1100px, calc(100vw - 48px));
  2812. height: min(780px, calc(100vh - 48px));
  2813. box-sizing: border-box;
  2814. background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
  2815. border: 1px solid rgba(0, 210, 255, 0.85);
  2816. box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35);
  2817. display: flex;
  2818. flex-direction: column;
  2819. overflow: hidden;
  2820. }
  2821. .pic-preview-header {
  2822. flex-shrink: 0;
  2823. height: 42px;
  2824. padding: 0 14px;
  2825. display: flex;
  2826. align-items: center;
  2827. justify-content: space-between;
  2828. border-bottom: 1px solid rgba(1, 254, 252, 0.25);
  2829. background: linear-gradient(to right, #134c77, transparent);
  2830. }
  2831. .pic-preview-title {
  2832. color: #fff;
  2833. font-size: 15px;
  2834. letter-spacing: 1px;
  2835. overflow: hidden;
  2836. text-overflow: ellipsis;
  2837. white-space: nowrap;
  2838. padding-right: 12px;
  2839. }
  2840. .pic-preview-body {
  2841. flex: 1;
  2842. min-height: 0;
  2843. display: flex;
  2844. align-items: center;
  2845. justify-content: center;
  2846. padding: 12px;
  2847. box-sizing: border-box;
  2848. background: rgba(0, 0, 0, 0.45);
  2849. }
  2850. .pic-preview-img {
  2851. max-width: 100%;
  2852. max-height: 100%;
  2853. object-fit: contain;
  2854. display: block;
  2855. }
  2856. /* 一键抓拍结果弹窗 */
  2857. .snapshot-view-mask {
  2858. position: fixed;
  2859. inset: 0;
  2860. z-index: 2200;
  2861. background: rgba(0, 8, 20, 0.55);
  2862. display: flex;
  2863. align-items: center;
  2864. justify-content: center;
  2865. }
  2866. .snapshot-view-panel {
  2867. width: min(920px, calc(100vw - 48px));
  2868. max-height: min(720px, calc(100vh - 64px));
  2869. box-sizing: border-box;
  2870. background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
  2871. border: 1px solid rgba(0, 210, 255, 0.85);
  2872. box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35);
  2873. display: flex;
  2874. flex-direction: column;
  2875. overflow: hidden;
  2876. }
  2877. .snapshot-view-header {
  2878. flex-shrink: 0;
  2879. height: 42px;
  2880. padding: 0 14px;
  2881. display: flex;
  2882. align-items: center;
  2883. justify-content: space-between;
  2884. border-bottom: 1px solid rgba(1, 254, 252, 0.25);
  2885. background: linear-gradient(to right, #134c77, transparent);
  2886. }
  2887. .snapshot-view-title {
  2888. color: #fff;
  2889. font-size: 15px;
  2890. letter-spacing: 1px;
  2891. }
  2892. .snapshot-view-body {
  2893. flex: 1;
  2894. min-height: 0;
  2895. display: flex;
  2896. flex-direction: column;
  2897. gap: 10px;
  2898. padding: 14px;
  2899. box-sizing: border-box;
  2900. }
  2901. .snapshot-images {
  2902. flex: 1;
  2903. min-height: 280px;
  2904. display: flex;
  2905. gap: 10px;
  2906. }
  2907. .snapshot-thumb {
  2908. position: relative;
  2909. flex: 1;
  2910. min-width: 0;
  2911. height: 100%;
  2912. border: 1px solid rgba(0, 183, 255, 0.45);
  2913. background: #000;
  2914. cursor: zoom-in;
  2915. overflow: hidden;
  2916. img {
  2917. width: 100%;
  2918. height: 100%;
  2919. object-fit: contain;
  2920. display: block;
  2921. pointer-events: none;
  2922. }
  2923. &:hover {
  2924. outline: 1px solid rgba(1, 254, 252, 0.7);
  2925. }
  2926. }
  2927. .snapshot-thumb-empty {
  2928. display: flex;
  2929. align-items: center;
  2930. justify-content: center;
  2931. color: rgba(200, 220, 235, 0.55);
  2932. font-size: 14px;
  2933. cursor: default;
  2934. &:hover {
  2935. outline: none;
  2936. }
  2937. }
  2938. .snapshot-meta {
  2939. flex-shrink: 0;
  2940. display: flex;
  2941. align-items: center;
  2942. justify-content: space-between;
  2943. gap: 12px;
  2944. padding: 8px 10px;
  2945. background: rgba(2, 12, 24, 0.75);
  2946. border: 1px solid rgba(0, 183, 255, 0.25);
  2947. }
  2948. .snapshot-meta-name,
  2949. .snapshot-meta-time {
  2950. overflow: hidden;
  2951. text-overflow: ellipsis;
  2952. white-space: nowrap;
  2953. }
  2954. .snapshot-meta-name {
  2955. color: #e8f4ff;
  2956. font-size: 14px;
  2957. }
  2958. .snapshot-meta-time {
  2959. color: rgba(180, 210, 230, 0.75);
  2960. font-size: 13px;
  2961. flex-shrink: 0;
  2962. }
  2963. /* 停止录像结果弹窗 */
  2964. .stop-record-view-mask {
  2965. position: fixed;
  2966. inset: 0;
  2967. z-index: 2200;
  2968. background: rgba(0, 8, 20, 0.55);
  2969. display: flex;
  2970. align-items: center;
  2971. justify-content: center;
  2972. }
  2973. .stop-record-view-panel {
  2974. width: min(980px, calc(100vw - 48px));
  2975. max-height: min(760px, calc(100vh - 64px));
  2976. box-sizing: border-box;
  2977. background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
  2978. border: 1px solid rgba(0, 210, 255, 0.85);
  2979. box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35);
  2980. display: flex;
  2981. flex-direction: column;
  2982. overflow: hidden;
  2983. }
  2984. .stop-record-view-header {
  2985. flex-shrink: 0;
  2986. height: 42px;
  2987. padding: 0 14px;
  2988. display: flex;
  2989. align-items: center;
  2990. justify-content: space-between;
  2991. border-bottom: 1px solid rgba(1, 254, 252, 0.25);
  2992. background: linear-gradient(to right, #134c77, transparent);
  2993. }
  2994. .stop-record-view-title {
  2995. color: #fff;
  2996. font-size: 15px;
  2997. letter-spacing: 1px;
  2998. }
  2999. .stop-record-view-body {
  3000. flex: 1;
  3001. min-height: 0;
  3002. display: flex;
  3003. flex-direction: column;
  3004. gap: 10px;
  3005. padding: 14px;
  3006. box-sizing: border-box;
  3007. }
  3008. .stop-record-videos {
  3009. flex: 1;
  3010. min-height: 320px;
  3011. display: flex;
  3012. gap: 10px;
  3013. }
  3014. .stop-record-video-item {
  3015. flex: 1;
  3016. min-width: 0;
  3017. display: flex;
  3018. flex-direction: column;
  3019. border: 1px solid rgba(0, 183, 255, 0.45);
  3020. background: #000;
  3021. overflow: hidden;
  3022. }
  3023. .stop-record-video-label {
  3024. flex-shrink: 0;
  3025. height: 28px;
  3026. line-height: 28px;
  3027. padding: 0 10px;
  3028. color: #e8f4ff;
  3029. font-size: 12px;
  3030. background: rgba(8, 40, 70, 0.85);
  3031. border-bottom: 1px solid rgba(1, 254, 252, 0.25);
  3032. }
  3033. .stop-record-video {
  3034. flex: 1;
  3035. min-height: 0;
  3036. width: 100%;
  3037. object-fit: contain;
  3038. background: #000;
  3039. }
  3040. .stop-record-video-empty {
  3041. flex: 1;
  3042. display: flex;
  3043. align-items: center;
  3044. justify-content: center;
  3045. color: rgba(200, 220, 235, 0.55);
  3046. font-size: 14px;
  3047. border: 1px solid rgba(0, 183, 255, 0.35);
  3048. background: rgba(0, 8, 20, 0.65);
  3049. }
  3050. .stop-record-meta {
  3051. flex-shrink: 0;
  3052. display: flex;
  3053. align-items: center;
  3054. justify-content: space-between;
  3055. gap: 12px;
  3056. padding: 8px 10px;
  3057. background: rgba(2, 12, 24, 0.75);
  3058. border: 1px solid rgba(0, 183, 255, 0.25);
  3059. }
  3060. .stop-record-meta-name,
  3061. .stop-record-meta-time {
  3062. overflow: hidden;
  3063. text-overflow: ellipsis;
  3064. white-space: nowrap;
  3065. }
  3066. .stop-record-meta-name {
  3067. color: #e8f4ff;
  3068. font-size: 14px;
  3069. }
  3070. .stop-record-meta-time {
  3071. color: rgba(180, 210, 230, 0.75);
  3072. font-size: 13px;
  3073. flex-shrink: 0;
  3074. }
  3075. /* 查看录像弹窗 */
  3076. .record-view-mask {
  3077. position: fixed;
  3078. inset: 0;
  3079. z-index: 1200;
  3080. background: rgba(0, 10, 24, 0.55);
  3081. display: flex;
  3082. align-items: center;
  3083. justify-content: center;
  3084. }
  3085. .record-view-panel {
  3086. width: min(920px, calc(100vw - 48px));
  3087. height: min(560px, calc(100vh - 64px));
  3088. box-sizing: border-box;
  3089. background: linear-gradient(180deg, rgba(10, 36, 62, 0.98) 0%, rgba(4, 18, 36, 0.98) 100%);
  3090. border: 1px solid rgba(1, 254, 252, 0.65);
  3091. border-radius: 4px;
  3092. box-shadow: 0 0 18px rgba(1, 254, 252, 0.28), inset 0 0 20px rgba(0, 120, 180, 0.12);
  3093. display: flex;
  3094. flex-direction: column;
  3095. overflow: hidden;
  3096. }
  3097. .record-view-header {
  3098. flex-shrink: 0;
  3099. height: 42px;
  3100. padding: 0 14px;
  3101. display: flex;
  3102. align-items: center;
  3103. justify-content: space-between;
  3104. border-bottom: 1px solid rgba(1, 254, 252, 0.25);
  3105. background: rgba(12, 48, 80, 0.65);
  3106. }
  3107. .record-view-title {
  3108. color: #fff;
  3109. font-size: 15px;
  3110. letter-spacing: 1px;
  3111. }
  3112. .record-view-body {
  3113. flex: 1;
  3114. min-height: 0;
  3115. display: flex;
  3116. gap: 12px;
  3117. padding: 12px;
  3118. box-sizing: border-box;
  3119. }
  3120. .record-list {
  3121. width: 280px;
  3122. flex-shrink: 0;
  3123. overflow: auto;
  3124. border: 1px solid rgba(54, 199, 255, 0.35);
  3125. border-radius: 2px;
  3126. background: rgba(4, 20, 40, 0.55);
  3127. }
  3128. .record-empty {
  3129. padding: 24px 12px;
  3130. color: rgba(200, 220, 235, 0.65);
  3131. font-size: 13px;
  3132. text-align: center;
  3133. }
  3134. .record-item {
  3135. padding: 10px 12px;
  3136. border-bottom: 1px solid rgba(54, 199, 255, 0.15);
  3137. cursor: pointer;
  3138. transition: background 0.2s;
  3139. &:hover,
  3140. &.active {
  3141. background: rgba(0, 140, 200, 0.28);
  3142. }
  3143. }
  3144. .record-item-name {
  3145. color: #e8f4ff;
  3146. font-size: 13px;
  3147. margin-bottom: 4px;
  3148. overflow: hidden;
  3149. text-overflow: ellipsis;
  3150. white-space: nowrap;
  3151. }
  3152. .record-item-path {
  3153. color: rgba(180, 210, 230, 0.75);
  3154. font-size: 11px;
  3155. margin-bottom: 2px;
  3156. overflow: hidden;
  3157. text-overflow: ellipsis;
  3158. white-space: nowrap;
  3159. }
  3160. .record-item-time {
  3161. color: rgba(150, 180, 200, 0.65);
  3162. font-size: 11px;
  3163. }
  3164. .record-player {
  3165. flex: 1;
  3166. min-width: 0;
  3167. min-height: 0;
  3168. display: flex;
  3169. flex-direction: column;
  3170. align-items: center;
  3171. justify-content: center;
  3172. border: 1px solid rgba(54, 199, 255, 0.35);
  3173. border-radius: 2px;
  3174. background: rgba(0, 8, 20, 0.85);
  3175. overflow: hidden;
  3176. }
  3177. .record-channel {
  3178. flex-shrink: 0;
  3179. display: flex;
  3180. gap: 8px;
  3181. width: 100%;
  3182. padding: 8px 10px;
  3183. box-sizing: border-box;
  3184. button {
  3185. height: 24px;
  3186. padding: 0 12px;
  3187. color: rgba(200, 230, 245, 0.85);
  3188. font-size: 12px;
  3189. background: transparent;
  3190. border: 1px solid rgba(54, 199, 255, 0.45);
  3191. cursor: pointer;
  3192. &.active {
  3193. color: #fff;
  3194. background: rgba(0, 140, 200, 0.45);
  3195. border-color: rgba(1, 254, 252, 0.8);
  3196. }
  3197. }
  3198. }
  3199. .record-video {
  3200. flex: 1;
  3201. min-height: 0;
  3202. width: 100%;
  3203. height: 100%;
  3204. object-fit: contain;
  3205. background: #000;
  3206. }
  3207. .record-player-empty {
  3208. color: rgba(200, 220, 235, 0.55);
  3209. font-size: 13px;
  3210. }
  3211. /* 报警自动弹窗:热源最新告警图 */
  3212. .auto-alarm-mask {
  3213. position: fixed;
  3214. inset: 0;
  3215. z-index: 2200;
  3216. display: flex;
  3217. align-items: center;
  3218. justify-content: center;
  3219. background: rgba(0, 8, 20, 0.55);
  3220. }
  3221. .auto-alarm-panel {
  3222. width: min(720px, 92vw);
  3223. max-height: 90vh;
  3224. &.dual {
  3225. width: min(980px, 96vw);
  3226. }
  3227. &.expanded {
  3228. width: min(1080px, 96vw);
  3229. }
  3230. &.expanded.dual {
  3231. width: min(1280px, 98vw);
  3232. }
  3233. }
  3234. .auto-alarm-frame {
  3235. position: relative;
  3236. padding: 18px 12px 0;
  3237. background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
  3238. border: 1px solid rgba(0, 210, 255, 0.85);
  3239. box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35), inset 0 0 0 1px rgba(0, 180, 255, 0.35);
  3240. clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px), 0 10px);
  3241. }
  3242. .auto-alarm-title-tab {
  3243. position: absolute;
  3244. top: -1px;
  3245. left: 50%;
  3246. transform: translateX(-50%);
  3247. min-width: 168px;
  3248. height: 28px;
  3249. padding: 0 28px;
  3250. display: flex;
  3251. align-items: center;
  3252. justify-content: center;
  3253. color: #fff;
  3254. font-size: 14px;
  3255. font-weight: 600;
  3256. letter-spacing: 1px;
  3257. background: linear-gradient(180deg, #2ad4e8 0%, #1498b8 55%, #0c6f8a 100%);
  3258. clip-path: polygon(12px 0, calc(100% - 12px) 0, 100% 100%, 0 100%);
  3259. box-shadow: 0 0 10px rgba(0, 200, 255, 0.45);
  3260. z-index: 2;
  3261. }
  3262. .auto-alarm-toolbar {
  3263. position: absolute;
  3264. top: 8px;
  3265. right: 12px;
  3266. display: flex;
  3267. gap: 8px;
  3268. z-index: 3;
  3269. }
  3270. .auto-alarm-icon-btn {
  3271. width: 22px;
  3272. height: 22px;
  3273. padding: 0;
  3274. border: none;
  3275. background: transparent;
  3276. color: #e8f4ff;
  3277. cursor: pointer;
  3278. display: inline-flex;
  3279. align-items: center;
  3280. justify-content: center;
  3281. &:hover {
  3282. color: #01fefc;
  3283. }
  3284. }
  3285. .auto-alarm-images {
  3286. display: flex;
  3287. gap: 10px;
  3288. margin-top: 14px;
  3289. &.single .auto-alarm-image-wrap {
  3290. flex: 1;
  3291. }
  3292. &.dual .auto-alarm-image-wrap {
  3293. flex: 1;
  3294. min-width: 0;
  3295. }
  3296. }
  3297. .auto-alarm-image-wrap {
  3298. position: relative;
  3299. height: 380px;
  3300. border: 1px solid rgba(0, 183, 255, 0.45);
  3301. background: #000;
  3302. overflow: hidden;
  3303. cursor: zoom-in;
  3304. .auto-alarm-panel.expanded & {
  3305. height: 560px;
  3306. }
  3307. }
  3308. .auto-alarm-image {
  3309. width: 100%;
  3310. height: 100%;
  3311. object-fit: contain;
  3312. display: block;
  3313. }
  3314. .auto-alarm-image-empty {
  3315. width: 100%;
  3316. height: 100%;
  3317. display: flex;
  3318. align-items: center;
  3319. justify-content: center;
  3320. color: rgba(200, 220, 235, 0.55);
  3321. font-size: 13px;
  3322. cursor: default;
  3323. }
  3324. .auto-alarm-overlay {
  3325. position: absolute;
  3326. color: #ff2a2a;
  3327. font-size: 14px;
  3328. font-weight: 600;
  3329. text-shadow: 0 0 4px rgba(0, 0, 0, 0.85);
  3330. pointer-events: none;
  3331. &.top-left {
  3332. top: 10px;
  3333. left: 12px;
  3334. }
  3335. &.top-right {
  3336. top: 10px;
  3337. right: 12px;
  3338. }
  3339. &.bottom-right {
  3340. right: 12px;
  3341. bottom: 10px;
  3342. font-size: 13px;
  3343. }
  3344. }
  3345. .auto-alarm-hotspot {
  3346. position: absolute;
  3347. left: 58%;
  3348. top: 42%;
  3349. display: flex;
  3350. align-items: center;
  3351. gap: 6px;
  3352. pointer-events: none;
  3353. .hotspot-box {
  3354. width: 18px;
  3355. height: 18px;
  3356. border: 2px solid #ff2a2a;
  3357. box-shadow: 0 0 6px rgba(255, 40, 40, 0.7);
  3358. }
  3359. .hotspot-value {
  3360. color: #ffe14a;
  3361. font-size: 14px;
  3362. font-weight: 700;
  3363. text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
  3364. }
  3365. }
  3366. .auto-alarm-footer {
  3367. display: flex;
  3368. align-items: center;
  3369. flex-wrap: wrap;
  3370. gap: 6px 8px;
  3371. min-height: 36px;
  3372. padding: 8px 12px 10px;
  3373. color: #e8f4ff;
  3374. font-size: 13px;
  3375. background: rgba(2, 12, 24, 0.95);
  3376. border-top: 1px solid rgba(0, 183, 255, 0.25);
  3377. }
  3378. .auto-alarm-sep {
  3379. color: rgba(180, 210, 230, 0.55);
  3380. }
  3381. /* 报警图片放大预览 */
  3382. .auto-alarm-preview-mask {
  3383. position: fixed;
  3384. inset: 0;
  3385. z-index: 2300;
  3386. display: flex;
  3387. align-items: center;
  3388. justify-content: center;
  3389. background: rgba(9, 23, 44, 0.82);
  3390. backdrop-filter: blur(4px);
  3391. }
  3392. .auto-alarm-preview-panel {
  3393. width: min(86vw, 1100px);
  3394. height: min(82vh, 760px);
  3395. display: flex;
  3396. flex-direction: column;
  3397. border: 1px solid rgba(11, 37, 66);
  3398. box-shadow: 0 0 20px 5px rgba(32, 131, 189, 0.7) inset, 0 0 24px rgba(1, 254, 252, 0.2);
  3399. background: rgba(9, 35, 58, 0.95);
  3400. }
  3401. .auto-alarm-preview-header {
  3402. display: flex;
  3403. align-items: center;
  3404. justify-content: space-between;
  3405. height: 36px;
  3406. padding: 0 14px;
  3407. color: #fff;
  3408. background: linear-gradient(to right, #134c77, transparent);
  3409. border-bottom: 1px solid rgba(41, 171, 241, 0.45);
  3410. }
  3411. .auto-alarm-preview-close {
  3412. width: 28px;
  3413. height: 24px;
  3414. line-height: 20px;
  3415. text-align: center;
  3416. cursor: pointer;
  3417. color: #01fefc;
  3418. border: 1px solid #01fefc;
  3419. border-radius: 2px;
  3420. transition: background-color 0.2s, box-shadow 0.2s;
  3421. &:hover {
  3422. background-color: rgba(36, 132, 188, 0.55);
  3423. box-shadow: 0 0 6px rgba(1, 254, 252, 0.45);
  3424. }
  3425. }
  3426. .auto-alarm-preview-body {
  3427. flex: 1;
  3428. display: flex;
  3429. align-items: center;
  3430. justify-content: center;
  3431. padding: 16px;
  3432. box-sizing: border-box;
  3433. background-color: #114268;
  3434. overflow: hidden;
  3435. img {
  3436. max-width: 100%;
  3437. max-height: 100%;
  3438. object-fit: contain;
  3439. }
  3440. }
  3441. </style>