index.vue 176 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088
  1. <template>
  2. <div ref="pageRootRef" :class="prefixCls" class="behavior-spacetime-page">
  3. <div class="target-strip-panel">
  4. <div class="target-strip">
  5. <div class="target-strip__label">已选对象:</div>
  6. <div class="selected-targets">
  7. <template v-if="displayTargets.length">
  8. <div v-for="target in displayTargets" :key="target" class="selected-target">
  9. <Checkbox
  10. class="selected-target__checkbox"
  11. :checked="checkedTargetSet.has(target)"
  12. :disabled="queryLoading"
  13. @change="handleToggleTargetChecked(target, $event.target.checked)"
  14. />
  15. <span class="selected-target__label">{{ target }}</span>
  16. <button type="button" class="selected-target__remove" @click.stop="removeTarget(target)">
  17. <CloseCircleFilled />
  18. </button>
  19. </div>
  20. </template>
  21. <div v-else class="selected-targets__empty">未选择对象</div>
  22. </div>
  23. <Button type="primary" class="add-target-btn" @click="openTargetPicker">添加对象</Button>
  24. </div>
  25. </div>
  26. <div class="filter-bar">
  27. <div class="filter-item">
  28. <span class="filter-label">行为分类:</span>
  29. <Select
  30. v-model:value="filters.behaviorTypes"
  31. mode="multiple"
  32. class="filter-input filter-type"
  33. :options="behaviorOptions"
  34. :max-tag-count="2"
  35. placeholder="请选择分类,不选择则默认选全部分类"
  36. allow-clear
  37. />
  38. </div>
  39. <div class="filter-item">
  40. <span class="filter-label">时间:</span>
  41. <RangePicker
  42. v-model:value="filters.dateRange"
  43. class="filter-date filter-date--compact"
  44. :format="DATE_FILTER_FORMAT"
  45. :value-format="DATE_FILTER_FORMAT"
  46. :placeholder="['开始日期', '结束日期']"
  47. />
  48. </div>
  49. <Checkbox
  50. class="filter-located-check"
  51. :checked="filters.locatedOnly === 'located'"
  52. @change="filters.locatedOnly = $event.target.checked ? 'located' : 'all'"
  53. >
  54. 仅位置
  55. </Checkbox>
  56. <div class="filter-actions">
  57. <Button type="primary" :loading="queryLoading" :disabled="!canSearch" @click="handleSearch">查询</Button>
  58. <Button @click="handleReset">重置</Button>
  59. </div>
  60. </div>
  61. <div ref="pageContentRef" class="page-content">
  62. <div class="content-panel">
  63. <div class="panel-left">
  64. <div class="panel-header">
  65. <div>
  66. <div class="panel-title">行为时间轴</div>
  67. </div>
  68. <div class="panel-extra">{{ timelineCountText }}</div>
  69. </div>
  70. <div ref="timelineListRef" class="timeline-list" @scroll.passive="handleTimelineScroll">
  71. <div
  72. v-for="entry in timelineEntries"
  73. :key="`${entry.item.id}-${entry.index}`"
  74. :data-timeline-id="entry.item.id"
  75. class="timeline-item"
  76. :class="{
  77. 'is-active': entry.item.id === activeEvent?.id,
  78. 'is-first': entry.index === 0,
  79. 'is-last': entry.index === timelineEntries.length - 1,
  80. }"
  81. @click="handleSelect(entry.item)"
  82. >
  83. <div class="timeline-item__time-pane">
  84. <span class="timeline-item__time">{{ entry.item.occurredAt }}</span>
  85. </div>
  86. <div class="timeline-item__axis">
  87. <span class="timeline-item__dot"></span>
  88. </div>
  89. <div class="timeline-item__body">
  90. <div class="timeline-item__top">
  91. <div class="timeline-item__heading">
  92. <div class="timeline-item__title">{{ entry.item.personName }}</div>
  93. <div class="timeline-item__person">{{ entry.item.title }}</div>
  94. </div>
  95. <div class="timeline-item__badges">
  96. <span v-if="entry.item.hasCoordinate" class="timeline-item__marker" title="地址标记">
  97. <EnvironmentFilled />
  98. </span>
  99. <span class="timeline-item__tag" :style="tagStyle(entry.item.behaviorType)">{{
  100. getBehaviorLabel(entry.item.behaviorType)
  101. }}</span>
  102. </div>
  103. </div>
  104. <div class="timeline-item__fields">
  105. <div
  106. v-for="field in getTimelineFields(entry.item)"
  107. :key="`${entry.item.id}-${entry.index}-${field.label}`"
  108. class="timeline-item__field"
  109. >
  110. <span class="timeline-item__field-label">{{ field.label }}:</span>
  111. <strong class="timeline-item__field-value">{{ field.value }}</strong>
  112. </div>
  113. </div>
  114. </div>
  115. </div>
  116. <div v-if="!filteredEvents.length" class="timeline-empty">
  117. <Empty :description="timelineEmptyText" />
  118. </div>
  119. <div v-if="showTimelineLoadState" class="timeline-list__footer">
  120. {{ timelineLoadStateText }}
  121. </div>
  122. </div>
  123. </div>
  124. <div class="panel-right">
  125. <div class="panel-header">
  126. <div>
  127. <div class="panel-title">离线地图</div>
  128. </div>
  129. <div class="map-actions">
  130. <Button size="small" :loading="mapReloading" @click="handleReloadMap">
  131. <template #icon><ReloadOutlined /></template>重新加载地图
  132. </Button>
  133. <Button size="small" @click="handleFit"
  134. ><template #icon><ReloadOutlined /></template>回到全景</Button
  135. >
  136. <Button size="small" type="primary" @click="handleFocus"
  137. ><template #icon><AimOutlined /></template>定位当前</Button
  138. >
  139. </div>
  140. </div>
  141. <div class="map-box">
  142. <div ref="mapRef" class="map-box__canvas"></div>
  143. <div
  144. v-if="hoverCard.visible"
  145. class="map-hover-card"
  146. :style="{
  147. left: `${hoverCard.x}px`,
  148. top: `${hoverCard.y}px`,
  149. }"
  150. @mouseenter="lockMapHoverCard"
  151. @mouseleave="unlockMapHoverCard"
  152. >
  153. <div class="map-hover-card__title">位置信息</div>
  154. <div class="map-hover-card__row">
  155. <span class="map-hover-card__label">位置</span>
  156. <span class="map-hover-card__value">{{ hoverCard.locationName || hoverCard.address || '-' }}</span>
  157. </div>
  158. <div class="map-hover-card__row">
  159. <span class="map-hover-card__label">大小区号</span>
  160. <span class="map-hover-card__value">{{ hoverCard.stationCode || '-' }}</span>
  161. </div>
  162. <div class="map-hover-card__row">
  163. <span class="map-hover-card__label">经纬度</span>
  164. <span class="map-hover-card__value">{{ hoverCard.coordinateText || '-' }}</span>
  165. </div>
  166. <div class="map-hover-card__actions">
  167. <button type="button" class="map-hover-card__action" @click.stop="handleCopyMapHoverText('address')">
  168. 复制位置
  169. </button>
  170. <button type="button" class="map-hover-card__action" @click.stop="handleCopyMapHoverText('coordinate')">
  171. 复制经纬度
  172. </button>
  173. </div>
  174. </div>
  175. <div class="map-box__toolbar">
  176. <span class="toolbar-pill is-pressure">{{ pressureStatusText }}</span>
  177. <span class="toolbar-pill">定位节点 {{ locatedEvents.length }}</span>
  178. <span class="toolbar-pill">地址标签 {{ addressLabelCount }}</span>
  179. <span class="toolbar-pill" :class="{ 'is-fallback': mapMode !== 'template' }">{{ mapStatusText }}</span>
  180. </div>
  181. <div class="map-box__legend">
  182. <span v-for="item in legendItems" :key="item.label" class="legend-item"
  183. ><i :style="{ background: item.color }"></i>{{ item.label }}</span
  184. >
  185. </div>
  186. <div v-if="!locatedEvents.length" class="map-box__empty">{{ mapEmptyText }}</div>
  187. </div>
  188. </div>
  189. </div>
  190. </div>
  191. <Modal
  192. v-model:open="targetPickerOpen"
  193. :width="540"
  194. :centered="true"
  195. ok-text="分析"
  196. cancel-text="取消"
  197. wrapClassName="behavior-target-picker-modal"
  198. @ok="confirmTargetPicker"
  199. @cancel="closeTargetPicker"
  200. >
  201. <template #title>
  202. <div class="target-picker-modal__title">
  203. <span>设置分析条件</span>
  204. </div>
  205. </template>
  206. <div class="target-picker target-picker--modal">
  207. <div class="target-picker__panel-head">
  208. <div class="target-picker__panel-title">选择被调查对象</div>
  209. <div class="target-picker__panel-count">已选对象: {{ targetDraft.length }}</div>
  210. </div>
  211. <div class="target-picker__toolbar">
  212. <div class="target-picker__list-head">
  213. <Checkbox
  214. :checked="allDraftTargetsChecked"
  215. :indeterminate="draftTargetsIndeterminate"
  216. @change="toggleDraftAll"
  217. >
  218. 被调查对象列表 ({{ availableTargetOptions.length }})
  219. </Checkbox>
  220. </div>
  221. <Input
  222. v-if="availableTargetOptions.length > 6"
  223. v-model:value="targetKeyword"
  224. class="target-picker__search-input"
  225. placeholder="请输入姓名或证件号码筛选对象"
  226. allow-clear
  227. />
  228. </div>
  229. <div class="target-picker__table">
  230. <div class="target-picker__list">
  231. <div
  232. v-for="item in pickerTargetOptions"
  233. :key="item.value"
  234. class="target-picker__item"
  235. :class="{ 'is-active': targetDraft.includes(item.value) }"
  236. @click="toggleDraftTarget(item.value, !targetDraft.includes(item.value))"
  237. >
  238. <span class="target-picker__checkbox" @click.stop>
  239. <Checkbox
  240. :checked="targetDraft.includes(item.value)"
  241. @change="toggleDraftTarget(item.value, $event.target.checked)"
  242. />
  243. </span>
  244. <PersonAvatar class="target-picker__avatar" :src="item.photograph" :alt="item.label" :size="32" />
  245. <span class="target-picker__item-body">
  246. <span class="target-picker__name-wrap">
  247. <span class="target-picker__name">{{ item.label }}</span>
  248. <span v-if="item.identityCode && item.identityCode !== item.label" class="target-picker__inline-code">
  249. {{ maskCertNo(item.identityCode) }}
  250. </span>
  251. </span>
  252. </span>
  253. </div>
  254. <div v-if="!pickerTargetOptions.length" class="target-picker__empty">未找到匹配对象</div>
  255. </div>
  256. </div>
  257. </div>
  258. </Modal>
  259. <BasicDrawer
  260. v-model:open="detailDrawerOpen"
  261. wrapClassName="behavior-detail-drawer"
  262. placement="right"
  263. :width="540"
  264. :maskClosable="true"
  265. :destroyOnClose="false"
  266. :showFooter="false"
  267. >
  268. <template #title>
  269. <div class="detail-drawer__title-wrap">
  270. <div class="detail-drawer__title-block">
  271. <div class="detail-drawer__title">{{ detailDrawerTitle }}</div>
  272. <div class="detail-drawer__sub">{{ detailDrawerSubTitle }}</div>
  273. </div>
  274. </div>
  275. </template>
  276. <div class="detail-drawer__body">
  277. <div v-if="detailLoading" class="detail-drawer__loading">详情加载中...</div>
  278. <div v-else-if="detailError" class="detail-drawer__empty">{{ detailError }}</div>
  279. <Empty v-else-if="!detailFlatFields.length" :description="detailDrawerEmptyText" />
  280. <div v-else class="detail-drawer__flat">
  281. <div v-for="field in detailFlatFields" :key="field.key" class="detail-drawer__flat-item">
  282. <span class="detail-drawer__flat-label">{{ field.label }}:</span>
  283. <span class="detail-drawer__flat-value">{{ field.value }}</span>
  284. </div>
  285. </div>
  286. </div>
  287. </BasicDrawer>
  288. </div>
  289. </template>
  290. <script lang="ts" setup name="ViewsBehaviorSpacetimeIndex">
  291. import { AimOutlined, CloseCircleFilled, EnvironmentFilled, ReloadOutlined } from '@ant-design/icons-vue';
  292. import { PersonAvatar } from '@jeesite/core/components/PersonAvatar';
  293. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  294. import { BasicDrawer } from '@jeesite/core/components/Drawer';
  295. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  296. import { useTableSkill } from '@jeesite/core/hooks/web/useTableSkill';
  297. import { useMultipleTabStore } from '@jeesite/core/store/modules/multipleTab';
  298. import { timeSeriesDetail, timeSeriesList } from '@jeesite/otg/api/otgApi';
  299. import type { TimeSeries, TimeSeriesDetailDTO } from '@jeesite/otg/api/types/dto';
  300. import type { TimeSeriesDetailQuery, TimeSeriesQuery } from '@jeesite/otg/api/types/query';
  301. import {
  302. BEHAVIOR_SPACETIME_JUMP_QUERY_KEY,
  303. buildTimeSeriesJumpQuery,
  304. consumeBehaviorSpacetimeJumpResult,
  305. hasMeaningfulJumpPayload,
  306. parseBehaviorSpacetimeJumpPayload,
  307. type BehaviorSpacetimeJumpPayload,
  308. } from '@jeesite/otg/utils/timeSeriesJump';
  309. import { getPersons } from '@jeesite/person/api/personApi';
  310. import {
  311. ElectronPmtilesSource,
  312. getOfflineMapDir,
  313. getOfflineMapFilePath,
  314. getOfflineMapPmtilesUrl,
  315. OFFLINE_MAP_DIR_CHANGED_EVENT,
  316. toLocalMapUrl,
  317. } from '@jeesite/core/utils/map/offlineMap';
  318. import { isCertFieldKey, maskCertNo } from '@jeesite/core/utils/sensitive';
  319. import { Button, Checkbox, DatePicker, Empty, Input, Modal, Select } from 'ant-design-vue';
  320. import type { CheckboxChangeEvent } from 'ant-design-vue/lib/checkbox/interface';
  321. import dayjs from 'dayjs';
  322. import maplibregl from 'maplibre-gl';
  323. import type { Map as MapLibreMap } from 'maplibre-gl';
  324. import 'maplibre-gl/dist/maplibre-gl.css';
  325. import { PMTiles, Protocol } from 'pmtiles';
  326. import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
  327. import type { RouteLocationNormalized } from 'vue-router';
  328. import { useRoute } from 'vue-router';
  329. import mapPinSvg from './assets/location_on_baseline.svg?raw';
  330. import { copyTextToClipboard } from '@jeesite/core/hooks/web/useCopyToClipboard';
  331. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  332. const { prefixCls } = useDesign('behavior-spacetime');
  333. const { createMessage } = useMessage();
  334. const { queryLoading, runWithQueryLoading } = useTableSkill();
  335. const tabStore = useMultipleTabStore();
  336. const route = useRoute();
  337. const RangePicker = DatePicker.RangePicker;
  338. const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
  339. const DATE_FILTER_FORMAT = 'YYYY-MM-DD';
  340. const BEHAVIOR_SPACETIME_ROUTE_PATH = '/behaviorSpacetime/index';
  341. const REMOTE_QUERY_LIMIT = 20;
  342. const TIMELINE_LOAD_MORE_THRESHOLD = 96;
  343. const STYLE_PATH = `${import.meta.env.BASE_URL}resource/map/behavior-spacetime-style.json`;
  344. const CENTER: [number, number] = [104.0668, 30.5728];
  345. const SOURCE_ID = 'behavior-events';
  346. const LABEL_ID = 'behavior-labels';
  347. const CLUSTER_LAYER = 'behavior-point-clusters';
  348. const CLUSTER_COUNT_LAYER = 'behavior-point-cluster-count';
  349. const POINT_LAYER = 'behavior-points';
  350. const POINT_LABEL_LAYER = 'behavior-point-labels';
  351. const PMTILES_PROTOCOL = 'pmtiles://';
  352. const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
  353. const POINT_LABEL_BASE_MIN_ZOOM = 8.2;
  354. const POINT_LABEL_LIGHT_LIMIT = 12;
  355. const POINT_LABEL_MEDIUM_LIMIT = 40;
  356. const POINT_LABEL_DENSE_LIMIT = 90;
  357. const POINT_LABEL_HEAVY_LIMIT = 180;
  358. const POINT_CLUSTER_RADIUS = 44;
  359. const POINT_CLUSTER_MAX_ZOOM = 10;
  360. const MAP_PIN_ICON_TRACK = 'behavior-pin-track';
  361. const MAP_PIN_ICON_TRANS = 'behavior-pin-trans';
  362. const MAP_PIN_ICON_CALL = 'behavior-pin-call';
  363. const MAP_PIN_ICON_ACTIVE = 'behavior-pin-active';
  364. type RangeValue = [string, string] | undefined;
  365. type BehaviorType = '轨迹信息' | '交易信息' | '通信信息';
  366. type BehaviorSpacetimeWindow = Window & {
  367. __BEHAVIOR_SPACETIME_STYLE__?: string | Record<string, any>;
  368. __BEHAVIOR_SPACETIME_PAGE_CACHE__?: BehaviorSpacetimePageCache;
  369. };
  370. type BehaviorFilters = {
  371. targets: string[];
  372. checkedTargets: string[];
  373. behaviorTypes: BehaviorType[];
  374. dateRange: RangeValue;
  375. keyword: string;
  376. locatedOnly: 'all' | 'located';
  377. };
  378. interface BehaviorEvent {
  379. id: string;
  380. rowId?: string;
  381. recordId?: string;
  382. rawTableNameEn?: string;
  383. tableNameEn?: string;
  384. tableNameCn?: string;
  385. otherName?: string;
  386. ext1?: string;
  387. ext2?: string;
  388. ext3?: string;
  389. ext4?: string;
  390. ext5?: string;
  391. ext6?: string;
  392. ext7?: string;
  393. ext8?: string;
  394. ext9?: string;
  395. personName: string;
  396. behaviorType: BehaviorType;
  397. title: string;
  398. locationName: string;
  399. address: string;
  400. occurredAt: string;
  401. durationMinutes: number;
  402. accuracyMeters: number;
  403. relatedCount: number;
  404. sourceLabel: string;
  405. hasCoordinate: boolean;
  406. lng?: number;
  407. lat?: number;
  408. summary: string;
  409. metaPrimaryLabel: string;
  410. metaPrimaryValue: string;
  411. metaSecondaryLabel: string;
  412. metaSecondaryValue: string;
  413. }
  414. interface TimelineField {
  415. label: string;
  416. value: string;
  417. }
  418. interface DetailField {
  419. key: string;
  420. label: string;
  421. value: string;
  422. span?: number;
  423. }
  424. interface DetailHeadColumn {
  425. title?: string | null;
  426. dataIndex?: string | number | Array<string | number> | null;
  427. key?: string | number | null;
  428. width?: string | number | null;
  429. sorter?: boolean;
  430. slot?: string | null;
  431. }
  432. interface DetailSection {
  433. title: string;
  434. fields: DetailField[];
  435. }
  436. interface DetailFieldConfig {
  437. key: string;
  438. label: string;
  439. }
  440. interface DetailSectionConfig {
  441. title: string;
  442. fields: DetailFieldConfig[];
  443. }
  444. interface DetailOverviewCard {
  445. label: string;
  446. value: string;
  447. }
  448. interface TargetOption {
  449. label: string;
  450. value: string;
  451. identityCode?: string;
  452. photograph?: string;
  453. }
  454. interface BehaviorSpacetimePageCache {
  455. filters: BehaviorFilters;
  456. applied: BehaviorFilters;
  457. activeJumpPayload: BehaviorSpacetimeJumpPayload | null;
  458. lastRouteJumpSignature: string;
  459. appliedJumpRequestSignature: string;
  460. remoteEvents: BehaviorEvent[];
  461. remoteTotal: number;
  462. selectedId: string;
  463. timelinePage: number;
  464. timelineHasMore: boolean;
  465. timelineCanLoadForward: boolean;
  466. timelineCanLoadBackward: boolean;
  467. timelineListInitialized: boolean;
  468. detailRecords: Record<string, TimeSeriesDetailDTO>;
  469. detailHeads: Record<string, DetailHeadColumn[]>;
  470. detailEvents: BehaviorEvent[];
  471. }
  472. const THEME: Record<BehaviorType, { color: string; soft: string }> = {
  473. 轨迹信息: { color: '#1677ff', soft: '#e6f4ff' },
  474. 交易信息: { color: '#d97706', soft: '#fef3c7' },
  475. 通信信息: { color: '#475569', soft: '#e2e8f0' },
  476. };
  477. const TABLE_CN_TO_EN_MAP: Record<string, string> = {
  478. 通信记录: 'call_record',
  479. 交易记录: 'trans_record',
  480. 住宿信息: 'hotel_stay_info',
  481. 铁路行程: 'train_ticket_info',
  482. 航班行程: 'flight_ticket_info',
  483. 汽车购票行程: 'track_car_ticket_info',
  484. 快递收发: 'express_info',
  485. };
  486. const TABLE_EN_ALIAS_MAP: Record<string, string> = {
  487. call_record_all: 'call_record',
  488. trans_record_all: 'trans_record',
  489. };
  490. const KNOWN_TIME_SERIES_TABLE_KEYS = new Set([
  491. 'call_record',
  492. 'trans_record',
  493. 'hotel_stay_info',
  494. 'train_ticket_info',
  495. 'together_train_info',
  496. 'flight_ticket_info',
  497. 'flight_departure_info',
  498. 'together_flight_info',
  499. 'track_car_ticket_info',
  500. 'express_info',
  501. ]);
  502. const TABLE_SOURCE_LABEL_MAP: Record<string, string> = {
  503. call_record: '通信记录',
  504. trans_record: '交易记录',
  505. hotel_stay_info: '住宿信息',
  506. train_ticket_info: '铁路行程',
  507. together_train_info: '铁路行程',
  508. flight_ticket_info: '航班行程',
  509. flight_departure_info: '航班行程',
  510. together_flight_info: '航班行程',
  511. track_car_ticket_info: '汽车购票行程',
  512. express_info: '快递收发',
  513. };
  514. const DETAIL_BASE_FIELD_ORDER = ['tableNameCn', 'personName', 'seriesDateTime'];
  515. const DETAIL_BASE_FIELD_LABEL_MAP: Record<string, string> = {
  516. tableNameCn: '表中文名',
  517. personName: '对象姓名',
  518. seriesDateTime: '发生时间',
  519. };
  520. const DETAIL_TABLE_FIELD_LABEL_MAP: Record<string, Partial<Record<string, string>>> = {
  521. call_record: {
  522. personName: '本方姓名',
  523. otherName: '对方户名',
  524. seriesDateTime: '通话日期时间',
  525. },
  526. trans_record: {
  527. personName: '客户名称',
  528. otherName: '交易对方名称',
  529. seriesDateTime: '交易日期时间',
  530. },
  531. hotel_stay_info: {
  532. personName: '旅客姓名',
  533. seriesDateTime: '入住时间',
  534. },
  535. train_ticket_info: {
  536. personName: '旅客姓名',
  537. seriesDateTime: '发车日期',
  538. },
  539. together_train_info: {
  540. personName: '姓名',
  541. seriesDateTime: '乘车时间',
  542. },
  543. flight_ticket_info: {
  544. personName: '旅客中文名',
  545. seriesDateTime: '当地出发时间',
  546. },
  547. flight_departure_info: {
  548. personName: '旅客中文姓名',
  549. seriesDateTime: '离港日期',
  550. },
  551. together_flight_info: {
  552. personName: '同行人姓名',
  553. seriesDateTime: '航班日期',
  554. },
  555. track_car_ticket_info: {
  556. personName: '乘车人姓名',
  557. seriesDateTime: '发车时间',
  558. },
  559. express_info: {
  560. personName: '收件人姓名',
  561. otherName: '寄件人姓名',
  562. seriesDateTime: '寄件日期',
  563. },
  564. };
  565. const DETAIL_EXT_KEYS = ['ext1', 'ext2', 'ext3', 'ext4', 'ext5', 'ext6', 'ext7', 'ext8', 'ext9'] as const;
  566. const DETAIL_EXT_FIELD_LABEL_MAP: Record<string, Partial<Record<(typeof DETAIL_EXT_KEYS)[number], string>>> = {
  567. call_record: {
  568. ext1: '本方号码',
  569. ext2: '对方号码',
  570. ext3: '运营商',
  571. ext4: '主被叫标识',
  572. ext5: '本方基站大区号',
  573. ext6: '本方基站小区号',
  574. ext7: '本方基站名称',
  575. ext8: '本方基站经度',
  576. ext9: '本方基站纬度',
  577. },
  578. trans_record: {
  579. ext1: '查询卡号',
  580. ext2: '交易对方卡号',
  581. ext3: '银行名称',
  582. ext4: '借贷标志',
  583. ext6: '交易金额',
  584. },
  585. hotel_stay_info: {
  586. ext1: '证件号码',
  587. ext2: '酒店名称',
  588. ext4: '房间号',
  589. ext5: '详细地址',
  590. },
  591. train_ticket_info: {
  592. ext1: '证件号码',
  593. ext2: '车号',
  594. ext3: '发站',
  595. ext4: '到站',
  596. },
  597. together_train_info: {
  598. ext1: '身份证号',
  599. ext2: '车次',
  600. ext3: '起始站名',
  601. ext4: '终点站名',
  602. },
  603. flight_ticket_info: {
  604. ext1: '证件号码',
  605. ext2: '票号',
  606. ext3: '起飞机场代码',
  607. ext4: '到达机场代码',
  608. },
  609. flight_departure_info: {
  610. ext1: '证件号码',
  611. ext2: '航班号',
  612. ext3: '起飞港三字码',
  613. ext4: '到达航站三字码',
  614. },
  615. together_flight_info: {
  616. ext1: '证件号码',
  617. ext2: '航班号',
  618. ext3: '出发地',
  619. ext4: '目的地',
  620. },
  621. track_car_ticket_info: {
  622. ext1: '证件号码',
  623. ext2: '班次号',
  624. ext3: '始发车站名称',
  625. ext4: '目的地名称',
  626. },
  627. express_info: {
  628. ext3: '收件人现住址',
  629. ext4: '寄件人现住址',
  630. },
  631. };
  632. const DETAIL_RECORD_FIELDS_MAP: Record<string, Array<{ key: string; label: string }>> = {
  633. call_record: [
  634. { key: 'personPhone', label: '本方号码' },
  635. { key: 'otherPhone', label: '对方号码' },
  636. { key: 'callDirection', label: '主被叫标识' },
  637. { key: 'operatorId', label: '运营商' },
  638. { key: 'callDateTime', label: '通话时间' },
  639. { key: 'personRegionId', label: '本方基站大区号' },
  640. { key: 'personCellId', label: '本方基站小区号' },
  641. { key: 'personStationName', label: '本方基站名称' },
  642. { key: 'personStationLongitude', label: '本方基站经度' },
  643. { key: 'personStationLatitude', label: '本方基站纬度' },
  644. ],
  645. trans_record: [
  646. { key: 'personCardNo', label: '本方卡号' },
  647. { key: 'otherName', label: '对方姓名' },
  648. { key: 'otherAccount', label: '对方账号' },
  649. { key: 'otherCardNo', label: '对方卡号' },
  650. { key: 'debitCreditFlag', label: '借贷标志' },
  651. { key: 'transType', label: '交易方向' },
  652. { key: 'currency', label: '币种' },
  653. { key: 'transAmount', label: '交易金额' },
  654. { key: 'transBalance', label: '交易后余额' },
  655. { key: 'transDateTime', label: '交易时间' },
  656. { key: 'transSerialNo', label: '交易流水号' },
  657. { key: 'otherAccountBank', label: '对方开户行' },
  658. { key: 'branchName', label: '本方网点' },
  659. { key: 'transSummary', label: '交易摘要' },
  660. { key: 'remark', label: '备注' },
  661. ],
  662. hotel_stay_info: [
  663. { key: 'personCertNo', label: '证件号码' },
  664. { key: 'hotelName', label: '酒店名称' },
  665. { key: 'inDateTime', label: '入住时间' },
  666. { key: 'roomNo', label: '房间号' },
  667. { key: 'addressDetail', label: '详细地址' },
  668. ],
  669. train_ticket_info: [
  670. { key: 'personCertNo', label: '证件号码' },
  671. { key: 'trainNo', label: '车号' },
  672. { key: 'depDateTime', label: '出发时间' },
  673. { key: 'origin', label: '发站' },
  674. { key: 'destination', label: '到站' },
  675. ],
  676. together_train_info: [
  677. { key: 'personCertNo', label: '证件号码' },
  678. { key: 'trainNo', label: '车次' },
  679. { key: 'depDateTime', label: '出发时间' },
  680. { key: 'origin', label: '起始站名' },
  681. { key: 'destination', label: '终点站名' },
  682. ],
  683. flight_ticket_info: [
  684. { key: 'personCertNo', label: '证件号码' },
  685. { key: 'ticketNo', label: '票号' },
  686. { key: 'depDateTime', label: '出发时间' },
  687. { key: 'depAirport', label: '起飞机场代码' },
  688. { key: 'arrAirport', label: '到达机场代码' },
  689. ],
  690. flight_departure_info: [
  691. { key: 'personCertNo', label: '证件号码' },
  692. { key: 'flightNo', label: '航班号' },
  693. { key: 'depDateTime', label: '离港时间' },
  694. { key: 'depCode', label: '起飞港三字码' },
  695. { key: 'arrCode', label: '到达港三字码' },
  696. ],
  697. together_flight_info: [
  698. { key: 'personCertNo', label: '证件号码' },
  699. { key: 'flightNo', label: '航班号' },
  700. { key: 'flightDateTime', label: '航班时间' },
  701. { key: 'origin', label: '出发地' },
  702. { key: 'destination', label: '目的地' },
  703. ],
  704. track_car_ticket_info: [
  705. { key: 'personCertNo', label: '证件号码' },
  706. { key: 'bch', label: '班次号' },
  707. { key: 'depDateTime', label: '发车时间' },
  708. { key: 'origin', label: '始发车站名称' },
  709. { key: 'destination', label: '目的地名称' },
  710. ],
  711. express_info: [
  712. { key: 'recipientName', label: '收件人' },
  713. { key: 'senderName', label: '寄件人' },
  714. { key: 'sendDate', label: '寄件时间' },
  715. { key: 'recipientCurrentAddress', label: '收件地址' },
  716. { key: 'senderCurrentAddress', label: '寄件地址' },
  717. ],
  718. };
  719. const DETAIL_FIELD_LABEL_MAP: Record<string, string> = {
  720. id: '详情ID',
  721. rowId: '行ID',
  722. fileId: '文件ID',
  723. sheetId: '工作表ID',
  724. tableName: '表名',
  725. tableNameCn: '表中文名',
  726. tableNameEn: '表英文名',
  727. personName: '对象姓名',
  728. seriesDateTime: '发生时间',
  729. bankName: '银行名称',
  730. personCertNo: '本方证件号',
  731. personAccount: '本方账号',
  732. personCardNo: '本方卡号',
  733. personLibId: '人员库ID',
  734. personPhone: '本方号码',
  735. personRegionId: '本方基站大区号',
  736. personCellId: '本方基站小区号',
  737. personStationName: '本方基站名称',
  738. personStationLongitude: '本方基站经度',
  739. personStationLatitude: '本方基站纬度',
  740. otherName: '对方姓名',
  741. otherCertNo: '对方证件号',
  742. otherAccount: '对方账号',
  743. otherCardNo: '对方卡号',
  744. otherPhone: '对方号码',
  745. otherBalance: '对方余额',
  746. otherAccountBank: '对方开户行',
  747. callDirection: '主被叫标识',
  748. operatorId: '运营商',
  749. callDateTime: '通话时间',
  750. transType: '交易方向',
  751. debitCreditFlag: '借贷标志',
  752. currency: '币种',
  753. transAmount: '交易金额',
  754. transBalance: '交易后余额',
  755. transDateTime: '交易时间',
  756. transDate: '交易日期',
  757. transSerialNo: '交易流水号',
  758. branchName: '本方网点',
  759. branchCode: '网点编号',
  760. cashFlag: '现转标志',
  761. terminalNo: '终端号',
  762. transLocation: '交易地点',
  763. merchantName: '商户名称',
  764. merchantNo: '商户编号',
  765. personIpAddress: '本方IP',
  766. personMacAddress: '本方MAC',
  767. transClerkNo: '柜员号',
  768. transChannel: '交易渠道',
  769. transSummary: '交易摘要',
  770. remark: '备注',
  771. festivalName: '节假日标识',
  772. hotelName: '酒店名称',
  773. inDateTime: '入住时间',
  774. roomNo: '房间号',
  775. addressDetail: '详细地址',
  776. trainNo: '车次',
  777. depDateTime: '出发时间',
  778. origin: '出发地',
  779. destination: '目的地',
  780. ticketNo: '票号',
  781. depAirport: '起飞机场代码',
  782. arrAirport: '到达机场代码',
  783. flightNo: '航班号',
  784. depCode: '起飞港三字码',
  785. arrCode: '到达港三字码',
  786. sendDate: '寄递时间',
  787. bch: '班次号',
  788. recipientName: '收件人姓名',
  789. senderName: '寄件人姓名',
  790. recipientCurrentAddress: '收件人现住址地址名称',
  791. senderCurrentAddress: '寄件人现住址地址名称',
  792. personNameCn: '旅客中文姓名',
  793. companionName: '同行人姓名',
  794. size: '分页大小',
  795. total: '总条数',
  796. pages: '总页数',
  797. };
  798. const DETAIL_SECTION_FIELDS_MAP: Record<string, DetailSectionConfig[]> = {
  799. trans_record: [
  800. {
  801. title: '主体账户',
  802. fields: [
  803. { key: 'personCardNo', label: '本方卡号' },
  804. { key: 'personAccount', label: '本方账号' },
  805. { key: 'personCertNo', label: '本方证件号' },
  806. { key: 'bankName', label: '银行名称' },
  807. ],
  808. },
  809. {
  810. title: '交易对手',
  811. fields: [
  812. { key: 'otherName', label: '对方姓名' },
  813. { key: 'otherAccount', label: '对方账号' },
  814. { key: 'otherCardNo', label: '对方卡号' },
  815. { key: 'otherCertNo', label: '对方证件号' },
  816. { key: 'otherAccountBank', label: '对方开户行' },
  817. { key: 'otherBalance', label: '对方余额' },
  818. ],
  819. },
  820. {
  821. title: '交易信息',
  822. fields: [
  823. { key: 'transType', label: '交易方向' },
  824. { key: 'debitCreditFlag', label: '借贷标志' },
  825. { key: 'currency', label: '币种' },
  826. { key: 'transAmount', label: '交易金额' },
  827. { key: 'transBalance', label: '交易后余额' },
  828. { key: 'transDateTime', label: '交易时间' },
  829. { key: 'transDate', label: '交易日期' },
  830. { key: 'transSerialNo', label: '交易流水号' },
  831. { key: 'festivalName', label: '节假日标识' },
  832. ],
  833. },
  834. {
  835. title: '渠道与网点',
  836. fields: [
  837. { key: 'branchName', label: '本方网点' },
  838. { key: 'branchCode', label: '网点编号' },
  839. { key: 'cashFlag', label: '现转标志' },
  840. { key: 'terminalNo', label: '终端号' },
  841. { key: 'transLocation', label: '交易地点' },
  842. { key: 'merchantName', label: '商户名称' },
  843. { key: 'merchantNo', label: '商户编号' },
  844. { key: 'transChannel', label: '交易渠道' },
  845. { key: 'transClerkNo', label: '柜员号' },
  846. ],
  847. },
  848. {
  849. title: '补充说明',
  850. fields: [
  851. { key: 'transSummary', label: '交易摘要' },
  852. { key: 'remark', label: '备注' },
  853. { key: 'personIpAddress', label: '本方IP' },
  854. { key: 'personMacAddress', label: '本方MAC' },
  855. ],
  856. },
  857. ],
  858. call_record: [
  859. {
  860. title: '通联信息',
  861. fields: [
  862. { key: 'personPhone', label: '本方号码' },
  863. { key: 'otherPhone', label: '对方号码' },
  864. { key: 'callDirection', label: '主被叫标识' },
  865. { key: 'operatorId', label: '运营商' },
  866. { key: 'callDateTime', label: '通话时间' },
  867. ],
  868. },
  869. {
  870. title: '基站信息',
  871. fields: [
  872. { key: 'personRegionId', label: '本方基站大区号' },
  873. { key: 'personCellId', label: '本方基站小区号' },
  874. { key: 'personStationName', label: '本方基站名称' },
  875. { key: 'personStationLongitude', label: '本方基站经度' },
  876. { key: 'personStationLatitude', label: '本方基站纬度' },
  877. ],
  878. },
  879. ],
  880. };
  881. const DETAIL_LONG_VALUE_KEYS = new Set([
  882. 'transSummary',
  883. 'remark',
  884. 'addressDetail',
  885. 'address',
  886. 'summary',
  887. 'ext1',
  888. 'ext2',
  889. 'ext3',
  890. 'ext4',
  891. 'ext5',
  892. 'ext6',
  893. 'ext7',
  894. 'ext8',
  895. 'ext9',
  896. ]);
  897. const DETAIL_RAW_SKIP_KEYS = new Set(['records', 'list', 'data', 'detail', 'record', 'result']);
  898. const DETAIL_WRAPPER_SKIP_KEYS = new Set([
  899. ...DETAIL_RAW_SKIP_KEYS,
  900. 'head',
  901. 'dt',
  902. 'message',
  903. 'code',
  904. 'success',
  905. 'status',
  906. ]);
  907. const DETAIL_ID_SKIP_KEYS = new Set(['id', 'rowId', 'fileId', 'sheetId', 'personLibId', 'size', 'total', 'pages']);
  908. const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
  909. const toText = (value?: string | number | null) => {
  910. if (value === 0) return '0';
  911. if (value === null || value === undefined) return '';
  912. return String(value).trim();
  913. };
  914. const fallbackText = (value?: string | number | null, fallback = '-') => {
  915. const text = toText(value);
  916. return text || fallback;
  917. };
  918. const joinText = (parts: Array<string | number | null | undefined>, separator = ' · ', fallback = '-') => {
  919. const values = parts.map((item) => toText(item)).filter(Boolean);
  920. return values.length ? values.join(separator) : fallback;
  921. };
  922. const parseNumeric = (value?: string | number | null) => {
  923. const text = toText(value).replace(/,/g, '');
  924. if (!text) return undefined;
  925. const numeric = Number(text);
  926. return Number.isFinite(numeric) ? numeric : undefined;
  927. };
  928. const formatAmount = (value?: string | number | null) => {
  929. const numeric = parseNumeric(value);
  930. if (numeric === undefined) return fallbackText(value);
  931. return numeric.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  932. };
  933. const maskValue = (value?: string | number | null, keepStart = 4, keepEnd = 4) => {
  934. const text = toText(value);
  935. if (!text) return '-';
  936. if (text.length <= keepStart + keepEnd) return text;
  937. return `${text.slice(0, keepStart)}****${text.slice(-keepEnd)}`;
  938. };
  939. const formatDateTimeText = (value?: string | null) => {
  940. const text = toText(value);
  941. if (!text) return '-';
  942. const parsed = dayjs(text);
  943. return parsed.isValid() ? parsed.format(DATE_TIME_FORMAT) : text;
  944. };
  945. const getTimeStamp = (value?: string | null) => {
  946. const parsed = dayjs(toText(value));
  947. return parsed.isValid() ? parsed.valueOf() : Number.MAX_SAFE_INTEGER;
  948. };
  949. const mapCallDirection = (value?: string | null) => {
  950. const text = toText(value);
  951. if (text === 'CALL_IN') return '被叫';
  952. if (text === 'CALL_OUT') return '主叫';
  953. return text || '通信';
  954. };
  955. const mapDebitCreditFlag = (value?: string | null) => {
  956. const text = toText(value);
  957. if (!text) return '交易';
  958. if (/[收入贷]/.test(text)) return '收入';
  959. if (/[支出借]/.test(text)) return '支出';
  960. return text;
  961. };
  962. const isNightEvent = (value?: string | null) => {
  963. const parsed = dayjs(toText(value));
  964. if (!parsed.isValid()) return false;
  965. const hour = parsed.hour();
  966. return hour >= 22 || hour < 6;
  967. };
  968. const normalizeTimeSeriesTableNameEn = (value?: string | null) => {
  969. const normalized = TABLE_EN_ALIAS_MAP[toText(value)] || toText(value);
  970. if (!normalized) return '';
  971. if (normalized.endsWith('_call_record')) return 'call_record';
  972. if (normalized.endsWith('_trans_record')) return 'trans_record';
  973. return normalized;
  974. };
  975. // GovernCache.java 里存在 `hotel_stay_info -> trans_record` 这类误标,
  976. // 同时部分接口会返回 `use_open_info_call_record` 这类真实表名。
  977. // 这里统一先归一表英文名,再在“未知表名”场景下回退到中文表名,避免前端把通信记录误识别成轨迹。
  978. const resolveTimeSeriesTableKey = (item: TimeSeries) => {
  979. const tableNameEn = normalizeTimeSeriesTableNameEn(item.tableNameEn);
  980. const tableNameCn = toText(item.tableNameCn);
  981. const fallbackTableKey = TABLE_CN_TO_EN_MAP[tableNameCn] || '';
  982. if (tableNameEn && KNOWN_TIME_SERIES_TABLE_KEYS.has(tableNameEn)) {
  983. if (tableNameEn === 'trans_record' && fallbackTableKey === 'hotel_stay_info') {
  984. return fallbackTableKey;
  985. }
  986. return tableNameEn;
  987. }
  988. return fallbackTableKey || tableNameEn;
  989. };
  990. const getBehaviorTypeFromTimeSeries = (item: TimeSeries): BehaviorType => {
  991. const tableKey = resolveTimeSeriesTableKey(item);
  992. if (tableKey === 'call_record') return '通信信息';
  993. if (tableKey === 'trans_record') return '交易信息';
  994. if (tableKey === 'hotel_stay_info' && isNightEvent(item.seriesDateTime)) return '轨迹信息';
  995. return '轨迹信息';
  996. };
  997. const normalizeTimeSeriesList = (resp: any): TimeSeries[] => {
  998. const extractIndexedObjectList = (value: unknown): TimeSeries[] => {
  999. if (!value || Array.isArray(value) || typeof value !== 'object') return [];
  1000. return Object.entries(value as Record<string, unknown>)
  1001. .filter(([key, item]) => /^\d+$/.test(key) && !!item && typeof item === 'object')
  1002. .sort((left, right) => Number(left[0]) - Number(right[0]))
  1003. .map(([, item]) => item as TimeSeries);
  1004. };
  1005. if (Array.isArray(resp)) return resp;
  1006. if (Array.isArray(resp?.records)) return resp.records;
  1007. if (Array.isArray(resp?.list)) return resp.list;
  1008. if (Array.isArray(resp?.data)) return resp.data;
  1009. if (Array.isArray(resp?.data?.records)) return resp.data.records;
  1010. if (Array.isArray(resp?.data?.list)) return resp.data.list;
  1011. if (Array.isArray(resp?.page?.records)) return resp.page.records;
  1012. if (Array.isArray(resp?.page?.list)) return resp.page.list;
  1013. const indexedDataList = extractIndexedObjectList(resp?.data);
  1014. if (indexedDataList.length) return indexedDataList;
  1015. const indexedRespList = extractIndexedObjectList(resp);
  1016. if (indexedRespList.length) return indexedRespList;
  1017. return [];
  1018. };
  1019. const normalizeTimeSeriesTotal = (resp: any, listLength: number) => {
  1020. const total =
  1021. resp?.total ?? resp?.count ?? resp?.data?.total ?? resp?.data?.count ?? resp?.page?.total ?? resp?.page?.count;
  1022. return Number.isFinite(Number(total)) ? Number(total) : 0;
  1023. };
  1024. const randomUnit = (seed: number) => {
  1025. const raw = Math.sin(seed * 12.9898 + 78.233) * 43758.5453123;
  1026. return raw - Math.floor(raw);
  1027. };
  1028. const makeFilters = (): BehaviorFilters => ({
  1029. targets: [],
  1030. checkedTargets: [],
  1031. behaviorTypes: [],
  1032. dateRange: undefined,
  1033. keyword: '',
  1034. locatedOnly: 'all' as 'all' | 'located',
  1035. });
  1036. const toDateFilterValue = (value?: string) => {
  1037. const text = toText(value);
  1038. if (!text) return undefined;
  1039. return text.slice(0, 10);
  1040. };
  1041. const buildFiltersFromJumpPayload = (payload: BehaviorSpacetimeJumpPayload): BehaviorFilters => {
  1042. const jumpTargets = normalizeTargetList([
  1043. ...(payload.personNames || []),
  1044. ...(payload.personName ? [payload.personName] : []),
  1045. ]);
  1046. return {
  1047. ...makeFilters(),
  1048. targets: jumpTargets,
  1049. checkedTargets: jumpTargets,
  1050. keyword: payload.keyword || '',
  1051. };
  1052. };
  1053. const buildRouteJumpSignature = (payload?: BehaviorSpacetimeJumpPayload | null) =>
  1054. payload ? JSON.stringify(buildTimeSeriesJumpQuery(payload)) : '';
  1055. const buildJumpRequestSignature = (payload: BehaviorSpacetimeJumpPayload, snapshot: BehaviorFilters) =>
  1056. JSON.stringify({
  1057. jump: buildTimeSeriesJumpQuery(payload),
  1058. filters: {
  1059. targets: normalizeTargetList(snapshot.targets),
  1060. checkedTargets: normalizeTargetList(snapshot.checkedTargets),
  1061. behaviorTypes: [...snapshot.behaviorTypes],
  1062. dateRange: snapshot.dateRange || [],
  1063. keyword: snapshot.keyword.trim(),
  1064. locatedOnly: snapshot.locatedOnly,
  1065. },
  1066. page: 1,
  1067. });
  1068. const resetBehaviorSpacetimeJumpState = (nextFilters: BehaviorFilters) => {
  1069. filters.value = cloneFilters(nextFilters);
  1070. applied.value = cloneFilters(nextFilters);
  1071. targetKeyword.value = '';
  1072. targetDraft.value = [...nextFilters.targets];
  1073. targetPickerOpen.value = false;
  1074. remoteEvents.value = [];
  1075. remoteTotal.value = 0;
  1076. timelinePage.value = 1;
  1077. timelineHasMore.value = true;
  1078. timelineCanLoadForward.value = true;
  1079. timelineCanLoadBackward.value = true;
  1080. timelineMoreLoading.value = false;
  1081. timelineListInitialized.value = false;
  1082. selectedId.value = '';
  1083. clearPendingTimelineScroll();
  1084. detailCache.clear();
  1085. detailRecordCache.value = {};
  1086. detailHeadCache.value = {};
  1087. detailDrawerOpen.value = false;
  1088. detailLoading.value = false;
  1089. detailError.value = '';
  1090. detailRequestToken += 1;
  1091. };
  1092. const buildJumpAnchorEvent = (payload: BehaviorSpacetimeJumpPayload): BehaviorEvent | undefined => {
  1093. const recordId = toText(payload.id || payload.rowId);
  1094. const tableNameEn = toText(payload.tableNameEn || payload.tableName);
  1095. const tableNameCn = toText(payload.tableNameCn);
  1096. const occurredAt = formatDateTimeText(payload.seriesDateTime);
  1097. if (!recordId && !tableNameEn && !tableNameCn && !occurredAt) return undefined;
  1098. const normalizedRecord = {
  1099. id: recordId || `jump-anchor-${joinText([tableNameEn, occurredAt], '-', 'unknown')}`,
  1100. rowId: recordId || undefined,
  1101. tableNameEn: tableNameEn || undefined,
  1102. tableNameCn: tableNameCn || undefined,
  1103. personName: toText(payload.personName) || '-',
  1104. seriesDateTime: toText(payload.seriesDateTime) || undefined,
  1105. } as TimeSeries;
  1106. return normalizeTimeSeriesToBehaviorEvent(normalizedRecord, 0);
  1107. };
  1108. const cloneRange = (range?: RangeValue): RangeValue => (range ? [range[0], range[1]] : undefined);
  1109. const cloneFilters = (value: BehaviorFilters): BehaviorFilters => ({
  1110. ...value,
  1111. targets: [...value.targets],
  1112. checkedTargets: [...value.checkedTargets],
  1113. behaviorTypes: [...value.behaviorTypes],
  1114. dateRange: cloneRange(value.dateRange),
  1115. });
  1116. const BEHAVIOR_LABEL_MAP: Record<BehaviorType, string> = {
  1117. 轨迹信息: '轨迹信息',
  1118. 交易信息: '交易信息',
  1119. 通信信息: '通信信息',
  1120. };
  1121. const BEHAVIOR_QUERY_TYPE_MAP: Record<BehaviorType, string> = {
  1122. 轨迹信息: 'TRACK',
  1123. 交易信息: 'TRANS',
  1124. 通信信息: 'CALL',
  1125. };
  1126. const toBehaviorQueryTypes = (behaviorTypes: BehaviorType[]) =>
  1127. Array.from(new Set(behaviorTypes.map((item) => BEHAVIOR_QUERY_TYPE_MAP[item]).filter(Boolean)));
  1128. const behaviorOptions = [
  1129. { label: '通信信息', value: '通信信息' as BehaviorType },
  1130. { label: '交易信息', value: '交易信息' as BehaviorType },
  1131. { label: '轨迹信息', value: '轨迹信息' as BehaviorType },
  1132. ];
  1133. const normalizeTimeSeriesToBehaviorEvent = (item: TimeSeries, index: number): BehaviorEvent => {
  1134. // ext1~ext9 的业务含义按 GovernCache.java 每张表旁边的注释解释。
  1135. const tableKey = resolveTimeSeriesTableKey(item);
  1136. const behaviorType = getBehaviorTypeFromTimeSeries(item);
  1137. const occurredAt = formatDateTimeText(item.seriesDateTime);
  1138. const sourceLabel = fallbackText(item.tableNameCn, TABLE_SOURCE_LABEL_MAP[tableKey] || '时空事件');
  1139. const rawTableNameEn = toText(item.tableNameEn);
  1140. const lng = parseNumeric(item.ext8);
  1141. const lat = parseNumeric(item.ext9);
  1142. const hasCoordinate = typeof lng === 'number' && typeof lat === 'number';
  1143. const baseEvent = {
  1144. id: joinText([tableKey || 'time-series', item.rowId || item.id || index], '-', `time-series-${index}`),
  1145. rowId: toText(item.rowId) || undefined,
  1146. recordId: toText(item.id || item.rowId) || undefined,
  1147. rawTableNameEn: rawTableNameEn || undefined,
  1148. tableNameEn: tableKey || rawTableNameEn || undefined,
  1149. tableNameCn: toText(item.tableNameCn) || undefined,
  1150. otherName: toText(item.otherName) || undefined,
  1151. ext1: toText(item.ext1) || undefined,
  1152. ext2: toText(item.ext2) || undefined,
  1153. ext3: toText(item.ext3) || undefined,
  1154. ext4: toText(item.ext4) || undefined,
  1155. ext5: toText(item.ext5) || undefined,
  1156. ext6: toText(item.ext6) || undefined,
  1157. ext7: toText(item.ext7) || undefined,
  1158. ext8: toText(item.ext8) || undefined,
  1159. ext9: toText(item.ext9) || undefined,
  1160. personName: fallbackText(item.personName, '未知对象'),
  1161. behaviorType,
  1162. occurredAt,
  1163. sourceLabel,
  1164. hasCoordinate,
  1165. lng,
  1166. lat,
  1167. };
  1168. if (tableKey === 'call_record') {
  1169. const directionText = mapCallDirection(item.ext4);
  1170. const otherLabel = fallbackText(item.otherName || item.ext2, '未知对端');
  1171. const locationName = fallbackText(item.ext7, '通信基站');
  1172. const stationCode = joinText(
  1173. [item.ext5 ? `LAC ${item.ext5}` : undefined, item.ext6 ? `CID ${item.ext6}` : undefined],
  1174. ' / ',
  1175. '基站编号待补充',
  1176. );
  1177. return {
  1178. ...baseEvent,
  1179. title: `${directionText}通信 · ${otherLabel}`,
  1180. locationName,
  1181. address: stationCode,
  1182. durationMinutes: 0,
  1183. accuracyMeters: 350,
  1184. relatedCount: 1,
  1185. summary: `${baseEvent.personName}于${occurredAt}与${otherLabel}发生${directionText}通信,运营商 ${fallbackText(
  1186. item.ext3,
  1187. '未知',
  1188. )}。`,
  1189. metaPrimaryLabel: '方向',
  1190. metaPrimaryValue: directionText,
  1191. metaSecondaryLabel: '基站',
  1192. metaSecondaryValue: stationCode,
  1193. };
  1194. }
  1195. if (tableKey === 'trans_record') {
  1196. const directionText = mapDebitCreditFlag(item.ext4);
  1197. const amountText = formatAmount(item.ext6);
  1198. const otherLabel = fallbackText(item.otherName || item.ext2, '未知对手');
  1199. const bankName = fallbackText(item.ext3, '未知银行');
  1200. return {
  1201. ...baseEvent,
  1202. title: `${directionText}交易 · ${otherLabel}`,
  1203. locationName: bankName,
  1204. address: joinText([`本方卡 ${maskValue(item.ext1)}`, `对手卡 ${maskValue(item.ext2)}`], ' / '),
  1205. durationMinutes: 0,
  1206. accuracyMeters: 0,
  1207. relatedCount: item.otherName ? 2 : 1,
  1208. summary: `${baseEvent.personName}于${occurredAt}与${otherLabel}发生${directionText}交易,金额 ${
  1209. amountText === '-' ? amountText : `${amountText} 元`
  1210. }。`,
  1211. metaPrimaryLabel: '金额',
  1212. metaPrimaryValue: amountText === '-' ? amountText : `${amountText} 元`,
  1213. metaSecondaryLabel: '方向',
  1214. metaSecondaryValue: directionText,
  1215. };
  1216. }
  1217. if (tableKey === 'hotel_stay_info') {
  1218. const hotelName = fallbackText(item.ext2, '未知酒店');
  1219. const roomNo = fallbackText(item.ext4, '待补充');
  1220. return {
  1221. ...baseEvent,
  1222. title: `入住停留 · ${hotelName}`,
  1223. locationName: hotelName,
  1224. address: fallbackText(item.ext5, '酒店地址待补充'),
  1225. durationMinutes: 180,
  1226. accuracyMeters: 80,
  1227. relatedCount: 1,
  1228. summary: `${baseEvent.personName}于${occurredAt}入住${hotelName},房号 ${roomNo}。`,
  1229. metaPrimaryLabel: '房号',
  1230. metaPrimaryValue: roomNo,
  1231. metaSecondaryLabel: '证件',
  1232. metaSecondaryValue: maskValue(item.ext1, 6, 4),
  1233. };
  1234. }
  1235. if (tableKey === 'train_ticket_info' || tableKey === 'together_train_info') {
  1236. const trainNo = fallbackText(item.ext2, '未知车次');
  1237. const routeText = joinText([item.ext3, item.ext4], ' → ', '路线待补充');
  1238. return {
  1239. ...baseEvent,
  1240. title: `${tableKey === 'together_train_info' ? '同行铁路' : '铁路行程'} · ${trainNo}`,
  1241. locationName: routeText,
  1242. address: routeText,
  1243. durationMinutes: 90,
  1244. accuracyMeters: 120,
  1245. relatedCount: tableKey === 'together_train_info' ? 2 : 1,
  1246. summary: `${baseEvent.personName}于${occurredAt}乘坐${trainNo},路线 ${routeText}${
  1247. tableKey === 'together_train_info' ? ',命中同行落点线索。' : '。'
  1248. }`,
  1249. metaPrimaryLabel: '车次',
  1250. metaPrimaryValue: trainNo,
  1251. metaSecondaryLabel: '路线',
  1252. metaSecondaryValue: routeText,
  1253. };
  1254. }
  1255. if (tableKey === 'flight_ticket_info') {
  1256. const ticketNo = fallbackText(item.ext2, '未知票号');
  1257. const routeText = joinText([item.ext3, item.ext4], ' → ', '航线待补充');
  1258. return {
  1259. ...baseEvent,
  1260. title: `航班行程 · ${ticketNo}`,
  1261. locationName: routeText,
  1262. address: routeText,
  1263. durationMinutes: 120,
  1264. accuracyMeters: 150,
  1265. relatedCount: 1,
  1266. summary: `${baseEvent.personName}于${occurredAt}命中航班订票记录,票号 ${ticketNo},航线 ${routeText}。`,
  1267. metaPrimaryLabel: '票号',
  1268. metaPrimaryValue: ticketNo,
  1269. metaSecondaryLabel: '航线',
  1270. metaSecondaryValue: routeText,
  1271. };
  1272. }
  1273. if (tableKey === 'flight_departure_info' || tableKey === 'together_flight_info') {
  1274. const flightNo = fallbackText(item.ext2, '未知航班');
  1275. const routeText = joinText([item.ext3, item.ext4], ' → ', '航线待补充');
  1276. return {
  1277. ...baseEvent,
  1278. title: `${tableKey === 'together_flight_info' ? '同行航班' : '航班行程'} · ${flightNo}`,
  1279. locationName: routeText,
  1280. address: routeText,
  1281. durationMinutes: 120,
  1282. accuracyMeters: 150,
  1283. relatedCount: tableKey === 'together_flight_info' ? 2 : 1,
  1284. summary: `${baseEvent.personName}于${occurredAt}乘坐${flightNo},航线 ${routeText}${
  1285. tableKey === 'together_flight_info' ? ',命中同行落点线索。' : '。'
  1286. }`,
  1287. metaPrimaryLabel: '航班',
  1288. metaPrimaryValue: flightNo,
  1289. metaSecondaryLabel: '航线',
  1290. metaSecondaryValue: routeText,
  1291. };
  1292. }
  1293. if (tableKey === 'track_car_ticket_info') {
  1294. const busNo = fallbackText(item.ext2, '未知班次');
  1295. const routeText = joinText([item.ext3, item.ext4], ' → ', '路线待补充');
  1296. return {
  1297. ...baseEvent,
  1298. title: `汽车行程 · ${busNo}`,
  1299. locationName: routeText,
  1300. address: routeText,
  1301. durationMinutes: 90,
  1302. accuracyMeters: 180,
  1303. relatedCount: 1,
  1304. summary: `${baseEvent.personName}于${occurredAt}乘坐汽车班次 ${busNo},路线 ${routeText}。`,
  1305. metaPrimaryLabel: '班次',
  1306. metaPrimaryValue: busNo,
  1307. metaSecondaryLabel: '路线',
  1308. metaSecondaryValue: routeText,
  1309. };
  1310. }
  1311. if (tableKey === 'express_info') {
  1312. const senderName = fallbackText(item.otherName, '未知寄件人');
  1313. const receiveAddress = fallbackText(item.ext3, '收件地址待补充');
  1314. const senderAddress = fallbackText(item.ext4, '寄件地址待补充');
  1315. return {
  1316. ...baseEvent,
  1317. title: `快递收发 · ${senderName}`,
  1318. locationName: receiveAddress,
  1319. address: joinText([`收件地 ${receiveAddress}`, `寄件地 ${senderAddress}`], ' / '),
  1320. durationMinutes: 20,
  1321. accuracyMeters: 200,
  1322. relatedCount: item.otherName ? 2 : 1,
  1323. summary: `${baseEvent.personName}于${occurredAt}接收来自${senderName}的快递,收件地 ${receiveAddress}。`,
  1324. metaPrimaryLabel: '寄件人',
  1325. metaPrimaryValue: senderName,
  1326. metaSecondaryLabel: '寄件地',
  1327. metaSecondaryValue: senderAddress,
  1328. };
  1329. }
  1330. return {
  1331. ...baseEvent,
  1332. title: sourceLabel,
  1333. locationName: fallbackText(item.otherName || item.ext2 || item.ext3, '位置待补充'),
  1334. address: fallbackText(item.ext5 || item.ext4 || item.ext3, '地址待补充'),
  1335. durationMinutes: 0,
  1336. accuracyMeters: 0,
  1337. relatedCount: item.otherName ? 2 : 1,
  1338. summary: `${baseEvent.personName}于${occurredAt}命中 ${sourceLabel} 记录。`,
  1339. metaPrimaryLabel: '来源',
  1340. metaPrimaryValue: sourceLabel,
  1341. metaSecondaryLabel: '记录ID',
  1342. metaSecondaryValue: fallbackText(item.id || item.rowId),
  1343. };
  1344. };
  1345. const normalizeDetailHeadColumns = (source: unknown): DetailHeadColumn[] => {
  1346. if (!Array.isArray(source)) return [];
  1347. return source.filter((item): item is DetailHeadColumn => !!item && typeof item === 'object');
  1348. };
  1349. const normalizeTimeSeriesDetailPayload = (resp: any): { record?: TimeSeriesDetailDTO; head: DetailHeadColumn[] } => {
  1350. const mergeDetailMeta = (record: any, container?: any) => {
  1351. if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined;
  1352. return {
  1353. ...record,
  1354. size: record.size ?? container?.size ?? container?.pageSize,
  1355. total: record.total ?? container?.total,
  1356. pages: record.pages ?? container?.pages,
  1357. } as TimeSeriesDetailDTO;
  1358. };
  1359. let current = resp;
  1360. for (let index = 0; index < 6; index += 1) {
  1361. if (!current || Array.isArray(current) || typeof current !== 'object') return { head: [], record: undefined };
  1362. const head = normalizeDetailHeadColumns(current.head);
  1363. const dt = current.dt;
  1364. if (head.length || (dt && typeof dt === 'object' && !Array.isArray(dt))) {
  1365. return {
  1366. head,
  1367. record: mergeDetailMeta(dt && typeof dt === 'object' && !Array.isArray(dt) ? dt : current, current),
  1368. };
  1369. }
  1370. if (Array.isArray(current.records)) return { head: [], record: mergeDetailMeta(current.records[0], current) };
  1371. if (Array.isArray(current.list)) return { head: [], record: mergeDetailMeta(current.list[0], current) };
  1372. if (Array.isArray(current.rows)) return { head: [], record: mergeDetailMeta(current.rows[0], current) };
  1373. if (Array.isArray(current.data)) return { head: [], record: mergeDetailMeta(current.data[0], current) };
  1374. const next =
  1375. (current.data && !Array.isArray(current.data) ? current.data : undefined) ||
  1376. current.detail ||
  1377. current.record ||
  1378. current.row ||
  1379. current.obj ||
  1380. current.result;
  1381. if (!next || next === current) {
  1382. return { head, record: mergeDetailMeta(current, current) };
  1383. }
  1384. current = next;
  1385. }
  1386. return {
  1387. head: normalizeDetailHeadColumns(current?.head),
  1388. record: mergeDetailMeta(current, current),
  1389. };
  1390. };
  1391. const extractDetailRecordEntries = (
  1392. source: unknown,
  1393. path = '',
  1394. entries: Array<{ key: string; value: unknown }> = [],
  1395. ) => {
  1396. if (!source || typeof source !== 'object' || Array.isArray(source)) return entries;
  1397. Object.entries(source as Record<string, unknown>).forEach(([key, value]) => {
  1398. if (DETAIL_WRAPPER_SKIP_KEYS.has(key)) {
  1399. if (value && typeof value === 'object' && !Array.isArray(value)) {
  1400. extractDetailRecordEntries(value, path ? `${path}.${key}` : key, entries);
  1401. }
  1402. return;
  1403. }
  1404. const nestedKey = path ? `${path}.${key}` : key;
  1405. entries.push({ key: nestedKey, value });
  1406. });
  1407. return entries;
  1408. };
  1409. const shouldSkipDetailField = (fieldKey: string) => {
  1410. const normalizedKey = fieldKey.split('.').pop() || fieldKey;
  1411. return DETAIL_ID_SKIP_KEYS.has(normalizedKey);
  1412. };
  1413. const resolveDetailColumnFieldKey = (column?: DetailHeadColumn | null) => {
  1414. if (!column) return '';
  1415. const dataIndex = column.dataIndex;
  1416. if (Array.isArray(dataIndex)) {
  1417. return dataIndex
  1418. .map((item) => toText(item))
  1419. .filter(Boolean)
  1420. .join('.');
  1421. }
  1422. return toText(dataIndex) || toText(column.key);
  1423. };
  1424. const resolveDetailFieldValue = (source: unknown, fieldKey: string): unknown => {
  1425. if (!source || typeof source !== 'object' || !fieldKey) return undefined;
  1426. const record = source as Record<string, unknown>;
  1427. if (fieldKey in record) return record[fieldKey];
  1428. return fieldKey.split('.').reduce<unknown>((acc, segment) => {
  1429. if (!segment || !acc || typeof acc !== 'object' || Array.isArray(acc)) return undefined;
  1430. return (acc as Record<string, unknown>)[segment];
  1431. }, source);
  1432. };
  1433. const createRawDetailFields = (detailRecord: TimeSeriesDetailDTO, tableKey: string) => {
  1434. return extractDetailRecordEntries(detailRecord)
  1435. .filter(({ key }) => !shouldSkipDetailField(key))
  1436. .map(({ key, value }) => {
  1437. const normalizedKey = key.split('.').pop() || key;
  1438. return createDetailField(key, getDetailFieldLabel(normalizedKey, tableKey), value, tableKey, normalizedKey);
  1439. })
  1440. .filter((field) => field.value !== '-');
  1441. };
  1442. const createMappedDetailFields = (
  1443. columns: DetailHeadColumn[],
  1444. detailRecord: TimeSeriesDetailDTO,
  1445. tableKey: string,
  1446. ): DetailField[] => {
  1447. const consumedKeys = new Set<string>();
  1448. return columns
  1449. .map((column, index) => {
  1450. const fieldKey = resolveDetailColumnFieldKey(column);
  1451. if (!fieldKey || consumedKeys.has(fieldKey) || shouldSkipDetailField(fieldKey)) return null;
  1452. consumedKeys.add(fieldKey);
  1453. const normalizedKey = fieldKey.split('.').pop() || fieldKey;
  1454. const label = toText(column.title) || getDetailFieldLabel(normalizedKey, tableKey);
  1455. return createDetailField(
  1456. `${fieldKey}-${index}`,
  1457. label,
  1458. resolveDetailFieldValue(detailRecord, fieldKey),
  1459. tableKey,
  1460. normalizedKey,
  1461. );
  1462. })
  1463. .filter((field): field is DetailField => !!field);
  1464. };
  1465. const buildTimeSeriesDetailQuery = (item: BehaviorEvent): TimeSeriesDetailQuery => ({
  1466. id: item.recordId || undefined,
  1467. rowId: item.rowId || undefined,
  1468. tableName: item.rawTableNameEn || item.tableNameEn || undefined,
  1469. tableNameEn: item.rawTableNameEn || item.tableNameEn || undefined,
  1470. tableNameCn: item.tableNameCn || undefined,
  1471. personName: item.personName || undefined,
  1472. seriesDateTime: item.occurredAt || undefined,
  1473. });
  1474. const toTimeSeriesDetailRecord = (detailRecord: TimeSeriesDetailDTO, baseEvent: BehaviorEvent): TimeSeries => ({
  1475. ...detailRecord,
  1476. id: detailRecord.id ?? detailRecord.rowId ?? baseEvent.recordId ?? baseEvent.rowId,
  1477. rowId: detailRecord.rowId ?? detailRecord.id ?? baseEvent.rowId ?? baseEvent.recordId,
  1478. tableNameEn: toText(detailRecord.tableNameEn) || baseEvent.rawTableNameEn || baseEvent.tableNameEn,
  1479. tableNameCn: toText(detailRecord.tableNameCn) || baseEvent.tableNameCn || baseEvent.sourceLabel,
  1480. personName: toText(detailRecord.personName) || baseEvent.personName,
  1481. otherName: toText(detailRecord.otherName) || undefined,
  1482. seriesDateTime: toText(detailRecord.seriesDateTime) || baseEvent.occurredAt,
  1483. ext8: toText(detailRecord.ext8) || baseEvent.ext8,
  1484. ext9: toText(detailRecord.ext9) || baseEvent.ext9,
  1485. });
  1486. const mergeTimeSeriesDetailToBehaviorEvent = (baseEvent: BehaviorEvent, detailRecord: TimeSeriesDetailDTO) => {
  1487. const normalized = normalizeTimeSeriesToBehaviorEvent(toTimeSeriesDetailRecord(detailRecord, baseEvent), 0);
  1488. const rawTableNameEn = toText(detailRecord.tableNameEn) || baseEvent.rawTableNameEn || baseEvent.tableNameEn;
  1489. const mergedLng = typeof normalized.lng === 'number' ? normalized.lng : baseEvent.lng;
  1490. const mergedLat = typeof normalized.lat === 'number' ? normalized.lat : baseEvent.lat;
  1491. const mergedHasCoordinate = typeof mergedLng === 'number' && typeof mergedLat === 'number';
  1492. const mergedExt8 = toText(normalized.ext8) || baseEvent.ext8;
  1493. const mergedExt9 = toText(normalized.ext9) || baseEvent.ext9;
  1494. return {
  1495. ...baseEvent,
  1496. ...normalized,
  1497. id: baseEvent.id,
  1498. rowId: toText(detailRecord.rowId ?? detailRecord.id) || baseEvent.rowId,
  1499. recordId: toText(detailRecord.id ?? detailRecord.rowId) || baseEvent.recordId,
  1500. rawTableNameEn: rawTableNameEn || undefined,
  1501. tableNameEn: normalized.tableNameEn || baseEvent.tableNameEn,
  1502. tableNameCn: toText(detailRecord.tableNameCn) || baseEvent.tableNameCn,
  1503. title: baseEvent.title,
  1504. otherName: baseEvent.otherName,
  1505. locationName: baseEvent.locationName,
  1506. address: baseEvent.address,
  1507. summary: baseEvent.summary,
  1508. metaPrimaryLabel: baseEvent.metaPrimaryLabel,
  1509. metaPrimaryValue: baseEvent.metaPrimaryValue,
  1510. metaSecondaryLabel: baseEvent.metaSecondaryLabel,
  1511. metaSecondaryValue: baseEvent.metaSecondaryValue,
  1512. ext1: baseEvent.ext1,
  1513. ext2: baseEvent.ext2,
  1514. ext3: baseEvent.ext3,
  1515. ext4: baseEvent.ext4,
  1516. ext5: baseEvent.ext5,
  1517. ext6: baseEvent.ext6,
  1518. ext7: baseEvent.ext7,
  1519. ext8: mergedExt8,
  1520. ext9: mergedExt9,
  1521. lng: mergedLng,
  1522. lat: mergedLat,
  1523. hasCoordinate: mergedHasCoordinate,
  1524. };
  1525. };
  1526. const filters = ref<BehaviorFilters>(makeFilters());
  1527. const applied = ref<BehaviorFilters>(cloneFilters(makeFilters()));
  1528. const activeJumpPayload = ref<BehaviorSpacetimeJumpPayload | null>(null);
  1529. const lastRouteJumpSignature = ref('');
  1530. const runningJumpRequestSignature = ref('');
  1531. const appliedJumpRequestSignature = ref('');
  1532. const targetOptions = ref<TargetOption[]>([]);
  1533. const targetPickerOpen = ref(false);
  1534. const targetKeyword = ref('');
  1535. const targetDraft = ref<string[]>([]);
  1536. const remoteEvents = ref<BehaviorEvent[]>([]);
  1537. const remoteTotal = ref(0);
  1538. const detailLoading = ref(false);
  1539. const detailDrawerOpen = ref(false);
  1540. const detailError = ref('');
  1541. const pageRootRef = ref<HTMLElement | null>(null);
  1542. const pageContentRef = ref<HTMLElement | null>(null);
  1543. const timelineListRef = ref<HTMLElement | null>(null);
  1544. const mapRef = ref<HTMLElement | null>(null);
  1545. const hoverCard = ref({
  1546. visible: false,
  1547. x: 0,
  1548. y: 0,
  1549. lng: 0,
  1550. lat: 0,
  1551. locationName: '',
  1552. address: '',
  1553. stationCode: '',
  1554. coordinateText: '',
  1555. });
  1556. const hoverCardLocked = ref(false);
  1557. let hoverCardHideTimer: ReturnType<typeof setTimeout> | null = null;
  1558. const map = shallowRef<MapLibreMap | null>(null);
  1559. const mapLoaded = ref(false);
  1560. const mapMode = ref<'template' | 'fallback'>('fallback');
  1561. const mapReloading = ref(false);
  1562. const selectedId = ref('');
  1563. const errorMsg = ref('');
  1564. const timelinePage = ref(1);
  1565. const timelineMoreLoading = ref(false);
  1566. const timelineHasMore = ref(false);
  1567. const timelineCanLoadForward = ref(false);
  1568. const timelineCanLoadBackward = ref(false);
  1569. const timelineListInitialized = ref(false);
  1570. const detailCache = new Map<string, BehaviorEvent>();
  1571. const detailRecordCache = ref<Record<string, TimeSeriesDetailDTO>>({});
  1572. const detailHeadCache = ref<Record<string, DetailHeadColumn[]>>({});
  1573. const pendingTimelineScrollId = ref('');
  1574. const pendingTimelineScrollBehavior = ref<ScrollBehavior>('auto');
  1575. const pendingTimelineScrollAttempts = ref(0);
  1576. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  1577. refresh: () => handleSearch(),
  1578. hasData: () =>
  1579. remoteEvents.value.length > 0 &&
  1580. (applied.value.checkedTargets.length > 0 || hasMeaningfulJumpPayload(activeJumpPayload.value)),
  1581. });
  1582. let timelineScrollRetryTimer: number | null = null;
  1583. let timelineProgrammaticScrollUntil = 0;
  1584. let detailRequestToken = 0;
  1585. let timelineRequestToken = 0;
  1586. let restoringPageCache = false;
  1587. const getBehaviorSpacetimeWindow = () => window as BehaviorSpacetimeWindow;
  1588. const clearBehaviorSpacetimePageCache = () => {
  1589. delete getBehaviorSpacetimeWindow().__BEHAVIOR_SPACETIME_PAGE_CACHE__;
  1590. };
  1591. const isBehaviorSpacetimeJumpCache = (cache?: BehaviorSpacetimePageCache | null) => {
  1592. if (!cache) return false;
  1593. return Boolean(cache.lastRouteJumpSignature || hasMeaningfulJumpPayload(cache.activeJumpPayload));
  1594. };
  1595. const saveBehaviorSpacetimePageCache = () => {
  1596. if (restoringPageCache) return;
  1597. getBehaviorSpacetimeWindow().__BEHAVIOR_SPACETIME_PAGE_CACHE__ = {
  1598. filters: cloneFilters(filters.value),
  1599. applied: cloneFilters(applied.value),
  1600. activeJumpPayload: activeJumpPayload.value ? buildTimeSeriesJumpQuery(activeJumpPayload.value) : null,
  1601. lastRouteJumpSignature: lastRouteJumpSignature.value,
  1602. appliedJumpRequestSignature: appliedJumpRequestSignature.value,
  1603. remoteEvents: remoteEvents.value.map((item) => ({ ...item })),
  1604. remoteTotal: remoteTotal.value,
  1605. selectedId: selectedId.value,
  1606. timelinePage: timelinePage.value,
  1607. timelineHasMore: timelineHasMore.value,
  1608. timelineCanLoadForward: timelineCanLoadForward.value,
  1609. timelineCanLoadBackward: timelineCanLoadBackward.value,
  1610. timelineListInitialized: timelineListInitialized.value,
  1611. detailRecords: { ...detailRecordCache.value },
  1612. detailHeads: { ...detailHeadCache.value },
  1613. detailEvents: Array.from(detailCache.values()).map((item) => ({ ...item })),
  1614. };
  1615. };
  1616. const restoreBehaviorSpacetimePageCache = (options?: { allowJumpCache?: boolean }) => {
  1617. const { allowJumpCache = false } = options || {};
  1618. const cache = getBehaviorSpacetimeWindow().__BEHAVIOR_SPACETIME_PAGE_CACHE__;
  1619. if (!cache?.remoteEvents?.length) return false;
  1620. if (!allowJumpCache && isBehaviorSpacetimeJumpCache(cache)) return false;
  1621. restoringPageCache = true;
  1622. try {
  1623. filters.value = cloneFilters(cache.filters);
  1624. applied.value = cloneFilters(cache.applied);
  1625. activeJumpPayload.value = cache.activeJumpPayload ? buildTimeSeriesJumpQuery(cache.activeJumpPayload) : null;
  1626. lastRouteJumpSignature.value = cache.lastRouteJumpSignature || '';
  1627. appliedJumpRequestSignature.value = cache.appliedJumpRequestSignature || '';
  1628. remoteEvents.value = cache.remoteEvents.map((item) => ({ ...item }));
  1629. remoteTotal.value = cache.remoteTotal || remoteEvents.value.length;
  1630. selectedId.value = cache.selectedId || remoteEvents.value[0]?.id || '';
  1631. timelinePage.value = cache.timelinePage || 1;
  1632. timelineHasMore.value = !!cache.timelineHasMore;
  1633. timelineCanLoadForward.value = !!cache.timelineCanLoadForward;
  1634. timelineCanLoadBackward.value = !!cache.timelineCanLoadBackward;
  1635. timelineListInitialized.value = !!cache.timelineListInitialized;
  1636. detailCache.clear();
  1637. cache.detailEvents.forEach((item) => {
  1638. if (item?.id) detailCache.set(item.id, { ...item });
  1639. });
  1640. detailRecordCache.value = { ...cache.detailRecords };
  1641. detailHeadCache.value = { ...cache.detailHeads };
  1642. detailDrawerOpen.value = false;
  1643. detailLoading.value = false;
  1644. detailError.value = '';
  1645. timelineRequestToken += 1;
  1646. detailRequestToken += 1;
  1647. } finally {
  1648. restoringPageCache = false;
  1649. }
  1650. return true;
  1651. };
  1652. const restoreBehaviorSpacetimePageCacheForJumpPayload = (payload?: BehaviorSpacetimeJumpPayload | null) => {
  1653. const cache = getBehaviorSpacetimeWindow().__BEHAVIOR_SPACETIME_PAGE_CACHE__;
  1654. if (!payload || !cache?.remoteEvents?.length) return false;
  1655. const routeSignature = buildRouteJumpSignature(payload);
  1656. const cacheRouteSignature =
  1657. cache.lastRouteJumpSignature ||
  1658. (cache.activeJumpPayload ? buildRouteJumpSignature(cache.activeJumpPayload) : '');
  1659. if (!routeSignature || routeSignature !== cacheRouteSignature) return false;
  1660. const restored = restoreBehaviorSpacetimePageCache({ allowJumpCache: true });
  1661. if (!restored) return false;
  1662. activeJumpPayload.value = buildTimeSeriesJumpQuery(payload);
  1663. lastRouteJumpSignature.value = routeSignature;
  1664. return true;
  1665. };
  1666. const buildFiltersForExitJumpContext = () => {
  1667. const sourceFilters = cloneFilters(filters.value.targets.length ? filters.value : applied.value);
  1668. const eventTargets = normalizeTargetList([
  1669. ...remoteEvents.value.map((item) => item.personName),
  1670. ...filteredEvents.value.map((item) => item.personName),
  1671. ...(activeEvent.value?.personName ? [activeEvent.value.personName] : []),
  1672. ]);
  1673. const jumpTargets = normalizeTargetList([
  1674. ...sourceFilters.targets,
  1675. ...sourceFilters.checkedTargets,
  1676. ...displayTargets.value,
  1677. ...checkedTargets.value,
  1678. ...(activeJumpPayload.value?.personNames || []),
  1679. ...(activeJumpPayload.value?.personName ? [activeJumpPayload.value.personName] : []),
  1680. ...eventTargets,
  1681. ]);
  1682. const nextCheckedTargets = normalizeTargetList(
  1683. sourceFilters.checkedTargets.length ? sourceFilters.checkedTargets : jumpTargets,
  1684. ).filter((item) => jumpTargets.includes(item));
  1685. return {
  1686. ...sourceFilters,
  1687. keyword: '',
  1688. targets: jumpTargets,
  1689. checkedTargets: nextCheckedTargets.length ? nextCheckedTargets : [...jumpTargets],
  1690. } satisfies BehaviorFilters;
  1691. };
  1692. const exitRouteJumpContext = async () => {
  1693. const nextFilters = buildFiltersForExitJumpContext();
  1694. targetKeyword.value = '';
  1695. targetDraft.value = [];
  1696. targetPickerOpen.value = false;
  1697. clearBehaviorData(nextFilters);
  1698. filters.value = cloneFilters(nextFilters);
  1699. activeJumpPayload.value = null;
  1700. lastRouteJumpSignature.value = '';
  1701. runningJumpRequestSignature.value = '';
  1702. appliedJumpRequestSignature.value = '';
  1703. clearBehaviorSpacetimePageCache();
  1704. if (nextFilters.checkedTargets.length) {
  1705. await handleSearch();
  1706. return;
  1707. }
  1708. syncAnalysisDataRefreshState(false);
  1709. resetPageScrollPosition();
  1710. };
  1711. const getTimeSeriesCursorRowId = (list: BehaviorEvent[], direction: 'GT' | 'LT' = 'GT') => {
  1712. if (!list.length) return undefined;
  1713. const target = direction === 'LT' ? list[0] : list[list.length - 1];
  1714. return target?.rowId || target?.recordId || undefined;
  1715. };
  1716. const availableTargetOptions = computed(() => targetOptions.value.filter((item) => item.value !== 'all'));
  1717. const pickerTargetOptions = computed(() => {
  1718. const keyword = targetKeyword.value.trim().toLowerCase();
  1719. if (!keyword) return availableTargetOptions.value;
  1720. return availableTargetOptions.value.filter((item) => {
  1721. const label = String(item.label || '').toLowerCase();
  1722. const value = String(item.value || '').toLowerCase();
  1723. const identityCode = String(item.identityCode || '').toLowerCase();
  1724. return label.includes(keyword) || value.includes(keyword) || identityCode.includes(keyword);
  1725. });
  1726. });
  1727. const allDraftTargetsChecked = computed(() => {
  1728. const availableValues = availableTargetOptions.value.map((item) => item.value);
  1729. return !!availableValues.length && availableValues.every((value) => targetDraft.value.includes(value));
  1730. });
  1731. const draftTargetsIndeterminate = computed(() => {
  1732. if (!targetDraft.value.length) return false;
  1733. return !allDraftTargetsChecked.value;
  1734. });
  1735. const normalizeTargetList = (list: string[]) => Array.from(new Set(list.map((item) => item.trim()).filter(Boolean)));
  1736. const displayTargets = computed(() => {
  1737. const sourceTargets = filters.value.targets.length ? filters.value.targets : applied.value.targets;
  1738. return normalizeTargetList(sourceTargets);
  1739. });
  1740. const checkedTargets = computed(() => {
  1741. const sourceTargets = filters.value.targets.length ? filters.value.checkedTargets : applied.value.checkedTargets;
  1742. const allowedTargets = new Set(displayTargets.value);
  1743. return normalizeTargetList(sourceTargets).filter((item) => allowedTargets.has(item));
  1744. });
  1745. const checkedTargetSet = computed(() => new Set(checkedTargets.value));
  1746. const hasActiveJumpPayload = computed(() => hasMeaningfulJumpPayload(activeJumpPayload.value));
  1747. const isCurrentBehaviorSpacetimeRoute = computed(() => route.path === BEHAVIOR_SPACETIME_ROUTE_PATH);
  1748. const canSearch = computed(() => checkedTargets.value.length > 0 || hasActiveJumpPayload.value);
  1749. const appliedPressurePointCount = computed(() => 0);
  1750. const pressureTargetNames = computed(() => [] as string[]);
  1751. const allEvents = computed<BehaviorEvent[]>(() => remoteEvents.value);
  1752. const filteredEvents = computed(() => {
  1753. const keyword = applied.value.keyword.trim().toLowerCase();
  1754. const [startTime, endTime] = applied.value.dateRange || [];
  1755. return allEvents.value.filter((item) => {
  1756. if (applied.value.checkedTargets.length && !applied.value.checkedTargets.includes(item.personName)) return false;
  1757. if (applied.value.behaviorTypes.length && !applied.value.behaviorTypes.includes(item.behaviorType)) return false;
  1758. if (startTime && dayjs(item.occurredAt).isBefore(dayjs(startTime).startOf('day'))) return false;
  1759. if (endTime && dayjs(item.occurredAt).isAfter(dayjs(endTime).endOf('day'))) return false;
  1760. if (!keyword) return true;
  1761. return [item.personName, item.title, item.locationName, item.address, item.summary]
  1762. .join(' ')
  1763. .toLowerCase()
  1764. .includes(keyword);
  1765. });
  1766. });
  1767. const locatedEvents = computed(() =>
  1768. filteredEvents.value.filter(
  1769. (item) => item.hasCoordinate && typeof item.lng === 'number' && typeof item.lat === 'number',
  1770. ),
  1771. );
  1772. const activeEvent = computed(
  1773. () => filteredEvents.value.find((item) => item.id === selectedId.value) || filteredEvents.value[0],
  1774. );
  1775. const currentDetailRecord = computed(() =>
  1776. activeEvent.value ? detailRecordCache.value[activeEvent.value.id] : undefined,
  1777. );
  1778. const currentDetailHead = computed(() =>
  1779. activeEvent.value ? detailHeadCache.value[activeEvent.value.id] || [] : [],
  1780. );
  1781. const detailOverviewCards = computed<DetailOverviewCard[]>(() => {
  1782. const item = activeEvent.value;
  1783. const detailRecord = currentDetailRecord.value;
  1784. if (!item || !detailRecord) return [];
  1785. const snapshot = buildDetailRecordSnapshot(detailRecord, item);
  1786. const tableKey = getDetailTableKey(snapshot, item);
  1787. const cards: DetailOverviewCard[] = [];
  1788. const pushCard = (label: string, value: unknown, fieldKey?: string) => {
  1789. const formatted = fieldKey ? formatDetailFieldValue(fieldKey, value, tableKey) : normalizeDetailValue(value);
  1790. if (formatted === '-') return;
  1791. cards.push({ label, value: formatted });
  1792. };
  1793. if (tableKey === 'trans_record') {
  1794. pushCard('交易金额', snapshot.transAmount, 'transAmount');
  1795. pushCard('借贷标志', snapshot.debitCreditFlag || snapshot.transType, 'debitCreditFlag');
  1796. pushCard('交易对手', snapshot.otherName || snapshot.otherAccount);
  1797. pushCard('本方卡号', maskValue(snapshot.personCardNo, 6, 4));
  1798. return cards;
  1799. }
  1800. if (tableKey === 'call_record') {
  1801. pushCard('对方号码', snapshot.otherPhone || snapshot.ext2);
  1802. pushCard('主被叫', snapshot.callDirection || snapshot.ext4, 'callDirection');
  1803. pushCard('通话时间', snapshot.callDateTime || snapshot.seriesDateTime, 'callDateTime');
  1804. pushCard('基站名称', snapshot.personStationName || snapshot.ext7);
  1805. return cards;
  1806. }
  1807. pushCard(item.metaPrimaryLabel, item.metaPrimaryValue);
  1808. pushCard(item.metaSecondaryLabel, item.metaSecondaryValue);
  1809. pushCard('发生时间', snapshot.seriesDateTime, 'seriesDateTime');
  1810. pushCard('位置/对象', snapshot.otherName || item.locationName);
  1811. return cards.filter((card, index, list) => list.findIndex((target) => target.label === card.label) === index);
  1812. });
  1813. const detailFlatFields = computed<DetailField[]>(() => {
  1814. const item = activeEvent.value;
  1815. const detailRecord = currentDetailRecord.value;
  1816. if (!item || !detailRecord) return [];
  1817. const snapshot = buildDetailRecordSnapshot(detailRecord, item);
  1818. const tableKey = getDetailTableKey(snapshot, item);
  1819. return currentDetailHead.value.length
  1820. ? createMappedDetailFields(currentDetailHead.value, snapshot, tableKey)
  1821. : createRawDetailFields(snapshot, tableKey);
  1822. });
  1823. const detailDrawerTitle = computed(() => activeEvent.value?.personName || '行为详情');
  1824. const detailDrawerSubTitle = computed(() => {
  1825. if (!activeEvent.value) return '点击时间轴节点查看接口详情';
  1826. return `${getBehaviorLabel(activeEvent.value.behaviorType)} · ${activeEvent.value.occurredAt}`;
  1827. });
  1828. const detailDrawerEmptyText = computed(() => {
  1829. if (!activeEvent.value) return '请选择时间轴节点';
  1830. return '详情接口暂无返回数据';
  1831. });
  1832. const timelineEntries = computed(() => {
  1833. return filteredEvents.value.map((item, index) => ({ item, index }));
  1834. });
  1835. const isUsableMapText = (value?: string) => {
  1836. const text = toText(value);
  1837. if (!text) return false;
  1838. if (['地址待补充', '位置待补充', '基站编号待补充', '-'].includes(text)) return false;
  1839. if (/^LAC\s*\d+/i.test(text)) return false;
  1840. return true;
  1841. };
  1842. const resolveMapPointLabel = (item: BehaviorEvent) => {
  1843. const locationName = toText(item.locationName);
  1844. const address = toText(item.address);
  1845. if (isUsableMapText(locationName)) return locationName;
  1846. if (isUsableMapText(address)) return address;
  1847. return fallbackText(item.personName);
  1848. };
  1849. const buildMapDisplayLabel = (item: BehaviorEvent) => {
  1850. return resolveMapPointLabel(item);
  1851. };
  1852. const buildMapCoordinateKey = (lng: number, lat: number) => `${lng.toFixed(6)}:${lat.toFixed(6)}`;
  1853. const formatMapCoordinateText = (lng?: number, lat?: number) => {
  1854. if (typeof lng !== 'number' || typeof lat !== 'number') return '';
  1855. return `${lng.toFixed(6)}, ${lat.toFixed(6)}`;
  1856. };
  1857. const getMapPointLabelPriority = (item: BehaviorEvent) => {
  1858. if (isUsableMapText(item.locationName)) return 2;
  1859. if (isUsableMapText(item.address)) return 1;
  1860. return 0;
  1861. };
  1862. const getMapPointPriority = (item: BehaviorEvent) => {
  1863. let priority = getMapPointLabelPriority(item) * 10;
  1864. if (item.id === activeEvent.value?.id) priority += 100;
  1865. if (item.behaviorType === '轨迹信息') priority += 3;
  1866. if (item.behaviorType === '交易信息') priority += 2;
  1867. if (item.behaviorType === '通信信息') priority += 1;
  1868. return priority;
  1869. };
  1870. const buildMapLabelFeatures = (list: BehaviorEvent[]) => {
  1871. const labelMap = new Map<
  1872. string,
  1873. {
  1874. priority: number;
  1875. feature: {
  1876. type: 'Feature';
  1877. properties: {
  1878. id: string;
  1879. mapLabel: string;
  1880. displayLabel: string;
  1881. personName: string;
  1882. title: string;
  1883. locationName: string;
  1884. address: string;
  1885. };
  1886. geometry: { type: 'Point'; coordinates: [number, number] };
  1887. };
  1888. }
  1889. >();
  1890. list.forEach((item) => {
  1891. if (typeof item.lng !== 'number' || typeof item.lat !== 'number') return;
  1892. const mapLabel = resolveMapPointLabel(item);
  1893. if (!mapLabel || mapLabel === '-') return;
  1894. const key = buildMapCoordinateKey(item.lng, item.lat);
  1895. const nextPriority = getMapPointLabelPriority(item);
  1896. const nextFeature = {
  1897. type: 'Feature' as const,
  1898. properties: {
  1899. id: item.id,
  1900. mapLabel,
  1901. displayLabel: mapLabel,
  1902. personName: item.personName,
  1903. title: item.title,
  1904. locationName: item.locationName,
  1905. address: item.address,
  1906. },
  1907. geometry: { type: 'Point' as const, coordinates: [item.lng, item.lat] as [number, number] },
  1908. };
  1909. const current = labelMap.get(key);
  1910. if (
  1911. !current ||
  1912. nextPriority > current.priority ||
  1913. (nextPriority === current.priority && mapLabel.length > current.feature.properties.mapLabel.length)
  1914. ) {
  1915. labelMap.set(key, {
  1916. priority: nextPriority,
  1917. feature: nextFeature,
  1918. });
  1919. }
  1920. });
  1921. return Array.from(labelMap.values()).map((item) => item.feature);
  1922. };
  1923. const mapLabelFeatures = computed(() => buildMapLabelFeatures(locatedEvents.value));
  1924. const hasSameMapViewportBasis = (list: BehaviorEvent[], previousList?: BehaviorEvent[]) => {
  1925. if (!previousList || list.length !== previousList.length) return false;
  1926. return list.every((item, index) => {
  1927. const previousItem = previousList[index];
  1928. if (!previousItem || item.id !== previousItem.id) return false;
  1929. return (
  1930. item.hasCoordinate === previousItem.hasCoordinate &&
  1931. (item.lng ?? null) === (previousItem.lng ?? null) &&
  1932. (item.lat ?? null) === (previousItem.lat ?? null)
  1933. );
  1934. });
  1935. };
  1936. const getPointLabelMinZoom = (labelCount: number) => {
  1937. if (labelCount <= POINT_LABEL_LIGHT_LIMIT) return POINT_LABEL_BASE_MIN_ZOOM;
  1938. if (labelCount > POINT_LABEL_HEAVY_LIMIT) return 11.8;
  1939. if (labelCount > POINT_LABEL_DENSE_LIMIT) return 10.9;
  1940. if (labelCount > POINT_LABEL_MEDIUM_LIMIT) return 10.1;
  1941. return 9.4;
  1942. };
  1943. const getPointLabelTextSize = (labelCount: number) => {
  1944. if (labelCount > POINT_LABEL_HEAVY_LIMIT) return 10;
  1945. if (labelCount > POINT_LABEL_DENSE_LIMIT) return 10.5;
  1946. if (labelCount > POINT_LABEL_MEDIUM_LIMIT) return 11;
  1947. return 11.5;
  1948. };
  1949. const addressLabelCount = computed(() => mapLabelFeatures.value.length);
  1950. const summaryCards = computed(() => {
  1951. const hotspot = new Map<string, number>();
  1952. locatedEvents.value.forEach((item) => hotspot.set(item.locationName, (hotspot.get(item.locationName) || 0) + 1));
  1953. const topHotspot = Array.from(hotspot.entries()).sort((a, b) => b[1] - a[1])[0];
  1954. return [
  1955. { label: '时空节点', value: filteredEvents.value.length, desc: `定位点 ${locatedEvents.value.length}` },
  1956. {
  1957. label: '关联对象',
  1958. value: new Set(filteredEvents.value.map((item) => item.personName)).size,
  1959. desc: addressLabelCount.value ? `${addressLabelCount.value} 个地址标签` : '暂无地址标签',
  1960. },
  1961. {
  1962. label: '热点区域',
  1963. value: topHotspot?.[0] || '-',
  1964. desc: topHotspot ? `命中 ${topHotspot[1]} 次` : '暂无聚集热点',
  1965. },
  1966. ];
  1967. });
  1968. const legendItems = computed(() =>
  1969. (Object.keys(THEME) as BehaviorType[]).map((name) => ({
  1970. label: BEHAVIOR_LABEL_MAP[name],
  1971. color: THEME[name].color,
  1972. })),
  1973. );
  1974. const isStressMode = computed(() => false);
  1975. const hasAppliedTargets = computed(() => applied.value.targets.length > 0);
  1976. const hasAppliedCheckedTargets = computed(() => applied.value.checkedTargets.length > 0);
  1977. const hasAppliedJumpPayload = computed(() => hasMeaningfulJumpPayload(activeJumpPayload.value));
  1978. const remoteLoadedCount = computed(() => remoteEvents.value.length);
  1979. const hasLoadedAllRemote = computed(
  1980. () => timelineListInitialized.value && remoteLoadedCount.value > 0 && !timelineHasMore.value,
  1981. );
  1982. const showTimelineLoadedTotal = computed(() => remoteTotal.value > 0);
  1983. const getTimelineLoadedText = (suffix = '') => {
  1984. const base = showTimelineLoadedTotal.value
  1985. ? `已加载 ${remoteLoadedCount.value}/${remoteTotal.value} 条`
  1986. : `已加载 ${remoteLoadedCount.value} 条`;
  1987. return suffix ? `${base},${suffix}` : base;
  1988. };
  1989. const pressureStatusText = computed(() => `接口模式 · ${locatedEvents.value.length} 点`);
  1990. const timelineCountText = computed(() =>
  1991. hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value
  1992. ? !timelineListInitialized.value
  1993. ? remoteLoadedCount.value
  1994. ? `已定位 ${remoteLoadedCount.value} 条跳转记录`
  1995. : '已接收跳转条件'
  1996. : !isStressMode.value && timelineHasMore.value
  1997. ? getTimelineLoadedText()
  1998. : `${filteredEvents.value.length} 个节点`
  1999. : !hasAppliedTargets.value
  2000. ? '未选择对象'
  2001. : !hasAppliedCheckedTargets.value
  2002. ? '未勾选对象'
  2003. : !isStressMode.value && timelineHasMore.value
  2004. ? getTimelineLoadedText()
  2005. : !isStressMode.value || filteredEvents.value.length <= timelineEntries.value.length
  2006. ? `${filteredEvents.value.length} 个节点`
  2007. : `${filteredEvents.value.length} 个节点 / 预览 ${timelineEntries.value.length} 条`,
  2008. );
  2009. const timelineLoadStateText = computed(() => {
  2010. if (timelineMoreLoading.value) return `正在加载更多...`;
  2011. if (hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value && !timelineListInitialized.value) {
  2012. return '继续下拉加载时间轴列表';
  2013. }
  2014. if (hasLoadedAllRemote.value) return getTimelineLoadedText('无更多数据');
  2015. if (timelineHasMore.value) return getTimelineLoadedText('下拉继续加载');
  2016. if (remoteLoadedCount.value) return getTimelineLoadedText();
  2017. return '';
  2018. });
  2019. const showTimelineLoadState = computed(
  2020. () =>
  2021. (hasAppliedCheckedTargets.value || hasAppliedJumpPayload.value) &&
  2022. !!timelineLoadStateText.value &&
  2023. (!!remoteLoadedCount.value || timelineHasMore.value || timelineMoreLoading.value),
  2024. );
  2025. const mapStatusText = computed(() =>
  2026. errorMsg.value
  2027. ? '离线底图异常,已回退空白底图'
  2028. : mapMode.value === 'template'
  2029. ? '已加载离线底图模板'
  2030. : '当前为空白底图',
  2031. );
  2032. const timelineEmptyText = computed(() =>
  2033. hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value
  2034. ? '当前明细未命中可展示的时空节点'
  2035. : !hasAppliedTargets.value
  2036. ? '请先点击“添加对象”选择对象'
  2037. : !hasAppliedCheckedTargets.value
  2038. ? '请勾选对象后查看数据'
  2039. : '暂无命中节点',
  2040. );
  2041. const mapEmptyText = computed(() =>
  2042. hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value
  2043. ? '当前明细暂无可绘制定位点'
  2044. : !hasAppliedTargets.value
  2045. ? '请先点击“添加对象”选择对象'
  2046. : !hasAppliedCheckedTargets.value
  2047. ? '请勾选对象后查看数据'
  2048. : '暂无可绘制定位点',
  2049. );
  2050. const tagStyle = (type: BehaviorType) => ({
  2051. color: THEME[type].color,
  2052. background: THEME[type].soft,
  2053. borderColor: THEME[type].soft,
  2054. });
  2055. const getBehaviorLabel = (type: BehaviorType) => BEHAVIOR_LABEL_MAP[type] || type;
  2056. const createTimelineField = (label: string, value?: string | number | null, fallback = '-'): TimelineField => ({
  2057. label,
  2058. value: fallbackText(value, fallback),
  2059. });
  2060. const getAmountFieldValue = (value?: string | number | null, fallbackValue?: string | number | null) => {
  2061. const amountText = toText(value) ? formatAmount(value) : fallbackText(fallbackValue);
  2062. return amountText === '-' ? amountText : `${amountText} 元`;
  2063. };
  2064. const getTimelineFields = (item: BehaviorEvent): TimelineField[] => {
  2065. const tableKey = resolveTimeSeriesTableKey({
  2066. tableNameEn: item.tableNameEn,
  2067. tableNameCn: item.tableNameCn,
  2068. } as TimeSeries);
  2069. if (tableKey === 'call_record') {
  2070. const stationName = toText(item.ext7) || (item.locationName !== '通信基站' ? item.locationName : undefined);
  2071. return [
  2072. createTimelineField('本方号码', item.ext1),
  2073. createTimelineField('对方号码', item.ext2 || item.otherName),
  2074. createTimelineField('通话类型', mapCallDirection(item.ext4)),
  2075. createTimelineField('本方号码归属运营商', item.ext3),
  2076. createTimelineField('本方基站名称', stationName),
  2077. ];
  2078. }
  2079. if (tableKey === 'trans_record') {
  2080. return [
  2081. createTimelineField('本方卡号', item.ext1),
  2082. createTimelineField('对方卡号', item.ext2),
  2083. createTimelineField('借贷标志', mapDebitCreditFlag(item.ext4)),
  2084. createTimelineField('交易金额', getAmountFieldValue(item.ext6, item.metaPrimaryValue)),
  2085. createTimelineField('对方交易名称', item.otherName),
  2086. ];
  2087. }
  2088. if (tableKey === 'hotel_stay_info') {
  2089. return [
  2090. createTimelineField('旅店名称', item.ext2),
  2091. createTimelineField('入住时间', item.occurredAt),
  2092. createTimelineField('入住房号', item.ext4),
  2093. createTimelineField('住址详情', item.ext5),
  2094. ];
  2095. }
  2096. if (tableKey === 'train_ticket_info' || tableKey === 'together_train_info') {
  2097. return [
  2098. createTimelineField('车次', item.ext2),
  2099. createTimelineField('出发时间', item.occurredAt),
  2100. createTimelineField('起始站', item.ext3),
  2101. createTimelineField('到达站', item.ext4),
  2102. ];
  2103. }
  2104. if (tableKey === 'flight_ticket_info') {
  2105. return [
  2106. createTimelineField('票号', item.ext2),
  2107. createTimelineField('出发时间', item.occurredAt),
  2108. createTimelineField('起飞机场/代码', item.ext3),
  2109. createTimelineField('到达机场/代码', item.ext4),
  2110. ];
  2111. }
  2112. if (tableKey === 'flight_departure_info' || tableKey === 'together_flight_info') {
  2113. return [
  2114. createTimelineField('航班号', item.ext2),
  2115. createTimelineField('出发时间', item.occurredAt),
  2116. createTimelineField('起飞机场/代码', item.ext3),
  2117. createTimelineField('到达机场/代码', item.ext4),
  2118. ];
  2119. }
  2120. if (tableKey === 'track_car_ticket_info') {
  2121. return [
  2122. createTimelineField('班次号', item.ext2),
  2123. createTimelineField('发车时间', item.occurredAt),
  2124. createTimelineField('始发车站', item.ext3),
  2125. createTimelineField('目的地', item.ext4),
  2126. ];
  2127. }
  2128. if (tableKey === 'express_info') {
  2129. return [
  2130. createTimelineField('收件人', item.personName),
  2131. createTimelineField('寄件人', item.otherName),
  2132. createTimelineField('寄件时间', item.occurredAt),
  2133. createTimelineField('收件地址', item.ext3),
  2134. createTimelineField('寄件地址', item.ext4),
  2135. ];
  2136. }
  2137. return [
  2138. createTimelineField('位置名称', item.locationName),
  2139. createTimelineField('数据来源', item.sourceLabel),
  2140. createTimelineField(item.metaPrimaryLabel || '主信息', item.metaPrimaryValue),
  2141. createTimelineField(item.metaSecondaryLabel || '次信息', item.metaSecondaryValue),
  2142. ];
  2143. };
  2144. const normalizeDetailValue = (value: unknown): string => {
  2145. if (value === null || value === undefined || value === '') return '-';
  2146. if (Array.isArray(value)) {
  2147. const values = value.map((item) => normalizeDetailValue(item)).filter((item) => item !== '-');
  2148. return values.length ? values.join('、') : '-';
  2149. }
  2150. if (typeof value === 'object') {
  2151. try {
  2152. return JSON.stringify(value, null, 2);
  2153. } catch {
  2154. return String(value);
  2155. }
  2156. }
  2157. return String(value);
  2158. };
  2159. const getDetailTableKey = (detailRecord?: TimeSeriesDetailDTO, item?: BehaviorEvent) => {
  2160. if (!detailRecord && !item) return '';
  2161. return (
  2162. resolveTimeSeriesTableKey({
  2163. tableNameEn: toText(detailRecord?.tableNameEn) || item?.tableNameEn,
  2164. tableNameCn: toText(detailRecord?.tableNameCn) || item?.tableNameCn,
  2165. } as TimeSeries) ||
  2166. item?.tableNameEn ||
  2167. ''
  2168. );
  2169. };
  2170. const getDetailFieldLabel = (key: string, tableKey: string) => {
  2171. const tableFieldLabel = DETAIL_TABLE_FIELD_LABEL_MAP[tableKey]?.[key];
  2172. if (tableFieldLabel) return tableFieldLabel;
  2173. const detailField = (DETAIL_RECORD_FIELDS_MAP[tableKey] || []).find((field) => field.key === key);
  2174. if (detailField) return detailField.label;
  2175. if (key in (DETAIL_EXT_FIELD_LABEL_MAP[tableKey] || {})) {
  2176. return DETAIL_EXT_FIELD_LABEL_MAP[tableKey][key as (typeof DETAIL_EXT_KEYS)[number]] || key;
  2177. }
  2178. return DETAIL_FIELD_LABEL_MAP[key] || key;
  2179. };
  2180. const getBaseDetailFieldLabel = (key: string, tableKey: string) => {
  2181. return DETAIL_TABLE_FIELD_LABEL_MAP[tableKey]?.[key] || DETAIL_BASE_FIELD_LABEL_MAP[key] || key;
  2182. };
  2183. const formatDetailFieldValue = (key: string, value: unknown, tableKey: string) => {
  2184. const text = normalizeDetailValue(value);
  2185. if (text === '-') return text;
  2186. if (isCertFieldKey(key)) return maskCertNo(text);
  2187. if (
  2188. [
  2189. 'seriesDateTime',
  2190. 'callDateTime',
  2191. 'transDateTime',
  2192. 'transDate',
  2193. 'inDateTime',
  2194. 'depDateTime',
  2195. 'flightDateTime',
  2196. 'sendDate',
  2197. ].includes(key)
  2198. ) {
  2199. return formatDateTimeText(text);
  2200. }
  2201. if (tableKey === 'call_record' && key === 'ext4') return mapCallDirection(text);
  2202. if (tableKey === 'call_record' && key === 'callDirection') return mapCallDirection(text);
  2203. if (tableKey === 'trans_record' && key === 'ext4') return mapDebitCreditFlag(text);
  2204. if (tableKey === 'trans_record' && ['debitCreditFlag', 'transType'].includes(key)) return mapDebitCreditFlag(text);
  2205. if (tableKey === 'trans_record' && key === 'ext6') return getAmountFieldValue(text);
  2206. if (['transAmount', 'transBalance', 'otherBalance'].includes(key)) return formatAmount(text);
  2207. return text;
  2208. };
  2209. const buildDetailRecordSnapshot = (detailRecord: TimeSeriesDetailDTO, item: BehaviorEvent): TimeSeriesDetailDTO => ({
  2210. ...detailRecord,
  2211. id: detailRecord.id ?? item.recordId ?? item.id,
  2212. rowId: detailRecord.rowId ?? item.rowId,
  2213. tableNameEn: toText(detailRecord.tableNameEn) || item.tableNameEn,
  2214. tableNameCn: toText(detailRecord.tableNameCn) || item.tableNameCn,
  2215. personName: toText(detailRecord.personName) || item.personName,
  2216. otherName: toText(detailRecord.otherName) || item.otherName,
  2217. seriesDateTime: toText(detailRecord.seriesDateTime) || item.occurredAt,
  2218. ext1: toText(detailRecord.ext1) || item.ext1,
  2219. ext2: toText(detailRecord.ext2) || item.ext2,
  2220. ext3: toText(detailRecord.ext3) || item.ext3,
  2221. ext4: toText(detailRecord.ext4) || item.ext4,
  2222. ext5: toText(detailRecord.ext5) || item.ext5,
  2223. ext6: toText(detailRecord.ext6) || item.ext6,
  2224. ext7: toText(detailRecord.ext7) || item.ext7,
  2225. ext8: toText(detailRecord.ext8) || item.ext8,
  2226. ext9: toText(detailRecord.ext9) || item.ext9,
  2227. });
  2228. const createDetailField = (
  2229. key: string,
  2230. label: string,
  2231. value: unknown,
  2232. tableKey: string,
  2233. formatKey = key,
  2234. ): DetailField => {
  2235. const formattedValue = formatDetailFieldValue(formatKey, value, tableKey);
  2236. return {
  2237. key,
  2238. label,
  2239. value: formattedValue,
  2240. span: DETAIL_LONG_VALUE_KEYS.has(formatKey) || formattedValue.length > 26 ? 2 : 1,
  2241. };
  2242. };
  2243. const toPoints = (list: BehaviorEvent[]) => {
  2244. const pointMap = new Map<string, BehaviorEvent>();
  2245. list.forEach((item) => {
  2246. const key = buildMapCoordinateKey(item.lng as number, item.lat as number);
  2247. const current = pointMap.get(key);
  2248. if (!current) {
  2249. pointMap.set(key, item);
  2250. return;
  2251. }
  2252. const nextPriority = getMapPointPriority(item);
  2253. const currentPriority = getMapPointPriority(current);
  2254. if (nextPriority > currentPriority) {
  2255. pointMap.set(key, item);
  2256. return;
  2257. }
  2258. if (nextPriority === currentPriority && dayjs(item.occurredAt).isAfter(dayjs(current.occurredAt))) {
  2259. pointMap.set(key, item);
  2260. }
  2261. });
  2262. return {
  2263. type: 'FeatureCollection',
  2264. features: Array.from(pointMap.values()).map((item) => ({
  2265. type: 'Feature',
  2266. properties: {
  2267. id: item.id,
  2268. isActive: item.id === activeEvent.value?.id,
  2269. behaviorType: item.behaviorType,
  2270. personName: item.personName,
  2271. title: item.title,
  2272. locationName: item.locationName,
  2273. address: item.address,
  2274. mapLabel: resolveMapPointLabel(item),
  2275. displayLabel: buildMapDisplayLabel(item),
  2276. },
  2277. geometry: { type: 'Point', coordinates: [item.lng, item.lat] },
  2278. })),
  2279. };
  2280. };
  2281. const toLabelPoints = () => ({
  2282. type: 'FeatureCollection',
  2283. features: mapLabelFeatures.value,
  2284. });
  2285. const createMapPinImage = async (fillColor: string, size = 26): Promise<ImageData | null> => {
  2286. const svgMarkup = mapPinSvg.replace(/<path\b/, `<path fill="${fillColor}"`);
  2287. const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
  2288. const imageElement = await new Promise<HTMLImageElement>((resolve, reject) => {
  2289. const image = new Image();
  2290. image.onload = () => resolve(image);
  2291. image.onerror = (error) => reject(error);
  2292. image.src = svgDataUrl;
  2293. });
  2294. const pixelRatio = 2;
  2295. const logicalWidth = size;
  2296. const logicalHeight = size;
  2297. const canvas = document.createElement('canvas');
  2298. canvas.width = logicalWidth * pixelRatio;
  2299. canvas.height = logicalHeight * pixelRatio;
  2300. const ctx = canvas.getContext('2d');
  2301. if (!ctx) return null;
  2302. ctx.scale(pixelRatio, pixelRatio);
  2303. ctx.clearRect(0, 0, logicalWidth, logicalHeight);
  2304. ctx.drawImage(imageElement, 0, 0, logicalWidth, logicalHeight);
  2305. return ctx.getImageData(0, 0, canvas.width, canvas.height);
  2306. };
  2307. const registerMapPinImages = async (instance: MapLibreMap) => {
  2308. const imageConfig = [
  2309. [MAP_PIN_ICON_TRACK, '#1677ff', 24],
  2310. [MAP_PIN_ICON_TRANS, '#1677ff', 24],
  2311. [MAP_PIN_ICON_CALL, '#1677ff', 24],
  2312. [MAP_PIN_ICON_ACTIVE, '#e53935', 28],
  2313. ] as const;
  2314. for (const [id, fillColor, size] of imageConfig) {
  2315. if (instance.hasImage(id)) {
  2316. instance.removeImage(id);
  2317. }
  2318. const image = await createMapPinImage(fillColor, size);
  2319. if (!image) continue;
  2320. instance.addImage(id, image, { pixelRatio: 2 });
  2321. }
  2322. };
  2323. const getPointPinImageExpression = () =>
  2324. [
  2325. 'match',
  2326. ['get', 'behaviorType'],
  2327. '轨迹信息',
  2328. MAP_PIN_ICON_TRACK,
  2329. '交易信息',
  2330. MAP_PIN_ICON_TRANS,
  2331. '通信信息',
  2332. MAP_PIN_ICON_CALL,
  2333. MAP_PIN_ICON_TRACK,
  2334. ] as any;
  2335. let pmtilesRegistered = false;
  2336. let pmtilesProtocol: Protocol | null = null;
  2337. const ensurePmtiles = () => {
  2338. if (pmtilesRegistered) return;
  2339. pmtilesProtocol = new Protocol();
  2340. maplibregl.addProtocol('pmtiles', pmtilesProtocol.tile);
  2341. pmtilesRegistered = true;
  2342. };
  2343. const ensureOfflinePmtilesArchive = () => {
  2344. const filePath = getOfflineMapFilePath('china.pmtiles');
  2345. if (!pmtilesProtocol || !filePath) return;
  2346. if (pmtilesProtocol.get(filePath)) return;
  2347. pmtilesProtocol.add(new PMTiles(new ElectronPmtilesSource(filePath) as any));
  2348. };
  2349. const getFallbackStyle = () => ({
  2350. version: 8,
  2351. sources: {},
  2352. layers: [{ id: 'behavior-background', type: 'background', paint: { 'background-color': '#edf3f8' } }],
  2353. });
  2354. const buildExternalMapStyle = () => {
  2355. const offlineMapDir = getOfflineMapDir();
  2356. const pmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
  2357. if (!offlineMapDir || !pmtilesFilePath) return null;
  2358. return {
  2359. version: 8,
  2360. name: 'behavior-spacetime-external-map',
  2361. sources: {
  2362. 'behavior-basemap': {
  2363. type: 'vector',
  2364. url: `${PMTILES_PROTOCOL}${pmtilesFilePath}`,
  2365. },
  2366. },
  2367. layers: [
  2368. { id: 'behavior-background', type: 'background', paint: { 'background-color': '#f7fafc' } },
  2369. {
  2370. id: 'behavior-landcover',
  2371. type: 'fill',
  2372. source: 'behavior-basemap',
  2373. 'source-layer': 'landcover',
  2374. paint: { 'fill-color': '#e7efe4', 'fill-opacity': 0.8 },
  2375. },
  2376. {
  2377. id: 'behavior-landuse',
  2378. type: 'fill',
  2379. source: 'behavior-basemap',
  2380. 'source-layer': 'landuse',
  2381. paint: { 'fill-color': '#eef2e7', 'fill-opacity': 0.72 },
  2382. },
  2383. {
  2384. id: 'behavior-park',
  2385. type: 'fill',
  2386. source: 'behavior-basemap',
  2387. 'source-layer': 'park',
  2388. paint: { 'fill-color': '#dcefd4', 'fill-opacity': 0.76 },
  2389. },
  2390. {
  2391. id: 'behavior-water',
  2392. type: 'fill',
  2393. source: 'behavior-basemap',
  2394. 'source-layer': 'water',
  2395. paint: { 'fill-color': '#cfe6ff', 'fill-opacity': 0.96 },
  2396. },
  2397. {
  2398. id: 'behavior-waterway',
  2399. type: 'line',
  2400. source: 'behavior-basemap',
  2401. 'source-layer': 'waterway',
  2402. paint: { 'line-color': '#9dc8f7', 'line-width': 1.2 },
  2403. },
  2404. {
  2405. id: 'behavior-boundary',
  2406. type: 'line',
  2407. source: 'behavior-basemap',
  2408. 'source-layer': 'boundary',
  2409. paint: { 'line-color': '#a7b3c2', 'line-width': 0.8, 'line-opacity': 0.7 },
  2410. },
  2411. {
  2412. id: 'behavior-transportation',
  2413. type: 'line',
  2414. source: 'behavior-basemap',
  2415. 'source-layer': 'transportation',
  2416. layout: { 'line-cap': 'round', 'line-join': 'round' },
  2417. paint: { 'line-color': '#f3c985', 'line-width': 1.1 },
  2418. },
  2419. {
  2420. id: 'behavior-place-label',
  2421. type: 'symbol',
  2422. source: 'behavior-basemap',
  2423. 'source-layer': 'place',
  2424. minzoom: 3,
  2425. filter: ['has', 'name'],
  2426. layout: {
  2427. 'text-field': ['coalesce', ['get', 'name:zh-Hans'], ['get', 'name:zh'], ['get', 'name']],
  2428. 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
  2429. 'text-size': 11,
  2430. 'text-anchor': 'top',
  2431. 'text-offset': [0, 0.6],
  2432. },
  2433. paint: {
  2434. 'text-color': '#1f2937',
  2435. 'text-halo-color': 'rgba(255, 255, 255, 0.98)',
  2436. 'text-halo-width': 1.2,
  2437. },
  2438. },
  2439. ],
  2440. };
  2441. };
  2442. const getOfflineMapStylePath = () => getOfflineMapFilePath('behavior-spacetime-style.json');
  2443. const readOfflineMapStyle = async () => {
  2444. const ipcRenderer = (window as any)?.electron?.ipcRenderer;
  2445. const filePath = getOfflineMapStylePath();
  2446. if (!ipcRenderer?.invoke || !filePath) return '';
  2447. try {
  2448. return (await ipcRenderer.invoke('controller/os/readTextFile', { filePath, encoding: 'utf8' })) || '';
  2449. } catch {
  2450. return '';
  2451. }
  2452. };
  2453. const getPublicBaseUrl = () => new URL(import.meta.env.BASE_URL || './', window.location.href).toString();
  2454. const resolveStyleAssetUrl = (url: string, styleUrl: string) => {
  2455. if (ABSOLUTE_URL_RE.test(url)) return toLocalMapUrl(url);
  2456. if (url.startsWith('./') || url.startsWith('../')) return new URL(url, styleUrl).toString();
  2457. return new URL(url.replace(/^\/+/, ''), getPublicBaseUrl()).toString();
  2458. };
  2459. const normalizePmtilesSources = (style: Record<string, any>, baseUrl: string, overridePmtilesUrl?: string) => {
  2460. const sources = style?.sources;
  2461. if (!sources || typeof sources !== 'object') return style;
  2462. const normalized = Object.entries(sources).reduce<Record<string, any>>((acc, [key, source]) => {
  2463. const sourceItem = source as Record<string, any>;
  2464. if (
  2465. !sourceItem ||
  2466. typeof sourceItem !== 'object' ||
  2467. typeof sourceItem.url !== 'string' ||
  2468. !sourceItem.url.startsWith(PMTILES_PROTOCOL)
  2469. ) {
  2470. acc[key] = source;
  2471. return acc;
  2472. }
  2473. const normalizedUrl = overridePmtilesUrl
  2474. ? overridePmtilesUrl
  2475. : toLocalMapUrl(resolveStyleAssetUrl(sourceItem.url.slice(PMTILES_PROTOCOL.length), baseUrl));
  2476. acc[key] = {
  2477. ...sourceItem,
  2478. url: `${PMTILES_PROTOCOL}${normalizedUrl}`,
  2479. };
  2480. return acc;
  2481. }, {});
  2482. return { ...style, sources: normalized };
  2483. };
  2484. const resolveStyle = async () => {
  2485. const custom = (window as BehaviorSpacetimeWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
  2486. try {
  2487. const offlineMapDir = getOfflineMapDir();
  2488. const offlinePmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
  2489. const offlineStylePath = getOfflineMapStylePath();
  2490. if (offlineMapDir && offlinePmtilesFilePath && offlineStylePath) {
  2491. const styleText = await readOfflineMapStyle();
  2492. if (styleText) {
  2493. const style = JSON.parse(styleText);
  2494. mapMode.value = 'template';
  2495. errorMsg.value = '';
  2496. return normalizePmtilesSources(style as Record<string, any>, offlineStylePath, offlinePmtilesFilePath);
  2497. }
  2498. }
  2499. const externalStyle = buildExternalMapStyle();
  2500. if (externalStyle) {
  2501. mapMode.value = 'template';
  2502. errorMsg.value = '';
  2503. return externalStyle;
  2504. }
  2505. if (custom && typeof custom === 'object') {
  2506. mapMode.value = 'template';
  2507. errorMsg.value = '';
  2508. return normalizePmtilesSources(custom, window.location.href, offlinePmtilesFilePath || undefined);
  2509. }
  2510. const styleUrl = resolveStyleAssetUrl(String(custom), window.location.href);
  2511. const response = await fetch(styleUrl, { cache: 'no-store' });
  2512. if (!response.ok) throw new Error(`style request failed: ${response.status}`);
  2513. const style = await response.json();
  2514. mapMode.value = 'template';
  2515. errorMsg.value = '';
  2516. return normalizePmtilesSources(
  2517. style as Record<string, any>,
  2518. response.url || styleUrl,
  2519. offlinePmtilesFilePath || undefined,
  2520. );
  2521. } catch (error) {
  2522. errorMsg.value = error instanceof Error ? error.message : 'style error';
  2523. }
  2524. mapMode.value = 'fallback';
  2525. return getFallbackStyle();
  2526. };
  2527. const updatePointSource = () => {
  2528. if (!map.value || !mapLoaded.value) return;
  2529. (map.value.getSource(SOURCE_ID) as any)?.setData(toPoints(locatedEvents.value) as any);
  2530. };
  2531. const updateLabelSource = () => {
  2532. if (!map.value || !mapLoaded.value) return;
  2533. (map.value.getSource(LABEL_ID) as any)?.setData(toLabelPoints() as any);
  2534. };
  2535. let mapLabelRestoreTimer: ReturnType<typeof setTimeout> | null = null;
  2536. const clearMapLabelRestoreTimer = () => {
  2537. if (mapLabelRestoreTimer !== null) {
  2538. clearTimeout(mapLabelRestoreTimer);
  2539. mapLabelRestoreTimer = null;
  2540. }
  2541. };
  2542. const setMapLabelLayerVisibility = (visible: boolean) => {
  2543. if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER)) return;
  2544. map.value.setLayoutProperty(POINT_LABEL_LAYER, 'visibility', visible ? 'visible' : 'none');
  2545. };
  2546. const syncMapLabelLayerStyle = () => {
  2547. if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER)) return;
  2548. map.value.setLayoutProperty(POINT_LABEL_LAYER, 'text-size', getPointLabelTextSize(mapLabelFeatures.value.length));
  2549. };
  2550. const shouldShowMapLabelLayer = () => {
  2551. if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER)) return false;
  2552. const labelCount = mapLabelFeatures.value.length;
  2553. return labelCount > 0;
  2554. };
  2555. const syncMapLabelLayerVisibility = () => {
  2556. setMapLabelLayerVisibility(shouldShowMapLabelLayer());
  2557. };
  2558. const scheduleMapLabelLayerRestore = () => {
  2559. clearMapLabelRestoreTimer();
  2560. mapLabelRestoreTimer = setTimeout(() => {
  2561. mapLabelRestoreTimer = null;
  2562. syncMapLabelLayerVisibility();
  2563. }, 120);
  2564. };
  2565. const updateSources = () => {
  2566. updatePointSource();
  2567. updateLabelSource();
  2568. syncMapLabelLayerStyle();
  2569. syncMapLabelLayerVisibility();
  2570. };
  2571. const clearHoverCardHideTimer = () => {
  2572. if (hoverCardHideTimer !== null) {
  2573. clearTimeout(hoverCardHideTimer);
  2574. hoverCardHideTimer = null;
  2575. }
  2576. };
  2577. const syncMapHoverCardPosition = () => {
  2578. if (!hoverCard.value.visible || !map.value) return;
  2579. const projected = map.value.project([hoverCard.value.lng, hoverCard.value.lat]);
  2580. hoverCard.value.x = projected.x;
  2581. hoverCard.value.y = projected.y - 10;
  2582. };
  2583. const hideMapHoverCard = () => {
  2584. if (hoverCardLocked.value) return;
  2585. clearHoverCardHideTimer();
  2586. hoverCard.value.visible = false;
  2587. };
  2588. const lockMapHoverCard = () => {
  2589. clearHoverCardHideTimer();
  2590. hoverCardLocked.value = true;
  2591. };
  2592. const unlockMapHoverCard = () => {
  2593. hoverCardLocked.value = false;
  2594. hideMapHoverCard();
  2595. };
  2596. const scheduleHideMapHoverCard = () => {
  2597. if (hoverCardLocked.value) return;
  2598. clearHoverCardHideTimer();
  2599. hoverCardHideTimer = setTimeout(() => {
  2600. hoverCardHideTimer = null;
  2601. if (!hoverCardLocked.value) {
  2602. hoverCard.value.visible = false;
  2603. }
  2604. }, 180);
  2605. };
  2606. const showMapHoverCard = (event: maplibregl.MapLayerMouseEvent) => {
  2607. const feature = event.features?.[0] as any;
  2608. const coordinates = feature?.geometry?.coordinates as [number, number] | undefined;
  2609. const properties = feature?.properties as Record<string, unknown> | undefined;
  2610. if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties) {
  2611. hideMapHoverCard();
  2612. return;
  2613. }
  2614. clearHoverCardHideTimer();
  2615. hoverCardLocked.value = false;
  2616. const lng = Number(coordinates[0]);
  2617. const lat = Number(coordinates[1]);
  2618. const projectedPoint = map.value?.project([lng, lat]);
  2619. hoverCard.value = {
  2620. visible: true,
  2621. x: projectedPoint?.x ?? event.point.x,
  2622. y: (projectedPoint?.y ?? event.point.y) - 10,
  2623. lng,
  2624. lat,
  2625. locationName: toText(properties.locationName),
  2626. address: toText(properties.address) || toText(properties.displayLabel) || toText(properties.mapLabel),
  2627. stationCode: toText(properties.address),
  2628. coordinateText: formatMapCoordinateText(lng, lat),
  2629. };
  2630. };
  2631. const handleCopyMapHoverText = (type: 'address' | 'coordinate') => {
  2632. const text =
  2633. type === 'coordinate'
  2634. ? hoverCard.value.coordinateText
  2635. : hoverCard.value.address || hoverCard.value.locationName || hoverCard.value.coordinateText;
  2636. if (!text) {
  2637. createMessage.warning(type === 'coordinate' ? '暂无可复制的经纬度' : '暂无可复制的位置信息');
  2638. return;
  2639. }
  2640. const copied = copyTextToClipboard(text, { target: pageRootRef.value || document.body });
  2641. if (copied) {
  2642. createMessage.success(type === 'coordinate' ? '经纬度已复制' : '位置信息已复制');
  2643. return;
  2644. }
  2645. createMessage.warning(type === 'coordinate' ? '经纬度复制失败' : '位置信息复制失败');
  2646. };
  2647. const fitMap = (animate = true) => {
  2648. if (!map.value || !mapLoaded.value) return;
  2649. if (!locatedEvents.value.length) {
  2650. map.value.easeTo({ center: CENTER, zoom: 10.6, duration: animate ? 600 : 0 });
  2651. return;
  2652. }
  2653. if (locatedEvents.value.length === 1) {
  2654. const item = locatedEvents.value[0];
  2655. map.value.easeTo({ center: [item.lng as number, item.lat as number], zoom: 13.6, duration: animate ? 600 : 0 });
  2656. return;
  2657. }
  2658. const bounds = new maplibregl.LngLatBounds();
  2659. locatedEvents.value.forEach((item) => bounds.extend([item.lng as number, item.lat as number]));
  2660. map.value.fitBounds(bounds, { padding: 56, duration: animate ? 700 : 0, maxZoom: 13.5 });
  2661. };
  2662. const focusMap = (item?: BehaviorEvent) => {
  2663. if (
  2664. !item ||
  2665. !item.hasCoordinate ||
  2666. typeof item.lng !== 'number' ||
  2667. typeof item.lat !== 'number' ||
  2668. !map.value ||
  2669. !mapLoaded.value
  2670. )
  2671. return;
  2672. map.value.easeTo({ center: [item.lng, item.lat], zoom: 13.8, duration: 600 });
  2673. };
  2674. const initMap = async () => {
  2675. if (!mapRef.value || map.value) return;
  2676. ensurePmtiles();
  2677. ensureOfflinePmtilesArchive();
  2678. const style = await resolveStyle();
  2679. const instance = new maplibregl.Map({
  2680. container: mapRef.value,
  2681. style: style as any,
  2682. center: CENTER,
  2683. zoom: 10.6,
  2684. minZoom: 4,
  2685. maxZoom: 14,
  2686. attributionControl: false,
  2687. crossSourceCollisions: false,
  2688. fadeDuration: 0,
  2689. renderWorldCopies: false,
  2690. });
  2691. map.value = instance;
  2692. instance.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
  2693. instance.on('load', () => {
  2694. mapLoaded.value = true;
  2695. void (async () => {
  2696. try {
  2697. await registerMapPinImages(instance);
  2698. instance.addSource(SOURCE_ID, {
  2699. type: 'geojson',
  2700. data: toPoints(locatedEvents.value) as any,
  2701. cluster: true,
  2702. clusterRadius: POINT_CLUSTER_RADIUS,
  2703. clusterMaxZoom: POINT_CLUSTER_MAX_ZOOM,
  2704. });
  2705. instance.addSource(LABEL_ID, {
  2706. type: 'geojson',
  2707. data: toLabelPoints() as any,
  2708. });
  2709. instance.addLayer({
  2710. id: CLUSTER_LAYER,
  2711. type: 'circle',
  2712. source: SOURCE_ID,
  2713. filter: ['has', 'point_count'],
  2714. paint: {
  2715. 'circle-radius': ['step', ['get', 'point_count'], 17, 6, 20, 12, 23, 20, 26],
  2716. 'circle-color': '#2563eb',
  2717. 'circle-stroke-color': '#ffffff',
  2718. 'circle-stroke-width': 2.5,
  2719. 'circle-opacity': 0.96,
  2720. },
  2721. });
  2722. instance.addLayer({
  2723. id: CLUSTER_COUNT_LAYER,
  2724. type: 'symbol',
  2725. source: SOURCE_ID,
  2726. filter: ['has', 'point_count'],
  2727. layout: {
  2728. 'text-field': ['to-string', ['get', 'point_count']],
  2729. 'text-size': 11,
  2730. 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
  2731. 'text-allow-overlap': true,
  2732. 'text-ignore-placement': true,
  2733. },
  2734. paint: {
  2735. 'text-color': '#ffffff',
  2736. },
  2737. });
  2738. instance.addLayer({
  2739. id: POINT_LAYER,
  2740. type: 'symbol',
  2741. source: SOURCE_ID,
  2742. filter: ['!', ['has', 'point_count']],
  2743. layout: {
  2744. 'icon-image': [
  2745. 'case',
  2746. ['boolean', ['get', 'isActive'], false],
  2747. MAP_PIN_ICON_ACTIVE,
  2748. getPointPinImageExpression(),
  2749. ],
  2750. 'icon-size': ['interpolate', ['linear'], ['zoom'], 4, 1.52, 10.6, 1.76, 14, 1.92],
  2751. 'icon-anchor': 'bottom',
  2752. 'icon-allow-overlap': true,
  2753. 'icon-ignore-placement': true,
  2754. },
  2755. });
  2756. instance.addLayer({
  2757. id: POINT_LABEL_LAYER,
  2758. type: 'symbol',
  2759. source: SOURCE_ID,
  2760. filter: ['!', ['has', 'point_count']],
  2761. layout: {
  2762. 'text-field': [
  2763. 'coalesce',
  2764. ['get', 'displayLabel'],
  2765. ['get', 'mapLabel'],
  2766. ['get', 'locationName'],
  2767. ['get', 'address'],
  2768. '',
  2769. ],
  2770. 'text-size': getPointLabelTextSize(mapLabelFeatures.value.length),
  2771. 'text-offset': [0, 0.58],
  2772. 'text-anchor': 'top',
  2773. 'text-justify': 'center',
  2774. 'text-line-height': 1.22,
  2775. 'text-max-width': 18,
  2776. 'text-allow-overlap': true,
  2777. 'text-ignore-placement': true,
  2778. 'text-optional': true,
  2779. 'text-padding': 2,
  2780. },
  2781. paint: {
  2782. 'text-color': '#0f172a',
  2783. 'text-halo-color': 'rgba(255, 255, 255, 0.96)',
  2784. 'text-halo-width': 1.5,
  2785. },
  2786. });
  2787. updateSources();
  2788. fitMap(false);
  2789. syncMapLabelLayerVisibility();
  2790. setTimeout(() => instance.resize(), 0);
  2791. } catch (error) {
  2792. console.error('行为时空地图图标加载失败', error);
  2793. createMessage.warning('地图点位图标加载失败');
  2794. }
  2795. })();
  2796. });
  2797. instance.on('movestart', () => {
  2798. clearMapLabelRestoreTimer();
  2799. setMapLabelLayerVisibility(false);
  2800. hideMapHoverCard();
  2801. });
  2802. instance.on('moveend', () => {
  2803. scheduleMapLabelLayerRestore();
  2804. });
  2805. const handleClusterClick = (event: maplibregl.MapLayerMouseEvent) => {
  2806. const clusterFeature = event.features?.[0] as any;
  2807. const clusterId = clusterFeature?.properties?.cluster_id;
  2808. const pointCount = Number(clusterFeature?.properties?.point_count || 0);
  2809. const clusterCoordinates = clusterFeature?.geometry?.coordinates as [number, number] | undefined;
  2810. if (clusterId === undefined || !clusterCoordinates) return;
  2811. const source = instance.getSource(SOURCE_ID) as any;
  2812. if (!source?.getClusterExpansionZoom || !source?.getClusterLeaves) return;
  2813. void (async () => {
  2814. try {
  2815. const leaves = (await source.getClusterLeaves(clusterId, Math.max(pointCount, 1), 0)) as Array<any>;
  2816. const coordinates = leaves
  2817. .map((feature) => feature?.geometry?.coordinates)
  2818. .filter(
  2819. (coords): coords is [number, number] =>
  2820. Array.isArray(coords) &&
  2821. coords.length >= 2 &&
  2822. typeof coords[0] === 'number' &&
  2823. typeof coords[1] === 'number',
  2824. );
  2825. const uniqueCoordinateKeys = new Set(
  2826. coordinates.map((coords) => `${coords[0].toFixed(6)}:${coords[1].toFixed(6)}`),
  2827. );
  2828. if (coordinates.length > 1 && uniqueCoordinateKeys.size > 1) {
  2829. const bounds = new maplibregl.LngLatBounds();
  2830. coordinates.forEach((coords) => bounds.extend(coords));
  2831. instance.fitBounds(bounds, {
  2832. padding: 96,
  2833. duration: 420,
  2834. maxZoom: 14,
  2835. });
  2836. return;
  2837. }
  2838. const zoom = await source.getClusterExpansionZoom(clusterId);
  2839. instance.easeTo({
  2840. center: coordinates[0] || clusterCoordinates,
  2841. zoom: Math.min(Math.max(zoom, POINT_CLUSTER_MAX_ZOOM + 1.2), 14),
  2842. duration: 420,
  2843. });
  2844. } catch {
  2845. const zoom = await source.getClusterExpansionZoom(clusterId);
  2846. instance.easeTo({
  2847. center: clusterCoordinates,
  2848. zoom: Math.min(Math.max(zoom, POINT_CLUSTER_MAX_ZOOM + 1.2), 14),
  2849. duration: 420,
  2850. });
  2851. }
  2852. })();
  2853. };
  2854. instance.on('click', CLUSTER_LAYER, handleClusterClick);
  2855. instance.on('click', CLUSTER_COUNT_LAYER, handleClusterClick);
  2856. instance.on('click', POINT_LAYER, (event) => {
  2857. const targetId = (event.features?.[0] as any)?.properties?.id;
  2858. const target = filteredEvents.value.find((item) => item.id === targetId);
  2859. if (target) handleSelect(target, false);
  2860. });
  2861. instance.on('click', POINT_LABEL_LAYER, (event) => {
  2862. const targetId = (event.features?.[0] as any)?.properties?.id;
  2863. const target = filteredEvents.value.find((item) => item.id === targetId);
  2864. if (target) handleSelect(target, false);
  2865. });
  2866. instance.on('mouseenter', CLUSTER_LAYER, () => (instance.getCanvas().style.cursor = 'pointer'));
  2867. instance.on('mouseleave', CLUSTER_LAYER, () => (instance.getCanvas().style.cursor = ''));
  2868. instance.on('mouseenter', CLUSTER_COUNT_LAYER, () => (instance.getCanvas().style.cursor = 'pointer'));
  2869. instance.on('mouseleave', CLUSTER_COUNT_LAYER, () => (instance.getCanvas().style.cursor = ''));
  2870. instance.on('mouseenter', POINT_LAYER, (event) => {
  2871. instance.getCanvas().style.cursor = 'pointer';
  2872. showMapHoverCard(event);
  2873. });
  2874. instance.on('mousemove', POINT_LAYER, showMapHoverCard);
  2875. instance.on('mouseleave', POINT_LAYER, () => {
  2876. instance.getCanvas().style.cursor = '';
  2877. scheduleHideMapHoverCard();
  2878. });
  2879. instance.on('mouseenter', POINT_LABEL_LAYER, (event) => {
  2880. instance.getCanvas().style.cursor = 'pointer';
  2881. showMapHoverCard(event);
  2882. });
  2883. instance.on('mousemove', POINT_LABEL_LAYER, showMapHoverCard);
  2884. instance.on('mouseleave', POINT_LABEL_LAYER, () => {
  2885. instance.getCanvas().style.cursor = '';
  2886. scheduleHideMapHoverCard();
  2887. });
  2888. instance.on('move', syncMapHoverCardPosition);
  2889. instance.on('error', (event) => {
  2890. errorMsg.value = event.error?.message || 'map error';
  2891. });
  2892. };
  2893. const recreateMap = async () => {
  2894. mapLoaded.value = false;
  2895. errorMsg.value = '';
  2896. clearMapLabelRestoreTimer();
  2897. clearHoverCardHideTimer();
  2898. hideMapHoverCard();
  2899. map.value?.remove();
  2900. map.value = null;
  2901. await nextTick();
  2902. await initMap();
  2903. const instance = map.value as MapLibreMap | null;
  2904. if (instance !== null) instance.resize();
  2905. };
  2906. const handleOfflineMapDirChanged = () => {
  2907. void recreateMap();
  2908. };
  2909. const loadTargets = async () => {
  2910. try {
  2911. const persons = await getPersons();
  2912. targetOptions.value = (Array.isArray(persons) ? persons : [])
  2913. .filter((item) => toText(item.personName))
  2914. .map((item) => ({
  2915. label: toText(item.personName),
  2916. value: toText(item.personName),
  2917. identityCode: toText(item.personCertNo) || undefined,
  2918. photograph: toText(item.photograph) || '',
  2919. }));
  2920. } catch {
  2921. targetOptions.value = [];
  2922. }
  2923. };
  2924. const buildTimeSeriesQuery = (snapshot: BehaviorFilters, page = 1): TimeSeriesQuery => {
  2925. const personNames = snapshot.checkedTargets.length ? snapshot.checkedTargets : undefined;
  2926. const types = toBehaviorQueryTypes(snapshot.behaviorTypes);
  2927. return {
  2928. hasLocal: snapshot.locatedOnly === 'located' ? 'Y' : 'N',
  2929. page,
  2930. limit: REMOTE_QUERY_LIMIT,
  2931. orderKey: 'seriesDateTime',
  2932. startDate: snapshot.dateRange?.[0] || undefined,
  2933. endDate: snapshot.dateRange?.[1] || undefined,
  2934. personNames,
  2935. types: types.length ? types : undefined,
  2936. keyword: snapshot.keyword.trim() || undefined,
  2937. };
  2938. };
  2939. const buildTimeSeriesCursorQuery = (
  2940. snapshot: BehaviorFilters,
  2941. list: BehaviorEvent[],
  2942. direction: 'GT' | 'LT' = 'GT',
  2943. ): TimeSeriesQuery => {
  2944. const baseQuery = buildTimeSeriesQuery(snapshot, 1);
  2945. return {
  2946. ...baseQuery,
  2947. rowId: getTimeSeriesCursorRowId(list, direction),
  2948. rowIdSymbol: direction,
  2949. page: undefined,
  2950. };
  2951. };
  2952. const buildJumpTimeSeriesQuery = (
  2953. payload: BehaviorSpacetimeJumpPayload,
  2954. snapshot: BehaviorFilters,
  2955. page = 1,
  2956. ): TimeSeriesQuery => {
  2957. const baseQuery = buildTimeSeriesJumpQuery(payload);
  2958. const types = toBehaviorQueryTypes(snapshot.behaviorTypes);
  2959. return {
  2960. ...baseQuery,
  2961. page,
  2962. limit: REMOTE_QUERY_LIMIT,
  2963. orderKey: 'seriesDateTime',
  2964. startDate: snapshot.dateRange?.[0] || undefined,
  2965. endDate: snapshot.dateRange?.[1] || undefined,
  2966. types: types.length ? types : baseQuery.types,
  2967. keyword: snapshot.keyword.trim() || baseQuery.keyword,
  2968. };
  2969. };
  2970. const buildJumpTimeSeriesCursorQuery = (
  2971. payload: BehaviorSpacetimeJumpPayload,
  2972. snapshot: BehaviorFilters,
  2973. list: BehaviorEvent[],
  2974. direction: 'GT' | 'LT' = 'GT',
  2975. ): TimeSeriesQuery => {
  2976. const baseQuery = buildJumpTimeSeriesQuery(payload, snapshot, 1);
  2977. return {
  2978. ...baseQuery,
  2979. rowId: getTimeSeriesCursorRowId(list, direction),
  2980. rowIdSymbol: direction,
  2981. page: undefined,
  2982. };
  2983. };
  2984. const appendRemoteEvents = (baseList: BehaviorEvent[], nextList: BehaviorEvent[]) =>
  2985. [...baseList, ...nextList].sort((left, right) => getTimeStamp(left.occurredAt) - getTimeStamp(right.occurredAt));
  2986. const upsertRemoteEventDetail = (detailEvent: BehaviorEvent) => {
  2987. if (!remoteEvents.value.some((item) => item.id === detailEvent.id)) return;
  2988. let changed = false;
  2989. const nextList = remoteEvents.value.map((item) => {
  2990. if (item.id !== detailEvent.id) return item;
  2991. const merged = { ...item, ...detailEvent };
  2992. const isSame = Object.keys(merged).every((key) => merged[key] === item[key]);
  2993. if (isSame) return item;
  2994. changed = true;
  2995. return merged;
  2996. });
  2997. if (changed) remoteEvents.value = nextList;
  2998. };
  2999. const applyTimeSeriesListResponse = (resp: any, page: number, append = false, direction: 'GT' | 'LT' = 'GT') => {
  3000. const list = normalizeTimeSeriesList(resp);
  3001. const normalizedList = list
  3002. .map((item, index) => normalizeTimeSeriesToBehaviorEvent(item, (page - 1) * REMOTE_QUERY_LIMIT + index))
  3003. .map((item) => detailCache.get(item.id) || item)
  3004. .sort((left, right) => getTimeStamp(left.occurredAt) - getTimeStamp(right.occurredAt));
  3005. remoteEvents.value = append ? appendRemoteEvents(remoteEvents.value, normalizedList) : normalizedList;
  3006. remoteTotal.value = normalizeTimeSeriesTotal(resp, remoteEvents.value.length);
  3007. timelinePage.value = page;
  3008. if (!append) {
  3009. timelineCanLoadForward.value = list.length >= REMOTE_QUERY_LIMIT;
  3010. timelineCanLoadBackward.value = false;
  3011. } else if (direction === 'LT') {
  3012. timelineCanLoadBackward.value = list.length >= REMOTE_QUERY_LIMIT;
  3013. } else {
  3014. timelineCanLoadForward.value = list.length >= REMOTE_QUERY_LIMIT;
  3015. }
  3016. if (remoteTotal.value > 0 && remoteEvents.value.length >= remoteTotal.value) {
  3017. timelineCanLoadForward.value = false;
  3018. timelineCanLoadBackward.value = false;
  3019. }
  3020. timelineHasMore.value = timelineCanLoadForward.value || timelineCanLoadBackward.value;
  3021. timelineListInitialized.value = true;
  3022. return true;
  3023. };
  3024. const fetchTimeSeriesEventDetail = async (item: BehaviorEvent) => {
  3025. const cachedEvent = detailCache.get(item.id);
  3026. if (cachedEvent) {
  3027. upsertRemoteEventDetail(cachedEvent);
  3028. }
  3029. const query = buildTimeSeriesDetailQuery(item);
  3030. if (!query.id && !query.rowId) {
  3031. detailLoading.value = false;
  3032. detailError.value = '当前节点缺少详情主键,无法查询接口详情';
  3033. return;
  3034. }
  3035. const requestToken = ++detailRequestToken;
  3036. detailLoading.value = true;
  3037. detailError.value = '';
  3038. try {
  3039. const resp = await timeSeriesDetail(query);
  3040. if (requestToken !== detailRequestToken || selectedId.value !== item.id) return;
  3041. const { record: detailRecord, head } = normalizeTimeSeriesDetailPayload(resp);
  3042. if (!detailRecord) {
  3043. detailError.value = '详情接口未返回有效数据';
  3044. return;
  3045. }
  3046. const merged = mergeTimeSeriesDetailToBehaviorEvent(item, detailRecord);
  3047. detailCache.set(item.id, merged);
  3048. detailRecordCache.value[item.id] = detailRecord;
  3049. detailHeadCache.value[item.id] = head;
  3050. upsertRemoteEventDetail(merged);
  3051. } catch {
  3052. detailError.value = '详情接口请求失败,请稍后重试';
  3053. } finally {
  3054. if (requestToken === detailRequestToken && selectedId.value === item.id) {
  3055. detailLoading.value = false;
  3056. }
  3057. }
  3058. };
  3059. const hydrateJumpAnchorEvent = async (item: BehaviorEvent) => {
  3060. const query = buildTimeSeriesDetailQuery(item);
  3061. if (!query.id && !query.rowId) return;
  3062. const requestToken = ++detailRequestToken;
  3063. detailLoading.value = true;
  3064. detailError.value = '';
  3065. try {
  3066. const resp = await timeSeriesDetail(query);
  3067. if (requestToken !== detailRequestToken || selectedId.value !== item.id) return;
  3068. const { record: detailRecord, head } = normalizeTimeSeriesDetailPayload(resp);
  3069. if (!detailRecord) {
  3070. detailError.value = '详情接口未返回有效数据';
  3071. return;
  3072. }
  3073. const detailTimeSeries = toTimeSeriesDetailRecord(detailRecord, item);
  3074. const normalized = normalizeTimeSeriesToBehaviorEvent(detailTimeSeries, 0);
  3075. const rawTableNameEn = toText(detailRecord.tableNameEn) || item.rawTableNameEn || item.tableNameEn;
  3076. const mergedLng = typeof normalized.lng === 'number' ? normalized.lng : item.lng;
  3077. const mergedLat = typeof normalized.lat === 'number' ? normalized.lat : item.lat;
  3078. const mergedHasCoordinate = typeof mergedLng === 'number' && typeof mergedLat === 'number';
  3079. const merged = {
  3080. ...item,
  3081. ...normalized,
  3082. id: item.id,
  3083. rowId: toText(detailRecord.rowId ?? detailRecord.id) || item.rowId,
  3084. recordId: toText(detailRecord.id ?? detailRecord.rowId) || item.recordId,
  3085. rawTableNameEn: rawTableNameEn || undefined,
  3086. tableNameEn: normalized.tableNameEn || item.tableNameEn,
  3087. tableNameCn: toText(detailRecord.tableNameCn) || normalized.tableNameCn || item.tableNameCn,
  3088. lng: mergedLng,
  3089. lat: mergedLat,
  3090. hasCoordinate: mergedHasCoordinate,
  3091. } as BehaviorEvent;
  3092. detailCache.set(item.id, merged);
  3093. detailRecordCache.value[item.id] = detailRecord;
  3094. detailHeadCache.value[item.id] = head;
  3095. remoteEvents.value = remoteEvents.value.map((eventItem) => (eventItem.id === item.id ? merged : eventItem));
  3096. } catch {
  3097. detailError.value = '详情接口请求失败,请稍后重试';
  3098. throw new Error('jump-anchor-detail-failed');
  3099. } finally {
  3100. if (requestToken === detailRequestToken && selectedId.value === item.id) {
  3101. detailLoading.value = false;
  3102. }
  3103. }
  3104. };
  3105. const fetchTimeSeriesEvents = async (snapshot: BehaviorFilters, page = 1, append = false, requestToken?: number) => {
  3106. const query = append
  3107. ? buildTimeSeriesCursorQuery(snapshot, remoteEvents.value, 'GT')
  3108. : buildTimeSeriesQuery(snapshot, page);
  3109. const resp = await timeSeriesList(query);
  3110. if (requestToken !== undefined && requestToken !== timelineRequestToken) return false;
  3111. return applyTimeSeriesListResponse(resp, page, append);
  3112. };
  3113. const fetchJumpTimeSeriesEvents = async (
  3114. payload: BehaviorSpacetimeJumpPayload,
  3115. snapshot: BehaviorFilters,
  3116. page = 1,
  3117. append = false,
  3118. requestToken?: number,
  3119. direction: 'GT' | 'LT' = 'GT',
  3120. ) => {
  3121. const query = append
  3122. ? buildJumpTimeSeriesCursorQuery(payload, snapshot, remoteEvents.value, direction)
  3123. : buildJumpTimeSeriesQuery(payload, snapshot, page);
  3124. const resp = await timeSeriesList(query);
  3125. if (requestToken !== undefined && requestToken !== timelineRequestToken) return false;
  3126. const applied = applyTimeSeriesListResponse(resp, page, append, direction);
  3127. if (!append && remoteEvents.value.length) {
  3128. timelineCanLoadForward.value = true;
  3129. timelineCanLoadBackward.value = true;
  3130. timelineHasMore.value = true;
  3131. }
  3132. return applied;
  3133. };
  3134. const applyJumpTimeSeriesResponse = (resp: any, payload: BehaviorSpacetimeJumpPayload, page = 1, append = false) => {
  3135. const applied = applyTimeSeriesListResponse(resp, page, append);
  3136. if (!applied || append || remoteEvents.value.length) return applied;
  3137. const anchorEvent = buildJumpAnchorEvent(payload);
  3138. if (anchorEvent) {
  3139. remoteEvents.value = [anchorEvent];
  3140. remoteTotal.value = Math.max(remoteTotal.value, 1);
  3141. timelineCanLoadForward.value = true;
  3142. timelineCanLoadBackward.value = true;
  3143. timelineHasMore.value = true;
  3144. timelineListInitialized.value = false;
  3145. selectedId.value = anchorEvent.id;
  3146. }
  3147. return applied;
  3148. };
  3149. const clearBehaviorData = (snapshot?: BehaviorFilters, preserveJump = false) => {
  3150. const nextFilters = snapshot ? cloneFilters(snapshot) : cloneFilters(filters.value);
  3151. applied.value = nextFilters;
  3152. if (!preserveJump) {
  3153. activeJumpPayload.value = null;
  3154. }
  3155. remoteEvents.value = [];
  3156. remoteTotal.value = 0;
  3157. timelinePage.value = 1;
  3158. timelineHasMore.value = false;
  3159. timelineCanLoadForward.value = false;
  3160. timelineCanLoadBackward.value = false;
  3161. timelineMoreLoading.value = false;
  3162. timelineListInitialized.value = false;
  3163. timelineRequestToken += 1;
  3164. selectedId.value = '';
  3165. clearPendingTimelineScroll();
  3166. detailCache.clear();
  3167. detailRecordCache.value = {};
  3168. detailHeadCache.value = {};
  3169. detailDrawerOpen.value = false;
  3170. detailLoading.value = false;
  3171. detailError.value = '';
  3172. detailRequestToken += 1;
  3173. };
  3174. const applyJumpAnchorEvent = (payload: BehaviorSpacetimeJumpPayload) => {
  3175. const anchorEvent = buildJumpAnchorEvent(payload);
  3176. if (!anchorEvent) return undefined;
  3177. remoteEvents.value = [anchorEvent];
  3178. remoteTotal.value = Math.max(remoteTotal.value, 1);
  3179. selectedId.value = anchorEvent.id;
  3180. return anchorEvent;
  3181. };
  3182. const collectComparableIds = (...values: Array<unknown>) => {
  3183. const idSet = new Set<string>();
  3184. values.forEach((value) => {
  3185. if (typeof value !== 'string' && typeof value !== 'number') return;
  3186. const text = toText(value);
  3187. if (!text) return;
  3188. idSet.add(text);
  3189. const suffix = text.split('-').pop();
  3190. if (suffix && suffix !== text) {
  3191. idSet.add(suffix);
  3192. }
  3193. });
  3194. return idSet;
  3195. };
  3196. const getNormalizedEventTableName = (item: BehaviorEvent) => {
  3197. return normalizeTimeSeriesTableNameEn(item.rawTableNameEn || item.tableNameEn);
  3198. };
  3199. const syncCurrentTabCachePolicy = async () => {
  3200. delete route.meta?.ignoreKeepAlive;
  3201. await tabStore.addTab(route as RouteLocationNormalized);
  3202. await tabStore.updateCacheTab();
  3203. };
  3204. const clearTimelineScrollRetry = () => {
  3205. if (timelineScrollRetryTimer !== null) {
  3206. clearTimeout(timelineScrollRetryTimer);
  3207. timelineScrollRetryTimer = null;
  3208. }
  3209. };
  3210. const isTimelineProgrammaticScrolling = () => Date.now() < timelineProgrammaticScrollUntil;
  3211. const clearPendingTimelineScroll = () => {
  3212. pendingTimelineScrollId.value = '';
  3213. pendingTimelineScrollAttempts.value = 0;
  3214. pendingTimelineScrollBehavior.value = 'auto';
  3215. clearTimelineScrollRetry();
  3216. };
  3217. const markTimelineProgrammaticScroll = (duration = 480) => {
  3218. timelineProgrammaticScrollUntil = Date.now() + duration;
  3219. };
  3220. const getTimelineTargetElement = (id: string) => {
  3221. const container = timelineListRef.value;
  3222. if (!container || !id) return null;
  3223. return (Array.from(container.querySelectorAll('.timeline-item')).find(
  3224. (element) => element.getAttribute('data-timeline-id') === id,
  3225. ) || null) as HTMLElement | null;
  3226. };
  3227. const queueTimelineScroll = (id?: string | null, behavior: ScrollBehavior = 'auto', attempts = 6) => {
  3228. const targetId = toText(id);
  3229. if (!targetId) {
  3230. clearPendingTimelineScroll();
  3231. return;
  3232. }
  3233. pendingTimelineScrollId.value = targetId;
  3234. pendingTimelineScrollBehavior.value = behavior;
  3235. pendingTimelineScrollAttempts.value = Math.max(1, attempts);
  3236. };
  3237. const resetPageScrollPosition = (options?: { resetTimeline?: boolean }) => {
  3238. const { resetTimeline = true } = options || {};
  3239. nextTick(() => {
  3240. pageContentRef.value?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
  3241. if (resetTimeline) {
  3242. timelineListRef.value?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
  3243. }
  3244. const layoutContent = pageRootRef.value?.closest('.jeesite-layout-content') as HTMLElement | null;
  3245. layoutContent?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
  3246. });
  3247. };
  3248. const applyRouteJumpPayload = async (payload: BehaviorSpacetimeJumpPayload) => {
  3249. const nextFilters = buildFiltersFromJumpPayload(payload);
  3250. const requestSignature = buildJumpRequestSignature(payload, nextFilters);
  3251. if (
  3252. requestSignature &&
  3253. appliedJumpRequestSignature.value === requestSignature &&
  3254. remoteEvents.value.length
  3255. ) {
  3256. activeJumpPayload.value = buildTimeSeriesJumpQuery(payload);
  3257. syncSelectionFromJumpPayload(payload);
  3258. resetPageScrollPosition({ resetTimeline: false });
  3259. return;
  3260. }
  3261. if (restoreBehaviorSpacetimePageCacheForJumpPayload(payload)) {
  3262. syncSelectionFromJumpPayload(payload);
  3263. resetPageScrollPosition({ resetTimeline: false });
  3264. return;
  3265. }
  3266. resetBehaviorSpacetimeJumpState(nextFilters);
  3267. activeJumpPayload.value = payload;
  3268. if (
  3269. requestSignature &&
  3270. (runningJumpRequestSignature.value === requestSignature || appliedJumpRequestSignature.value === requestSignature)
  3271. ) {
  3272. syncSelectionFromJumpPayload(payload);
  3273. resetPageScrollPosition({ resetTimeline: false });
  3274. return;
  3275. }
  3276. runningJumpRequestSignature.value = requestSignature;
  3277. timelineRequestToken += 1;
  3278. const requestToken = timelineRequestToken;
  3279. appliedJumpRequestSignature.value = requestSignature;
  3280. resetPageScrollPosition({ resetTimeline: false });
  3281. await runWithQueryLoading(async () => {
  3282. try {
  3283. const cachedJumpResp = consumeBehaviorSpacetimeJumpResult();
  3284. if (cachedJumpResp) {
  3285. applyJumpTimeSeriesResponse(cachedJumpResp, payload, 1, false);
  3286. } else {
  3287. const anchorEvent = applyJumpAnchorEvent(payload);
  3288. if (anchorEvent) {
  3289. await hydrateJumpAnchorEvent(anchorEvent);
  3290. }
  3291. }
  3292. syncSelectionFromJumpPayload(payload);
  3293. } catch {
  3294. clearBehaviorData(nextFilters, true);
  3295. applied.value = cloneFilters(nextFilters);
  3296. activeJumpPayload.value = payload;
  3297. if (runningJumpRequestSignature.value === requestSignature) {
  3298. runningJumpRequestSignature.value = '';
  3299. }
  3300. resetPageScrollPosition({ resetTimeline: false });
  3301. createMessage.error('行为时空跳转明细加载失败,请检查 /otg/timeSeries/detailTimeSeries 联调状态。');
  3302. return;
  3303. }
  3304. });
  3305. if (runningJumpRequestSignature.value === requestSignature) {
  3306. runningJumpRequestSignature.value = '';
  3307. }
  3308. };
  3309. const handleSearch = async () => {
  3310. const nextFilters = cloneFilters(filters.value);
  3311. nextFilters.targets = normalizeTargetList(nextFilters.targets);
  3312. nextFilters.checkedTargets = normalizeTargetList(nextFilters.checkedTargets).filter((item) =>
  3313. nextFilters.targets.includes(item),
  3314. );
  3315. const jumpPayload = !nextFilters.checkedTargets.length ? activeJumpPayload.value : null;
  3316. const requestToken = ++timelineRequestToken;
  3317. timelinePage.value = 1;
  3318. timelineHasMore.value = false;
  3319. timelineCanLoadForward.value = false;
  3320. timelineCanLoadBackward.value = false;
  3321. timelineMoreLoading.value = false;
  3322. timelineListInitialized.value = false;
  3323. if (!nextFilters.checkedTargets.length && !jumpPayload) {
  3324. clearBehaviorData(nextFilters);
  3325. resetPageScrollPosition();
  3326. return;
  3327. }
  3328. await runWithQueryLoading(async () => {
  3329. try {
  3330. if (nextFilters.checkedTargets.length) {
  3331. activeJumpPayload.value = null;
  3332. clearPendingTimelineScroll();
  3333. await fetchTimeSeriesEvents(nextFilters, 1, false, requestToken);
  3334. } else if (jumpPayload) {
  3335. const cachedJumpResp = consumeBehaviorSpacetimeJumpResult();
  3336. if (cachedJumpResp) {
  3337. applyJumpTimeSeriesResponse(cachedJumpResp, jumpPayload, 1, false);
  3338. } else {
  3339. await fetchJumpTimeSeriesEvents(jumpPayload, nextFilters, 1, false, requestToken);
  3340. }
  3341. activeJumpPayload.value = jumpPayload;
  3342. syncSelectionFromJumpPayload(jumpPayload);
  3343. }
  3344. } catch {
  3345. clearBehaviorData(nextFilters, !!jumpPayload);
  3346. if (jumpPayload) {
  3347. activeJumpPayload.value = jumpPayload;
  3348. }
  3349. resetPageScrollPosition();
  3350. createMessage.error(
  3351. jumpPayload
  3352. ? '行为时空跳转数据加载失败,请检查 /otg/timeSeries/jump 与 /otg/timeSeries/list 联调状态。'
  3353. : '行为时间轴接口请求失败,请检查 /otg/timeSeries/list 联调状态。',
  3354. );
  3355. return;
  3356. }
  3357. applied.value = nextFilters;
  3358. syncAnalysisDataRefreshState(
  3359. remoteEvents.value.length > 0 && (!!nextFilters.checkedTargets.length || !!jumpPayload),
  3360. );
  3361. resetPageScrollPosition({ resetTimeline: !jumpPayload });
  3362. });
  3363. };
  3364. const handleReset = async () => {
  3365. activeJumpPayload.value = null;
  3366. filters.value = {
  3367. ...makeFilters(),
  3368. targets: [...displayTargets.value],
  3369. checkedTargets: [...checkedTargets.value],
  3370. };
  3371. targetKeyword.value = '';
  3372. await handleSearch();
  3373. };
  3374. const openTargetPicker = () => {
  3375. targetDraft.value = [...displayTargets.value];
  3376. targetKeyword.value = '';
  3377. targetPickerOpen.value = true;
  3378. };
  3379. const closeTargetPicker = () => {
  3380. targetPickerOpen.value = false;
  3381. targetKeyword.value = '';
  3382. };
  3383. const toggleDraftTarget = (target: string, checked: boolean) => {
  3384. if (checked) {
  3385. if (!targetDraft.value.includes(target)) {
  3386. targetDraft.value = [...targetDraft.value, target];
  3387. }
  3388. return;
  3389. }
  3390. targetDraft.value = targetDraft.value.filter((item) => item !== target);
  3391. };
  3392. const toggleDraftAll = (event: CheckboxChangeEvent) => {
  3393. if (event.target.checked) {
  3394. targetDraft.value = availableTargetOptions.value.map((item) => item.value);
  3395. return;
  3396. }
  3397. targetDraft.value = [];
  3398. };
  3399. const confirmTargetPicker = async () => {
  3400. if (!targetDraft.value.length) {
  3401. createMessage.warning('请至少选择一个对象');
  3402. return;
  3403. }
  3404. const nextTargets = normalizeTargetList(targetDraft.value);
  3405. filters.value.targets = nextTargets;
  3406. filters.value.checkedTargets = [...nextTargets];
  3407. closeTargetPicker();
  3408. await handleSearch();
  3409. };
  3410. const removeTarget = async (target: string) => {
  3411. filters.value.targets = displayTargets.value.filter((item) => item !== target);
  3412. filters.value.checkedTargets = checkedTargets.value.filter((item) => item !== target);
  3413. await handleSearch();
  3414. };
  3415. const handleToggleTargetChecked = async (target: string, checked: boolean) => {
  3416. if (!displayTargets.value.includes(target)) return;
  3417. const nextCheckedTargets = checked
  3418. ? normalizeTargetList([...checkedTargets.value, target])
  3419. : checkedTargets.value.filter((item) => item !== target);
  3420. if (nextCheckedTargets.length === checkedTargets.value.length && checked) return;
  3421. if (!checked && !checkedTargetSet.value.has(target)) return;
  3422. filters.value.checkedTargets = nextCheckedTargets;
  3423. await handleSearch();
  3424. };
  3425. const findEventByJumpPayload = (payload?: BehaviorSpacetimeJumpPayload | null) => {
  3426. if (!payload) return undefined;
  3427. const targetIds = collectComparableIds(payload.id, payload.rowId, payload.id || payload.rowId);
  3428. const targetTableName = normalizeTimeSeriesTableNameEn(payload.tableNameEn || payload.tableName);
  3429. const payloadOccurredAt = formatDateTimeText(payload.seriesDateTime);
  3430. const payloadPersonName = toText(payload.personName);
  3431. const matchesIds = (item: BehaviorEvent) => {
  3432. if (!targetIds.size) return false;
  3433. const itemIds = collectComparableIds(item.recordId, item.rowId, item.id);
  3434. return Array.from(targetIds).some((id) => itemIds.has(id));
  3435. };
  3436. const matchedByTableAndId = filteredEvents.value.find((item) => {
  3437. if (!matchesIds(item)) return false;
  3438. if (!targetTableName) return true;
  3439. return getNormalizedEventTableName(item) === targetTableName;
  3440. });
  3441. if (matchedByTableAndId) return matchedByTableAndId;
  3442. const matchedByIdOnly = filteredEvents.value.find((item) => matchesIds(item));
  3443. if (matchedByIdOnly) return matchedByIdOnly;
  3444. if (!payloadOccurredAt && !payloadPersonName) return undefined;
  3445. return filteredEvents.value.find((item) => {
  3446. const sameTime = payloadOccurredAt && item.occurredAt === payloadOccurredAt;
  3447. const samePerson = payloadPersonName && item.personName === payloadPersonName;
  3448. const sameTable = !targetTableName || getNormalizedEventTableName(item) === targetTableName;
  3449. return sameTable && Boolean(sameTime || samePerson);
  3450. });
  3451. };
  3452. const flushPendingTimelineScroll = () => {
  3453. const targetId = pendingTimelineScrollId.value;
  3454. const behavior = pendingTimelineScrollBehavior.value;
  3455. const attempts = pendingTimelineScrollAttempts.value;
  3456. if (!targetId || attempts <= 0) return;
  3457. if (timelineMoreLoading.value) return;
  3458. clearTimelineScrollRetry();
  3459. nextTick(() => {
  3460. window.requestAnimationFrame(() => {
  3461. const container = timelineListRef.value;
  3462. const target = getTimelineTargetElement(targetId);
  3463. if (!container || !target) {
  3464. if (pendingTimelineScrollId.value !== targetId) return;
  3465. if (attempts <= 1) {
  3466. clearPendingTimelineScroll();
  3467. return;
  3468. }
  3469. pendingTimelineScrollAttempts.value = attempts - 1;
  3470. timelineScrollRetryTimer = window.setTimeout(() => {
  3471. timelineScrollRetryTimer = null;
  3472. flushPendingTimelineScroll();
  3473. }, 120);
  3474. return;
  3475. }
  3476. markTimelineProgrammaticScroll();
  3477. target.scrollIntoView({ block: 'center', inline: 'nearest', behavior });
  3478. window.requestAnimationFrame(() => {
  3479. const latestContainer = timelineListRef.value;
  3480. const latestTarget = getTimelineTargetElement(targetId);
  3481. if (!latestContainer || !latestTarget || pendingTimelineScrollId.value !== targetId) return;
  3482. const containerRect = latestContainer.getBoundingClientRect();
  3483. const targetRect = latestTarget.getBoundingClientRect();
  3484. const safePadding = 16;
  3485. const minTop = containerRect.top + safePadding;
  3486. const maxBottom = containerRect.bottom - safePadding;
  3487. let corrected = false;
  3488. if (targetRect.top < minTop) {
  3489. const nextTop = latestContainer.scrollTop - (minTop - targetRect.top);
  3490. latestContainer.scrollTo({ top: Math.max(0, nextTop), behavior });
  3491. corrected = true;
  3492. }
  3493. if (targetRect.bottom > maxBottom) {
  3494. const nextTop = latestContainer.scrollTop + (targetRect.bottom - maxBottom);
  3495. latestContainer.scrollTo({ top: Math.max(0, nextTop), behavior });
  3496. corrected = true;
  3497. }
  3498. if (corrected) {
  3499. markTimelineProgrammaticScroll();
  3500. }
  3501. const latestRect = latestTarget.getBoundingClientRect();
  3502. const fullyVisible = latestRect.top >= minTop && latestRect.bottom <= maxBottom;
  3503. if (fullyVisible) {
  3504. clearPendingTimelineScroll();
  3505. return;
  3506. }
  3507. if (attempts <= 1) {
  3508. clearPendingTimelineScroll();
  3509. return;
  3510. }
  3511. pendingTimelineScrollAttempts.value = attempts - 1;
  3512. timelineScrollRetryTimer = window.setTimeout(
  3513. () => {
  3514. timelineScrollRetryTimer = null;
  3515. flushPendingTimelineScroll();
  3516. },
  3517. corrected ? 90 : 140,
  3518. );
  3519. });
  3520. });
  3521. });
  3522. };
  3523. const selectTimelineItemSilently = (item: BehaviorEvent) => {
  3524. selectedId.value = item.id;
  3525. detailDrawerOpen.value = false;
  3526. detailLoading.value = false;
  3527. detailError.value = '';
  3528. };
  3529. const syncSelectionFromJumpPayload = (payload?: BehaviorSpacetimeJumpPayload | null) => {
  3530. const matched = findEventByJumpPayload(payload);
  3531. if (!matched) return false;
  3532. selectTimelineItemSilently(matched);
  3533. queueTimelineScroll(matched.id, 'auto', 8);
  3534. return true;
  3535. };
  3536. const handleSelect = (item: BehaviorEvent, focus = true) => {
  3537. selectedId.value = item.id;
  3538. detailDrawerOpen.value = true;
  3539. detailError.value = '';
  3540. queueTimelineScroll(item.id, 'smooth', 2);
  3541. void fetchTimeSeriesEventDetail(item);
  3542. if (focus) nextTick(() => focusMap(item));
  3543. };
  3544. const loadMoreTimeSeriesEvents = async (direction: 'GT' | 'LT' = 'GT') => {
  3545. const canLoadByDirection = direction === 'LT' ? timelineCanLoadBackward.value : timelineCanLoadForward.value;
  3546. if (
  3547. (!hasAppliedCheckedTargets.value && !hasAppliedJumpPayload.value) ||
  3548. queryLoading.value ||
  3549. timelineMoreLoading.value ||
  3550. hasLoadedAllRemote.value ||
  3551. !timelineHasMore.value ||
  3552. !canLoadByDirection
  3553. ) {
  3554. return;
  3555. }
  3556. const requestToken = timelineRequestToken;
  3557. const snapshot = cloneFilters(applied.value);
  3558. timelineMoreLoading.value = true;
  3559. try {
  3560. if (hasAppliedCheckedTargets.value) {
  3561. await fetchTimeSeriesEvents(snapshot, timelinePage.value + 1, true, requestToken);
  3562. } else if (activeJumpPayload.value) {
  3563. if (!timelineListInitialized.value) {
  3564. await fetchJumpTimeSeriesEvents(activeJumpPayload.value, snapshot, 1, false, requestToken);
  3565. } else {
  3566. const query = buildJumpTimeSeriesCursorQuery(
  3567. activeJumpPayload.value,
  3568. snapshot,
  3569. remoteEvents.value,
  3570. direction,
  3571. );
  3572. const resp = await timeSeriesList(query);
  3573. if (requestToken !== timelineRequestToken) return;
  3574. const nextList = normalizeTimeSeriesList(resp);
  3575. if (nextList.length) {
  3576. applyTimeSeriesListResponse(resp, timelinePage.value + 1, true, direction);
  3577. } else if (direction === 'LT') {
  3578. timelineCanLoadBackward.value = false;
  3579. timelineHasMore.value = timelineCanLoadForward.value || timelineCanLoadBackward.value;
  3580. } else {
  3581. timelineCanLoadForward.value = false;
  3582. timelineHasMore.value = timelineCanLoadForward.value || timelineCanLoadBackward.value;
  3583. }
  3584. }
  3585. }
  3586. } catch {
  3587. createMessage.error('加载更多失败,请稍后重试。');
  3588. } finally {
  3589. if (requestToken === timelineRequestToken) {
  3590. timelineMoreLoading.value = false;
  3591. }
  3592. }
  3593. };
  3594. const handleTimelineScroll = () => {
  3595. const container = timelineListRef.value;
  3596. if (
  3597. !container ||
  3598. timelineMoreLoading.value ||
  3599. hasLoadedAllRemote.value ||
  3600. !timelineHasMore.value ||
  3601. isTimelineProgrammaticScrolling()
  3602. )
  3603. return;
  3604. const canBidirectionalLoad = hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value;
  3605. const reachedTop = container.scrollTop <= TIMELINE_LOAD_MORE_THRESHOLD;
  3606. const reachedBottom =
  3607. container.scrollTop + container.clientHeight >= container.scrollHeight - TIMELINE_LOAD_MORE_THRESHOLD;
  3608. if (canBidirectionalLoad && reachedTop && timelineCanLoadBackward.value) {
  3609. void loadMoreTimeSeriesEvents('LT');
  3610. return;
  3611. }
  3612. if (reachedBottom && timelineCanLoadForward.value) {
  3613. void loadMoreTimeSeriesEvents('GT');
  3614. }
  3615. };
  3616. const handleTimelineWheel = (event: WheelEvent) => {
  3617. if (
  3618. timelineMoreLoading.value ||
  3619. hasLoadedAllRemote.value ||
  3620. !timelineHasMore.value ||
  3621. isTimelineProgrammaticScrolling()
  3622. )
  3623. return;
  3624. const container = timelineListRef.value;
  3625. if (!container) return;
  3626. const canBidirectionalLoad = hasAppliedJumpPayload.value && !hasAppliedCheckedTargets.value;
  3627. const canScroll = container.scrollHeight - container.clientHeight > 0;
  3628. if (event.deltaY < 0) {
  3629. if (
  3630. canBidirectionalLoad &&
  3631. (!canScroll || container.scrollTop <= TIMELINE_LOAD_MORE_THRESHOLD) &&
  3632. timelineCanLoadBackward.value
  3633. ) {
  3634. void loadMoreTimeSeriesEvents('LT');
  3635. }
  3636. return;
  3637. }
  3638. if (
  3639. event.deltaY > 0 &&
  3640. (!canScroll ||
  3641. container.scrollTop + container.clientHeight >= container.scrollHeight - TIMELINE_LOAD_MORE_THRESHOLD) &&
  3642. timelineCanLoadForward.value
  3643. ) {
  3644. void loadMoreTimeSeriesEvents('GT');
  3645. }
  3646. };
  3647. const handleFit = () => fitMap();
  3648. const handleReloadMap = async () => {
  3649. if (mapReloading.value) return;
  3650. mapReloading.value = true;
  3651. try {
  3652. await recreateMap();
  3653. createMessage.success('离线地图已重新加载');
  3654. } finally {
  3655. mapReloading.value = false;
  3656. }
  3657. };
  3658. const handleFocus = () => {
  3659. if (!activeEvent.value) return;
  3660. if (!activeEvent.value.hasCoordinate) {
  3661. createMessage.warning('当前节点暂无精确坐标,无法在地图中定位。');
  3662. return;
  3663. }
  3664. focusMap(activeEvent.value);
  3665. };
  3666. const handleResize = () => map.value?.resize();
  3667. watch(
  3668. filteredEvents,
  3669. (list, previousList) => {
  3670. if (!list.length) {
  3671. selectedId.value = '';
  3672. clearPendingTimelineScroll();
  3673. detailDrawerOpen.value = false;
  3674. detailLoading.value = false;
  3675. detailError.value = '';
  3676. detailRequestToken += 1;
  3677. updateSources();
  3678. fitMap(false);
  3679. return;
  3680. }
  3681. const hadCurrentSelection = list.some((item) => item.id === selectedId.value);
  3682. if (!hadCurrentSelection) {
  3683. selectedId.value = list[0].id;
  3684. detailDrawerOpen.value = false;
  3685. detailLoading.value = false;
  3686. detailError.value = '';
  3687. }
  3688. if (hasAppliedJumpPayload.value && !hadCurrentSelection) {
  3689. syncSelectionFromJumpPayload(activeJumpPayload.value);
  3690. }
  3691. const keepCurrentViewport =
  3692. (timelineMoreLoading.value && !!previousList?.length) || hasSameMapViewportBasis(list, previousList);
  3693. nextTick(() => {
  3694. updateSources();
  3695. if (!keepCurrentViewport) fitMap(false);
  3696. });
  3697. },
  3698. { immediate: true },
  3699. );
  3700. watch(
  3701. activeEvent,
  3702. (item) => {
  3703. nextTick(() => {
  3704. updatePointSource();
  3705. });
  3706. if (!item) {
  3707. detailLoading.value = false;
  3708. detailError.value = '';
  3709. detailDrawerOpen.value = false;
  3710. return;
  3711. }
  3712. },
  3713. { immediate: true },
  3714. );
  3715. watch(
  3716. [selectedId, timelineEntries, timelineMoreLoading],
  3717. ([id, entries, loading]) => {
  3718. if (!id || loading || !entries.length) return;
  3719. if (pendingTimelineScrollId.value && pendingTimelineScrollId.value === id) {
  3720. flushPendingTimelineScroll();
  3721. }
  3722. },
  3723. { immediate: true },
  3724. );
  3725. watch(
  3726. () => ({
  3727. filters: filters.value,
  3728. applied: applied.value,
  3729. activeJumpPayload: activeJumpPayload.value,
  3730. lastRouteJumpSignature: lastRouteJumpSignature.value,
  3731. appliedJumpRequestSignature: appliedJumpRequestSignature.value,
  3732. remoteEvents: remoteEvents.value,
  3733. remoteTotal: remoteTotal.value,
  3734. selectedId: selectedId.value,
  3735. timelinePage: timelinePage.value,
  3736. timelineHasMore: timelineHasMore.value,
  3737. timelineCanLoadForward: timelineCanLoadForward.value,
  3738. timelineCanLoadBackward: timelineCanLoadBackward.value,
  3739. timelineListInitialized: timelineListInitialized.value,
  3740. detailRecordCache: detailRecordCache.value,
  3741. detailHeadCache: detailHeadCache.value,
  3742. }),
  3743. () => {
  3744. saveBehaviorSpacetimePageCache();
  3745. },
  3746. { deep: true },
  3747. );
  3748. watch(
  3749. () => route.query[BEHAVIOR_SPACETIME_JUMP_QUERY_KEY],
  3750. (rawPayload) => {
  3751. if (!isCurrentBehaviorSpacetimeRoute.value) return;
  3752. const payload = parseBehaviorSpacetimeJumpPayload(rawPayload);
  3753. if (!payload) {
  3754. const hadJumpContext = Boolean(
  3755. lastRouteJumpSignature.value ||
  3756. runningJumpRequestSignature.value ||
  3757. appliedJumpRequestSignature.value ||
  3758. hasMeaningfulJumpPayload(activeJumpPayload.value),
  3759. );
  3760. if (hadJumpContext) {
  3761. void exitRouteJumpContext();
  3762. return;
  3763. }
  3764. lastRouteJumpSignature.value = '';
  3765. runningJumpRequestSignature.value = '';
  3766. appliedJumpRequestSignature.value = '';
  3767. return;
  3768. }
  3769. const signature = buildRouteJumpSignature(payload);
  3770. if (signature && signature === lastRouteJumpSignature.value) return;
  3771. lastRouteJumpSignature.value = signature;
  3772. void applyRouteJumpPayload(payload);
  3773. },
  3774. { immediate: true },
  3775. );
  3776. onMounted(async () => {
  3777. window.addEventListener('resize', handleResize);
  3778. window.addEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, handleOfflineMapDirChanged);
  3779. timelineListRef.value?.addEventListener('wheel', handleTimelineWheel, { passive: true });
  3780. await syncCurrentTabCachePolicy();
  3781. const hasRouteJumpPayload = !!parseBehaviorSpacetimeJumpPayload(route.query[BEHAVIOR_SPACETIME_JUMP_QUERY_KEY]);
  3782. if (!hasRouteJumpPayload) {
  3783. restoreBehaviorSpacetimePageCache();
  3784. }
  3785. await loadTargets();
  3786. resetPageScrollPosition({ resetTimeline: !selectedId.value });
  3787. if (selectedId.value) {
  3788. queueTimelineScroll(
  3789. selectedId.value,
  3790. hasAppliedJumpPayload.value ? 'auto' : 'smooth',
  3791. hasAppliedJumpPayload.value ? 8 : 2,
  3792. );
  3793. flushPendingTimelineScroll();
  3794. }
  3795. void initMap();
  3796. });
  3797. onActivated(() => {
  3798. void syncCurrentTabCachePolicy();
  3799. resetPageScrollPosition({ resetTimeline: !selectedId.value });
  3800. if (selectedId.value) {
  3801. queueTimelineScroll(
  3802. selectedId.value,
  3803. hasAppliedJumpPayload.value ? 'auto' : 'smooth',
  3804. hasAppliedJumpPayload.value ? 8 : 2,
  3805. );
  3806. flushPendingTimelineScroll();
  3807. }
  3808. });
  3809. onBeforeUnmount(() => {
  3810. clearTimelineScrollRetry();
  3811. clearMapLabelRestoreTimer();
  3812. window.removeEventListener('resize', handleResize);
  3813. window.removeEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, handleOfflineMapDirChanged);
  3814. timelineListRef.value?.removeEventListener('wheel', handleTimelineWheel);
  3815. mapLoaded.value = false;
  3816. map.value?.remove();
  3817. map.value = null;
  3818. });
  3819. </script>
  3820. <style lang="less">
  3821. @prefix-cls: ~'jeesite-behavior-spacetime';
  3822. .@{prefix-cls}.behavior-spacetime-page {
  3823. .trans-analysis-table-ui();
  3824. background: #fff;
  3825. padding: 0 8px 2px;
  3826. min-height: 100%;
  3827. height: 100%;
  3828. display: flex;
  3829. flex-direction: column;
  3830. overflow: hidden;
  3831. box-sizing: border-box;
  3832. .page-subtitle,
  3833. .panel-desc,
  3834. .panel-extra {
  3835. color: #6b7280;
  3836. font-size: 12px;
  3837. line-height: 20px;
  3838. }
  3839. .filter-bar {
  3840. display: flex;
  3841. align-items: center;
  3842. gap: 8px;
  3843. padding: 6px;
  3844. min-height: 40px;
  3845. border: 0;
  3846. border-radius: 0;
  3847. background: transparent;
  3848. box-shadow: none;
  3849. flex-wrap: wrap;
  3850. flex-shrink: 0;
  3851. box-sizing: border-box;
  3852. }
  3853. .page-content {
  3854. display: flex;
  3855. flex-direction: column;
  3856. gap: 0;
  3857. }
  3858. .target-strip-panel {
  3859. flex: 0 0 auto;
  3860. margin-bottom: 0;
  3861. padding: 8px 16px;
  3862. border: 0;
  3863. border-radius: 0;
  3864. background: transparent;
  3865. }
  3866. .target-strip {
  3867. width: 100%;
  3868. display: flex;
  3869. align-items: center;
  3870. gap: 12px;
  3871. min-width: 0;
  3872. flex-wrap: wrap;
  3873. }
  3874. .selected-targets {
  3875. flex: 1 1 auto;
  3876. min-width: 0;
  3877. min-height: 24px;
  3878. display: flex;
  3879. align-items: center;
  3880. align-content: center;
  3881. flex-wrap: wrap;
  3882. gap: 10px 18px;
  3883. }
  3884. .selected-targets__empty {
  3885. color: #9ca3af;
  3886. font-size: 13px;
  3887. line-height: 20px;
  3888. }
  3889. .selected-target {
  3890. display: inline-flex;
  3891. align-items: center;
  3892. gap: 8px;
  3893. min-height: 24px;
  3894. color: #111827;
  3895. font-size: 14px;
  3896. line-height: 20px;
  3897. }
  3898. .selected-target__checkbox {
  3899. flex: 0 0 auto;
  3900. line-height: 1;
  3901. }
  3902. .selected-target__label {
  3903. white-space: nowrap;
  3904. }
  3905. .selected-target__remove {
  3906. display: inline-flex;
  3907. align-items: center;
  3908. justify-content: center;
  3909. width: 18px;
  3910. height: 18px;
  3911. padding: 0;
  3912. border: 0;
  3913. border-radius: 50%;
  3914. background: transparent;
  3915. color: #6b7280;
  3916. cursor: pointer;
  3917. &:hover {
  3918. color: #374151;
  3919. }
  3920. }
  3921. .add-target-btn.ant-btn {
  3922. min-width: 112px;
  3923. height: 30px;
  3924. border-radius: 4px;
  3925. box-shadow: none;
  3926. flex: 0 0 auto;
  3927. }
  3928. .filter-item {
  3929. display: inline-flex;
  3930. align-items: center;
  3931. gap: 8px;
  3932. }
  3933. .filter-label {
  3934. color: #4b5563;
  3935. font-size: 12px;
  3936. white-space: nowrap;
  3937. }
  3938. .filter-input {
  3939. width: 168px;
  3940. }
  3941. .filter-type {
  3942. width: 320px;
  3943. }
  3944. .filter-date--compact {
  3945. width: 280px;
  3946. }
  3947. .filter-actions,
  3948. .map-actions {
  3949. display: inline-flex;
  3950. align-items: center;
  3951. gap: 8px;
  3952. flex-wrap: wrap;
  3953. }
  3954. .filter-actions {
  3955. margin-left: auto;
  3956. }
  3957. .filter-located-check {
  3958. color: #374151;
  3959. font-size: 13px;
  3960. line-height: 20px;
  3961. }
  3962. .content-panel {
  3963. flex: 1 1 auto;
  3964. min-height: 0;
  3965. display: grid;
  3966. grid-template-columns: minmax(460px, 6fr) minmax(340px, 4fr);
  3967. gap: 2px;
  3968. align-items: stretch;
  3969. overflow: hidden;
  3970. padding: 0;
  3971. background: transparent;
  3972. box-sizing: border-box;
  3973. }
  3974. .target-strip__label {
  3975. flex: 0 0 auto;
  3976. color: #6b7280;
  3977. font-size: 12px;
  3978. line-height: 20px;
  3979. white-space: nowrap;
  3980. }
  3981. .panel-left,
  3982. .panel-right {
  3983. .analysis-card-shell();
  3984. }
  3985. .panel-left,
  3986. .panel-right {
  3987. width: 100%;
  3988. max-width: none;
  3989. min-width: 0;
  3990. }
  3991. .panel-header {
  3992. display: flex;
  3993. align-items: center;
  3994. justify-content: space-between;
  3995. gap: 12px;
  3996. padding: 0 0 8px;
  3997. border-bottom: 0;
  3998. }
  3999. .panel-title,
  4000. .timeline-item__title {
  4001. color: #111827;
  4002. font-size: 14px;
  4003. font-weight: 600;
  4004. line-height: 22px;
  4005. }
  4006. .timeline-list {
  4007. flex: 1 1 auto;
  4008. min-height: 0;
  4009. padding: 0;
  4010. overflow: auto;
  4011. }
  4012. .timeline-item {
  4013. display: grid;
  4014. grid-template-columns: clamp(112px, 26%, 156px) 28px minmax(0, 1fr);
  4015. align-items: stretch;
  4016. gap: 10px;
  4017. padding: 8px 0 10px;
  4018. cursor: pointer;
  4019. }
  4020. .timeline-item__time-pane {
  4021. display: flex;
  4022. align-items: center;
  4023. justify-content: flex-end;
  4024. min-width: 0;
  4025. padding-top: 14px;
  4026. }
  4027. .timeline-item__time {
  4028. color: #0f172a;
  4029. font-size: 13px;
  4030. font-weight: 500;
  4031. line-height: 20px;
  4032. white-space: nowrap;
  4033. font-variant-numeric: tabular-nums;
  4034. }
  4035. .timeline-item__axis {
  4036. position: relative;
  4037. display: flex;
  4038. align-items: center;
  4039. justify-content: center;
  4040. }
  4041. .timeline-item__axis::before {
  4042. content: '';
  4043. position: absolute;
  4044. top: -8px;
  4045. bottom: -10px;
  4046. left: 50%;
  4047. width: 2px;
  4048. border-radius: 999px;
  4049. background: #cbd5e1;
  4050. transform: translateX(-50%);
  4051. }
  4052. .timeline-item.is-first .timeline-item__axis::before {
  4053. top: 50%;
  4054. }
  4055. .timeline-item.is-last .timeline-item__axis::before {
  4056. bottom: 50%;
  4057. }
  4058. .timeline-item__dot {
  4059. position: relative;
  4060. z-index: 1;
  4061. width: 14px;
  4062. height: 14px;
  4063. border: 3px solid #ffffff;
  4064. border-radius: 50%;
  4065. background: #94a3b8;
  4066. box-shadow:
  4067. 0 0 0 2px #cbd5e1,
  4068. 0 8px 16px rgba(15, 23, 42, 0.08);
  4069. }
  4070. .timeline-item__body {
  4071. flex: 1 1 auto;
  4072. min-width: 0;
  4073. padding: 10px 0 10px 12px;
  4074. border: 0;
  4075. border-left: 1px solid #e5e7eb;
  4076. border-radius: 0;
  4077. background: transparent;
  4078. transition:
  4079. border-color 0.2s ease,
  4080. background-color 0.2s ease,
  4081. box-shadow 0.2s ease,
  4082. transform 0.2s ease;
  4083. }
  4084. .timeline-item:hover .timeline-item__body {
  4085. border-color: #cbd5e1;
  4086. box-shadow: none;
  4087. }
  4088. .timeline-item.is-active .timeline-item__body {
  4089. border-color: #93c5fd;
  4090. background: rgba(239, 246, 255, 0.72);
  4091. box-shadow: none;
  4092. transform: none;
  4093. }
  4094. .timeline-item.is-active .timeline-item__dot {
  4095. background: #1677ff;
  4096. box-shadow:
  4097. 0 0 0 2px #bfdbfe,
  4098. 0 10px 18px rgba(22, 119, 255, 0.2);
  4099. }
  4100. .timeline-item__top {
  4101. display: flex;
  4102. align-items: flex-start;
  4103. justify-content: space-between;
  4104. gap: 8px;
  4105. }
  4106. .timeline-item__heading {
  4107. flex: 1 1 auto;
  4108. min-width: 0;
  4109. }
  4110. .timeline-item__badges {
  4111. display: inline-flex;
  4112. align-items: center;
  4113. justify-content: flex-end;
  4114. gap: 8px;
  4115. flex-wrap: wrap;
  4116. flex: 0 0 auto;
  4117. }
  4118. .timeline-item__person {
  4119. margin-top: 2px;
  4120. color: #64748b;
  4121. font-size: 12px;
  4122. line-height: 18px;
  4123. }
  4124. .timeline-item__marker {
  4125. display: inline-flex;
  4126. align-items: center;
  4127. justify-content: center;
  4128. width: 28px;
  4129. height: 28px;
  4130. border: 1px solid #c7dcff;
  4131. border-radius: 999px;
  4132. background: linear-gradient(180deg, #f8fbff 0%, #eaf3ff 100%);
  4133. color: #2563eb;
  4134. box-shadow:
  4135. inset 0 1px 0 rgba(255, 255, 255, 0.9),
  4136. 0 4px 10px rgba(37, 99, 235, 0.14);
  4137. white-space: nowrap;
  4138. .anticon {
  4139. font-size: 15px;
  4140. }
  4141. }
  4142. .timeline-item__tag {
  4143. display: inline-flex;
  4144. align-items: center;
  4145. justify-content: center;
  4146. min-width: 74px;
  4147. padding: 2px 10px;
  4148. border: 1px solid transparent;
  4149. border-radius: 999px;
  4150. font-size: 12px;
  4151. line-height: 20px;
  4152. white-space: nowrap;
  4153. }
  4154. .timeline-item__fields {
  4155. margin-top: 8px;
  4156. display: flex;
  4157. flex-direction: column;
  4158. gap: 4px;
  4159. }
  4160. .timeline-item__field {
  4161. display: flex;
  4162. align-items: flex-start;
  4163. gap: 6px;
  4164. min-width: 0;
  4165. font-size: 12px;
  4166. line-height: 20px;
  4167. }
  4168. .timeline-item__field-label {
  4169. flex: 0 0 auto;
  4170. color: #475569;
  4171. white-space: nowrap;
  4172. }
  4173. .timeline-item__field-value {
  4174. flex: 1 1 auto;
  4175. min-width: 0;
  4176. color: #111827;
  4177. font-size: 13px;
  4178. font-weight: 500;
  4179. line-height: 20px;
  4180. word-break: break-word;
  4181. }
  4182. .timeline-empty {
  4183. min-height: 240px;
  4184. display: flex;
  4185. align-items: center;
  4186. justify-content: center;
  4187. }
  4188. .timeline-list__footer {
  4189. padding: 8px 0 4px;
  4190. color: #94a3b8;
  4191. font-size: 12px;
  4192. line-height: 20px;
  4193. text-align: center;
  4194. }
  4195. .map-box {
  4196. position: relative;
  4197. flex: 1 1 auto;
  4198. min-height: 0;
  4199. margin: 0;
  4200. border-top: 0;
  4201. border-radius: 0;
  4202. overflow: hidden;
  4203. background: transparent;
  4204. }
  4205. .map-box__canvas {
  4206. width: 100%;
  4207. height: 100%;
  4208. min-height: 0;
  4209. }
  4210. .map-hover-card {
  4211. position: absolute;
  4212. z-index: 4;
  4213. min-width: 268px;
  4214. max-width: 360px;
  4215. padding: 14px 16px 12px;
  4216. border-radius: 14px;
  4217. background: rgba(255, 255, 255, 0.98);
  4218. box-shadow: 0 16px 34px rgba(15, 23, 42, 0.18);
  4219. transform: translate(-50%, calc(-100% - 22px));
  4220. user-select: text;
  4221. }
  4222. .map-hover-card::after {
  4223. content: '';
  4224. position: absolute;
  4225. left: 50%;
  4226. bottom: -7px;
  4227. width: 16px;
  4228. height: 16px;
  4229. background: rgba(255, 255, 255, 0.98);
  4230. transform: translateX(-50%) rotate(45deg);
  4231. box-shadow: 6px 6px 16px rgba(15, 23, 42, 0.06);
  4232. }
  4233. .map-hover-card__title,
  4234. .map-hover-card__row,
  4235. .map-hover-card__actions {
  4236. position: relative;
  4237. z-index: 1;
  4238. }
  4239. .map-hover-card__title {
  4240. color: #111827;
  4241. font-size: 16px;
  4242. font-weight: 700;
  4243. line-height: 24px;
  4244. }
  4245. .map-hover-card__row {
  4246. margin-top: 8px;
  4247. display: flex;
  4248. gap: 8px;
  4249. align-items: flex-start;
  4250. }
  4251. .map-hover-card__label {
  4252. flex: 0 0 52px;
  4253. color: #94a3b8;
  4254. font-size: 12px;
  4255. line-height: 20px;
  4256. }
  4257. .map-hover-card__value {
  4258. flex: 1 1 auto;
  4259. color: #1f2937;
  4260. font-size: 13px;
  4261. line-height: 20px;
  4262. word-break: break-all;
  4263. }
  4264. .map-hover-card__actions {
  4265. margin-top: 12px;
  4266. display: flex;
  4267. gap: 10px;
  4268. }
  4269. .map-hover-card__action {
  4270. padding: 0 12px;
  4271. height: 30px;
  4272. border: 1px solid #c7d2fe;
  4273. border-radius: 8px;
  4274. background: #eff6ff;
  4275. color: #2563eb;
  4276. font-size: 12px;
  4277. line-height: 28px;
  4278. cursor: pointer;
  4279. transition:
  4280. border-color 0.2s ease,
  4281. background 0.2s ease,
  4282. color 0.2s ease;
  4283. }
  4284. .map-hover-card__action:hover {
  4285. border-color: #93c5fd;
  4286. background: #dbeafe;
  4287. color: #1d4ed8;
  4288. }
  4289. .map-box__toolbar,
  4290. .map-box__legend {
  4291. position: absolute;
  4292. left: 12px;
  4293. z-index: 3;
  4294. display: flex;
  4295. flex-wrap: wrap;
  4296. gap: 8px;
  4297. }
  4298. .map-box__toolbar {
  4299. top: 12px;
  4300. right: 72px;
  4301. }
  4302. .map-box__legend {
  4303. bottom: 12px;
  4304. max-width: calc(100% - 24px);
  4305. padding: 10px 12px;
  4306. border-radius: 12px;
  4307. background: rgba(255, 255, 255, 0.94);
  4308. box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08);
  4309. pointer-events: none;
  4310. }
  4311. .toolbar-pill,
  4312. .legend-item {
  4313. display: inline-flex;
  4314. align-items: center;
  4315. gap: 6px;
  4316. font-size: 12px;
  4317. line-height: 18px;
  4318. }
  4319. .toolbar-pill {
  4320. padding: 6px 10px;
  4321. border-radius: 999px;
  4322. background: rgba(255, 255, 255, 0.92);
  4323. color: #334155;
  4324. box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08);
  4325. }
  4326. .toolbar-pill.is-fallback {
  4327. color: #92400e;
  4328. }
  4329. .toolbar-pill.is-pressure {
  4330. color: #155e75;
  4331. background: rgba(236, 254, 255, 0.94);
  4332. }
  4333. .legend-item {
  4334. color: #475569;
  4335. white-space: nowrap;
  4336. }
  4337. .legend-item i {
  4338. width: 10px;
  4339. height: 10px;
  4340. border-radius: 50%;
  4341. }
  4342. .map-box__empty {
  4343. position: absolute;
  4344. inset: 0;
  4345. z-index: 2;
  4346. display: flex;
  4347. align-items: center;
  4348. justify-content: center;
  4349. background: rgba(248, 250, 252, 0.34);
  4350. color: #64748b;
  4351. font-size: 13px;
  4352. pointer-events: none;
  4353. }
  4354. :deep(.maplibregl-ctrl-top-right) {
  4355. top: 12px;
  4356. right: 12px;
  4357. }
  4358. :deep(.maplibregl-ctrl-group) {
  4359. border-radius: 12px;
  4360. overflow: hidden;
  4361. box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08);
  4362. }
  4363. @media screen and (max-width: 1480px) {
  4364. .content-panel {
  4365. grid-template-columns: minmax(420px, 6fr) minmax(320px, 4fr);
  4366. gap: 2px;
  4367. }
  4368. .panel-header {
  4369. padding: 0 0 8px;
  4370. }
  4371. .timeline-list {
  4372. padding: 0;
  4373. }
  4374. .timeline-item {
  4375. gap: 10px;
  4376. }
  4377. }
  4378. @media screen and (max-width: 1220px) {
  4379. .content-panel {
  4380. grid-template-columns: minmax(340px, 6fr) minmax(280px, 4fr);
  4381. gap: 2px;
  4382. }
  4383. .panel-header {
  4384. flex-wrap: wrap;
  4385. align-items: flex-start;
  4386. }
  4387. .map-actions {
  4388. justify-content: flex-end;
  4389. }
  4390. .timeline-item {
  4391. grid-template-columns: 108px 22px minmax(0, 1fr);
  4392. gap: 8px;
  4393. }
  4394. .timeline-item__time {
  4395. font-size: 12px;
  4396. }
  4397. .timeline-item__body {
  4398. padding: 10px 0 10px 12px;
  4399. }
  4400. .timeline-item__top {
  4401. flex-wrap: wrap;
  4402. }
  4403. .timeline-item__badges {
  4404. width: 100%;
  4405. justify-content: flex-start;
  4406. }
  4407. .timeline-item__tag {
  4408. min-width: 0;
  4409. }
  4410. .map-box__toolbar {
  4411. right: 12px;
  4412. }
  4413. }
  4414. @media screen and (max-width: 920px) {
  4415. .content-panel {
  4416. grid-template-columns: 1fr;
  4417. overflow: auto;
  4418. }
  4419. .panel-left,
  4420. .panel-right {
  4421. min-height: 360px;
  4422. }
  4423. }
  4424. @media screen and (max-width: 920px) {
  4425. .target-strip,
  4426. .filter-item,
  4427. .filter-input,
  4428. .filter-type,
  4429. .filter-date--compact {
  4430. width: 100%;
  4431. }
  4432. .selected-targets {
  4433. width: 100%;
  4434. }
  4435. .target-strip__label {
  4436. width: 100%;
  4437. }
  4438. .add-target-btn.ant-btn {
  4439. width: 100%;
  4440. }
  4441. .filter-actions {
  4442. width: 100%;
  4443. margin-left: 0;
  4444. justify-content: flex-start;
  4445. }
  4446. .timeline-item {
  4447. grid-template-columns: 124px 26px minmax(0, 1fr);
  4448. gap: 10px;
  4449. }
  4450. .timeline-item__time {
  4451. font-size: 12px;
  4452. }
  4453. .panel-header,
  4454. .map-actions {
  4455. align-items: flex-start;
  4456. }
  4457. }
  4458. }
  4459. .behavior-target-picker-modal {
  4460. .ant-modal {
  4461. top: 16px;
  4462. padding-bottom: 0;
  4463. max-width: calc(100vw - 48px);
  4464. }
  4465. .ant-modal-content {
  4466. border-radius: 8px;
  4467. overflow: hidden;
  4468. background: #ffffff;
  4469. box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
  4470. }
  4471. .ant-modal-header {
  4472. padding: 12px 16px;
  4473. border-bottom: 1px solid #e5e7eb;
  4474. background: @modal-header-bg-color;
  4475. }
  4476. .ant-modal-title {
  4477. color: #111827;
  4478. font-size: 16px;
  4479. font-weight: 600;
  4480. line-height: 24px;
  4481. }
  4482. .ant-modal-body {
  4483. padding: 16px;
  4484. }
  4485. .ant-modal-footer {
  4486. padding: 8px 16px 10px;
  4487. border-top: 1px solid #e5e7eb;
  4488. background: #fff;
  4489. }
  4490. .ant-modal-close {
  4491. top: 16px;
  4492. right: 16px;
  4493. width: 30px;
  4494. height: 30px;
  4495. border-radius: 8px;
  4496. color: #8b95a7;
  4497. transition:
  4498. color 0.2s ease,
  4499. background-color 0.2s ease;
  4500. }
  4501. .ant-modal-close:hover {
  4502. color: #0f172a;
  4503. background: rgba(37, 99, 235, 0.08);
  4504. }
  4505. .target-picker {
  4506. width: 100%;
  4507. display: flex;
  4508. flex-direction: column;
  4509. gap: 14px;
  4510. }
  4511. .target-picker-modal__title {
  4512. display: inline-flex;
  4513. align-items: center;
  4514. color: #111827;
  4515. font-size: 16px;
  4516. font-weight: 600;
  4517. line-height: 24px;
  4518. }
  4519. .target-picker__panel-head {
  4520. display: flex;
  4521. align-items: center;
  4522. justify-content: space-between;
  4523. gap: 12px;
  4524. padding: 12px 14px;
  4525. border-radius: 0;
  4526. background: #f4f7fb;
  4527. }
  4528. .target-picker__panel-title {
  4529. color: #111827;
  4530. font-size: 16px;
  4531. font-weight: 700;
  4532. line-height: 24px;
  4533. }
  4534. .target-picker__panel-count {
  4535. color: #16a34a;
  4536. font-size: 14px;
  4537. font-weight: 600;
  4538. white-space: nowrap;
  4539. }
  4540. .target-picker__toolbar {
  4541. display: flex;
  4542. flex-direction: column;
  4543. gap: 8px;
  4544. padding: 0;
  4545. border: none;
  4546. border-radius: 0;
  4547. background: #ffffff;
  4548. }
  4549. .target-picker__list-head {
  4550. display: flex;
  4551. align-items: center;
  4552. padding: 0 2px;
  4553. color: #374151;
  4554. font-size: 14px;
  4555. font-weight: 500;
  4556. line-height: 20px;
  4557. }
  4558. .target-picker__list-head :deep(.ant-checkbox-wrapper) {
  4559. color: inherit;
  4560. font-size: inherit;
  4561. line-height: inherit;
  4562. }
  4563. .target-picker__list-head :deep(.ant-checkbox + span) {
  4564. padding-inline-start: 6px;
  4565. }
  4566. .target-picker__search-input.ant-input-affix-wrapper {
  4567. width: 100%;
  4568. min-height: 34px;
  4569. padding: 0 12px;
  4570. border: 1px solid #d8e0eb;
  4571. border-radius: 8px;
  4572. box-shadow: none;
  4573. background: #fff;
  4574. transition: border-color 0.2s ease;
  4575. font-size: 13px;
  4576. }
  4577. .target-picker__search-input.ant-input-affix-wrapper:hover,
  4578. .target-picker__search-input.ant-input-affix-wrapper:focus,
  4579. .target-picker__search-input.ant-input-affix-wrapper-focused {
  4580. border-color: #b2c3df;
  4581. box-shadow: none;
  4582. }
  4583. .target-picker__table {
  4584. display: flex;
  4585. flex-direction: column;
  4586. width: 100%;
  4587. padding: 0;
  4588. overflow: hidden;
  4589. border: none;
  4590. border-radius: 0;
  4591. background: #ffffff;
  4592. }
  4593. .target-picker__list {
  4594. width: 100%;
  4595. max-height: 340px;
  4596. overflow: auto;
  4597. display: flex;
  4598. flex-direction: column;
  4599. gap: 0;
  4600. padding-right: 2px;
  4601. border-top: 1px solid #eef2f7;
  4602. scrollbar-color: #8c8c8c transparent;
  4603. scrollbar-width: thin;
  4604. }
  4605. .target-picker__list::-webkit-scrollbar {
  4606. width: 10px;
  4607. }
  4608. .target-picker__list::-webkit-scrollbar-track {
  4609. background: transparent;
  4610. }
  4611. .target-picker__list::-webkit-scrollbar-thumb {
  4612. border-radius: 999px;
  4613. background: #8c8c8c;
  4614. border: 2px solid transparent;
  4615. background-clip: content-box;
  4616. }
  4617. .target-picker__item {
  4618. display: flex;
  4619. align-items: center;
  4620. gap: 10px;
  4621. width: 100%;
  4622. min-height: 54px;
  4623. padding: 11px 14px;
  4624. border: none;
  4625. border-bottom: 1px solid #edf1f6;
  4626. border-radius: 0;
  4627. background: #ffffff;
  4628. color: #374151;
  4629. font-size: 13px;
  4630. line-height: 18px;
  4631. box-sizing: border-box;
  4632. cursor: pointer;
  4633. transition:
  4634. background-color 0.2s ease,
  4635. box-shadow 0.2s ease;
  4636. }
  4637. .target-picker__item:last-child {
  4638. border-bottom: none;
  4639. }
  4640. .target-picker__item:hover {
  4641. background: #fbfdff;
  4642. }
  4643. .target-picker__item.is-active {
  4644. background: #f4f8ff;
  4645. }
  4646. .target-picker__checkbox {
  4647. flex: 0 0 auto;
  4648. line-height: 1;
  4649. }
  4650. .target-picker__checkbox .ant-checkbox {
  4651. margin: 0;
  4652. }
  4653. .target-picker__avatar {
  4654. flex: 0 0 32px;
  4655. display: inline-flex;
  4656. }
  4657. .target-picker__item-body {
  4658. flex: 1 1 auto;
  4659. min-width: 0;
  4660. align-items: center;
  4661. display: flex;
  4662. }
  4663. .target-picker__name-wrap {
  4664. flex: 1 1 auto;
  4665. min-width: 0;
  4666. display: flex;
  4667. align-items: baseline;
  4668. gap: 10px;
  4669. overflow: hidden;
  4670. }
  4671. .target-picker__name {
  4672. flex: 0 0 auto;
  4673. color: #111827;
  4674. font-size: 14px;
  4675. font-weight: 600;
  4676. line-height: 20px;
  4677. white-space: nowrap;
  4678. }
  4679. .target-picker__inline-code {
  4680. min-width: 0;
  4681. color: #9aa8bf;
  4682. font-size: 12px;
  4683. line-height: 20px;
  4684. white-space: nowrap;
  4685. overflow: hidden;
  4686. text-overflow: ellipsis;
  4687. }
  4688. .target-picker__empty {
  4689. padding: 32px 0;
  4690. border: 1px dashed #dbe4ef;
  4691. border-radius: 8px;
  4692. background: #ffffff;
  4693. color: #9ca3af;
  4694. font-size: 12px;
  4695. line-height: 20px;
  4696. text-align: center;
  4697. }
  4698. }
  4699. .behavior-detail-drawer {
  4700. .ant-drawer-header {
  4701. padding: 14px 16px 12px;
  4702. border-bottom: 1px solid #e5e7eb;
  4703. background: #ffffff;
  4704. }
  4705. .ant-drawer-body {
  4706. padding: 0;
  4707. background: #f8fafc;
  4708. }
  4709. .detail-drawer__title-wrap {
  4710. display: flex;
  4711. align-items: flex-start;
  4712. justify-content: flex-start;
  4713. gap: 10px;
  4714. }
  4715. .detail-drawer__title-block {
  4716. min-width: 0;
  4717. }
  4718. .detail-drawer__title {
  4719. color: #111827;
  4720. font-size: 15px;
  4721. font-weight: 600;
  4722. line-height: 22px;
  4723. }
  4724. .detail-drawer__sub {
  4725. margin-top: 2px;
  4726. color: #64748b;
  4727. font-size: 12px;
  4728. line-height: 18px;
  4729. }
  4730. .detail-drawer__body {
  4731. padding: 16px;
  4732. display: flex;
  4733. flex-direction: column;
  4734. gap: 14px;
  4735. }
  4736. .detail-drawer__loading,
  4737. .detail-drawer__empty {
  4738. padding: 24px 16px;
  4739. border: 1px dashed #dbe4ef;
  4740. border-radius: 12px;
  4741. background: #fff;
  4742. color: #64748b;
  4743. font-size: 13px;
  4744. line-height: 22px;
  4745. text-align: center;
  4746. }
  4747. .detail-drawer__flat {
  4748. display: flex;
  4749. flex-direction: column;
  4750. gap: 0;
  4751. }
  4752. .detail-drawer__flat-item {
  4753. display: flex;
  4754. align-items: flex-start;
  4755. gap: 8px;
  4756. padding: 10px 0;
  4757. border-bottom: 1px solid #eef2f7;
  4758. }
  4759. .detail-drawer__flat-label {
  4760. flex: 0 0 112px;
  4761. color: #6b7280;
  4762. font-size: 13px;
  4763. line-height: 22px;
  4764. text-align: right;
  4765. white-space: nowrap;
  4766. }
  4767. .detail-drawer__flat-value {
  4768. flex: 1 1 auto;
  4769. min-width: 0;
  4770. color: #111827;
  4771. font-size: 13px;
  4772. line-height: 22px;
  4773. white-space: pre-wrap;
  4774. word-break: break-word;
  4775. }
  4776. }
  4777. @media screen and (max-width: 920px) {
  4778. .behavior-detail-drawer {
  4779. .detail-drawer__flat-item {
  4780. flex-direction: column;
  4781. gap: 4px;
  4782. }
  4783. .detail-drawer__flat-label {
  4784. flex: none;
  4785. text-align: left;
  4786. }
  4787. }
  4788. .behavior-target-picker-modal {
  4789. .target-picker__panel-head,
  4790. .target-picker__toolbar {
  4791. padding: 12px;
  4792. }
  4793. .target-picker__panel-head {
  4794. flex-direction: column;
  4795. align-items: flex-start;
  4796. }
  4797. .target-picker__search-input.ant-input-affix-wrapper {
  4798. width: 100%;
  4799. }
  4800. .target-picker__item {
  4801. min-height: 52px;
  4802. padding: 10px 12px;
  4803. }
  4804. .target-picker__name-wrap {
  4805. gap: 6px;
  4806. flex-wrap: wrap;
  4807. align-items: flex-start;
  4808. }
  4809. }
  4810. }
  4811. @import '../styles/trans-analysis-table-ui.less';
  4812. </style>