| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088 |
- <template>
- <div ref="pageRootRef" :class="prefixCls" class="behavior-spacetime-page">
- <div class="target-strip-panel">
- <div class="target-strip">
- <div class="target-strip__label">已选对象:</div>
- <div class="selected-targets">
- <template v-if="displayTargets.length">
- <div v-for="target in displayTargets" :key="target" class="selected-target">
- <Checkbox
- class="selected-target__checkbox"
- :checked="checkedTargetSet.has(target)"
- :disabled="queryLoading"
- @change="handleToggleTargetChecked(target, $event.target.checked)"
- />
- <span class="selected-target__label">{{ target }}</span>
- <button type="button" class="selected-target__remove" @click.stop="removeTarget(target)">
- <CloseCircleFilled />
- </button>
- </div>
- </template>
- <div v-else class="selected-targets__empty">未选择对象</div>
- </div>
- <Button type="primary" class="add-target-btn" @click="openTargetPicker">添加对象</Button>
- </div>
- </div>
- <div class="filter-bar">
- <div class="filter-item">
- <span class="filter-label">行为分类:</span>
- <Select
- v-model:value="filters.behaviorTypes"
- mode="multiple"
- class="filter-input filter-type"
- :options="behaviorOptions"
- :max-tag-count="2"
- placeholder="请选择分类,不选择则默认选全部分类"
- allow-clear
- />
- </div>
- <div class="filter-item">
- <span class="filter-label">时间:</span>
- <RangePicker
- v-model:value="filters.dateRange"
- class="filter-date filter-date--compact"
- :format="DATE_FILTER_FORMAT"
- :value-format="DATE_FILTER_FORMAT"
- :placeholder="['开始日期', '结束日期']"
- />
- </div>
- <Checkbox
- class="filter-located-check"
- :checked="filters.locatedOnly === 'located'"
- @change="filters.locatedOnly = $event.target.checked ? 'located' : 'all'"
- >
- 仅位置
- </Checkbox>
- <div class="filter-actions">
- <Button type="primary" :loading="queryLoading" :disabled="!canSearch" @click="handleSearch">查询</Button>
- <Button @click="handleReset">重置</Button>
- </div>
- </div>
- <div ref="pageContentRef" class="page-content">
- <div class="content-panel">
- <div class="panel-left">
- <div class="panel-header">
- <div>
- <div class="panel-title">行为时间轴</div>
- </div>
- <div class="panel-extra">{{ timelineCountText }}</div>
- </div>
- <div ref="timelineListRef" class="timeline-list" @scroll.passive="handleTimelineScroll">
- <div
- v-for="entry in timelineEntries"
- :key="`${entry.item.id}-${entry.index}`"
- :data-timeline-id="entry.item.id"
- class="timeline-item"
- :class="{
- 'is-active': entry.item.id === activeEvent?.id,
- 'is-first': entry.index === 0,
- 'is-last': entry.index === timelineEntries.length - 1,
- }"
- @click="handleSelect(entry.item)"
- >
- <div class="timeline-item__time-pane">
- <span class="timeline-item__time">{{ entry.item.occurredAt }}</span>
- </div>
- <div class="timeline-item__axis">
- <span class="timeline-item__dot"></span>
- </div>
- <div class="timeline-item__body">
- <div class="timeline-item__top">
- <div class="timeline-item__heading">
- <div class="timeline-item__title">{{ entry.item.personName }}</div>
- <div class="timeline-item__person">{{ entry.item.title }}</div>
- </div>
- <div class="timeline-item__badges">
- <span v-if="entry.item.hasCoordinate" class="timeline-item__marker" title="地址标记">
- <EnvironmentFilled />
- </span>
- <span class="timeline-item__tag" :style="tagStyle(entry.item.behaviorType)">{{
- getBehaviorLabel(entry.item.behaviorType)
- }}</span>
- </div>
- </div>
- <div class="timeline-item__fields">
- <div
- v-for="field in getTimelineFields(entry.item)"
- :key="`${entry.item.id}-${entry.index}-${field.label}`"
- class="timeline-item__field"
- >
- <span class="timeline-item__field-label">{{ field.label }}:</span>
- <strong class="timeline-item__field-value">{{ field.value }}</strong>
- </div>
- </div>
- </div>
- </div>
- <div v-if="!filteredEvents.length" class="timeline-empty">
- <Empty :description="timelineEmptyText" />
- </div>
- <div v-if="showTimelineLoadState" class="timeline-list__footer">
- {{ timelineLoadStateText }}
- </div>
- </div>
- </div>
- <div class="panel-right">
- <div class="panel-header">
- <div>
- <div class="panel-title">离线地图</div>
- </div>
- <div class="map-actions">
- <Button size="small" :loading="mapReloading" @click="handleReloadMap">
- <template #icon><ReloadOutlined /></template>重新加载地图
- </Button>
- <Button size="small" @click="handleFit"
- ><template #icon><ReloadOutlined /></template>回到全景</Button
- >
- <Button size="small" type="primary" @click="handleFocus"
- ><template #icon><AimOutlined /></template>定位当前</Button
- >
- </div>
- </div>
- <div class="map-box">
- <div ref="mapRef" class="map-box__canvas"></div>
- <div
- v-if="hoverCard.visible"
- class="map-hover-card"
- :style="{
- left: `${hoverCard.x}px`,
- top: `${hoverCard.y}px`,
- }"
- @mouseenter="lockMapHoverCard"
- @mouseleave="unlockMapHoverCard"
- >
- <div class="map-hover-card__title">位置信息</div>
- <div class="map-hover-card__row">
- <span class="map-hover-card__label">位置</span>
- <span class="map-hover-card__value">{{ hoverCard.locationName || hoverCard.address || '-' }}</span>
- </div>
- <div class="map-hover-card__row">
- <span class="map-hover-card__label">大小区号</span>
- <span class="map-hover-card__value">{{ hoverCard.stationCode || '-' }}</span>
- </div>
- <div class="map-hover-card__row">
- <span class="map-hover-card__label">经纬度</span>
- <span class="map-hover-card__value">{{ hoverCard.coordinateText || '-' }}</span>
- </div>
- <div class="map-hover-card__actions">
- <button type="button" class="map-hover-card__action" @click.stop="handleCopyMapHoverText('address')">
- 复制位置
- </button>
- <button type="button" class="map-hover-card__action" @click.stop="handleCopyMapHoverText('coordinate')">
- 复制经纬度
- </button>
- </div>
- </div>
- <div class="map-box__toolbar">
- <span class="toolbar-pill is-pressure">{{ pressureStatusText }}</span>
- <span class="toolbar-pill">定位节点 {{ locatedEvents.length }}</span>
- <span class="toolbar-pill">地址标签 {{ addressLabelCount }}</span>
- <span class="toolbar-pill" :class="{ 'is-fallback': mapMode !== 'template' }">{{ mapStatusText }}</span>
- </div>
- <div class="map-box__legend">
- <span v-for="item in legendItems" :key="item.label" class="legend-item"
- ><i :style="{ background: item.color }"></i>{{ item.label }}</span
- >
- </div>
- <div v-if="!locatedEvents.length" class="map-box__empty">{{ mapEmptyText }}</div>
- </div>
- </div>
- </div>
- </div>
- <Modal
- v-model:open="targetPickerOpen"
- :width="540"
- :centered="true"
- ok-text="分析"
- cancel-text="取消"
- wrapClassName="behavior-target-picker-modal"
- @ok="confirmTargetPicker"
- @cancel="closeTargetPicker"
- >
- <template #title>
- <div class="target-picker-modal__title">
- <span>设置分析条件</span>
- </div>
- </template>
- <div class="target-picker target-picker--modal">
- <div class="target-picker__panel-head">
- <div class="target-picker__panel-title">选择被调查对象</div>
- <div class="target-picker__panel-count">已选对象: {{ targetDraft.length }}</div>
- </div>
- <div class="target-picker__toolbar">
- <div class="target-picker__list-head">
- <Checkbox
- :checked="allDraftTargetsChecked"
- :indeterminate="draftTargetsIndeterminate"
- @change="toggleDraftAll"
- >
- 被调查对象列表 ({{ availableTargetOptions.length }})
- </Checkbox>
- </div>
- <Input
- v-if="availableTargetOptions.length > 6"
- v-model:value="targetKeyword"
- class="target-picker__search-input"
- placeholder="请输入姓名或证件号码筛选对象"
- allow-clear
- />
- </div>
- <div class="target-picker__table">
- <div class="target-picker__list">
- <div
- v-for="item in pickerTargetOptions"
- :key="item.value"
- class="target-picker__item"
- :class="{ 'is-active': targetDraft.includes(item.value) }"
- @click="toggleDraftTarget(item.value, !targetDraft.includes(item.value))"
- >
- <span class="target-picker__checkbox" @click.stop>
- <Checkbox
- :checked="targetDraft.includes(item.value)"
- @change="toggleDraftTarget(item.value, $event.target.checked)"
- />
- </span>
- <PersonAvatar class="target-picker__avatar" :src="item.photograph" :alt="item.label" :size="32" />
- <span class="target-picker__item-body">
- <span class="target-picker__name-wrap">
- <span class="target-picker__name">{{ item.label }}</span>
- <span v-if="item.identityCode && item.identityCode !== item.label" class="target-picker__inline-code">
- {{ maskCertNo(item.identityCode) }}
- </span>
- </span>
- </span>
- </div>
- <div v-if="!pickerTargetOptions.length" class="target-picker__empty">未找到匹配对象</div>
- </div>
- </div>
- </div>
- </Modal>
- <BasicDrawer
- v-model:open="detailDrawerOpen"
- wrapClassName="behavior-detail-drawer"
- placement="right"
- :width="540"
- :maskClosable="true"
- :destroyOnClose="false"
- :showFooter="false"
- >
- <template #title>
- <div class="detail-drawer__title-wrap">
- <div class="detail-drawer__title-block">
- <div class="detail-drawer__title">{{ detailDrawerTitle }}</div>
- <div class="detail-drawer__sub">{{ detailDrawerSubTitle }}</div>
- </div>
- </div>
- </template>
- <div class="detail-drawer__body">
- <div v-if="detailLoading" class="detail-drawer__loading">详情加载中...</div>
- <div v-else-if="detailError" class="detail-drawer__empty">{{ detailError }}</div>
- <Empty v-else-if="!detailFlatFields.length" :description="detailDrawerEmptyText" />
- <div v-else class="detail-drawer__flat">
- <div v-for="field in detailFlatFields" :key="field.key" class="detail-drawer__flat-item">
- <span class="detail-drawer__flat-label">{{ field.label }}:</span>
- <span class="detail-drawer__flat-value">{{ field.value }}</span>
- </div>
- </div>
- </div>
- </BasicDrawer>
- </div>
- </template>
- <script lang="ts" setup name="ViewsBehaviorSpacetimeIndex">
- import { AimOutlined, CloseCircleFilled, EnvironmentFilled, ReloadOutlined } from '@ant-design/icons-vue';
- import { PersonAvatar } from '@jeesite/core/components/PersonAvatar';
- import { useDesign } from '@jeesite/core/hooks/web/useDesign';
- import { BasicDrawer } from '@jeesite/core/components/Drawer';
- import { useMessage } from '@jeesite/core/hooks/web/useMessage';
- import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
- import { useMultipleTabStore } from '@jeesite/core/store/modules/multipleTab';
- import { timeSeriesDetail, timeSeriesList } from '@jeesite/otg/api/otgApi';
- import type { TimeSeries, TimeSeriesDetailDTO } from '@jeesite/otg/api/types/dto';
- import type { TimeSeriesDetailQuery, TimeSeriesQuery } from '@jeesite/otg/api/types/query';
- import {
- BEHAVIOR_SPACETIME_JUMP_QUERY_KEY,
- buildTimeSeriesJumpQuery,
- consumeBehaviorSpacetimeJumpResult,
- hasMeaningfulJumpPayload,
- parseBehaviorSpacetimeJumpPayload,
- type BehaviorSpacetimeJumpPayload,
- } from '@jeesite/otg/utils/timeSeriesJump';
- import { getPersons } from '@jeesite/person/api/personApi';
- import {
- ElectronPmtilesSource,
- getOfflineMapDir,
- getOfflineMapFilePath,
- getOfflineMapPmtilesUrl,
- OFFLINE_MAP_DIR_CHANGED_EVENT,
- toLocalMapUrl,
- } from '@jeesite/core/utils/map/offlineMap';
- import { isCertFieldKey, maskCertNo } from '@jeesite/core/utils/sensitive';
- import { Button, Checkbox, DatePicker, Empty, Input, Modal, Select } from 'ant-design-vue';
- import type { CheckboxChangeEvent } from 'ant-design-vue/lib/checkbox/interface';
- import dayjs from 'dayjs';
- import maplibregl from 'maplibre-gl';
- import type { Map as MapLibreMap } from 'maplibre-gl';
- import 'maplibre-gl/dist/maplibre-gl.css';
- import { PMTiles, Protocol } from 'pmtiles';
- import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
- import type { RouteLocationNormalized } from 'vue-router';
- import { useRoute } from 'vue-router';
- import mapPinSvg from './assets/location_on_baseline.svg?raw';
- import { copyTextToClipboard } from '@jeesite/core/hooks/web/useCopyToClipboard';
- import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
- const { prefixCls } = useDesign('behavior-spacetime');
- const { createMessage } = useMessage();
- const { queryLoading, runWithQueryLoading } = useTableSkill();
- const tabStore = useMultipleTabStore();
- const route = useRoute();
- const RangePicker = DatePicker.RangePicker;
- const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
- const DATE_FILTER_FORMAT = 'YYYY-MM-DD';
- const BEHAVIOR_SPACETIME_ROUTE_PATH = '/behaviorSpacetime/index';
- const REMOTE_QUERY_LIMIT = 20;
- const TIMELINE_LOAD_MORE_THRESHOLD = 96;
- const STYLE_PATH = `${import.meta.env.BASE_URL}resource/map/behavior-spacetime-style.json`;
- const CENTER: [number, number] = [104.0668, 30.5728];
- const SOURCE_ID = 'behavior-events';
- const LABEL_ID = 'behavior-labels';
- const CLUSTER_LAYER = 'behavior-point-clusters';
- const CLUSTER_COUNT_LAYER = 'behavior-point-cluster-count';
- const POINT_LAYER = 'behavior-points';
- const POINT_LABEL_LAYER = 'behavior-point-labels';
- const PMTILES_PROTOCOL = 'pmtiles://';
- const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
- const POINT_LABEL_BASE_MIN_ZOOM = 8.2;
- const POINT_LABEL_LIGHT_LIMIT = 12;
- const POINT_LABEL_MEDIUM_LIMIT = 40;
- const POINT_LABEL_DENSE_LIMIT = 90;
- const POINT_LABEL_HEAVY_LIMIT = 180;
- const POINT_CLUSTER_RADIUS = 44;
- const POINT_CLUSTER_MAX_ZOOM = 10;
- const MAP_PIN_ICON_TRACK = 'behavior-pin-track';
- const MAP_PIN_ICON_TRANS = 'behavior-pin-trans';
- const MAP_PIN_ICON_CALL = 'behavior-pin-call';
- const MAP_PIN_ICON_ACTIVE = 'behavior-pin-active';
- type RangeValue = [string, string] | undefined;
- type BehaviorType = '轨迹信息' | '交易信息' | '通信信息';
- type BehaviorSpacetimeWindow = Window & {
- __BEHAVIOR_SPACETIME_STYLE__?: string | Record<string, any>;
- __BEHAVIOR_SPACETIME_PAGE_CACHE__?: BehaviorSpacetimePageCache;
- };
- type BehaviorFilters = {
- targets: string[];
- checkedTargets: string[];
- behaviorTypes: BehaviorType[];
- dateRange: RangeValue;
- keyword: string;
- locatedOnly: 'all' | 'located';
- };
- interface BehaviorEvent {
- id: string;
- rowId?: string;
- recordId?: string;
- rawTableNameEn?: string;
- tableNameEn?: string;
- tableNameCn?: string;
- otherName?: string;
- ext1?: string;
- ext2?: string;
- ext3?: string;
- ext4?: string;
- ext5?: string;
- ext6?: string;
- ext7?: string;
- ext8?: string;
- ext9?: string;
- personName: string;
- behaviorType: BehaviorType;
- title: string;
- locationName: string;
- address: string;
- occurredAt: string;
- durationMinutes: number;
- accuracyMeters: number;
- relatedCount: number;
- sourceLabel: string;
- hasCoordinate: boolean;
- lng?: number;
- lat?: number;
- summary: string;
- metaPrimaryLabel: string;
- metaPrimaryValue: string;
- metaSecondaryLabel: string;
- metaSecondaryValue: string;
- }
- interface TimelineField {
- label: string;
- value: string;
- }
- interface DetailField {
- key: string;
- label: string;
- value: string;
- span?: number;
- }
- interface DetailHeadColumn {
- title?: string | null;
- dataIndex?: string | number | Array<string | number> | null;
- key?: string | number | null;
- width?: string | number | null;
- sorter?: boolean;
- slot?: string | null;
- }
- interface DetailSection {
- title: string;
- fields: DetailField[];
- }
- interface DetailFieldConfig {
- key: string;
- label: string;
- }
- interface DetailSectionConfig {
- title: string;
- fields: DetailFieldConfig[];
- }
- interface DetailOverviewCard {
- label: string;
- value: string;
- }
- interface TargetOption {
- label: string;
- value: string;
- identityCode?: string;
- photograph?: string;
- }
- interface BehaviorSpacetimePageCache {
- filters: BehaviorFilters;
- applied: BehaviorFilters;
- activeJumpPayload: BehaviorSpacetimeJumpPayload | null;
- lastRouteJumpSignature: string;
- appliedJumpRequestSignature: string;
- remoteEvents: BehaviorEvent[];
- remoteTotal: number;
- selectedId: string;
- timelinePage: number;
- timelineHasMore: boolean;
- timelineCanLoadForward: boolean;
- timelineCanLoadBackward: boolean;
- timelineListInitialized: boolean;
- detailRecords: Record<string, TimeSeriesDetailDTO>;
- detailHeads: Record<string, DetailHeadColumn[]>;
- detailEvents: BehaviorEvent[];
- }
- const THEME: Record<BehaviorType, { color: string; soft: string }> = {
- 轨迹信息: { color: '#1677ff', soft: '#e6f4ff' },
- 交易信息: { color: '#d97706', soft: '#fef3c7' },
- 通信信息: { color: '#475569', soft: '#e2e8f0' },
- };
- const TABLE_CN_TO_EN_MAP: Record<string, string> = {
- 通信记录: 'call_record',
- 交易记录: 'trans_record',
- 住宿信息: 'hotel_stay_info',
- 铁路行程: 'train_ticket_info',
- 航班行程: 'flight_ticket_info',
- 汽车购票行程: 'track_car_ticket_info',
- 快递收发: 'express_info',
- };
- const TABLE_EN_ALIAS_MAP: Record<string, string> = {
- call_record_all: 'call_record',
- trans_record_all: 'trans_record',
- };
- const KNOWN_TIME_SERIES_TABLE_KEYS = new Set([
- 'call_record',
- 'trans_record',
- 'hotel_stay_info',
- 'train_ticket_info',
- 'together_train_info',
- 'flight_ticket_info',
- 'flight_departure_info',
- 'together_flight_info',
- 'track_car_ticket_info',
- 'express_info',
- ]);
- const TABLE_SOURCE_LABEL_MAP: Record<string, string> = {
- call_record: '通信记录',
- trans_record: '交易记录',
- hotel_stay_info: '住宿信息',
- train_ticket_info: '铁路行程',
- together_train_info: '铁路行程',
- flight_ticket_info: '航班行程',
- flight_departure_info: '航班行程',
- together_flight_info: '航班行程',
- track_car_ticket_info: '汽车购票行程',
- express_info: '快递收发',
- };
- const DETAIL_BASE_FIELD_ORDER = ['tableNameCn', 'personName', 'seriesDateTime'];
- const DETAIL_BASE_FIELD_LABEL_MAP: Record<string, string> = {
- tableNameCn: '表中文名',
- personName: '对象姓名',
- seriesDateTime: '发生时间',
- };
- const DETAIL_TABLE_FIELD_LABEL_MAP: Record<string, Partial<Record<string, string>>> = {
- call_record: {
- personName: '本方姓名',
- otherName: '对方户名',
- seriesDateTime: '通话日期时间',
- },
- trans_record: {
- personName: '客户名称',
- otherName: '交易对方名称',
- seriesDateTime: '交易日期时间',
- },
- hotel_stay_info: {
- personName: '旅客姓名',
- seriesDateTime: '入住时间',
- },
- train_ticket_info: {
- personName: '旅客姓名',
- seriesDateTime: '发车日期',
- },
- together_train_info: {
- personName: '姓名',
- seriesDateTime: '乘车时间',
- },
- flight_ticket_info: {
- personName: '旅客中文名',
- seriesDateTime: '当地出发时间',
- },
- flight_departure_info: {
- personName: '旅客中文姓名',
- seriesDateTime: '离港日期',
- },
- together_flight_info: {
- personName: '同行人姓名',
- seriesDateTime: '航班日期',
- },
- track_car_ticket_info: {
- personName: '乘车人姓名',
- seriesDateTime: '发车时间',
- },
- express_info: {
- personName: '收件人姓名',
- otherName: '寄件人姓名',
- seriesDateTime: '寄件日期',
- },
- };
- const DETAIL_EXT_KEYS = ['ext1', 'ext2', 'ext3', 'ext4', 'ext5', 'ext6', 'ext7', 'ext8', 'ext9'] as const;
- const DETAIL_EXT_FIELD_LABEL_MAP: Record<string, Partial<Record<(typeof DETAIL_EXT_KEYS)[number], string>>> = {
- call_record: {
- ext1: '本方号码',
- ext2: '对方号码',
- ext3: '运营商',
- ext4: '主被叫标识',
- ext5: '本方基站大区号',
- ext6: '本方基站小区号',
- ext7: '本方基站名称',
- ext8: '本方基站经度',
- ext9: '本方基站纬度',
- },
- trans_record: {
- ext1: '查询卡号',
- ext2: '交易对方卡号',
- ext3: '银行名称',
- ext4: '借贷标志',
- ext6: '交易金额',
- },
- hotel_stay_info: {
- ext1: '证件号码',
- ext2: '酒店名称',
- ext4: '房间号',
- ext5: '详细地址',
- },
- train_ticket_info: {
- ext1: '证件号码',
- ext2: '车号',
- ext3: '发站',
- ext4: '到站',
- },
- together_train_info: {
- ext1: '身份证号',
- ext2: '车次',
- ext3: '起始站名',
- ext4: '终点站名',
- },
- flight_ticket_info: {
- ext1: '证件号码',
- ext2: '票号',
- ext3: '起飞机场代码',
- ext4: '到达机场代码',
- },
- flight_departure_info: {
- ext1: '证件号码',
- ext2: '航班号',
- ext3: '起飞港三字码',
- ext4: '到达航站三字码',
- },
- together_flight_info: {
- ext1: '证件号码',
- ext2: '航班号',
- ext3: '出发地',
- ext4: '目的地',
- },
- track_car_ticket_info: {
- ext1: '证件号码',
- ext2: '班次号',
- ext3: '始发车站名称',
- ext4: '目的地名称',
- },
- express_info: {
- ext3: '收件人现住址',
- ext4: '寄件人现住址',
- },
- };
- const DETAIL_RECORD_FIELDS_MAP: Record<string, Array<{ key: string; label: string }>> = {
- call_record: [
- { key: 'personPhone', label: '本方号码' },
- { key: 'otherPhone', label: '对方号码' },
- { key: 'callDirection', label: '主被叫标识' },
- { key: 'operatorId', label: '运营商' },
- { key: 'callDateTime', label: '通话时间' },
- { key: 'personRegionId', label: '本方基站大区号' },
- { key: 'personCellId', label: '本方基站小区号' },
- { key: 'personStationName', label: '本方基站名称' },
- { key: 'personStationLongitude', label: '本方基站经度' },
- { key: 'personStationLatitude', label: '本方基站纬度' },
- ],
- trans_record: [
- { key: 'personCardNo', label: '本方卡号' },
- { key: 'otherName', label: '对方姓名' },
- { key: 'otherAccount', label: '对方账号' },
- { key: 'otherCardNo', label: '对方卡号' },
- { key: 'debitCreditFlag', label: '借贷标志' },
- { key: 'transType', label: '交易方向' },
- { key: 'currency', label: '币种' },
- { key: 'transAmount', label: '交易金额' },
- { key: 'transBalance', label: '交易后余额' },
- { key: 'transDateTime', label: '交易时间' },
- { key: 'transSerialNo', label: '交易流水号' },
- { key: 'otherAccountBank', label: '对方开户行' },
- { key: 'branchName', label: '本方网点' },
- { key: 'transSummary', label: '交易摘要' },
- { key: 'remark', label: '备注' },
- ],
- hotel_stay_info: [
- { key: 'personCertNo', label: '证件号码' },
- { key: 'hotelName', label: '酒店名称' },
- { key: 'inDateTime', label: '入住时间' },
- { key: 'roomNo', label: '房间号' },
- { key: 'addressDetail', label: '详细地址' },
- ],
- train_ticket_info: [
- { key: 'personCertNo', label: '证件号码' },
- { key: 'trainNo', label: '车号' },
- { key: 'depDateTime', label: '出发时间' },
- { key: 'origin', label: '发站' },
- { key: 'destination', label: '到站' },
- ],
- together_train_info: [
- { key: 'personCertNo', label: '证件号码' },
- { key: 'trainNo', label: '车次' },
- { key: 'depDateTime', label: '出发时间' },
- { key: 'origin', label: '起始站名' },
- { key: 'destination', label: '终点站名' },
- ],
- flight_ticket_info: [
- { key: 'personCertNo', label: '证件号码' },
- { key: 'ticketNo', label: '票号' },
- { key: 'depDateTime', label: '出发时间' },
- { key: 'depAirport', label: '起飞机场代码' },
- { key: 'arrAirport', label: '到达机场代码' },
- ],
- flight_departure_info: [
- { key: 'personCertNo', label: '证件号码' },
- { key: 'flightNo', label: '航班号' },
- { key: 'depDateTime', label: '离港时间' },
- { key: 'depCode', label: '起飞港三字码' },
- { key: 'arrCode', label: '到达港三字码' },
- ],
- together_flight_info: [
- { key: 'personCertNo', label: '证件号码' },
- { key: 'flightNo', label: '航班号' },
- { key: 'flightDateTime', label: '航班时间' },
- { key: 'origin', label: '出发地' },
- { key: 'destination', label: '目的地' },
- ],
- track_car_ticket_info: [
- { key: 'personCertNo', label: '证件号码' },
- { key: 'bch', label: '班次号' },
- { key: 'depDateTime', label: '发车时间' },
- { key: 'origin', label: '始发车站名称' },
- { key: 'destination', label: '目的地名称' },
- ],
- express_info: [
- { key: 'recipientName', label: '收件人' },
- { key: 'senderName', label: '寄件人' },
- { key: 'sendDate', label: '寄件时间' },
- { key: 'recipientCurrentAddress', label: '收件地址' },
- { key: 'senderCurrentAddress', label: '寄件地址' },
- ],
- };
- const DETAIL_FIELD_LABEL_MAP: Record<string, string> = {
- id: '详情ID',
- rowId: '行ID',
- fileId: '文件ID',
- sheetId: '工作表ID',
- tableName: '表名',
- tableNameCn: '表中文名',
- tableNameEn: '表英文名',
- personName: '对象姓名',
- seriesDateTime: '发生时间',
- bankName: '银行名称',
- personCertNo: '本方证件号',
- personAccount: '本方账号',
- personCardNo: '本方卡号',
- personLibId: '人员库ID',
- personPhone: '本方号码',
- personRegionId: '本方基站大区号',
- personCellId: '本方基站小区号',
- personStationName: '本方基站名称',
- personStationLongitude: '本方基站经度',
- personStationLatitude: '本方基站纬度',
- otherName: '对方姓名',
- otherCertNo: '对方证件号',
- otherAccount: '对方账号',
- otherCardNo: '对方卡号',
- otherPhone: '对方号码',
- otherBalance: '对方余额',
- otherAccountBank: '对方开户行',
- callDirection: '主被叫标识',
- operatorId: '运营商',
- callDateTime: '通话时间',
- transType: '交易方向',
- debitCreditFlag: '借贷标志',
- currency: '币种',
- transAmount: '交易金额',
- transBalance: '交易后余额',
- transDateTime: '交易时间',
- transDate: '交易日期',
- transSerialNo: '交易流水号',
- branchName: '本方网点',
- branchCode: '网点编号',
- cashFlag: '现转标志',
- terminalNo: '终端号',
- transLocation: '交易地点',
- merchantName: '商户名称',
- merchantNo: '商户编号',
- personIpAddress: '本方IP',
- personMacAddress: '本方MAC',
- transClerkNo: '柜员号',
- transChannel: '交易渠道',
- transSummary: '交易摘要',
- remark: '备注',
- festivalName: '节假日标识',
- hotelName: '酒店名称',
- inDateTime: '入住时间',
- roomNo: '房间号',
- addressDetail: '详细地址',
- trainNo: '车次',
- depDateTime: '出发时间',
- origin: '出发地',
- destination: '目的地',
- ticketNo: '票号',
- depAirport: '起飞机场代码',
- arrAirport: '到达机场代码',
- flightNo: '航班号',
- depCode: '起飞港三字码',
- arrCode: '到达港三字码',
- sendDate: '寄递时间',
- bch: '班次号',
- recipientName: '收件人姓名',
- senderName: '寄件人姓名',
- recipientCurrentAddress: '收件人现住址地址名称',
- senderCurrentAddress: '寄件人现住址地址名称',
- personNameCn: '旅客中文姓名',
- companionName: '同行人姓名',
- size: '分页大小',
- total: '总条数',
- pages: '总页数',
- };
- const DETAIL_SECTION_FIELDS_MAP: Record<string, DetailSectionConfig[]> = {
- trans_record: [
- {
- title: '主体账户',
- fields: [
- { key: 'personCardNo', label: '本方卡号' },
- { key: 'personAccount', label: '本方账号' },
- { key: 'personCertNo', label: '本方证件号' },
- { key: 'bankName', label: '银行名称' },
- ],
- },
- {
- title: '交易对手',
- fields: [
- { key: 'otherName', label: '对方姓名' },
- { key: 'otherAccount', label: '对方账号' },
- { key: 'otherCardNo', label: '对方卡号' },
- { key: 'otherCertNo', label: '对方证件号' },
- { key: 'otherAccountBank', label: '对方开户行' },
- { key: 'otherBalance', label: '对方余额' },
- ],
- },
- {
- title: '交易信息',
- fields: [
- { key: 'transType', label: '交易方向' },
- { key: 'debitCreditFlag', label: '借贷标志' },
- { key: 'currency', label: '币种' },
- { key: 'transAmount', label: '交易金额' },
- { key: 'transBalance', label: '交易后余额' },
- { key: 'transDateTime', label: '交易时间' },
- { key: 'transDate', label: '交易日期' },
- { key: 'transSerialNo', label: '交易流水号' },
- { key: 'festivalName', label: '节假日标识' },
- ],
- },
- {
- title: '渠道与网点',
- fields: [
- { key: 'branchName', label: '本方网点' },
- { key: 'branchCode', label: '网点编号' },
- { key: 'cashFlag', label: '现转标志' },
- { key: 'terminalNo', label: '终端号' },
- { key: 'transLocation', label: '交易地点' },
- { key: 'merchantName', label: '商户名称' },
- { key: 'merchantNo', label: '商户编号' },
- { key: 'transChannel', label: '交易渠道' },
- { key: 'transClerkNo', label: '柜员号' },
- ],
- },
- {
- title: '补充说明',
- fields: [
- { key: 'transSummary', label: '交易摘要' },
- { key: 'remark', label: '备注' },
- { key: 'personIpAddress', label: '本方IP' },
- { key: 'personMacAddress', label: '本方MAC' },
- ],
- },
- ],
- call_record: [
- {
- title: '通联信息',
- fields: [
- { key: 'personPhone', label: '本方号码' },
- { key: 'otherPhone', label: '对方号码' },
- { key: 'callDirection', label: '主被叫标识' },
- { key: 'operatorId', label: '运营商' },
- { key: 'callDateTime', label: '通话时间' },
- ],
- },
- {
- title: '基站信息',
- fields: [
- { key: 'personRegionId', label: '本方基站大区号' },
- { key: 'personCellId', label: '本方基站小区号' },
- { key: 'personStationName', label: '本方基站名称' },
- { key: 'personStationLongitude', label: '本方基站经度' },
- { key: 'personStationLatitude', label: '本方基站纬度' },
- ],
- },
- ],
- };
- const DETAIL_LONG_VALUE_KEYS = new Set([
- 'transSummary',
- 'remark',
- 'addressDetail',
- 'address',
- 'summary',
- 'ext1',
- 'ext2',
- 'ext3',
- 'ext4',
- 'ext5',
- 'ext6',
- 'ext7',
- 'ext8',
- 'ext9',
- ]);
- const DETAIL_RAW_SKIP_KEYS = new Set(['records', 'list', 'data', 'detail', 'record', 'result']);
- const DETAIL_WRAPPER_SKIP_KEYS = new Set([
- ...DETAIL_RAW_SKIP_KEYS,
- 'head',
- 'dt',
- 'message',
- 'code',
- 'success',
- 'status',
- ]);
- const DETAIL_ID_SKIP_KEYS = new Set(['id', 'rowId', 'fileId', 'sheetId', 'personLibId', 'size', 'total', 'pages']);
- const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
- const toText = (value?: string | number | null) => {
- if (value === 0) return '0';
- if (value === null || value === undefined) return '';
- return String(value).trim();
- };
- const fallbackText = (value?: string | number | null, fallback = '-') => {
- const text = toText(value);
- return text || fallback;
- };
- const joinText = (parts: Array<string | number | null | undefined>, separator = ' · ', fallback = '-') => {
- const values = parts.map((item) => toText(item)).filter(Boolean);
- return values.length ? values.join(separator) : fallback;
- };
- const parseNumeric = (value?: string | number | null) => {
- const text = toText(value).replace(/,/g, '');
- if (!text) return undefined;
- const numeric = Number(text);
- return Number.isFinite(numeric) ? numeric : undefined;
- };
- const formatAmount = (value?: string | number | null) => {
- const numeric = parseNumeric(value);
- if (numeric === undefined) return fallbackText(value);
- return numeric.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
- };
- const maskValue = (value?: string | number | null, keepStart = 4, keepEnd = 4) => {
- const text = toText(value);
- if (!text) return '-';
- if (text.length <= keepStart + keepEnd) return text;
- return `${text.slice(0, keepStart)}****${text.slice(-keepEnd)}`;
- };
- const formatDateTimeText = (value?: string | null) => {
- const text = toText(value);
- if (!text) return '-';
- const parsed = dayjs(text);
- return parsed.isValid() ? parsed.format(DATE_TIME_FORMAT) : text;
- };
- const getTimeStamp = (value?: string | null) => {
- const parsed = dayjs(toText(value));
- return parsed.isValid() ? parsed.valueOf() : Number.MAX_SAFE_INTEGER;
- };
- const mapCallDirection = (value?: string | null) => {
- const text = toText(value);
- if (text === 'CALL_IN') return '被叫';
- if (text === 'CALL_OUT') return '主叫';
- return text || '通信';
- };
- const mapDebitCreditFlag = (value?: string | null) => {
- const text = toText(value);
- if (!text) return '交易';
- if (/[收入贷]/.test(text)) return '收入';
- if (/[支出借]/.test(text)) return '支出';
- return text;
- };
- const isNightEvent = (value?: string | null) => {
- const parsed = dayjs(toText(value));
- if (!parsed.isValid()) return false;
- const hour = parsed.hour();
- return hour >= 22 || hour < 6;
- };
- const normalizeTimeSeriesTableNameEn = (value?: string | null) => {
- const normalized = TABLE_EN_ALIAS_MAP[toText(value)] || toText(value);
- if (!normalized) return '';
- if (normalized.endsWith('_call_record')) return 'call_record';
- if (normalized.endsWith('_trans_record')) return 'trans_record';
- return normalized;
- };
- // GovernCache.java 里存在 `hotel_stay_info -> trans_record` 这类误标,
- // 同时部分接口会返回 `use_open_info_call_record` 这类真实表名。
- // 这里统一先归一表英文名,再在“未知表名”场景下回退到中文表名,避免前端把通信记录误识别成轨迹。
- const resolveTimeSeriesTableKey = (item: TimeSeries) => {
- const tableNameEn = normalizeTimeSeriesTableNameEn(item.tableNameEn);
- const tableNameCn = toText(item.tableNameCn);
- const fallbackTableKey = TABLE_CN_TO_EN_MAP[tableNameCn] || '';
- if (tableNameEn && KNOWN_TIME_SERIES_TABLE_KEYS.has(tableNameEn)) {
- if (tableNameEn === 'trans_record' && fallbackTableKey === 'hotel_stay_info') {
- return fallbackTableKey;
- }
- return tableNameEn;
- }
- return fallbackTableKey || tableNameEn;
- };
- const getBehaviorTypeFromTimeSeries = (item: TimeSeries): BehaviorType => {
- const tableKey = resolveTimeSeriesTableKey(item);
- if (tableKey === 'call_record') return '通信信息';
- if (tableKey === 'trans_record') return '交易信息';
- if (tableKey === 'hotel_stay_info' && isNightEvent(item.seriesDateTime)) return '轨迹信息';
- return '轨迹信息';
- };
- const normalizeTimeSeriesList = (resp: any): TimeSeries[] => {
- const extractIndexedObjectList = (value: unknown): TimeSeries[] => {
- if (!value || Array.isArray(value) || typeof value !== 'object') return [];
- return Object.entries(value as Record<string, unknown>)
- .filter(([key, item]) => /^\d+$/.test(key) && !!item && typeof item === 'object')
- .sort((left, right) => Number(left[0]) - Number(right[0]))
- .map(([, item]) => item as TimeSeries);
- };
- if (Array.isArray(resp)) return resp;
- if (Array.isArray(resp?.records)) return resp.records;
- if (Array.isArray(resp?.list)) return resp.list;
- if (Array.isArray(resp?.data)) return resp.data;
- if (Array.isArray(resp?.data?.records)) return resp.data.records;
- if (Array.isArray(resp?.data?.list)) return resp.data.list;
- if (Array.isArray(resp?.page?.records)) return resp.page.records;
- if (Array.isArray(resp?.page?.list)) return resp.page.list;
- const indexedDataList = extractIndexedObjectList(resp?.data);
- if (indexedDataList.length) return indexedDataList;
- const indexedRespList = extractIndexedObjectList(resp);
- if (indexedRespList.length) return indexedRespList;
- return [];
- };
- const normalizeTimeSeriesTotal = (resp: any, listLength: number) => {
- const total =
- resp?.total ?? resp?.count ?? resp?.data?.total ?? resp?.data?.count ?? resp?.page?.total ?? resp?.page?.count;
- return Number.isFinite(Number(total)) ? Number(total) : 0;
- };
- const randomUnit = (seed: number) => {
- const raw = Math.sin(seed * 12.9898 + 78.233) * 43758.5453123;
- return raw - Math.floor(raw);
- };
- const makeFilters = (): BehaviorFilters => ({
- targets: [],
- checkedTargets: [],
- behaviorTypes: [],
- dateRange: undefined,
- keyword: '',
- locatedOnly: 'all' as 'all' | 'located',
- });
- const toDateFilterValue = (value?: string) => {
- const text = toText(value);
- if (!text) return undefined;
- return text.slice(0, 10);
- };
- const buildFiltersFromJumpPayload = (payload: BehaviorSpacetimeJumpPayload): BehaviorFilters => {
- const jumpTargets = normalizeTargetList([
- ...(payload.personNames || []),
- ...(payload.personName ? [payload.personName] : []),
- ]);
- return {
- ...makeFilters(),
- targets: jumpTargets,
- checkedTargets: jumpTargets,
- keyword: payload.keyword || '',
- };
- };
- const buildRouteJumpSignature = (payload?: BehaviorSpacetimeJumpPayload | null) =>
- payload ? JSON.stringify(buildTimeSeriesJumpQuery(payload)) : '';
- const buildJumpRequestSignature = (payload: BehaviorSpacetimeJumpPayload, snapshot: BehaviorFilters) =>
- JSON.stringify({
- jump: buildTimeSeriesJumpQuery(payload),
- filters: {
- targets: normalizeTargetList(snapshot.targets),
- checkedTargets: normalizeTargetList(snapshot.checkedTargets),
- behaviorTypes: [...snapshot.behaviorTypes],
- dateRange: snapshot.dateRange || [],
- keyword: snapshot.keyword.trim(),
- locatedOnly: snapshot.locatedOnly,
- },
- page: 1,
- });
- const resetBehaviorSpacetimeJumpState = (nextFilters: BehaviorFilters) => {
- filters.value = cloneFilters(nextFilters);
- applied.value = cloneFilters(nextFilters);
- targetKeyword.value = '';
- targetDraft.value = [...nextFilters.targets];
- targetPickerOpen.value = false;
- remoteEvents.value = [];
- remoteTotal.value = 0;
- timelinePage.value = 1;
- timelineHasMore.value = true;
- timelineCanLoadForward.value = true;
- timelineCanLoadBackward.value = true;
- timelineMoreLoading.value = false;
- timelineListInitialized.value = false;
- selectedId.value = '';
- clearPendingTimelineScroll();
- detailCache.clear();
- detailRecordCache.value = {};
- detailHeadCache.value = {};
- detailDrawerOpen.value = false;
- detailLoading.value = false;
- detailError.value = '';
- detailRequestToken += 1;
- };
- const buildJumpAnchorEvent = (payload: BehaviorSpacetimeJumpPayload): BehaviorEvent | undefined => {
- const recordId = toText(payload.id || payload.rowId);
- const tableNameEn = toText(payload.tableNameEn || payload.tableName);
- const tableNameCn = toText(payload.tableNameCn);
- const occurredAt = formatDateTimeText(payload.seriesDateTime);
- if (!recordId && !tableNameEn && !tableNameCn && !occurredAt) return undefined;
- const normalizedRecord = {
- id: recordId || `jump-anchor-${joinText([tableNameEn, occurredAt], '-', 'unknown')}`,
- rowId: recordId || undefined,
- tableNameEn: tableNameEn || undefined,
- tableNameCn: tableNameCn || undefined,
- personName: toText(payload.personName) || '-',
- seriesDateTime: toText(payload.seriesDateTime) || undefined,
- } as TimeSeries;
- return normalizeTimeSeriesToBehaviorEvent(normalizedRecord, 0);
- };
- const cloneRange = (range?: RangeValue): RangeValue => (range ? [range[0], range[1]] : undefined);
- const cloneFilters = (value: BehaviorFilters): BehaviorFilters => ({
- ...value,
- targets: [...value.targets],
- checkedTargets: [...value.checkedTargets],
- behaviorTypes: [...value.behaviorTypes],
- dateRange: cloneRange(value.dateRange),
- });
- const BEHAVIOR_LABEL_MAP: Record<BehaviorType, string> = {
- 轨迹信息: '轨迹信息',
- 交易信息: '交易信息',
- 通信信息: '通信信息',
- };
- const BEHAVIOR_QUERY_TYPE_MAP: Record<BehaviorType, string> = {
- 轨迹信息: 'TRACK',
- 交易信息: 'TRANS',
- 通信信息: 'CALL',
- };
- const toBehaviorQueryTypes = (behaviorTypes: BehaviorType[]) =>
- Array.from(new Set(behaviorTypes.map((item) => BEHAVIOR_QUERY_TYPE_MAP[item]).filter(Boolean)));
- const behaviorOptions = [
- { label: '通信信息', value: '通信信息' as BehaviorType },
- { label: '交易信息', value: '交易信息' as BehaviorType },
- { label: '轨迹信息', value: '轨迹信息' as BehaviorType },
- ];
- const normalizeTimeSeriesToBehaviorEvent = (item: TimeSeries, index: number): BehaviorEvent => {
- // ext1~ext9 的业务含义按 GovernCache.java 每张表旁边的注释解释。
- const tableKey = resolveTimeSeriesTableKey(item);
- const behaviorType = getBehaviorTypeFromTimeSeries(item);
- const occurredAt = formatDateTimeText(item.seriesDateTime);
- const sourceLabel = fallbackText(item.tableNameCn, TABLE_SOURCE_LABEL_MAP[tableKey] || '时空事件');
- const rawTableNameEn = toText(item.tableNameEn);
- const lng = parseNumeric(item.ext8);
- const lat = parseNumeric(item.ext9);
- const hasCoordinate = typeof lng === 'number' && typeof lat === 'number';
- const baseEvent = {
- id: joinText([tableKey || 'time-series', item.rowId || item.id || index], '-', `time-series-${index}`),
- rowId: toText(item.rowId) || undefined,
- recordId: toText(item.id || item.rowId) || undefined,
- rawTableNameEn: rawTableNameEn || undefined,
- tableNameEn: tableKey || rawTableNameEn || undefined,
- tableNameCn: toText(item.tableNameCn) || undefined,
- otherName: toText(item.otherName) || undefined,
- ext1: toText(item.ext1) || undefined,
- ext2: toText(item.ext2) || undefined,
- ext3: toText(item.ext3) || undefined,
- ext4: toText(item.ext4) || undefined,
- ext5: toText(item.ext5) || undefined,
- ext6: toText(item.ext6) || undefined,
- ext7: toText(item.ext7) || undefined,
- ext8: toText(item.ext8) || undefined,
- ext9: toText(item.ext9) || undefined,
- personName: fallbackText(item.personName, '未知对象'),
- behaviorType,
- occurredAt,
- sourceLabel,
- hasCoordinate,
- lng,
- lat,
- };
- if (tableKey === 'call_record') {
- const directionText = mapCallDirection(item.ext4);
- const otherLabel = fallbackText(item.otherName || item.ext2, '未知对端');
- const locationName = fallbackText(item.ext7, '通信基站');
- const stationCode = joinText(
- [item.ext5 ? `LAC ${item.ext5}` : undefined, item.ext6 ? `CID ${item.ext6}` : undefined],
- ' / ',
- '基站编号待补充',
- );
- return {
- ...baseEvent,
- title: `${directionText}通信 · ${otherLabel}`,
- locationName,
- address: stationCode,
- durationMinutes: 0,
- accuracyMeters: 350,
- relatedCount: 1,
- summary: `${baseEvent.personName}于${occurredAt}与${otherLabel}发生${directionText}通信,运营商 ${fallbackText(
- item.ext3,
- '未知',
- )}。`,
- metaPrimaryLabel: '方向',
- metaPrimaryValue: directionText,
- metaSecondaryLabel: '基站',
- metaSecondaryValue: stationCode,
- };
- }
- if (tableKey === 'trans_record') {
- const directionText = mapDebitCreditFlag(item.ext4);
- const amountText = formatAmount(item.ext6);
- const otherLabel = fallbackText(item.otherName || item.ext2, '未知对手');
- const bankName = fallbackText(item.ext3, '未知银行');
- return {
- ...baseEvent,
- title: `${directionText}交易 · ${otherLabel}`,
- locationName: bankName,
- address: joinText([`本方卡 ${maskValue(item.ext1)}`, `对手卡 ${maskValue(item.ext2)}`], ' / '),
- durationMinutes: 0,
- accuracyMeters: 0,
- relatedCount: item.otherName ? 2 : 1,
- summary: `${baseEvent.personName}于${occurredAt}与${otherLabel}发生${directionText}交易,金额 ${
- amountText === '-' ? amountText : `${amountText} 元`
- }。`,
- metaPrimaryLabel: '金额',
- metaPrimaryValue: amountText === '-' ? amountText : `${amountText} 元`,
- metaSecondaryLabel: '方向',
- metaSecondaryValue: directionText,
- };
- }
- if (tableKey === 'hotel_stay_info') {
- const hotelName = fallbackText(item.ext2, '未知酒店');
- const roomNo = fallbackText(item.ext4, '待补充');
- return {
- ...baseEvent,
- title: `入住停留 · ${hotelName}`,
- locationName: hotelName,
- address: fallbackText(item.ext5, '酒店地址待补充'),
- durationMinutes: 180,
- accuracyMeters: 80,
- relatedCount: 1,
- summary: `${baseEvent.personName}于${occurredAt}入住${hotelName},房号 ${roomNo}。`,
- metaPrimaryLabel: '房号',
- metaPrimaryValue: roomNo,
- metaSecondaryLabel: '证件',
- metaSecondaryValue: maskValue(item.ext1, 6, 4),
- };
- }
- if (tableKey === 'train_ticket_info' || tableKey === 'together_train_info') {
- const trainNo = fallbackText(item.ext2, '未知车次');
- const routeText = joinText([item.ext3, item.ext4], ' → ', '路线待补充');
- return {
- ...baseEvent,
- title: `${tableKey === 'together_train_info' ? '同行铁路' : '铁路行程'} · ${trainNo}`,
- locationName: routeText,
- address: routeText,
- durationMinutes: 90,
- accuracyMeters: 120,
- relatedCount: tableKey === 'together_train_info' ? 2 : 1,
- summary: `${baseEvent.personName}于${occurredAt}乘坐${trainNo},路线 ${routeText}${
- tableKey === 'together_train_info' ? ',命中同行落点线索。' : '。'
- }`,
- metaPrimaryLabel: '车次',
- metaPrimaryValue: trainNo,
- metaSecondaryLabel: '路线',
- metaSecondaryValue: routeText,
- };
- }
- if (tableKey === 'flight_ticket_info') {
- const ticketNo = fallbackText(item.ext2, '未知票号');
- const routeText = joinText([item.ext3, item.ext4], ' → ', '航线待补充');
- return {
- ...baseEvent,
- title: `航班行程 · ${ticketNo}`,
- locationName: routeText,
- address: routeText,
- durationMinutes: 120,
- accuracyMeters: 150,
- relatedCount: 1,
- summary: `${baseEvent.personName}于${occurredAt}命中航班订票记录,票号 ${ticketNo},航线 ${routeText}。`,
- metaPrimaryLabel: '票号',
- metaPrimaryValue: ticketNo,
- metaSecondaryLabel: '航线',
- metaSecondaryValue: routeText,
- };
- }
- if (tableKey === 'flight_departure_info' || tableKey === 'together_flight_info') {
- const flightNo = fallbackText(item.ext2, '未知航班');
- const routeText = joinText([item.ext3, item.ext4], ' → ', '航线待补充');
- return {
- ...baseEvent,
- title: `${tableKey === 'together_flight_info' ? '同行航班' : '航班行程'} · ${flightNo}`,
- locationName: routeText,
- address: routeText,
- durationMinutes: 120,
- accuracyMeters: 150,
- relatedCount: tableKey === 'together_flight_info' ? 2 : 1,
- summary: `${baseEvent.personName}于${occurredAt}乘坐${flightNo},航线 ${routeText}${
- tableKey === 'together_flight_info' ? ',命中同行落点线索。' : '。'
- }`,
- metaPrimaryLabel: '航班',
- metaPrimaryValue: flightNo,
- metaSecondaryLabel: '航线',
- metaSecondaryValue: routeText,
- };
- }
- if (tableKey === 'track_car_ticket_info') {
- const busNo = fallbackText(item.ext2, '未知班次');
- const routeText = joinText([item.ext3, item.ext4], ' → ', '路线待补充');
- return {
- ...baseEvent,
- title: `汽车行程 · ${busNo}`,
- locationName: routeText,
- address: routeText,
- durationMinutes: 90,
- accuracyMeters: 180,
- relatedCount: 1,
- summary: `${baseEvent.personName}于${occurredAt}乘坐汽车班次 ${busNo},路线 ${routeText}。`,
- metaPrimaryLabel: '班次',
- metaPrimaryValue: busNo,
- metaSecondaryLabel: '路线',
- metaSecondaryValue: routeText,
- };
- }
- if (tableKey === 'express_info') {
- const senderName = fallbackText(item.otherName, '未知寄件人');
- const receiveAddress = fallbackText(item.ext3, '收件地址待补充');
- const senderAddress = fallbackText(item.ext4, '寄件地址待补充');
- return {
- ...baseEvent,
- title: `快递收发 · ${senderName}`,
- locationName: receiveAddress,
- address: joinText([`收件地 ${receiveAddress}`, `寄件地 ${senderAddress}`], ' / '),
- durationMinutes: 20,
- accuracyMeters: 200,
- relatedCount: item.otherName ? 2 : 1,
- summary: `${baseEvent.personName}于${occurredAt}接收来自${senderName}的快递,收件地 ${receiveAddress}。`,
- metaPrimaryLabel: '寄件人',
- metaPrimaryValue: senderName,
- metaSecondaryLabel: '寄件地',
- metaSecondaryValue: senderAddress,
- };
- }
- return {
- ...baseEvent,
- title: sourceLabel,
- locationName: fallbackText(item.otherName || item.ext2 || item.ext3, '位置待补充'),
- address: fallbackText(item.ext5 || item.ext4 || item.ext3, '地址待补充'),
- durationMinutes: 0,
- accuracyMeters: 0,
- relatedCount: item.otherName ? 2 : 1,
- summary: `${baseEvent.personName}于${occurredAt}命中 ${sourceLabel} 记录。`,
- metaPrimaryLabel: '来源',
- metaPrimaryValue: sourceLabel,
- metaSecondaryLabel: '记录ID',
- metaSecondaryValue: fallbackText(item.id || item.rowId),
- };
- };
- const normalizeDetailHeadColumns = (source: unknown): DetailHeadColumn[] => {
- if (!Array.isArray(source)) return [];
- return source.filter((item): item is DetailHeadColumn => !!item && typeof item === 'object');
- };
- const normalizeTimeSeriesDetailPayload = (resp: any): { record?: TimeSeriesDetailDTO; head: DetailHeadColumn[] } => {
- const mergeDetailMeta = (record: any, container?: any) => {
- if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined;
- return {
- ...record,
- size: record.size ?? container?.size ?? container?.pageSize,
- total: record.total ?? container?.total,
- pages: record.pages ?? container?.pages,
- } as TimeSeriesDetailDTO;
- };
- let current = resp;
- for (let index = 0; index < 6; index += 1) {
- if (!current || Array.isArray(current) || typeof current !== 'object') return { head: [], record: undefined };
- const head = normalizeDetailHeadColumns(current.head);
- const dt = current.dt;
- if (head.length || (dt && typeof dt === 'object' && !Array.isArray(dt))) {
- return {
- head,
- record: mergeDetailMeta(dt && typeof dt === 'object' && !Array.isArray(dt) ? dt : current, current),
- };
- }
- if (Array.isArray(current.records)) return { head: [], record: mergeDetailMeta(current.records[0], current) };
- if (Array.isArray(current.list)) return { head: [], record: mergeDetailMeta(current.list[0], current) };
- if (Array.isArray(current.rows)) return { head: [], record: mergeDetailMeta(current.rows[0], current) };
- if (Array.isArray(current.data)) return { head: [], record: mergeDetailMeta(current.data[0], current) };
- const next =
- (current.data && !Array.isArray(current.data) ? current.data : undefined) ||
- current.detail ||
- current.record ||
- current.row ||
- current.obj ||
- current.result;
- if (!next || next === current) {
- return { head, record: mergeDetailMeta(current, current) };
- }
- current = next;
- }
- return {
- head: normalizeDetailHeadColumns(current?.head),
- record: mergeDetailMeta(current, current),
- };
- };
- const extractDetailRecordEntries = (
- source: unknown,
- path = '',
- entries: Array<{ key: string; value: unknown }> = [],
- ) => {
- if (!source || typeof source !== 'object' || Array.isArray(source)) return entries;
- Object.entries(source as Record<string, unknown>).forEach(([key, value]) => {
- if (DETAIL_WRAPPER_SKIP_KEYS.has(key)) {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- extractDetailRecordEntries(value, path ? `${path}.${key}` : key, entries);
- }
- return;
- }
- const nestedKey = path ? `${path}.${key}` : key;
- entries.push({ key: nestedKey, value });
- });
- return entries;
- };
- const shouldSkipDetailField = (fieldKey: string) => {
- const normalizedKey = fieldKey.split('.').pop() || fieldKey;
- return DETAIL_ID_SKIP_KEYS.has(normalizedKey);
- };
- const resolveDetailColumnFieldKey = (column?: DetailHeadColumn | null) => {
- if (!column) return '';
- const dataIndex = column.dataIndex;
- if (Array.isArray(dataIndex)) {
- return dataIndex
- .map((item) => toText(item))
- .filter(Boolean)
- .join('.');
- }
- return toText(dataIndex) || toText(column.key);
- };
- const resolveDetailFieldValue = (source: unknown, fieldKey: string): unknown => {
- if (!source || typeof source !== 'object' || !fieldKey) return undefined;
- const record = source as Record<string, unknown>;
- if (fieldKey in record) return record[fieldKey];
- return fieldKey.split('.').reduce<unknown>((acc, segment) => {
- if (!segment || !acc || typeof acc !== 'object' || Array.isArray(acc)) return undefined;
- return (acc as Record<string, unknown>)[segment];
- }, source);
- };
- const createRawDetailFields = (detailRecord: TimeSeriesDetailDTO, tableKey: string) => {
- return extractDetailRecordEntries(detailRecord)
- .filter(({ key }) => !shouldSkipDetailField(key))
- .map(({ key, value }) => {
- const normalizedKey = key.split('.').pop() || key;
- return createDetailField(key, getDetailFieldLabel(normalizedKey, tableKey), value, tableKey, normalizedKey);
- })
- .filter((field) => field.value !== '-');
- };
- const createMappedDetailFields = (
- columns: DetailHeadColumn[],
- detailRecord: TimeSeriesDetailDTO,
- tableKey: string,
- ): DetailField[] => {
- const consumedKeys = new Set<string>();
- return columns
- .map((column, index) => {
- const fieldKey = resolveDetailColumnFieldKey(column);
- if (!fieldKey || consumedKeys.has(fieldKey) || shouldSkipDetailField(fieldKey)) return null;
- consumedKeys.add(fieldKey);
- const normalizedKey = fieldKey.split('.').pop() || fieldKey;
- const label = toText(column.title) || getDetailFieldLabel(normalizedKey, tableKey);
- return createDetailField(
- `${fieldKey}-${index}`,
- label,
- resolveDetailFieldValue(detailRecord, fieldKey),
- tableKey,
- normalizedKey,
- );
- })
- .filter((field): field is DetailField => !!field);
- };
- const buildTimeSeriesDetailQuery = (item: BehaviorEvent): TimeSeriesDetailQuery => ({
- id: item.recordId || undefined,
- rowId: item.rowId || undefined,
- tableName: item.rawTableNameEn || item.tableNameEn || undefined,
- tableNameEn: item.rawTableNameEn || item.tableNameEn || undefined,
- tableNameCn: item.tableNameCn || undefined,
- personName: item.personName || undefined,
- seriesDateTime: item.occurredAt || undefined,
- });
- const toTimeSeriesDetailRecord = (detailRecord: TimeSeriesDetailDTO, baseEvent: BehaviorEvent): TimeSeries => ({
- ...detailRecord,
- id: detailRecord.id ?? detailRecord.rowId ?? baseEvent.recordId ?? baseEvent.rowId,
- rowId: detailRecord.rowId ?? detailRecord.id ?? baseEvent.rowId ?? baseEvent.recordId,
- tableNameEn: toText(detailRecord.tableNameEn) || baseEvent.rawTableNameEn || baseEvent.tableNameEn,
- tableNameCn: toText(detailRecord.tableNameCn) || baseEvent.tableNameCn || baseEvent.sourceLabel,
- personName: toText(detailRecord.personName) || baseEvent.personName,
- otherName: toText(detailRecord.otherName) || undefined,
- seriesDateTime: toText(detailRecord.seriesDateTime) || baseEvent.occurredAt,
- ext8: toText(detailRecord.ext8) || baseEvent.ext8,
- ext9: toText(detailRecord.ext9) || baseEvent.ext9,
- });
- const mergeTimeSeriesDetailToBehaviorEvent = (baseEvent: BehaviorEvent, detailRecord: TimeSeriesDetailDTO) => {
- const normalized = normalizeTimeSeriesToBehaviorEvent(toTimeSeriesDetailRecord(detailRecord, baseEvent), 0);
- const rawTableNameEn = toText(detailRecord.tableNameEn) || baseEvent.rawTableNameEn || baseEvent.tableNameEn;
- const mergedLng = typeof normalized.lng === 'number' ? normalized.lng : baseEvent.lng;
- const mergedLat = typeof normalized.lat === 'number' ? normalized.lat : baseEvent.lat;
- const mergedHasCoordinate = typeof mergedLng === 'number' && typeof mergedLat === 'number';
- const mergedExt8 = toText(normalized.ext8) || baseEvent.ext8;
- const mergedExt9 = toText(normalized.ext9) || baseEvent.ext9;
- return {
- ...baseEvent,
- ...normalized,
- id: baseEvent.id,
- rowId: toText(detailRecord.rowId ?? detailRecord.id) || baseEvent.rowId,
- recordId: toText(detailRecord.id ?? detailRecord.rowId) || baseEvent.recordId,
- rawTableNameEn: rawTableNameEn || undefined,
- tableNameEn: normalized.tableNameEn || baseEvent.tableNameEn,
- tableNameCn: toText(detailRecord.tableNameCn) || baseEvent.tableNameCn,
- title: baseEvent.title,
- otherName: baseEvent.otherName,
- locationName: baseEvent.locationName,
- address: baseEvent.address,
- summary: baseEvent.summary,
- metaPrimaryLabel: baseEvent.metaPrimaryLabel,
- metaPrimaryValue: baseEvent.metaPrimaryValue,
- metaSecondaryLabel: baseEvent.metaSecondaryLabel,
- metaSecondaryValue: baseEvent.metaSecondaryValue,
- ext1: baseEvent.ext1,
- ext2: baseEvent.ext2,
- ext3: baseEvent.ext3,
- ext4: baseEvent.ext4,
- ext5: baseEvent.ext5,
- ext6: baseEvent.ext6,
- ext7: baseEvent.ext7,
- ext8: mergedExt8,
- ext9: mergedExt9,
- lng: mergedLng,
- lat: mergedLat,
- hasCoordinate: mergedHasCoordinate,
- };
- };
- const filters = ref<BehaviorFilters>(makeFilters());
- const applied = ref<BehaviorFilters>(cloneFilters(makeFilters()));
- const activeJumpPayload = ref<BehaviorSpacetimeJumpPayload | null>(null);
- const lastRouteJumpSignature = ref('');
- const runningJumpRequestSignature = ref('');
- const appliedJumpRequestSignature = ref('');
- const targetOptions = ref<TargetOption[]>([]);
- const targetPickerOpen = ref(false);
- const targetKeyword = ref('');
- const targetDraft = ref<string[]>([]);
- const remoteEvents = ref<BehaviorEvent[]>([]);
- const remoteTotal = ref(0);
- const detailLoading = ref(false);
- const detailDrawerOpen = ref(false);
- const detailError = ref('');
- const pageRootRef = ref<HTMLElement | null>(null);
- const pageContentRef = ref<HTMLElement | null>(null);
- const timelineListRef = ref<HTMLElement | null>(null);
- const mapRef = ref<HTMLElement | null>(null);
- const hoverCard = ref({
- visible: false,
- x: 0,
- y: 0,
- lng: 0,
- lat: 0,
- locationName: '',
- address: '',
- stationCode: '',
- coordinateText: '',
- });
- const hoverCardLocked = ref(false);
- let hoverCardHideTimer: ReturnType<typeof setTimeout> | null = null;
- const map = shallowRef<MapLibreMap | null>(null);
- const mapLoaded = ref(false);
- const mapMode = ref<'template' | 'fallback'>('fallback');
- const mapReloading = ref(false);
- const selectedId = ref('');
- const errorMsg = ref('');
- const timelinePage = ref(1);
- const timelineMoreLoading = ref(false);
- const timelineHasMore = ref(false);
- const timelineCanLoadForward = ref(false);
- const timelineCanLoadBackward = ref(false);
- const timelineListInitialized = ref(false);
- const detailCache = new Map<string, BehaviorEvent>();
- const detailRecordCache = ref<Record<string, TimeSeriesDetailDTO>>({});
- const detailHeadCache = ref<Record<string, DetailHeadColumn[]>>({});
- const pendingTimelineScrollId = ref('');
- const pendingTimelineScrollBehavior = ref<ScrollBehavior>('auto');
- const pendingTimelineScrollAttempts = ref(0);
- const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
- refresh: () => handleSearch(),
- hasData: () =>
- remoteEvents.value.length > 0 &&
- (applied.value.checkedTargets.length > 0 || hasMeaningfulJumpPayload(activeJumpPayload.value)),
- });
- let timelineScrollRetryTimer: number | null = null;
- let timelineProgrammaticScrollUntil = 0;
- let detailRequestToken = 0;
- let timelineRequestToken = 0;
- let restoringPageCache = false;
- const getBehaviorSpacetimeWindow = () => window as BehaviorSpacetimeWindow;
- const clearBehaviorSpacetimePageCache = () => {
- delete getBehaviorSpacetimeWindow().__BEHAVIOR_SPACETIME_PAGE_CACHE__;
- };
- const isBehaviorSpacetimeJumpCache = (cache?: BehaviorSpacetimePageCache | null) => {
- if (!cache) return false;
- return Boolean(cache.lastRouteJumpSignature || hasMeaningfulJumpPayload(cache.activeJumpPayload));
- };
- const saveBehaviorSpacetimePageCache = () => {
- if (restoringPageCache) return;
- getBehaviorSpacetimeWindow().__BEHAVIOR_SPACETIME_PAGE_CACHE__ = {
- filters: cloneFilters(filters.value),
- applied: cloneFilters(applied.value),
- activeJumpPayload: activeJumpPayload.value ? buildTimeSeriesJumpQuery(activeJumpPayload.value) : null,
- lastRouteJumpSignature: lastRouteJumpSignature.value,
- appliedJumpRequestSignature: appliedJumpRequestSignature.value,
- remoteEvents: remoteEvents.value.map((item) => ({ ...item })),
- remoteTotal: remoteTotal.value,
- selectedId: selectedId.value,
- timelinePage: timelinePage.value,
- timelineHasMore: timelineHasMore.value,
- timelineCanLoadForward: timelineCanLoadForward.value,
- timelineCanLoadBackward: timelineCanLoadBackward.value,
- timelineListInitialized: timelineListInitialized.value,
- detailRecords: { ...detailRecordCache.value },
- detailHeads: { ...detailHeadCache.value },
- detailEvents: Array.from(detailCache.values()).map((item) => ({ ...item })),
- };
- };
- const restoreBehaviorSpacetimePageCache = (options?: { allowJumpCache?: boolean }) => {
- const { allowJumpCache = false } = options || {};
- const cache = getBehaviorSpacetimeWindow().__BEHAVIOR_SPACETIME_PAGE_CACHE__;
- if (!cache?.remoteEvents?.length) return false;
- if (!allowJumpCache && isBehaviorSpacetimeJumpCache(cache)) return false;
- restoringPageCache = true;
- try {
- filters.value = cloneFilters(cache.filters);
- applied.value = cloneFilters(cache.applied);
- activeJumpPayload.value = cache.activeJumpPayload ? buildTimeSeriesJumpQuery(cache.activeJumpPayload) : null;
- lastRouteJumpSignature.value = cache.lastRouteJumpSignature || '';
- appliedJumpRequestSignature.value = cache.appliedJumpRequestSignature || '';
- remoteEvents.value = cache.remoteEvents.map((item) => ({ ...item }));
- remoteTotal.value = cache.remoteTotal || remoteEvents.value.length;
- selectedId.value = cache.selectedId || remoteEvents.value[0]?.id || '';
- timelinePage.value = cache.timelinePage || 1;
- timelineHasMore.value = !!cache.timelineHasMore;
- timelineCanLoadForward.value = !!cache.timelineCanLoadForward;
- timelineCanLoadBackward.value = !!cache.timelineCanLoadBackward;
- timelineListInitialized.value = !!cache.timelineListInitialized;
- detailCache.clear();
- cache.detailEvents.forEach((item) => {
- if (item?.id) detailCache.set(item.id, { ...item });
- });
- detailRecordCache.value = { ...cache.detailRecords };
- detailHeadCache.value = { ...cache.detailHeads };
- detailDrawerOpen.value = false;
- detailLoading.value = false;
- detailError.value = '';
- timelineRequestToken += 1;
- detailRequestToken += 1;
- } finally {
- restoringPageCache = false;
- }
- return true;
- };
- const restoreBehaviorSpacetimePageCacheForJumpPayload = (payload?: BehaviorSpacetimeJumpPayload | null) => {
- const cache = getBehaviorSpacetimeWindow().__BEHAVIOR_SPACETIME_PAGE_CACHE__;
- if (!payload || !cache?.remoteEvents?.length) return false;
- const routeSignature = buildRouteJumpSignature(payload);
- const cacheRouteSignature =
- cache.lastRouteJumpSignature ||
- (cache.activeJumpPayload ? buildRouteJumpSignature(cache.activeJumpPayload) : '');
- if (!routeSignature || routeSignature !== cacheRouteSignature) return false;
- const restored = restoreBehaviorSpacetimePageCache({ allowJumpCache: true });
- if (!restored) return false;
- activeJumpPayload.value = buildTimeSeriesJumpQuery(payload);
- lastRouteJumpSignature.value = routeSignature;
- return true;
- };
- const buildFiltersForExitJumpContext = () => {
- const sourceFilters = cloneFilters(filters.value.targets.length ? filters.value : applied.value);
- const eventTargets = normalizeTargetList([
- ...remoteEvents.value.map((item) => item.personName),
- ...filteredEvents.value.map((item) => item.personName),
- ...(activeEvent.value?.personName ? [activeEvent.value.personName] : []),
- ]);
- const jumpTargets = normalizeTargetList([
- ...sourceFilters.targets,
- ...sourceFilters.checkedTargets,
- ...displayTargets.value,
- ...checkedTargets.value,
- ...(activeJumpPayload.value?.personNames || []),
- ...(activeJumpPayload.value?.personName ? [activeJumpPayload.value.personName] : []),
- ...eventTargets,
- ]);
- const nextCheckedTargets = normalizeTargetList(
- sourceFilters.checkedTargets.length ? sourceFilters.checkedTargets : jumpTargets,
- ).filter((item) => jumpTargets.includes(item));
- return {
- ...sourceFilters,
- keyword: '',
- targets: jumpTargets,
- checkedTargets: nextCheckedTargets.length ? nextCheckedTargets : [...jumpTargets],
- } satisfies BehaviorFilters;
- };
- const exitRouteJumpContext = async () => {
- const nextFilters = buildFiltersForExitJumpContext();
- targetKeyword.value = '';
- targetDraft.value = [];
- targetPickerOpen.value = false;
- clearBehaviorData(nextFilters);
- filters.value = cloneFilters(nextFilters);
- activeJumpPayload.value = null;
- lastRouteJumpSignature.value = '';
- runningJumpRequestSignature.value = '';
- appliedJumpRequestSignature.value = '';
- clearBehaviorSpacetimePageCache();
- if (nextFilters.checkedTargets.length) {
- await handleSearch();
- return;
- }
- syncAnalysisDataRefreshState(false);
- resetPageScrollPosition();
- };
- const getTimeSeriesCursorRowId = (list: BehaviorEvent[], direction: 'GT' | 'LT' = 'GT') => {
- if (!list.length) return undefined;
- const target = direction === 'LT' ? list[0] : list[list.length - 1];
- return target?.rowId || target?.recordId || undefined;
- };
- const availableTargetOptions = computed(() => targetOptions.value.filter((item) => item.value !== 'all'));
- const pickerTargetOptions = computed(() => {
- const keyword = targetKeyword.value.trim().toLowerCase();
- if (!keyword) return availableTargetOptions.value;
- return availableTargetOptions.value.filter((item) => {
- const label = String(item.label || '').toLowerCase();
- const value = String(item.value || '').toLowerCase();
- const identityCode = String(item.identityCode || '').toLowerCase();
- return label.includes(keyword) || value.includes(keyword) || identityCode.includes(keyword);
- });
- });
- const allDraftTargetsChecked = computed(() => {
- const availableValues = availableTargetOptions.value.map((item) => item.value);
- return !!availableValues.length && availableValues.every((value) => targetDraft.value.includes(value));
- });
- const draftTargetsIndeterminate = computed(() => {
- if (!targetDraft.value.length) return false;
- return !allDraftTargetsChecked.value;
- });
- const normalizeTargetList = (list: string[]) => Array.from(new Set(list.map((item) => item.trim()).filter(Boolean)));
- const displayTargets = computed(() => {
- const sourceTargets = filters.value.targets.length ? filters.value.targets : applied.value.targets;
- return normalizeTargetList(sourceTargets);
- });
- const checkedTargets = computed(() => {
- const sourceTargets = filters.value.targets.length ? filters.value.checkedTargets : applied.value.checkedTargets;
- const allowedTargets = new Set(displayTargets.value);
- return normalizeTargetList(sourceTargets).filter((item) => allowedTargets.has(item));
- });
- const checkedTargetSet = computed(() => new Set(checkedTargets.value));
- const hasActiveJumpPayload = computed(() => hasMeaningfulJumpPayload(activeJumpPayload.value));
- const isCurrentBehaviorSpacetimeRoute = computed(() => route.path === BEHAVIOR_SPACETIME_ROUTE_PATH);
- const canSearch = computed(() => checkedTargets.value.length > 0 || hasActiveJumpPayload.value);
- const appliedPressurePointCount = computed(() => 0);
- const pressureTargetNames = computed(() => [] as string[]);
- const allEvents = computed<BehaviorEvent[]>(() => remoteEvents.value);
- const filteredEvents = computed(() => {
- const keyword = applied.value.keyword.trim().toLowerCase();
- const [startTime, endTime] = applied.value.dateRange || [];
- return allEvents.value.filter((item) => {
- if (applied.value.checkedTargets.length && !applied.value.checkedTargets.includes(item.personName)) return false;
- if (applied.value.behaviorTypes.length && !applied.value.behaviorTypes.includes(item.behaviorType)) return false;
- if (startTime && dayjs(item.occurredAt).isBefore(dayjs(startTime).startOf('day'))) return false;
- if (endTime && dayjs(item.occurredAt).isAfter(dayjs(endTime).endOf('day'))) return false;
- if (!keyword) return true;
- return [item.personName, item.title, item.locationName, item.address, item.summary]
- .join(' ')
- .toLowerCase()
- .includes(keyword);
- });
- });
- const locatedEvents = computed(() =>
- filteredEvents.value.filter(
- (item) => item.hasCoordinate && typeof item.lng === 'number' && typeof item.lat === 'number',
- ),
- );
- const activeEvent = computed(
- () => filteredEvents.value.find((item) => item.id === selectedId.value) || filteredEvents.value[0],
- );
- const currentDetailRecord = computed(() =>
- activeEvent.value ? detailRecordCache.value[activeEvent.value.id] : undefined,
- );
- const currentDetailHead = computed(() =>
- activeEvent.value ? detailHeadCache.value[activeEvent.value.id] || [] : [],
- );
- const detailOverviewCards = computed<DetailOverviewCard[]>(() => {
- const item = activeEvent.value;
- const detailRecord = currentDetailRecord.value;
- if (!item || !detailRecord) return [];
- const snapshot = buildDetailRecordSnapshot(detailRecord, item);
- const tableKey = getDetailTableKey(snapshot, item);
- const cards: DetailOverviewCard[] = [];
- const pushCard = (label: string, value: unknown, fieldKey?: string) => {
- const formatted = fieldKey ? formatDetailFieldValue(fieldKey, value, tableKey) : normalizeDetailValue(value);
- if (formatted === '-') return;
- cards.push({ label, value: formatted });
- };
- if (tableKey === 'trans_record') {
- pushCard('交易金额', snapshot.transAmount, 'transAmount');
- pushCard('借贷标志', snapshot.debitCreditFlag || snapshot.transType, 'debitCreditFlag');
- pushCard('交易对手', snapshot.otherName || snapshot.otherAccount);
- pushCard('本方卡号', maskValue(snapshot.personCardNo, 6, 4));
- return cards;
- }
- if (tableKey === 'call_record') {
- pushCard('对方号码', snapshot.otherPhone || snapshot.ext2);
- pushCard('主被叫', snapshot.callDirection || snapshot.ext4, 'callDirection');
- pushCard('通话时间', snapshot.callDateTime || snapshot.seriesDateTime, 'callDateTime');
- pushCard('基站名称', snapshot.personStationName || snapshot.ext7);
- return cards;
- }
- pushCard(item.metaPrimaryLabel, item.metaPrimaryValue);
- pushCard(item.metaSecondaryLabel, item.metaSecondaryValue);
- pushCard('发生时间', snapshot.seriesDateTime, 'seriesDateTime');
- pushCard('位置/对象', snapshot.otherName || item.locationName);
- return cards.filter((card, index, list) => list.findIndex((target) => target.label === card.label) === index);
- });
- const detailFlatFields = computed<DetailField[]>(() => {
- const item = activeEvent.value;
- const detailRecord = currentDetailRecord.value;
- if (!item || !detailRecord) return [];
- const snapshot = buildDetailRecordSnapshot(detailRecord, item);
- const tableKey = getDetailTableKey(snapshot, item);
- return currentDetailHead.value.length
- ? createMappedDetailFields(currentDetailHead.value, snapshot, tableKey)
- : createRawDetailFields(snapshot, tableKey);
- });
- const detailDrawerTitle = computed(() => activeEvent.value?.personName || '行为详情');
- const detailDrawerSubTitle = computed(() => {
- if (!activeEvent.value) return '点击时间轴节点查看接口详情';
- return `${getBehaviorLabel(activeEvent.value.behaviorType)} · ${activeEvent.value.occurredAt}`;
- });
- const detailDrawerEmptyText = computed(() => {
- if (!activeEvent.value) return '请选择时间轴节点';
- return '详情接口暂无返回数据';
- });
- const timelineEntries = computed(() => {
- return filteredEvents.value.map((item, index) => ({ item, index }));
- });
- const isUsableMapText = (value?: string) => {
- const text = toText(value);
- if (!text) return false;
- if (['地址待补充', '位置待补充', '基站编号待补充', '-'].includes(text)) return false;
- if (/^LAC\s*\d+/i.test(text)) return false;
- return true;
- };
- const resolveMapPointLabel = (item: BehaviorEvent) => {
- const locationName = toText(item.locationName);
- const address = toText(item.address);
- if (isUsableMapText(locationName)) return locationName;
- if (isUsableMapText(address)) return address;
- return fallbackText(item.personName);
- };
- const buildMapDisplayLabel = (item: BehaviorEvent) => {
- return resolveMapPointLabel(item);
- };
- const buildMapCoordinateKey = (lng: number, lat: number) => `${lng.toFixed(6)}:${lat.toFixed(6)}`;
- const formatMapCoordinateText = (lng?: number, lat?: number) => {
- if (typeof lng !== 'number' || typeof lat !== 'number') return '';
- return `${lng.toFixed(6)}, ${lat.toFixed(6)}`;
- };
- const getMapPointLabelPriority = (item: BehaviorEvent) => {
- if (isUsableMapText(item.locationName)) return 2;
- if (isUsableMapText(item.address)) return 1;
- return 0;
- };
- const getMapPointPriority = (item: BehaviorEvent) => {
- let priority = getMapPointLabelPriority(item) * 10;
- if (item.id === activeEvent.value?.id) priority += 100;
- if (item.behaviorType === '轨迹信息') priority += 3;
- if (item.behaviorType === '交易信息') priority += 2;
- if (item.behaviorType === '通信信息') priority += 1;
- return priority;
- };
- const buildMapLabelFeatures = (list: BehaviorEvent[]) => {
- const labelMap = new Map<
- string,
- {
- priority: number;
- feature: {
- type: 'Feature';
- properties: {
- id: string;
- mapLabel: string;
- displayLabel: string;
- personName: string;
- title: string;
- locationName: string;
- address: string;
- };
- geometry: { type: 'Point'; coordinates: [number, number] };
- };
- }
- >();
- list.forEach((item) => {
- if (typeof item.lng !== 'number' || typeof item.lat !== 'number') return;
- const mapLabel = resolveMapPointLabel(item);
- if (!mapLabel || mapLabel === '-') return;
- const key = buildMapCoordinateKey(item.lng, item.lat);
- const nextPriority = getMapPointLabelPriority(item);
- const nextFeature = {
- type: 'Feature' as const,
- properties: {
- id: item.id,
- mapLabel,
- displayLabel: mapLabel,
- personName: item.personName,
- title: item.title,
- locationName: item.locationName,
- address: item.address,
- },
- geometry: { type: 'Point' as const, coordinates: [item.lng, item.lat] as [number, number] },
- };
- const current = labelMap.get(key);
- if (
- !current ||
- nextPriority > current.priority ||
- (nextPriority === current.priority && mapLabel.length > current.feature.properties.mapLabel.length)
- ) {
- labelMap.set(key, {
- priority: nextPriority,
- feature: nextFeature,
- });
- }
- });
- return Array.from(labelMap.values()).map((item) => item.feature);
- };
- const mapLabelFeatures = computed(() => buildMapLabelFeatures(locatedEvents.value));
- const hasSameMapViewportBasis = (list: BehaviorEvent[], previousList?: BehaviorEvent[]) => {
- if (!previousList || list.length !== previousList.length) return false;
- return list.every((item, index) => {
- const previousItem = previousList[index];
- if (!previousItem || item.id !== previousItem.id) return false;
- return (
- item.hasCoordinate === previousItem.hasCoordinate &&
- (item.lng ?? null) === (previousItem.lng ?? null) &&
- (item.lat ?? null) === (previousItem.lat ?? null)
- );
- });
- };
- const getPointLabelMinZoom = (labelCount: number) => {
- if (labelCount <= POINT_LABEL_LIGHT_LIMIT) return POINT_LABEL_BASE_MIN_ZOOM;
- if (labelCount > POINT_LABEL_HEAVY_LIMIT) return 11.8;
- if (labelCount > POINT_LABEL_DENSE_LIMIT) return 10.9;
- if (labelCount > POINT_LABEL_MEDIUM_LIMIT) return 10.1;
- return 9.4;
- };
- const getPointLabelTextSize = (labelCount: number) => {
- if (labelCount > POINT_LABEL_HEAVY_LIMIT) return 10;
- if (labelCount > POINT_LABEL_DENSE_LIMIT) return 10.5;
- if (labelCount > POINT_LABEL_MEDIUM_LIMIT) return 11;
- return 11.5;
- };
- const addressLabelCount = computed(() => mapLabelFeatures.value.length);
- const summaryCards = computed(() => {
- const hotspot = new Map<string, number>();
- locatedEvents.value.forEach((item) => hotspot.set(item.locationName, (hotspot.get(item.locationName) || 0) + 1));
- const topHotspot = Array.from(hotspot.entries()).sort((a, b) => b[1] - a[1])[0];
- return [
- { label: '时空节点', value: filteredEvents.value.length, desc: `定位点 ${locatedEvents.value.length}` },
- {
- label: '关联对象',
- value: new Set(filteredEvents.value.map((item) => item.personName)).size,
- desc: addressLabelCount.value ? `${addressLabelCount.value} 个地址标签` : '暂无地址标签',
- },
- {
- label: '热点区域',
- value: topHotspot?.[0] || '-',
- desc: topHotspot ? `命中 ${topHotspot[1]} 次` : '暂无聚集热点',
- },
- ];
- });
- const legendItems = computed(() =>
- (Object.keys(THEME) as BehaviorType[]).map((name) => ({
- label: BEHAVIOR_LABEL_MAP[name],
- color: THEME[name].color,
- })),
- );
- const isStressMode = computed(() => false);
- const hasAppliedTargets = computed(() => applied.value.targets.length > 0);
- const hasAppliedCheckedTargets = computed(() => applied.value.checkedTargets.length > 0);
- const hasAppliedJumpPayload = computed(() => hasMeaningfulJumpPayload(activeJumpPayload.value));
- const remoteLoadedCount = computed(() => remoteEvents.value.length);
- const hasLoadedAllRemote = computed(
- () => timelineListInitialized.value && remoteLoadedCount.value > 0 && !timelineHasMore.value,
- );
- const showTimelineLoadedTotal = computed(() => remoteTotal.value > 0);
- const getTimelineLoadedText = (suffix = '') => {
- const base = showTimelineLoadedTotal.value
- ? `已加载 ${remoteLoadedCount.value}/${remoteTotal.value} 条`
- : `已加载 ${remoteLoadedCount.value} 条`;
- return suffix ? `${base},${suffix}` : base;
- };
- const pressureStatusText = computed(() => `接口模式 · ${locatedEvents.value.length} 点`);
- const timelineCountText = computed(() =>
- hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value
- ? !timelineListInitialized.value
- ? remoteLoadedCount.value
- ? `已定位 ${remoteLoadedCount.value} 条跳转记录`
- : '已接收跳转条件'
- : !isStressMode.value && timelineHasMore.value
- ? getTimelineLoadedText()
- : `${filteredEvents.value.length} 个节点`
- : !hasAppliedTargets.value
- ? '未选择对象'
- : !hasAppliedCheckedTargets.value
- ? '未勾选对象'
- : !isStressMode.value && timelineHasMore.value
- ? getTimelineLoadedText()
- : !isStressMode.value || filteredEvents.value.length <= timelineEntries.value.length
- ? `${filteredEvents.value.length} 个节点`
- : `${filteredEvents.value.length} 个节点 / 预览 ${timelineEntries.value.length} 条`,
- );
- const timelineLoadStateText = computed(() => {
- if (timelineMoreLoading.value) return `正在加载更多...`;
- if (hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value && !timelineListInitialized.value) {
- return '继续下拉加载时间轴列表';
- }
- if (hasLoadedAllRemote.value) return getTimelineLoadedText('无更多数据');
- if (timelineHasMore.value) return getTimelineLoadedText('下拉继续加载');
- if (remoteLoadedCount.value) return getTimelineLoadedText();
- return '';
- });
- const showTimelineLoadState = computed(
- () =>
- (hasAppliedCheckedTargets.value || hasAppliedJumpPayload.value) &&
- !!timelineLoadStateText.value &&
- (!!remoteLoadedCount.value || timelineHasMore.value || timelineMoreLoading.value),
- );
- const mapStatusText = computed(() =>
- errorMsg.value
- ? '离线底图异常,已回退空白底图'
- : mapMode.value === 'template'
- ? '已加载离线底图模板'
- : '当前为空白底图',
- );
- const timelineEmptyText = computed(() =>
- hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value
- ? '当前明细未命中可展示的时空节点'
- : !hasAppliedTargets.value
- ? '请先点击“添加对象”选择对象'
- : !hasAppliedCheckedTargets.value
- ? '请勾选对象后查看数据'
- : '暂无命中节点',
- );
- const mapEmptyText = computed(() =>
- hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value
- ? '当前明细暂无可绘制定位点'
- : !hasAppliedTargets.value
- ? '请先点击“添加对象”选择对象'
- : !hasAppliedCheckedTargets.value
- ? '请勾选对象后查看数据'
- : '暂无可绘制定位点',
- );
- const tagStyle = (type: BehaviorType) => ({
- color: THEME[type].color,
- background: THEME[type].soft,
- borderColor: THEME[type].soft,
- });
- const getBehaviorLabel = (type: BehaviorType) => BEHAVIOR_LABEL_MAP[type] || type;
- const createTimelineField = (label: string, value?: string | number | null, fallback = '-'): TimelineField => ({
- label,
- value: fallbackText(value, fallback),
- });
- const getAmountFieldValue = (value?: string | number | null, fallbackValue?: string | number | null) => {
- const amountText = toText(value) ? formatAmount(value) : fallbackText(fallbackValue);
- return amountText === '-' ? amountText : `${amountText} 元`;
- };
- const getTimelineFields = (item: BehaviorEvent): TimelineField[] => {
- const tableKey = resolveTimeSeriesTableKey({
- tableNameEn: item.tableNameEn,
- tableNameCn: item.tableNameCn,
- } as TimeSeries);
- if (tableKey === 'call_record') {
- const stationName = toText(item.ext7) || (item.locationName !== '通信基站' ? item.locationName : undefined);
- return [
- createTimelineField('本方号码', item.ext1),
- createTimelineField('对方号码', item.ext2 || item.otherName),
- createTimelineField('通话类型', mapCallDirection(item.ext4)),
- createTimelineField('本方号码归属运营商', item.ext3),
- createTimelineField('本方基站名称', stationName),
- ];
- }
- if (tableKey === 'trans_record') {
- return [
- createTimelineField('本方卡号', item.ext1),
- createTimelineField('对方卡号', item.ext2),
- createTimelineField('借贷标志', mapDebitCreditFlag(item.ext4)),
- createTimelineField('交易金额', getAmountFieldValue(item.ext6, item.metaPrimaryValue)),
- createTimelineField('对方交易名称', item.otherName),
- ];
- }
- if (tableKey === 'hotel_stay_info') {
- return [
- createTimelineField('旅店名称', item.ext2),
- createTimelineField('入住时间', item.occurredAt),
- createTimelineField('入住房号', item.ext4),
- createTimelineField('住址详情', item.ext5),
- ];
- }
- if (tableKey === 'train_ticket_info' || tableKey === 'together_train_info') {
- return [
- createTimelineField('车次', item.ext2),
- createTimelineField('出发时间', item.occurredAt),
- createTimelineField('起始站', item.ext3),
- createTimelineField('到达站', item.ext4),
- ];
- }
- if (tableKey === 'flight_ticket_info') {
- return [
- createTimelineField('票号', item.ext2),
- createTimelineField('出发时间', item.occurredAt),
- createTimelineField('起飞机场/代码', item.ext3),
- createTimelineField('到达机场/代码', item.ext4),
- ];
- }
- if (tableKey === 'flight_departure_info' || tableKey === 'together_flight_info') {
- return [
- createTimelineField('航班号', item.ext2),
- createTimelineField('出发时间', item.occurredAt),
- createTimelineField('起飞机场/代码', item.ext3),
- createTimelineField('到达机场/代码', item.ext4),
- ];
- }
- if (tableKey === 'track_car_ticket_info') {
- return [
- createTimelineField('班次号', item.ext2),
- createTimelineField('发车时间', item.occurredAt),
- createTimelineField('始发车站', item.ext3),
- createTimelineField('目的地', item.ext4),
- ];
- }
- if (tableKey === 'express_info') {
- return [
- createTimelineField('收件人', item.personName),
- createTimelineField('寄件人', item.otherName),
- createTimelineField('寄件时间', item.occurredAt),
- createTimelineField('收件地址', item.ext3),
- createTimelineField('寄件地址', item.ext4),
- ];
- }
- return [
- createTimelineField('位置名称', item.locationName),
- createTimelineField('数据来源', item.sourceLabel),
- createTimelineField(item.metaPrimaryLabel || '主信息', item.metaPrimaryValue),
- createTimelineField(item.metaSecondaryLabel || '次信息', item.metaSecondaryValue),
- ];
- };
- const normalizeDetailValue = (value: unknown): string => {
- if (value === null || value === undefined || value === '') return '-';
- if (Array.isArray(value)) {
- const values = value.map((item) => normalizeDetailValue(item)).filter((item) => item !== '-');
- return values.length ? values.join('、') : '-';
- }
- if (typeof value === 'object') {
- try {
- return JSON.stringify(value, null, 2);
- } catch {
- return String(value);
- }
- }
- return String(value);
- };
- const getDetailTableKey = (detailRecord?: TimeSeriesDetailDTO, item?: BehaviorEvent) => {
- if (!detailRecord && !item) return '';
- return (
- resolveTimeSeriesTableKey({
- tableNameEn: toText(detailRecord?.tableNameEn) || item?.tableNameEn,
- tableNameCn: toText(detailRecord?.tableNameCn) || item?.tableNameCn,
- } as TimeSeries) ||
- item?.tableNameEn ||
- ''
- );
- };
- const getDetailFieldLabel = (key: string, tableKey: string) => {
- const tableFieldLabel = DETAIL_TABLE_FIELD_LABEL_MAP[tableKey]?.[key];
- if (tableFieldLabel) return tableFieldLabel;
- const detailField = (DETAIL_RECORD_FIELDS_MAP[tableKey] || []).find((field) => field.key === key);
- if (detailField) return detailField.label;
- if (key in (DETAIL_EXT_FIELD_LABEL_MAP[tableKey] || {})) {
- return DETAIL_EXT_FIELD_LABEL_MAP[tableKey][key as (typeof DETAIL_EXT_KEYS)[number]] || key;
- }
- return DETAIL_FIELD_LABEL_MAP[key] || key;
- };
- const getBaseDetailFieldLabel = (key: string, tableKey: string) => {
- return DETAIL_TABLE_FIELD_LABEL_MAP[tableKey]?.[key] || DETAIL_BASE_FIELD_LABEL_MAP[key] || key;
- };
- const formatDetailFieldValue = (key: string, value: unknown, tableKey: string) => {
- const text = normalizeDetailValue(value);
- if (text === '-') return text;
- if (isCertFieldKey(key)) return maskCertNo(text);
- if (
- [
- 'seriesDateTime',
- 'callDateTime',
- 'transDateTime',
- 'transDate',
- 'inDateTime',
- 'depDateTime',
- 'flightDateTime',
- 'sendDate',
- ].includes(key)
- ) {
- return formatDateTimeText(text);
- }
- if (tableKey === 'call_record' && key === 'ext4') return mapCallDirection(text);
- if (tableKey === 'call_record' && key === 'callDirection') return mapCallDirection(text);
- if (tableKey === 'trans_record' && key === 'ext4') return mapDebitCreditFlag(text);
- if (tableKey === 'trans_record' && ['debitCreditFlag', 'transType'].includes(key)) return mapDebitCreditFlag(text);
- if (tableKey === 'trans_record' && key === 'ext6') return getAmountFieldValue(text);
- if (['transAmount', 'transBalance', 'otherBalance'].includes(key)) return formatAmount(text);
- return text;
- };
- const buildDetailRecordSnapshot = (detailRecord: TimeSeriesDetailDTO, item: BehaviorEvent): TimeSeriesDetailDTO => ({
- ...detailRecord,
- id: detailRecord.id ?? item.recordId ?? item.id,
- rowId: detailRecord.rowId ?? item.rowId,
- tableNameEn: toText(detailRecord.tableNameEn) || item.tableNameEn,
- tableNameCn: toText(detailRecord.tableNameCn) || item.tableNameCn,
- personName: toText(detailRecord.personName) || item.personName,
- otherName: toText(detailRecord.otherName) || item.otherName,
- seriesDateTime: toText(detailRecord.seriesDateTime) || item.occurredAt,
- ext1: toText(detailRecord.ext1) || item.ext1,
- ext2: toText(detailRecord.ext2) || item.ext2,
- ext3: toText(detailRecord.ext3) || item.ext3,
- ext4: toText(detailRecord.ext4) || item.ext4,
- ext5: toText(detailRecord.ext5) || item.ext5,
- ext6: toText(detailRecord.ext6) || item.ext6,
- ext7: toText(detailRecord.ext7) || item.ext7,
- ext8: toText(detailRecord.ext8) || item.ext8,
- ext9: toText(detailRecord.ext9) || item.ext9,
- });
- const createDetailField = (
- key: string,
- label: string,
- value: unknown,
- tableKey: string,
- formatKey = key,
- ): DetailField => {
- const formattedValue = formatDetailFieldValue(formatKey, value, tableKey);
- return {
- key,
- label,
- value: formattedValue,
- span: DETAIL_LONG_VALUE_KEYS.has(formatKey) || formattedValue.length > 26 ? 2 : 1,
- };
- };
- const toPoints = (list: BehaviorEvent[]) => {
- const pointMap = new Map<string, BehaviorEvent>();
- list.forEach((item) => {
- const key = buildMapCoordinateKey(item.lng as number, item.lat as number);
- const current = pointMap.get(key);
- if (!current) {
- pointMap.set(key, item);
- return;
- }
- const nextPriority = getMapPointPriority(item);
- const currentPriority = getMapPointPriority(current);
- if (nextPriority > currentPriority) {
- pointMap.set(key, item);
- return;
- }
- if (nextPriority === currentPriority && dayjs(item.occurredAt).isAfter(dayjs(current.occurredAt))) {
- pointMap.set(key, item);
- }
- });
- return {
- type: 'FeatureCollection',
- features: Array.from(pointMap.values()).map((item) => ({
- type: 'Feature',
- properties: {
- id: item.id,
- isActive: item.id === activeEvent.value?.id,
- behaviorType: item.behaviorType,
- personName: item.personName,
- title: item.title,
- locationName: item.locationName,
- address: item.address,
- mapLabel: resolveMapPointLabel(item),
- displayLabel: buildMapDisplayLabel(item),
- },
- geometry: { type: 'Point', coordinates: [item.lng, item.lat] },
- })),
- };
- };
- const toLabelPoints = () => ({
- type: 'FeatureCollection',
- features: mapLabelFeatures.value,
- });
- const createMapPinImage = async (fillColor: string, size = 26): Promise<ImageData | null> => {
- const svgMarkup = mapPinSvg.replace(/<path\b/, `<path fill="${fillColor}"`);
- const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
- const imageElement = await new Promise<HTMLImageElement>((resolve, reject) => {
- const image = new Image();
- image.onload = () => resolve(image);
- image.onerror = (error) => reject(error);
- image.src = svgDataUrl;
- });
- const pixelRatio = 2;
- const logicalWidth = size;
- const logicalHeight = size;
- const canvas = document.createElement('canvas');
- canvas.width = logicalWidth * pixelRatio;
- canvas.height = logicalHeight * pixelRatio;
- const ctx = canvas.getContext('2d');
- if (!ctx) return null;
- ctx.scale(pixelRatio, pixelRatio);
- ctx.clearRect(0, 0, logicalWidth, logicalHeight);
- ctx.drawImage(imageElement, 0, 0, logicalWidth, logicalHeight);
- return ctx.getImageData(0, 0, canvas.width, canvas.height);
- };
- const registerMapPinImages = async (instance: MapLibreMap) => {
- const imageConfig = [
- [MAP_PIN_ICON_TRACK, '#1677ff', 24],
- [MAP_PIN_ICON_TRANS, '#1677ff', 24],
- [MAP_PIN_ICON_CALL, '#1677ff', 24],
- [MAP_PIN_ICON_ACTIVE, '#e53935', 28],
- ] as const;
- for (const [id, fillColor, size] of imageConfig) {
- if (instance.hasImage(id)) {
- instance.removeImage(id);
- }
- const image = await createMapPinImage(fillColor, size);
- if (!image) continue;
- instance.addImage(id, image, { pixelRatio: 2 });
- }
- };
- const getPointPinImageExpression = () =>
- [
- 'match',
- ['get', 'behaviorType'],
- '轨迹信息',
- MAP_PIN_ICON_TRACK,
- '交易信息',
- MAP_PIN_ICON_TRANS,
- '通信信息',
- MAP_PIN_ICON_CALL,
- MAP_PIN_ICON_TRACK,
- ] as any;
- let pmtilesRegistered = false;
- let pmtilesProtocol: Protocol | null = null;
- const ensurePmtiles = () => {
- if (pmtilesRegistered) return;
- pmtilesProtocol = new Protocol();
- maplibregl.addProtocol('pmtiles', pmtilesProtocol.tile);
- pmtilesRegistered = true;
- };
- const ensureOfflinePmtilesArchive = () => {
- const filePath = getOfflineMapFilePath('china.pmtiles');
- if (!pmtilesProtocol || !filePath) return;
- if (pmtilesProtocol.get(filePath)) return;
- pmtilesProtocol.add(new PMTiles(new ElectronPmtilesSource(filePath) as any));
- };
- const getFallbackStyle = () => ({
- version: 8,
- sources: {},
- layers: [{ id: 'behavior-background', type: 'background', paint: { 'background-color': '#edf3f8' } }],
- });
- const buildExternalMapStyle = () => {
- const offlineMapDir = getOfflineMapDir();
- const pmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
- if (!offlineMapDir || !pmtilesFilePath) return null;
- return {
- version: 8,
- name: 'behavior-spacetime-external-map',
- sources: {
- 'behavior-basemap': {
- type: 'vector',
- url: `${PMTILES_PROTOCOL}${pmtilesFilePath}`,
- },
- },
- layers: [
- { id: 'behavior-background', type: 'background', paint: { 'background-color': '#f7fafc' } },
- {
- id: 'behavior-landcover',
- type: 'fill',
- source: 'behavior-basemap',
- 'source-layer': 'landcover',
- paint: { 'fill-color': '#e7efe4', 'fill-opacity': 0.8 },
- },
- {
- id: 'behavior-landuse',
- type: 'fill',
- source: 'behavior-basemap',
- 'source-layer': 'landuse',
- paint: { 'fill-color': '#eef2e7', 'fill-opacity': 0.72 },
- },
- {
- id: 'behavior-park',
- type: 'fill',
- source: 'behavior-basemap',
- 'source-layer': 'park',
- paint: { 'fill-color': '#dcefd4', 'fill-opacity': 0.76 },
- },
- {
- id: 'behavior-water',
- type: 'fill',
- source: 'behavior-basemap',
- 'source-layer': 'water',
- paint: { 'fill-color': '#cfe6ff', 'fill-opacity': 0.96 },
- },
- {
- id: 'behavior-waterway',
- type: 'line',
- source: 'behavior-basemap',
- 'source-layer': 'waterway',
- paint: { 'line-color': '#9dc8f7', 'line-width': 1.2 },
- },
- {
- id: 'behavior-boundary',
- type: 'line',
- source: 'behavior-basemap',
- 'source-layer': 'boundary',
- paint: { 'line-color': '#a7b3c2', 'line-width': 0.8, 'line-opacity': 0.7 },
- },
- {
- id: 'behavior-transportation',
- type: 'line',
- source: 'behavior-basemap',
- 'source-layer': 'transportation',
- layout: { 'line-cap': 'round', 'line-join': 'round' },
- paint: { 'line-color': '#f3c985', 'line-width': 1.1 },
- },
- {
- id: 'behavior-place-label',
- type: 'symbol',
- source: 'behavior-basemap',
- 'source-layer': 'place',
- minzoom: 3,
- filter: ['has', 'name'],
- layout: {
- 'text-field': ['coalesce', ['get', 'name:zh-Hans'], ['get', 'name:zh'], ['get', 'name']],
- 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
- 'text-size': 11,
- 'text-anchor': 'top',
- 'text-offset': [0, 0.6],
- },
- paint: {
- 'text-color': '#1f2937',
- 'text-halo-color': 'rgba(255, 255, 255, 0.98)',
- 'text-halo-width': 1.2,
- },
- },
- ],
- };
- };
- const getOfflineMapStylePath = () => getOfflineMapFilePath('behavior-spacetime-style.json');
- const readOfflineMapStyle = async () => {
- const ipcRenderer = (window as any)?.electron?.ipcRenderer;
- const filePath = getOfflineMapStylePath();
- if (!ipcRenderer?.invoke || !filePath) return '';
- try {
- return (await ipcRenderer.invoke('controller/os/readTextFile', { filePath, encoding: 'utf8' })) || '';
- } catch {
- return '';
- }
- };
- const getPublicBaseUrl = () => new URL(import.meta.env.BASE_URL || './', window.location.href).toString();
- const resolveStyleAssetUrl = (url: string, styleUrl: string) => {
- if (ABSOLUTE_URL_RE.test(url)) return toLocalMapUrl(url);
- if (url.startsWith('./') || url.startsWith('../')) return new URL(url, styleUrl).toString();
- return new URL(url.replace(/^\/+/, ''), getPublicBaseUrl()).toString();
- };
- const normalizePmtilesSources = (style: Record<string, any>, baseUrl: string, overridePmtilesUrl?: string) => {
- const sources = style?.sources;
- if (!sources || typeof sources !== 'object') return style;
- const normalized = Object.entries(sources).reduce<Record<string, any>>((acc, [key, source]) => {
- const sourceItem = source as Record<string, any>;
- if (
- !sourceItem ||
- typeof sourceItem !== 'object' ||
- typeof sourceItem.url !== 'string' ||
- !sourceItem.url.startsWith(PMTILES_PROTOCOL)
- ) {
- acc[key] = source;
- return acc;
- }
- const normalizedUrl = overridePmtilesUrl
- ? overridePmtilesUrl
- : toLocalMapUrl(resolveStyleAssetUrl(sourceItem.url.slice(PMTILES_PROTOCOL.length), baseUrl));
- acc[key] = {
- ...sourceItem,
- url: `${PMTILES_PROTOCOL}${normalizedUrl}`,
- };
- return acc;
- }, {});
- return { ...style, sources: normalized };
- };
- const resolveStyle = async () => {
- const custom = (window as BehaviorSpacetimeWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
- try {
- const offlineMapDir = getOfflineMapDir();
- const offlinePmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
- const offlineStylePath = getOfflineMapStylePath();
- if (offlineMapDir && offlinePmtilesFilePath && offlineStylePath) {
- const styleText = await readOfflineMapStyle();
- if (styleText) {
- const style = JSON.parse(styleText);
- mapMode.value = 'template';
- errorMsg.value = '';
- return normalizePmtilesSources(style as Record<string, any>, offlineStylePath, offlinePmtilesFilePath);
- }
- }
- const externalStyle = buildExternalMapStyle();
- if (externalStyle) {
- mapMode.value = 'template';
- errorMsg.value = '';
- return externalStyle;
- }
- if (custom && typeof custom === 'object') {
- mapMode.value = 'template';
- errorMsg.value = '';
- return normalizePmtilesSources(custom, window.location.href, offlinePmtilesFilePath || undefined);
- }
- const styleUrl = resolveStyleAssetUrl(String(custom), window.location.href);
- const response = await fetch(styleUrl, { cache: 'no-store' });
- if (!response.ok) throw new Error(`style request failed: ${response.status}`);
- const style = await response.json();
- mapMode.value = 'template';
- errorMsg.value = '';
- return normalizePmtilesSources(
- style as Record<string, any>,
- response.url || styleUrl,
- offlinePmtilesFilePath || undefined,
- );
- } catch (error) {
- errorMsg.value = error instanceof Error ? error.message : 'style error';
- }
- mapMode.value = 'fallback';
- return getFallbackStyle();
- };
- const updatePointSource = () => {
- if (!map.value || !mapLoaded.value) return;
- (map.value.getSource(SOURCE_ID) as any)?.setData(toPoints(locatedEvents.value) as any);
- };
- const updateLabelSource = () => {
- if (!map.value || !mapLoaded.value) return;
- (map.value.getSource(LABEL_ID) as any)?.setData(toLabelPoints() as any);
- };
- let mapLabelRestoreTimer: ReturnType<typeof setTimeout> | null = null;
- const clearMapLabelRestoreTimer = () => {
- if (mapLabelRestoreTimer !== null) {
- clearTimeout(mapLabelRestoreTimer);
- mapLabelRestoreTimer = null;
- }
- };
- const setMapLabelLayerVisibility = (visible: boolean) => {
- if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER)) return;
- map.value.setLayoutProperty(POINT_LABEL_LAYER, 'visibility', visible ? 'visible' : 'none');
- };
- const syncMapLabelLayerStyle = () => {
- if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER)) return;
- map.value.setLayoutProperty(POINT_LABEL_LAYER, 'text-size', getPointLabelTextSize(mapLabelFeatures.value.length));
- };
- const shouldShowMapLabelLayer = () => {
- if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER)) return false;
- const labelCount = mapLabelFeatures.value.length;
- return labelCount > 0;
- };
- const syncMapLabelLayerVisibility = () => {
- setMapLabelLayerVisibility(shouldShowMapLabelLayer());
- };
- const scheduleMapLabelLayerRestore = () => {
- clearMapLabelRestoreTimer();
- mapLabelRestoreTimer = setTimeout(() => {
- mapLabelRestoreTimer = null;
- syncMapLabelLayerVisibility();
- }, 120);
- };
- const updateSources = () => {
- updatePointSource();
- updateLabelSource();
- syncMapLabelLayerStyle();
- syncMapLabelLayerVisibility();
- };
- const clearHoverCardHideTimer = () => {
- if (hoverCardHideTimer !== null) {
- clearTimeout(hoverCardHideTimer);
- hoverCardHideTimer = null;
- }
- };
- const syncMapHoverCardPosition = () => {
- if (!hoverCard.value.visible || !map.value) return;
- const projected = map.value.project([hoverCard.value.lng, hoverCard.value.lat]);
- hoverCard.value.x = projected.x;
- hoverCard.value.y = projected.y - 10;
- };
- const hideMapHoverCard = () => {
- if (hoverCardLocked.value) return;
- clearHoverCardHideTimer();
- hoverCard.value.visible = false;
- };
- const lockMapHoverCard = () => {
- clearHoverCardHideTimer();
- hoverCardLocked.value = true;
- };
- const unlockMapHoverCard = () => {
- hoverCardLocked.value = false;
- hideMapHoverCard();
- };
- const scheduleHideMapHoverCard = () => {
- if (hoverCardLocked.value) return;
- clearHoverCardHideTimer();
- hoverCardHideTimer = setTimeout(() => {
- hoverCardHideTimer = null;
- if (!hoverCardLocked.value) {
- hoverCard.value.visible = false;
- }
- }, 180);
- };
- const showMapHoverCard = (event: maplibregl.MapLayerMouseEvent) => {
- const feature = event.features?.[0] as any;
- const coordinates = feature?.geometry?.coordinates as [number, number] | undefined;
- const properties = feature?.properties as Record<string, unknown> | undefined;
- if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties) {
- hideMapHoverCard();
- return;
- }
- clearHoverCardHideTimer();
- hoverCardLocked.value = false;
- const lng = Number(coordinates[0]);
- const lat = Number(coordinates[1]);
- const projectedPoint = map.value?.project([lng, lat]);
- hoverCard.value = {
- visible: true,
- x: projectedPoint?.x ?? event.point.x,
- y: (projectedPoint?.y ?? event.point.y) - 10,
- lng,
- lat,
- locationName: toText(properties.locationName),
- address: toText(properties.address) || toText(properties.displayLabel) || toText(properties.mapLabel),
- stationCode: toText(properties.address),
- coordinateText: formatMapCoordinateText(lng, lat),
- };
- };
- const handleCopyMapHoverText = (type: 'address' | 'coordinate') => {
- const text =
- type === 'coordinate'
- ? hoverCard.value.coordinateText
- : hoverCard.value.address || hoverCard.value.locationName || hoverCard.value.coordinateText;
- if (!text) {
- createMessage.warning(type === 'coordinate' ? '暂无可复制的经纬度' : '暂无可复制的位置信息');
- return;
- }
- const copied = copyTextToClipboard(text, { target: pageRootRef.value || document.body });
- if (copied) {
- createMessage.success(type === 'coordinate' ? '经纬度已复制' : '位置信息已复制');
- return;
- }
- createMessage.warning(type === 'coordinate' ? '经纬度复制失败' : '位置信息复制失败');
- };
- const fitMap = (animate = true) => {
- if (!map.value || !mapLoaded.value) return;
- if (!locatedEvents.value.length) {
- map.value.easeTo({ center: CENTER, zoom: 10.6, duration: animate ? 600 : 0 });
- return;
- }
- if (locatedEvents.value.length === 1) {
- const item = locatedEvents.value[0];
- map.value.easeTo({ center: [item.lng as number, item.lat as number], zoom: 13.6, duration: animate ? 600 : 0 });
- return;
- }
- const bounds = new maplibregl.LngLatBounds();
- locatedEvents.value.forEach((item) => bounds.extend([item.lng as number, item.lat as number]));
- map.value.fitBounds(bounds, { padding: 56, duration: animate ? 700 : 0, maxZoom: 13.5 });
- };
- const focusMap = (item?: BehaviorEvent) => {
- if (
- !item ||
- !item.hasCoordinate ||
- typeof item.lng !== 'number' ||
- typeof item.lat !== 'number' ||
- !map.value ||
- !mapLoaded.value
- )
- return;
- map.value.easeTo({ center: [item.lng, item.lat], zoom: 13.8, duration: 600 });
- };
- const initMap = async () => {
- if (!mapRef.value || map.value) return;
- ensurePmtiles();
- ensureOfflinePmtilesArchive();
- const style = await resolveStyle();
- const instance = new maplibregl.Map({
- container: mapRef.value,
- style: style as any,
- center: CENTER,
- zoom: 10.6,
- minZoom: 4,
- maxZoom: 14,
- attributionControl: false,
- crossSourceCollisions: false,
- fadeDuration: 0,
- renderWorldCopies: false,
- });
- map.value = instance;
- instance.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
- instance.on('load', () => {
- mapLoaded.value = true;
- void (async () => {
- try {
- await registerMapPinImages(instance);
- instance.addSource(SOURCE_ID, {
- type: 'geojson',
- data: toPoints(locatedEvents.value) as any,
- cluster: true,
- clusterRadius: POINT_CLUSTER_RADIUS,
- clusterMaxZoom: POINT_CLUSTER_MAX_ZOOM,
- });
- instance.addSource(LABEL_ID, {
- type: 'geojson',
- data: toLabelPoints() as any,
- });
- instance.addLayer({
- id: CLUSTER_LAYER,
- type: 'circle',
- source: SOURCE_ID,
- filter: ['has', 'point_count'],
- paint: {
- 'circle-radius': ['step', ['get', 'point_count'], 17, 6, 20, 12, 23, 20, 26],
- 'circle-color': '#2563eb',
- 'circle-stroke-color': '#ffffff',
- 'circle-stroke-width': 2.5,
- 'circle-opacity': 0.96,
- },
- });
- instance.addLayer({
- id: CLUSTER_COUNT_LAYER,
- type: 'symbol',
- source: SOURCE_ID,
- filter: ['has', 'point_count'],
- layout: {
- 'text-field': ['to-string', ['get', 'point_count']],
- 'text-size': 11,
- 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
- 'text-allow-overlap': true,
- 'text-ignore-placement': true,
- },
- paint: {
- 'text-color': '#ffffff',
- },
- });
- instance.addLayer({
- id: POINT_LAYER,
- type: 'symbol',
- source: SOURCE_ID,
- filter: ['!', ['has', 'point_count']],
- layout: {
- 'icon-image': [
- 'case',
- ['boolean', ['get', 'isActive'], false],
- MAP_PIN_ICON_ACTIVE,
- getPointPinImageExpression(),
- ],
- 'icon-size': ['interpolate', ['linear'], ['zoom'], 4, 1.52, 10.6, 1.76, 14, 1.92],
- 'icon-anchor': 'bottom',
- 'icon-allow-overlap': true,
- 'icon-ignore-placement': true,
- },
- });
- instance.addLayer({
- id: POINT_LABEL_LAYER,
- type: 'symbol',
- source: SOURCE_ID,
- filter: ['!', ['has', 'point_count']],
- layout: {
- 'text-field': [
- 'coalesce',
- ['get', 'displayLabel'],
- ['get', 'mapLabel'],
- ['get', 'locationName'],
- ['get', 'address'],
- '',
- ],
- 'text-size': getPointLabelTextSize(mapLabelFeatures.value.length),
- 'text-offset': [0, 0.58],
- 'text-anchor': 'top',
- 'text-justify': 'center',
- 'text-line-height': 1.22,
- 'text-max-width': 18,
- 'text-allow-overlap': true,
- 'text-ignore-placement': true,
- 'text-optional': true,
- 'text-padding': 2,
- },
- paint: {
- 'text-color': '#0f172a',
- 'text-halo-color': 'rgba(255, 255, 255, 0.96)',
- 'text-halo-width': 1.5,
- },
- });
- updateSources();
- fitMap(false);
- syncMapLabelLayerVisibility();
- setTimeout(() => instance.resize(), 0);
- } catch (error) {
- console.error('行为时空地图图标加载失败', error);
- createMessage.warning('地图点位图标加载失败');
- }
- })();
- });
- instance.on('movestart', () => {
- clearMapLabelRestoreTimer();
- setMapLabelLayerVisibility(false);
- hideMapHoverCard();
- });
- instance.on('moveend', () => {
- scheduleMapLabelLayerRestore();
- });
- const handleClusterClick = (event: maplibregl.MapLayerMouseEvent) => {
- const clusterFeature = event.features?.[0] as any;
- const clusterId = clusterFeature?.properties?.cluster_id;
- const pointCount = Number(clusterFeature?.properties?.point_count || 0);
- const clusterCoordinates = clusterFeature?.geometry?.coordinates as [number, number] | undefined;
- if (clusterId === undefined || !clusterCoordinates) return;
- const source = instance.getSource(SOURCE_ID) as any;
- if (!source?.getClusterExpansionZoom || !source?.getClusterLeaves) return;
- void (async () => {
- try {
- const leaves = (await source.getClusterLeaves(clusterId, Math.max(pointCount, 1), 0)) as Array<any>;
- const coordinates = leaves
- .map((feature) => feature?.geometry?.coordinates)
- .filter(
- (coords): coords is [number, number] =>
- Array.isArray(coords) &&
- coords.length >= 2 &&
- typeof coords[0] === 'number' &&
- typeof coords[1] === 'number',
- );
- const uniqueCoordinateKeys = new Set(
- coordinates.map((coords) => `${coords[0].toFixed(6)}:${coords[1].toFixed(6)}`),
- );
- if (coordinates.length > 1 && uniqueCoordinateKeys.size > 1) {
- const bounds = new maplibregl.LngLatBounds();
- coordinates.forEach((coords) => bounds.extend(coords));
- instance.fitBounds(bounds, {
- padding: 96,
- duration: 420,
- maxZoom: 14,
- });
- return;
- }
- const zoom = await source.getClusterExpansionZoom(clusterId);
- instance.easeTo({
- center: coordinates[0] || clusterCoordinates,
- zoom: Math.min(Math.max(zoom, POINT_CLUSTER_MAX_ZOOM + 1.2), 14),
- duration: 420,
- });
- } catch {
- const zoom = await source.getClusterExpansionZoom(clusterId);
- instance.easeTo({
- center: clusterCoordinates,
- zoom: Math.min(Math.max(zoom, POINT_CLUSTER_MAX_ZOOM + 1.2), 14),
- duration: 420,
- });
- }
- })();
- };
- instance.on('click', CLUSTER_LAYER, handleClusterClick);
- instance.on('click', CLUSTER_COUNT_LAYER, handleClusterClick);
- instance.on('click', POINT_LAYER, (event) => {
- const targetId = (event.features?.[0] as any)?.properties?.id;
- const target = filteredEvents.value.find((item) => item.id === targetId);
- if (target) handleSelect(target, false);
- });
- instance.on('click', POINT_LABEL_LAYER, (event) => {
- const targetId = (event.features?.[0] as any)?.properties?.id;
- const target = filteredEvents.value.find((item) => item.id === targetId);
- if (target) handleSelect(target, false);
- });
- instance.on('mouseenter', CLUSTER_LAYER, () => (instance.getCanvas().style.cursor = 'pointer'));
- instance.on('mouseleave', CLUSTER_LAYER, () => (instance.getCanvas().style.cursor = ''));
- instance.on('mouseenter', CLUSTER_COUNT_LAYER, () => (instance.getCanvas().style.cursor = 'pointer'));
- instance.on('mouseleave', CLUSTER_COUNT_LAYER, () => (instance.getCanvas().style.cursor = ''));
- instance.on('mouseenter', POINT_LAYER, (event) => {
- instance.getCanvas().style.cursor = 'pointer';
- showMapHoverCard(event);
- });
- instance.on('mousemove', POINT_LAYER, showMapHoverCard);
- instance.on('mouseleave', POINT_LAYER, () => {
- instance.getCanvas().style.cursor = '';
- scheduleHideMapHoverCard();
- });
- instance.on('mouseenter', POINT_LABEL_LAYER, (event) => {
- instance.getCanvas().style.cursor = 'pointer';
- showMapHoverCard(event);
- });
- instance.on('mousemove', POINT_LABEL_LAYER, showMapHoverCard);
- instance.on('mouseleave', POINT_LABEL_LAYER, () => {
- instance.getCanvas().style.cursor = '';
- scheduleHideMapHoverCard();
- });
- instance.on('move', syncMapHoverCardPosition);
- instance.on('error', (event) => {
- errorMsg.value = event.error?.message || 'map error';
- });
- };
- const recreateMap = async () => {
- mapLoaded.value = false;
- errorMsg.value = '';
- clearMapLabelRestoreTimer();
- clearHoverCardHideTimer();
- hideMapHoverCard();
- map.value?.remove();
- map.value = null;
- await nextTick();
- await initMap();
- const instance = map.value as MapLibreMap | null;
- if (instance !== null) instance.resize();
- };
- const handleOfflineMapDirChanged = () => {
- void recreateMap();
- };
- const loadTargets = async () => {
- try {
- const persons = await getPersons();
- targetOptions.value = (Array.isArray(persons) ? persons : [])
- .filter((item) => toText(item.personName))
- .map((item) => ({
- label: toText(item.personName),
- value: toText(item.personName),
- identityCode: toText(item.personCertNo) || undefined,
- photograph: toText(item.photograph) || '',
- }));
- } catch {
- targetOptions.value = [];
- }
- };
- const buildTimeSeriesQuery = (snapshot: BehaviorFilters, page = 1): TimeSeriesQuery => {
- const personNames = snapshot.checkedTargets.length ? snapshot.checkedTargets : undefined;
- const types = toBehaviorQueryTypes(snapshot.behaviorTypes);
- return {
- hasLocal: snapshot.locatedOnly === 'located' ? 'Y' : 'N',
- page,
- limit: REMOTE_QUERY_LIMIT,
- orderKey: 'seriesDateTime',
- startDate: snapshot.dateRange?.[0] || undefined,
- endDate: snapshot.dateRange?.[1] || undefined,
- personNames,
- types: types.length ? types : undefined,
- keyword: snapshot.keyword.trim() || undefined,
- };
- };
- const buildTimeSeriesCursorQuery = (
- snapshot: BehaviorFilters,
- list: BehaviorEvent[],
- direction: 'GT' | 'LT' = 'GT',
- ): TimeSeriesQuery => {
- const baseQuery = buildTimeSeriesQuery(snapshot, 1);
- return {
- ...baseQuery,
- rowId: getTimeSeriesCursorRowId(list, direction),
- rowIdSymbol: direction,
- page: undefined,
- };
- };
- const buildJumpTimeSeriesQuery = (
- payload: BehaviorSpacetimeJumpPayload,
- snapshot: BehaviorFilters,
- page = 1,
- ): TimeSeriesQuery => {
- const baseQuery = buildTimeSeriesJumpQuery(payload);
- const types = toBehaviorQueryTypes(snapshot.behaviorTypes);
- return {
- ...baseQuery,
- page,
- limit: REMOTE_QUERY_LIMIT,
- orderKey: 'seriesDateTime',
- startDate: snapshot.dateRange?.[0] || undefined,
- endDate: snapshot.dateRange?.[1] || undefined,
- types: types.length ? types : baseQuery.types,
- keyword: snapshot.keyword.trim() || baseQuery.keyword,
- };
- };
- const buildJumpTimeSeriesCursorQuery = (
- payload: BehaviorSpacetimeJumpPayload,
- snapshot: BehaviorFilters,
- list: BehaviorEvent[],
- direction: 'GT' | 'LT' = 'GT',
- ): TimeSeriesQuery => {
- const baseQuery = buildJumpTimeSeriesQuery(payload, snapshot, 1);
- return {
- ...baseQuery,
- rowId: getTimeSeriesCursorRowId(list, direction),
- rowIdSymbol: direction,
- page: undefined,
- };
- };
- const appendRemoteEvents = (baseList: BehaviorEvent[], nextList: BehaviorEvent[]) =>
- [...baseList, ...nextList].sort((left, right) => getTimeStamp(left.occurredAt) - getTimeStamp(right.occurredAt));
- const upsertRemoteEventDetail = (detailEvent: BehaviorEvent) => {
- if (!remoteEvents.value.some((item) => item.id === detailEvent.id)) return;
- let changed = false;
- const nextList = remoteEvents.value.map((item) => {
- if (item.id !== detailEvent.id) return item;
- const merged = { ...item, ...detailEvent };
- const isSame = Object.keys(merged).every((key) => merged[key] === item[key]);
- if (isSame) return item;
- changed = true;
- return merged;
- });
- if (changed) remoteEvents.value = nextList;
- };
- const applyTimeSeriesListResponse = (resp: any, page: number, append = false, direction: 'GT' | 'LT' = 'GT') => {
- const list = normalizeTimeSeriesList(resp);
- const normalizedList = list
- .map((item, index) => normalizeTimeSeriesToBehaviorEvent(item, (page - 1) * REMOTE_QUERY_LIMIT + index))
- .map((item) => detailCache.get(item.id) || item)
- .sort((left, right) => getTimeStamp(left.occurredAt) - getTimeStamp(right.occurredAt));
- remoteEvents.value = append ? appendRemoteEvents(remoteEvents.value, normalizedList) : normalizedList;
- remoteTotal.value = normalizeTimeSeriesTotal(resp, remoteEvents.value.length);
- timelinePage.value = page;
- if (!append) {
- timelineCanLoadForward.value = list.length >= REMOTE_QUERY_LIMIT;
- timelineCanLoadBackward.value = false;
- } else if (direction === 'LT') {
- timelineCanLoadBackward.value = list.length >= REMOTE_QUERY_LIMIT;
- } else {
- timelineCanLoadForward.value = list.length >= REMOTE_QUERY_LIMIT;
- }
- if (remoteTotal.value > 0 && remoteEvents.value.length >= remoteTotal.value) {
- timelineCanLoadForward.value = false;
- timelineCanLoadBackward.value = false;
- }
- timelineHasMore.value = timelineCanLoadForward.value || timelineCanLoadBackward.value;
- timelineListInitialized.value = true;
- return true;
- };
- const fetchTimeSeriesEventDetail = async (item: BehaviorEvent) => {
- const cachedEvent = detailCache.get(item.id);
- if (cachedEvent) {
- upsertRemoteEventDetail(cachedEvent);
- }
- const query = buildTimeSeriesDetailQuery(item);
- if (!query.id && !query.rowId) {
- detailLoading.value = false;
- detailError.value = '当前节点缺少详情主键,无法查询接口详情';
- return;
- }
- const requestToken = ++detailRequestToken;
- detailLoading.value = true;
- detailError.value = '';
- try {
- const resp = await timeSeriesDetail(query);
- if (requestToken !== detailRequestToken || selectedId.value !== item.id) return;
- const { record: detailRecord, head } = normalizeTimeSeriesDetailPayload(resp);
- if (!detailRecord) {
- detailError.value = '详情接口未返回有效数据';
- return;
- }
- const merged = mergeTimeSeriesDetailToBehaviorEvent(item, detailRecord);
- detailCache.set(item.id, merged);
- detailRecordCache.value[item.id] = detailRecord;
- detailHeadCache.value[item.id] = head;
- upsertRemoteEventDetail(merged);
- } catch {
- detailError.value = '详情接口请求失败,请稍后重试';
- } finally {
- if (requestToken === detailRequestToken && selectedId.value === item.id) {
- detailLoading.value = false;
- }
- }
- };
- const hydrateJumpAnchorEvent = async (item: BehaviorEvent) => {
- const query = buildTimeSeriesDetailQuery(item);
- if (!query.id && !query.rowId) return;
- const requestToken = ++detailRequestToken;
- detailLoading.value = true;
- detailError.value = '';
- try {
- const resp = await timeSeriesDetail(query);
- if (requestToken !== detailRequestToken || selectedId.value !== item.id) return;
- const { record: detailRecord, head } = normalizeTimeSeriesDetailPayload(resp);
- if (!detailRecord) {
- detailError.value = '详情接口未返回有效数据';
- return;
- }
- const detailTimeSeries = toTimeSeriesDetailRecord(detailRecord, item);
- const normalized = normalizeTimeSeriesToBehaviorEvent(detailTimeSeries, 0);
- const rawTableNameEn = toText(detailRecord.tableNameEn) || item.rawTableNameEn || item.tableNameEn;
- const mergedLng = typeof normalized.lng === 'number' ? normalized.lng : item.lng;
- const mergedLat = typeof normalized.lat === 'number' ? normalized.lat : item.lat;
- const mergedHasCoordinate = typeof mergedLng === 'number' && typeof mergedLat === 'number';
- const merged = {
- ...item,
- ...normalized,
- id: item.id,
- rowId: toText(detailRecord.rowId ?? detailRecord.id) || item.rowId,
- recordId: toText(detailRecord.id ?? detailRecord.rowId) || item.recordId,
- rawTableNameEn: rawTableNameEn || undefined,
- tableNameEn: normalized.tableNameEn || item.tableNameEn,
- tableNameCn: toText(detailRecord.tableNameCn) || normalized.tableNameCn || item.tableNameCn,
- lng: mergedLng,
- lat: mergedLat,
- hasCoordinate: mergedHasCoordinate,
- } as BehaviorEvent;
- detailCache.set(item.id, merged);
- detailRecordCache.value[item.id] = detailRecord;
- detailHeadCache.value[item.id] = head;
- remoteEvents.value = remoteEvents.value.map((eventItem) => (eventItem.id === item.id ? merged : eventItem));
- } catch {
- detailError.value = '详情接口请求失败,请稍后重试';
- throw new Error('jump-anchor-detail-failed');
- } finally {
- if (requestToken === detailRequestToken && selectedId.value === item.id) {
- detailLoading.value = false;
- }
- }
- };
- const fetchTimeSeriesEvents = async (snapshot: BehaviorFilters, page = 1, append = false, requestToken?: number) => {
- const query = append
- ? buildTimeSeriesCursorQuery(snapshot, remoteEvents.value, 'GT')
- : buildTimeSeriesQuery(snapshot, page);
- const resp = await timeSeriesList(query);
- if (requestToken !== undefined && requestToken !== timelineRequestToken) return false;
- return applyTimeSeriesListResponse(resp, page, append);
- };
- const fetchJumpTimeSeriesEvents = async (
- payload: BehaviorSpacetimeJumpPayload,
- snapshot: BehaviorFilters,
- page = 1,
- append = false,
- requestToken?: number,
- direction: 'GT' | 'LT' = 'GT',
- ) => {
- const query = append
- ? buildJumpTimeSeriesCursorQuery(payload, snapshot, remoteEvents.value, direction)
- : buildJumpTimeSeriesQuery(payload, snapshot, page);
- const resp = await timeSeriesList(query);
- if (requestToken !== undefined && requestToken !== timelineRequestToken) return false;
- const applied = applyTimeSeriesListResponse(resp, page, append, direction);
- if (!append && remoteEvents.value.length) {
- timelineCanLoadForward.value = true;
- timelineCanLoadBackward.value = true;
- timelineHasMore.value = true;
- }
- return applied;
- };
- const applyJumpTimeSeriesResponse = (resp: any, payload: BehaviorSpacetimeJumpPayload, page = 1, append = false) => {
- const applied = applyTimeSeriesListResponse(resp, page, append);
- if (!applied || append || remoteEvents.value.length) return applied;
- const anchorEvent = buildJumpAnchorEvent(payload);
- if (anchorEvent) {
- remoteEvents.value = [anchorEvent];
- remoteTotal.value = Math.max(remoteTotal.value, 1);
- timelineCanLoadForward.value = true;
- timelineCanLoadBackward.value = true;
- timelineHasMore.value = true;
- timelineListInitialized.value = false;
- selectedId.value = anchorEvent.id;
- }
- return applied;
- };
- const clearBehaviorData = (snapshot?: BehaviorFilters, preserveJump = false) => {
- const nextFilters = snapshot ? cloneFilters(snapshot) : cloneFilters(filters.value);
- applied.value = nextFilters;
- if (!preserveJump) {
- activeJumpPayload.value = null;
- }
- remoteEvents.value = [];
- remoteTotal.value = 0;
- timelinePage.value = 1;
- timelineHasMore.value = false;
- timelineCanLoadForward.value = false;
- timelineCanLoadBackward.value = false;
- timelineMoreLoading.value = false;
- timelineListInitialized.value = false;
- timelineRequestToken += 1;
- selectedId.value = '';
- clearPendingTimelineScroll();
- detailCache.clear();
- detailRecordCache.value = {};
- detailHeadCache.value = {};
- detailDrawerOpen.value = false;
- detailLoading.value = false;
- detailError.value = '';
- detailRequestToken += 1;
- };
- const applyJumpAnchorEvent = (payload: BehaviorSpacetimeJumpPayload) => {
- const anchorEvent = buildJumpAnchorEvent(payload);
- if (!anchorEvent) return undefined;
- remoteEvents.value = [anchorEvent];
- remoteTotal.value = Math.max(remoteTotal.value, 1);
- selectedId.value = anchorEvent.id;
- return anchorEvent;
- };
- const collectComparableIds = (...values: Array<unknown>) => {
- const idSet = new Set<string>();
- values.forEach((value) => {
- if (typeof value !== 'string' && typeof value !== 'number') return;
- const text = toText(value);
- if (!text) return;
- idSet.add(text);
- const suffix = text.split('-').pop();
- if (suffix && suffix !== text) {
- idSet.add(suffix);
- }
- });
- return idSet;
- };
- const getNormalizedEventTableName = (item: BehaviorEvent) => {
- return normalizeTimeSeriesTableNameEn(item.rawTableNameEn || item.tableNameEn);
- };
- const syncCurrentTabCachePolicy = async () => {
- delete route.meta?.ignoreKeepAlive;
- await tabStore.addTab(route as RouteLocationNormalized);
- await tabStore.updateCacheTab();
- };
- const clearTimelineScrollRetry = () => {
- if (timelineScrollRetryTimer !== null) {
- clearTimeout(timelineScrollRetryTimer);
- timelineScrollRetryTimer = null;
- }
- };
- const isTimelineProgrammaticScrolling = () => Date.now() < timelineProgrammaticScrollUntil;
- const clearPendingTimelineScroll = () => {
- pendingTimelineScrollId.value = '';
- pendingTimelineScrollAttempts.value = 0;
- pendingTimelineScrollBehavior.value = 'auto';
- clearTimelineScrollRetry();
- };
- const markTimelineProgrammaticScroll = (duration = 480) => {
- timelineProgrammaticScrollUntil = Date.now() + duration;
- };
- const getTimelineTargetElement = (id: string) => {
- const container = timelineListRef.value;
- if (!container || !id) return null;
- return (Array.from(container.querySelectorAll('.timeline-item')).find(
- (element) => element.getAttribute('data-timeline-id') === id,
- ) || null) as HTMLElement | null;
- };
- const queueTimelineScroll = (id?: string | null, behavior: ScrollBehavior = 'auto', attempts = 6) => {
- const targetId = toText(id);
- if (!targetId) {
- clearPendingTimelineScroll();
- return;
- }
- pendingTimelineScrollId.value = targetId;
- pendingTimelineScrollBehavior.value = behavior;
- pendingTimelineScrollAttempts.value = Math.max(1, attempts);
- };
- const resetPageScrollPosition = (options?: { resetTimeline?: boolean }) => {
- const { resetTimeline = true } = options || {};
- nextTick(() => {
- pageContentRef.value?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
- if (resetTimeline) {
- timelineListRef.value?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
- }
- const layoutContent = pageRootRef.value?.closest('.jeesite-layout-content') as HTMLElement | null;
- layoutContent?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
- });
- };
- const applyRouteJumpPayload = async (payload: BehaviorSpacetimeJumpPayload) => {
- const nextFilters = buildFiltersFromJumpPayload(payload);
- const requestSignature = buildJumpRequestSignature(payload, nextFilters);
- if (
- requestSignature &&
- appliedJumpRequestSignature.value === requestSignature &&
- remoteEvents.value.length
- ) {
- activeJumpPayload.value = buildTimeSeriesJumpQuery(payload);
- syncSelectionFromJumpPayload(payload);
- resetPageScrollPosition({ resetTimeline: false });
- return;
- }
- if (restoreBehaviorSpacetimePageCacheForJumpPayload(payload)) {
- syncSelectionFromJumpPayload(payload);
- resetPageScrollPosition({ resetTimeline: false });
- return;
- }
- resetBehaviorSpacetimeJumpState(nextFilters);
- activeJumpPayload.value = payload;
- if (
- requestSignature &&
- (runningJumpRequestSignature.value === requestSignature || appliedJumpRequestSignature.value === requestSignature)
- ) {
- syncSelectionFromJumpPayload(payload);
- resetPageScrollPosition({ resetTimeline: false });
- return;
- }
- runningJumpRequestSignature.value = requestSignature;
- timelineRequestToken += 1;
- const requestToken = timelineRequestToken;
- appliedJumpRequestSignature.value = requestSignature;
- resetPageScrollPosition({ resetTimeline: false });
- await runWithQueryLoading(async () => {
- try {
- const cachedJumpResp = consumeBehaviorSpacetimeJumpResult();
- if (cachedJumpResp) {
- applyJumpTimeSeriesResponse(cachedJumpResp, payload, 1, false);
- } else {
- const anchorEvent = applyJumpAnchorEvent(payload);
- if (anchorEvent) {
- await hydrateJumpAnchorEvent(anchorEvent);
- }
- }
- syncSelectionFromJumpPayload(payload);
- } catch {
- clearBehaviorData(nextFilters, true);
- applied.value = cloneFilters(nextFilters);
- activeJumpPayload.value = payload;
- if (runningJumpRequestSignature.value === requestSignature) {
- runningJumpRequestSignature.value = '';
- }
- resetPageScrollPosition({ resetTimeline: false });
- createMessage.error('行为时空跳转明细加载失败,请检查 /otg/timeSeries/detailTimeSeries 联调状态。');
- return;
- }
- });
- if (runningJumpRequestSignature.value === requestSignature) {
- runningJumpRequestSignature.value = '';
- }
- };
- const handleSearch = async () => {
- const nextFilters = cloneFilters(filters.value);
- nextFilters.targets = normalizeTargetList(nextFilters.targets);
- nextFilters.checkedTargets = normalizeTargetList(nextFilters.checkedTargets).filter((item) =>
- nextFilters.targets.includes(item),
- );
- const jumpPayload = !nextFilters.checkedTargets.length ? activeJumpPayload.value : null;
- const requestToken = ++timelineRequestToken;
- timelinePage.value = 1;
- timelineHasMore.value = false;
- timelineCanLoadForward.value = false;
- timelineCanLoadBackward.value = false;
- timelineMoreLoading.value = false;
- timelineListInitialized.value = false;
- if (!nextFilters.checkedTargets.length && !jumpPayload) {
- clearBehaviorData(nextFilters);
- resetPageScrollPosition();
- return;
- }
- await runWithQueryLoading(async () => {
- try {
- if (nextFilters.checkedTargets.length) {
- activeJumpPayload.value = null;
- clearPendingTimelineScroll();
- await fetchTimeSeriesEvents(nextFilters, 1, false, requestToken);
- } else if (jumpPayload) {
- const cachedJumpResp = consumeBehaviorSpacetimeJumpResult();
- if (cachedJumpResp) {
- applyJumpTimeSeriesResponse(cachedJumpResp, jumpPayload, 1, false);
- } else {
- await fetchJumpTimeSeriesEvents(jumpPayload, nextFilters, 1, false, requestToken);
- }
- activeJumpPayload.value = jumpPayload;
- syncSelectionFromJumpPayload(jumpPayload);
- }
- } catch {
- clearBehaviorData(nextFilters, !!jumpPayload);
- if (jumpPayload) {
- activeJumpPayload.value = jumpPayload;
- }
- resetPageScrollPosition();
- createMessage.error(
- jumpPayload
- ? '行为时空跳转数据加载失败,请检查 /otg/timeSeries/jump 与 /otg/timeSeries/list 联调状态。'
- : '行为时间轴接口请求失败,请检查 /otg/timeSeries/list 联调状态。',
- );
- return;
- }
- applied.value = nextFilters;
- syncAnalysisDataRefreshState(
- remoteEvents.value.length > 0 && (!!nextFilters.checkedTargets.length || !!jumpPayload),
- );
- resetPageScrollPosition({ resetTimeline: !jumpPayload });
- });
- };
- const handleReset = async () => {
- activeJumpPayload.value = null;
- filters.value = {
- ...makeFilters(),
- targets: [...displayTargets.value],
- checkedTargets: [...checkedTargets.value],
- };
- targetKeyword.value = '';
- await handleSearch();
- };
- const openTargetPicker = () => {
- targetDraft.value = [...displayTargets.value];
- targetKeyword.value = '';
- targetPickerOpen.value = true;
- };
- const closeTargetPicker = () => {
- targetPickerOpen.value = false;
- targetKeyword.value = '';
- };
- const toggleDraftTarget = (target: string, checked: boolean) => {
- if (checked) {
- if (!targetDraft.value.includes(target)) {
- targetDraft.value = [...targetDraft.value, target];
- }
- return;
- }
- targetDraft.value = targetDraft.value.filter((item) => item !== target);
- };
- const toggleDraftAll = (event: CheckboxChangeEvent) => {
- if (event.target.checked) {
- targetDraft.value = availableTargetOptions.value.map((item) => item.value);
- return;
- }
- targetDraft.value = [];
- };
- const confirmTargetPicker = async () => {
- if (!targetDraft.value.length) {
- createMessage.warning('请至少选择一个对象');
- return;
- }
- const nextTargets = normalizeTargetList(targetDraft.value);
- filters.value.targets = nextTargets;
- filters.value.checkedTargets = [...nextTargets];
- closeTargetPicker();
- await handleSearch();
- };
- const removeTarget = async (target: string) => {
- filters.value.targets = displayTargets.value.filter((item) => item !== target);
- filters.value.checkedTargets = checkedTargets.value.filter((item) => item !== target);
- await handleSearch();
- };
- const handleToggleTargetChecked = async (target: string, checked: boolean) => {
- if (!displayTargets.value.includes(target)) return;
- const nextCheckedTargets = checked
- ? normalizeTargetList([...checkedTargets.value, target])
- : checkedTargets.value.filter((item) => item !== target);
- if (nextCheckedTargets.length === checkedTargets.value.length && checked) return;
- if (!checked && !checkedTargetSet.value.has(target)) return;
- filters.value.checkedTargets = nextCheckedTargets;
- await handleSearch();
- };
- const findEventByJumpPayload = (payload?: BehaviorSpacetimeJumpPayload | null) => {
- if (!payload) return undefined;
- const targetIds = collectComparableIds(payload.id, payload.rowId, payload.id || payload.rowId);
- const targetTableName = normalizeTimeSeriesTableNameEn(payload.tableNameEn || payload.tableName);
- const payloadOccurredAt = formatDateTimeText(payload.seriesDateTime);
- const payloadPersonName = toText(payload.personName);
- const matchesIds = (item: BehaviorEvent) => {
- if (!targetIds.size) return false;
- const itemIds = collectComparableIds(item.recordId, item.rowId, item.id);
- return Array.from(targetIds).some((id) => itemIds.has(id));
- };
- const matchedByTableAndId = filteredEvents.value.find((item) => {
- if (!matchesIds(item)) return false;
- if (!targetTableName) return true;
- return getNormalizedEventTableName(item) === targetTableName;
- });
- if (matchedByTableAndId) return matchedByTableAndId;
- const matchedByIdOnly = filteredEvents.value.find((item) => matchesIds(item));
- if (matchedByIdOnly) return matchedByIdOnly;
- if (!payloadOccurredAt && !payloadPersonName) return undefined;
- return filteredEvents.value.find((item) => {
- const sameTime = payloadOccurredAt && item.occurredAt === payloadOccurredAt;
- const samePerson = payloadPersonName && item.personName === payloadPersonName;
- const sameTable = !targetTableName || getNormalizedEventTableName(item) === targetTableName;
- return sameTable && Boolean(sameTime || samePerson);
- });
- };
- const flushPendingTimelineScroll = () => {
- const targetId = pendingTimelineScrollId.value;
- const behavior = pendingTimelineScrollBehavior.value;
- const attempts = pendingTimelineScrollAttempts.value;
- if (!targetId || attempts <= 0) return;
- if (timelineMoreLoading.value) return;
- clearTimelineScrollRetry();
- nextTick(() => {
- window.requestAnimationFrame(() => {
- const container = timelineListRef.value;
- const target = getTimelineTargetElement(targetId);
- if (!container || !target) {
- if (pendingTimelineScrollId.value !== targetId) return;
- if (attempts <= 1) {
- clearPendingTimelineScroll();
- return;
- }
- pendingTimelineScrollAttempts.value = attempts - 1;
- timelineScrollRetryTimer = window.setTimeout(() => {
- timelineScrollRetryTimer = null;
- flushPendingTimelineScroll();
- }, 120);
- return;
- }
- markTimelineProgrammaticScroll();
- target.scrollIntoView({ block: 'center', inline: 'nearest', behavior });
- window.requestAnimationFrame(() => {
- const latestContainer = timelineListRef.value;
- const latestTarget = getTimelineTargetElement(targetId);
- if (!latestContainer || !latestTarget || pendingTimelineScrollId.value !== targetId) return;
- const containerRect = latestContainer.getBoundingClientRect();
- const targetRect = latestTarget.getBoundingClientRect();
- const safePadding = 16;
- const minTop = containerRect.top + safePadding;
- const maxBottom = containerRect.bottom - safePadding;
- let corrected = false;
- if (targetRect.top < minTop) {
- const nextTop = latestContainer.scrollTop - (minTop - targetRect.top);
- latestContainer.scrollTo({ top: Math.max(0, nextTop), behavior });
- corrected = true;
- }
- if (targetRect.bottom > maxBottom) {
- const nextTop = latestContainer.scrollTop + (targetRect.bottom - maxBottom);
- latestContainer.scrollTo({ top: Math.max(0, nextTop), behavior });
- corrected = true;
- }
- if (corrected) {
- markTimelineProgrammaticScroll();
- }
- const latestRect = latestTarget.getBoundingClientRect();
- const fullyVisible = latestRect.top >= minTop && latestRect.bottom <= maxBottom;
- if (fullyVisible) {
- clearPendingTimelineScroll();
- return;
- }
- if (attempts <= 1) {
- clearPendingTimelineScroll();
- return;
- }
- pendingTimelineScrollAttempts.value = attempts - 1;
- timelineScrollRetryTimer = window.setTimeout(
- () => {
- timelineScrollRetryTimer = null;
- flushPendingTimelineScroll();
- },
- corrected ? 90 : 140,
- );
- });
- });
- });
- };
- const selectTimelineItemSilently = (item: BehaviorEvent) => {
- selectedId.value = item.id;
- detailDrawerOpen.value = false;
- detailLoading.value = false;
- detailError.value = '';
- };
- const syncSelectionFromJumpPayload = (payload?: BehaviorSpacetimeJumpPayload | null) => {
- const matched = findEventByJumpPayload(payload);
- if (!matched) return false;
- selectTimelineItemSilently(matched);
- queueTimelineScroll(matched.id, 'auto', 8);
- return true;
- };
- const handleSelect = (item: BehaviorEvent, focus = true) => {
- selectedId.value = item.id;
- detailDrawerOpen.value = true;
- detailError.value = '';
- queueTimelineScroll(item.id, 'smooth', 2);
- void fetchTimeSeriesEventDetail(item);
- if (focus) nextTick(() => focusMap(item));
- };
- const loadMoreTimeSeriesEvents = async (direction: 'GT' | 'LT' = 'GT') => {
- const canLoadByDirection = direction === 'LT' ? timelineCanLoadBackward.value : timelineCanLoadForward.value;
- if (
- (!hasAppliedCheckedTargets.value && !hasAppliedJumpPayload.value) ||
- queryLoading.value ||
- timelineMoreLoading.value ||
- hasLoadedAllRemote.value ||
- !timelineHasMore.value ||
- !canLoadByDirection
- ) {
- return;
- }
- const requestToken = timelineRequestToken;
- const snapshot = cloneFilters(applied.value);
- timelineMoreLoading.value = true;
- try {
- if (hasAppliedCheckedTargets.value) {
- await fetchTimeSeriesEvents(snapshot, timelinePage.value + 1, true, requestToken);
- } else if (activeJumpPayload.value) {
- if (!timelineListInitialized.value) {
- await fetchJumpTimeSeriesEvents(activeJumpPayload.value, snapshot, 1, false, requestToken);
- } else {
- const query = buildJumpTimeSeriesCursorQuery(
- activeJumpPayload.value,
- snapshot,
- remoteEvents.value,
- direction,
- );
- const resp = await timeSeriesList(query);
- if (requestToken !== timelineRequestToken) return;
- const nextList = normalizeTimeSeriesList(resp);
- if (nextList.length) {
- applyTimeSeriesListResponse(resp, timelinePage.value + 1, true, direction);
- } else if (direction === 'LT') {
- timelineCanLoadBackward.value = false;
- timelineHasMore.value = timelineCanLoadForward.value || timelineCanLoadBackward.value;
- } else {
- timelineCanLoadForward.value = false;
- timelineHasMore.value = timelineCanLoadForward.value || timelineCanLoadBackward.value;
- }
- }
- }
- } catch {
- createMessage.error('加载更多失败,请稍后重试。');
- } finally {
- if (requestToken === timelineRequestToken) {
- timelineMoreLoading.value = false;
- }
- }
- };
- const handleTimelineScroll = () => {
- const container = timelineListRef.value;
- if (
- !container ||
- timelineMoreLoading.value ||
- hasLoadedAllRemote.value ||
- !timelineHasMore.value ||
- isTimelineProgrammaticScrolling()
- )
- return;
- const canBidirectionalLoad = hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value;
- const reachedTop = container.scrollTop <= TIMELINE_LOAD_MORE_THRESHOLD;
- const reachedBottom =
- container.scrollTop + container.clientHeight >= container.scrollHeight - TIMELINE_LOAD_MORE_THRESHOLD;
- if (canBidirectionalLoad && reachedTop && timelineCanLoadBackward.value) {
- void loadMoreTimeSeriesEvents('LT');
- return;
- }
- if (reachedBottom && timelineCanLoadForward.value) {
- void loadMoreTimeSeriesEvents('GT');
- }
- };
- const handleTimelineWheel = (event: WheelEvent) => {
- if (
- timelineMoreLoading.value ||
- hasLoadedAllRemote.value ||
- !timelineHasMore.value ||
- isTimelineProgrammaticScrolling()
- )
- return;
- const container = timelineListRef.value;
- if (!container) return;
- const canBidirectionalLoad = hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value;
- const canScroll = container.scrollHeight - container.clientHeight > 0;
- if (event.deltaY < 0) {
- if (
- canBidirectionalLoad &&
- (!canScroll || container.scrollTop <= TIMELINE_LOAD_MORE_THRESHOLD) &&
- timelineCanLoadBackward.value
- ) {
- void loadMoreTimeSeriesEvents('LT');
- }
- return;
- }
- if (
- event.deltaY > 0 &&
- (!canScroll ||
- container.scrollTop + container.clientHeight >= container.scrollHeight - TIMELINE_LOAD_MORE_THRESHOLD) &&
- timelineCanLoadForward.value
- ) {
- void loadMoreTimeSeriesEvents('GT');
- }
- };
- const handleFit = () => fitMap();
- const handleReloadMap = async () => {
- if (mapReloading.value) return;
- mapReloading.value = true;
- try {
- await recreateMap();
- createMessage.success('离线地图已重新加载');
- } finally {
- mapReloading.value = false;
- }
- };
- const handleFocus = () => {
- if (!activeEvent.value) return;
- if (!activeEvent.value.hasCoordinate) {
- createMessage.warning('当前节点暂无精确坐标,无法在地图中定位。');
- return;
- }
- focusMap(activeEvent.value);
- };
- const handleResize = () => map.value?.resize();
- watch(
- filteredEvents,
- (list, previousList) => {
- if (!list.length) {
- selectedId.value = '';
- clearPendingTimelineScroll();
- detailDrawerOpen.value = false;
- detailLoading.value = false;
- detailError.value = '';
- detailRequestToken += 1;
- updateSources();
- fitMap(false);
- return;
- }
- const hadCurrentSelection = list.some((item) => item.id === selectedId.value);
- if (!hadCurrentSelection) {
- selectedId.value = list[0].id;
- detailDrawerOpen.value = false;
- detailLoading.value = false;
- detailError.value = '';
- }
- if (hasAppliedJumpPayload.value && !hadCurrentSelection) {
- syncSelectionFromJumpPayload(activeJumpPayload.value);
- }
- const keepCurrentViewport =
- (timelineMoreLoading.value && !!previousList?.length) || hasSameMapViewportBasis(list, previousList);
- nextTick(() => {
- updateSources();
- if (!keepCurrentViewport) fitMap(false);
- });
- },
- { immediate: true },
- );
- watch(
- activeEvent,
- (item) => {
- nextTick(() => {
- updatePointSource();
- });
- if (!item) {
- detailLoading.value = false;
- detailError.value = '';
- detailDrawerOpen.value = false;
- return;
- }
- },
- { immediate: true },
- );
- watch(
- [selectedId, timelineEntries, timelineMoreLoading],
- ([id, entries, loading]) => {
- if (!id || loading || !entries.length) return;
- if (pendingTimelineScrollId.value && pendingTimelineScrollId.value === id) {
- flushPendingTimelineScroll();
- }
- },
- { immediate: true },
- );
- watch(
- () => ({
- filters: filters.value,
- applied: applied.value,
- activeJumpPayload: activeJumpPayload.value,
- lastRouteJumpSignature: lastRouteJumpSignature.value,
- appliedJumpRequestSignature: appliedJumpRequestSignature.value,
- remoteEvents: remoteEvents.value,
- remoteTotal: remoteTotal.value,
- selectedId: selectedId.value,
- timelinePage: timelinePage.value,
- timelineHasMore: timelineHasMore.value,
- timelineCanLoadForward: timelineCanLoadForward.value,
- timelineCanLoadBackward: timelineCanLoadBackward.value,
- timelineListInitialized: timelineListInitialized.value,
- detailRecordCache: detailRecordCache.value,
- detailHeadCache: detailHeadCache.value,
- }),
- () => {
- saveBehaviorSpacetimePageCache();
- },
- { deep: true },
- );
- watch(
- () => route.query[BEHAVIOR_SPACETIME_JUMP_QUERY_KEY],
- (rawPayload) => {
- if (!isCurrentBehaviorSpacetimeRoute.value) return;
- const payload = parseBehaviorSpacetimeJumpPayload(rawPayload);
- if (!payload) {
- const hadJumpContext = Boolean(
- lastRouteJumpSignature.value ||
- runningJumpRequestSignature.value ||
- appliedJumpRequestSignature.value ||
- hasMeaningfulJumpPayload(activeJumpPayload.value),
- );
- if (hadJumpContext) {
- void exitRouteJumpContext();
- return;
- }
- lastRouteJumpSignature.value = '';
- runningJumpRequestSignature.value = '';
- appliedJumpRequestSignature.value = '';
- return;
- }
- const signature = buildRouteJumpSignature(payload);
- if (signature && signature === lastRouteJumpSignature.value) return;
- lastRouteJumpSignature.value = signature;
- void applyRouteJumpPayload(payload);
- },
- { immediate: true },
- );
- onMounted(async () => {
- window.addEventListener('resize', handleResize);
- window.addEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, handleOfflineMapDirChanged);
- timelineListRef.value?.addEventListener('wheel', handleTimelineWheel, { passive: true });
- await syncCurrentTabCachePolicy();
- const hasRouteJumpPayload = !!parseBehaviorSpacetimeJumpPayload(route.query[BEHAVIOR_SPACETIME_JUMP_QUERY_KEY]);
- if (!hasRouteJumpPayload) {
- restoreBehaviorSpacetimePageCache();
- }
- await loadTargets();
- resetPageScrollPosition({ resetTimeline: !selectedId.value });
- if (selectedId.value) {
- queueTimelineScroll(
- selectedId.value,
- hasAppliedJumpPayload.value ? 'auto' : 'smooth',
- hasAppliedJumpPayload.value ? 8 : 2,
- );
- flushPendingTimelineScroll();
- }
- void initMap();
- });
- onActivated(() => {
- void syncCurrentTabCachePolicy();
- resetPageScrollPosition({ resetTimeline: !selectedId.value });
- if (selectedId.value) {
- queueTimelineScroll(
- selectedId.value,
- hasAppliedJumpPayload.value ? 'auto' : 'smooth',
- hasAppliedJumpPayload.value ? 8 : 2,
- );
- flushPendingTimelineScroll();
- }
- });
- onBeforeUnmount(() => {
- clearTimelineScrollRetry();
- clearMapLabelRestoreTimer();
- window.removeEventListener('resize', handleResize);
- window.removeEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, handleOfflineMapDirChanged);
- timelineListRef.value?.removeEventListener('wheel', handleTimelineWheel);
- mapLoaded.value = false;
- map.value?.remove();
- map.value = null;
- });
- </script>
- <style lang="less">
- @prefix-cls: ~'jeesite-behavior-spacetime';
- .@{prefix-cls}.behavior-spacetime-page {
- .trans-analysis-table-ui();
- background: #fff;
- padding: 0 8px 2px;
- min-height: 100%;
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- box-sizing: border-box;
- .page-subtitle,
- .panel-desc,
- .panel-extra {
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- }
- .filter-bar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px;
- min-height: 40px;
- border: 0;
- border-radius: 0;
- background: transparent;
- box-shadow: none;
- flex-wrap: wrap;
- flex-shrink: 0;
- box-sizing: border-box;
- }
- .page-content {
- display: flex;
- flex-direction: column;
- gap: 0;
- }
- .target-strip-panel {
- flex: 0 0 auto;
- margin-bottom: 0;
- padding: 8px 16px;
- border: 0;
- border-radius: 0;
- background: transparent;
- }
- .target-strip {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
- flex-wrap: wrap;
- }
- .selected-targets {
- flex: 1 1 auto;
- min-width: 0;
- min-height: 24px;
- display: flex;
- align-items: center;
- align-content: center;
- flex-wrap: wrap;
- gap: 10px 18px;
- }
- .selected-targets__empty {
- color: #9ca3af;
- font-size: 13px;
- line-height: 20px;
- }
- .selected-target {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- min-height: 24px;
- color: #111827;
- font-size: 14px;
- line-height: 20px;
- }
- .selected-target__checkbox {
- flex: 0 0 auto;
- line-height: 1;
- }
- .selected-target__label {
- white-space: nowrap;
- }
- .selected-target__remove {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 18px;
- height: 18px;
- padding: 0;
- border: 0;
- border-radius: 50%;
- background: transparent;
- color: #6b7280;
- cursor: pointer;
- &:hover {
- color: #374151;
- }
- }
- .add-target-btn.ant-btn {
- min-width: 112px;
- height: 30px;
- border-radius: 4px;
- box-shadow: none;
- flex: 0 0 auto;
- }
- .filter-item {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- }
- .filter-label {
- color: #4b5563;
- font-size: 12px;
- white-space: nowrap;
- }
- .filter-input {
- width: 168px;
- }
- .filter-type {
- width: 320px;
- }
- .filter-date--compact {
- width: 280px;
- }
- .filter-actions,
- .map-actions {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
- }
- .filter-actions {
- margin-left: auto;
- }
- .filter-located-check {
- color: #374151;
- font-size: 13px;
- line-height: 20px;
- }
- .content-panel {
- flex: 1 1 auto;
- min-height: 0;
- display: grid;
- grid-template-columns: minmax(460px, 6fr) minmax(340px, 4fr);
- gap: 2px;
- align-items: stretch;
- overflow: hidden;
- padding: 0;
- background: transparent;
- box-sizing: border-box;
- }
- .target-strip__label {
- flex: 0 0 auto;
- color: #6b7280;
- font-size: 12px;
- line-height: 20px;
- white-space: nowrap;
- }
- .panel-left,
- .panel-right {
- .analysis-card-shell();
- }
- .panel-left,
- .panel-right {
- width: 100%;
- max-width: none;
- min-width: 0;
- }
- .panel-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 0 0 8px;
- border-bottom: 0;
- }
- .panel-title,
- .timeline-item__title {
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 22px;
- }
- .timeline-list {
- flex: 1 1 auto;
- min-height: 0;
- padding: 0;
- overflow: auto;
- }
- .timeline-item {
- display: grid;
- grid-template-columns: clamp(112px, 26%, 156px) 28px minmax(0, 1fr);
- align-items: stretch;
- gap: 10px;
- padding: 8px 0 10px;
- cursor: pointer;
- }
- .timeline-item__time-pane {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- min-width: 0;
- padding-top: 14px;
- }
- .timeline-item__time {
- color: #0f172a;
- font-size: 13px;
- font-weight: 500;
- line-height: 20px;
- white-space: nowrap;
- font-variant-numeric: tabular-nums;
- }
- .timeline-item__axis {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .timeline-item__axis::before {
- content: '';
- position: absolute;
- top: -8px;
- bottom: -10px;
- left: 50%;
- width: 2px;
- border-radius: 999px;
- background: #cbd5e1;
- transform: translateX(-50%);
- }
- .timeline-item.is-first .timeline-item__axis::before {
- top: 50%;
- }
- .timeline-item.is-last .timeline-item__axis::before {
- bottom: 50%;
- }
- .timeline-item__dot {
- position: relative;
- z-index: 1;
- width: 14px;
- height: 14px;
- border: 3px solid #ffffff;
- border-radius: 50%;
- background: #94a3b8;
- box-shadow:
- 0 0 0 2px #cbd5e1,
- 0 8px 16px rgba(15, 23, 42, 0.08);
- }
- .timeline-item__body {
- flex: 1 1 auto;
- min-width: 0;
- padding: 10px 0 10px 12px;
- border: 0;
- border-left: 1px solid #e5e7eb;
- border-radius: 0;
- background: transparent;
- transition:
- border-color 0.2s ease,
- background-color 0.2s ease,
- box-shadow 0.2s ease,
- transform 0.2s ease;
- }
- .timeline-item:hover .timeline-item__body {
- border-color: #cbd5e1;
- box-shadow: none;
- }
- .timeline-item.is-active .timeline-item__body {
- border-color: #93c5fd;
- background: rgba(239, 246, 255, 0.72);
- box-shadow: none;
- transform: none;
- }
- .timeline-item.is-active .timeline-item__dot {
- background: #1677ff;
- box-shadow:
- 0 0 0 2px #bfdbfe,
- 0 10px 18px rgba(22, 119, 255, 0.2);
- }
- .timeline-item__top {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 8px;
- }
- .timeline-item__heading {
- flex: 1 1 auto;
- min-width: 0;
- }
- .timeline-item__badges {
- display: inline-flex;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
- flex-wrap: wrap;
- flex: 0 0 auto;
- }
- .timeline-item__person {
- margin-top: 2px;
- color: #64748b;
- font-size: 12px;
- line-height: 18px;
- }
- .timeline-item__marker {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border: 1px solid #c7dcff;
- border-radius: 999px;
- background: linear-gradient(180deg, #f8fbff 0%, #eaf3ff 100%);
- color: #2563eb;
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.9),
- 0 4px 10px rgba(37, 99, 235, 0.14);
- white-space: nowrap;
- .anticon {
- font-size: 15px;
- }
- }
- .timeline-item__tag {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 74px;
- padding: 2px 10px;
- border: 1px solid transparent;
- border-radius: 999px;
- font-size: 12px;
- line-height: 20px;
- white-space: nowrap;
- }
- .timeline-item__fields {
- margin-top: 8px;
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
- .timeline-item__field {
- display: flex;
- align-items: flex-start;
- gap: 6px;
- min-width: 0;
- font-size: 12px;
- line-height: 20px;
- }
- .timeline-item__field-label {
- flex: 0 0 auto;
- color: #475569;
- white-space: nowrap;
- }
- .timeline-item__field-value {
- flex: 1 1 auto;
- min-width: 0;
- color: #111827;
- font-size: 13px;
- font-weight: 500;
- line-height: 20px;
- word-break: break-word;
- }
- .timeline-empty {
- min-height: 240px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .timeline-list__footer {
- padding: 8px 0 4px;
- color: #94a3b8;
- font-size: 12px;
- line-height: 20px;
- text-align: center;
- }
- .map-box {
- position: relative;
- flex: 1 1 auto;
- min-height: 0;
- margin: 0;
- border-top: 0;
- border-radius: 0;
- overflow: hidden;
- background: transparent;
- }
- .map-box__canvas {
- width: 100%;
- height: 100%;
- min-height: 0;
- }
- .map-hover-card {
- position: absolute;
- z-index: 4;
- min-width: 268px;
- max-width: 360px;
- padding: 14px 16px 12px;
- border-radius: 14px;
- background: rgba(255, 255, 255, 0.98);
- box-shadow: 0 16px 34px rgba(15, 23, 42, 0.18);
- transform: translate(-50%, calc(-100% - 22px));
- user-select: text;
- }
- .map-hover-card::after {
- content: '';
- position: absolute;
- left: 50%;
- bottom: -7px;
- width: 16px;
- height: 16px;
- background: rgba(255, 255, 255, 0.98);
- transform: translateX(-50%) rotate(45deg);
- box-shadow: 6px 6px 16px rgba(15, 23, 42, 0.06);
- }
- .map-hover-card__title,
- .map-hover-card__row,
- .map-hover-card__actions {
- position: relative;
- z-index: 1;
- }
- .map-hover-card__title {
- color: #111827;
- font-size: 16px;
- font-weight: 700;
- line-height: 24px;
- }
- .map-hover-card__row {
- margin-top: 8px;
- display: flex;
- gap: 8px;
- align-items: flex-start;
- }
- .map-hover-card__label {
- flex: 0 0 52px;
- color: #94a3b8;
- font-size: 12px;
- line-height: 20px;
- }
- .map-hover-card__value {
- flex: 1 1 auto;
- color: #1f2937;
- font-size: 13px;
- line-height: 20px;
- word-break: break-all;
- }
- .map-hover-card__actions {
- margin-top: 12px;
- display: flex;
- gap: 10px;
- }
- .map-hover-card__action {
- padding: 0 12px;
- height: 30px;
- border: 1px solid #c7d2fe;
- border-radius: 8px;
- background: #eff6ff;
- color: #2563eb;
- font-size: 12px;
- line-height: 28px;
- cursor: pointer;
- transition:
- border-color 0.2s ease,
- background 0.2s ease,
- color 0.2s ease;
- }
- .map-hover-card__action:hover {
- border-color: #93c5fd;
- background: #dbeafe;
- color: #1d4ed8;
- }
- .map-box__toolbar,
- .map-box__legend {
- position: absolute;
- left: 12px;
- z-index: 3;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- }
- .map-box__toolbar {
- top: 12px;
- right: 72px;
- }
- .map-box__legend {
- bottom: 12px;
- max-width: calc(100% - 24px);
- padding: 10px 12px;
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.94);
- box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08);
- pointer-events: none;
- }
- .toolbar-pill,
- .legend-item {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- line-height: 18px;
- }
- .toolbar-pill {
- padding: 6px 10px;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.92);
- color: #334155;
- box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08);
- }
- .toolbar-pill.is-fallback {
- color: #92400e;
- }
- .toolbar-pill.is-pressure {
- color: #155e75;
- background: rgba(236, 254, 255, 0.94);
- }
- .legend-item {
- color: #475569;
- white-space: nowrap;
- }
- .legend-item i {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- }
- .map-box__empty {
- position: absolute;
- inset: 0;
- z-index: 2;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(248, 250, 252, 0.34);
- color: #64748b;
- font-size: 13px;
- pointer-events: none;
- }
- :deep(.maplibregl-ctrl-top-right) {
- top: 12px;
- right: 12px;
- }
- :deep(.maplibregl-ctrl-group) {
- border-radius: 12px;
- overflow: hidden;
- box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08);
- }
- @media screen and (max-width: 1480px) {
- .content-panel {
- grid-template-columns: minmax(420px, 6fr) minmax(320px, 4fr);
- gap: 2px;
- }
- .panel-header {
- padding: 0 0 8px;
- }
- .timeline-list {
- padding: 0;
- }
- .timeline-item {
- gap: 10px;
- }
- }
- @media screen and (max-width: 1220px) {
- .content-panel {
- grid-template-columns: minmax(340px, 6fr) minmax(280px, 4fr);
- gap: 2px;
- }
- .panel-header {
- flex-wrap: wrap;
- align-items: flex-start;
- }
- .map-actions {
- justify-content: flex-end;
- }
- .timeline-item {
- grid-template-columns: 108px 22px minmax(0, 1fr);
- gap: 8px;
- }
- .timeline-item__time {
- font-size: 12px;
- }
- .timeline-item__body {
- padding: 10px 0 10px 12px;
- }
- .timeline-item__top {
- flex-wrap: wrap;
- }
- .timeline-item__badges {
- width: 100%;
- justify-content: flex-start;
- }
- .timeline-item__tag {
- min-width: 0;
- }
- .map-box__toolbar {
- right: 12px;
- }
- }
- @media screen and (max-width: 920px) {
- .content-panel {
- grid-template-columns: 1fr;
- overflow: auto;
- }
- .panel-left,
- .panel-right {
- min-height: 360px;
- }
- }
- @media screen and (max-width: 920px) {
- .target-strip,
- .filter-item,
- .filter-input,
- .filter-type,
- .filter-date--compact {
- width: 100%;
- }
- .selected-targets {
- width: 100%;
- }
- .target-strip__label {
- width: 100%;
- }
- .add-target-btn.ant-btn {
- width: 100%;
- }
- .filter-actions {
- width: 100%;
- margin-left: 0;
- justify-content: flex-start;
- }
- .timeline-item {
- grid-template-columns: 124px 26px minmax(0, 1fr);
- gap: 10px;
- }
- .timeline-item__time {
- font-size: 12px;
- }
- .panel-header,
- .map-actions {
- align-items: flex-start;
- }
- }
- }
- .behavior-target-picker-modal {
- .ant-modal {
- top: 16px;
- padding-bottom: 0;
- max-width: calc(100vw - 48px);
- }
- .ant-modal-content {
- border-radius: 8px;
- overflow: hidden;
- background: #ffffff;
- box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
- }
- .ant-modal-header {
- padding: 12px 16px;
- border-bottom: 1px solid #e5e7eb;
- background: @modal-header-bg-color;
- }
- .ant-modal-title {
- color: #111827;
- font-size: 16px;
- font-weight: 600;
- line-height: 24px;
- }
- .ant-modal-body {
- padding: 16px;
- }
- .ant-modal-footer {
- padding: 8px 16px 10px;
- border-top: 1px solid #e5e7eb;
- background: #fff;
- }
- .ant-modal-close {
- top: 16px;
- right: 16px;
- width: 30px;
- height: 30px;
- border-radius: 8px;
- color: #8b95a7;
- transition:
- color 0.2s ease,
- background-color 0.2s ease;
- }
- .ant-modal-close:hover {
- color: #0f172a;
- background: rgba(37, 99, 235, 0.08);
- }
- .target-picker {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 14px;
- }
- .target-picker-modal__title {
- display: inline-flex;
- align-items: center;
- color: #111827;
- font-size: 16px;
- font-weight: 600;
- line-height: 24px;
- }
- .target-picker__panel-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 12px 14px;
- border-radius: 0;
- background: #f4f7fb;
- }
- .target-picker__panel-title {
- color: #111827;
- font-size: 16px;
- font-weight: 700;
- line-height: 24px;
- }
- .target-picker__panel-count {
- color: #16a34a;
- font-size: 14px;
- font-weight: 600;
- white-space: nowrap;
- }
- .target-picker__toolbar {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 0;
- border: none;
- border-radius: 0;
- background: #ffffff;
- }
- .target-picker__list-head {
- display: flex;
- align-items: center;
- padding: 0 2px;
- color: #374151;
- font-size: 14px;
- font-weight: 500;
- line-height: 20px;
- }
- .target-picker__list-head :deep(.ant-checkbox-wrapper) {
- color: inherit;
- font-size: inherit;
- line-height: inherit;
- }
- .target-picker__list-head :deep(.ant-checkbox + span) {
- padding-inline-start: 6px;
- }
- .target-picker__search-input.ant-input-affix-wrapper {
- width: 100%;
- min-height: 34px;
- padding: 0 12px;
- border: 1px solid #d8e0eb;
- border-radius: 8px;
- box-shadow: none;
- background: #fff;
- transition: border-color 0.2s ease;
- font-size: 13px;
- }
- .target-picker__search-input.ant-input-affix-wrapper:hover,
- .target-picker__search-input.ant-input-affix-wrapper:focus,
- .target-picker__search-input.ant-input-affix-wrapper-focused {
- border-color: #b2c3df;
- box-shadow: none;
- }
- .target-picker__table {
- display: flex;
- flex-direction: column;
- width: 100%;
- padding: 0;
- overflow: hidden;
- border: none;
- border-radius: 0;
- background: #ffffff;
- }
- .target-picker__list {
- width: 100%;
- max-height: 340px;
- overflow: auto;
- display: flex;
- flex-direction: column;
- gap: 0;
- padding-right: 2px;
- border-top: 1px solid #eef2f7;
- scrollbar-color: #8c8c8c transparent;
- scrollbar-width: thin;
- }
- .target-picker__list::-webkit-scrollbar {
- width: 10px;
- }
- .target-picker__list::-webkit-scrollbar-track {
- background: transparent;
- }
- .target-picker__list::-webkit-scrollbar-thumb {
- border-radius: 999px;
- background: #8c8c8c;
- border: 2px solid transparent;
- background-clip: content-box;
- }
- .target-picker__item {
- display: flex;
- align-items: center;
- gap: 10px;
- width: 100%;
- min-height: 54px;
- padding: 11px 14px;
- border: none;
- border-bottom: 1px solid #edf1f6;
- border-radius: 0;
- background: #ffffff;
- color: #374151;
- font-size: 13px;
- line-height: 18px;
- box-sizing: border-box;
- cursor: pointer;
- transition:
- background-color 0.2s ease,
- box-shadow 0.2s ease;
- }
- .target-picker__item:last-child {
- border-bottom: none;
- }
- .target-picker__item:hover {
- background: #fbfdff;
- }
- .target-picker__item.is-active {
- background: #f4f8ff;
- }
- .target-picker__checkbox {
- flex: 0 0 auto;
- line-height: 1;
- }
- .target-picker__checkbox .ant-checkbox {
- margin: 0;
- }
- .target-picker__avatar {
- flex: 0 0 32px;
- display: inline-flex;
- }
- .target-picker__item-body {
- flex: 1 1 auto;
- min-width: 0;
- align-items: center;
- display: flex;
- }
- .target-picker__name-wrap {
- flex: 1 1 auto;
- min-width: 0;
- display: flex;
- align-items: baseline;
- gap: 10px;
- overflow: hidden;
- }
- .target-picker__name {
- flex: 0 0 auto;
- color: #111827;
- font-size: 14px;
- font-weight: 600;
- line-height: 20px;
- white-space: nowrap;
- }
- .target-picker__inline-code {
- min-width: 0;
- color: #9aa8bf;
- font-size: 12px;
- line-height: 20px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .target-picker__empty {
- padding: 32px 0;
- border: 1px dashed #dbe4ef;
- border-radius: 8px;
- background: #ffffff;
- color: #9ca3af;
- font-size: 12px;
- line-height: 20px;
- text-align: center;
- }
- }
- .behavior-detail-drawer {
- .ant-drawer-header {
- padding: 14px 16px 12px;
- border-bottom: 1px solid #e5e7eb;
- background: #ffffff;
- }
- .ant-drawer-body {
- padding: 0;
- background: #f8fafc;
- }
- .detail-drawer__title-wrap {
- display: flex;
- align-items: flex-start;
- justify-content: flex-start;
- gap: 10px;
- }
- .detail-drawer__title-block {
- min-width: 0;
- }
- .detail-drawer__title {
- color: #111827;
- font-size: 15px;
- font-weight: 600;
- line-height: 22px;
- }
- .detail-drawer__sub {
- margin-top: 2px;
- color: #64748b;
- font-size: 12px;
- line-height: 18px;
- }
- .detail-drawer__body {
- padding: 16px;
- display: flex;
- flex-direction: column;
- gap: 14px;
- }
- .detail-drawer__loading,
- .detail-drawer__empty {
- padding: 24px 16px;
- border: 1px dashed #dbe4ef;
- border-radius: 12px;
- background: #fff;
- color: #64748b;
- font-size: 13px;
- line-height: 22px;
- text-align: center;
- }
- .detail-drawer__flat {
- display: flex;
- flex-direction: column;
- gap: 0;
- }
- .detail-drawer__flat-item {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- padding: 10px 0;
- border-bottom: 1px solid #eef2f7;
- }
- .detail-drawer__flat-label {
- flex: 0 0 112px;
- color: #6b7280;
- font-size: 13px;
- line-height: 22px;
- text-align: right;
- white-space: nowrap;
- }
- .detail-drawer__flat-value {
- flex: 1 1 auto;
- min-width: 0;
- color: #111827;
- font-size: 13px;
- line-height: 22px;
- white-space: pre-wrap;
- word-break: break-word;
- }
- }
- @media screen and (max-width: 920px) {
- .behavior-detail-drawer {
- .detail-drawer__flat-item {
- flex-direction: column;
- gap: 4px;
- }
- .detail-drawer__flat-label {
- flex: none;
- text-align: left;
- }
- }
- .behavior-target-picker-modal {
- .target-picker__panel-head,
- .target-picker__toolbar {
- padding: 12px;
- }
- .target-picker__panel-head {
- flex-direction: column;
- align-items: flex-start;
- }
- .target-picker__search-input.ant-input-affix-wrapper {
- width: 100%;
- }
- .target-picker__item {
- min-height: 52px;
- padding: 10px 12px;
- }
- .target-picker__name-wrap {
- gap: 6px;
- flex-wrap: wrap;
- align-items: flex-start;
- }
- }
- }
- @import '../styles/trans-analysis-table-ui.less';
- </style>
|