| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795 |
- <template>
- <!-- 设备树组件:左侧面板,包含设备分组树、Tab切换、高级功能等 -->
- <div class="device-tree">
- <!-- 顶部 Tab -->
- <div class="tree-tabs">
- <div v-for="tab in tabs" :key="tab.key" :class="['tab-item', { active: activeTab === tab.key }]" @click="changeTab(tab.key)">
- {{ tab.label }}
- </div>
- </div>
- <!-- 设备树列表 -->
- <div v-show="activeTab === 'device'" class="tree-body">
- <div v-for="group in treeData" :key="group.id" class="tree-group">
- <div class="group-row" @click="toggleGroup(group.id)">
- <span :class="['expand-icon', { collapsed: !isGroupExpanded(group.id) }]">
- <svg viewBox="0 0 10 10" width="10" height="10">
- <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" />
- </svg>
- </span>
- <span class="group-icon">
- <SvgIcon class="icon-style2" size="18" name="jg-icon" />
- </span>
- <span class="group-title">{{ group.title }}</span>
- <!-- 位于设备勾选列正上方:一键全选 -->
- <div class="group-checks" @click.stop>
- <a-checkbox
- class="check-item select-all-check"
- :checked="isAllChecked"
- :indeterminate="isPartialChecked"
- @update:checked="(val) => toggleSelectAll(!!val)"
- >
- 一键全选
- </a-checkbox>
- </div>
- </div>
- <div v-show="isGroupExpanded(group.id)" class="group-children">
- <div
- v-for="(device, index) in group.children || []"
- :key="device.id"
- :class="[
- 'device-row',
- {
- active: selectedId === device.id,
- 'is-last': index === (group.children?.length || 0) - 1,
- },
- ]"
- @click="selectDevice(device)"
- >
- <span class="tree-line"></span>
- <span class="device-icon">
- <SvgIcon class="icon-style2" size="18" name="video-icon" />
- </span>
- <span class="device-name" :title="device.name">{{ device.name }}</span>
- <div class="device-checks" @click.stop>
- <a-checkbox
- v-for="(checked, cIndex) in device.checks"
- :key="cIndex"
- class="check-item"
- :checked="!!checked"
- @update:checked="(val) => toggleCheck(device, cIndex, !!val)"
- >
- {{ checkTips[cIndex] }}
- </a-checkbox>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 高级功能面板 -->
- <div v-show="activeTab === 'advanced'" class="advanced-panel">
- <div class="adv-section">
- <a-button class="adv-btn" @click="onAdvancedAction('snapshot')">一键抓拍</a-button>
- <a-button class="adv-btn" @click="onAdvancedAction('viewImages')">查看图片</a-button>
- </div>
- <div class="adv-section">
- <a-button class="adv-btn" :disabled="advancedState.recording" @click="setRecording(true)"> 开始录像 </a-button>
- <a-button class="adv-btn" :disabled="!advancedState.recording" @click="setRecording(false)"> 停止录像 </a-button>
- <a-button class="adv-btn" @click="onAdvancedAction('viewRecords')">查看录像</a-button>
- </div>
- <div class="adv-section adv-section-last">
- <a-checkbox class="adv-check" :checked="advancedState.alarmPopup" @update:checked="(val) => onAlarmPopupChange(!!val)">
- 报警自动弹窗
- </a-checkbox>
- <!-- <a-checkbox v-model:checked="advancedState.fillLight1" class="adv-check">补光灯1</a-checkbox>
- <a-checkbox v-model:checked="advancedState.fillLight2" class="adv-check">补光灯2</a-checkbox> -->
- </div>
- </div>
- <!-- 角度看板 -->
- <div v-show="activeTab === 'angle'" class="angle-panel">
- <div class="angle-fields">
- <div v-for="field in angleFields" :key="field.label" class="angle-group">
- <div class="angle-label">{{ field.label }}</div>
- <div class="angle-range">
- <div class="angle-input-wrap">
- <a-input-number v-model:value="angleState[field.minKey]" v-bind="angleInputProps" class="angle-input" />
- </div>
- <span class="angle-sep">~</span>
- <div class="angle-input-wrap">
- <a-input-number v-model:value="angleState[field.maxKey]" v-bind="angleInputProps" class="angle-input" />
- </div>
- </div>
- </div>
- </div>
- <div class="angle-actions">
- <a-button class="angle-btn angle-btn-refresh" @click="onAngleAction('refresh')">刷新</a-button>
- <a-button class="angle-btn angle-btn-save" @click="onAngleAction('save')">保存</a-button>
- </div>
- </div>
- <!-- 统计看板 -->
- <div v-show="activeTab === 'stats'" class="stats-panel">
- <div class="stats-header">
- <div class="stats-total-left">
- <SvgIcon class="stats-cam-icon" size="22" name="video-icon" />
- <span class="stats-total-label">设备总数</span>
- </div>
- <span class="stats-total-num">{{ statsTotal }}</span>
- </div>
- <div class="stats-divider"></div>
- <div ref="statsChartRef" class="stats-chart"></div>
- </div>
- <!-- 预置位看板 -->
- <div v-show="activeTab === 'preset'" class="preset-panel">
- <div class="preset-toolbar">
- <button
- v-for="item in presetToolbarItems"
- :key="item.key"
- type="button"
- class="preset-tool-btn"
- :title="item.title"
- @click="onPresetAction(item.key)"
- >
- <SvgIcon :name="item.icon" :size="18" />
- </button>
- </div>
- <div class="preset-table">
- <div class="preset-thead">
- <span class="col-id">编号</span>
- <span class="col-name">名称</span>
- <span class="col-action">操作</span>
- </div>
- <div class="preset-tbody">
- <div
- v-for="(row, index) in presetDisplayRows"
- :key="row?.id || `empty-${index}`"
- :class="['preset-row', { 'is-odd': index % 2 === 1, empty: !row }]"
- >
- <template v-if="row">
- <span class="col-id">{{ row.id }}</span>
- <span class="col-name" :title="row.name">{{ row.name }}</span>
- <span class="col-action">
- <button type="button" class="preset-zoom-btn" title="操作" @click.stop="onRegionPreview(row)">
- <SvgIcon name="3" size="16" />
- </button>
- <button type="button" class="preset-del-btn" title="删除" @click.stop="onDeletePreset(row)">
- <SvgIcon name="6" size="16" />
- </button>
- </span>
- </template>
- </div>
- </div>
- </div>
- </div>
- <!-- 其它 Tab 占位 -->
- <div v-show="showPlaceholder" class="tab-placeholder">
- {{ placeholderText }}
- </div>
- <!-- 未选摄像头提示弹窗(风格对齐报警弹窗) -->
- <Teleport to="body">
- <div v-if="deviceTipVisible" class="device-tip-mask" @click.self="closeDeviceTip">
- <div class="device-tip-panel">
- <div class="device-tip-frame">
- <div class="device-tip-title-tab">提示</div>
- <div class="device-tip-body">请选择设备!</div>
- <div class="device-tip-footer">
- <button type="button" class="device-tip-btn" @click="closeDeviceTip">确定</button>
- </div>
- </div>
- </div>
- </div>
- </Teleport>
- <!-- 查看图片弹窗:picVideoList 分页展示抓拍图 -->
- <Teleport to="body">
- <div v-if="picViewVisible" class="pic-view-mask" @click.self="closePicView">
- <div class="pic-view-panel">
- <div class="pic-view-header">
- <span class="pic-view-title">查看图片</span>
- <button type="button" class="alarm-icon-btn" title="关闭" @click="closePicView">
- <svg viewBox="0 0 16 16" width="14" height="14">
- <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
- </svg>
- </button>
- </div>
- <div class="pic-view-body">
- <div v-if="picViewLoading" class="pic-view-empty">图片加载中...</div>
- <div v-else-if="!picViewList.length" class="pic-view-empty">暂无抓拍图片</div>
- <div v-else class="pic-view-grid">
- <div v-for="item in picViewList" :key="item.id" class="pic-card">
- <div class="pic-card-images">
- <div v-if="item.visibleUrl" class="pic-thumb" title="点击放大预览" @click="openPicPreview(item.visibleUrl, '可见光', item)">
- <img :src="item.visibleUrl" alt="可见光" />
- <span class="pic-thumb-tag">可见光</span>
- </div>
- <div v-if="item.infraredUrl" class="pic-thumb" title="点击放大预览" @click="openPicPreview(item.infraredUrl, '红外', item)">
- <img :src="item.infraredUrl" alt="红外" />
- <span class="pic-thumb-tag">红外</span>
- </div>
- <div v-if="!item.visibleUrl && !item.infraredUrl" class="pic-thumb pic-thumb-empty">暂无图像</div>
- </div>
- <div class="pic-card-meta">
- <span class="pic-card-name" :title="item.deviceName">{{ item.deviceName || '--' }}</span>
- <span class="pic-card-time">{{ item.createTime || '--' }}</span>
- </div>
- </div>
- </div>
- </div>
- <div class="pic-view-footer">
- <a-pagination
- size="small"
- :current="picPage.current"
- :page-size="picPage.pageSize"
- :total="picPage.total"
- :show-size-changer="false"
- :show-less-items="true"
- @change="onPicPageChange"
- />
- </div>
- </div>
- </div>
- </Teleport>
- <!-- 查看图片放大预览 -->
- <Teleport to="body">
- <div v-if="picPreviewVisible" class="pic-preview-mask" @click.self="closePicPreview">
- <div class="pic-preview-panel">
- <div class="pic-preview-header">
- <span class="pic-preview-title">{{ picPreviewTitle }}</span>
- <button type="button" class="alarm-icon-btn" title="关闭" @click="closePicPreview">
- <svg viewBox="0 0 16 16" width="14" height="14">
- <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
- </svg>
- </button>
- </div>
- <div class="pic-preview-body">
- <img v-if="picPreviewUrl" :src="picPreviewUrl" :alt="picPreviewTitle" class="pic-preview-img" />
- </div>
- </div>
- </div>
- </Teleport>
- <!-- 一键抓拍结果弹窗 -->
- <Teleport to="body">
- <div v-if="snapshotViewVisible" class="snapshot-view-mask" @click.self="closeSnapshotView">
- <div class="snapshot-view-panel">
- <div class="snapshot-view-header">
- <span class="snapshot-view-title">一键抓拍</span>
- <button type="button" class="alarm-icon-btn" title="关闭" @click="closeSnapshotView">
- <svg viewBox="0 0 16 16" width="14" height="14">
- <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
- </svg>
- </button>
- </div>
- <div class="snapshot-view-body">
- <div class="snapshot-images">
- <div
- v-if="snapshotViewData.visibleUrl"
- class="snapshot-thumb"
- title="点击放大预览"
- @click="openPicPreview(snapshotViewData.visibleUrl, '可见光', snapshotViewData)"
- >
- <img :src="snapshotViewData.visibleUrl" alt="可见光" />
- <span class="pic-thumb-tag">可见光</span>
- </div>
- <div
- v-if="snapshotViewData.infraredUrl"
- class="snapshot-thumb"
- title="点击放大预览"
- @click="openPicPreview(snapshotViewData.infraredUrl, '红外', snapshotViewData)"
- >
- <img :src="snapshotViewData.infraredUrl" alt="红外" />
- <span class="pic-thumb-tag">红外</span>
- </div>
- <div v-if="!snapshotViewData.visibleUrl && !snapshotViewData.infraredUrl" class="snapshot-thumb snapshot-thumb-empty">
- 暂无抓拍图像
- </div>
- </div>
- <div class="snapshot-meta">
- <span class="snapshot-meta-name" :title="snapshotViewData.deviceName">
- {{ snapshotViewData.deviceName || '--' }}
- </span>
- <span class="snapshot-meta-time">{{ snapshotViewData.createTime || '--' }}</span>
- </div>
- </div>
- </div>
- </div>
- </Teleport>
- <!-- 停止录像结果弹窗 -->
- <Teleport to="body">
- <div v-if="stopRecordViewVisible" class="stop-record-view-mask" @click.self="closeStopRecordView">
- <div class="stop-record-view-panel">
- <div class="stop-record-view-header">
- <span class="stop-record-view-title">录像结果</span>
- <button type="button" class="alarm-icon-btn" title="关闭" @click="closeStopRecordView">
- <svg viewBox="0 0 16 16" width="14" height="14">
- <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
- </svg>
- </button>
- </div>
- <div class="stop-record-view-body">
- <div class="stop-record-videos">
- <div v-if="stopRecordViewData.visibleUrl" class="stop-record-video-item">
- <div class="stop-record-video-label">可见光</div>
- <video :key="stopRecordViewData.visibleUrl" class="stop-record-video" controls autoplay playsinline>
- <source :src="stopRecordViewData.visibleUrl" :type="stopRecordViewData.visibleMime" />
- </video>
- </div>
- <div v-if="stopRecordViewData.infraredUrl" class="stop-record-video-item">
- <div class="stop-record-video-label">红外</div>
- <video :key="stopRecordViewData.infraredUrl" class="stop-record-video" controls playsinline>
- <source :src="stopRecordViewData.infraredUrl" :type="stopRecordViewData.infraredMime" />
- </video>
- </div>
- <div v-if="!stopRecordViewData.visibleUrl && !stopRecordViewData.infraredUrl" class="stop-record-video-empty"> 暂无录像 </div>
- </div>
- <div class="stop-record-meta">
- <span class="stop-record-meta-name" :title="stopRecordViewData.deviceName">
- {{ stopRecordViewData.deviceName || '--' }}
- </span>
- <span class="stop-record-meta-time">{{ stopRecordViewData.recordTime || '--' }}</span>
- </div>
- </div>
- </div>
- </div>
- </Teleport>
- <!-- 查看录像弹窗:picVideoList type=1,分页列表内播放 MP4 -->
- <Teleport to="body">
- <div v-if="recordViewVisible" class="record-view-mask" @click.self="closeRecordView">
- <div class="record-view-panel">
- <div class="record-view-header">
- <span class="record-view-title">查看录像</span>
- <button type="button" class="alarm-icon-btn" title="关闭" @click="closeRecordView">
- <svg viewBox="0 0 16 16" width="14" height="14">
- <path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
- </svg>
- </button>
- </div>
- <div class="record-view-body">
- <div class="record-list">
- <div v-if="recordViewLoading" class="record-empty">录像加载中...</div>
- <div v-else-if="!videoViewList.length" class="record-empty">暂无录像</div>
- <template v-else>
- <div
- v-for="(item, index) in videoViewList"
- :key="item.id"
- :class="['record-item', { active: currentRecordIndex === index }]"
- @click="playRecord(index)"
- >
- <div class="record-item-name" :title="item.fileName">{{ item.fileName }}</div>
- <div class="record-item-path" :title="item.deviceName">{{ item.deviceName || '--' }}</div>
- <div class="record-item-time">{{ item.recordTime || '--' }}</div>
- </div>
- </template>
- </div>
- <div class="record-player">
- <div v-if="currentRecordHasBoth" class="record-channel">
- <button type="button" :class="{ active: recordChannel === 'visible' }" @click="recordChannel = 'visible'">可见光</button>
- <button type="button" :class="{ active: recordChannel === 'infrared' }" @click="recordChannel = 'infrared'">红外</button>
- </div>
- <video v-if="currentRecordPlayUrl" :key="currentRecordPlayUrl" class="record-video" controls autoplay playsinline>
- <source :src="currentRecordPlayUrl" :type="currentRecordMime" />
- </video>
- <div v-else class="record-player-empty">请选择左侧录像进行播放</div>
- </div>
- </div>
- <div class="pic-view-footer">
- <a-pagination
- size="small"
- :current="recordPage.current"
- :page-size="recordPage.pageSize"
- :total="recordPage.total"
- :show-size-changer="false"
- :show-less-items="true"
- @change="onRecordPageChange"
- />
- </div>
- </div>
- </div>
- </Teleport>
- <!-- 报警自动弹窗:推送报警图片 -->
- <Teleport to="body">
- <div v-if="autoAlarmVisible" class="auto-alarm-mask" @click.self="closeAutoAlarmPopup">
- <div
- :class="[
- 'auto-alarm-panel',
- {
- expanded: autoAlarmExpanded,
- dual: autoAlarmData.imageUrls.length > 1,
- },
- ]"
- >
- <div class="auto-alarm-frame">
- <div class="auto-alarm-title-tab">{{ autoAlarmTitle }}</div>
- <div class="auto-alarm-toolbar">
- <button type="button" class="auto-alarm-icon-btn" title="放大" @click="toggleAutoAlarmExpand">
- <svg viewBox="0 0 16 16" width="14" height="14">
- <rect x="3" y="3" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1.3" />
- <path
- d="M9 3 H13 V7 M13 3 L9.5 6.5 M7 9 L3 13 M3 9 V13 H7"
- fill="none"
- stroke="currentColor"
- stroke-width="1.3"
- stroke-linecap="round"
- stroke-linejoin="round"
- />
- </svg>
- </button>
- <button type="button" class="auto-alarm-icon-btn" title="关闭" @click="closeAutoAlarmPopup">
- <svg viewBox="0 0 16 16" width="14" height="14">
- <rect x="2.5" y="2.5" width="8" height="8" fill="none" stroke="currentColor" stroke-width="1.3" />
- <path
- d="M8 8 L13.5 13.5 M10.5 13.5 H13.5 V10.5"
- fill="none"
- stroke="currentColor"
- stroke-width="1.3"
- stroke-linecap="round"
- stroke-linejoin="round"
- />
- </svg>
- </button>
- </div>
- <div class="auto-alarm-images" :class="{ dual: autoAlarmData.imageUrls.length > 1, single: autoAlarmData.imageUrls.length <= 1 }">
- <div
- v-for="(url, index) in autoAlarmData.imageUrls"
- :key="`${url}-${index}`"
- class="auto-alarm-image-wrap"
- title="点击放大预览"
- @click="openAutoAlarmPreview(url)"
- >
- <img class="auto-alarm-image" :src="url" :alt="`${autoAlarmTitle}-${index + 1}`" />
- <div v-if="index === 0 && autoAlarmData.maxTemp" class="auto-alarm-overlay top-left"> Max: {{ autoAlarmData.maxTemp }} ℃ </div>
- <div v-if="index === 0 && autoAlarmData.overlayTime" class="auto-alarm-overlay top-right">
- {{ autoAlarmData.overlayTime }}
- </div>
- <div v-if="index === 0 && autoAlarmData.hotspotValue" class="auto-alarm-hotspot">
- <span class="hotspot-box"></span>
- <span class="hotspot-value">{{ autoAlarmData.hotspotValue }}</span>
- </div>
- <div v-if="index === 0 && autoAlarmData.deviceLabel" class="auto-alarm-overlay bottom-right">
- {{ autoAlarmData.deviceLabel }}
- </div>
- </div>
- <div v-if="!autoAlarmData.imageUrls.length" class="auto-alarm-image-wrap">
- <div class="auto-alarm-image-empty">暂无告警图像</div>
- </div>
- </div>
- <div class="auto-alarm-footer">
- <span v-if="autoAlarmData.msgTxt">{{ autoAlarmData.msgTxt }}</span>
- <template v-else>
- <span>告警时间:{{ autoAlarmData.alarmTime || '--' }}</span>
- <span class="auto-alarm-sep">|</span>
- <span>告警设备:{{ autoAlarmData.alarmDevice || '--' }}</span>
- <span class="auto-alarm-sep">|</span>
- <span>告警类型:{{ autoAlarmData.alarmType || '--' }}</span>
- </template>
- <template v-if="autoAlarmData.msgTxt && (autoAlarmData.alarmDevice || autoAlarmData.alarmType)">
- <span class="auto-alarm-sep">|</span>
- <span>告警设备:{{ autoAlarmData.alarmDevice || '--' }}</span>
- <span class="auto-alarm-sep">|</span>
- <span>告警类型:{{ autoAlarmData.alarmType || '--' }}</span>
- </template>
- </div>
- </div>
- </div>
- </div>
- </Teleport>
- <!-- 报警图片点击放大预览 -->
- <Teleport to="body">
- <div v-if="autoAlarmPreviewVisible" class="auto-alarm-preview-mask" @click.self="closeAutoAlarmPreview">
- <div class="auto-alarm-preview-panel">
- <div class="auto-alarm-preview-header">
- <span>图片预览</span>
- <div class="auto-alarm-preview-close" @click="closeAutoAlarmPreview">×</div>
- </div>
- <div class="auto-alarm-preview-body">
- <img v-if="autoAlarmPreviewSrc" :src="autoAlarmPreviewSrc" alt="报警图片预览" />
- </div>
- </div>
- </div>
- </Teleport>
- </div>
- </template>
- <script setup lang="ts">
- import { computed, h, nextTick, onUnmounted, reactive, ref, watch, type PropType, type Ref } from 'vue';
- import type { EChartsOption } from 'echarts';
- import { Modal } from 'ant-design-vue';
- import { SvgIcon } from '/@/components/Icon';
- import { useECharts } from '/@/hooks/web/useECharts';
- import { useMessage } from '/@/hooks/web/useMessage';
- import { useUserStore } from '/@/store/modules/user';
- import { getToken } from '/@/utils/auth';
- import {
- // captureToFile,
- // recordMP4File,
- // stopRecordMP4File,
- getSysCfg,
- setSysCfg,
- // alarmAutoCapture,
- hsqComControl,
- picVideoList,
- presetList,
- presetSet,
- presetDelete,
- } from '../hsqHome.api';
- import type {
- DeviceNode,
- DeviceGroup,
- AngleState,
- StatsCount,
- PresetItem,
- RecordFileItem,
- AutoAlarmPopupData,
- PicVideoRecord,
- PicViewItem,
- VideoViewItem,
- } from './type';
- import { tabs, ANGLE_DEFAULT, angleFields, checkTips, presetToolbarItems, PRESET_EMPTY_ROW_COUNT } from '../hsqHome.data';
- import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
- const props = defineProps<{
- treeData: DeviceGroup[];
- selectedCamera: Record<string, any> | null;
- }>();
- const emit = defineEmits<{
- (e: 'select', device: DeviceNode): void;
- (e: 'checkChange', payload: { device: DeviceNode; index: number; checked: boolean }): void;
- (e: 'tabChange', key: string): void;
- (e: 'advancedAction', payload: { action: string; state: Record<string, unknown> }): void;
- (e: 'angleAction', payload: { action: string; state: AngleState }): void;
- (e: 'presetAction', key: string): void;
- /** 预置位操作列点击放大:放大当前选中摄像头并显示左侧区域选择,preset 为选中预置位编号 */
- (e: 'regionPreview', preset: number): void;
- /** 勾选设备 ID 列表(任一取流勾选即计入,随勾选动态更新) */
- (e: 'checkedDeviceIdsChange', ids: string[]): void;
- }>();
- const { createMessage } = useMessage();
- const userStore = useUserStore();
- /** 已实现内容的 Tab(其余走占位) */
- const CONTENT_TABS = new Set(['device', 'advanced', 'angle', 'stats', 'preset']);
- const activeTab = ref('device');
- const selectedId = ref('');
- const expandedMap = reactive<Record<string, boolean>>({});
- /** 无设备状态字段时的示意统计(对齐设计稿) */
- const STATS_FALLBACK: StatsCount = { online: 0, offline: 0 };
- const angleInputProps = {
- precision: 2,
- controls: false,
- bordered: false,
- } as const;
- /** 未选摄像头提示弹窗 */
- const deviceTipVisible = ref(false);
- /** 查看图片弹窗 */
- const picViewVisible = ref(false);
- const picViewLoading = ref(false);
- const picViewList = ref<PicViewItem[]>([]);
- const picPage = reactive({
- current: 1,
- pageSize: 8,
- total: 0,
- });
- /** 图片放大预览 */
- const picPreviewVisible = ref(false);
- const picPreviewUrl = ref('');
- const picPreviewTitle = ref('图片预览');
- /** 一键抓拍结果弹窗 */
- const snapshotViewVisible = ref(false);
- const snapshotViewData = reactive<PicViewItem>({
- id: '',
- deviceName: '',
- createTime: '',
- visibleUrl: '',
- infraredUrl: '',
- });
- /** 查看录像弹窗 */
- const recordViewVisible = ref(false);
- const recordViewLoading = ref(false);
- /** 录像文件列表(开始/停止录像仍写入本地缓存,查看录像弹窗不使用) */
- const recordFileList = ref<RecordFileItem[]>([]);
- /** 查看录像弹窗中的服务端录像列表 */
- const videoViewList = ref<VideoViewItem[]>([]);
- /** 当前播放的录像下标 */
- const currentRecordIndex = ref(-1);
- /** 当前录像通道:可见光 path1 / 红外 path2 */
- const recordChannel = ref<'visible' | 'infrared'>('visible');
- const recordPage = reactive({
- current: 1,
- pageSize: 8,
- total: 0,
- });
- /** 开始录像时使用的文件名(接口未返回 fileName 时回退) */
- const pendingRecordFileName = ref('');
- /** 停止录像结果弹窗 */
- const stopRecordViewVisible = ref(false);
- const stopRecordViewData = reactive({
- id: '',
- deviceName: '',
- recordTime: '',
- visibleUrl: '',
- infraredUrl: '',
- visibleMime: 'video/mp4',
- infraredMime: 'video/mp4',
- });
- /** 报警自动弹窗 */
- const autoAlarmVisible = ref(false);
- const autoAlarmExpanded = ref(false);
- const autoAlarmTitle = ref('最新告警图');
- const autoAlarmData = reactive<AutoAlarmPopupData>({
- imageUrl: '',
- imageUrls: [],
- maxTemp: '',
- overlayTime: '',
- deviceLabel: '',
- alarmTime: '',
- alarmDevice: '',
- alarmType: '',
- hotspotValue: '',
- msgTxt: '',
- });
- /** 报警图片放大预览 */
- const autoAlarmPreviewVisible = ref(false);
- const autoAlarmPreviewSrc = ref('');
- /** 报警自动弹窗专用 WebSocket(独立连接,避免影响全局消息推送) */
- let alarmAutoWs: WebSocket | null = null;
- const ALARM_WS_HOST = '192.168.247.85';
- const ALARM_WS_PORT = 8065;
- const advancedState = reactive({
- alarmPopup: false,
- fillLight1: false,
- fillLight2: false,
- recording: false,
- });
- const angleState = reactive<AngleState>({ ...ANGLE_DEFAULT });
- /** 进入角度看板时缓存的完整系统配置,保存时在此基础上覆盖角度字段 */
- const cachedSysCfg = ref<Record<string, any>>({});
- /** 预置位列表(数据来源:presetList 接口 POST /api/zc/device/getPresetInfo) */
- const presetData = ref<PresetItem[]>([]);
- const statsChartRef = ref<HTMLDivElement | null>(null);
- const { setOptions, resize: resizeStatsChart } = useECharts(statsChartRef as Ref<HTMLDivElement>);
- /** 当前选中的录像 */
- const currentRecordItem = computed(() => videoViewList.value[currentRecordIndex.value]);
- /** 同时存在可见光与红外时才显示通道切换 */
- const currentRecordHasBoth = computed(() => {
- const item = currentRecordItem.value;
- return !!(item?.visibleUrl && item?.infraredUrl);
- });
- /** 当前播放地址:优先当前通道,缺省时回退另一路 */
- const currentRecordPlayUrl = computed(() => {
- const item = currentRecordItem.value;
- if (!item) return '';
- if (recordChannel.value === 'infrared') return item.infraredUrl || item.visibleUrl;
- return item.visibleUrl || item.infraredUrl;
- });
- /** 当前录像 MIME,MP4 使用 video/mp4 */
- const currentRecordMime = computed(() => {
- const item = currentRecordItem.value;
- if (!item) return 'video/mp4';
- if (recordChannel.value === 'infrared' && item.infraredUrl) return item.infraredMime;
- if (item.visibleUrl) return item.visibleMime;
- return item.infraredMime || 'video/mp4';
- });
- /** 统计在线/离线:优先 netStatus,否则回退示意数据 */
- const statsCount = computed<StatsCount>(() => {
- let online = 0;
- let offline = 0;
- let hasStatus = false;
- for (const group of props.treeData || []) {
- for (const device of group.children || []) {
- hasStatus = true;
- if (device.netStatus == 1 || device.netStatus === true) online += 1;
- else offline += 1;
- }
- }
- return hasStatus ? { online, offline } : { ...STATS_FALLBACK };
- });
- const statsTotal = computed(() => statsCount.value.online + statsCount.value.offline);
- /** 预置位展示行:有数据用真实行,不足时补齐空行以还原斑马纹区域 */
- const presetDisplayRows = computed<(PresetItem | null)[]>(() => {
- const list = presetData.value || [];
- const rows: (PresetItem | null)[] = [...list];
- const minRows = Math.max(PRESET_EMPTY_ROW_COUNT, list.length);
- while (rows.length < minRows) rows.push(null);
- return rows;
- });
- const showPlaceholder = computed(() => !CONTENT_TABS.has(activeTab.value));
- const placeholderText = computed(() => '');
- /** 展平设备树中的全部设备节点 */
- const allDevices = computed(() => {
- const list: DeviceNode[] = [];
- for (const group of props.treeData || []) {
- for (const device of group.children || []) {
- list.push(device);
- }
- }
- return list;
- });
- /** 是否所有设备的可见光/红外均已勾选 */
- const isAllChecked = computed(() => {
- const list = allDevices.value;
- if (!list.length) return false;
- return list.every((device) => !!device.checks?.[0] && !!device.checks?.[1]);
- });
- /** 是否存在部分勾选(半选态) */
- const isPartialChecked = computed(() => {
- const list = allDevices.value;
- if (!list.length || isAllChecked.value) return false;
- return list.some((device) => !!device.checks?.[0] || !!device.checks?.[1]);
- });
- /** 当前勾选中的设备 ID 数组(可见光或红外任一勾选即计入) */
- const checkedDeviceIds = computed(() =>
- allDevices.value.filter((device) => !!device.checks?.[0] || !!device.checks?.[1]).map((device) => String(device.id))
- );
- /** 向父组件同步勾选设备 ID 列表 */
- function emitCheckedDeviceIds() {
- emit('checkedDeviceIdsChange', [...checkedDeviceIds.value]);
- }
- /** 分组是否展开(默认展开) */
- function isGroupExpanded(id: string) {
- return expandedMap[id] !== false;
- }
- /** 未选摄像头时弹出提示,已选中则返回摄像头对象 */
- function ensureSelectedCamera() {
- if (!props.selectedCamera) {
- deviceTipVisible.value = true;
- return null;
- }
- return props.selectedCamera;
- }
- /** SDK 设备操作公共参数(抓拍 / 录像 / 系统配置等) */
- function buildSdkDeviceParams(paramcode: string, camera = props.selectedCamera) {
- const deviceid = resolveCameraDeviceId(camera);
- return {
- deviceid,
- paramcode,
- noCheckPassword: true,
- };
- }
- /** 从选中摄像头解析设备 ID(兼容多种字段名) */
- function resolveCameraDeviceId(camera: Record<string, any> | null | undefined) {
- if (!camera) return '';
- const raw = camera.deviceid ?? camera.deviceId ?? camera.deviceID ?? camera.devId ?? '';
- if (raw == null) return '';
- return String(raw).trim();
- }
- /** 解析数值,非法时回退默认值 */
- function toFiniteNumber(value: unknown, fallback: number) {
- const num = Number(value);
- return Number.isFinite(num) ? num : fallback;
- }
- /** 构建饼图扇区配置 */
- function buildPieSlice(
- name: string,
- value: number,
- options: {
- color: string;
- labelLineColor: string;
- labelLineWidth?: number;
- length?: number;
- length2?: number;
- }
- ) {
- const show = value > 0;
- return {
- name,
- value,
- itemStyle: {
- color: options.color,
- borderColor: 'transparent',
- },
- label: {
- show,
- color: '#e8f4ff',
- fontSize: 13,
- formatter: `${name}${value}`,
- },
- labelLine: {
- show,
- length: options.length ?? 12,
- length2: options.length2 ?? 30,
- lineStyle: {
- color: options.labelLineColor,
- width: options.labelLineWidth ?? 1,
- },
- },
- };
- }
- /** 渲染统计饼图 */
- function renderStatsChart() {
- const { online, offline } = statsCount.value;
- const total = online + offline || 1;
- const startAngle = 232;
- const offlineSpan = (offline / total) * 360;
- const offlineMidAngle = startAngle - offlineSpan / 2;
- const rad = (offlineMidAngle * Math.PI) / 180;
- const arrowDist = 0.58 * 0.72;
- const arrowX = 50 + Math.cos(rad) * arrowDist * 50;
- const arrowY = 52 - Math.sin(rad) * arrowDist * 50;
- const option = {
- backgroundColor: 'transparent',
- animationDuration: 400,
- series: [
- {
- type: 'pie',
- radius: ['0%', '58%'],
- center: ['50%', '52%'],
- startAngle,
- clockwise: true,
- avoidLabelOverlap: true,
- silent: true,
- data: [
- buildPieSlice('离线', offline, {
- color: 'rgba(7, 21, 40, 0)',
- labelLineColor: '#01fefc',
- labelLineWidth: 1.5,
- length: 14,
- length2: 40,
- }),
- buildPieSlice('在线', online, {
- color: '#2f6fb8',
- labelLineColor: 'rgba(232, 244, 255, 0.85)',
- }),
- ],
- },
- ],
- graphic:
- offline > 0
- ? [
- {
- type: 'polygon',
- z: 10,
- rotation: -rad,
- left: `${arrowX}%`,
- top: `${arrowY}%`,
- bounding: 'raw',
- origin: [0, 0],
- shape: {
- points: [
- [8, 0],
- [0, -4.5],
- [0, 4.5],
- ],
- },
- style: { fill: '#01fefc' },
- },
- ]
- : [],
- } as EChartsOption;
- setOptions(option);
- }
- function changeTab(key: string) {
- if (activeTab.value === key) return;
- activeTab.value = key;
- }
- function toggleGroup(id: string) {
- expandedMap[id] = !isGroupExpanded(id);
- }
- function selectDevice(device: DeviceNode) {
- selectedId.value = device.id;
- emit('select', device);
- }
- function toggleCheck(device: DeviceNode, index: number, checked: boolean) {
- device.checks[index] = checked;
- emit('checkChange', { device, index, checked });
- emitCheckedDeviceIds();
- }
- /** 一键全选 / 取消全选:同步所有设备的可见光与红外勾选 */
- function toggleSelectAll(checked: boolean) {
- for (const device of allDevices.value) {
- if (!device.checks) {
- device.checks = [false, false];
- }
- for (let index = 0; index < device.checks.length; index++) {
- if (!!device.checks[index] === checked) continue;
- device.checks[index] = checked;
- emit('checkChange', { device, index, checked });
- }
- }
- emitCheckedDeviceIds();
- }
- function onAdvancedAction(action: string) {
- emit('advancedAction', { action, state: { ...advancedState } });
- const actionMap: Record<string, () => void> = {
- snapshot: trySnapshot,
- viewImages: tryViewImages,
- viewRecords: tryViewRecords,
- };
- actionMap[action]?.();
- }
- /** 关闭未选设备提示 */
- function closeDeviceTip() {
- deviceTipVisible.value = false;
- }
- /** 有选中摄像头且 deviceid 非空则调用抓拍接口,否则提示选择设备 */
- async function trySnapshot() {
- const camera = ensureSelectedCamera();
- if (!camera) return;
- const deviceid = resolveCameraDeviceId(camera);
- if (!deviceid) {
- deviceTipVisible.value = true;
- return;
- }
- try {
- const res = await hsqComControl({
- ...buildSdkDeviceParams('captureToFile', camera),
- deviceid,
- });
- const ok = openSnapshotView(res as Record<string, any>);
- if (!ok) {
- createMessage.warning('抓拍成功,但未返回可展示图片');
- return;
- }
- createMessage.success('抓拍成功');
- } catch (e) {
- console.error('一键抓拍失败', e);
- }
- }
- /** 解析一键抓拍返回(兼容 result 包裹与直接对象) */
- function normalizeSnapshotResult(res: Record<string, any> | null | undefined) {
- if (!res || typeof res !== 'object') return null;
- if (res.success === false) return null;
- const data = (res.result && typeof res.result === 'object' ? res.result : res) as Record<string, any>;
- const path1 = data.path1;
- const path2 = data.path2;
- if (path1 == null && path2 == null && !data.id) return null;
- return data;
- }
- /** 打开一键抓拍结果弹窗;无可展示数据时返回 false */
- function openSnapshotView(res: Record<string, any> | null | undefined) {
- const data = normalizeSnapshotResult(res);
- if (!data) return false;
- snapshotViewData.id = String(data.id || `snapshot-${Date.now()}`);
- snapshotViewData.deviceName = String(data.deviceName || data.devicePos || '');
- snapshotViewData.createTime = String(data.createTime || '');
- snapshotViewData.visibleUrl = resolvePicUrl(data.path1);
- snapshotViewData.infraredUrl = resolvePicUrl(data.path2);
- if (!snapshotViewData.visibleUrl && !snapshotViewData.infraredUrl) return false;
- snapshotViewVisible.value = true;
- return true;
- }
- /** 关闭一键抓拍结果弹窗 */
- function closeSnapshotView() {
- snapshotViewVisible.value = false;
- closePicPreview();
- }
- /** 拼接录像完整路径(兼容 Windows / Unix 分隔符) */
- function buildRecordFullPath(filePath: string, fileName: string) {
- if (!filePath) return fileName;
- if (!fileName) return filePath;
- const normalized = filePath.replace(/[/\\]+$/, '');
- const sep = filePath.includes('\\') ? '\\' : '/';
- return `${normalized}${sep}${fileName}`;
- }
- /** 将存储路径转为可播放的 HTTP 地址 */
- function buildRecordPlayUrl(fullPath: string) {
- if (!fullPath) return '';
- if (/^https?:\/\//i.test(fullPath)) return fullPath;
- return getFileAccessHttpUrl(fullPath.replace(/\\/g, '/'));
- }
- /** 根据 recordMP4File 返回写入录像列表 */
- function appendRecordFile(res: Record<string, any> | null | undefined, fallbackName: string) {
- const data = res || {};
- const fileName = String(data.fileName || fallbackName || '');
- const filePath = String(data.filePath || '');
- if (!fileName) return;
- const fullPath = buildRecordFullPath(filePath, fileName);
- const item: RecordFileItem = {
- fileName,
- filePath,
- fullPath,
- playUrl: buildRecordPlayUrl(fullPath),
- recordTime: new Date().toLocaleString(),
- };
- recordFileList.value = [item, ...recordFileList.value.filter((r) => r.fullPath !== fullPath)];
- }
- /** 将抓拍路径转为可访问图片地址 */
- function resolvePicUrl(raw: unknown) {
- if (raw == null || raw === '') return '';
- const text = String(raw);
- if (/^https?:\/\//i.test(text) || text.startsWith('data:')) return text;
- const path = text.includes('h') ? text.substring(text.indexOf('h')) : text;
- return getFileAccessHttpUrl(path.replace(/\\/g, '/'));
- }
- /** 解析 picVideoList 分页结果(兼容 result 包裹与直接分页对象) */
- function normalizePicPage(res: Record<string, any> | null | undefined) {
- const data = (res?.result && typeof res.result === 'object' ? res.result : res) || {};
- const records = Array.isArray(data.records) ? (data.records as PicVideoRecord[]) : [];
- return {
- records,
- total: Number(data.total) || 0,
- };
- }
- function mapPicRecords(records: PicVideoRecord[]): PicViewItem[] {
- return records.map((item, index) => ({
- id: String(item.id || `${item.deviceId || 'pic'}-${index}`),
- deviceName: String(item.deviceName || item.devicePos || ''),
- createTime: String(item.createTime || ''),
- visibleUrl: resolvePicUrl(item.path1),
- infraredUrl: resolvePicUrl(item.path2),
- }));
- }
- /** 按当前分页拉取抓拍图片 */
- async function fetchPicList() {
- const camera = props.selectedCamera;
- const deviceId = resolveCameraDeviceId(camera);
- if (!deviceId) return;
- picViewLoading.value = true;
- try {
- const res = await picVideoList({
- deviceId,
- pageNo: picPage.current,
- pageSize: picPage.pageSize,
- type: 0,
- });
- const page = normalizePicPage(res as Record<string, any>);
- picViewList.value = mapPicRecords(page.records);
- picPage.total = page.total;
- } catch (e) {
- console.error('获取抓拍图片失败', e);
- picViewList.value = [];
- createMessage.error('获取抓拍图片失败');
- } finally {
- picViewLoading.value = false;
- }
- }
- /** 查看图片:未选设备提示;已选则打开分页图片弹窗 */
- function tryViewImages() {
- const camera = ensureSelectedCamera();
- if (!camera) return;
- const deviceId = resolveCameraDeviceId(camera);
- if (!deviceId) {
- deviceTipVisible.value = true;
- return;
- }
- picPage.current = 1;
- picViewList.value = [];
- picViewVisible.value = true;
- fetchPicList();
- }
- function closePicView() {
- picViewVisible.value = false;
- closePicPreview();
- }
- /** 打开图片放大预览 */
- function openPicPreview(url: string, channelLabel: string, item?: PicViewItem) {
- if (!url) return;
- const deviceName = item?.deviceName ? ` - ${item.deviceName}` : '';
- picPreviewUrl.value = url;
- picPreviewTitle.value = `${channelLabel}预览${deviceName}`;
- picPreviewVisible.value = true;
- }
- /** 关闭图片放大预览 */
- function closePicPreview() {
- picPreviewVisible.value = false;
- picPreviewUrl.value = '';
- picPreviewTitle.value = '图片预览';
- }
- function onPicPageChange(page: number) {
- picPage.current = page;
- fetchPicList();
- }
- /** 录像地址转可播放 URL,并按扩展名识别 MIME(默认 MP4) */
- function resolveVideoPlay(raw: unknown) {
- const text = String(raw || '').trim();
- if (!text) return { playUrl: '', mimeType: 'video/mp4', fileName: '' };
- const fileName = text.split(/[/\\]/).pop() || '录像.mp4';
- const ext = (fileName.split('.').pop() || 'mp4').toLowerCase();
- const mimeMap: Record<string, string> = {
- mp4: 'video/mp4',
- m4v: 'video/mp4',
- webm: 'video/webm',
- ogg: 'video/ogg',
- mov: 'video/quicktime',
- };
- return {
- playUrl: resolvePicUrl(text),
- mimeType: mimeMap[ext] || 'video/mp4',
- fileName,
- };
- }
- /** 将 picVideoList 录像记录转为可播放列表(path1 可见光,path2 红外) */
- function mapVideoRecords(records: PicVideoRecord[]): VideoViewItem[] {
- return records.map((item, index) => {
- const visible = resolveVideoPlay(item.path1);
- const infrared = resolveVideoPlay(item.path2);
- const fileName = visible.fileName || infrared.fileName || item.deviceName || '录像.mp4';
- return {
- id: String(item.id || `${item.deviceId || 'video'}-${index}`),
- fileName,
- deviceName: String(item.deviceName || item.devicePos || ''),
- recordTime: String(item.createTime || ''),
- visibleUrl: visible.playUrl,
- infraredUrl: infrared.playUrl,
- visibleMime: visible.mimeType,
- infraredMime: infrared.mimeType,
- };
- });
- }
- /** 拉取录像分页;成功返回 true */
- async function fetchRecordList() {
- const deviceId = resolveCameraDeviceId(props.selectedCamera);
- if (!deviceId) return false;
- recordViewLoading.value = true;
- try {
- const res = await picVideoList({
- deviceId,
- pageNo: recordPage.current,
- pageSize: recordPage.pageSize,
- type: 1,
- });
- const page = normalizePicPage(res as Record<string, any>);
- videoViewList.value = mapVideoRecords(page.records);
- recordPage.total = page.total;
- return true;
- } catch (e) {
- console.error('获取录像列表失败', e);
- createMessage.error('获取录像列表失败');
- return false;
- } finally {
- recordViewLoading.value = false;
- }
- }
- /** 查看录像:未选设备提示;已选则请求成功后再打开弹窗 */
- async function tryViewRecords() {
- const camera = ensureSelectedCamera();
- if (!camera) return;
- const deviceId = resolveCameraDeviceId(camera);
- if (!deviceId) {
- deviceTipVisible.value = true;
- return;
- }
- recordPage.current = 1;
- const ok = await fetchRecordList();
- if (!ok) return;
- recordViewVisible.value = true;
- playRecord(videoViewList.value.length ? 0 : -1);
- }
- function onRecordPageChange(page: number) {
- recordPage.current = page;
- fetchRecordList().then((ok) => {
- if (!ok) return;
- playRecord(videoViewList.value.length ? 0 : -1);
- });
- }
- /** 关闭查看录像弹窗 */
- function closeRecordView() {
- recordViewVisible.value = false;
- currentRecordIndex.value = -1;
- }
- /** 播放指定录像,默认可见光,没有可见光时播红外 */
- function playRecord(index: number) {
- if (index < 0 || index >= videoViewList.value.length) {
- currentRecordIndex.value = -1;
- return;
- }
- currentRecordIndex.value = index;
- const item = videoViewList.value[index];
- recordChannel.value = item?.visibleUrl ? 'visible' : 'infrared';
- }
- /** 切换录像状态:开始/停止均走 SDK 接口,成功后再切换本地录像态 */
- async function setRecording(recording: boolean) {
- const camera = ensureSelectedCamera();
- if (!camera) return;
- const paramcode = recording ? 'recordMP4File' : 'stopRecordMP4File';
- const request = hsqComControl;
- const fileName = recording ? `record_${Date.now()}.mp4` : pendingRecordFileName.value;
- if (recording) pendingRecordFileName.value = fileName;
- try {
- const params = {
- ...buildSdkDeviceParams(paramcode, camera),
- ...(recording ? { s1: fileName } : {}),
- };
- const res = await request(params);
- if (recording) {
- if (res && ((res as any).fileName || (res as any).filePath)) {
- appendRecordFile(res as Record<string, any>, fileName);
- }
- } else {
- const opened = openStopRecordView(res as Record<string, any>);
- if (!opened && res && ((res as any).fileName || (res as any).filePath)) {
- appendRecordFile(res as Record<string, any>, fileName);
- }
- }
- advancedState.recording = recording;
- onAdvancedAction(recording ? 'startRecord' : 'stopRecord');
- createMessage.success(recording ? '开始录像成功' : '停止录像成功');
- } catch (e) {
- console.error(recording ? '开始录像失败' : '停止录像失败', e);
- }
- }
- /** 解析停止录像返回(兼容 result 包裹与直接对象) */
- function normalizeStopRecordResult(res: Record<string, any> | null | undefined) {
- if (!res || typeof res !== 'object') return null;
- if (res.success === false) return null;
- const data = (res.result && typeof res.result === 'object' ? res.result : res) as Record<string, any>;
- if (data.path1 == null && data.path2 == null) return null;
- return data;
- }
- /** 打开停止录像结果弹窗;无可播放地址时返回 false */
- function openStopRecordView(res: Record<string, any> | null | undefined) {
- const data = normalizeStopRecordResult(res);
- if (!data) return false;
- const visible = resolveVideoPlay(data.path1);
- const infrared = resolveVideoPlay(data.path2);
- if (!visible.playUrl && !infrared.playUrl) return false;
- stopRecordViewData.id = String(data.id || `stop-record-${Date.now()}`);
- stopRecordViewData.deviceName = String(data.deviceName || data.devicePos || '');
- stopRecordViewData.recordTime = String(data.createTime || '');
- stopRecordViewData.visibleUrl = visible.playUrl;
- stopRecordViewData.infraredUrl = infrared.playUrl;
- stopRecordViewData.visibleMime = visible.mimeType;
- stopRecordViewData.infraredMime = infrared.mimeType;
- stopRecordViewVisible.value = true;
- return true;
- }
- /** 关闭停止录像结果弹窗 */
- function closeStopRecordView() {
- stopRecordViewVisible.value = false;
- stopRecordViewData.visibleUrl = '';
- stopRecordViewData.infraredUrl = '';
- }
- /** 解析报警推送中的图片地址(兼容 path1/path2、IRPic、picName 等) */
- function resolveAutoAlarmImageUrl(data: Record<string, any>) {
- const urls = collectAutoAlarmImageUrls(data);
- return urls[0] || '';
- }
- /** 收集推送中的全部可展示图片地址(支持 picName + picName2) */
- function collectAutoAlarmImageUrls(data: Record<string, any>): string[] {
- const rawList: unknown[] = [];
- if (data.picName || data.picName2) {
- if (data.picName) rawList.push(data.picName);
- if (data.picName2) rawList.push(data.picName2);
- } else {
- const single = data.IRPic || data.irPic || data.path2 || data.normalPic || data.path1 || data.imageUrl || data.url || data.fileName || '';
- if (single) rawList.push(single);
- else if (data.filePath) {
- const fullPath = buildRecordFullPath(String(data.filePath), String(data.fileName || ''));
- const playUrl = buildRecordPlayUrl(fullPath);
- if (playUrl) return [playUrl];
- }
- }
- const urls: string[] = [];
- const seen = new Set<string>();
- rawList.forEach((raw) => {
- const url = resolvePicUrl(raw);
- if (url && !seen.has(url)) {
- seen.add(url);
- urls.push(url);
- }
- });
- return urls;
- }
- /** 从 msgTxt / warndata 文案解析设备名与告警类型,如「采场西侧2-热源告警」 */
- function parseAlarmMsgText(text: unknown) {
- const raw = String(text || '').trim();
- if (!raw) return { device: '', type: '' };
- const sepIdx = raw.indexOf('-');
- if (sepIdx <= 0) return { device: raw, type: '' };
- const device = raw.slice(0, sepIdx).trim();
- const typePart = raw.slice(sepIdx + 1).trim();
- let type = typePart.replace(/告警$/u, '').trim();
- if (typePart.includes('热源')) type = '热源';
- else if (typePart.includes('闯入')) type = '闯入';
- else if (typePart.includes('烟雾')) type = '烟雾';
- return { device, type: type || typePart };
- }
- /** 判断 WebSocket 消息是否为可展示的报警信息 */
- function hasAutoAlarmPayload(res: unknown) {
- if (res == null || res === false || res === '') return false;
- if (typeof res === 'number') return false;
- if (typeof res === 'string') return res.trim().length > 0;
- if (typeof res === 'object') {
- const data = res as Record<string, any>;
- const detail = data.msgDetail && typeof data.msgDetail === 'object' ? (data.msgDetail as Record<string, any>) : null;
- return !!(
- data.IRPic ||
- data.irPic ||
- data.normalPic ||
- data.path1 ||
- data.path2 ||
- data.picName ||
- data.picName2 ||
- data.imageUrl ||
- data.url ||
- data.filePath ||
- data.fileName ||
- data.alarmTime ||
- data.createTime ||
- data.deviceName ||
- data.maxTemp ||
- data.max ||
- data.eventType ||
- data.topic === 'warn' ||
- data.cmd === 'user' ||
- data.cmd === 'alarm' ||
- data.msgTxt ||
- data.warndata ||
- data.msgDetail ||
- detail?.picName ||
- detail?.picName2
- );
- }
- return false;
- }
- /** 从 WebSocket 原始消息中提取报警数据对象 */
- function extractAlarmPayload(message: Record<string, any>) {
- if (!message || typeof message !== 'object') return null;
- const nested =
- (message.msgDetail && typeof message.msgDetail === 'object' ? message.msgDetail : null) ||
- (message.result && typeof message.result === 'object' ? message.result : null) ||
- (message.data && typeof message.data === 'object' ? message.data : null) ||
- (message.warndata && typeof message.warndata === 'object' ? message.warndata : null) ||
- (message.payload && typeof message.payload === 'object' ? message.payload : null);
- const candidate = (nested || message) as Record<string, any>;
- if (!hasAutoAlarmPayload(candidate) && !hasAutoAlarmPayload(message)) return null;
- return {
- ...message,
- ...candidate,
- };
- }
- /** 将报警数据映射到弹窗字段 */
- function applyAutoAlarmData(res: Record<string, any>) {
- const msgTxt = String(res.msgTxt || (typeof res.warndata === 'string' ? res.warndata : '') || '');
- const parsedText = parseAlarmMsgText(msgTxt);
- const maxTemp = res.maxTemp ?? res.max ?? res.maxTemperature ?? '';
- const alarmTime = String(res.alarmTime || res.createTime || res.time || '');
- const alarmDevice = String(res.alarmDevice || res.deviceName || res.devName || res.strname || parsedText.device || '');
- const alarmTypeRaw = res.alarmType ?? res.eventType ?? res.type ?? parsedText.type;
- let alarmType = String(alarmTypeRaw ?? '');
- if (alarmTypeRaw == '10001' || alarmTypeRaw === 10001 || String(alarmType).includes('热源')) {
- alarmType = '热源';
- autoAlarmTitle.value = String(res.msgTitle || '热源最新告警图');
- } else if (alarmTypeRaw == '10002' || alarmTypeRaw === 10002 || String(alarmType).includes('闯入')) {
- alarmType = '闯入';
- autoAlarmTitle.value = String(res.msgTitle || '闯入最新告警图');
- } else {
- autoAlarmTitle.value = String(res.msgTitle || '最新告警图');
- if (!alarmType) alarmType = '热源';
- }
- const imageUrls = collectAutoAlarmImageUrls(res);
- autoAlarmData.imageUrls = imageUrls;
- autoAlarmData.imageUrl = imageUrls[0] || resolveAutoAlarmImageUrl(res);
- autoAlarmData.maxTemp = maxTemp === '' || maxTemp == null ? '' : String(maxTemp);
- autoAlarmData.overlayTime = String(res.overlayTime || res.captureTime || alarmTime || '');
- autoAlarmData.deviceLabel = String(res.deviceLabel || res.channelName || `${alarmDevice}${alarmDevice ? '红外' : ''}`);
- autoAlarmData.alarmTime = alarmTime;
- autoAlarmData.alarmDevice = alarmDevice;
- autoAlarmData.alarmType = alarmType;
- autoAlarmData.hotspotValue = String(res.hotspotValue ?? res.maxTemp ?? res.max ?? '');
- autoAlarmData.msgTxt = msgTxt;
- }
- /** 构建报警 WebSocket 连接地址 */
- function buildAlarmWebSocketUrl() {
- const token = (getToken() || '') as string;
- const userId = String((userStore.getUserInfo as any)?.id || '');
- const base = `ws://${ALARM_WS_HOST}:${ALARM_WS_PORT}/websocket/${userId}`;
- return token ? `${base}?token=${encodeURIComponent(token)}` : base;
- }
- /** 断开报警自动弹窗 WebSocket */
- function disconnectAlarmWebSocket() {
- if (!alarmAutoWs) return;
- try {
- alarmAutoWs.onopen = null;
- alarmAutoWs.onmessage = null;
- alarmAutoWs.onerror = null;
- alarmAutoWs.onclose = null;
- if (alarmAutoWs.readyState === WebSocket.OPEN || alarmAutoWs.readyState === WebSocket.CONNECTING) {
- alarmAutoWs.close();
- }
- } catch (e) {
- console.error('关闭报警 WebSocket 失败', e);
- } finally {
- alarmAutoWs = null;
- }
- }
- /** 处理报警 WebSocket 消息:有报警信息则打开弹窗 */
- function handleAlarmWebSocketMessage(raw: MessageEvent | string | Record<string, any>) {
- if (!advancedState.alarmPopup) return;
- try {
- let parsed: any = raw;
- if (raw && typeof raw === 'object' && 'data' in (raw as MessageEvent)) {
- parsed = (raw as MessageEvent).data;
- }
- if (typeof parsed === 'string') {
- const text = parsed.trim();
- if (!text) return;
- parsed = JSON.parse(text);
- }
- if (!parsed || typeof parsed !== 'object') return;
- const payload = extractAlarmPayload(parsed as Record<string, any>);
- if (!payload) return;
- applyAutoAlarmData(payload);
- autoAlarmExpanded.value = false;
- autoAlarmPreviewVisible.value = false;
- autoAlarmPreviewSrc.value = '';
- autoAlarmVisible.value = true;
- } catch (e) {
- console.error('解析报警 WebSocket 消息失败', e);
- }
- }
- /** 连接报警自动弹窗 WebSocket */
- function connectAlarmWebSocket() {
- disconnectAlarmWebSocket();
- const url = buildAlarmWebSocketUrl();
- try {
- const ws = new WebSocket(url);
- alarmAutoWs = ws;
- ws.onopen = () => {
- console.log('[报警WebSocket] 连接成功', url);
- };
- ws.onmessage = (event) => {
- handleAlarmWebSocketMessage(event);
- };
- ws.onerror = (event) => {
- console.error('[报警WebSocket] 连接错误', event);
- };
- ws.onclose = () => {
- console.log('[报警WebSocket] 连接关闭');
- if (alarmAutoWs === ws) alarmAutoWs = null;
- };
- } catch (e) {
- console.error('[报警WebSocket] 创建连接失败', e);
- createMessage.error('报警 WebSocket 连接失败');
- }
- }
- /** 勾选「报警自动弹窗」:连接 WebSocket;取消则断开 */
- function onAlarmPopupChange(checked: boolean) {
- advancedState.alarmPopup = checked;
- if (checked) {
- connectAlarmWebSocket();
- return;
- }
- disconnectAlarmWebSocket();
- autoAlarmVisible.value = false;
- autoAlarmExpanded.value = false;
- closeAutoAlarmPreview();
- }
- function toggleAutoAlarmExpand() {
- autoAlarmExpanded.value = !autoAlarmExpanded.value;
- }
- /** 打开报警图片放大预览 */
- function openAutoAlarmPreview(src: string) {
- if (!src) return;
- autoAlarmPreviewSrc.value = src;
- autoAlarmPreviewVisible.value = true;
- }
- /** 关闭报警图片放大预览 */
- function closeAutoAlarmPreview() {
- autoAlarmPreviewVisible.value = false;
- autoAlarmPreviewSrc.value = '';
- }
- /** 关闭弹窗(仍勾选时继续监听 WebSocket) */
- function closeAutoAlarmPopup() {
- autoAlarmVisible.value = false;
- autoAlarmExpanded.value = false;
- closeAutoAlarmPreview();
- }
- /** 解析 getSysCfg 返回的系统配置对象 */
- function normalizeSysCfg(res: Record<string, any> | null | undefined) {
- if (!res || typeof res !== 'object') return {};
- if (res.data && typeof res.data === 'object' && !Array.isArray(res.data)) {
- return { ...res.data };
- }
- return { ...res };
- }
- /** 将系统配置中的扫描角度同步到角度看板表单 */
- function applyAngleFromSysCfg(cfg: Record<string, any>) {
- angleState.hMin = toFiniteNumber(cfg.horStartAngle, ANGLE_DEFAULT.hMin);
- angleState.hMax = toFiniteNumber(cfg.horEndAngle, ANGLE_DEFAULT.hMax);
- angleState.vMin = toFiniteNumber(cfg.verStartAngle, ANGLE_DEFAULT.vMin);
- angleState.vMax = toFiniteNumber(cfg.verEndAngle, ANGLE_DEFAULT.vMax);
- }
- /** 切换到角度看板时拉取系统配置,回填扫描角度并缓存完整配置 */
- async function loadAngleSysCfg() {
- const camera = props.selectedCamera;
- const deviceid = resolveCameraDeviceId(camera);
- if (!deviceid) {
- cachedSysCfg.value = {};
- return;
- }
- try {
- const cfgRes = await getSysCfg({
- deviceid,
- paramcode: 'getSysCfg',
- noCheckPassword: true,
- });
- const cfg = normalizeSysCfg(cfgRes as Record<string, any>);
- cachedSysCfg.value = cfg;
- applyAngleFromSysCfg(cfg);
- } catch (e) {
- console.error('获取系统配置失败', e);
- cachedSysCfg.value = {};
- }
- }
- /** 校验并读取当前角度看板四个角度值 */
- function readAngleRange() {
- const horStartAngle = Number(angleState.hMin);
- const horEndAngle = Number(angleState.hMax);
- const verStartAngle = Number(angleState.vMin);
- const verEndAngle = Number(angleState.vMax);
- if ([horStartAngle, horEndAngle, verStartAngle, verEndAngle].some((n) => Number.isNaN(n))) {
- createMessage.error('请输入有效的扫描角度');
- return null;
- }
- if (horStartAngle > horEndAngle || verStartAngle > verEndAngle) {
- createMessage.error('起始角度不能大于结束角度');
- return null;
- }
- return { horStartAngle, horEndAngle, verStartAngle, verEndAngle };
- }
- /** 构建 setSysCfg 的 data:在缓存的完整配置上覆盖扫描角度字段 */
- function buildAngleSysCfgData(angleRange: { horStartAngle: number; horEndAngle: number; verStartAngle: number; verEndAngle: number }) {
- return {
- ...cachedSysCfg.value,
- ...angleRange,
- };
- }
- /** 保存扫描角度:未选设备提示;已选则调用 setSysCfg,成功后重新 getSysCfg 刷新 */
- async function saveAngleCfg() {
- const camera = ensureSelectedCamera();
- if (!camera) return;
- const deviceid = resolveCameraDeviceId(camera);
- if (!deviceid) {
- deviceTipVisible.value = true;
- return;
- }
- const angleRange = readAngleRange();
- if (!angleRange) return;
- try {
- await hsqComControl({
- deviceid,
- paramcode: 'setSysCfg',
- noCheckPassword: true,
- data: buildAngleSysCfgData(angleRange),
- });
- createMessage.success('角度配置保存成功,设备将按设置范围扫描');
- emit('angleAction', { action: 'save', state: { ...angleState } });
- await loadAngleSysCfg();
- } catch (e) {
- console.error('保存角度配置失败', e);
- }
- }
- /** 将水平 / 垂直扫描角度恢复为默认初始值 */
- function resetAngleToDefault() {
- angleState.hMin = ANGLE_DEFAULT.hMin;
- angleState.hMax = ANGLE_DEFAULT.hMax;
- angleState.vMin = ANGLE_DEFAULT.vMin;
- angleState.vMax = ANGLE_DEFAULT.vMax;
- }
- function onAngleAction(action: 'save' | 'refresh') {
- if (action === 'refresh') {
- resetAngleToDefault();
- emit('angleAction', { action, state: { ...angleState } });
- return;
- }
- saveAngleCfg();
- }
- /** 从预置位接口返回中取出列表数组(兼容多种返回结构) */
- function pickPresetArray(res: any): any[] {
- if (!res) return [];
- if (Array.isArray(res)) return res;
- if (typeof res !== 'object') return [];
- const payload = res.data ?? res.result ?? res;
- if (Array.isArray(payload)) return payload;
- if (payload && typeof payload === 'object') {
- for (const key of ['presetList', 'presets', 'preset', 'list', 'records', 'data']) {
- const value = (payload as Record<string, any>)[key];
- if (Array.isArray(value)) return value;
- }
- }
- return [];
- }
- /** 预置位名称兼容 C 结构体 byte[] */
- function decodePresetName(raw: unknown) {
- if (Array.isArray(raw)) {
- const codes = raw.map((n) => Number(n)).filter((n) => Number.isFinite(n) && n > 0);
- return codes.length ? String.fromCharCode(...codes) : '';
- }
- return raw == null ? '' : String(raw);
- }
- /** 预置位接口数据 → 表格行(编号 / 名称) */
- function normalizePresetList(res: any): PresetItem[] {
- const rows: PresetItem[] = [];
- for (const item of pickPresetArray(res)) {
- if (item == null) continue;
- if (typeof item !== 'object') {
- rows.push({ id: String(item), name: '' });
- continue;
- }
- const no = item.no ?? item.index ?? item.idx ?? item.preset ?? item.presetNo ?? item.i1 ?? item.id ?? '';
- const name = decodePresetName(item.name ?? item.presetName ?? item.strname ?? item.title);
- if (no === '' && !name) continue;
- const presetNo = Number(no);
- rows.push({ id: no === '' ? '' : String(no), preset: Number.isFinite(presetNo) ? presetNo : undefined, name });
- }
- return rows;
- }
- /**
- * 拉取当前选中摄像头的预置位列表
- * 请求参数:{ deviceid: 选中摄像头id, paramcode: 'getPresetInfo', noCheckPassword: true }
- * 未选中摄像头时置空列表,仅保留斑马纹占位行
- */
- async function loadPresetList() {
- const camera = props.selectedCamera;
- if (!camera || !resolveCameraDeviceId(camera)) {
- presetData.value = [];
- return;
- }
- try {
- const res = await presetList(buildSdkDeviceParams('getAllPreset', camera));
- console.log(res, '预置位列表返回值');
- presetData.value = normalizePresetList(res);
- } catch (e) {
- console.error('获取预置位列表失败', e);
- presetData.value = [];
- }
- }
- /** 预置位工具栏操作:刷新重新拉取列表;添加新增一行;其余仅抛事件 */
- function onPresetAction(key: string) {
- emit('presetAction', key);
- if (key === 'refresh') {
- loadPresetList();
- return;
- }
- if (key === 'add') {
- onAddPreset();
- }
- }
- /** 解析预置位编号:优先取 id,其次从名称中提取数字 */
- function parsePresetNo(row: PresetItem) {
- const fromId = Number(row.id);
- if (row.id !== '' && Number.isFinite(fromId)) return fromId;
- const matched = String(row.name ?? '').match(/\d+/);
- return matched ? Number(matched[0]) : NaN;
- }
- /** 预置位编号上限 */
- const PRESET_NO_MAX = 255;
- /**
- * 新增预置位
- * 1)遍历当前列表取最大编号(最大不超过 255),新增编号 = 最大编号 + 1
- * 例:已有预置位18(编号18)→ 新增预置位19(编号19)
- * 2)调用新增预置位接口:paramcode = setPtzPreset,多携带 i1 = 预置位编号
- * 3)接口成功后再追加到列表
- */
- async function onAddPreset() {
- const camera = ensureSelectedCamera();
- if (!camera) return;
- const maxNo = presetData.value.reduce<number>((max, row) => {
- const no = parsePresetNo(row);
- return Number.isFinite(no) && no > max ? no : max;
- }, 0);
- if (maxNo >= PRESET_NO_MAX) {
- createMessage.warning(`预置位编号已达上限 ${PRESET_NO_MAX}`);
- return;
- }
- const next = maxNo + 1;
- Modal.confirm({
- title: '新增确认',
- content: h('div', { style: { color: '#fff' } }, `确定新增预置位${next}吗?`),
- iconType: 'info',
- okText: '确定',
- cancelText: '取消',
- zIndex: 4000,
- onOk: async () => {
- try {
- const res = await presetSet({
- ...buildSdkDeviceParams('setPtzPreset', camera),
- i1: next,
- });
- console.log(res, '新增预置位返回值');
- const code = (res as any)?.code;
- if (code !== undefined && code !== 0 && code !== 200) {
- createMessage.error((res as any)?.message || '新增预置位失败');
- return;
- }
- presetData.value = [...presetData.value, { id: String(next), preset: next, name: `预置位${next}` }];
- createMessage.success(`预置位${next} 添加成功`);
- } catch (e) {
- console.error('新增预置位失败', e);
- createMessage.error('新增预置位失败');
- }
- },
- });
- }
- /**
- * 删除预置位
- * 1)解析当前行预置位编号(优先 preset,其次 id / 名称中的数字)
- * 2)调用删除预置位接口:paramcode = delPtzPreset,多携带 i1 = 预置位编号
- * 3)接口成功后再从列表移除该行
- */
- async function onDeletePreset(row: PresetItem) {
- const camera = ensureSelectedCamera();
- if (!camera) return;
- const presetNo = Number(row.preset ?? parsePresetNo(row));
- if (!Number.isFinite(presetNo) || presetNo <= 0) {
- createMessage.warning('未识别到预置位编号,无法删除');
- return;
- }
- Modal.confirm({
- title: '删除确认',
- content: h('div', { style: { color: '#fff' } }, `确定删除预置位「${row.name || row.id}」吗?`),
- iconType: 'warning',
- okText: '删除',
- okType: 'danger',
- cancelText: '取消',
- zIndex: 4000,
- onOk: async () => {
- try {
- const res = await presetDelete({
- ...buildSdkDeviceParams('delPtzPreset', camera),
- i1: presetNo,
- });
- console.log(res, '删除预置位返回值');
- const code = (res as any)?.code;
- if (code !== undefined && code !== 0 && code !== 200) {
- createMessage.error((res as any)?.message || '删除预置位失败');
- return;
- }
- presetData.value = presetData.value.filter((item) => item !== row);
- createMessage.success('删除预置位成功');
- } catch (e) {
- console.error('删除预置位失败', e);
- createMessage.error('删除预置位失败');
- }
- },
- });
- }
- /** 预置位操作列「放大预览」:通知父组件放大当前选中摄像头并显示左侧区域选择,携带选中预置位编号 */
- function onRegionPreview(row: PresetItem) {
- const preset = row?.preset ?? (Number(row?.id) || 1);
- emit('regionPreview', preset);
- }
- /** 同步分组展开状态:新增分组默认展开;树数据变化时同步勾选设备 ID */
- watch(
- () => props.treeData,
- (list) => {
- (list || []).forEach((group) => {
- if (expandedMap[group.id] === undefined) {
- expandedMap[group.id] = true;
- }
- });
- emitCheckedDeviceIds();
- },
- { immediate: true, deep: true }
- );
- /** Tab 切换:通知外部;进入角度拉取配置;进入预置位拉取列表;进入统计渲染图表 */
- watch(activeTab, async (key) => {
- emit('tabChange', key);
- if (key === 'angle') {
- await loadAngleSysCfg();
- return;
- }
- if (key === 'preset') {
- await loadPresetList();
- return;
- }
- if (key === 'stats') {
- await nextTick();
- renderStatsChart();
- resizeStatsChart();
- }
- });
- /** 选中摄像头变化:预置位看板可见时同步刷新列表 */
- watch(
- () => props.selectedCamera,
- () => {
- if (activeTab.value === 'preset') loadPresetList();
- }
- );
- /** 统计数据变化时同步刷新图表 */
- watch(statsCount, () => {
- if (activeTab.value === 'stats') renderStatsChart();
- });
- onUnmounted(() => {
- disconnectAlarmWebSocket();
- });
- </script>
- <style lang="less" scoped>
- // 兼容项目 zxm 前缀与默认 ant 前缀
- @cb: ~'.ant-checkbox, .zxm-checkbox';
- @cb-inner: ~'.ant-checkbox-inner, .zxm-checkbox-inner';
- @cb-checked-inner: ~'.ant-checkbox-checked .ant-checkbox-inner, .zxm-checkbox-checked .zxm-checkbox-inner';
- @cb-checked-after: ~'.ant-checkbox-checked .ant-checkbox-inner::after, .zxm-checkbox-checked .zxm-checkbox-inner::after';
- @in-num: ~'.ant-input-number, .zxm-input-number';
- @in-num-input: ~'.ant-input-number-input-wrap, .zxm-input-number-input-wrap, .ant-input-number-input, .zxm-input-number-input';
- .checkbox-box(@size: 14px) {
- :deep(@{cb}) {
- top: 0;
- }
- :deep(@{cb-inner}) {
- width: @size;
- height: @size;
- box-sizing: border-box;
- border: 1px solid rgba(0, 183, 255, 0.75);
- border-radius: 2px;
- background: rgba(0, 30, 55, 0.85);
- }
- :deep(@{cb-checked-inner}) {
- background: var(--dt-accent);
- border-color: var(--dt-accent);
- }
- :deep(@{cb-checked-after}) {
- border-color: #fff;
- border-width: 1.5px;
- }
- }
- .device-tree {
- --dt-bg-row: transparent;
- --dt-bg-active: rgba(22, 90, 150, 0.55);
- --dt-accent: #00b7ff;
- --dt-text: #e8f4ff;
- --dt-text-muted: #8aa0b8;
- --dt-line: rgba(120, 150, 180, 0.55);
- height: 100%;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
- color: var(--dt-text);
- font-size: 13px;
- overflow: hidden;
- padding: 15px;
- }
- .tree-tabs {
- display: flex;
- align-items: stretch;
- height: 30px;
- flex-shrink: 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
- background: #15517b;
- margin-bottom: 10px;
- .tab-item {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- color: var(--dt-text-muted);
- position: relative;
- transition: color 0.2s;
- &:hover {
- color: #cfe9ff;
- }
- &.active {
- color: #fff;
- font-weight: 600;
- background-color: #1d73a8;
- &::after {
- content: '';
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- height: 2px;
- background: #36c7ff;
- border-radius: 1px;
- box-shadow: 0 0 6px rgba(0, 183, 255, 0.6);
- }
- }
- }
- }
- .tree-body {
- flex: 1;
- overflow: auto;
- padding: 6px 0;
- box-sizing: border-box;
- &::-webkit-scrollbar {
- width: 4px;
- }
- &::-webkit-scrollbar-thumb {
- background: rgba(0, 183, 255, 0.35);
- border-radius: 2px;
- }
- }
- .group-row {
- display: flex;
- align-items: center;
- height: 30px;
- padding: 0 10px 0 8px;
- cursor: pointer;
- user-select: none;
- &:hover {
- background: rgba(255, 255, 255, 0.03);
- }
- .expand-icon {
- width: 14px;
- height: 14px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- color: #d8e6f5;
- margin-right: 4px;
- transition: transform 0.2s;
- &.collapsed {
- transform: rotate(-90deg);
- }
- }
- .group-icon {
- display: inline-flex;
- margin-right: 6px;
- }
- .group-title {
- flex: 1;
- min-width: 0;
- font-size: 13px;
- color: var(--dt-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .group-checks {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- margin-left: 8px;
- flex-shrink: 0;
- }
- }
- .group-children {
- position: relative;
- }
- .device-row {
- position: relative;
- display: flex;
- align-items: center;
- height: 30px;
- padding: 0 10px 0 28px;
- cursor: pointer;
- background: var(--dt-bg-row);
- &:hover {
- background: rgba(255, 255, 255, 0.04);
- }
- &.active {
- background: var(--dt-bg-active);
- }
- .tree-line {
- position: absolute;
- left: 14px;
- top: 0;
- bottom: 0;
- width: 0;
- border-left: 1px dashed var(--dt-line);
- &::after {
- content: '';
- position: absolute;
- left: 0;
- top: 15px;
- width: 10px;
- border-top: 1px dashed var(--dt-line);
- }
- }
- &.is-last .tree-line {
- bottom: 50%;
- }
- .device-icon {
- display: inline-flex;
- margin-right: 6px;
- flex-shrink: 0;
- }
- .device-name {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: #d7e9fa;
- font-size: 12px;
- }
- .device-checks {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-left: 8px;
- flex-shrink: 0;
- }
- }
- .check-item {
- .checkbox-box(14px);
- position: relative;
- display: inline-flex !important;
- align-items: center !important;
- cursor: pointer;
- margin: 0 !important;
- color: #d7e9fa;
- user-select: none;
- line-height: 1;
- white-space: nowrap;
- height: auto;
- :deep(.ant-checkbox),
- :deep(.zxm-checkbox) {
- top: 0 !important;
- display: inline-flex !important;
- align-items: center;
- align-self: center;
- margin-inline-end: 0 !important;
- line-height: 1;
- }
- :deep(@{cb-checked-after}) {
- width: 4px;
- height: 7px;
- }
- :deep(.ant-checkbox-input:focus + .ant-checkbox-inner),
- :deep(.zxm-checkbox-input:focus + .zxm-checkbox-inner),
- :deep(.ant-checkbox:hover .ant-checkbox-inner),
- :deep(.zxm-checkbox:hover .zxm-checkbox-inner) {
- border-color: rgba(0, 183, 255, 0.95);
- }
- /* 勾选框在前,文字描述在后 */
- :deep(.ant-checkbox + span),
- :deep(.zxm-checkbox + span) {
- display: inline-block;
- padding-inline-start: 6px !important;
- padding-inline-end: 0 !important;
- color: #d7e9fa;
- font-size: 12px;
- line-height: 1.2;
- }
- &:hover {
- :deep(.ant-checkbox + span),
- :deep(.zxm-checkbox + span) {
- color: #01fefc;
- }
- }
- }
- .advanced-panel {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- padding: 8px 6px;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .adv-section {
- // flex: 1;
- height: 36px;
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: flex-start;
- flex-wrap: nowrap;
- gap: 12px;
- padding: 0px 12px;
- min-height: 0;
- box-sizing: border-box;
- background: url('../../../../../assets/images//home-container/configurable/hsq/7-2.png') no-repeat center;
- background-size: 100% 100%;
- // background: rgba(8, 36, 64, 0.55);
- // border: 1px solid rgba(58, 225, 255, 0.12);
- border-radius: 2px;
- &.adv-section-last {
- border-bottom-color: rgba(58, 225, 255, 0.12);
- }
- .adv-check-wrap {
- display: inline-flex;
- align-items: center;
- }
- }
- /* 高级看板按钮 / 角度看板保存:双框青蓝渐变(外框 + 深色缝隙 + 内亮边) */
- .advanced-panel .adv-btn,
- .angle-panel .angle-btn-save {
- position: relative;
- flex: 0 0 auto;
- min-width: 78px;
- height: 30px !important;
- min-height: 30px;
- max-height: 30px;
- padding: 0 18px !important;
- margin: 0 !important;
- border: 1px solid #22babd !important;
- border-radius: 3px !important;
- background: linear-gradient(180deg, #3ad8e8 0%, #22c4d6 28%, #1498ac 62%, #0d6f82 100%) !important;
- color: #fff !important;
- font-size: 13px !important;
- font-weight: 500 !important;
- letter-spacing: 1px;
- line-height: 1 !important;
- cursor: pointer;
- white-space: nowrap;
- display: inline-flex !important;
- align-items: center;
- justify-content: center;
- 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;
- text-shadow: 0 1px 1px rgba(0, 30, 45, 0.45);
- transition: filter 0.2s, box-shadow 0.2s, opacity 0.2s;
- &:hover:not(:disabled),
- &:focus:not(:disabled) {
- color: #fff !important;
- filter: brightness(1.08);
- border-color: #0e8a9e !important;
- background: linear-gradient(180deg, #48e4f2 0%, #2ad0e2 28%, #18a8bc 62%, #0f7d90 100%) !important;
- 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;
- }
- &:disabled {
- opacity: 0.42;
- cursor: not-allowed;
- filter: grayscale(0.35);
- border-color: rgba(80, 120, 140, 0.55) !important;
- color: rgba(220, 235, 245, 0.6) !important;
- background: linear-gradient(180deg, rgba(40, 100, 120, 0.55) 0%, rgba(20, 60, 80, 0.55) 100%) !important;
- 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;
- text-shadow: none;
- }
- :deep(span) {
- color: inherit;
- position: relative;
- z-index: 1;
- }
- }
- /* 角度看板等共用按钮基础态 */
- .adv-btn {
- flex: 0 0 auto;
- min-width: 76px;
- height: 28px !important;
- min-height: 28px;
- max-height: 28px;
- padding: 0 14px !important;
- border: 1px solid rgba(1, 220, 255, 0.85) !important;
- border-radius: 3px !important;
- background: linear-gradient(180deg, rgba(18, 120, 150, 0.95) 0%, rgba(8, 70, 100, 0.95) 100%) !important;
- color: #fff !important;
- font-size: 12px !important;
- letter-spacing: 0.5px;
- cursor: pointer;
- box-shadow: 0 0 8px rgba(1, 254, 252, 0.22);
- transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, opacity 0.2s;
- white-space: nowrap;
- display: inline-flex !important;
- align-items: center;
- justify-content: center;
- &:hover:not(:disabled),
- &:focus:not(:disabled) {
- background: linear-gradient(180deg, rgba(24, 150, 180, 0.98) 0%, rgba(10, 90, 120, 0.98) 100%) !important;
- border-color: #01fefc !important;
- color: #fff !important;
- }
- &:disabled {
- opacity: 0.4;
- cursor: not-allowed;
- }
- :deep(span) {
- color: inherit;
- }
- }
- .adv-check {
- .checkbox-box(13px);
- flex: 0 0 auto;
- display: inline-flex !important;
- align-items: center !important;
- gap: 0;
- cursor: pointer;
- color: #fff;
- user-select: none;
- line-height: 1;
- margin: 0 !important;
- white-space: nowrap;
- height: auto;
- &.ant-checkbox-wrapper-disabled,
- &.zxm-checkbox-wrapper-disabled {
- cursor: not-allowed;
- opacity: 0.45;
- }
- /* 勾选框本体与文字垂直居中 */
- :deep(.ant-checkbox),
- :deep(.zxm-checkbox) {
- top: 0 !important;
- display: inline-flex !important;
- align-items: center;
- align-self: center;
- margin-inline-end: 0 !important;
- line-height: 1;
- }
- :deep(.ant-checkbox-inner),
- :deep(.zxm-checkbox-inner) {
- display: block;
- position: relative;
- top: 0;
- }
- :deep(@{cb-checked-after}) {
- width: 3.5px;
- height: 6.5px;
- top: 45%;
- inset-inline-start: 22%;
- }
- :deep(.ant-checkbox + span),
- :deep(.zxm-checkbox + span) {
- padding-inline-start: 6px;
- padding-inline-end: 0;
- font-size: 12px;
- line-height: 13px;
- color: #fff;
- display: inline-flex;
- align-items: center;
- }
- :deep(.ant-checkbox:hover .ant-checkbox-inner),
- :deep(.zxm-checkbox:hover .zxm-checkbox-inner) {
- border-color: rgba(0, 183, 255, 0.95);
- }
- }
- /* 预置位看板 */
- .preset-panel {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 4px 2px 2px;
- box-sizing: border-box;
- }
- .preset-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-around;
- flex-shrink: 0;
- gap: 8px;
- padding: 2px 0 4px;
- background: url('../../../../../assets/images//home-container/configurable/hsq/7-2.png') no-repeat center;
- background-size: 100% 100%;
- }
- .preset-tool-btn {
- width: 32px;
- height: 32px;
- padding: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border: 1px solid rgba(54, 199, 255, 0.45);
- border-radius: 2px;
- background: linear-gradient(180deg, rgba(18, 70, 110, 0.95) 0%, rgba(8, 36, 64, 0.95) 100%);
- color: #36c7ff;
- cursor: pointer;
- box-shadow: inset 0 0 0 1px rgba(0, 40, 70, 0.55);
- transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
- &:hover {
- border-color: #36c7ff;
- box-shadow: inset 0 0 0 1px rgba(0, 40, 70, 0.45), 0 0 8px rgba(54, 199, 255, 0.35);
- background: linear-gradient(180deg, rgba(28, 100, 150, 0.98) 0%, rgba(12, 50, 85, 0.98) 100%);
- }
- :deep(svg) {
- display: block;
- color: #36c7ff;
- fill: #36c7ff;
- }
- }
- .preset-table {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .preset-thead {
- position: relative;
- z-index: 1;
- flex-shrink: 0;
- height: 34px;
- display: grid;
- grid-template-columns: 1fr 1.4fr 1fr;
- align-items: center;
- padding: 0 18px;
- color: #36c7ff;
- font-size: 13px;
- letter-spacing: 1px;
- // background: linear-gradient(
- // 90deg,
- // rgba(10, 60, 100, 0.98) 0%,
- // rgba(20, 95, 145, 0.88) 50%,
- // rgba(10, 60, 100, 0.98) 100%
- // );
- // border: 1px solid rgba(54, 199, 255, 0.9);
- // border-bottom-color: rgba(54, 199, 255, 0.45);
- /* 顶部左右倒角,贴近设计稿表头框 */
- // clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%, 0 10px);
- // box-shadow:
- // 0 0 12px rgba(54, 199, 255, 0.35),
- // inset 0 1px 0 rgba(160, 235, 255, 0.28);
- background: url('../../../../../assets/images//home-container/configurable/hsq/table-head.png') no-repeat center;
- background-size: 100% 100%;
- margin-bottom: 10px;
- .col-id {
- text-align: left;
- }
- .col-name {
- text-align: center;
- }
- .col-action {
- text-align: right;
- }
- }
- .preset-tbody {
- flex: 1;
- min-height: 0;
- overflow: auto;
- border: 1px solid rgba(54, 199, 255, 0.55);
- border-top: none;
- box-sizing: border-box;
- background: rgba(4, 20, 40, 0.55);
- &::-webkit-scrollbar {
- width: 4px;
- }
- &::-webkit-scrollbar-thumb {
- background: rgba(54, 199, 255, 0.35);
- border-radius: 2px;
- }
- }
- .preset-row {
- display: grid;
- grid-template-columns: 1fr 1.4fr 1fr;
- align-items: center;
- min-height: 34px;
- height: 34px;
- padding: 0 16px;
- box-sizing: border-box;
- color: #e8f4ff;
- font-size: 12px;
- background: rgba(6, 28, 52, 0.92);
- &.is-odd {
- background: rgba(18, 58, 95, 0.72);
- }
- &.empty {
- pointer-events: none;
- }
- .col-id {
- text-align: left;
- }
- .col-name {
- text-align: center;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .col-action {
- text-align: right;
- }
- }
- /* 操作列按钮(放大 / 删除) */
- .preset-del-btn,
- .preset-zoom-btn {
- width: 24px;
- height: 22px;
- padding: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border: 1px solid #1c668f;
- border-radius: 2px;
- background: rgba(15, 62, 97, 0.55);
- cursor: pointer;
- transition: border-color 0.2s, box-shadow 0.2s, color 0.2s, background 0.2s;
- :deep(svg) {
- display: block;
- color: inherit;
- fill: currentColor;
- }
- &:hover {
- color: #fff;
- border-color: #6a8dff;
- background: rgba(26, 93, 140, 0.7);
- box-shadow: 0 0 6px rgba(26, 93, 140, 0.45);
- }
- }
- .preset-zoom-btn {
- margin-right: 6px;
- }
- .tab-placeholder {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--dt-text-muted);
- font-size: 13px;
- }
- .stats-panel {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- padding: 10px 8px 4px;
- box-sizing: border-box;
- }
- .stats-header {
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- flex-shrink: 0;
- padding: 0 6px 8px;
- }
- .stats-total-left {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 6px;
- }
- .stats-cam-icon {
- color: #e8f4ff;
- filter: brightness(1.15);
- }
- .stats-total-label {
- color: #e8f4ff;
- font-size: 13px;
- line-height: 1.2;
- letter-spacing: 0.5px;
- }
- .stats-total-num {
- color: #fff;
- font-size: 36px;
- font-weight: 500;
- line-height: 1;
- letter-spacing: 1px;
- }
- .stats-divider {
- flex-shrink: 0;
- height: 1px;
- margin: 0 4px 4px;
- background: linear-gradient(
- 90deg,
- transparent 0%,
- rgba(200, 210, 230, 0.55) 15%,
- rgba(232, 244, 255, 0.75) 50%,
- rgba(200, 210, 230, 0.55) 85%,
- transparent 100%
- );
- }
- .stats-chart {
- flex: 1;
- width: 100%;
- min-height: 0;
- }
- .angle-panel {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- padding: 14px 8px 12px;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- gap: 14px;
- }
- .angle-fields {
- display: flex;
- flex-direction: column;
- gap: 12px;
- flex-shrink: 0;
- }
- .angle-group {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- min-height: 44px;
- padding: 8px 12px;
- box-sizing: border-box;
- background: rgba(19, 47, 80, 0.88);
- border-radius: 2px;
- }
- .angle-label {
- flex-shrink: 0;
- color: #fff;
- font-size: 13px;
- line-height: 1.2;
- letter-spacing: 0.5px;
- white-space: nowrap;
- }
- .angle-range {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
- flex: 1;
- min-width: 0;
- }
- .angle-input-wrap {
- position: relative;
- display: flex;
- align-items: center;
- width: 88px;
- flex: 0 0 88px;
- min-width: 72px;
- height: 28px;
- padding: 0 8px;
- box-sizing: border-box;
- background: rgba(8, 24, 44, 0.95);
- border: 1px solid #1883b5;
- border-radius: 4px;
- }
- .angle-input {
- flex: 1;
- min-width: 0;
- width: 100%;
- :deep(@{in-num-input}) {
- height: 26px;
- padding: 0 !important;
- background: transparent !important;
- color: #fff !important;
- font-size: 13px;
- box-shadow: none !important;
- text-align: left;
- }
- &:hover,
- &.ant-input-number-focused,
- &.zxm-input-number-focused {
- :deep(.ant-input-number-input),
- :deep(.zxm-input-number-input) {
- color: #fff !important;
- }
- }
- /* 隐藏数字输入框增减按钮,保持纯手动输入 */
- :deep(.ant-input-number-handler-wrap),
- :deep(.zxm-input-number-handler-wrap) {
- display: none !important;
- }
- }
- :deep(.angle-input.ant-input-number),
- :deep(.angle-input.zxm-input-number) {
- width: 100%;
- background: transparent !important;
- border: none !important;
- box-shadow: none !important;
- }
- .angle-sep {
- flex-shrink: 0;
- color: #fff;
- font-size: 14px;
- line-height: 1;
- padding: 0 2px;
- }
- .angle-actions {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: flex-end;
- gap: 12px;
- padding: 2px 0 0;
- flex-shrink: 0;
- }
- .angle-btn {
- min-width: 72px !important;
- height: 28px !important;
- padding: 0 18px !important;
- border-radius: 4px !important;
- font-size: 13px !important;
- letter-spacing: 1px;
- line-height: 1 !important;
- display: inline-flex !important;
- align-items: center;
- justify-content: center;
- box-shadow: none !important;
- :deep(span) {
- color: inherit;
- }
- }
- .angle-btn-refresh {
- background: transparent !important;
- border: 1px solid #1883b5 !important;
- color: #fff !important;
- &:hover,
- &:focus {
- background: rgba(24, 131, 181, 0.15) !important;
- border-color: #36c7ff !important;
- color: #fff !important;
- }
- }
- ::v-deep .zxm-input-number-input {
- color: #e8f4ff !important;
- }
- ::v-deep .zxm-input {
- color: #fff !important;
- }
- ::-webkit-scrollbar {
- display: none;
- }
- </style>
- <!-- 弹窗等挂载到 body,需非 scoped 样式 -->
- <style lang="less">
- /* 未选设备提示弹窗(风格对齐报警自动弹窗) */
- .device-tip-mask {
- position: fixed;
- inset: 0;
- z-index: 2200;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0, 8, 20, 0.55);
- }
- .device-tip-panel {
- width: min(360px, calc(100vw - 48px));
- }
- .device-tip-frame {
- position: relative;
- padding: 36px 20px 18px;
- box-sizing: border-box;
- background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
- border: 1px solid rgba(0, 210, 255, 0.85);
- 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);
- 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);
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .device-tip-title-tab {
- position: absolute;
- top: -1px;
- left: 50%;
- transform: translateX(-50%);
- min-width: 112px;
- height: 28px;
- padding: 0 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #fff;
- font-size: 14px;
- font-weight: 600;
- letter-spacing: 2px;
- background: linear-gradient(180deg, #2ad4e8 0%, #1498b8 55%, #0c6f8a 100%);
- clip-path: polygon(12px 0, calc(100% - 12px) 0, 100% 100%, 0 100%);
- box-shadow: 0 0 10px rgba(0, 200, 255, 0.45);
- z-index: 2;
- }
- .device-tip-body {
- min-height: 72px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #e8f4ff;
- font-size: 16px;
- letter-spacing: 1px;
- padding: 12px 8px 8px;
- text-align: center;
- }
- .device-tip-footer {
- display: flex;
- justify-content: center;
- padding: 4px 0 4px;
- }
- .device-tip-btn {
- min-width: 96px;
- height: 32px;
- padding: 0 22px;
- border: 1px solid rgba(0, 210, 255, 0.75);
- border-radius: 2px;
- background: linear-gradient(180deg, rgba(20, 100, 140, 0.85) 0%, rgba(10, 60, 95, 0.9) 100%);
- color: #fff;
- font-size: 14px;
- letter-spacing: 2px;
- cursor: pointer;
- box-shadow: 0 0 8px rgba(0, 183, 255, 0.25);
- transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s;
- &:hover,
- &:focus {
- border-color: #01fefc;
- color: #01fefc;
- background: rgba(0, 90, 140, 0.55);
- box-shadow: 0 0 10px rgba(1, 254, 252, 0.35);
- outline: none;
- }
- &:active {
- transform: translateY(1px);
- }
- }
- /* 查看图片弹窗 */
- .pic-view-mask {
- position: fixed;
- inset: 0;
- z-index: 2200;
- background: rgba(0, 8, 20, 0.55);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .pic-view-panel {
- width: min(960px, calc(100vw - 48px));
- height: min(640px, calc(100vh - 64px));
- box-sizing: border-box;
- background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
- border: 1px solid rgba(0, 210, 255, 0.85);
- box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35);
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .pic-view-header {
- flex-shrink: 0;
- height: 42px;
- padding: 0 14px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- border-bottom: 1px solid rgba(1, 254, 252, 0.25);
- background: linear-gradient(to right, #134c77, transparent);
- }
- .pic-view-title {
- color: #fff;
- font-size: 15px;
- letter-spacing: 1px;
- }
- .pic-view-body {
- flex: 1;
- min-height: 0;
- padding: 12px;
- box-sizing: border-box;
- overflow: auto;
- }
- .pic-view-empty {
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- color: rgba(200, 220, 235, 0.65);
- font-size: 14px;
- }
- .pic-view-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px;
- }
- .pic-card {
- min-width: 0;
- border: 1px solid rgba(0, 183, 255, 0.4);
- background: rgba(4, 20, 40, 0.65);
- overflow: hidden;
- }
- .pic-card-images {
- display: flex;
- gap: 4px;
- height: 120px;
- background: #000;
- }
- .pic-thumb {
- position: relative;
- flex: 1;
- min-width: 0;
- height: 100%;
- cursor: zoom-in;
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- pointer-events: none;
- }
- &:hover {
- outline: 1px solid rgba(1, 254, 252, 0.7);
- }
- }
- .pic-thumb-empty {
- display: flex;
- align-items: center;
- justify-content: center;
- color: rgba(200, 220, 235, 0.45);
- font-size: 12px;
- cursor: default;
- &:hover {
- outline: none;
- }
- }
- .pic-thumb-tag {
- position: absolute;
- left: 4px;
- top: 4px;
- padding: 0 6px;
- height: 18px;
- line-height: 18px;
- font-size: 11px;
- color: #fff;
- background: rgba(8, 40, 70, 0.75);
- border: 1px solid rgba(1, 254, 252, 0.45);
- }
- .pic-card-meta {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 6px 8px 8px;
- }
- .pic-card-name,
- .pic-card-time {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .pic-card-name {
- color: #e8f4ff;
- font-size: 13px;
- }
- .pic-card-time {
- color: rgba(180, 210, 230, 0.75);
- font-size: 12px;
- }
- .pic-view-footer {
- flex-shrink: 0;
- display: flex;
- justify-content: flex-end;
- padding: 8px 14px 12px;
- }
- /* 查看图片放大预览 */
- .pic-preview-mask {
- position: fixed;
- inset: 0;
- z-index: 2300;
- background: rgba(0, 8, 20, 0.72);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .pic-preview-panel {
- width: min(1100px, calc(100vw - 48px));
- height: min(780px, calc(100vh - 48px));
- box-sizing: border-box;
- background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
- border: 1px solid rgba(0, 210, 255, 0.85);
- box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35);
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .pic-preview-header {
- flex-shrink: 0;
- height: 42px;
- padding: 0 14px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- border-bottom: 1px solid rgba(1, 254, 252, 0.25);
- background: linear-gradient(to right, #134c77, transparent);
- }
- .pic-preview-title {
- color: #fff;
- font-size: 15px;
- letter-spacing: 1px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- padding-right: 12px;
- }
- .pic-preview-body {
- flex: 1;
- min-height: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 12px;
- box-sizing: border-box;
- background: rgba(0, 0, 0, 0.45);
- }
- .pic-preview-img {
- max-width: 100%;
- max-height: 100%;
- object-fit: contain;
- display: block;
- }
- /* 一键抓拍结果弹窗 */
- .snapshot-view-mask {
- position: fixed;
- inset: 0;
- z-index: 2200;
- background: rgba(0, 8, 20, 0.55);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .snapshot-view-panel {
- width: min(920px, calc(100vw - 48px));
- max-height: min(720px, calc(100vh - 64px));
- box-sizing: border-box;
- background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
- border: 1px solid rgba(0, 210, 255, 0.85);
- box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35);
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .snapshot-view-header {
- flex-shrink: 0;
- height: 42px;
- padding: 0 14px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- border-bottom: 1px solid rgba(1, 254, 252, 0.25);
- background: linear-gradient(to right, #134c77, transparent);
- }
- .snapshot-view-title {
- color: #fff;
- font-size: 15px;
- letter-spacing: 1px;
- }
- .snapshot-view-body {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 14px;
- box-sizing: border-box;
- }
- .snapshot-images {
- flex: 1;
- min-height: 280px;
- display: flex;
- gap: 10px;
- }
- .snapshot-thumb {
- position: relative;
- flex: 1;
- min-width: 0;
- height: 100%;
- border: 1px solid rgba(0, 183, 255, 0.45);
- background: #000;
- cursor: zoom-in;
- overflow: hidden;
- img {
- width: 100%;
- height: 100%;
- object-fit: contain;
- display: block;
- pointer-events: none;
- }
- &:hover {
- outline: 1px solid rgba(1, 254, 252, 0.7);
- }
- }
- .snapshot-thumb-empty {
- display: flex;
- align-items: center;
- justify-content: center;
- color: rgba(200, 220, 235, 0.55);
- font-size: 14px;
- cursor: default;
- &:hover {
- outline: none;
- }
- }
- .snapshot-meta {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 8px 10px;
- background: rgba(2, 12, 24, 0.75);
- border: 1px solid rgba(0, 183, 255, 0.25);
- }
- .snapshot-meta-name,
- .snapshot-meta-time {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .snapshot-meta-name {
- color: #e8f4ff;
- font-size: 14px;
- }
- .snapshot-meta-time {
- color: rgba(180, 210, 230, 0.75);
- font-size: 13px;
- flex-shrink: 0;
- }
- /* 停止录像结果弹窗 */
- .stop-record-view-mask {
- position: fixed;
- inset: 0;
- z-index: 2200;
- background: rgba(0, 8, 20, 0.55);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .stop-record-view-panel {
- width: min(980px, calc(100vw - 48px));
- max-height: min(760px, calc(100vh - 64px));
- box-sizing: border-box;
- background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
- border: 1px solid rgba(0, 210, 255, 0.85);
- box-shadow: 0 0 0 2px rgba(8, 40, 70, 0.95), 0 0 22px rgba(0, 200, 255, 0.35);
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .stop-record-view-header {
- flex-shrink: 0;
- height: 42px;
- padding: 0 14px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- border-bottom: 1px solid rgba(1, 254, 252, 0.25);
- background: linear-gradient(to right, #134c77, transparent);
- }
- .stop-record-view-title {
- color: #fff;
- font-size: 15px;
- letter-spacing: 1px;
- }
- .stop-record-view-body {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 14px;
- box-sizing: border-box;
- }
- .stop-record-videos {
- flex: 1;
- min-height: 320px;
- display: flex;
- gap: 10px;
- }
- .stop-record-video-item {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- border: 1px solid rgba(0, 183, 255, 0.45);
- background: #000;
- overflow: hidden;
- }
- .stop-record-video-label {
- flex-shrink: 0;
- height: 28px;
- line-height: 28px;
- padding: 0 10px;
- color: #e8f4ff;
- font-size: 12px;
- background: rgba(8, 40, 70, 0.85);
- border-bottom: 1px solid rgba(1, 254, 252, 0.25);
- }
- .stop-record-video {
- flex: 1;
- min-height: 0;
- width: 100%;
- object-fit: contain;
- background: #000;
- }
- .stop-record-video-empty {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- color: rgba(200, 220, 235, 0.55);
- font-size: 14px;
- border: 1px solid rgba(0, 183, 255, 0.35);
- background: rgba(0, 8, 20, 0.65);
- }
- .stop-record-meta {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 8px 10px;
- background: rgba(2, 12, 24, 0.75);
- border: 1px solid rgba(0, 183, 255, 0.25);
- }
- .stop-record-meta-name,
- .stop-record-meta-time {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .stop-record-meta-name {
- color: #e8f4ff;
- font-size: 14px;
- }
- .stop-record-meta-time {
- color: rgba(180, 210, 230, 0.75);
- font-size: 13px;
- flex-shrink: 0;
- }
- /* 查看录像弹窗 */
- .record-view-mask {
- position: fixed;
- inset: 0;
- z-index: 1200;
- background: rgba(0, 10, 24, 0.55);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .record-view-panel {
- width: min(920px, calc(100vw - 48px));
- height: min(560px, calc(100vh - 64px));
- box-sizing: border-box;
- background: linear-gradient(180deg, rgba(10, 36, 62, 0.98) 0%, rgba(4, 18, 36, 0.98) 100%);
- border: 1px solid rgba(1, 254, 252, 0.65);
- border-radius: 4px;
- box-shadow: 0 0 18px rgba(1, 254, 252, 0.28), inset 0 0 20px rgba(0, 120, 180, 0.12);
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .record-view-header {
- flex-shrink: 0;
- height: 42px;
- padding: 0 14px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- border-bottom: 1px solid rgba(1, 254, 252, 0.25);
- background: rgba(12, 48, 80, 0.65);
- }
- .record-view-title {
- color: #fff;
- font-size: 15px;
- letter-spacing: 1px;
- }
- .record-view-body {
- flex: 1;
- min-height: 0;
- display: flex;
- gap: 12px;
- padding: 12px;
- box-sizing: border-box;
- }
- .record-list {
- width: 280px;
- flex-shrink: 0;
- overflow: auto;
- border: 1px solid rgba(54, 199, 255, 0.35);
- border-radius: 2px;
- background: rgba(4, 20, 40, 0.55);
- }
- .record-empty {
- padding: 24px 12px;
- color: rgba(200, 220, 235, 0.65);
- font-size: 13px;
- text-align: center;
- }
- .record-item {
- padding: 10px 12px;
- border-bottom: 1px solid rgba(54, 199, 255, 0.15);
- cursor: pointer;
- transition: background 0.2s;
- &:hover,
- &.active {
- background: rgba(0, 140, 200, 0.28);
- }
- }
- .record-item-name {
- color: #e8f4ff;
- font-size: 13px;
- margin-bottom: 4px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .record-item-path {
- color: rgba(180, 210, 230, 0.75);
- font-size: 11px;
- margin-bottom: 2px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .record-item-time {
- color: rgba(150, 180, 200, 0.65);
- font-size: 11px;
- }
- .record-player {
- flex: 1;
- min-width: 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- border: 1px solid rgba(54, 199, 255, 0.35);
- border-radius: 2px;
- background: rgba(0, 8, 20, 0.85);
- overflow: hidden;
- }
- .record-channel {
- flex-shrink: 0;
- display: flex;
- gap: 8px;
- width: 100%;
- padding: 8px 10px;
- box-sizing: border-box;
- button {
- height: 24px;
- padding: 0 12px;
- color: rgba(200, 230, 245, 0.85);
- font-size: 12px;
- background: transparent;
- border: 1px solid rgba(54, 199, 255, 0.45);
- cursor: pointer;
- &.active {
- color: #fff;
- background: rgba(0, 140, 200, 0.45);
- border-color: rgba(1, 254, 252, 0.8);
- }
- }
- }
- .record-video {
- flex: 1;
- min-height: 0;
- width: 100%;
- height: 100%;
- object-fit: contain;
- background: #000;
- }
- .record-player-empty {
- color: rgba(200, 220, 235, 0.55);
- font-size: 13px;
- }
- /* 报警自动弹窗:热源最新告警图 */
- .auto-alarm-mask {
- position: fixed;
- inset: 0;
- z-index: 2200;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0, 8, 20, 0.55);
- }
- .auto-alarm-panel {
- width: min(720px, 92vw);
- max-height: 90vh;
- &.dual {
- width: min(980px, 96vw);
- }
- &.expanded {
- width: min(1080px, 96vw);
- }
- &.expanded.dual {
- width: min(1280px, 98vw);
- }
- }
- .auto-alarm-frame {
- position: relative;
- padding: 18px 12px 0;
- background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
- border: 1px solid rgba(0, 210, 255, 0.85);
- 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);
- 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);
- }
- .auto-alarm-title-tab {
- position: absolute;
- top: -1px;
- left: 50%;
- transform: translateX(-50%);
- min-width: 168px;
- height: 28px;
- padding: 0 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #fff;
- font-size: 14px;
- font-weight: 600;
- letter-spacing: 1px;
- background: linear-gradient(180deg, #2ad4e8 0%, #1498b8 55%, #0c6f8a 100%);
- clip-path: polygon(12px 0, calc(100% - 12px) 0, 100% 100%, 0 100%);
- box-shadow: 0 0 10px rgba(0, 200, 255, 0.45);
- z-index: 2;
- }
- .auto-alarm-toolbar {
- position: absolute;
- top: 8px;
- right: 12px;
- display: flex;
- gap: 8px;
- z-index: 3;
- }
- .auto-alarm-icon-btn {
- width: 22px;
- height: 22px;
- padding: 0;
- border: none;
- background: transparent;
- color: #e8f4ff;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- &:hover {
- color: #01fefc;
- }
- }
- .auto-alarm-images {
- display: flex;
- gap: 10px;
- margin-top: 14px;
- &.single .auto-alarm-image-wrap {
- flex: 1;
- }
- &.dual .auto-alarm-image-wrap {
- flex: 1;
- min-width: 0;
- }
- }
- .auto-alarm-image-wrap {
- position: relative;
- height: 380px;
- border: 1px solid rgba(0, 183, 255, 0.45);
- background: #000;
- overflow: hidden;
- cursor: zoom-in;
- .auto-alarm-panel.expanded & {
- height: 560px;
- }
- }
- .auto-alarm-image {
- width: 100%;
- height: 100%;
- object-fit: contain;
- display: block;
- }
- .auto-alarm-image-empty {
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- color: rgba(200, 220, 235, 0.55);
- font-size: 13px;
- cursor: default;
- }
- .auto-alarm-overlay {
- position: absolute;
- color: #ff2a2a;
- font-size: 14px;
- font-weight: 600;
- text-shadow: 0 0 4px rgba(0, 0, 0, 0.85);
- pointer-events: none;
- &.top-left {
- top: 10px;
- left: 12px;
- }
- &.top-right {
- top: 10px;
- right: 12px;
- }
- &.bottom-right {
- right: 12px;
- bottom: 10px;
- font-size: 13px;
- }
- }
- .auto-alarm-hotspot {
- position: absolute;
- left: 58%;
- top: 42%;
- display: flex;
- align-items: center;
- gap: 6px;
- pointer-events: none;
- .hotspot-box {
- width: 18px;
- height: 18px;
- border: 2px solid #ff2a2a;
- box-shadow: 0 0 6px rgba(255, 40, 40, 0.7);
- }
- .hotspot-value {
- color: #ffe14a;
- font-size: 14px;
- font-weight: 700;
- text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
- }
- }
- .auto-alarm-footer {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px 8px;
- min-height: 36px;
- padding: 8px 12px 10px;
- color: #e8f4ff;
- font-size: 13px;
- background: rgba(2, 12, 24, 0.95);
- border-top: 1px solid rgba(0, 183, 255, 0.25);
- }
- .auto-alarm-sep {
- color: rgba(180, 210, 230, 0.55);
- }
- /* 报警图片放大预览 */
- .auto-alarm-preview-mask {
- position: fixed;
- inset: 0;
- z-index: 2300;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(9, 23, 44, 0.82);
- backdrop-filter: blur(4px);
- }
- .auto-alarm-preview-panel {
- width: min(86vw, 1100px);
- height: min(82vh, 760px);
- display: flex;
- flex-direction: column;
- border: 1px solid rgba(11, 37, 66);
- box-shadow: 0 0 20px 5px rgba(32, 131, 189, 0.7) inset, 0 0 24px rgba(1, 254, 252, 0.2);
- background: rgba(9, 35, 58, 0.95);
- }
- .auto-alarm-preview-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: 36px;
- padding: 0 14px;
- color: #fff;
- background: linear-gradient(to right, #134c77, transparent);
- border-bottom: 1px solid rgba(41, 171, 241, 0.45);
- }
- .auto-alarm-preview-close {
- width: 28px;
- height: 24px;
- line-height: 20px;
- text-align: center;
- cursor: pointer;
- color: #01fefc;
- border: 1px solid #01fefc;
- border-radius: 2px;
- transition: background-color 0.2s, box-shadow 0.2s;
- &:hover {
- background-color: rgba(36, 132, 188, 0.55);
- box-shadow: 0 0 6px rgba(1, 254, 252, 0.45);
- }
- }
- .auto-alarm-preview-body {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 16px;
- box-sizing: border-box;
- background-color: #114268;
- overflow: hidden;
- img {
- max-width: 100%;
- max-height: 100%;
- object-fit: contain;
- }
- }
- </style>
|