portrait.vue 261 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205720672077208720972107211721272137214721572167217721872197220722172227223722472257226722772287229723072317232723372347235723672377238723972407241724272437244724572467247724872497250725172527253725472557256725772587259726072617262726372647265726672677268726972707271727272737274727572767277727872797280728172827283728472857286728772887289729072917292729372947295729672977298729973007301730273037304730573067307730873097310731173127313731473157316731773187319732073217322732373247325732673277328732973307331733273337334733573367337733873397340734173427343734473457346734773487349735073517352735373547355735673577358735973607361736273637364736573667367736873697370737173727373737473757376737773787379738073817382738373847385738673877388738973907391739273937394739573967397739873997400740174027403740474057406740774087409741074117412741374147415741674177418741974207421742274237424742574267427742874297430743174327433743474357436743774387439744074417442744374447445744674477448744974507451745274537454745574567457745874597460746174627463746474657466746774687469747074717472747374747475747674777478747974807481748274837484748574867487748874897490749174927493749474957496749774987499750075017502750375047505750675077508750975107511751275137514751575167517751875197520752175227523752475257526752775287529753075317532753375347535753675377538753975407541754275437544754575467547754875497550755175527553755475557556755775587559756075617562756375647565756675677568756975707571757275737574757575767577757875797580758175827583758475857586758775887589759075917592759375947595759675977598759976007601760276037604760576067607760876097610761176127613761476157616761776187619762076217622762376247625762676277628762976307631763276337634763576367637763876397640764176427643764476457646764776487649765076517652765376547655765676577658765976607661766276637664766576667667766876697670767176727673767476757676767776787679768076817682768376847685768676877688768976907691769276937694769576967697769876997700770177027703770477057706770777087709771077117712771377147715771677177718771977207721772277237724772577267727772877297730773177327733773477357736773777387739774077417742774377447745774677477748774977507751775277537754775577567757775877597760776177627763776477657766776777687769777077717772777377747775777677777778777977807781778277837784778577867787778877897790779177927793779477957796779777987799780078017802780378047805780678077808780978107811781278137814781578167817781878197820782178227823782478257826782778287829783078317832783378347835783678377838783978407841784278437844784578467847784878497850785178527853785478557856785778587859786078617862786378647865786678677868786978707871787278737874787578767877787878797880788178827883788478857886788778887889789078917892789378947895789678977898789979007901790279037904790579067907790879097910791179127913791479157916791779187919792079217922792379247925792679277928792979307931793279337934793579367937793879397940794179427943794479457946794779487949795079517952795379547955795679577958795979607961796279637964796579667967796879697970797179727973797479757976797779787979798079817982798379847985798679877988798979907991799279937994799579967997799879998000800180028003800480058006800780088009801080118012801380148015801680178018801980208021802280238024802580268027802880298030803180328033803480358036803780388039804080418042804380448045804680478048804980508051805280538054805580568057805880598060806180628063806480658066806780688069807080718072807380748075807680778078807980808081808280838084808580868087808880898090809180928093809480958096809780988099810081018102810381048105810681078108810981108111811281138114811581168117811881198120812181228123812481258126812781288129813081318132813381348135813681378138813981408141814281438144814581468147814881498150815181528153815481558156815781588159816081618162816381648165816681678168816981708171817281738174817581768177817881798180818181828183818481858186818781888189819081918192819381948195819681978198819982008201820282038204820582068207820882098210821182128213821482158216821782188219822082218222822382248225822682278228822982308231823282338234823582368237823882398240824182428243824482458246824782488249825082518252825382548255825682578258825982608261826282638264826582668267826882698270827182728273827482758276827782788279828082818282828382848285828682878288828982908291829282938294829582968297829882998300830183028303830483058306830783088309831083118312831383148315831683178318831983208321832283238324832583268327832883298330833183328333833483358336833783388339834083418342834383448345834683478348834983508351835283538354835583568357835883598360836183628363836483658366836783688369837083718372837383748375837683778378837983808381838283838384838583868387838883898390839183928393839483958396839783988399840084018402840384048405840684078408840984108411841284138414841584168417841884198420842184228423842484258426842784288429843084318432843384348435843684378438843984408441844284438444844584468447844884498450845184528453845484558456845784588459846084618462846384648465846684678468846984708471847284738474847584768477847884798480848184828483848484858486848784888489849084918492849384948495849684978498849985008501850285038504850585068507850885098510851185128513851485158516851785188519852085218522852385248525852685278528852985308531853285338534853585368537853885398540854185428543854485458546854785488549855085518552855385548555855685578558855985608561856285638564856585668567856885698570857185728573857485758576857785788579858085818582858385848585858685878588858985908591859285938594859585968597859885998600860186028603860486058606860786088609861086118612861386148615861686178618861986208621862286238624862586268627862886298630863186328633863486358636863786388639864086418642864386448645864686478648864986508651865286538654865586568657865886598660866186628663866486658666866786688669867086718672867386748675867686778678867986808681868286838684868586868687868886898690869186928693869486958696869786988699870087018702870387048705870687078708870987108711871287138714871587168717871887198720872187228723872487258726872787288729873087318732873387348735873687378738873987408741874287438744874587468747874887498750875187528753875487558756875787588759876087618762876387648765876687678768876987708771877287738774877587768777877887798780878187828783878487858786878787888789879087918792879387948795879687978798879988008801880288038804880588068807880888098810881188128813881488158816881788188819882088218822882388248825882688278828882988308831883288338834883588368837883888398840884188428843884488458846884788488849885088518852885388548855885688578858885988608861886288638864886588668867886888698870887188728873887488758876887788788879888088818882888388848885888688878888888988908891889288938894889588968897889888998900890189028903890489058906890789088909891089118912891389148915891689178918891989208921892289238924892589268927892889298930893189328933893489358936893789388939
  1. <template>
  2. <div :class="prefixCls" class="case-portrait-page">
  3. <div class="page-main">
  4. <div class="page-layout">
  5. <aside class="left-panel">
  6. <div class="panel-card">
  7. <div class="panel-heading">
  8. <div class="panel-heading__title">对象列表</div>
  9. <div class="panel-heading__badge">{{ filteredPersonList.length }}</div>
  10. </div>
  11. <div class="search-box">
  12. <Input v-model:value="personKeyword" placeholder="对象名称 / 证件号搜索" allow-clear />
  13. </div>
  14. <div class="left-actions">
  15. <Button type="primary" block class="side-action-btn side-action-btn--primary" @click="openCreateModal"
  16. >新增对象</Button
  17. >
  18. </div>
  19. <div class="person-list">
  20. <template v-if="filteredPersonList.length">
  21. <div
  22. v-for="item in filteredPersonList"
  23. :key="item.person.personCertNo || item.person.personName || item.index"
  24. class="person-card"
  25. :class="{ active: selectedPersonId === item.index }"
  26. :title="toPersonName(item.person)"
  27. @click="selectPerson(item.index)"
  28. >
  29. <Popover placement="rightTop" trigger="hover" overlayClassName="person-card-popover">
  30. <template #content>
  31. <div class="avatar-pop-content">
  32. <div class="edit-item" @click.stop="openEditModal(item.person)">编辑</div>
  33. <div class="edit-item delete" @click.stop="handleDelete(item.person)">删除</div>
  34. </div>
  35. </template>
  36. <div class="person-trigger">
  37. <div class="person-avatar-wrap">
  38. <PersonAvatar
  39. class="person-avatar"
  40. :src="getPersonPhoto(item.person)"
  41. :alt="toPersonName(item.person)"
  42. :size="40"
  43. />
  44. </div>
  45. <div class="person-name">{{ toPersonName(item.person) }}</div>
  46. </div>
  47. </Popover>
  48. </div>
  49. </template>
  50. <div v-else class="empty-tip">
  51. <EmptyState
  52. :title="personList.length ? '未找到匹配对象' : '暂无对象'"
  53. :description="personList.length ? '试试更换搜索关键词' : '可点击上方“新增对象”创建画像主体'"
  54. variant="compact"
  55. :min-height="180"
  56. />
  57. </div>
  58. </div>
  59. <div class="group-btn-wrap">
  60. <Button class="side-action-btn side-action-btn--secondary group-btn" @click="openGroupModal"
  61. >对象分组</Button
  62. >
  63. </div>
  64. </div>
  65. </aside>
  66. <section class="right-panel" ref="rightPanelRef">
  67. <div id="section-basic" class="section-card">
  68. <div class="section-header">
  69. <div class="section-title">基本信息</div>
  70. <Button type="link" class="section-action" @click="handleEditBasicInfo">编辑</Button>
  71. </div>
  72. <div class="basic-content">
  73. <div class="basic-profile">
  74. <PersonAvatar class="profile-avatar" :src="basicPhoto" :alt="basicName" :size="56" />
  75. <div class="profile-name">{{ basicName }}</div>
  76. </div>
  77. <div class="info-grid">
  78. <div v-for="item in basicInfo" :key="item.label" class="info-item">
  79. <span class="info-label">{{ item.label }}:</span>
  80. <span class="info-value">{{ item.value }}</span>
  81. </div>
  82. </div>
  83. </div>
  84. </div>
  85. <IntimacyPanel
  86. id="section-relation"
  87. class="section-card"
  88. :selected-person="selectedPerson"
  89. @jump-to-analysis="handleJumpToAnalysis"
  90. />
  91. <div id="section-comm" class="section-card comm-section">
  92. <div class="section-header">
  93. <div class="section-title">通讯信息</div>
  94. <div class="section-tools">
  95. <Button type="link" class="section-action" @click="openCallSummaryModal">通信汇总</Button>
  96. </div>
  97. </div>
  98. <div class="comm-info">
  99. <div class="comm-row">
  100. <span class="label">电话:</span>
  101. <span class="value">{{ commSummary.phoneSummary }}</span>
  102. <button
  103. v-for="carrier in carrierTags"
  104. :key="carrier.carrierName"
  105. type="button"
  106. class="carrier carrier--link"
  107. @click="openCallOperPhoneModal(carrier)"
  108. >
  109. {{ carrier.label }}
  110. </button>
  111. <span v-if="!carrierTags.length" class="carrier">-</span>
  112. </div>
  113. <div class="comm-summary-card">
  114. <div class="comm-grid">
  115. <div class="grid-item"
  116. ><span class="label">通讯总次数:</span><span class="value">{{ commSummary.totalCount }}</span></div
  117. >
  118. <div class="grid-item"
  119. ><span class="label">对方号码汇总:</span
  120. ><span class="value">{{ commSummary.contactCount }}</span></div
  121. >
  122. <div class="grid-item"
  123. ><span class="label">通讯记录时间段:</span
  124. ><span class="value">{{ commSummary.timeRange }}</span></div
  125. >
  126. </div>
  127. </div>
  128. </div>
  129. <div class="comm-divider"></div>
  130. <div class="comm-chart-row">
  131. <div class="comm-chart">
  132. <div class="comm-chart-header">
  133. <div class="chart-title"><span class="flag"></span>通信类型占比:</div>
  134. </div>
  135. <div
  136. v-if="hasRenderableCommTypeRatioData"
  137. class="comm-chart-box visual-chart-surface visual-chart-surface--comm visual-chart-surface--ratio"
  138. >
  139. <div ref="commTypeChartRef" class="comm-echart"></div>
  140. </div>
  141. <div v-else class="chart-empty">
  142. <EmptyState description="通信占比数据接入后将在此展示" />
  143. </div>
  144. </div>
  145. <div class="comm-chart">
  146. <div class="comm-top-header">
  147. <div class="chart-title"><span class="flag"></span>常联系对象TOP10:</div>
  148. <div class="comm-top-tools">
  149. <span class="tool-label">联系日期:</span>
  150. <DatePicker.RangePicker
  151. v-model:value="commTopQuery.dateRange"
  152. class="date-input range-date-input comm-top-range"
  153. value-format="YYYY-MM-DD"
  154. :placeholder="['开始日期', '结束日期']"
  155. />
  156. <Button type="link" size="small" class="chart-more-link" @click="openCallSummaryModal">
  157. 查看更多
  158. </Button>
  159. </div>
  160. </div>
  161. <div
  162. v-if="hasRenderableTopContacts"
  163. class="comm-top-chart-box visual-chart-surface visual-chart-surface--comm visual-chart-surface--bar"
  164. >
  165. <div ref="commTopChartRef" class="comm-top-echart"></div>
  166. </div>
  167. <div v-else class="chart-empty">
  168. <EmptyState description="筛选联系人后可查看 TOP10 统计结果" />
  169. </div>
  170. </div>
  171. </div>
  172. <div class="comm-divider"></div>
  173. <div class="station-row">
  174. <div class="chart-title"><span class="flag"></span>常通话基站:</div>
  175. <Button type="link" size="small" color="primary" :skill="true" @click="viewStationMap"> 查看地图 </Button>
  176. </div>
  177. <div v-if="stations.length" class="station-list">
  178. <div class="station-item" v-for="station in stations" :key="station">{{ station }}</div>
  179. </div>
  180. <div v-else class="chart-empty">
  181. <EmptyState description="通话基站信息接入后将在此展示" />
  182. </div>
  183. </div>
  184. <div id="section-trade" class="section-card trade-section">
  185. <div class="section-header">
  186. <div class="section-title">交易信息</div>
  187. <div class="section-tools">
  188. <Button type="link" class="section-action" @click="openTransSummaryModal">交易汇总</Button>
  189. </div>
  190. </div>
  191. <div v-if="hasTradeSummary" class="trade-summary">
  192. <div class="trade-row">
  193. <span class="label">银行卡:</span>
  194. <span class="value">{{ tradeSummary.cardSummary }}</span>
  195. <button
  196. v-for="tag in tradeBankTags"
  197. :key="tag.bankName"
  198. type="button"
  199. class="trade-tag trade-tag--link"
  200. @click="openTransBankCardModal(tag)"
  201. >
  202. {{ tag.label }}
  203. </button>
  204. </div>
  205. </div>
  206. <div v-if="hasTradeSummary" class="comm-divider"></div>
  207. <div class="trade-chart-row">
  208. <div class="trade-chart">
  209. <div class="trade-chart-header">
  210. <div class="chart-title"><span class="flag"></span>借贷类型占比:</div>
  211. </div>
  212. <template v-if="hasTradeRatioData">
  213. <div
  214. class="trade-ratio-panel visual-chart-surface visual-chart-surface--trade visual-chart-surface--ratio"
  215. >
  216. <div class="donut-wrap">
  217. <div ref="tradeRatioChartRef" class="trade-ratio-echart"></div>
  218. <div class="donut-legend">
  219. <div class="legend-click" @click.stop="handleDrillByDebitCredit('收入')">
  220. <div class="legend-main">
  221. <span class="dot success"></span>
  222. <span class="legend-title">总收入:</span>
  223. <span class="legend-rate">{{ tradeSummary.incomeRate }}</span>
  224. </div>
  225. <div class="legend-amount">{{ tradeSummary.incomeAmount }}</div>
  226. </div>
  227. <div class="legend-click" @click.stop="handleDrillByDebitCredit('支出')">
  228. <div class="legend-main">
  229. <span class="dot warning"></span>
  230. <span class="legend-title">总支出:</span>
  231. <span class="legend-rate">{{ tradeSummary.expendRate }}</span>
  232. </div>
  233. <div class="legend-amount">{{ tradeSummary.expendAmount }}</div>
  234. </div>
  235. </div>
  236. </div>
  237. <div class="trade-kpi">
  238. <div class="trade-kpi-item">
  239. <span class="trade-kpi-label">单笔最高支出:</span>
  240. <span class="trade-kpi-value down">{{ tradeSummary.maxExpend }}</span>
  241. </div>
  242. <div class="trade-kpi-item">
  243. <span class="trade-kpi-label">单笔最高收入:</span>
  244. <span class="trade-kpi-value up">{{ tradeSummary.maxIncome }}</span>
  245. </div>
  246. </div>
  247. </div>
  248. </template>
  249. <div v-else class="chart-empty">
  250. <EmptyState description="借贷类型汇总完成后将在此展示" />
  251. </div>
  252. </div>
  253. <div class="trade-chart">
  254. <div class="trade-top-header">
  255. <div class="chart-title"><span class="flag"></span>常交易对象TOP10:</div>
  256. <div class="trade-top-tools">
  257. <span class="tool-label">统计指标:</span>
  258. <Select
  259. v-model:value="tradeTopQuery.indicator"
  260. class="select-input metric-select"
  261. :options="tradeMetricOptions"
  262. />
  263. <span class="tool-label">交易日期:</span>
  264. <DatePicker.RangePicker
  265. v-model:value="tradeTopQuery.dateRange"
  266. class="date-input range-date-input trade-top-range"
  267. value-format="YYYY-MM-DD"
  268. :placeholder="['开始日期', '结束日期']"
  269. />
  270. <span class="tool-label">维度:</span>
  271. <Radio.Group v-model:value="tradeTopQuery.dimension" class="trade-top-dimension">
  272. <Radio value="交易次数">交易次数</Radio>
  273. <Radio value="交易金额">交易金额</Radio>
  274. </Radio.Group>
  275. <Button type="link" size="small" class="chart-more-link" @click="openTransSummaryModal">
  276. 查看更多
  277. </Button>
  278. </div>
  279. </div>
  280. <div
  281. v-if="tradeTop.length"
  282. class="trade-top-chart-box visual-chart-surface visual-chart-surface--trade visual-chart-surface--bar"
  283. >
  284. <div ref="tradeTopChartRef" class="trade-top-echart"></div>
  285. </div>
  286. <div v-else class="chart-empty">
  287. <EmptyState description="调整日期或统计指标后可查看 TOP10 结果" />
  288. </div>
  289. </div>
  290. </div>
  291. <div class="comm-divider"></div>
  292. <div class="trade-trend trade-trend-panel">
  293. <div class="trade-trend-header">
  294. <div class="trade-trend-heading">
  295. <div class="chart-title"><span class="flag"></span>交易趋势:</div>
  296. <div class="trade-trend-subtitle"
  297. >按时间维度观察收入与支出走势,可结合对方名称与日期范围进行筛选。</div
  298. >
  299. </div>
  300. </div>
  301. <div class="trend-tools trade-trend-toolbar">
  302. <div class="trade-trend-filters">
  303. <span class="tool-label">对方名称:</span>
  304. <Select
  305. v-model:value="tradeTrendQuery.otherName"
  306. class="trend-search-input"
  307. placeholder="请输入或选择对方名称"
  308. allow-clear
  309. show-search
  310. :filter-option="false"
  311. :options="tradeTrendOtherNameOptions"
  312. :loading="tradeTrendOtherNameLoading"
  313. :not-found-content="tradeTrendOtherNameLoading ? '加载中...' : '暂无数据'"
  314. @search="handleTradeTrendOtherNameSearch"
  315. @popupScroll="handleTradeTrendOtherNamePopupScroll"
  316. @dropdownVisibleChange="handleTradeTrendOtherNameDropdownVisibleChange"
  317. />
  318. <span class="tool-label">交易日期:</span>
  319. <DatePicker.RangePicker
  320. v-model:value="tradeTrendQuery.dateRange"
  321. class="date-input range-date-input trend-range-input"
  322. value-format="YYYY-MM-DD"
  323. :placeholder="['开始日期', '结束日期']"
  324. />
  325. <span class="tool-label">时间维度:</span>
  326. <div class="trade-trend-dimension">
  327. <span
  328. class="radio"
  329. :class="{ active: tradeTrendQuery.timeDimension === 'MONTH' }"
  330. @click="changeTradeTrendDimension('MONTH')"
  331. >月</span
  332. >
  333. <span
  334. class="radio"
  335. :class="{ active: tradeTrendQuery.timeDimension === 'YEAR' }"
  336. @click="changeTradeTrendDimension('YEAR')"
  337. >年</span
  338. >
  339. </div>
  340. </div>
  341. </div>
  342. <div
  343. v-if="trendPoints.length && trendLabels.length"
  344. class="trade-trend-chart-box visual-chart-surface visual-chart-surface--trade visual-chart-surface--trend"
  345. >
  346. <div ref="tradeTrendScrollRef" class="trend-chart-scroll">
  347. <div class="trend-chart" :style="trendChartStyle">
  348. <div ref="tradeTrendChartRef" class="trend-echart"></div>
  349. </div>
  350. </div>
  351. </div>
  352. <div v-else class="chart-empty trade-trend-empty">
  353. <EmptyState description="设置查询条件后可查看收入与支出趋势" />
  354. </div>
  355. </div>
  356. </div>
  357. </section>
  358. </div>
  359. <div class="quick-nav">
  360. <div
  361. v-for="nav in navItems"
  362. :key="nav.id"
  363. class="nav-item"
  364. :class="{ active: activeNavId === nav.id }"
  365. @click="scrollToSection(nav.id)"
  366. >{{ nav.label }}</div
  367. >
  368. </div>
  369. </div>
  370. <Modal
  371. v-model:open="personModalVisible"
  372. @ok="handleSubmit"
  373. @cancel="closePersonModal"
  374. :confirmLoading="saving"
  375. :width="1040"
  376. :centered="true"
  377. :okText="isEditMode ? '保存修改' : '创建对象'"
  378. cancelText="取消"
  379. wrapClassName="person-edit-modal"
  380. destroyOnClose
  381. >
  382. <template #title>
  383. <div class="portrait-modal-title-block">
  384. <div class="portrait-modal-title">{{ isEditMode ? '编辑对象' : '新增对象' }}</div>
  385. <div class="portrait-modal-subtitle">维护画像主体的基础信息、联系方式与银行卡资料</div>
  386. </div>
  387. </template>
  388. <Form layout="vertical" class="person-form">
  389. <div class="person-form-section">
  390. <div class="form-section-head">
  391. <div>
  392. <div class="form-section-title">主要信息</div>
  393. <div class="form-section-desc">基础身份信息、籍贯住址与对象标签归类</div>
  394. </div>
  395. </div>
  396. <div class="person-form-grid">
  397. <Form.Item label="姓名" required :validateStatus="personNameError ? 'error' : ''" :help="personNameError">
  398. <Input v-model:value="personForm.personName" placeholder="请输入姓名" @blur="validatePersonName" />
  399. </Form.Item>
  400. <Form.Item label="证件号">
  401. <Input v-model:value="personForm.personCertNo" placeholder="请输入证件号" />
  402. </Form.Item>
  403. <Form.Item label="性别">
  404. <Select
  405. v-model:value="personForm.personGender"
  406. placeholder="请选择"
  407. :getPopupContainer="getModalPopupContainer"
  408. >
  409. <Select.Option value="男">男</Select.Option>
  410. <Select.Option value="女">女</Select.Option>
  411. </Select>
  412. </Form.Item>
  413. <Form.Item label="婚姻状态">
  414. <Select v-model:value="personForm.marry" placeholder="请选择" :getPopupContainer="getModalPopupContainer">
  415. <Select.Option :value="0">未知</Select.Option>
  416. <Select.Option :value="1">已婚</Select.Option>
  417. <Select.Option :value="2">未婚</Select.Option>
  418. </Select>
  419. </Form.Item>
  420. <Form.Item label="出生日期">
  421. <Input v-model:value="personForm.personBirth" placeholder="请输入 YYYY-MM-DD" />
  422. </Form.Item>
  423. <Form.Item label="年龄">
  424. <Input v-model:value="personForm.personAge" placeholder="请输入年龄" />
  425. </Form.Item>
  426. <Form.Item label="标签">
  427. <Select
  428. v-model:value="personForm.personTag"
  429. placeholder="请选择标签"
  430. :getPopupContainer="getModalPopupContainer"
  431. >
  432. <Select.Option value="MAIN">主体</Select.Option>
  433. </Select>
  434. </Form.Item>
  435. <Form.Item label="民族">
  436. <Input v-model:value="personForm.nation" placeholder="请输入民族" />
  437. </Form.Item>
  438. <Form.Item label="籍贯">
  439. <Input v-model:value="personForm.nativePlace" placeholder="请输入籍贯" />
  440. </Form.Item>
  441. <Form.Item label="户籍地">
  442. <Input v-model:value="personForm.householdRegistered" placeholder="请输入户籍地" />
  443. </Form.Item>
  444. <Form.Item label="地址">
  445. <Input v-model:value="personForm.personAddress" placeholder="请输入地址" />
  446. </Form.Item>
  447. <Form.Item label="上传头像" class="form-item-span-2 person-avatar-form-item">
  448. <div class="person-avatar-upload">
  449. <input
  450. ref="avatarInputRef"
  451. type="file"
  452. accept="image/*"
  453. hidden
  454. aria-hidden="true"
  455. class="person-avatar-upload__input"
  456. @change="handleAvatarFileChange"
  457. />
  458. <button type="button" class="person-avatar-upload__preview" @click="openAvatarFileDialog">
  459. <PersonAvatar :src="personAvatarPreviewSrc" :size="92" alt="人物头像" />
  460. </button>
  461. <div class="person-avatar-upload__tips">
  462. <div class="person-avatar-upload__title">支持上传人物头像</div>
  463. <div class="person-avatar-upload__desc">点击头像或按钮选择图片,保存时直接写入本地图片地址。</div>
  464. <div class="person-avatar-upload__actions">
  465. <Button @click="openAvatarFileDialog">上传头像</Button>
  466. <Button v-if="personForm.photograph" @click="clearAvatarSelection">清空头像</Button>
  467. </div>
  468. </div>
  469. </div>
  470. </Form.Item>
  471. </div>
  472. </div>
  473. <div class="person-form-section">
  474. <div class="form-section-head">
  475. <div>
  476. <div class="form-section-title">扩展信息</div>
  477. <div class="form-section-desc">联系方式与银行卡信息补充</div>
  478. </div>
  479. </div>
  480. <div class="person-form-grid">
  481. <Form.Item label="联系电话" class="form-item-span-2">
  482. <div class="dynamic-input-list">
  483. <div v-for="(phone, index) in personForm.phoneList" :key="index" class="dynamic-input-item">
  484. <Form.Item
  485. :validateStatus="phoneListErrors[index] ? 'error' : ''"
  486. :help="phoneListErrors[index]"
  487. :style="{ marginBottom: '8px' }"
  488. >
  489. <Input
  490. v-model:value="personForm.phoneList[index]"
  491. placeholder="请输入手机号"
  492. @blur="validatePhone(index)"
  493. />
  494. </Form.Item>
  495. <Button v-if="personForm.phoneList.length > 1" class="remove-btn" @click="removePhone(index)">
  496. <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
  497. <path d="M19 13H5v-2h14v2z" />
  498. </svg>
  499. </Button>
  500. <Button class="add-btn" @click="addPhone">
  501. <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
  502. <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
  503. </svg>
  504. </Button>
  505. </div>
  506. </div>
  507. </Form.Item>
  508. <Form.Item label="银行卡" class="form-item-span-2">
  509. <div class="dynamic-input-list">
  510. <div v-for="(card, index) in personForm.cardList" :key="index" class="dynamic-input-item">
  511. <Form.Item
  512. :validateStatus="cardListErrors[index] ? 'error' : ''"
  513. :help="cardListErrors[index]"
  514. :style="{ marginBottom: '8px' }"
  515. >
  516. <Input
  517. v-model:value="personForm.cardList[index]"
  518. placeholder="请输入银行卡号"
  519. @blur="validateCard(index)"
  520. />
  521. </Form.Item>
  522. <Button v-if="personForm.cardList.length > 1" class="remove-btn" @click="removeCard(index)">
  523. <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
  524. <path d="M19 13H5v-2h14v2z" />
  525. </svg>
  526. </Button>
  527. <Button class="add-btn" @click="addCard">
  528. <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
  529. <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
  530. </svg>
  531. </Button>
  532. </div>
  533. </div>
  534. </Form.Item>
  535. </div>
  536. </div>
  537. </Form>
  538. </Modal>
  539. <QueryTableModal
  540. v-model:open="callSummaryModalVisible"
  541. title="通信汇总"
  542. :z-index="callSummaryModalZIndex"
  543. :loading="callSummaryLoading"
  544. :filters="callSummaryQuery"
  545. :filter-schema="callSummaryFilterSchema"
  546. :columns="callSummaryColumns"
  547. :data-source="callSummaryRows"
  548. :pagination="callSummaryPagination"
  549. :total-pages="callSummaryTotalPages"
  550. :page-size-options="pageSizeOptions"
  551. :show-table-setting="false"
  552. :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
  553. :can-fullscreen="true"
  554. @search="handleSearchCallSummary"
  555. @reset="handleResetCallSummary"
  556. @prev-page="handleCallSummaryPrevPage"
  557. @next-page="handleCallSummaryNextPage"
  558. @page-size-change="handleCallSummaryPageSizeChange"
  559. @page-change="handleCallSummaryPageChange"
  560. @sort-change="handleSortCallSummary"
  561. />
  562. <QueryTableModal
  563. v-model:open="callOperPhoneModalVisible"
  564. :title="callOperPhoneModalTitle"
  565. wrap-class-name="call-oper-phone-modal"
  566. :loading="callOperPhoneLoading"
  567. :filters="callOperPhoneQuery"
  568. :filter-schema="callOperPhoneFilterSchema"
  569. :columns="callOperPhoneColumns"
  570. :data-source="callOperPhoneRows"
  571. :pagination="callOperPhonePagination"
  572. :total-pages="callOperPhoneTotalPages"
  573. :page-size-options="pageSizeOptions"
  574. :show-table-setting="true"
  575. :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
  576. :can-fullscreen="true"
  577. @search="handleSearchCallOperPhone"
  578. @reset="handleResetCallOperPhone"
  579. @prev-page="handleCallOperPhonePrevPage"
  580. @next-page="handleCallOperPhoneNextPage"
  581. @page-size-change="handleCallOperPhonePageSizeChange"
  582. @page-change="handleCallOperPhonePageChange"
  583. @sort-change="handleSortCallOperPhone"
  584. />
  585. <CallRecordModal
  586. v-model:open="callRecordModalVisible"
  587. title="通信记录"
  588. :z-index="callRecordModalZIndex"
  589. :base-query="callRecordBaseQuery"
  590. :hide-technical-id-columns="true"
  591. @jump="handleCallRecordJump"
  592. />
  593. <QueryTableModal
  594. v-model:open="transSummaryModalVisible"
  595. title="交易汇总"
  596. :z-index="transSummaryModalZIndex"
  597. :loading="transSummaryLoading"
  598. :filters="transSummaryQuery"
  599. :filter-schema="transSummaryFilterSchema"
  600. :columns="transSummaryColumns"
  601. :data-source="transSummaryRows"
  602. :pagination="transSummaryPagination"
  603. :total-pages="transSummaryTotalPages"
  604. :page-size-options="pageSizeOptions"
  605. :show-table-setting="false"
  606. :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
  607. :can-fullscreen="true"
  608. @search="handleSearchTransSummary"
  609. @reset="handleResetTransSummary"
  610. @prev-page="handleTransSummaryPrevPage"
  611. @next-page="handleTransSummaryNextPage"
  612. @page-size-change="handleTransSummaryPageSizeChange"
  613. @page-change="handleTransSummaryPageChange"
  614. @sort-change="handleSortTransSummary"
  615. />
  616. <QueryTableModal
  617. v-model:open="transBankCardModalVisible"
  618. :title="transBankCardModalTitle"
  619. :loading="transBankCardLoading"
  620. :filters="transBankCardQuery"
  621. :filter-schema="transBankCardFilterSchema"
  622. :columns="transBankCardColumns"
  623. :data-source="transBankCardRows"
  624. :pagination="transBankCardPagination"
  625. :total-pages="transBankCardTotalPages"
  626. :page-size-options="pageSizeOptions"
  627. :show-table-setting="true"
  628. :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
  629. :can-fullscreen="true"
  630. @search="handleSearchTransBankCard"
  631. @reset="handleResetTransBankCard"
  632. @prev-page="handleTransBankCardPrevPage"
  633. @next-page="handleTransBankCardNextPage"
  634. @page-size-change="handleTransBankCardPageSizeChange"
  635. @page-change="handleTransBankCardPageChange"
  636. @sort-change="handleSortTransBankCard"
  637. />
  638. <TransRecordModal
  639. v-model:open="transRecordModalVisible"
  640. :z-index="transRecordModalZIndex"
  641. :base-query="transRecordBaseQuery"
  642. :hide-technical-id-columns="true"
  643. @jump="handleTransRecordJump"
  644. />
  645. <StationMapModal v-model:open="stationMapModalVisible" :stations="stationMapPoints" :title="stationMapModalTitle" />
  646. <Modal
  647. v-model:open="groupModalVisible"
  648. :footer="null"
  649. :centered="true"
  650. :width="960"
  651. wrapClassName="object-group-modal"
  652. @cancel="closeGroupModal"
  653. >
  654. <template #title>
  655. <div class="portrait-modal-title-block">
  656. <div class="portrait-modal-title">对象分组</div>
  657. <div class="portrait-modal-subtitle">维护分组结构、查看成员状态并设置主要对象</div>
  658. </div>
  659. </template>
  660. <div class="group-modal-content">
  661. <div class="group-modal-actions">
  662. <div class="group-modal-actions__info">左侧切换分组,右侧维护成员与主要对象。</div>
  663. <div class="group-modal-actions__buttons">
  664. <Button class="head-btn head-btn--primary" @click="openCreateGroupModal">新增分组</Button>
  665. <Button class="head-btn" @click="handleEditGroup">编辑分组</Button>
  666. <Button class="head-btn head-btn--danger" @click="handleDeleteGroup">删除分组</Button>
  667. <Button class="head-btn head-btn--accent" @click="handleAddObject">添加对象</Button>
  668. </div>
  669. </div>
  670. <div class="group-modal-main">
  671. <div class="group-left">
  672. <div class="group-panel-head">
  673. <div class="group-panel-title">分组列表</div>
  674. <div class="group-panel-badge">{{ objectGroups.length }}</div>
  675. </div>
  676. <List :data-source="objectGroups" size="small" class="group-list">
  677. <template #renderItem="{ item }">
  678. <List.Item>
  679. <Button
  680. block
  681. class="group-select-btn"
  682. :type="item.id === activeGroupId ? 'primary' : 'default'"
  683. @click="activeGroupId = item.id"
  684. >
  685. <span class="group-select-btn__name">{{ item.name }}</span>
  686. </Button>
  687. </List.Item>
  688. </template>
  689. </List>
  690. </div>
  691. <div class="group-right">
  692. <div class="group-panel-head group-panel-head--right">
  693. <div class="group-panel-title-wrap">
  694. <div class="group-panel-title">{{ activeGroup?.name || '分组成员' }}</div>
  695. <div class="group-panel-subtitle">
  696. {{ activeGroup ? `当前分组共 ${activeGroupMembers.length} 人` : '请选择左侧分组查看成员' }}
  697. </div>
  698. </div>
  699. </div>
  700. <List :data-source="activeGroupMembers" class="member-list" item-layout="horizontal">
  701. <template #renderItem="{ item }">
  702. <List.Item class="member-item">
  703. <template #actions>
  704. <Button
  705. type="link"
  706. class="member-main-btn"
  707. :disabled="item.hasMain"
  708. @click="handleChangeMain(item.id)"
  709. >
  710. 设为主对象
  711. </Button>
  712. <Button type="link" class="member-remove-btn" @click="handleRemoveMember(item.id)">移出分组</Button>
  713. </template>
  714. <List.Item.Meta>
  715. <template #avatar>
  716. <Avatar :size="34" class="member-avatar-icon">
  717. <svg viewBox="0 0 24 24" fill="currentColor">
  718. <path
  719. d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
  720. />
  721. </svg>
  722. </Avatar>
  723. </template>
  724. <template #title>
  725. <div class="member-name-row">
  726. <span class="member-name">{{ item.personName }}</span>
  727. <span v-if="item.hasMain" class="member-main-tag">主要</span>
  728. </div>
  729. </template>
  730. <template #description>
  731. <span class="member-desc">{{ item.hasMain ? '当前主对象' : '分组成员' }}</span>
  732. </template>
  733. </List.Item.Meta>
  734. </List.Item>
  735. </template>
  736. </List>
  737. </div>
  738. </div>
  739. </div>
  740. </Modal>
  741. <Modal
  742. v-model:open="createGroupModalVisible"
  743. :title="isEditGroupMode ? '编辑分组' : '新建分组'"
  744. :confirmLoading="creatingGroup"
  745. :width="640"
  746. :centered="true"
  747. :okText="isEditGroupMode ? '保存分组' : '创建分组'"
  748. cancelText="取消"
  749. wrapClassName="create-group-modal"
  750. @ok="handleCreateGroup"
  751. @cancel="closeCreateGroupModal"
  752. >
  753. <Form layout="vertical" class="create-group-form">
  754. <div class="create-group-section">
  755. <div class="create-group-section__head">
  756. <div class="create-group-section__title">分组信息</div>
  757. <div class="create-group-section__desc">建议使用便于识别的名称,主体对象可按需选择。</div>
  758. </div>
  759. <Form.Item label="分组名称" required :validateStatus="groupNameError ? 'error' : ''" :help="groupNameError">
  760. <Input
  761. v-model:value="createGroupForm.groupName"
  762. placeholder="请输入分组名称"
  763. @blur="() => validateGroupName(true)"
  764. />
  765. </Form.Item>
  766. <Form.Item
  767. label="分组主体对象"
  768. :required="!isEditGroupMode"
  769. :validateStatus="mainPersonError ? 'error' : ''"
  770. :help="mainPersonError"
  771. >
  772. <Select
  773. v-model:value="createGroupForm.mainPerson"
  774. :options="personOptions"
  775. :placeholder="isEditGroupMode ? '请选择主体对象' : '请选择主体对象'"
  776. allowClear
  777. :getPopupContainer="getModalPopupContainer"
  778. />
  779. </Form.Item>
  780. <Form.Item label="备注">
  781. <Input.TextArea v-model:value="createGroupForm.remark" :rows="4" placeholder="请输入分组说明" />
  782. </Form.Item>
  783. </div>
  784. </Form>
  785. </Modal>
  786. <Modal
  787. v-model:open="addObjectModalVisible"
  788. title="添加对象"
  789. :confirmLoading="addingObjectToGroup"
  790. :width="720"
  791. :centered="true"
  792. okText="加入分组"
  793. cancelText="取消"
  794. wrapClassName="add-group-person-modal"
  795. @ok="handleConfirmAddObject"
  796. @cancel="closeAddObjectModal"
  797. >
  798. <div class="add-object-modal">
  799. <div class="add-object-modal__search">
  800. <div class="add-object-modal__search-head">
  801. <div class="add-object-modal__title-block">
  802. <div class="add-object-modal__title">选择对象</div>
  803. <div class="add-object-modal__desc">
  804. {{ activeGroup ? `当前分组:${activeGroup.name}` : '请选择需要加入分组的对象' }}
  805. </div>
  806. </div>
  807. <div class="add-object-modal__stats">共 {{ filteredAddObjectCandidates.length }} 个对象</div>
  808. </div>
  809. <Input v-model:value="addObjectKeyword" allowClear placeholder="请输入姓名 / 证件号 / 手机号" />
  810. </div>
  811. <div class="add-object-modal__content">
  812. <div v-if="filteredAddObjectCandidates.length" class="add-object-list">
  813. <div
  814. v-for="item in filteredAddObjectCandidates"
  815. :key="item.key"
  816. class="add-object-item"
  817. :class="{
  818. 'is-selected': selectedAddPersonNames.includes(item.name),
  819. 'is-disabled': item.disabled,
  820. }"
  821. @click="handleToggleAddObject(item)"
  822. >
  823. <Checkbox
  824. :checked="selectedAddPersonNames.includes(item.name)"
  825. :disabled="item.disabled"
  826. @click.stop
  827. @change="(e) => handleCheckAddObject(item, e.target.checked)"
  828. />
  829. <PersonAvatar
  830. class="add-object-item__avatar"
  831. :src="item.photograph || undefined"
  832. :alt="item.name"
  833. :size="42"
  834. />
  835. <div class="add-object-item__body">
  836. <div class="add-object-item__head">
  837. <div class="add-object-item__name">{{ item.name }}</div>
  838. <span v-if="item.disabled" class="add-object-item__tag">已在分组</span>
  839. </div>
  840. <div class="add-object-item__meta">
  841. <span>证件号:{{ maskCertNo(item.personCertNo) }}</span>
  842. <span>手机号:{{ item.primaryPhone }}</span>
  843. <span>银行卡:{{ item.cardCount }} 张</span>
  844. </div>
  845. </div>
  846. </div>
  847. </div>
  848. <div v-else class="add-object-modal__empty">{{ addObjectEmptyText }}</div>
  849. </div>
  850. </div>
  851. </Modal>
  852. </div>
  853. </template>
  854. <script lang="ts" setup name="ViewsDmPortrait">
  855. import { computed, ref, onMounted, reactive, watch, nextTick, onBeforeUnmount, h } from 'vue';
  856. import { useRoute, useRouter } from 'vue-router';
  857. import {
  858. Button,
  859. DatePicker,
  860. Input,
  861. Select,
  862. Modal,
  863. Form,
  864. Popover,
  865. List,
  866. Avatar,
  867. Radio,
  868. Checkbox,
  869. } from 'ant-design-vue';
  870. import componentSetting from '@jeesite/core/settings/componentSetting';
  871. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  872. import { useGlobSetting } from '@jeesite/core/hooks/setting';
  873. import { useMessage } from '@jeesite/core/hooks/web/useMessage';
  874. import { getModalPopupContainer as resolveModalPopupContainer } from '@jeesite/core/utils';
  875. import { normalizeAvatarSrc } from '@jeesite/core/utils/avatar';
  876. import {
  877. COMPARE_OPERATOR_OPTIONS,
  878. DEFAULT_COMPARE_OPERATOR,
  879. normalizeCompareOperator,
  880. } from '@jeesite/core/utils/compareOperator';
  881. import { maskCertNo } from '@jeesite/core/utils/sensitive';
  882. import { EmptyState } from '@jeesite/core/components/EmptyState';
  883. import { PersonAvatar } from '@jeesite/core/components/PersonAvatar';
  884. import QueryTableModal from './components/QueryTableModal.vue';
  885. import IntimacyPanel from './components/IntimacyPanel.vue';
  886. import StationMapModal from './components/StationMapModal.vue';
  887. import CallRecordModal from '../../../call/views/components/CallRecordModal.vue';
  888. import TransRecordModal from '../../../trans/views/components/TransRecordModal.vue';
  889. import * as echarts from 'echarts';
  890. import type { CallRecordQuery } from '../../../call/api/types/query';
  891. import type { TransRecordQuery } from '../../../trans/api/types/query';
  892. import {
  893. getPersons,
  894. createPersonBasicInfo,
  895. updatePersonBasicInfo,
  896. deletePersonBasicInfo,
  897. listPersonGroups,
  898. createPersonGroup,
  899. updatePersonGroup,
  900. deletePersonGroup,
  901. addPersonToGroup,
  902. remPersonFromGroup,
  903. changeMainPerson,
  904. } from '../../../person/api/personApi';
  905. import type {
  906. PersonBasicInfo,
  907. PersonBasicInfoDTO,
  908. CallDataProfileStatDTO,
  909. CallOftenTop10DTO,
  910. CallOpenInfo,
  911. CallStatStationDTO,
  912. TransBankCardInfoDTO,
  913. TransDataProfileStatDTO,
  914. TransOftenTop10DTO,
  915. TransTrendDTO,
  916. TransRecordSummaryDTO,
  917. } from '../../../person/api/types/dto';
  918. import {
  919. callStat,
  920. callOftenTop10,
  921. getCallOperPhoneInfo,
  922. callStatStation,
  923. getTransOtherName,
  924. transStat,
  925. transOfPersons,
  926. transAmountTrendYM,
  927. transAmountTrendY,
  928. getTransBankCardInfo,
  929. getTransRecordSummary,
  930. getCallRecordSummary,
  931. } from '../../../person/api/dpApi';
  932. import { getCallRecord } from '../../../call/api/callApi';
  933. import {
  934. CallOperPhoneInfoQuery,
  935. TransBankCardInfoQuery,
  936. TransRecordSummaryQuery,
  937. } from '../../../person/api/types/query';
  938. const { prefixCls } = useDesign('case-portrait');
  939. const { ctxAdminPath } = useGlobSetting();
  940. const { createMessage, createConfirm } = useMessage();
  941. const route = useRoute();
  942. const router = useRouter();
  943. const getModalPopupContainer = (triggerNode?: HTMLElement) => resolveModalPopupContainer(triggerNode);
  944. type PortraitPerson = PersonBasicInfo & {
  945. personNation?: string;
  946. personEducation?: string;
  947. personMarriage?: string;
  948. personHeight?: string | number;
  949. personResidence?: string;
  950. personWorkUnit?: string;
  951. personGroup?: string;
  952. };
  953. const personList = ref<PortraitPerson[]>([]);
  954. const selectedPersonId = ref<number | null>(null);
  955. const personModalVisible = ref(false);
  956. const callSummaryModalVisible = ref(false);
  957. const callSummaryModalZIndex = 1200;
  958. const callOperPhoneModalVisible = ref(false);
  959. const callRecordModalVisible = ref(false);
  960. const callRecordModalZIndex = 1210;
  961. const transSummaryModalVisible = ref(false);
  962. const transSummaryModalZIndex = 1200;
  963. const transBankCardModalVisible = ref(false);
  964. const transRecordModalVisible = ref(false);
  965. const transRecordModalZIndex = 1210;
  966. const stationMapModalVisible = ref(false);
  967. const groupModalVisible = ref(false);
  968. const createGroupModalVisible = ref(false);
  969. const creatingGroup = ref(false);
  970. const isEditGroupMode = ref(false);
  971. const editingGroupData = ref<{ id: string; name: string; remark?: string } | null>(null);
  972. const isEditMode = ref(false);
  973. const objectGroups = ref<{ id: string; name: string; remark?: string }[]>([]);
  974. const activeGroupId = ref<string | null>(null);
  975. const groupMembers = ref<{ id: string; groupId: string; personName: string; hasMain?: boolean }[]>([]);
  976. const saving = ref(false);
  977. const editingPerson = ref<PersonBasicInfo | null>(null);
  978. const loadingProfile = ref(false);
  979. const stationMapPoints = computed<CallStatStationDTO[]>(() =>
  980. stationData.value.filter((item): item is CallStatStationDTO => {
  981. if (!item || typeof item !== 'object') return false;
  982. const longitude = Number(String(item.personStationLongitude || '').trim());
  983. const latitude = Number(String(item.personStationLatitude || '').trim());
  984. return Number.isFinite(longitude) && Number.isFinite(latitude);
  985. }),
  986. );
  987. const canViewStationMap = computed(() => stationMapPoints.value.length > 0);
  988. const stationMapModalTitle = computed(() => {
  989. const personName = selectedPerson.value?.personName?.trim();
  990. const suffix = stationMapPoints.value.length ? `(${stationMapPoints.value.length}个节点)` : '';
  991. return personName ? `${personName} - 通信基站${suffix}` : `通信基站${suffix}`;
  992. });
  993. const callStatData = ref<CallDataProfileStatDTO | null>(null);
  994. const transStatData = ref<TransDataProfileStatDTO | null>(null);
  995. const callOperPhoneModalTitle = ref('运营商手机号信息');
  996. const transBankCardModalTitle = ref('银行卡交易信息');
  997. const commTypeRatioData = ref<{ value: number; name: string; itemStyle?: { color: string } }[]>([]);
  998. const commTop10Data = ref<CallOftenTop10DTO[]>([]);
  999. const stationData = ref<CallStatStationDTO[]>([]);
  1000. const stationDisplayLimit = 3;
  1001. const tradeTop10Data = ref<TransOftenTop10DTO[]>([]);
  1002. const tradeTrendData = ref<TransTrendDTO[]>([]);
  1003. const tradeTrendChartRef = ref<HTMLElement | null>(null);
  1004. const tradeTrendScrollRef = ref<HTMLElement | null>(null);
  1005. const tradeRatioChartRef = ref<HTMLElement | null>(null);
  1006. const tradeTopChartRef = ref<HTMLElement | null>(null);
  1007. const commTypeChartRef = ref<HTMLElement | null>(null);
  1008. const commTopChartRef = ref<HTMLElement | null>(null);
  1009. let tradeTrendChart: echarts.ECharts | null = null;
  1010. let tradeRatioChart: echarts.ECharts | null = null;
  1011. let tradeTopChart: echarts.ECharts | null = null;
  1012. let commTypeChart: echarts.ECharts | null = null;
  1013. let commTopChart: echarts.ECharts | null = null;
  1014. let chartResizeObserver: ResizeObserver | null = null;
  1015. let chartResizeFrame: number | null = null;
  1016. const observedChartElements = new Set<Element>();
  1017. let tradeTrendSearchTimer: number | null = null;
  1018. const trendLegendArr = ref([
  1019. { isShow: true, name: '收入', color: '#5ad8a6' },
  1020. { isShow: true, name: '支出', color: '#e8684a' },
  1021. ]);
  1022. const commTopQuery = reactive({
  1023. dateRange: undefined as [string, string] | undefined,
  1024. });
  1025. type TradeTopDimension = '交易金额' | '交易次数';
  1026. const tradeTopQuery = reactive({
  1027. indicator: 'personName',
  1028. dateRange: undefined as [string, string] | undefined,
  1029. dimension: '交易金额' as TradeTopDimension,
  1030. });
  1031. const tradeTrendQuery = reactive({
  1032. otherName: '',
  1033. dateRange: undefined as [string, string] | undefined,
  1034. timeDimension: 'YEAR' as 'MONTH' | 'YEAR',
  1035. });
  1036. const tradeTrendOtherNameOptions = ref<{ label: string; value: string }[]>([]);
  1037. const tradeTrendOtherNameLoading = ref(false);
  1038. const tradeTrendOtherNameSearch = ref('');
  1039. const tradeTrendOtherNamePage = ref(1);
  1040. const tradeTrendOtherNameHasMore = ref(true);
  1041. const tradeTrendOtherNameLoadedPerson = ref('');
  1042. const avatarInputRef = ref<HTMLInputElement | null>(null);
  1043. const avatarPreviewObjectUrl = ref('');
  1044. let tradeTrendOtherNameSearchTimer: number | null = null;
  1045. let tradeTrendOtherNameRequestId = 0;
  1046. const personForm = reactive({
  1047. personName: '',
  1048. personCertNo: '',
  1049. personBirth: '',
  1050. personGender: '',
  1051. personAge: '',
  1052. personAddress: '',
  1053. photograph: '',
  1054. marry: 0,
  1055. nation: '',
  1056. nativePlace: '',
  1057. householdRegistered: '',
  1058. personTag: '',
  1059. phoneList: [''],
  1060. cardList: [''],
  1061. });
  1062. const phoneListErrors = ref<string[]>([]);
  1063. const cardListErrors = ref<string[]>([]);
  1064. const personNameError = ref('');
  1065. const groupNameError = ref('');
  1066. const mainPersonError = ref('');
  1067. const createGroupForm = reactive({
  1068. groupName: '',
  1069. mainPerson: undefined as string | undefined,
  1070. remark: '',
  1071. });
  1072. const addObjectModalVisible = ref(false);
  1073. const addingObjectToGroup = ref(false);
  1074. const addObjectKeyword = ref('');
  1075. const selectedAddPersonNames = ref<string[]>([]);
  1076. const createCallSummaryQuery = () => ({
  1077. keyword: '',
  1078. otherName: '',
  1079. otherPhone: '',
  1080. callDirection: '',
  1081. callType: '',
  1082. startDate: '',
  1083. endDate: '',
  1084. orderKey: '',
  1085. sort: 'DESC' as 'ASC' | 'DESC',
  1086. });
  1087. type CallSummaryQueryState = ReturnType<typeof createCallSummaryQuery>;
  1088. const callSummaryLoading = ref(false);
  1089. const callSummaryQuery = reactive<CallSummaryQueryState>(createCallSummaryQuery());
  1090. const callSummaryBaseQuery = ref<CallSummaryQueryState>(createCallSummaryQuery());
  1091. const callSummaryRows = ref<any[]>([]);
  1092. const callSummaryPagination = reactive({
  1093. page: 1,
  1094. limit: componentSetting.table.defaultPageSize,
  1095. total: 0,
  1096. });
  1097. const callSummaryTotalPages = computed(() => {
  1098. if (!callSummaryPagination.total) return 1;
  1099. return Math.max(1, Math.ceil(callSummaryPagination.total / callSummaryPagination.limit));
  1100. });
  1101. const createCallOperPhoneQuery = () => ({
  1102. personName: '',
  1103. operatorName: '',
  1104. phoneNumber: '',
  1105. orderKey: '',
  1106. sort: 'DESC' as 'ASC' | 'DESC',
  1107. phoneList: [] as string[],
  1108. operatorId: '',
  1109. });
  1110. type CallOperPhoneQueryState = ReturnType<typeof createCallOperPhoneQuery>;
  1111. const callOperPhoneLoading = ref(false);
  1112. const callOperPhoneQuery = reactive<CallOperPhoneQueryState>(createCallOperPhoneQuery());
  1113. const callOperPhoneBaseQuery = ref<CallOperPhoneQueryState>(createCallOperPhoneQuery());
  1114. const callOperPhoneAllRows = ref<any[]>([]);
  1115. const callOperPhoneRows = ref<any[]>([]);
  1116. const callOperPhonePagination = reactive({
  1117. page: 1,
  1118. limit: componentSetting.table.defaultPageSize,
  1119. total: 0,
  1120. });
  1121. const callOperPhoneTotalPages = computed(() => {
  1122. if (!callOperPhonePagination.total) return 1;
  1123. return Math.max(1, Math.ceil(callOperPhonePagination.total / callOperPhonePagination.limit));
  1124. });
  1125. const createTransBankCardQuery = () => ({
  1126. keyword: '',
  1127. orderKey: '',
  1128. sort: 'DESC' as 'ASC' | 'DESC',
  1129. bankName: '',
  1130. cardNoList: [] as string[],
  1131. });
  1132. type TransBankCardQueryState = ReturnType<typeof createTransBankCardQuery>;
  1133. const transBankCardLoading = ref(false);
  1134. const transBankCardQuery = reactive<TransBankCardQueryState>(createTransBankCardQuery());
  1135. const transBankCardBaseQuery = ref<TransBankCardQueryState>(createTransBankCardQuery());
  1136. const transBankCardRows = ref<any[]>([]);
  1137. const transBankCardPagination = reactive({
  1138. page: 1,
  1139. limit: componentSetting.table.defaultPageSize,
  1140. total: 0,
  1141. });
  1142. const transBankCardTotalPages = computed(() => {
  1143. if (!transBankCardPagination.total) return 1;
  1144. return Math.max(1, Math.ceil(transBankCardPagination.total / transBankCardPagination.limit));
  1145. });
  1146. const createTransSummaryQuery = () => ({
  1147. personCardNo: '',
  1148. otherName: '',
  1149. transAmount: '',
  1150. transAmountSymbol: DEFAULT_COMPARE_OPERATOR,
  1151. transCount: '',
  1152. transCountSymbol: DEFAULT_COMPARE_OPERATOR,
  1153. orderKey: '',
  1154. sort: 'DESC' as 'ASC' | 'DESC',
  1155. });
  1156. type TransSummaryQueryState = ReturnType<typeof createTransSummaryQuery>;
  1157. const transSummaryLoading = ref(false);
  1158. const transSummaryQuery = reactive<TransSummaryQueryState>(createTransSummaryQuery());
  1159. const transSummaryBaseQuery = ref<TransSummaryQueryState>(createTransSummaryQuery());
  1160. type TransRecordDrilldownQuery = Partial<TransRecordQuery> & Record<string, any>;
  1161. const transSummaryRows = ref<any[]>([]);
  1162. const callRecordBaseQuery = ref<Partial<CallRecordQuery>>({});
  1163. const transRecordBaseQuery = ref<TransRecordDrilldownQuery>({});
  1164. const transSummaryPagination = reactive({
  1165. page: 1,
  1166. limit: componentSetting.table.defaultPageSize,
  1167. total: 0,
  1168. });
  1169. const transSummaryTotalPages = computed(() => {
  1170. if (!transSummaryPagination.total) return 1;
  1171. return Math.max(1, Math.ceil(transSummaryPagination.total / transSummaryPagination.limit));
  1172. });
  1173. const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({ label: value, value }));
  1174. const compareOperatorOptions = COMPARE_OPERATOR_OPTIONS;
  1175. const callSummaryFilterSchema = [
  1176. { field: 'otherPhone', label: '对方号码', type: 'input' as const, placeholder: '请输入对方号码', width: 260 },
  1177. { field: 'otherName', label: '对方姓名', type: 'input' as const, placeholder: '请输入对方姓名', width: 260 },
  1178. ];
  1179. const callSummaryColumns = [
  1180. { title: '本方姓名', dataIndex: 'personName', key: 'personName', width: 160, sorter: true },
  1181. { title: '本方号码', dataIndex: 'personPhone', key: 'personPhone', width: 180, sorter: true },
  1182. { title: '对方姓名', dataIndex: 'otherName', key: 'otherName', width: 160, sorter: true },
  1183. { title: '对方号码', dataIndex: 'otherPhone', key: 'otherPhone', width: 200, sorter: true },
  1184. { title: '通话次数', dataIndex: 'callCount', key: 'callCount', width: 120, sorter: true },
  1185. {
  1186. title: '操作',
  1187. dataIndex: 'action',
  1188. key: 'action',
  1189. width: 120,
  1190. fixed: 'right' as const,
  1191. align: 'center' as const,
  1192. customRender: ({ record }: any) =>
  1193. h(
  1194. Button,
  1195. {
  1196. type: 'link',
  1197. size: 'small',
  1198. onClick: () => handleViewCallSummaryDetail(record),
  1199. },
  1200. { default: () => '查看详情' },
  1201. ),
  1202. },
  1203. ];
  1204. const callOperPhoneFilterSchema = [
  1205. { field: 'phoneNumber', label: '手机号码', type: 'input' as const, placeholder: '请输入手机号码', width: 220 },
  1206. ];
  1207. const callOperPhoneColumns = [
  1208. { title: '本方姓名', dataIndex: 'personName', key: 'personName', width: 180, sorter: true },
  1209. { title: '手机号码', dataIndex: 'personPhone', key: 'personPhone', width: 180, sorter: true },
  1210. { title: '号码类型', dataIndex: 'phoneType', key: 'phoneType', width: 140, sorter: true },
  1211. { title: '运营商标识', dataIndex: 'operatorId', key: 'operatorId', width: 180, sorter: true },
  1212. { title: '用户类型', dataIndex: 'personType', key: 'personType', width: 140, sorter: true },
  1213. { title: '使用人证件号码', dataIndex: 'personCertNo', key: 'personCertNo', width: 220, sorter: true },
  1214. { title: '使用人证件地址', dataIndex: 'personCertAddress', key: 'personCertAddress', width: 260 },
  1215. { title: '通信地址', dataIndex: 'mailingAddress', key: 'mailingAddress', width: 260 },
  1216. { title: '号码归属省', dataIndex: 'province', key: 'province', width: 140, sorter: true },
  1217. { title: '号码归属市', dataIndex: 'city', key: 'city', width: 140, sorter: true },
  1218. { title: '号码状态', dataIndex: 'phoneStatus', key: 'phoneStatus', width: 140, sorter: true },
  1219. { title: '入网时间', dataIndex: 'activationDateTime', key: 'activationDateTime', width: 200, sorter: true },
  1220. { title: '销户时间', dataIndex: 'closingDateTime', key: 'closingDateTime', width: 200, sorter: true },
  1221. { title: '首次通话时间', dataIndex: 'firstCallDateTime', key: 'firstCallDateTime', width: 200, sorter: true },
  1222. {
  1223. title: '最后停机时间',
  1224. dataIndex: 'lastSuspensionDateTime',
  1225. key: 'lastSuspensionDateTime',
  1226. width: 200,
  1227. sorter: true,
  1228. },
  1229. {
  1230. title: '操作',
  1231. dataIndex: 'action',
  1232. key: 'action',
  1233. width: 120,
  1234. fixed: 'right' as const,
  1235. align: 'center' as const,
  1236. customRender: ({ record }: any) =>
  1237. h(
  1238. Button,
  1239. {
  1240. type: 'link',
  1241. size: 'small',
  1242. onClick: () => handleViewCallOperPhoneDetail(record),
  1243. },
  1244. { default: () => '查看详情' },
  1245. ),
  1246. },
  1247. ];
  1248. const transBankCardFilterSchema = [
  1249. { field: 'keyword', label: '卡号', type: 'input' as const, placeholder: '请输入卡号' },
  1250. ];
  1251. const transBankCardColumns = [
  1252. { title: '本方卡号', dataIndex: 'personCardNo', key: 'personCardNo', width: 200, sorter: true },
  1253. { title: '本方姓名', dataIndex: 'personName', key: 'personName', width: 140 },
  1254. { title: '所属银行', dataIndex: 'bankName', key: 'bankName', width: 180, sorter: true },
  1255. { title: '收入总金额', dataIndex: 'incomeTotalAmount', key: 'incomeTotalAmount', width: 140, sorter: true },
  1256. { title: '支出总金额', dataIndex: 'expenseTotalAmount', key: 'expenseTotalAmount', width: 140, sorter: true },
  1257. { title: '净额', dataIndex: 'netAmount', key: 'netAmount', width: 140, sorter: true },
  1258. { title: '交易总金额', dataIndex: 'transTotalAmount', key: 'transTotalAmount', width: 140, sorter: true },
  1259. { title: '交易笔数', dataIndex: 'transCount', key: 'transCount', width: 100, sorter: true },
  1260. { title: '账户余额', dataIndex: 'totalBalance', key: 'totalBalance', width: 140, sorter: true },
  1261. { title: '账户状态', dataIndex: 'accountStatus', key: 'accountStatus', width: 100, sorter: true },
  1262. { title: '开户日期', dataIndex: 'openingDate', key: 'openingDate', width: 140, sorter: true },
  1263. { title: '销户日期', dataIndex: 'closeAccountDate', key: 'closeAccountDate', width: 140, sorter: true },
  1264. {
  1265. title: '操作',
  1266. dataIndex: 'action',
  1267. key: 'action',
  1268. width: 120,
  1269. fixed: 'right' as const,
  1270. align: 'center' as const,
  1271. customRender: ({ record }: any) =>
  1272. h(
  1273. Button,
  1274. {
  1275. type: 'link',
  1276. size: 'small',
  1277. onClick: () => handleViewTransBankCardDetail(record),
  1278. },
  1279. { default: () => '查看详情' },
  1280. ),
  1281. },
  1282. ];
  1283. const transSummaryFilterSchema = [
  1284. {
  1285. field: 'personCardNo',
  1286. label: '银行卡(账号)',
  1287. type: 'input' as const,
  1288. placeholder: '请输入银行卡(账号)',
  1289. width: 260,
  1290. },
  1291. {
  1292. field: 'otherName',
  1293. label: '对方名称',
  1294. type: 'input' as const,
  1295. placeholder: '请输入对方名称',
  1296. width: 260,
  1297. },
  1298. {
  1299. field: 'transAmount',
  1300. label: '交易总金额',
  1301. type: 'compare-input' as const,
  1302. operatorField: 'transAmountSymbol',
  1303. options: compareOperatorOptions,
  1304. operatorWidth: 72,
  1305. inputWidth: 180,
  1306. inputMaxWidth: 180,
  1307. placeholder: '请输入交易总金额',
  1308. },
  1309. {
  1310. field: 'transCount',
  1311. label: '交易次数',
  1312. type: 'compare-input' as const,
  1313. operatorField: 'transCountSymbol',
  1314. options: compareOperatorOptions,
  1315. operatorWidth: 72,
  1316. inputWidth: 180,
  1317. inputMaxWidth: 180,
  1318. placeholder: '请输入交易次数',
  1319. },
  1320. ];
  1321. const transSummaryColumns = [
  1322. { title: '本方卡号', dataIndex: 'personCardNo', key: 'personCardNo', width: 180, sorter: true },
  1323. { title: '本方姓名', dataIndex: 'personName', key: 'personName', width: 160, sorter: true },
  1324. { title: '对方卡号', dataIndex: 'otherCardNo', key: 'otherCardNo', width: 180, sorter: true },
  1325. { title: '对方名称', dataIndex: 'otherName', key: 'otherName', width: 160, sorter: true },
  1326. { title: '所属银行', dataIndex: 'bankName', key: 'bankName', width: 180, sorter: true },
  1327. { title: '交易次数', dataIndex: 'transCount', key: 'transCount', width: 120, sorter: true },
  1328. { title: '交易总金额', dataIndex: 'transTotalAmount', key: 'transTotalAmount', width: 140, sorter: true },
  1329. { title: '收入总金额', dataIndex: 'incomeTotalAmount', key: 'incomeTotalAmount', width: 180, sorter: true },
  1330. { title: '支出总金额', dataIndex: 'expenseTotalAmount', key: 'expenseTotalAmount', width: 180, sorter: true },
  1331. {
  1332. title: '操作',
  1333. dataIndex: 'action',
  1334. key: 'action',
  1335. width: 120,
  1336. fixed: 'right' as const,
  1337. align: 'center' as const,
  1338. customRender: ({ record }: any) =>
  1339. h(
  1340. Button,
  1341. {
  1342. type: 'link',
  1343. size: 'small',
  1344. onClick: () => handleViewTransSummaryDetail(record),
  1345. },
  1346. { default: () => '查看详情' },
  1347. ),
  1348. },
  1349. ];
  1350. const toPersonName = (person: PersonBasicInfo) => person.personName ?? '-';
  1351. const getSelectedPersonPhones = (person = selectedPerson.value) => {
  1352. const phones = (person?.phoneList || []).map((item) => String(item.personNo || '').trim()).filter(Boolean);
  1353. return phones.length ? Array.from(new Set(phones)) : undefined;
  1354. };
  1355. const toLibNoList = (text: string, libType: 2 | 3) => {
  1356. return text
  1357. .split(',')
  1358. .map((item) => item.trim())
  1359. .filter(Boolean)
  1360. .map((personNo) => ({ personNo, libType }));
  1361. };
  1362. const normalizePersonIdCard = (value?: string | null) =>
  1363. String(value ?? '')
  1364. .trim()
  1365. .replace(/\s/g, '')
  1366. .toUpperCase();
  1367. const isLeapYear = (year: number) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
  1368. const isValidDateParts = (year: number, month: number, day: number) => {
  1369. if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return false;
  1370. if (month < 1 || month > 12 || day < 1) return false;
  1371. const dayList = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  1372. return day <= dayList[month - 1];
  1373. };
  1374. const getPersonIdCardCheckCode = (body: string) => {
  1375. const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
  1376. const codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
  1377. const total = body.split('').reduce((sum, char, index) => sum + Number(char) * weights[index], 0);
  1378. return codes[total % 11];
  1379. };
  1380. const parsePersonProfileFromIdCard = (value?: string | null) => {
  1381. const certNo = normalizePersonIdCard(value);
  1382. if (!certNo) return null;
  1383. let birthText = '';
  1384. let gender = '';
  1385. if (/^\d{15}$/u.test(certNo)) {
  1386. const year = Number(`19${certNo.slice(6, 8)}`);
  1387. const month = Number(certNo.slice(8, 10));
  1388. const day = Number(certNo.slice(10, 12));
  1389. if (!isValidDateParts(year, month, day)) return null;
  1390. birthText = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
  1391. gender = Number(certNo[14]) % 2 === 1 ? '男' : '女';
  1392. } else if (/^\d{17}[\dX]$/u.test(certNo)) {
  1393. const year = Number(certNo.slice(6, 10));
  1394. const month = Number(certNo.slice(10, 12));
  1395. const day = Number(certNo.slice(12, 14));
  1396. if (!isValidDateParts(year, month, day)) return null;
  1397. if (getPersonIdCardCheckCode(certNo.slice(0, 17)) !== certNo[17]) return null;
  1398. birthText = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
  1399. gender = Number(certNo[16]) % 2 === 1 ? '男' : '女';
  1400. } else {
  1401. return null;
  1402. }
  1403. const [yearText, monthText, dayText] = birthText.split('-');
  1404. const birthDate = new Date(Number(yearText), Number(monthText) - 1, Number(dayText));
  1405. if (Number.isNaN(birthDate.getTime())) return null;
  1406. const now = new Date();
  1407. let age = now.getFullYear() - birthDate.getFullYear();
  1408. const currentMonthDay = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
  1409. if (`${monthText}-${dayText}` > currentMonthDay) {
  1410. age -= 1;
  1411. }
  1412. age = Math.max(0, age);
  1413. return {
  1414. personBirth: birthText,
  1415. personGender: gender,
  1416. personAge: String(age),
  1417. };
  1418. };
  1419. const autofillPersonProfileByIdCard = (certNo?: string | null) => {
  1420. const parsed = parsePersonProfileFromIdCard(certNo);
  1421. if (!parsed) return;
  1422. personForm.personBirth = parsed.personBirth;
  1423. personForm.personGender = parsed.personGender;
  1424. personForm.personAge = parsed.personAge;
  1425. };
  1426. const resetPersonForm = () => {
  1427. revokeAvatarPreviewObjectUrl();
  1428. personForm.personName = '';
  1429. personForm.personCertNo = '';
  1430. personForm.personBirth = '';
  1431. personForm.personGender = '';
  1432. personForm.personAge = '';
  1433. personForm.personAddress = '';
  1434. updatePersonPhotograph('');
  1435. personForm.marry = 0;
  1436. personForm.nation = '';
  1437. personForm.nativePlace = '';
  1438. personForm.householdRegistered = '';
  1439. personForm.personTag = '';
  1440. personForm.phoneList = [''];
  1441. personForm.cardList = [''];
  1442. phoneListErrors.value = [];
  1443. cardListErrors.value = [];
  1444. personNameError.value = '';
  1445. };
  1446. const fillPersonForm = (person: PersonBasicInfo) => {
  1447. revokeAvatarPreviewObjectUrl();
  1448. personForm.personName = person.personName ?? '';
  1449. personForm.personCertNo = person.personCertNo ?? '';
  1450. personForm.personBirth = person.personBirth ?? '';
  1451. personForm.personGender = person.personGender ?? '';
  1452. personForm.personAge = person.personAge !== undefined && person.personAge !== null ? String(person.personAge) : '';
  1453. personForm.personAddress = person.personAddress ?? '';
  1454. updatePersonPhotograph(person.photograph);
  1455. personForm.marry = person.marry ?? 0;
  1456. personForm.nation = person.nation ?? '';
  1457. personForm.nativePlace = person.nativePlace ?? '';
  1458. personForm.householdRegistered = person.householdRegistered ?? '';
  1459. personForm.personTag = person.personTag ?? '';
  1460. const phoneArr = (person.phoneList || []).map((item) => item.personNo ?? '').filter(Boolean);
  1461. personForm.phoneList = phoneArr.length > 0 ? phoneArr : [''];
  1462. const cardArr = (person.cardList || []).map((item) => item.personNo ?? '').filter(Boolean);
  1463. personForm.cardList = cardArr.length > 0 ? cardArr : [''];
  1464. if (personForm.personCertNo && (!personForm.personBirth || !personForm.personGender || !personForm.personAge)) {
  1465. autofillPersonProfileByIdCard(personForm.personCertNo);
  1466. }
  1467. phoneListErrors.value = [];
  1468. cardListErrors.value = [];
  1469. personNameError.value = '';
  1470. };
  1471. watch(
  1472. () => personForm.personCertNo,
  1473. (value) => {
  1474. const certNo = normalizePersonIdCard(value);
  1475. if (!certNo) return;
  1476. autofillPersonProfileByIdCard(certNo);
  1477. },
  1478. );
  1479. const resetRightPanelData = () => {
  1480. callStatData.value = null;
  1481. transStatData.value = null;
  1482. commTypeRatioData.value = [];
  1483. commTop10Data.value = [];
  1484. stationData.value = [];
  1485. tradeTop10Data.value = [];
  1486. tradeTrendData.value = [];
  1487. };
  1488. const normalizeObjectList = <T extends Record<string, any>>(source: unknown): T[] => {
  1489. if (!Array.isArray(source)) return [];
  1490. return source.filter((item): item is T => !!item && typeof item === 'object');
  1491. };
  1492. const normalizeTrendRows = (resp: any): TransTrendDTO[] => {
  1493. if (Array.isArray(resp)) return normalizeObjectList<TransTrendDTO>(resp);
  1494. if (Array.isArray(resp?.records)) return normalizeObjectList<TransTrendDTO>(resp.records);
  1495. if (Array.isArray(resp?.data)) return normalizeObjectList<TransTrendDTO>(resp.data);
  1496. return [];
  1497. };
  1498. const loadTradeTrendData = async (personName: string) => {
  1499. const startDate = tradeTrendQuery.dateRange?.[0];
  1500. const endDate = tradeTrendQuery.dateRange?.[1];
  1501. const otherName = String(tradeTrendQuery.otherName || '').trim();
  1502. const payload = {
  1503. personName,
  1504. otherName: otherName || undefined,
  1505. startDate: startDate || undefined,
  1506. endDate: endDate || undefined,
  1507. } as any;
  1508. const resp =
  1509. tradeTrendQuery.timeDimension === 'YEAR' ? await transAmountTrendY(payload) : await transAmountTrendYM(payload);
  1510. return normalizeTrendRows(resp);
  1511. };
  1512. const normalizeStringList = (resp: any) => {
  1513. const candidates = [resp, resp?.records, resp?.list, resp?.data, resp?.data?.records, resp?.data?.list];
  1514. for (const item of candidates) {
  1515. if (Array.isArray(item)) {
  1516. return item.map((value) => String(value || '').trim()).filter(Boolean);
  1517. }
  1518. }
  1519. return [] as string[];
  1520. };
  1521. const setTradeTrendOtherNameOptions = (names: string[], replace = false) => {
  1522. const optionMap = new Map<string, { label: string; value: string }>();
  1523. const appendOption = (value: string) => {
  1524. const normalized = String(value || '').trim();
  1525. if (!normalized) return;
  1526. optionMap.set(normalized, { label: normalized, value: normalized });
  1527. };
  1528. if (!replace) {
  1529. tradeTrendOtherNameOptions.value.forEach((item) => appendOption(item.value));
  1530. }
  1531. names.forEach((item) => appendOption(item));
  1532. const currentValue = String(tradeTrendQuery.otherName || '').trim();
  1533. if (currentValue) {
  1534. appendOption(currentValue);
  1535. }
  1536. tradeTrendOtherNameOptions.value = Array.from(optionMap.values());
  1537. };
  1538. const resetTradeTrendOtherNameState = () => {
  1539. tradeTrendOtherNameRequestId += 1;
  1540. if (tradeTrendOtherNameSearchTimer !== null) {
  1541. window.clearTimeout(tradeTrendOtherNameSearchTimer);
  1542. tradeTrendOtherNameSearchTimer = null;
  1543. }
  1544. tradeTrendOtherNameOptions.value = [];
  1545. tradeTrendOtherNameSearch.value = '';
  1546. tradeTrendOtherNamePage.value = 1;
  1547. tradeTrendOtherNameHasMore.value = true;
  1548. tradeTrendOtherNameLoadedPerson.value = '';
  1549. tradeTrendOtherNameLoading.value = false;
  1550. };
  1551. const resetProfilePanelQueries = () => {
  1552. commTopQuery.dateRange = undefined;
  1553. tradeTopQuery.indicator = 'personName';
  1554. tradeTopQuery.dateRange = undefined;
  1555. tradeTopQuery.dimension = '交易金额';
  1556. tradeTrendQuery.otherName = '';
  1557. tradeTrendQuery.dateRange = undefined;
  1558. tradeTrendQuery.timeDimension = 'YEAR';
  1559. trendLegendArr.value = [
  1560. { isShow: true, name: '收入', color: '#5ad8a6' },
  1561. { isShow: true, name: '支出', color: '#e8684a' },
  1562. ];
  1563. resetTradeTrendOtherNameState();
  1564. };
  1565. const loadTradeTrendOtherNameOptions = async (options?: { reset?: boolean; search?: string }) => {
  1566. const { reset = false, search = tradeTrendOtherNameSearch.value } = options || {};
  1567. const personName = selectedPerson.value?.personName?.trim();
  1568. if (!personName) {
  1569. resetTradeTrendOtherNameState();
  1570. return;
  1571. }
  1572. if (tradeTrendOtherNameLoading.value) return;
  1573. if (!reset && !tradeTrendOtherNameHasMore.value) return;
  1574. const page = reset ? 1 : tradeTrendOtherNamePage.value;
  1575. const currentRequestId = ++tradeTrendOtherNameRequestId;
  1576. tradeTrendOtherNameLoading.value = true;
  1577. try {
  1578. const resp = await getTransOtherName({
  1579. personName,
  1580. search: search || undefined,
  1581. page,
  1582. limit: 20,
  1583. } as any);
  1584. if (currentRequestId !== tradeTrendOtherNameRequestId) return;
  1585. const list = normalizeStringList(resp);
  1586. const total = Number((resp as any)?.total ?? (resp as any)?.data?.total ?? 0);
  1587. const prevSize = reset ? 0 : tradeTrendOtherNameOptions.value.length;
  1588. setTradeTrendOtherNameOptions(list, reset);
  1589. tradeTrendOtherNamePage.value = page + 1;
  1590. tradeTrendOtherNameHasMore.value =
  1591. Number.isFinite(total) && total > 0 ? tradeTrendOtherNameOptions.value.length < total : list.length >= 20;
  1592. if (!reset && tradeTrendOtherNameOptions.value.length <= prevSize) {
  1593. tradeTrendOtherNameHasMore.value = false;
  1594. }
  1595. tradeTrendOtherNameLoadedPerson.value = personName;
  1596. } catch (error) {
  1597. if (currentRequestId !== tradeTrendOtherNameRequestId) return;
  1598. console.error('获取交易趋势对方名称失败', error);
  1599. if (reset) {
  1600. setTradeTrendOtherNameOptions([], true);
  1601. }
  1602. tradeTrendOtherNameHasMore.value = false;
  1603. } finally {
  1604. if (currentRequestId === tradeTrendOtherNameRequestId) {
  1605. tradeTrendOtherNameLoading.value = false;
  1606. }
  1607. }
  1608. };
  1609. const normalizeCallTopRows = (resp: any): CallOftenTop10DTO[] => {
  1610. if (Array.isArray(resp)) return normalizeObjectList<CallOftenTop10DTO>(resp).slice(0, 10);
  1611. if (Array.isArray(resp?.records)) return normalizeObjectList<CallOftenTop10DTO>(resp.records).slice(0, 10);
  1612. if (Array.isArray(resp?.data)) return normalizeObjectList<CallOftenTop10DTO>(resp.data).slice(0, 10);
  1613. return [];
  1614. };
  1615. const buildCallTopRowsFromRecords = (records: any[]): CallOftenTop10DTO[] => {
  1616. const counter = new Map<string, { otherName?: string; otherPhone?: string; callCount: number }>();
  1617. records.forEach((item) => {
  1618. const otherPhone = item?.otherPhone || '';
  1619. const otherName = item?.otherName || '';
  1620. const key = `${otherPhone}__${otherName}`;
  1621. const current = counter.get(key) || {
  1622. otherName,
  1623. otherPhone,
  1624. callCount: 0,
  1625. };
  1626. current.callCount += 1;
  1627. counter.set(key, current);
  1628. });
  1629. return Array.from(counter.values())
  1630. .sort((a, b) => b.callCount - a.callCount)
  1631. .slice(0, 10)
  1632. .map((item) => ({
  1633. otherName: item.otherName,
  1634. otherPhone: item.otherPhone,
  1635. callCount: item.callCount,
  1636. }));
  1637. };
  1638. const loadCommTopData = async (personName: string) => {
  1639. const startDate = commTopQuery.dateRange?.[0];
  1640. const endDate = commTopQuery.dateRange?.[1];
  1641. if (!startDate && !endDate) {
  1642. const resp = await callOftenTop10({ personName });
  1643. return normalizeCallTopRows(resp);
  1644. }
  1645. const resp = (await getCallRecord({
  1646. page: 1,
  1647. limit: 50000,
  1648. personName,
  1649. personPhones: getSelectedPersonPhones(),
  1650. startDate: startDate || undefined,
  1651. endDate: endDate || undefined,
  1652. } as any)) as any;
  1653. const records = Array.isArray(resp?.records)
  1654. ? resp.records
  1655. : Array.isArray(resp?.data)
  1656. ? resp.data
  1657. : Array.isArray(resp)
  1658. ? resp
  1659. : [];
  1660. return buildCallTopRowsFromRecords(records);
  1661. };
  1662. const normalizeTradeTopRows = (resp: any): TransOftenTop10DTO[] => {
  1663. if (Array.isArray(resp)) return normalizeObjectList<TransOftenTop10DTO>(resp);
  1664. if (Array.isArray(resp?.records)) return normalizeObjectList<TransOftenTop10DTO>(resp.records);
  1665. if (Array.isArray(resp?.data)) return normalizeObjectList<TransOftenTop10DTO>(resp.data);
  1666. return [];
  1667. };
  1668. const loadTradeTopData = async (personName: string) => {
  1669. const startDate = tradeTopQuery.dateRange?.[0];
  1670. const endDate = tradeTopQuery.dateRange?.[1];
  1671. const resp = await transOfPersons({
  1672. personName,
  1673. dimension: tradeTopQuery.dimension,
  1674. indicator: tradeTopQuery.indicator,
  1675. startDate: startDate || undefined,
  1676. endDate: endDate || undefined,
  1677. page: 1,
  1678. limit: 10,
  1679. });
  1680. return normalizeTradeTopRows(resp);
  1681. };
  1682. const buildCommTypeRatioData = (callStatInfo?: CallDataProfileStatDTO | null) => {
  1683. const callTypeColorMap: Record<string, string> = {
  1684. 主叫: '#5b8ff9',
  1685. 被叫: '#5ad8a6',
  1686. 其他: '#f6bd16',
  1687. };
  1688. const callTypeOrder = ['被叫', '主叫', '其他'];
  1689. const ratioEntries = Object.entries(callStatInfo?.callRatioMap || {}).sort(([nameA], [nameB]) => {
  1690. const indexA = callTypeOrder.indexOf(nameA);
  1691. const indexB = callTypeOrder.indexOf(nameB);
  1692. return (indexA === -1 ? 99 : indexA) - (indexB === -1 ? 99 : indexB);
  1693. });
  1694. if (ratioEntries.length) {
  1695. const normalizedRatioEntries = ratioEntries
  1696. .map(([name, value]) => {
  1697. const rawValue = typeof value === 'string' ? value.replace('%', '') : value;
  1698. const ratioValue = Number(rawValue || 0);
  1699. return {
  1700. name,
  1701. value: Number.isFinite(ratioValue) ? ratioValue : 0,
  1702. itemStyle: {
  1703. color: callTypeColorMap[name] || '#5b8ff9',
  1704. },
  1705. };
  1706. })
  1707. .filter((item) => String(item.name || '').trim() && item.value > 0);
  1708. if (normalizedRatioEntries.length) {
  1709. return normalizedRatioEntries;
  1710. }
  1711. }
  1712. const countEntries = [
  1713. { name: '被叫', count: Number(callStatInfo?.inCount || 0) },
  1714. { name: '主叫', count: Number(callStatInfo?.outCount || 0) },
  1715. { name: '其他', count: Number(callStatInfo?.otherCount || 0) },
  1716. ];
  1717. const total = countEntries.reduce((sum, item) => sum + (Number.isFinite(item.count) ? item.count : 0), 0);
  1718. if (!total) return [];
  1719. return countEntries.map(({ name, count }) => ({
  1720. name,
  1721. value: Number((((Number.isFinite(count) ? count : 0) / total) * 100).toFixed(2)),
  1722. itemStyle: {
  1723. color: callTypeColorMap[name] || '#5b8ff9',
  1724. },
  1725. }));
  1726. };
  1727. const normalizeStationList = (resp: any): CallStatStationDTO[] => {
  1728. if (Array.isArray(resp)) return normalizeObjectList<CallStatStationDTO>(resp);
  1729. if (Array.isArray(resp?.data)) return normalizeObjectList<CallStatStationDTO>(resp.data);
  1730. if (Array.isArray(resp?.list)) return normalizeObjectList<CallStatStationDTO>(resp.list);
  1731. if (Array.isArray(resp?.records)) return normalizeObjectList<CallStatStationDTO>(resp.records);
  1732. if (Array.isArray(resp?.data?.list)) return normalizeObjectList<CallStatStationDTO>(resp.data.list);
  1733. if (Array.isArray(resp?.data?.records)) return normalizeObjectList<CallStatStationDTO>(resp.data.records);
  1734. return [];
  1735. };
  1736. const fetchProfileData = async () => {
  1737. const personName = selectedPerson.value?.personName;
  1738. if (!personName) {
  1739. resetRightPanelData();
  1740. return;
  1741. }
  1742. loadingProfile.value = true;
  1743. try {
  1744. const [callStatResp, transStatResp, callTop10Resp, callStationResp, transTop10Resp, transTrendResp] =
  1745. await Promise.all([
  1746. callStat(personName),
  1747. transStat(personName),
  1748. loadCommTopData(personName),
  1749. callStatStation({ personName }),
  1750. loadTradeTopData(personName),
  1751. loadTradeTrendData(personName),
  1752. ]);
  1753. callStatData.value = callStatResp || null;
  1754. transStatData.value = transStatResp || null;
  1755. commTypeRatioData.value = buildCommTypeRatioData(callStatResp);
  1756. commTop10Data.value = normalizeCallTopRows(callTop10Resp);
  1757. stationData.value = normalizeStationList(callStationResp);
  1758. tradeTop10Data.value = normalizeTradeTopRows(transTop10Resp);
  1759. tradeTrendData.value = normalizeTrendRows(transTrendResp);
  1760. } catch (error) {
  1761. console.error('获取数据画像右侧数据失败', error);
  1762. resetRightPanelData();
  1763. } finally {
  1764. loadingProfile.value = false;
  1765. }
  1766. };
  1767. function selectPerson(id: number) {
  1768. if (selectedPersonId.value === id) return;
  1769. resetProfilePanelQueries();
  1770. selectedPersonId.value = id;
  1771. fetchProfileData();
  1772. }
  1773. const findPortraitPersonIndex = () => {
  1774. const routePersonName = String(route.query.personName || '').trim();
  1775. const routePersonCertNo = String(route.query.personCertNo || '').trim();
  1776. if (!routePersonName && !routePersonCertNo) return -1;
  1777. return personList.value.findIndex((person) => {
  1778. const currentName = String(person.personName || '').trim();
  1779. const currentCertNo = String(person.personCertNo || '').trim();
  1780. if (routePersonCertNo && currentCertNo && currentCertNo === routePersonCertNo) return true;
  1781. if (routePersonName && currentName && currentName === routePersonName) return true;
  1782. return false;
  1783. });
  1784. };
  1785. const syncSelectedPersonFromRoute = async () => {
  1786. const targetIndex = findPortraitPersonIndex();
  1787. if (targetIndex < 0) return false;
  1788. if (selectedPersonId.value === targetIndex) return true;
  1789. resetProfilePanelQueries();
  1790. selectedPersonId.value = targetIndex;
  1791. await fetchProfileData();
  1792. return true;
  1793. };
  1794. const fetchPersons = async () => {
  1795. try {
  1796. const data = await getPersons();
  1797. personList.value = Array.isArray(data) ? data : [];
  1798. if (!personList.value.length) {
  1799. selectedPersonId.value = null;
  1800. return;
  1801. }
  1802. if (await syncSelectedPersonFromRoute()) {
  1803. return;
  1804. }
  1805. if (selectedPersonId.value === null) {
  1806. resetProfilePanelQueries();
  1807. selectedPersonId.value = 0;
  1808. await fetchProfileData();
  1809. }
  1810. } catch (error) {
  1811. console.error('获取人员信息失败', error);
  1812. personList.value = [];
  1813. }
  1814. };
  1815. const openCreateModal = () => {
  1816. isEditMode.value = false;
  1817. editingPerson.value = null;
  1818. resetPersonForm();
  1819. personModalVisible.value = true;
  1820. };
  1821. const revokeAvatarPreviewObjectUrl = () => {
  1822. if (!avatarPreviewObjectUrl.value) return;
  1823. URL.revokeObjectURL(avatarPreviewObjectUrl.value);
  1824. avatarPreviewObjectUrl.value = '';
  1825. };
  1826. const updatePersonPhotograph = (source?: string | null) => {
  1827. personForm.photograph = String(source || '').trim();
  1828. };
  1829. const personAvatarPreviewSrc = computed(
  1830. () => avatarPreviewObjectUrl.value || String(personForm.photograph || '').trim(),
  1831. );
  1832. const stripLocalFileProtocol = (value: string) => {
  1833. const source = String(value || '').trim();
  1834. if (!/^file:\/\//i.test(source)) return source;
  1835. const decoded = decodeURI(source.replace(/^file:\/\//i, ''));
  1836. if (/^\/[a-zA-Z]:/.test(decoded)) {
  1837. return decoded.slice(1);
  1838. }
  1839. return decoded;
  1840. };
  1841. const resolveLocalAvatarPath = (file: File) => {
  1842. const electronApi = window.electron;
  1843. const localPath =
  1844. electronApi?.getPathForFile?.(file) || String((file as File & { path?: string }).path || '').trim();
  1845. if (localPath) {
  1846. return stripLocalFileProtocol(localPath);
  1847. }
  1848. return '';
  1849. };
  1850. const updateAvatarPreviewFromFile = (file: File) => {
  1851. revokeAvatarPreviewObjectUrl();
  1852. avatarPreviewObjectUrl.value = URL.createObjectURL(file);
  1853. };
  1854. const openAvatarFileDialog = () => {
  1855. avatarInputRef.value?.click();
  1856. };
  1857. const clearAvatarSelection = () => {
  1858. revokeAvatarPreviewObjectUrl();
  1859. updatePersonPhotograph('');
  1860. if (avatarInputRef.value) {
  1861. avatarInputRef.value.value = '';
  1862. }
  1863. };
  1864. const handleAvatarFileChange = (event: Event) => {
  1865. const input = event.target as HTMLInputElement | null;
  1866. const file = input?.files?.[0];
  1867. if (!file) return;
  1868. if (file.type && !file.type.startsWith('image/')) {
  1869. createMessage.warning('请选择图片文件');
  1870. input.value = '';
  1871. return;
  1872. }
  1873. updateAvatarPreviewFromFile(file);
  1874. updatePersonPhotograph(resolveLocalAvatarPath(file) || avatarPreviewObjectUrl.value);
  1875. input.value = '';
  1876. };
  1877. const openEditModal = (person: PersonBasicInfo) => {
  1878. isEditMode.value = true;
  1879. editingPerson.value = person;
  1880. fillPersonForm(person);
  1881. personModalVisible.value = true;
  1882. };
  1883. const handleEditBasicInfo = () => {
  1884. if (!selectedPerson.value) {
  1885. createMessage.warning('请先选择对象');
  1886. return;
  1887. }
  1888. openEditModal(selectedPerson.value);
  1889. };
  1890. interface JumpToAnalysisPayload {
  1891. personName: string;
  1892. otherName: string;
  1893. behaviorType: string;
  1894. detailRecord: Record<string, unknown>;
  1895. path: string;
  1896. query?: Record<string, string>;
  1897. }
  1898. const handleJumpToAnalysis = async (payload: JumpToAnalysisPayload) => {
  1899. if (!payload.path) {
  1900. createMessage.warning('当前行为暂未配置分析页跳转。');
  1901. return;
  1902. }
  1903. const query: Record<string, string> = {
  1904. ...(payload.query || {}),
  1905. };
  1906. const personName = String(payload.personName || '').trim();
  1907. const otherName = String(payload.otherName || '').trim();
  1908. if (personName) {
  1909. query.personName = personName;
  1910. if (!query.personNames) {
  1911. query.personNames = personName;
  1912. }
  1913. }
  1914. if (otherName) {
  1915. query.otherName = otherName;
  1916. }
  1917. query.jumpAt = String(Date.now());
  1918. createMessage.info(`正在跳转至${payload.behaviorType}分析页面...`);
  1919. await router.push({
  1920. path: payload.path,
  1921. query,
  1922. });
  1923. };
  1924. const closePersonModal = () => {
  1925. personModalVisible.value = false;
  1926. };
  1927. const loadGroupData = async () => {
  1928. try {
  1929. const groups = await listPersonGroups();
  1930. objectGroups.value = (groups || []).map((item) => ({
  1931. id: item.id || '',
  1932. name: item.groupName || `分组${item.id}`,
  1933. remark: item.remark,
  1934. }));
  1935. if (objectGroups.value.length) {
  1936. const currentExists = objectGroups.value.some((item) => item.id === activeGroupId.value);
  1937. activeGroupId.value = currentExists ? activeGroupId.value : objectGroups.value[0].id;
  1938. } else {
  1939. activeGroupId.value = null;
  1940. }
  1941. groupMembers.value = (groups || []).flatMap((group) =>
  1942. (group.personList || []).map((rel) => ({
  1943. id: rel.id || '',
  1944. groupId: rel.groupId || group.id || '',
  1945. personName: rel.personName || '-',
  1946. hasMain: rel.hasMain,
  1947. })),
  1948. );
  1949. } catch (error) {
  1950. console.error('获取分组列表失败', error);
  1951. createMessage.error('获取分组列表失败');
  1952. }
  1953. };
  1954. const openGroupModal = async () => {
  1955. groupModalVisible.value = true;
  1956. await loadGroupData();
  1957. };
  1958. const openGroupModalFromRoute = async () => {
  1959. if (route.query.openGroupModal !== '1') return;
  1960. await openGroupModal();
  1961. if (route.query.openCreateGroupModal === '1') {
  1962. await nextTick();
  1963. openCreateGroupModal();
  1964. }
  1965. const { openGroupModal: _openGroupModal, openCreateGroupModal: _openCreateGroupModal, ...query } = route.query;
  1966. await router.replace({ path: route.path, query });
  1967. };
  1968. const closeGroupModal = () => {
  1969. groupModalVisible.value = false;
  1970. };
  1971. const resolvePageTotal = (resp: any, records: unknown[] = []) => {
  1972. const total = Number(resp?.total ?? resp?.count ?? resp?.page?.total ?? resp?.page?.count);
  1973. if (Number.isFinite(total) && total >= 0) {
  1974. return total;
  1975. }
  1976. return records.length;
  1977. };
  1978. const loadCallSummary = async () => {
  1979. const person = selectedPerson.value;
  1980. if (!person?.personName) {
  1981. callSummaryRows.value = [];
  1982. callSummaryPagination.total = 0;
  1983. return;
  1984. }
  1985. const personPhones = getSelectedPersonPhones(person);
  1986. callSummaryLoading.value = true;
  1987. try {
  1988. const resp = await getCallRecordSummary(
  1989. Object.assign(callSummaryQuery, {
  1990. page: callSummaryPagination.page,
  1991. limit: callSummaryPagination.limit,
  1992. personName: person.personName,
  1993. personPhones,
  1994. keyword: callSummaryQuery.keyword || undefined,
  1995. otherName: callSummaryQuery.otherName || undefined,
  1996. otherPhones: callSummaryQuery.otherPhone ? [callSummaryQuery.otherPhone] : undefined,
  1997. callDirection: callSummaryQuery.callDirection || undefined,
  1998. callType: callSummaryQuery.callType || undefined,
  1999. startDate: callSummaryQuery.startDate || undefined,
  2000. endDate: callSummaryQuery.endDate || undefined,
  2001. orderKey: callSummaryQuery.orderKey || undefined,
  2002. sort: callSummaryQuery.sort || undefined,
  2003. }) as any,
  2004. );
  2005. const records = Array.isArray(resp?.records) ? resp.records : Array.isArray(resp) ? resp : [];
  2006. callSummaryRows.value = records.map((item: any, index: number) => ({
  2007. rowKey: `${item.personName || ''}-${item.personPhone || ''}-${item.otherPhone || ''}-${index}`,
  2008. personName: fallbackValue(item.personName || person.personName),
  2009. personPhone: fallbackValue(item.personPhone),
  2010. otherName: fallbackValue(item.otherName),
  2011. otherPhone: fallbackValue(item.otherPhone),
  2012. callCount: fallbackValue(item.callCount),
  2013. }));
  2014. callSummaryPagination.total = resolvePageTotal(resp, records);
  2015. } catch (error) {
  2016. console.error('查询通话记录列表失败', error);
  2017. callSummaryRows.value = [];
  2018. callSummaryPagination.total = 0;
  2019. } finally {
  2020. callSummaryLoading.value = false;
  2021. }
  2022. };
  2023. const loadCallOperPhoneInfo = async () => {
  2024. const person = selectedPerson.value;
  2025. if (!person?.personName || !callOperPhoneQuery.phoneList.length) {
  2026. callOperPhoneAllRows.value = [];
  2027. callOperPhoneRows.value = [];
  2028. callOperPhonePagination.total = 0;
  2029. return;
  2030. }
  2031. callOperPhoneLoading.value = true;
  2032. try {
  2033. const resp = await getCallOperPhoneInfo({
  2034. personName: person.personName,
  2035. phoneList: callOperPhoneQuery.phoneList.length ? callOperPhoneQuery.phoneList : undefined,
  2036. operatorId: callOperPhoneQuery.operatorId || undefined,
  2037. } as CallOperPhoneInfoQuery);
  2038. const rawRecords = Array.isArray((resp as any)?.records)
  2039. ? (resp as any).records
  2040. : Array.isArray(resp)
  2041. ? resp
  2042. : [];
  2043. callOperPhoneAllRows.value = rawRecords.map((item: CallOpenInfo, index: number) => ({
  2044. rowKey: `${item.personName || person.personName || ''}-${item.operatorId || ''}-${item.personPhone || ''}-${index}`,
  2045. personName: item.personName || person.personName || '-',
  2046. personPhone: fallbackValue(item.personPhone),
  2047. phoneNumber: fallbackValue(item.personPhone),
  2048. phoneType: fallbackValue(item.phoneType),
  2049. operatorId: fallbackValue(item.operatorId),
  2050. operatorName: fallbackValue(item.operatorId),
  2051. personType: fallbackValue(item.personType),
  2052. personCertNo: fallbackValue(item.personCertNo),
  2053. personCertAddress: fallbackValue(item.personCertAddress),
  2054. mailingAddress: fallbackValue(item.mailingAddress),
  2055. province: fallbackValue(item.province),
  2056. city: fallbackValue(item.city),
  2057. phoneStatus: fallbackValue(item.phoneStatus),
  2058. activationDateTime: fallbackValue(item.activationDateTime),
  2059. closingDateTime: fallbackValue(item.closingDateTime),
  2060. firstCallDateTime: fallbackValue(item.firstCallDateTime),
  2061. lastSuspensionDateTime: fallbackValue(item.lastSuspensionDateTime),
  2062. }));
  2063. applyCallOperPhoneRows();
  2064. } catch (error) {
  2065. console.error('查询运营商手机号信息失败', error);
  2066. callOperPhoneAllRows.value = [];
  2067. callOperPhoneRows.value = [];
  2068. callOperPhonePagination.total = 0;
  2069. } finally {
  2070. callOperPhoneLoading.value = false;
  2071. }
  2072. };
  2073. const applyCallOperPhoneRows = () => {
  2074. const personNameKeyword = callOperPhoneQuery.personName.trim().toLocaleLowerCase();
  2075. const operatorNameKeyword = callOperPhoneQuery.operatorName.trim().toLocaleLowerCase();
  2076. const phoneNumberKeyword = callOperPhoneQuery.phoneNumber.trim().toLocaleLowerCase();
  2077. const filteredRows = callOperPhoneAllRows.value.filter((item) => {
  2078. const matchPersonName =
  2079. !personNameKeyword ||
  2080. String(item.personName || '')
  2081. .toLocaleLowerCase()
  2082. .includes(personNameKeyword);
  2083. const matchOperatorName =
  2084. !operatorNameKeyword ||
  2085. String(item.operatorName || '')
  2086. .toLocaleLowerCase()
  2087. .includes(operatorNameKeyword);
  2088. const matchPhoneNumber =
  2089. !phoneNumberKeyword ||
  2090. String(item.phoneNumber || '')
  2091. .toLocaleLowerCase()
  2092. .includes(phoneNumberKeyword);
  2093. return matchPersonName && matchOperatorName && matchPhoneNumber;
  2094. });
  2095. const sortedRows = [...filteredRows];
  2096. if (callOperPhoneQuery.orderKey) {
  2097. const sortFactor = callOperPhoneQuery.sort === 'ASC' ? 1 : -1;
  2098. sortedRows.sort((left, right) => {
  2099. const leftValue = String(left?.[callOperPhoneQuery.orderKey as keyof typeof left] || '');
  2100. const rightValue = String(right?.[callOperPhoneQuery.orderKey as keyof typeof right] || '');
  2101. return leftValue.localeCompare(rightValue, 'zh-CN', { numeric: true }) * sortFactor;
  2102. });
  2103. }
  2104. callOperPhonePagination.total = sortedRows.length;
  2105. const start = (callOperPhonePagination.page - 1) * callOperPhonePagination.limit;
  2106. const end = start + callOperPhonePagination.limit;
  2107. callOperPhoneRows.value = sortedRows.slice(start, end);
  2108. };
  2109. const loadTransBankCardInfo = async () => {
  2110. if (!transBankCardQuery.bankName || !transBankCardQuery.cardNoList.length) {
  2111. transBankCardRows.value = [];
  2112. transBankCardPagination.total = 0;
  2113. return;
  2114. }
  2115. transBankCardLoading.value = true;
  2116. try {
  2117. const resp = await getTransBankCardInfo({
  2118. page: transBankCardPagination.page,
  2119. limit: transBankCardPagination.limit,
  2120. keyword: transBankCardQuery.keyword || undefined,
  2121. orderKey: transBankCardQuery.orderKey || undefined,
  2122. sort: transBankCardQuery.sort || undefined,
  2123. bankName: transBankCardQuery.bankName || undefined,
  2124. cardNoList: transBankCardQuery.cardNoList.length ? transBankCardQuery.cardNoList : undefined,
  2125. } as TransBankCardInfoQuery);
  2126. const rawRecords = Array.isArray((resp as any)?.records)
  2127. ? (resp as any).records
  2128. : Array.isArray(resp)
  2129. ? resp
  2130. : [];
  2131. const records = Array.isArray((resp as any)?.records)
  2132. ? rawRecords
  2133. : rawRecords.slice(
  2134. (transBankCardPagination.page - 1) * transBankCardPagination.limit,
  2135. transBankCardPagination.page * transBankCardPagination.limit,
  2136. );
  2137. transBankCardRows.value = records.map((item: TransBankCardInfoDTO, index: number) => ({
  2138. rowKey: `${item.personCardNo || ''}-${index}`,
  2139. personCardNo: fallbackValue(item.personCardNo),
  2140. personName: fallbackValue(item.personName),
  2141. bankName: fallbackValue(item.bankName),
  2142. transIncome: fallbackValue(item.transIncome),
  2143. transExpend: fallbackValue(item.transExpend),
  2144. transBalance: fallbackValue(item.transBalance),
  2145. transCount: fallbackValue(item.transCount),
  2146. accountLeftMoney: fallbackValue(item.accountLeftMoney),
  2147. totalBalance: fallbackValue(item.totalBalance),
  2148. accountStatus: fallbackValue(item.accountStatus),
  2149. openAccountDate: fallbackValue(item.openAccountDate || item.openingDate),
  2150. openingDate: fallbackValue(item.openingDate || item.openAccountDate),
  2151. closeAccountDate: fallbackValue(item.closeAccountDate),
  2152. incomeTotalAmount: fallbackValue(item.incomeTotalAmount),
  2153. expenseTotalAmount: fallbackValue(item.expenseTotalAmount),
  2154. netAmount: fallbackValue(item.netAmount),
  2155. transTotalAmount: fallbackValue(item.transTotalAmount),
  2156. }));
  2157. transBankCardPagination.total = Number((resp as any)?.count || (resp as any)?.total || rawRecords.length || 0);
  2158. } catch (error) {
  2159. console.error('查询银行卡交易信息失败', error);
  2160. transBankCardRows.value = [];
  2161. transBankCardPagination.total = 0;
  2162. } finally {
  2163. transBankCardLoading.value = false;
  2164. }
  2165. };
  2166. const applyCallSummaryQuery = (query: Partial<CallSummaryQueryState> = {}) => {
  2167. Object.assign(callSummaryQuery, createCallSummaryQuery(), query);
  2168. };
  2169. const applyCallOperPhoneQuery = (query: Partial<CallOperPhoneQueryState> = {}) => {
  2170. Object.assign(callOperPhoneQuery, createCallOperPhoneQuery(), query);
  2171. };
  2172. const applyTransBankCardQuery = (query: Partial<TransBankCardQueryState> = {}) => {
  2173. Object.assign(transBankCardQuery, createTransBankCardQuery(), query);
  2174. };
  2175. const setCallSummaryBaseQuery = (query: Partial<CallSummaryQueryState> = {}) => {
  2176. callSummaryBaseQuery.value = {
  2177. ...createCallSummaryQuery(),
  2178. ...query,
  2179. };
  2180. };
  2181. const setCallOperPhoneBaseQuery = (query: Partial<CallOperPhoneQueryState> = {}) => {
  2182. callOperPhoneBaseQuery.value = {
  2183. ...createCallOperPhoneQuery(),
  2184. ...query,
  2185. };
  2186. };
  2187. const setTransBankCardBaseQuery = (query: Partial<TransBankCardQueryState> = {}) => {
  2188. transBankCardBaseQuery.value = {
  2189. ...createTransBankCardQuery(),
  2190. ...query,
  2191. };
  2192. };
  2193. const resetCallSummaryFilters = () => {
  2194. applyCallSummaryQuery(callSummaryBaseQuery.value);
  2195. };
  2196. const resetCallOperPhoneFilters = () => {
  2197. applyCallOperPhoneQuery(callOperPhoneBaseQuery.value);
  2198. };
  2199. const resetTransBankCardFilters = () => {
  2200. applyTransBankCardQuery(transBankCardBaseQuery.value);
  2201. };
  2202. const getCommTopDateRangeQuery = (): Partial<CallSummaryQueryState> => {
  2203. return {
  2204. startDate: commTopQuery.dateRange?.[0] || '',
  2205. endDate: commTopQuery.dateRange?.[1] || '',
  2206. };
  2207. };
  2208. const openCallSummaryWithFilters = async (
  2209. overrides: Partial<CallSummaryQueryState> = {},
  2210. options?: { useCommTopDateRange?: boolean },
  2211. ) => {
  2212. const nextBaseQuery: Partial<CallSummaryQueryState> = options?.useCommTopDateRange
  2213. ? getCommTopDateRangeQuery()
  2214. : {};
  2215. Object.assign(nextBaseQuery, overrides);
  2216. setCallSummaryBaseQuery(nextBaseQuery);
  2217. resetCallSummaryFilters();
  2218. callSummaryModalVisible.value = true;
  2219. callSummaryPagination.page = 1;
  2220. await loadCallSummary();
  2221. };
  2222. const openCallOperPhoneModal = async (carrierInfo = carrierTags.value[0]) => {
  2223. const person = selectedPerson.value;
  2224. if (!person?.personName || !carrierInfo.carrierName || !carrierInfo.phoneList.length) {
  2225. createMessage.warning('暂无运营商手机号数据');
  2226. return;
  2227. }
  2228. callOperPhoneModalTitle.value = `${carrierInfo.carrierName}手机号信息`;
  2229. setCallOperPhoneBaseQuery({
  2230. phoneList: carrierInfo.phoneList,
  2231. operatorId: carrierInfo.operatorId || '',
  2232. });
  2233. resetCallOperPhoneFilters();
  2234. callOperPhoneModalVisible.value = true;
  2235. callOperPhonePagination.page = 1;
  2236. await loadCallOperPhoneInfo();
  2237. };
  2238. const openTransBankCardModal = async (tag: { bankName: string; cardNoList: string[] }) => {
  2239. if (!tag.bankName || !tag.cardNoList.length) {
  2240. createMessage.warning('暂无银行卡数据');
  2241. return;
  2242. }
  2243. transBankCardModalTitle.value = '银行卡交易信息';
  2244. setTransBankCardBaseQuery({
  2245. bankName: tag.bankName,
  2246. cardNoList: tag.cardNoList,
  2247. });
  2248. resetTransBankCardFilters();
  2249. transBankCardModalVisible.value = true;
  2250. transBankCardPagination.page = 1;
  2251. await loadTransBankCardInfo();
  2252. };
  2253. const openCallRecordWithFilters = (
  2254. overrides: Partial<CallRecordQuery> = {},
  2255. options?: { useCommTopDateRange?: boolean; omitPersonPhones?: boolean },
  2256. ) => {
  2257. const person = selectedPerson.value;
  2258. if (!person?.personName) return;
  2259. callRecordBaseQuery.value = {
  2260. personName: person.personName,
  2261. ...(!options?.omitPersonPhones ? { personPhones: getSelectedPersonPhones(person) } : {}),
  2262. ...(options?.useCommTopDateRange ? getCommTopDateRangeQuery() : {}),
  2263. ...overrides,
  2264. };
  2265. callRecordModalVisible.value = true;
  2266. };
  2267. const toSummaryQueryText = (value?: string) => {
  2268. const text = String(value || '').trim();
  2269. return text && text !== '-' ? text : '';
  2270. };
  2271. const toSummaryNumericText = (value?: string | number) => {
  2272. const text = String(value ?? '')
  2273. .replace(/,/g, '')
  2274. .trim();
  2275. if (!text) return '';
  2276. const num = Number(text);
  2277. return Number.isFinite(num) ? text : '';
  2278. };
  2279. const handleViewCallSummaryDetail = (record: any) => {
  2280. const person = selectedPerson.value;
  2281. if (!person?.personName) {
  2282. createMessage.warning('请先选择对象');
  2283. return;
  2284. }
  2285. const personPhone = toSummaryQueryText(record?.personPhone);
  2286. const otherName = toSummaryQueryText(record?.otherName);
  2287. const otherPhone = toSummaryQueryText(record?.otherPhone);
  2288. openCallRecordWithFilters({
  2289. personPhones: personPhone ? [personPhone] : getSelectedPersonPhones(person),
  2290. otherName: otherName || undefined,
  2291. otherPhones: otherPhone ? [otherPhone] : undefined,
  2292. callDirection: callSummaryQuery.callDirection || undefined,
  2293. callType: callSummaryQuery.callType || undefined,
  2294. startDate: callSummaryQuery.startDate || undefined,
  2295. endDate: callSummaryQuery.endDate || undefined,
  2296. });
  2297. };
  2298. const handleViewCallOperPhoneDetail = (record: any) => {
  2299. const person = selectedPerson.value;
  2300. if (!person?.personName) {
  2301. createMessage.warning('请先选择对象');
  2302. return;
  2303. }
  2304. const personPhone = toSummaryQueryText(record?.personPhone || record?.phoneNumber);
  2305. if (!personPhone) {
  2306. createMessage.warning('当前手机号信息缺失,无法查看详情');
  2307. return;
  2308. }
  2309. const operatorId = toSummaryQueryText(record?.operatorId);
  2310. openCallRecordWithFilters({
  2311. personPhones: [personPhone],
  2312. operatorId: operatorId || undefined,
  2313. });
  2314. };
  2315. const handleViewTransSummaryDetail = (record: any) => {
  2316. const personName = selectedPerson.value?.personName;
  2317. if (!personName) {
  2318. createMessage.warning('请先选择对象');
  2319. return;
  2320. }
  2321. openTransRecordDrilldown({
  2322. personCardNo: toSummaryQueryText(record?.personCardNo) || undefined,
  2323. otherName: toSummaryQueryText(record?.otherName) || undefined,
  2324. otherCardNo: toSummaryQueryText(record?.otherCardNo) || undefined,
  2325. bankName: toSummaryQueryText(record?.bankName) || undefined,
  2326. });
  2327. };
  2328. const handleViewTransBankCardDetail = (record: any) => {
  2329. const personCardNo = toSummaryQueryText(record?.personCardNo);
  2330. if (!personCardNo) {
  2331. createMessage.warning('当前卡号信息缺失,无法查看详情');
  2332. return;
  2333. }
  2334. openTransRecordDrilldown({
  2335. personCardNo,
  2336. bankName: toSummaryQueryText(transBankCardQuery.bankName) || undefined,
  2337. });
  2338. };
  2339. const openCallSummaryModal = async () => {
  2340. await openCallSummaryWithFilters();
  2341. };
  2342. const loadTransSummary = async () => {
  2343. const person = selectedPerson.value;
  2344. if (!person?.personName) {
  2345. transSummaryRows.value = [];
  2346. transSummaryPagination.total = 0;
  2347. return;
  2348. }
  2349. transSummaryLoading.value = true;
  2350. try {
  2351. const transAmount = toSummaryNumericText(transSummaryQuery.transAmount);
  2352. const transCountText = toSummaryNumericText(transSummaryQuery.transCount);
  2353. const query: TransRecordSummaryQuery = {
  2354. page: transSummaryPagination.page,
  2355. limit: transSummaryPagination.limit,
  2356. personNames: person.personName ? [person.personName] : undefined,
  2357. personCardNo: toSummaryQueryText(transSummaryQuery.personCardNo) || undefined,
  2358. otherName: toSummaryQueryText(transSummaryQuery.otherName) || undefined,
  2359. transAmount: transAmount || undefined,
  2360. transCount: transCountText ? Number(transCountText) : undefined,
  2361. transAmountSymbol: transAmount
  2362. ? normalizeCompareOperator(transSummaryQuery.transAmountSymbol, DEFAULT_COMPARE_OPERATOR)
  2363. : undefined,
  2364. transCountSymbol: transCountText
  2365. ? normalizeCompareOperator(transSummaryQuery.transCountSymbol, DEFAULT_COMPARE_OPERATOR)
  2366. : undefined,
  2367. orderKey: transSummaryQuery.orderKey || undefined,
  2368. sort: transSummaryQuery.sort || undefined,
  2369. };
  2370. const resp = await getTransRecordSummary(query);
  2371. const records = Array.isArray(resp?.records) ? resp.records : Array.isArray(resp) ? resp : [];
  2372. transSummaryRows.value = records.map((item: TransRecordSummaryDTO, index: number) => ({
  2373. rowKey: `${item.personName || ''}-${item.otherCardNo || ''}-${index}`,
  2374. personCardNo: fallbackValue(item.personCardNo),
  2375. personName: fallbackValue(item.personName || person.personName),
  2376. otherCardNo: fallbackValue(item.otherCardNo),
  2377. otherName: fallbackValue(item.otherName),
  2378. bankName: fallbackValue(item.bankName),
  2379. transCount: fallbackValue(item.transCount),
  2380. transTotalAmount: fallbackValue(item.transTotalAmount),
  2381. incomeTotalAmount: fallbackValue(item.incomeTotalAmount),
  2382. expenseTotalAmount: fallbackValue(item.expenseTotalAmount),
  2383. cashKey: Array.isArray(item.cashKey) && item.cashKey.length ? item.cashKey.join('、') : '-',
  2384. }));
  2385. transSummaryPagination.total = resolvePageTotal(resp, records);
  2386. } catch (error) {
  2387. console.error('查询交易汇总失败', error);
  2388. transSummaryRows.value = [];
  2389. transSummaryPagination.total = 0;
  2390. } finally {
  2391. transSummaryLoading.value = false;
  2392. }
  2393. };
  2394. const applyTransSummaryQuery = (query: Partial<TransSummaryQueryState> = {}) => {
  2395. Object.assign(transSummaryQuery, createTransSummaryQuery(), {
  2396. ...query,
  2397. transAmountSymbol: normalizeCompareOperator(query.transAmountSymbol, DEFAULT_COMPARE_OPERATOR),
  2398. transCountSymbol: normalizeCompareOperator(query.transCountSymbol, DEFAULT_COMPARE_OPERATOR),
  2399. });
  2400. };
  2401. const setTransSummaryBaseQuery = (query: Partial<TransSummaryQueryState> = {}) => {
  2402. transSummaryBaseQuery.value = {
  2403. ...createTransSummaryQuery(),
  2404. ...query,
  2405. transAmountSymbol: normalizeCompareOperator(query.transAmountSymbol, DEFAULT_COMPARE_OPERATOR),
  2406. transCountSymbol: normalizeCompareOperator(query.transCountSymbol, DEFAULT_COMPARE_OPERATOR),
  2407. };
  2408. };
  2409. const resetTransSummaryFilters = () => {
  2410. applyTransSummaryQuery(transSummaryBaseQuery.value);
  2411. };
  2412. const openTransSummaryWithFilters = async (overrides: Partial<TransSummaryQueryState> = {}) => {
  2413. setTransSummaryBaseQuery(overrides);
  2414. resetTransSummaryFilters();
  2415. transSummaryModalVisible.value = true;
  2416. transSummaryPagination.page = 1;
  2417. await loadTransSummary();
  2418. };
  2419. const openTransSummaryModal = async () => {
  2420. await openTransSummaryWithFilters();
  2421. };
  2422. const handleSearchCallSummary = async () => {
  2423. callSummaryPagination.page = 1;
  2424. await loadCallSummary();
  2425. };
  2426. const handleSearchCallOperPhone = async () => {
  2427. callOperPhonePagination.page = 1;
  2428. applyCallOperPhoneRows();
  2429. };
  2430. const handleSearchTransBankCard = async () => {
  2431. transBankCardPagination.page = 1;
  2432. await loadTransBankCardInfo();
  2433. };
  2434. const handleSearchTransSummary = async () => {
  2435. transSummaryPagination.page = 1;
  2436. await loadTransSummary();
  2437. };
  2438. const handleResetCallSummary = async () => {
  2439. resetCallSummaryFilters();
  2440. callSummaryPagination.page = 1;
  2441. await loadCallSummary();
  2442. };
  2443. const handleResetCallOperPhone = async () => {
  2444. resetCallOperPhoneFilters();
  2445. callOperPhonePagination.page = 1;
  2446. applyCallOperPhoneRows();
  2447. };
  2448. const handleResetTransBankCard = async () => {
  2449. resetTransBankCardFilters();
  2450. transBankCardPagination.page = 1;
  2451. await loadTransBankCardInfo();
  2452. };
  2453. const handleResetTransSummary = async () => {
  2454. resetTransSummaryFilters();
  2455. transSummaryPagination.page = 1;
  2456. await loadTransSummary();
  2457. };
  2458. const handleCallSummaryPrevPage = async () => {
  2459. if (callSummaryPagination.page <= 1) return;
  2460. callSummaryPagination.page -= 1;
  2461. await loadCallSummary();
  2462. };
  2463. const handleCallSummaryNextPage = async () => {
  2464. if (callSummaryPagination.page >= callSummaryTotalPages.value) return;
  2465. callSummaryPagination.page += 1;
  2466. await loadCallSummary();
  2467. };
  2468. const handleCallSummaryPageChange = async (page: number, pageSize: number) => {
  2469. if (callSummaryLoading.value) return;
  2470. if (pageSize !== callSummaryPagination.limit) {
  2471. await handleCallSummaryPageSizeChange(pageSize);
  2472. return;
  2473. }
  2474. if (page === callSummaryPagination.page - 1) {
  2475. await handleCallSummaryPrevPage();
  2476. return;
  2477. }
  2478. if (page === callSummaryPagination.page + 1) {
  2479. await handleCallSummaryNextPage();
  2480. return;
  2481. }
  2482. if (page === callSummaryPagination.page) return;
  2483. callSummaryPagination.page = page;
  2484. await loadCallSummary();
  2485. };
  2486. const handleCallOperPhonePrevPage = async () => {
  2487. if (callOperPhonePagination.page <= 1) return;
  2488. callOperPhonePagination.page -= 1;
  2489. applyCallOperPhoneRows();
  2490. };
  2491. const handleCallOperPhoneNextPage = async () => {
  2492. if (callOperPhonePagination.page >= callOperPhoneTotalPages.value) return;
  2493. callOperPhonePagination.page += 1;
  2494. applyCallOperPhoneRows();
  2495. };
  2496. const handleCallOperPhonePageChange = async (page: number, pageSize: number) => {
  2497. if (callOperPhoneLoading.value) return;
  2498. if (pageSize !== callOperPhonePagination.limit) {
  2499. await handleCallOperPhonePageSizeChange(pageSize);
  2500. return;
  2501. }
  2502. if (page === callOperPhonePagination.page - 1) {
  2503. await handleCallOperPhonePrevPage();
  2504. return;
  2505. }
  2506. if (page === callOperPhonePagination.page + 1) {
  2507. await handleCallOperPhoneNextPage();
  2508. return;
  2509. }
  2510. if (page === callOperPhonePagination.page) return;
  2511. callOperPhonePagination.page = page;
  2512. applyCallOperPhoneRows();
  2513. };
  2514. const handleTransBankCardPrevPage = async () => {
  2515. if (transBankCardPagination.page <= 1) return;
  2516. transBankCardPagination.page -= 1;
  2517. await loadTransBankCardInfo();
  2518. };
  2519. const handleTransBankCardNextPage = async () => {
  2520. if (transBankCardPagination.page >= transBankCardTotalPages.value) return;
  2521. transBankCardPagination.page += 1;
  2522. await loadTransBankCardInfo();
  2523. };
  2524. const handleTransBankCardPageChange = async (page: number, pageSize: number) => {
  2525. if (transBankCardLoading.value) return;
  2526. if (pageSize !== transBankCardPagination.limit) {
  2527. await handleTransBankCardPageSizeChange(pageSize);
  2528. return;
  2529. }
  2530. if (page === transBankCardPagination.page - 1) {
  2531. await handleTransBankCardPrevPage();
  2532. return;
  2533. }
  2534. if (page === transBankCardPagination.page + 1) {
  2535. await handleTransBankCardNextPage();
  2536. return;
  2537. }
  2538. if (page === transBankCardPagination.page) return;
  2539. transBankCardPagination.page = page;
  2540. await loadTransBankCardInfo();
  2541. };
  2542. const handleCallSummaryPageSizeChange = async (value: number) => {
  2543. callSummaryPagination.limit = Number(value);
  2544. callSummaryPagination.page = 1;
  2545. await loadCallSummary();
  2546. };
  2547. const handleCallOperPhonePageSizeChange = async (value: number) => {
  2548. callOperPhonePagination.limit = Number(value);
  2549. callOperPhonePagination.page = 1;
  2550. applyCallOperPhoneRows();
  2551. };
  2552. const handleTransBankCardPageSizeChange = async (value: number) => {
  2553. transBankCardPagination.limit = Number(value);
  2554. transBankCardPagination.page = 1;
  2555. await loadTransBankCardInfo();
  2556. };
  2557. const handleSortCallSummary = async ({ orderKey, sort }: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
  2558. callSummaryQuery.orderKey = orderKey;
  2559. callSummaryQuery.sort = sort;
  2560. callSummaryPagination.page = 1;
  2561. await loadCallSummary();
  2562. };
  2563. const handleSortCallOperPhone = async ({ orderKey, sort }: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
  2564. callOperPhoneQuery.orderKey = orderKey;
  2565. callOperPhoneQuery.sort = sort;
  2566. callOperPhonePagination.page = 1;
  2567. applyCallOperPhoneRows();
  2568. };
  2569. const handleSortTransBankCard = async ({ orderKey, sort }: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
  2570. transBankCardQuery.orderKey = orderKey;
  2571. transBankCardQuery.sort = sort;
  2572. transBankCardPagination.page = 1;
  2573. await loadTransBankCardInfo();
  2574. };
  2575. const handleTransSummaryPrevPage = async () => {
  2576. if (transSummaryPagination.page <= 1) return;
  2577. transSummaryPagination.page -= 1;
  2578. await loadTransSummary();
  2579. };
  2580. const handleTransSummaryNextPage = async () => {
  2581. if (transSummaryPagination.page >= transSummaryTotalPages.value) return;
  2582. transSummaryPagination.page += 1;
  2583. await loadTransSummary();
  2584. };
  2585. const handleTransSummaryPageChange = async (page: number, pageSize: number) => {
  2586. if (transSummaryLoading.value) return;
  2587. if (pageSize !== transSummaryPagination.limit) {
  2588. await handleTransSummaryPageSizeChange(pageSize);
  2589. return;
  2590. }
  2591. if (page === transSummaryPagination.page - 1) {
  2592. await handleTransSummaryPrevPage();
  2593. return;
  2594. }
  2595. if (page === transSummaryPagination.page + 1) {
  2596. await handleTransSummaryNextPage();
  2597. return;
  2598. }
  2599. if (page === transSummaryPagination.page) return;
  2600. transSummaryPagination.page = page;
  2601. await loadTransSummary();
  2602. };
  2603. const handleTransSummaryPageSizeChange = async (value: number) => {
  2604. transSummaryPagination.limit = Number(value);
  2605. transSummaryPagination.page = 1;
  2606. await loadTransSummary();
  2607. };
  2608. const handleSortTransSummary = async ({ orderKey, sort }: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
  2609. transSummaryQuery.orderKey = orderKey;
  2610. transSummaryQuery.sort = sort;
  2611. transSummaryPagination.page = 1;
  2612. await loadTransSummary();
  2613. };
  2614. const openTransRecordDrilldown = (baseQuery: TransRecordDrilldownQuery) => {
  2615. const personName = selectedPerson.value?.personName;
  2616. if (!personName) {
  2617. createMessage.warning('请先选择对象');
  2618. return;
  2619. }
  2620. transRecordBaseQuery.value = {
  2621. personName,
  2622. ...baseQuery,
  2623. };
  2624. transRecordModalVisible.value = true;
  2625. };
  2626. const handleTransRecordJump = () => {
  2627. transRecordModalVisible.value = false;
  2628. transBankCardModalVisible.value = false;
  2629. transSummaryModalVisible.value = false;
  2630. };
  2631. const handleCallRecordJump = () => {
  2632. callRecordModalVisible.value = false;
  2633. callOperPhoneModalVisible.value = false;
  2634. callSummaryModalVisible.value = false;
  2635. };
  2636. const handleDrillByDebitCredit = (flag: '收入' | '支出') => {
  2637. openTransRecordDrilldown({
  2638. debitCreditFlag: flag,
  2639. });
  2640. };
  2641. const handleDrillByTradeTop = (item: { name: string; filterValue?: string }) => {
  2642. const filterValue = String(item?.filterValue || item?.name || '').trim();
  2643. const baseQuery: Partial<TransRecordQuery> = {
  2644. indicator: tradeTopQuery.indicator,
  2645. };
  2646. const startDate = tradeTopQuery.dateRange?.[0];
  2647. const endDate = tradeTopQuery.dateRange?.[1];
  2648. if (startDate && endDate) {
  2649. baseQuery.startDate = startDate;
  2650. baseQuery.endDate = endDate;
  2651. }
  2652. if (filterValue) {
  2653. if (tradeTopQuery.indicator === 'otherCardNo') {
  2654. baseQuery.otherCardNo = filterValue;
  2655. } else if (tradeTopQuery.indicator === 'personName') {
  2656. baseQuery.otherName = filterValue;
  2657. } else if (tradeTopQuery.indicator === 'branchName') {
  2658. baseQuery.branchName = filterValue;
  2659. } else if (tradeTopQuery.indicator === 'personIpAddress') {
  2660. baseQuery.personIpAddress = filterValue;
  2661. } else if (tradeTopQuery.indicator === 'personMacAddress') {
  2662. baseQuery.personMacAddress = filterValue;
  2663. }
  2664. }
  2665. openTransRecordDrilldown(baseQuery);
  2666. };
  2667. const handleSearchTradeTrend = async () => {
  2668. const personName = selectedPerson.value?.personName;
  2669. if (!personName) {
  2670. tradeTrendData.value = [];
  2671. return;
  2672. }
  2673. loadingProfile.value = true;
  2674. try {
  2675. tradeTrendData.value = await loadTradeTrendData(personName);
  2676. tradeTrendChart?.resize();
  2677. } catch (error) {
  2678. console.error('查询交易趋势失败', error);
  2679. tradeTrendData.value = [];
  2680. } finally {
  2681. loadingProfile.value = false;
  2682. }
  2683. };
  2684. const handleTradeTrendOtherNameSearch = (value: string) => {
  2685. tradeTrendOtherNameSearch.value = String(value || '').trim();
  2686. if (tradeTrendOtherNameSearchTimer !== null) {
  2687. window.clearTimeout(tradeTrendOtherNameSearchTimer);
  2688. }
  2689. tradeTrendOtherNameSearchTimer = window.setTimeout(() => {
  2690. tradeTrendOtherNameSearchTimer = null;
  2691. void loadTradeTrendOtherNameOptions({
  2692. reset: true,
  2693. search: tradeTrendOtherNameSearch.value,
  2694. });
  2695. }, 240);
  2696. };
  2697. const handleTradeTrendOtherNamePopupScroll = (event: UIEvent) => {
  2698. const target = event.target as HTMLElement | null;
  2699. if (!target || tradeTrendOtherNameLoading.value || !tradeTrendOtherNameHasMore.value) return;
  2700. if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) {
  2701. void loadTradeTrendOtherNameOptions();
  2702. }
  2703. };
  2704. const handleTradeTrendOtherNameDropdownVisibleChange = (open: boolean) => {
  2705. if (!open) return;
  2706. const personName = selectedPerson.value?.personName?.trim();
  2707. if (!personName) return;
  2708. if (tradeTrendOtherNameLoadedPerson.value !== personName || !tradeTrendOtherNameOptions.value.length) {
  2709. tradeTrendOtherNameSearch.value = '';
  2710. void loadTradeTrendOtherNameOptions({ reset: true, search: '' });
  2711. }
  2712. };
  2713. const scheduleTradeTrendSearch = (delay = 240) => {
  2714. if (tradeTrendSearchTimer !== null) {
  2715. window.clearTimeout(tradeTrendSearchTimer);
  2716. }
  2717. tradeTrendSearchTimer = window.setTimeout(() => {
  2718. tradeTrendSearchTimer = null;
  2719. void handleSearchTradeTrend();
  2720. }, delay);
  2721. };
  2722. const viewStationMap = () => {
  2723. if (!canViewStationMap.value) {
  2724. createMessage.warning('当前基站暂无可展示的坐标信息');
  2725. return;
  2726. }
  2727. stationMapModalVisible.value = true;
  2728. };
  2729. const changeTradeTrendDimension = (dimension: 'MONTH' | 'YEAR') => {
  2730. if (tradeTrendQuery.timeDimension === dimension) return;
  2731. trendLegendArr.value = [
  2732. { isShow: true, name: '收入', color: '#5ad8a6' },
  2733. { isShow: true, name: '支出', color: '#e8684a' },
  2734. ];
  2735. tradeTrendQuery.timeDimension = dimension;
  2736. };
  2737. const handleTrendChartResize = () => {
  2738. tradeTrendChart?.resize();
  2739. tradeRatioChart?.resize();
  2740. tradeTopChart?.resize();
  2741. commTopChart?.resize();
  2742. if (commTypeChartRef.value && commTypeRatioData.value.length) {
  2743. commTypeChart?.resize();
  2744. initCommTypeChart();
  2745. }
  2746. };
  2747. const handleSubmit = async () => {
  2748. console.error('[portrait] handleSubmit clicked');
  2749. (window as any).__portraitSaveDebug = {
  2750. stage: 'clicked',
  2751. form: { ...personForm },
  2752. at: new Date().toISOString(),
  2753. };
  2754. console.error('[portrait] form snapshot before validation:', { ...personForm });
  2755. // 校验姓名
  2756. if (!personForm.personName.trim()) {
  2757. console.error('[portrait] save blocked: empty personName');
  2758. personNameError.value = '请输入姓名';
  2759. createMessage.warning('请输入姓名');
  2760. return;
  2761. }
  2762. // const nameRegex = /^[\u4e00-\u9fa5a-zA-Z\s·]+$/;
  2763. // if (!nameRegex.test(personForm.personName.trim())) {
  2764. // console.error('[portrait] save blocked: invalid personName');
  2765. // personNameError.value = '姓名只能包含中文、字母或空格';
  2766. // createMessage.warning('姓名格式不正确');
  2767. // return;
  2768. // }
  2769. // if (personForm.personName.trim().length < 2) {
  2770. // console.error('[portrait] save blocked: short personName');
  2771. // personNameError.value = '姓名至少2个字符';
  2772. // createMessage.warning('姓名至少2个字符');
  2773. // return;
  2774. // }
  2775. personNameError.value = '';
  2776. // 校验手机号
  2777. const phoneErrors: string[] = [];
  2778. personForm.phoneList.forEach((phone, index) => {
  2779. if (phone.trim()) {
  2780. const phoneRegex = /^1[3-9]\d{9}$/;
  2781. if (!phoneRegex.test(phone.trim())) {
  2782. phoneErrors[index] = '手机号格式不正确';
  2783. }
  2784. }
  2785. });
  2786. phoneListErrors.value = phoneErrors;
  2787. // 校验银行卡
  2788. const cardErrors: string[] = [];
  2789. personForm.cardList.forEach((card, index) => {
  2790. if (card.trim()) {
  2791. if (card.trim().length < 16 || card.trim().length > 19) {
  2792. cardErrors[index] = '银行卡号长度应为16-19位';
  2793. }
  2794. }
  2795. });
  2796. cardListErrors.value = cardErrors;
  2797. if (phoneErrors.some(Boolean) || cardErrors.some(Boolean)) {
  2798. console.error('[portrait] save blocked: phone/card validation failed', { phoneErrors, cardErrors });
  2799. createMessage.warning('请检查输入格式');
  2800. return;
  2801. }
  2802. saving.value = true;
  2803. try {
  2804. const uploadedPhotograph = String(personForm.photograph || '').trim();
  2805. updatePersonPhotograph(uploadedPhotograph);
  2806. const payload: PersonBasicInfoDTO = {
  2807. personName: personForm.personName,
  2808. personCertNo: personForm.personCertNo,
  2809. personBirth: personForm.personBirth,
  2810. personGender: personForm.personGender,
  2811. personAge: personForm.personAge ? Number(personForm.personAge) : 0,
  2812. personAddress: personForm.personAddress,
  2813. photograph: uploadedPhotograph,
  2814. marry: personForm.marry,
  2815. nation: personForm.nation,
  2816. nativePlace: personForm.nativePlace,
  2817. householdRegistered: personForm.householdRegistered,
  2818. personTag: personForm.personTag === '' ? null : personForm.personTag,
  2819. phoneList: toLibNoList(personForm.phoneList.filter((p) => p.trim()).join(','), 2),
  2820. cardList: toLibNoList(personForm.cardList.filter((c) => c.trim()).join(','), 3),
  2821. };
  2822. (window as any).__portraitSaveDebug = {
  2823. stage: 'payload-ready',
  2824. payload: JSON.parse(JSON.stringify(payload)),
  2825. at: new Date().toISOString(),
  2826. };
  2827. console.error('[portrait] save person payload:', JSON.parse(JSON.stringify(payload)));
  2828. if (isEditMode.value) {
  2829. await updatePersonBasicInfo(payload);
  2830. createMessage.success('更新成功');
  2831. } else {
  2832. await createPersonBasicInfo(payload);
  2833. createMessage.success('新增成功');
  2834. }
  2835. personModalVisible.value = false;
  2836. await fetchPersons();
  2837. } catch (error: any) {
  2838. console.error('[portrait] avatar upload/save error detail:', error?.response?.data || error?.message || error);
  2839. console.error('[portrait] save debug snapshot:', (window as any).__portraitSaveDebug);
  2840. console.error('保存人员失败', error);
  2841. createMessage.error(isEditMode.value ? '更新失败' : '新增失败');
  2842. } finally {
  2843. saving.value = false;
  2844. }
  2845. };
  2846. // 姓名校验方法
  2847. const validatePersonName = () => {
  2848. if (!personForm.personName.trim()) {
  2849. personNameError.value = '请输入姓名';
  2850. return;
  2851. }
  2852. const nameRegex = /^[\u4e00-\u9fa5a-zA-Z\s·]+$/;
  2853. if (!nameRegex.test(personForm.personName.trim())) {
  2854. personNameError.value = '姓名只能包含中文、字母或空格';
  2855. return;
  2856. }
  2857. if (personForm.personName.trim().length < 2) {
  2858. personNameError.value = '姓名至少2个字符';
  2859. return;
  2860. }
  2861. personNameError.value = '';
  2862. };
  2863. // 手机号操作方法
  2864. const addPhone = () => {
  2865. personForm.phoneList.push('');
  2866. phoneListErrors.value.push('');
  2867. };
  2868. const removePhone = (index: number) => {
  2869. if (personForm.phoneList.length > 1) {
  2870. personForm.phoneList.splice(index, 1);
  2871. phoneListErrors.value.splice(index, 1);
  2872. }
  2873. };
  2874. const validatePhone = (index: number) => {
  2875. const phone = personForm.phoneList[index];
  2876. if (phone.trim()) {
  2877. const phoneRegex = /^1[3-9]\d{9}$/;
  2878. phoneListErrors.value[index] = phoneRegex.test(phone.trim()) ? '' : '手机号格式不正确';
  2879. } else {
  2880. phoneListErrors.value[index] = '';
  2881. }
  2882. };
  2883. // 银行卡操作方法
  2884. const addCard = () => {
  2885. personForm.cardList.push('');
  2886. cardListErrors.value.push('');
  2887. };
  2888. const removeCard = (index: number) => {
  2889. if (personForm.cardList.length > 1) {
  2890. personForm.cardList.splice(index, 1);
  2891. cardListErrors.value.splice(index, 1);
  2892. }
  2893. };
  2894. const validateCard = (index: number) => {
  2895. const card = personForm.cardList[index];
  2896. if (card.trim()) {
  2897. const len = card.trim().length;
  2898. cardListErrors.value[index] = len >= 16 && len <= 19 ? '' : '银行卡号长度应为16-19位';
  2899. } else {
  2900. cardListErrors.value[index] = '';
  2901. }
  2902. };
  2903. const handleDelete = (person: PersonBasicInfo) => {
  2904. const name = person.personName;
  2905. if (!name) {
  2906. createMessage.warning('缺少人员姓名');
  2907. return;
  2908. }
  2909. createConfirm({
  2910. iconType: 'warning',
  2911. centered: true,
  2912. title: '确认删除对象',
  2913. content: `确定要删除对象“${name}”吗?删除后无法恢复。`,
  2914. okButtonProps: { type: 'primary' },
  2915. autoFocusButton: 'ok',
  2916. async onOk() {
  2917. try {
  2918. await deletePersonBasicInfo(name);
  2919. createMessage.success('删除成功');
  2920. await fetchPersons();
  2921. } catch (error) {
  2922. console.error('删除人员失败', error);
  2923. createMessage.error('删除失败');
  2924. }
  2925. },
  2926. });
  2927. };
  2928. // ==================== Quick Nav 滚动联动 ====================
  2929. const rightPanelRef = ref<HTMLElement | null>(null);
  2930. const activeNavId = ref('section-basic');
  2931. const scheduleChartResize = () => {
  2932. if (chartResizeFrame !== null) {
  2933. window.cancelAnimationFrame(chartResizeFrame);
  2934. }
  2935. chartResizeFrame = window.requestAnimationFrame(() => {
  2936. chartResizeFrame = null;
  2937. handleTrendChartResize();
  2938. if (tradeRatioChartRef.value && hasTradeRatioData.value && !tradeRatioChart) {
  2939. initTradeRatioChart();
  2940. }
  2941. if (tradeTopChartRef.value && tradeTop.value.length && !tradeTopChart) {
  2942. initTradeTopChart();
  2943. }
  2944. if (commTypeChartRef.value && hasRenderableCommTypeRatioData.value && !commTypeChart) {
  2945. initCommTypeChart();
  2946. }
  2947. if (commTopChartRef.value && hasRenderableTopContacts.value && !commTopChart) {
  2948. initCommTopChart();
  2949. }
  2950. if (tradeTrendChartRef.value && trendLabels.value.length && !tradeTrendChart) {
  2951. initTradeTrendChart();
  2952. }
  2953. });
  2954. };
  2955. const observeChartElement = (element: Element | null) => {
  2956. if (!chartResizeObserver || !element || observedChartElements.has(element)) return;
  2957. chartResizeObserver.observe(element);
  2958. observedChartElements.add(element);
  2959. };
  2960. const setupChartResizeObserver = () => {
  2961. if (typeof ResizeObserver === 'undefined') return;
  2962. if (!chartResizeObserver) {
  2963. chartResizeObserver = new ResizeObserver(() => {
  2964. scheduleChartResize();
  2965. });
  2966. }
  2967. [
  2968. rightPanelRef.value,
  2969. tradeTrendChartRef.value,
  2970. tradeRatioChartRef.value,
  2971. tradeTopChartRef.value,
  2972. commTypeChartRef.value,
  2973. commTopChartRef.value,
  2974. ].forEach(observeChartElement);
  2975. scheduleChartResize();
  2976. };
  2977. const navItems = [
  2978. { id: 'section-basic', label: '基本信息' },
  2979. { id: 'section-relation', label: '亲密关系人' },
  2980. { id: 'section-comm', label: '通讯信息' },
  2981. { id: 'section-trade', label: '交易信息' },
  2982. ];
  2983. const scrollToSection = (id: string) => {
  2984. const container = rightPanelRef.value;
  2985. if (!container) return;
  2986. const el = container.querySelector(`#${id}`) as HTMLElement | null;
  2987. if (!el) return;
  2988. container.scrollTo({ top: el.offsetTop - container.offsetTop - 12, behavior: 'smooth' });
  2989. activeNavId.value = id;
  2990. };
  2991. let navObserver: IntersectionObserver | null = null;
  2992. const initNavObserver = () => {
  2993. const container = rightPanelRef.value;
  2994. if (!container) return;
  2995. navObserver?.disconnect();
  2996. const sections = navItems
  2997. .map((n) => container.querySelector(`#${n.id}`) as HTMLElement | null)
  2998. .filter(Boolean) as HTMLElement[];
  2999. if (!sections.length) return;
  3000. navObserver = new IntersectionObserver(
  3001. (entries) => {
  3002. // 找到第一个可见的 section
  3003. const visible = entries
  3004. .filter((e) => e.isIntersecting)
  3005. .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
  3006. if (visible.length) {
  3007. activeNavId.value = visible[0].target.id;
  3008. }
  3009. },
  3010. {
  3011. root: container,
  3012. threshold: 0.15,
  3013. },
  3014. );
  3015. sections.forEach((el) => navObserver!.observe(el));
  3016. };
  3017. onMounted(() => {
  3018. fetchPersons();
  3019. resetRightPanelData();
  3020. void openGroupModalFromRoute();
  3021. window.addEventListener('resize', handleTrendChartResize);
  3022. nextTick(() => {
  3023. initNavObserver();
  3024. setupChartResizeObserver();
  3025. });
  3026. });
  3027. onBeforeUnmount(() => {
  3028. revokeAvatarPreviewObjectUrl();
  3029. navObserver?.disconnect();
  3030. navObserver = null;
  3031. if (tradeTrendSearchTimer !== null) {
  3032. window.clearTimeout(tradeTrendSearchTimer);
  3033. tradeTrendSearchTimer = null;
  3034. }
  3035. if (tradeTrendOtherNameSearchTimer !== null) {
  3036. window.clearTimeout(tradeTrendOtherNameSearchTimer);
  3037. tradeTrendOtherNameSearchTimer = null;
  3038. }
  3039. window.removeEventListener('resize', handleTrendChartResize);
  3040. chartResizeObserver?.disconnect();
  3041. chartResizeObserver = null;
  3042. observedChartElements.clear();
  3043. if (chartResizeFrame !== null) {
  3044. window.cancelAnimationFrame(chartResizeFrame);
  3045. chartResizeFrame = null;
  3046. }
  3047. if ((window as any)._handleTrendDetail) {
  3048. delete (window as any)._handleTrendDetail;
  3049. }
  3050. if (tradeTrendChart) {
  3051. tradeTrendChart.dispose();
  3052. tradeTrendChart = null;
  3053. }
  3054. if (tradeRatioChart) {
  3055. tradeRatioChart.dispose();
  3056. tradeRatioChart = null;
  3057. }
  3058. if (tradeTopChart) {
  3059. tradeTopChart.dispose();
  3060. tradeTopChart = null;
  3061. }
  3062. if (commTypeChart) {
  3063. commTypeChart.dispose();
  3064. commTypeChart = null;
  3065. }
  3066. if (commTopChart) {
  3067. commTopChart.dispose();
  3068. commTopChart = null;
  3069. }
  3070. });
  3071. const fallbackValue = (value: string | number | null | undefined) => {
  3072. if (value === 0) return '0';
  3073. if (value === null || value === undefined || value === '') return '-';
  3074. return String(value);
  3075. };
  3076. const normalizePhotoSrc = (value?: string | null) => normalizeAvatarSrc(value);
  3077. const getPersonPhoto = (person?: Partial<PersonBasicInfo> | null) => String(person?.photograph || '').trim();
  3078. const selectedPerson = computed(() => {
  3079. if (selectedPersonId.value === null) return null;
  3080. return personList.value[selectedPersonId.value] ?? null;
  3081. });
  3082. const personKeyword = ref('');
  3083. const filteredPersonList = computed(() => {
  3084. const keyword = personKeyword.value.trim();
  3085. const source = personList.value.map((person, index) => ({ person, index }));
  3086. if (!keyword) return source;
  3087. return source.filter(({ person }) => {
  3088. const personName = String(person.personName || '');
  3089. const certNo = String(person.personCertNo || '');
  3090. return personName.includes(keyword) || certNo.includes(keyword);
  3091. });
  3092. });
  3093. const basicInfo = computed(() => {
  3094. const person = selectedPerson.value;
  3095. if (!person) {
  3096. return [
  3097. { label: '身份证', value: '-' },
  3098. { label: '性别', value: '-' },
  3099. { label: '民族', value: '-' },
  3100. { label: '年龄', value: '-' },
  3101. { label: '出生日期', value: '-' },
  3102. { label: '文化程度', value: '-' },
  3103. { label: '婚姻状况', value: '-' },
  3104. { label: '身高', value: '-' },
  3105. { label: '户籍地', value: '-' },
  3106. { label: '现住址', value: '-' },
  3107. { label: '工作单位', value: '-' },
  3108. { label: '对象分组', value: '-' },
  3109. ];
  3110. }
  3111. return [
  3112. { label: '身份证', value: maskCertNo(person.personCertNo) },
  3113. { label: '性别', value: fallbackValue(person.personGender) },
  3114. { label: '民族', value: fallbackValue(person.personNation) },
  3115. { label: '年龄', value: fallbackValue(person.personAge) },
  3116. { label: '出生日期', value: fallbackValue(person.personBirth) },
  3117. { label: '文化程度', value: fallbackValue(person.personEducation) },
  3118. { label: '婚姻状况', value: fallbackValue(person.personMarriage) },
  3119. { label: '身高', value: fallbackValue(person.personHeight) },
  3120. { label: '户籍地', value: fallbackValue(person.personResidence) },
  3121. { label: '现住址', value: fallbackValue(person.personAddress) },
  3122. { label: '工作单位', value: fallbackValue(person.personWorkUnit) },
  3123. { label: '对象分组', value: fallbackValue(person.personGroup) },
  3124. ];
  3125. });
  3126. const basicPhoto = computed(() => getPersonPhoto(selectedPerson.value));
  3127. const basicName = computed(() => {
  3128. return selectedPerson.value?.personName ?? '-';
  3129. });
  3130. const topContacts = computed(() => {
  3131. if (!commTop10Data.value.length) return [];
  3132. const max = Math.max(...commTop10Data.value.map((item) => Number(item.callCount || 0)), 1);
  3133. return commTop10Data.value
  3134. .slice(0, 10)
  3135. .map((item, index) => {
  3136. const count = Number(item.callCount || 0);
  3137. return {
  3138. name: item.otherName || item.otherPhone || `联系人${index + 1}`,
  3139. otherName: item.otherName || '',
  3140. otherPhone: item.otherPhone || '',
  3141. count,
  3142. rate: Math.round((count / max) * 100),
  3143. };
  3144. })
  3145. .filter((item) => String(item.name || '').trim() && Number.isFinite(item.count) && item.count > 0)
  3146. .sort((a, b) => b.count - a.count);
  3147. });
  3148. const hasRenderableCommTypeRatioData = computed(() =>
  3149. commTypeRatioData.value.some((item) => String(item.name || '').trim() && Number(item.value || 0) > 0),
  3150. );
  3151. const hasRenderableTopContacts = computed(() =>
  3152. topContacts.value.some((item) => String(item.name || '').trim() && Number(item.count || 0) > 0),
  3153. );
  3154. const tradeMetricOptions = ref([
  3155. { label: '交易对方卡(账)号', value: 'otherCardNo' },
  3156. { label: '交易对方', value: 'personName' },
  3157. { label: 'ip地址', value: 'personIpAddress' },
  3158. { label: 'mac地址', value: 'personMacAddress' },
  3159. { label: '交易网点名称', value: 'branchName' },
  3160. ]);
  3161. const getTradeTopNumericValue = (item: TransOftenTop10DTO) => {
  3162. return tradeTopQuery.dimension === '交易金额'
  3163. ? parseMoneyValue(item.transTotalAmount)
  3164. : Number(item.transCount || 0);
  3165. };
  3166. const tradeTop = computed(() => {
  3167. if (!tradeTop10Data.value.length) return [];
  3168. const max = Math.max(...tradeTop10Data.value.map((item) => getTradeTopNumericValue(item)), 1);
  3169. return tradeTop10Data.value
  3170. .map((item, index) => {
  3171. const value = getTradeTopNumericValue(item);
  3172. return {
  3173. name: item.indicator || item.dimension || `对象${index + 1}`,
  3174. filterValue: item.indicator || item.dimension || '',
  3175. value,
  3176. rate: Math.round((value / max) * 100),
  3177. raw: item,
  3178. };
  3179. })
  3180. .sort((a, b) => b.value - a.value);
  3181. });
  3182. const carrierTags = computed(() => {
  3183. const operCountMap = callStatData.value?.operCountMap ?? {};
  3184. return Object.entries(operCountMap)
  3185. .map(([carrierName, phones]) => {
  3186. const phoneList = Array.isArray(phones) ? phones.map((item) => String(item || '').trim()).filter(Boolean) : [];
  3187. const name = String(carrierName || '').trim();
  3188. return {
  3189. carrierName: name,
  3190. phoneList,
  3191. carrierCount: phoneList.length,
  3192. operatorId: name,
  3193. label: `${name}(${phoneList.length})`,
  3194. };
  3195. })
  3196. .filter((item) => item.carrierName);
  3197. });
  3198. const tradeBankTags = computed(() => {
  3199. const bankCountMap = transStatData.value?.bankCountMap ?? {};
  3200. return Object.entries(bankCountMap).map(([bankName, cards]) => {
  3201. const cardNoList = Array.isArray(cards) ? cards.map((item) => String(item || '').trim()).filter(Boolean) : [];
  3202. return {
  3203. bankName,
  3204. cardNoList,
  3205. label: `${bankName}(${cardNoList.length})`,
  3206. };
  3207. });
  3208. });
  3209. const commSummary = computed(() => {
  3210. const callStatInfo = callStatData.value;
  3211. const phoneTotal = callStatInfo?.phoneCount ?? 0;
  3212. const callCount = callStatInfo?.callCount;
  3213. const otherCount = callStatInfo?.otherCount;
  3214. const timeRange = callStatInfo?.callDateBnd
  3215. ? String(callStatInfo.callDateBnd).replace(',', '至')
  3216. : callStatInfo?.callDateStart
  3217. ? `${callStatInfo.callDateStart} 至 ${callStatInfo.callDateEnd ?? '-'}`
  3218. : '-';
  3219. return {
  3220. phoneSummary: phoneTotal ? `共计 ${phoneTotal} 个号码` : '-',
  3221. totalCount: callCount ? `${callCount} 次` : '-',
  3222. contactCount: otherCount ? `${otherCount} 个` : '-',
  3223. timeRange,
  3224. };
  3225. });
  3226. const parseMoneyValue = (value: unknown) => {
  3227. if (value === null || value === undefined || value === '') return 0;
  3228. if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
  3229. const num = Number(
  3230. String(value)
  3231. .replace(/[,%¥元\s]/g, '')
  3232. .trim(),
  3233. );
  3234. return Number.isFinite(num) ? num : 0;
  3235. };
  3236. const parsePercentValue = (value: unknown) => {
  3237. if (value === null || value === undefined || value === '') return null;
  3238. const num = Number(
  3239. String(value)
  3240. .replace(/[,%\s]/g, '')
  3241. .trim(),
  3242. );
  3243. return Number.isFinite(num) ? num : null;
  3244. };
  3245. const formatAmountValue = (value: number) => {
  3246. return value.toLocaleString('zh-CN', {
  3247. minimumFractionDigits: 2,
  3248. maximumFractionDigits: 2,
  3249. });
  3250. };
  3251. const formatMoneyText = (value: unknown) => {
  3252. const amount = parseMoneyValue(value);
  3253. return `${formatAmountValue(amount)}元`;
  3254. };
  3255. const formatMoneyHighlightText = (value: unknown) => {
  3256. const amount = parseMoneyValue(value);
  3257. if (amount <= 0) return '-';
  3258. return `¥${formatAmountValue(amount)}`;
  3259. };
  3260. const formatPercentText = (value: unknown, fallback: number) => {
  3261. const raw = value === null || value === undefined ? '' : String(value).trim();
  3262. if (raw) {
  3263. return raw.includes('%') ? raw : `${raw}%`;
  3264. }
  3265. return `${fallback.toFixed(4)}%`;
  3266. };
  3267. const tradeSummary = computed(() => {
  3268. const transStatInfo = transStatData.value;
  3269. const cardCount = transStatInfo?.cardCount ?? 0;
  3270. const balance = transStatInfo?.totalBalance ?? 0;
  3271. const bankCountMap = transStatInfo?.bankCountMap ?? {};
  3272. const banks = Object.entries(bankCountMap).map(
  3273. ([name, cards]) => `${name}(${Array.isArray(cards) ? cards.length : 0})`,
  3274. );
  3275. const infoStat = transStatInfo?.transInfoStat;
  3276. const totalIncome = parseMoneyValue(infoStat?.totalIncome ?? infoStat?.incomeTotalAmount);
  3277. const totalExpend = parseMoneyValue(infoStat?.totalExpend ?? infoStat?.expenseTotalAmount);
  3278. const totalAmount = totalIncome + totalExpend;
  3279. const incomeRate = totalAmount ? Number(((totalIncome / totalAmount) * 100).toFixed(4)) : 0;
  3280. const expendRate = totalAmount ? Number(((totalExpend / totalAmount) * 100).toFixed(4)) : 0;
  3281. const incomeRatio = parsePercentValue(infoStat?.incomeRatio) ?? incomeRate;
  3282. const expendRatio = parsePercentValue(infoStat?.expenseRatio) ?? expendRate;
  3283. const maxExpend = parseMoneyValue(infoStat?.maxExpend ?? infoStat?.maxSingleExpense);
  3284. const maxIncome = parseMoneyValue(infoStat?.maxIncome ?? infoStat?.maxSingleIncome);
  3285. return {
  3286. cardSummary: cardCount ? `共计 ${cardCount} 张卡 总余额约 ${balance} 万` : '-',
  3287. bankTags: banks,
  3288. incomeRate: totalAmount ? formatPercentText(infoStat?.incomeRatio, incomeRate) : '-',
  3289. incomeAmount: formatMoneyText(totalIncome),
  3290. expendRate: totalAmount ? formatPercentText(infoStat?.expenseRatio, expendRate) : '-',
  3291. expendAmount: formatMoneyText(totalExpend),
  3292. maxExpend: formatMoneyHighlightText(maxExpend),
  3293. maxIncome: formatMoneyHighlightText(maxIncome),
  3294. incomeRatio,
  3295. expendRatio,
  3296. incomeValue: totalIncome,
  3297. expendValue: totalExpend,
  3298. };
  3299. });
  3300. const hasTradeSummary = computed(() => {
  3301. const cardCount = Number(transStatData.value?.cardCount ?? 0);
  3302. return cardCount > 0 || tradeBankTags.value.length > 0;
  3303. });
  3304. const hasTradeRatioData = computed(() => {
  3305. return Number(tradeSummary.value.incomeValue || 0) > 0 || Number(tradeSummary.value.expendValue || 0) > 0;
  3306. });
  3307. const parseTrendAmount = (value: unknown) => {
  3308. if (value === null || value === undefined) return 0;
  3309. if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
  3310. const num = Number(String(value).replace(/,/g, '').trim());
  3311. return Number.isFinite(num) ? num : 0;
  3312. };
  3313. const trendSummaryByDate = computed(() => {
  3314. const map = new Map<string, { income: number; expend: number }>();
  3315. tradeTrendData.value.forEach((item: any) => {
  3316. const date = item?.transDate || '-';
  3317. const flag = String(item?.debitCreditFlag || '').trim();
  3318. const amount = parseTrendAmount(item?.transTotalAmount);
  3319. const current = map.get(date) || { income: 0, expend: 0 };
  3320. if (flag === '收入') {
  3321. current.income += amount;
  3322. } else if (flag === '支出') {
  3323. current.expend += amount;
  3324. }
  3325. map.set(date, current);
  3326. });
  3327. return Array.from(map.entries())
  3328. .map(([date, value]) => ({ date, ...value }))
  3329. .sort((a, b) => a.date.localeCompare(b.date));
  3330. });
  3331. const trendLabels = computed(() => trendSummaryByDate.value.map((item) => item.date));
  3332. const trendIncomePoints = computed(() => trendSummaryByDate.value.map((item) => item.income));
  3333. const trendExpendPoints = computed(() => trendSummaryByDate.value.map((item) => item.expend));
  3334. const trendPoints = computed(() => {
  3335. const income = trendIncomePoints.value;
  3336. const expend = trendExpendPoints.value;
  3337. return income.map((v, i) => Math.max(Number(v || 0), Number(expend[i] || 0)));
  3338. });
  3339. const trendChartStyle = computed(() => {
  3340. const widthN = Math.max(1, Math.floor(trendLabels.value.length / 15));
  3341. const basePercent = (widthN + 1) * 100 - 100;
  3342. const widthPercent = tradeTrendQuery.timeDimension === 'YEAR' ? Math.max(220, basePercent) : basePercent;
  3343. const widthPx = tradeTrendQuery.timeDimension === 'YEAR' ? Math.max(1200, trendLabels.value.length * 420) : 0;
  3344. return {
  3345. height: '340px',
  3346. width: tradeTrendQuery.timeDimension === 'YEAR' ? `${widthPx}px` : `${widthPercent}%`,
  3347. minWidth: '100%',
  3348. paddingRight: tradeTrendQuery.timeDimension === 'YEAR' ? '24px' : '0',
  3349. };
  3350. });
  3351. const formatAmount = (value: number | string | null | undefined) => {
  3352. const num = Number(value || 0);
  3353. return num.toLocaleString('zh-CN');
  3354. };
  3355. const buildTooltipCard = ({
  3356. title,
  3357. value,
  3358. color,
  3359. suffix = '',
  3360. subText,
  3361. }: {
  3362. title: string;
  3363. value: string;
  3364. color: string;
  3365. suffix?: string;
  3366. subText?: string;
  3367. }) => {
  3368. const sub = subText
  3369. ? `<div style="font-size:12px; line-height:18px; color:#94a3b8; margin-top:6px;">${subText}</div>`
  3370. : '';
  3371. return [
  3372. '<div style="padding:12px 14px; min-width:110px; background:linear-gradient(180deg, rgba(19,33,62,0.98) 0%, rgba(15,23,42,0.98) 100%); border:1px solid rgba(148,163,184,0.2); border-radius:12px; box-shadow:0 16px 34px rgba(2,6,23,0.3);">',
  3373. `<div style="font-size:14px; line-height:20px; color:#f8fafc; margin-bottom:8px;">${title}</div>`,
  3374. '<div style="display:flex; align-items:center; gap:8px;">',
  3375. `<span style="width:10px; height:10px; border-radius:50%; background:${color}; box-shadow:0 0 0 4px rgba(255,255,255,0.06); display:inline-block;"></span>`,
  3376. `<span style="font-size:14px; line-height:20px; color:#eff6ff; font-weight:600;">${value}${suffix}</span>`,
  3377. '</div>',
  3378. sub,
  3379. '</div>',
  3380. ].join('');
  3381. };
  3382. const getConfinedTooltipPosition = (
  3383. point: number[],
  3384. _params: unknown,
  3385. dom: HTMLElement,
  3386. _rect: unknown,
  3387. size: { viewSize: number[] },
  3388. ) => {
  3389. const [pointX, pointY] = point;
  3390. const [viewWidth, viewHeight] = size.viewSize;
  3391. const cardWidth = dom.offsetWidth || 160;
  3392. const cardHeight = dom.offsetHeight || 96;
  3393. const gap = 10;
  3394. const left = Math.min(Math.max(pointX - cardWidth / 2, gap), Math.max(gap, viewWidth - cardWidth - gap));
  3395. const top = Math.min(Math.max(pointY - cardHeight - 18, gap), Math.max(gap, viewHeight - cardHeight - gap));
  3396. return [left, top];
  3397. };
  3398. const formatTrendAxisValue = (value: number) => {
  3399. const numericValue = Number(value || 0);
  3400. const absValue = Math.abs(numericValue);
  3401. if (absValue >= 100000000) {
  3402. return `${(numericValue / 100000000).toFixed(absValue % 100000000 === 0 ? 0 : 1)}亿`;
  3403. }
  3404. if (absValue >= 10000) {
  3405. return `${(numericValue / 10000).toFixed(absValue % 10000 === 0 ? 0 : 1)}万`;
  3406. }
  3407. return numericValue.toLocaleString('zh-CN');
  3408. };
  3409. const buildTrendTooltipCard = (time: string, params: any[]) => {
  3410. const rowHtml = params
  3411. .filter((item) => item && item.seriesName)
  3412. .map((item) => {
  3413. const color = String(item?.color || (item?.seriesName === '收入' ? '#2ac8a0' : '#ff8a66'));
  3414. return [
  3415. '<div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">',
  3416. '<span style="display:flex; align-items:center; gap:8px; font-size:13px; line-height:20px; color:#cbd5e1;">',
  3417. `<span style="width:10px; height:10px; border-radius:50%; background:${color}; box-shadow:0 0 0 4px rgba(255,255,255,0.06); display:inline-block;"></span>`,
  3418. `${item.seriesName}`,
  3419. '</span>',
  3420. `<span style="font-size:13px; line-height:20px; color:#eff6ff; font-weight:600;">${formatAmount(item?.value)}元</span>`,
  3421. '</div>',
  3422. ].join('');
  3423. })
  3424. .join('<div style="height:8px;"></div>');
  3425. if (!rowHtml) return '';
  3426. return [
  3427. '<div style="padding:14px 16px; min-width:188px; background:linear-gradient(180deg, rgba(19,33,62,0.98) 0%, rgba(15,23,42,0.98) 100%); border:1px solid rgba(148,163,184,0.2); border-radius:14px; box-shadow:0 16px 34px rgba(2,6,23,0.32);">',
  3428. '<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px;">',
  3429. `<span style="font-size:14px; line-height:20px; color:#f8fafc; font-weight:600;">${time}</span>`,
  3430. `<span style="font-size:12px; line-height:18px; color:#8ac5ff; cursor:pointer;" onclick='window._handleTrendDetail(${JSON.stringify(time)})'>查看详情</span>`,
  3431. '</div>',
  3432. rowHtml,
  3433. '</div>',
  3434. ].join('');
  3435. };
  3436. const wrapAxisLabel = (value: string, maxCharsPerLine = 5) => {
  3437. const chars = Array.from(value || '');
  3438. if (chars.length <= maxCharsPerLine) return value;
  3439. const lines: string[] = [];
  3440. for (let index = 0; index < chars.length; index += maxCharsPerLine) {
  3441. lines.push(chars.slice(index, index + maxCharsPerLine).join(''));
  3442. }
  3443. return lines.join('\n');
  3444. };
  3445. const resolveAxisLabelCharsPerLine = (container: HTMLElement | null | undefined, itemCount: number, fallback = 5) => {
  3446. const width = container?.clientWidth || 0;
  3447. if (!width || itemCount <= 0) return fallback;
  3448. const usableWidth = Math.max(width - 72, 0);
  3449. const slotWidth = usableWidth / Math.max(itemCount, 1);
  3450. const approximateCharWidth = 7.5;
  3451. const charsPerLine = Math.floor(slotWidth / approximateCharWidth);
  3452. return Math.max(4, Math.min(24, charsPerLine || fallback));
  3453. };
  3454. const formatTradeTopAxisLabel = (value: string, maxCharsPerLine = 5, maxLines = 2) => {
  3455. const chars = Array.from(value || '');
  3456. if (!chars.length) return '';
  3457. const normalizedCharsPerLine = Math.max(4, maxCharsPerLine);
  3458. const normalizedMaxLines = Math.max(1, maxLines);
  3459. const maxChars = normalizedCharsPerLine * normalizedMaxLines;
  3460. if (chars.length <= maxChars) {
  3461. return wrapAxisLabel(value, normalizedCharsPerLine);
  3462. }
  3463. const visibleChars = chars.slice(0, Math.max(maxChars - 1, 1));
  3464. const truncatedValue = `${visibleChars.join('')}…`;
  3465. return wrapAxisLabel(truncatedValue, normalizedCharsPerLine);
  3466. };
  3467. const getTradeTopAxisMax = (maxValue: number) => {
  3468. if (maxValue <= 0) return 1;
  3469. const magnitude = 10 ** Math.floor(Math.log10(maxValue));
  3470. return Math.ceil((maxValue * 1.15) / magnitude) * magnitude;
  3471. };
  3472. const getNiceAxisInterval = (maxValue: number, targetSplitCount = 5) => {
  3473. if (maxValue <= 0) return 1;
  3474. const safeSplitCount = Math.max(1, targetSplitCount);
  3475. const roughInterval = maxValue / safeSplitCount;
  3476. const magnitude = 10 ** Math.floor(Math.log10(roughInterval));
  3477. const normalized = roughInterval / magnitude;
  3478. if (normalized <= 1) return magnitude;
  3479. if (normalized <= 2) return 2 * magnitude;
  3480. if (normalized <= 2.5) return 2.5 * magnitude;
  3481. if (normalized <= 5) return 5 * magnitude;
  3482. return 10 * magnitude;
  3483. };
  3484. const getCommTopAxisInterval = (maxValue: number) => {
  3485. return Math.max(1, Math.ceil(getNiceAxisInterval(maxValue, 5)));
  3486. };
  3487. const getCommTopAxisMax = (maxValue: number) => {
  3488. const interval = getCommTopAxisInterval(maxValue);
  3489. return Math.max(interval, Math.ceil(maxValue / interval) * interval);
  3490. };
  3491. const formatTradeTopValue = (value: number) => {
  3492. if (tradeTopQuery.dimension === '交易金额') {
  3493. return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 元`;
  3494. }
  3495. return `${value.toLocaleString('zh-CN')} 次`;
  3496. };
  3497. const changeLegend = (title: string, index: number) => {
  3498. trendLegendArr.value[index].isShow = !trendLegendArr.value[index].isShow;
  3499. tradeTrendChart?.dispatchAction({
  3500. type: 'legendToggleSelect',
  3501. name: title,
  3502. });
  3503. };
  3504. const handleTrendDetail = async (name: string) => {
  3505. const baseQuery: Partial<TransRecordQuery> = {};
  3506. if (tradeTrendQuery.timeDimension === 'YEAR') {
  3507. baseQuery.startDate = `${name}-01-01`;
  3508. baseQuery.endDate = `${name}-12-31`;
  3509. } else {
  3510. baseQuery.startDate = `${name}-01`;
  3511. const [yearStr, monthStr] = name.split('-');
  3512. const year = Number(yearStr || 0);
  3513. const month = Number(monthStr || 1);
  3514. const lastDay = new Date(year, month, 0).getDate();
  3515. baseQuery.endDate = `${name}-${String(lastDay).padStart(2, '0')}`;
  3516. }
  3517. const startDate = tradeTrendQuery.dateRange?.[0];
  3518. const endDate = tradeTrendQuery.dateRange?.[1];
  3519. if (startDate && endDate) {
  3520. baseQuery.startDate = startDate;
  3521. baseQuery.endDate = endDate;
  3522. }
  3523. const otherName = String(tradeTrendQuery.otherName || '').trim();
  3524. if (otherName) {
  3525. baseQuery.otherName = otherName;
  3526. }
  3527. openTransRecordDrilldown(baseQuery);
  3528. };
  3529. const openCallSummaryFromTopContacts = async () => {
  3530. await openCallSummaryWithFilters({}, { useCommTopDateRange: true });
  3531. };
  3532. const initCommTypeChart = () => {
  3533. if (!commTypeChartRef.value || !hasRenderableCommTypeRatioData.value) return;
  3534. if (commTypeChart && commTypeChart.getDom() !== commTypeChartRef.value) {
  3535. commTypeChart.dispose();
  3536. commTypeChart = null;
  3537. }
  3538. if (!commTypeChart) commTypeChart = echarts.init(commTypeChartRef.value);
  3539. const pieData = commTypeRatioData.value.map((item) => ({
  3540. ...item,
  3541. name: `${item.name}:${Number(item.value || 0).toFixed(2)}%`,
  3542. rawName: item.name,
  3543. itemStyle: {
  3544. color: item.name === '主叫' ? '#4f7cff' : item.name === '被叫' ? '#39c7d9' : '#f6b659',
  3545. },
  3546. }));
  3547. const chartWidth = commTypeChartRef.value.clientWidth || 320;
  3548. const chartHeight = commTypeChartRef.value.clientHeight || 300;
  3549. const outerRadius = Math.min(Math.floor(chartHeight * 0.34), Math.floor(chartWidth * 0.29), 108);
  3550. const innerRadius = Math.max(Math.floor(outerRadius * 0.62), outerRadius - 28);
  3551. const centerX = Math.max(outerRadius + 20, Math.floor(chartWidth * 0.36));
  3552. const legendLeft = Math.max(
  3553. Math.min(Math.max(centerX + outerRadius + 10, Math.floor(chartWidth * 0.58)), chartWidth - 104),
  3554. Math.floor(chartWidth * 0.56),
  3555. );
  3556. const option: echarts.EChartsOption = {
  3557. backgroundColor: 'transparent',
  3558. tooltip: {
  3559. trigger: 'item',
  3560. confine: true,
  3561. backgroundColor: 'transparent',
  3562. borderWidth: 0,
  3563. padding: 0,
  3564. extraCssText: 'box-shadow:none;background:transparent;',
  3565. position: getConfinedTooltipPosition,
  3566. formatter: (params: any) => {
  3567. const rawName = String(params?.data?.rawName || params?.name || '');
  3568. const value = Number(params?.value || 0).toFixed(2);
  3569. return buildTooltipCard({
  3570. title: rawName,
  3571. value,
  3572. color: String(params?.color || '#5b8ff9'),
  3573. suffix: '%',
  3574. });
  3575. },
  3576. },
  3577. legend: {
  3578. icon: 'circle',
  3579. left: legendLeft,
  3580. top: 'center',
  3581. orient: 'vertical',
  3582. itemWidth: 10,
  3583. itemHeight: 10,
  3584. itemGap: 16,
  3585. selectedMode: false,
  3586. textStyle: {
  3587. color: '#43506a',
  3588. fontSize: 13,
  3589. lineHeight: 18,
  3590. },
  3591. data: pieData.map((i) => i.name),
  3592. },
  3593. series: [
  3594. {
  3595. type: 'pie',
  3596. center: [centerX, '50%'],
  3597. radius: [innerRadius, outerRadius],
  3598. avoidLabelOverlap: false,
  3599. emphasis: {
  3600. scale: false,
  3601. },
  3602. itemStyle: {
  3603. borderWidth: 4,
  3604. borderColor: 'rgba(246, 249, 255, 0.95)',
  3605. },
  3606. labelLine: { show: false },
  3607. label: { show: false },
  3608. data: pieData,
  3609. },
  3610. ],
  3611. };
  3612. commTypeChart.setOption(option, true);
  3613. commTypeChart.off('click');
  3614. commTypeChart.on('click', (params: any) => {
  3615. void (async () => {
  3616. const rawName = String(params?.data?.rawName || '');
  3617. const n = rawName || String(params?.data?.name || '');
  3618. const callDirection = n.includes('主叫') ? '主叫' : n.includes('被叫') ? '被叫' : '';
  3619. openCallRecordWithFilters(callDirection ? { callDirection } : {}, { omitPersonPhones: true });
  3620. })();
  3621. });
  3622. };
  3623. const initCommTopChart = () => {
  3624. if (!commTopChartRef.value || !hasRenderableTopContacts.value) return;
  3625. if (commTopChart && commTopChart.getDom() !== commTopChartRef.value) {
  3626. commTopChart.dispose();
  3627. commTopChart = null;
  3628. }
  3629. if (!commTopChart) commTopChart = echarts.init(commTopChartRef.value);
  3630. const xData = topContacts.value.map((v) => v.name);
  3631. const yData = topContacts.value.map((v) => Number(v.count || 0));
  3632. const maxValue = Math.max(...yData, 0);
  3633. const axisInterval = getCommTopAxisInterval(maxValue);
  3634. const axisMax = getCommTopAxisMax(maxValue);
  3635. const labelCharsPerLine = resolveAxisLabelCharsPerLine(commTopChartRef.value, xData.length, 5);
  3636. const option: echarts.EChartsOption = {
  3637. backgroundColor: 'transparent',
  3638. tooltip: {
  3639. trigger: 'item',
  3640. confine: true,
  3641. backgroundColor: 'transparent',
  3642. borderWidth: 0,
  3643. padding: 0,
  3644. extraCssText: 'box-shadow:none;background:transparent;',
  3645. position: getConfinedTooltipPosition,
  3646. formatter: (params: any) => {
  3647. const name = String(params?.name || '');
  3648. const value = Number(params?.value || 0);
  3649. return buildTooltipCard({
  3650. title: name,
  3651. value: value.toLocaleString('zh-CN'),
  3652. color: '#62c4ff',
  3653. subText: '联系次数',
  3654. });
  3655. },
  3656. },
  3657. grid: { top: 42, left: 18, right: 24, bottom: 8, outerBoundsMode: 'same', outerBoundsContain: 'axisLabel' },
  3658. xAxis: [
  3659. {
  3660. type: 'category',
  3661. data: xData,
  3662. axisTick: {
  3663. alignWithLabel: true,
  3664. lineStyle: {
  3665. color: '#8d95a3',
  3666. },
  3667. },
  3668. axisLine: { lineStyle: { color: '#8d95a3' } },
  3669. axisLabel: {
  3670. interval: 0,
  3671. color: '#6b7280',
  3672. fontSize: 12,
  3673. lineHeight: 16,
  3674. margin: 10,
  3675. formatter: (value: string) => wrapAxisLabel(value, labelCharsPerLine),
  3676. },
  3677. },
  3678. ],
  3679. yAxis: [
  3680. {
  3681. name: '次数',
  3682. type: 'value',
  3683. min: 0,
  3684. max: axisMax,
  3685. minInterval: 1,
  3686. interval: axisInterval,
  3687. splitNumber: Math.min(6, Math.max(1, Math.round(axisMax / axisInterval))),
  3688. nameLocation: 'end',
  3689. nameRotate: 0,
  3690. nameGap: 14,
  3691. nameTextStyle: {
  3692. color: '#6b7280',
  3693. fontSize: 12,
  3694. align: 'left',
  3695. padding: [0, 0, 0, -8],
  3696. },
  3697. axisLine: { show: false },
  3698. axisTick: { show: false },
  3699. axisLabel: {
  3700. color: '#6b7280',
  3701. formatter: (value: number) => value.toLocaleString('zh-CN'),
  3702. },
  3703. splitLine: {
  3704. lineStyle: {
  3705. color: '#dde5f2',
  3706. },
  3707. },
  3708. },
  3709. ],
  3710. series: [
  3711. {
  3712. type: 'bar',
  3713. barWidth: 34,
  3714. data: yData,
  3715. itemStyle: {
  3716. borderRadius: [6, 6, 0, 0],
  3717. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  3718. { offset: 0, color: '#4d7dff' },
  3719. { offset: 1, color: '#61c8ff' },
  3720. ]),
  3721. },
  3722. emphasis: {
  3723. itemStyle: {
  3724. color: '#4f8cff',
  3725. },
  3726. },
  3727. },
  3728. ],
  3729. };
  3730. commTopChart.setOption(option, true);
  3731. commTopChart.getZr().off('click');
  3732. commTopChart.getZr().on('click', (params: any) => {
  3733. void (async () => {
  3734. const pointInPixel = [params.offsetX, params.offsetY];
  3735. const pointInGrid = commTopChart!.convertFromPixel({ seriesIndex: 0 }, pointInPixel) as number[];
  3736. const dataIndex = pointInGrid?.[0];
  3737. const row = topContacts.value[dataIndex];
  3738. if (!row) return;
  3739. const baseQuery: Partial<CallRecordQuery> = row.otherPhone
  3740. ? {
  3741. otherPhones: [row.otherPhone],
  3742. }
  3743. : row.otherName
  3744. ? {
  3745. otherName: row.otherName,
  3746. }
  3747. : {};
  3748. openCallRecordWithFilters(baseQuery, { useCommTopDateRange: true, omitPersonPhones: true });
  3749. })();
  3750. });
  3751. requestAnimationFrame(() => {
  3752. commTopChart?.resize();
  3753. });
  3754. };
  3755. const initTradeRatioChart = () => {
  3756. if (!tradeRatioChartRef.value || !hasTradeRatioData.value) return;
  3757. if (tradeRatioChart && tradeRatioChart.getDom() !== tradeRatioChartRef.value) {
  3758. tradeRatioChart.dispose();
  3759. tradeRatioChart = null;
  3760. }
  3761. if (!tradeRatioChart) tradeRatioChart = echarts.init(tradeRatioChartRef.value);
  3762. const income = Number(tradeSummary.value.incomeValue || 0);
  3763. const expend = Number(tradeSummary.value.expendValue || 0);
  3764. const option: echarts.EChartsOption = {
  3765. backgroundColor: 'transparent',
  3766. tooltip: {
  3767. trigger: 'item',
  3768. confine: true,
  3769. backgroundColor: 'transparent',
  3770. borderWidth: 0,
  3771. padding: 0,
  3772. extraCssText: 'box-shadow:none;background:transparent;',
  3773. position: getConfinedTooltipPosition,
  3774. formatter: (params: any) => {
  3775. const name = String(params?.name || '');
  3776. const value = Number(params?.value || 0);
  3777. const ratio = Number(params?.percent || 0);
  3778. return buildTooltipCard({
  3779. title: name,
  3780. value: `${formatAmountValue(value)}元`,
  3781. color: String(params?.color || '#2ac8a0'),
  3782. subText: `占比 ${ratio.toFixed(4)}%`,
  3783. });
  3784. },
  3785. },
  3786. legend: { show: false },
  3787. series: [
  3788. {
  3789. type: 'pie',
  3790. radius: ['56%', '82%'],
  3791. center: ['50%', '50%'],
  3792. avoidLabelOverlap: false,
  3793. emphasis: {
  3794. scale: false,
  3795. },
  3796. itemStyle: {
  3797. borderWidth: 4,
  3798. borderColor: '#f6f8fd',
  3799. },
  3800. label: { show: false },
  3801. labelLine: { show: false },
  3802. data: [
  3803. { value: income, name: '收入', itemStyle: { color: '#2ac8a0' } },
  3804. { value: expend, name: '支出', itemStyle: { color: '#ff8a66' } },
  3805. ].filter((item) => item.value > 0),
  3806. },
  3807. ],
  3808. };
  3809. tradeRatioChart.setOption(option, true);
  3810. tradeRatioChart.off('click');
  3811. tradeRatioChart.on('click', (params: any) => {
  3812. const name = String(params?.name || '');
  3813. if (name === '收入' || name === '支出') {
  3814. void handleDrillByDebitCredit(name as '收入' | '支出');
  3815. }
  3816. });
  3817. requestAnimationFrame(() => {
  3818. tradeRatioChart?.resize();
  3819. });
  3820. };
  3821. const initTradeTopChart = () => {
  3822. if (!tradeTopChartRef.value || !tradeTop.value.length) return;
  3823. if (tradeTopChart && tradeTopChart.getDom() !== tradeTopChartRef.value) {
  3824. tradeTopChart.dispose();
  3825. tradeTopChart = null;
  3826. }
  3827. if (!tradeTopChart) tradeTopChart = echarts.init(tradeTopChartRef.value);
  3828. const xData = tradeTop.value.map((v) => v.name);
  3829. const yData = tradeTop.value.map((v) => Number(v.value || 0));
  3830. const maxValue = Math.max(...yData, 0);
  3831. const labelCharsPerLine = resolveAxisLabelCharsPerLine(tradeTopChartRef.value, xData.length, 5);
  3832. const option: echarts.EChartsOption = {
  3833. backgroundColor: 'transparent',
  3834. tooltip: {
  3835. trigger: 'item',
  3836. backgroundColor: 'transparent',
  3837. borderWidth: 0,
  3838. padding: 0,
  3839. extraCssText: 'box-shadow:none;background:transparent;',
  3840. formatter: (params: any) => {
  3841. const name = String(params?.name || '');
  3842. const value = Number(params?.value || 0);
  3843. return buildTooltipCard({
  3844. title: name,
  3845. value: formatTradeTopValue(value),
  3846. color: '#7b8cff',
  3847. subText: tradeTopQuery.dimension,
  3848. });
  3849. },
  3850. },
  3851. grid: { top: 48, left: 20, right: 24, bottom: 64, outerBoundsMode: 'same', outerBoundsContain: 'axisLabel' },
  3852. xAxis: [
  3853. {
  3854. type: 'category',
  3855. data: xData,
  3856. axisTick: { alignWithLabel: true },
  3857. axisLine: { lineStyle: { color: '#8d95a3' } },
  3858. axisLabel: {
  3859. interval: 0,
  3860. color: '#6b7280',
  3861. fontSize: 12,
  3862. lineHeight: 16,
  3863. margin: 10,
  3864. formatter: (value: string) => formatTradeTopAxisLabel(value, labelCharsPerLine, 2),
  3865. },
  3866. },
  3867. ],
  3868. yAxis: [
  3869. {
  3870. name: tradeTopQuery.dimension === '交易金额' ? '金额' : '次数',
  3871. type: 'value',
  3872. min: 0,
  3873. max: getTradeTopAxisMax(maxValue),
  3874. splitNumber: 5,
  3875. nameLocation: 'end',
  3876. nameRotate: 0,
  3877. nameGap: 18,
  3878. nameTextStyle: {
  3879. color: '#6b7280',
  3880. fontSize: 12,
  3881. align: 'left',
  3882. padding: [0, 0, 0, -8],
  3883. },
  3884. axisLine: { show: false },
  3885. axisTick: { show: false },
  3886. axisLabel: {
  3887. color: '#6b7280',
  3888. formatter: (value: number) => value.toLocaleString('zh-CN'),
  3889. },
  3890. splitLine: {
  3891. lineStyle: {
  3892. color: '#dde5f2',
  3893. },
  3894. },
  3895. },
  3896. ],
  3897. series: [
  3898. {
  3899. type: 'bar',
  3900. barWidth: 36,
  3901. data: yData,
  3902. itemStyle: {
  3903. borderRadius: [6, 6, 0, 0],
  3904. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  3905. { offset: 0, color: '#7b8cff' },
  3906. { offset: 1, color: '#58b1ff' },
  3907. ]),
  3908. },
  3909. emphasis: {
  3910. itemStyle: {
  3911. color: '#6978ff',
  3912. },
  3913. },
  3914. },
  3915. ],
  3916. };
  3917. tradeTopChart.setOption(option, true);
  3918. tradeTopChart.getZr().off('click');
  3919. tradeTopChart.getZr().on('click', (params: any) => {
  3920. void (async () => {
  3921. if (!tradeTop10Data.value.length) return;
  3922. const pointInPixel = [params.offsetX, params.offsetY];
  3923. const pointInGrid = tradeTopChart!.convertFromPixel({ seriesIndex: 0 }, pointInPixel) as number[];
  3924. const dataIndex = pointInGrid?.[0];
  3925. const row = tradeTop.value[dataIndex];
  3926. if (!row) return;
  3927. handleDrillByTradeTop(row);
  3928. })();
  3929. });
  3930. requestAnimationFrame(() => {
  3931. tradeTopChart?.resize();
  3932. });
  3933. };
  3934. const initTradeTrendChart = () => {
  3935. if (!tradeTrendChartRef.value) return;
  3936. if (tradeTrendChart) {
  3937. tradeTrendChart.dispose();
  3938. tradeTrendChart = null;
  3939. }
  3940. tradeTrendChart = echarts.init(tradeTrendChartRef.value);
  3941. const option: echarts.EChartsOption = {
  3942. backgroundColor: 'transparent',
  3943. tooltip: {
  3944. backgroundColor: 'transparent',
  3945. borderWidth: 0,
  3946. padding: 0,
  3947. extraCssText: 'box-shadow:none;background:transparent;',
  3948. position: (pt: number[], _params: any, _dom: any, _rect: any, size: any) => {
  3949. const [width] = size?.contentSize || [0];
  3950. return [pt[0] - width / 2, pt[1] + 10];
  3951. },
  3952. trigger: 'axis',
  3953. axisPointer: {
  3954. type: 'line',
  3955. lineStyle: {
  3956. color: 'rgba(255, 148, 104, 0.42)',
  3957. width: 1.5,
  3958. type: 'dashed',
  3959. },
  3960. },
  3961. showContent: true,
  3962. alwaysShowContent: false,
  3963. showDelay: 0,
  3964. hideDelay: 1000,
  3965. enterable: true,
  3966. confine: false,
  3967. transitionDuration: 0.4,
  3968. formatter: (params: any) => {
  3969. const list = Array.isArray(params) ? params : [params];
  3970. const time = list[0]?.axisValueLabel || list[0]?.name || '-';
  3971. return buildTrendTooltipCard(time, list);
  3972. },
  3973. },
  3974. legend: {
  3975. show: false,
  3976. data: ['收入', '支出'],
  3977. selected: {
  3978. 收入: trendLegendArr.value[0]?.isShow ?? true,
  3979. 支出: trendLegendArr.value[1]?.isShow ?? true,
  3980. },
  3981. },
  3982. grid: {
  3983. left: 44,
  3984. right: 34,
  3985. bottom: 36,
  3986. top: 48,
  3987. outerBoundsMode: 'same',
  3988. outerBoundsContain: 'axisLabel',
  3989. },
  3990. xAxis: {
  3991. name: '时间',
  3992. type: 'category',
  3993. boundaryGap: false,
  3994. data: trendLabels.value,
  3995. nameTextStyle: {
  3996. color: '#7b8797',
  3997. fontSize: 12,
  3998. padding: [18, 0, 0, 0],
  3999. },
  4000. axisLine: {
  4001. lineStyle: {
  4002. color: 'rgba(206, 214, 229, 0.96)',
  4003. },
  4004. },
  4005. axisTick: { show: false },
  4006. axisLabel: {
  4007. interval: 0,
  4008. color: '#6b7280',
  4009. fontSize: 12,
  4010. margin: 16,
  4011. formatter: (value: string) => {
  4012. const maxLength = tradeTrendQuery.timeDimension === 'YEAR' ? 4 : 7;
  4013. const val = String(value ?? '');
  4014. const rowN = Math.ceil(val.length / maxLength);
  4015. if (rowN <= 1) return val;
  4016. let ret = '';
  4017. for (let i = 0; i < rowN; i++) {
  4018. ret += val.substring(i * maxLength, (i + 1) * maxLength) + '\n';
  4019. }
  4020. return ret;
  4021. },
  4022. },
  4023. },
  4024. yAxis: {
  4025. name: '金额',
  4026. nameLocation: 'end',
  4027. nameRotate: 0,
  4028. nameGap: 16,
  4029. nameTextStyle: {
  4030. color: '#7b8797',
  4031. fontSize: 12,
  4032. align: 'left',
  4033. padding: [0, 0, 0, -6],
  4034. },
  4035. type: 'value',
  4036. axisLine: { show: false },
  4037. axisTick: { show: false },
  4038. axisLabel: {
  4039. color: '#6b7280',
  4040. fontSize: 12,
  4041. formatter: (value: number) => formatTrendAxisValue(value),
  4042. },
  4043. splitNumber: 5,
  4044. splitLine: {
  4045. lineStyle: {
  4046. color: 'rgba(217, 225, 238, 0.92)',
  4047. type: 'dashed',
  4048. },
  4049. },
  4050. },
  4051. series: [
  4052. {
  4053. name: '收入',
  4054. type: 'line',
  4055. smooth: true,
  4056. symbol: 'circle',
  4057. symbolSize: 10,
  4058. showSymbol: true,
  4059. itemStyle: {
  4060. color: '#2ac8a0',
  4061. borderColor: '#ffffff',
  4062. borderWidth: 2,
  4063. shadowBlur: 10,
  4064. shadowColor: 'rgba(42, 200, 160, 0.22)',
  4065. },
  4066. lineStyle: { width: 3, color: '#2ac8a0' },
  4067. areaStyle: {
  4068. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  4069. { offset: 0, color: 'rgba(42, 200, 160, 0.22)' },
  4070. { offset: 1, color: 'rgba(42, 200, 160, 0.02)' },
  4071. ]),
  4072. },
  4073. emphasis: {
  4074. focus: 'series',
  4075. },
  4076. data: trendIncomePoints.value,
  4077. },
  4078. {
  4079. name: '支出',
  4080. type: 'line',
  4081. smooth: true,
  4082. symbol: 'circle',
  4083. symbolSize: 10,
  4084. showSymbol: true,
  4085. itemStyle: {
  4086. color: '#ff8a66',
  4087. borderColor: '#ffffff',
  4088. borderWidth: 2,
  4089. shadowBlur: 10,
  4090. shadowColor: 'rgba(255, 138, 102, 0.22)',
  4091. },
  4092. lineStyle: { width: 3, color: '#ff8a66' },
  4093. areaStyle: {
  4094. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  4095. { offset: 0, color: 'rgba(255, 138, 102, 0.2)' },
  4096. { offset: 1, color: 'rgba(255, 138, 102, 0.02)' },
  4097. ]),
  4098. },
  4099. emphasis: {
  4100. focus: 'series',
  4101. },
  4102. data: trendExpendPoints.value,
  4103. },
  4104. ],
  4105. };
  4106. tradeTrendChart.setOption(option, true);
  4107. (window as any)._handleTrendDetail = handleTrendDetail;
  4108. };
  4109. watch(
  4110. () => [trendLabels.value, trendIncomePoints.value, trendExpendPoints.value],
  4111. async () => {
  4112. if (!trendLabels.value.length) return;
  4113. await nextTick();
  4114. setupChartResizeObserver();
  4115. if (!tradeTrendChartRef.value) return;
  4116. initTradeTrendChart();
  4117. requestAnimationFrame(() => {
  4118. tradeTrendChart?.resize();
  4119. });
  4120. if (tradeTrendScrollRef.value) {
  4121. tradeTrendScrollRef.value.scrollLeft = 0;
  4122. }
  4123. },
  4124. { deep: true },
  4125. );
  4126. watch(
  4127. () => [
  4128. String(tradeTrendQuery.otherName || '').trim(),
  4129. tradeTrendQuery.dateRange?.[0],
  4130. tradeTrendQuery.dateRange?.[1],
  4131. tradeTrendQuery.timeDimension,
  4132. ],
  4133. () => {
  4134. const personName = selectedPerson.value?.personName;
  4135. if (!personName) {
  4136. tradeTrendData.value = [];
  4137. return;
  4138. }
  4139. scheduleTradeTrendSearch(tradeTrendQuery.timeDimension === 'YEAR' ? 0 : 240);
  4140. },
  4141. );
  4142. watch(
  4143. () => selectedPerson.value?.personName || '',
  4144. (personName) => {
  4145. resetTradeTrendOtherNameState();
  4146. if (!personName) return;
  4147. const currentValue = String(tradeTrendQuery.otherName || '').trim();
  4148. if (currentValue) {
  4149. setTradeTrendOtherNameOptions([currentValue], true);
  4150. }
  4151. void loadTradeTrendOtherNameOptions({ reset: true, search: '' });
  4152. },
  4153. );
  4154. watch(
  4155. () => tradeTrendChartRef.value,
  4156. async (el) => {
  4157. if (!el || !trendLabels.value.length) return;
  4158. await nextTick();
  4159. setupChartResizeObserver();
  4160. initTradeTrendChart();
  4161. requestAnimationFrame(() => {
  4162. tradeTrendChart?.resize();
  4163. });
  4164. },
  4165. );
  4166. watch(
  4167. () => [tradeSummary.value.incomeValue, tradeSummary.value.expendValue, tradeRatioChartRef.value],
  4168. async () => {
  4169. if (!hasTradeRatioData.value || !tradeRatioChartRef.value) {
  4170. if (tradeRatioChart) {
  4171. tradeRatioChart.dispose();
  4172. tradeRatioChart = null;
  4173. }
  4174. return;
  4175. }
  4176. await nextTick();
  4177. setupChartResizeObserver();
  4178. initTradeRatioChart();
  4179. },
  4180. );
  4181. watch(
  4182. () => tradeTop.value,
  4183. async () => {
  4184. await nextTick();
  4185. setupChartResizeObserver();
  4186. if (!tradeTop.value.length || !tradeTopChartRef.value) return;
  4187. initTradeTopChart();
  4188. },
  4189. { deep: true },
  4190. );
  4191. watch(
  4192. () => [
  4193. tradeTopQuery.indicator,
  4194. tradeTopQuery.dimension,
  4195. tradeTopQuery.dateRange?.[0],
  4196. tradeTopQuery.dateRange?.[1],
  4197. ],
  4198. async () => {
  4199. const personName = selectedPerson.value?.personName;
  4200. if (!personName) {
  4201. tradeTop10Data.value = [];
  4202. return;
  4203. }
  4204. try {
  4205. tradeTop10Data.value = await loadTradeTopData(personName);
  4206. } catch (error) {
  4207. console.error('获取常交易对象TOP10失败', error);
  4208. tradeTop10Data.value = [];
  4209. }
  4210. },
  4211. );
  4212. watch(
  4213. () => [hasRenderableCommTypeRatioData.value, commTypeRatioData.value, commTypeChartRef.value],
  4214. async () => {
  4215. if (!hasRenderableCommTypeRatioData.value || !commTypeChartRef.value) {
  4216. if (commTypeChart) {
  4217. commTypeChart.dispose();
  4218. commTypeChart = null;
  4219. }
  4220. return;
  4221. }
  4222. await nextTick();
  4223. setupChartResizeObserver();
  4224. initCommTypeChart();
  4225. requestAnimationFrame(() => {
  4226. commTypeChart?.resize();
  4227. });
  4228. },
  4229. { deep: true },
  4230. );
  4231. watch(
  4232. () => [hasRenderableTopContacts.value, topContacts.value, commTopChartRef.value],
  4233. async () => {
  4234. if (!hasRenderableTopContacts.value || !commTopChartRef.value) {
  4235. if (commTopChart) {
  4236. commTopChart.dispose();
  4237. commTopChart = null;
  4238. }
  4239. return;
  4240. }
  4241. await nextTick();
  4242. setupChartResizeObserver();
  4243. initCommTopChart();
  4244. },
  4245. { deep: true },
  4246. );
  4247. watch(
  4248. () => [commTopQuery.dateRange?.[0], commTopQuery.dateRange?.[1]],
  4249. async () => {
  4250. const personName = selectedPerson.value?.personName;
  4251. if (!personName) {
  4252. commTop10Data.value = [];
  4253. return;
  4254. }
  4255. try {
  4256. commTop10Data.value = await loadCommTopData(personName);
  4257. } catch (error) {
  4258. console.error('获取常联系对象TOP10失败', error);
  4259. commTop10Data.value = [];
  4260. }
  4261. },
  4262. );
  4263. const stations = computed(() => {
  4264. if (!stationData.value.length) return [];
  4265. return stationData.value
  4266. .filter((item): item is CallStatStationDTO => !!item && typeof item === 'object')
  4267. .map((item) => {
  4268. if (item.personStationName) return item.personStationName;
  4269. const regionId = item.personRegionId || '';
  4270. const cellId = item.personCellId || '';
  4271. if (regionId && cellId) return `${regionId}-${cellId}`;
  4272. return regionId || cellId || '-';
  4273. })
  4274. .filter(Boolean)
  4275. .slice(0, stationDisplayLimit);
  4276. });
  4277. const activeGroupMembers = computed(() => {
  4278. return groupMembers.value.filter((item) => item.groupId === activeGroupId.value);
  4279. });
  4280. const activeGroup = computed(() => {
  4281. return objectGroups.value.find((item) => item.id === activeGroupId.value) || null;
  4282. });
  4283. const groupedPersonNameSet = computed(() => {
  4284. return new Set(
  4285. groupMembers.value
  4286. .map((item) => item.personName?.trim())
  4287. .filter((item): item is string => !!item && item !== '-'),
  4288. );
  4289. });
  4290. const personOptions = computed(() => {
  4291. const currentMainPerson = createGroupForm.mainPerson?.trim() || '';
  4292. return personList.value
  4293. .map((item) => {
  4294. const personName = item.personName?.trim() || '';
  4295. return { label: personName || '-', value: personName };
  4296. })
  4297. .filter((item) => !!item.value)
  4298. .filter((item) => {
  4299. if (isEditGroupMode.value && currentMainPerson && item.value === currentMainPerson) {
  4300. return true;
  4301. }
  4302. return !groupedPersonNameSet.value.has(item.value);
  4303. })
  4304. .filter((item) => !!item.value);
  4305. });
  4306. const addObjectCandidates = computed(() => {
  4307. return personList.value
  4308. .map((item, index) => {
  4309. const name = item.personName?.trim() || '';
  4310. const phoneValues = (item.phoneList || []).map((phone) => phone.personNo || '').filter(Boolean);
  4311. const cardCount = (item.cardList || []).filter((card) => !!card?.personNo).length;
  4312. return {
  4313. key: String(item.id ?? item.objId ?? name ?? index),
  4314. name: name || `未命名对象${index + 1}`,
  4315. personCertNo: item.personCertNo || '-',
  4316. primaryPhone: phoneValues[0] || '-',
  4317. cardCount,
  4318. photograph: item.photograph || '',
  4319. disabled: false,
  4320. };
  4321. })
  4322. .filter((item) => !!item.name && !groupedPersonNameSet.value.has(item.name))
  4323. .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
  4324. });
  4325. const filteredAddObjectCandidates = computed(() => {
  4326. const keyword = addObjectKeyword.value.trim().toLocaleLowerCase();
  4327. if (!keyword) return addObjectCandidates.value;
  4328. return addObjectCandidates.value.filter((item) =>
  4329. [item.name, item.personCertNo, item.primaryPhone].some((value) => value.toLocaleLowerCase().includes(keyword)),
  4330. );
  4331. });
  4332. const addObjectEmptyText = computed(() => {
  4333. if (!addObjectCandidates.value.length) {
  4334. return '暂无未分组对象,对象限定在单一分组';
  4335. }
  4336. return '暂无匹配对象';
  4337. });
  4338. const normalizeGroupName = (value?: string | null) => value?.trim().toLocaleLowerCase() || '';
  4339. const isDuplicateGroupName = (groupName: string) => {
  4340. const normalizedName = normalizeGroupName(groupName);
  4341. if (!normalizedName) return false;
  4342. const editingId = editingGroupData.value?.id !== undefined ? String(editingGroupData.value.id) : '';
  4343. return objectGroups.value.some((item) => {
  4344. const groupId = item.id !== undefined && item.id !== null ? String(item.id) : '';
  4345. if (editingId && groupId === editingId) return false;
  4346. return normalizeGroupName(item.name) === normalizedName;
  4347. });
  4348. };
  4349. const validateGroupName = (showRequired = false) => {
  4350. const groupName = createGroupForm.groupName.trim();
  4351. if (!groupName) {
  4352. groupNameError.value = showRequired ? '请输入分组名称' : '';
  4353. return !showRequired;
  4354. }
  4355. if (isDuplicateGroupName(groupName)) {
  4356. groupNameError.value = '分组名称已存在,请重新输入';
  4357. return false;
  4358. }
  4359. groupNameError.value = '';
  4360. return true;
  4361. };
  4362. const validateMainPerson = (showRequired = false) => {
  4363. if (isEditGroupMode.value) {
  4364. mainPersonError.value = '';
  4365. return true;
  4366. }
  4367. if (!createGroupForm.mainPerson) {
  4368. mainPersonError.value = showRequired ? '请选择分组主体对象' : '';
  4369. return !showRequired;
  4370. }
  4371. mainPersonError.value = '';
  4372. return true;
  4373. };
  4374. watch(
  4375. () => createGroupForm.groupName,
  4376. () => {
  4377. if (!createGroupModalVisible.value) return;
  4378. if (!createGroupForm.groupName.trim() && !groupNameError.value) return;
  4379. validateGroupName(Boolean(createGroupForm.groupName.trim()));
  4380. },
  4381. );
  4382. watch(
  4383. () => createGroupForm.mainPerson,
  4384. () => {
  4385. if (!createGroupModalVisible.value) return;
  4386. if (!createGroupForm.mainPerson && !mainPersonError.value) return;
  4387. validateMainPerson(Boolean(createGroupForm.mainPerson));
  4388. },
  4389. );
  4390. const openCreateGroupModal = () => {
  4391. isEditGroupMode.value = false;
  4392. editingGroupData.value = null;
  4393. createGroupForm.groupName = '';
  4394. createGroupForm.mainPerson = undefined;
  4395. createGroupForm.remark = '';
  4396. groupNameError.value = '';
  4397. mainPersonError.value = '';
  4398. createGroupModalVisible.value = true;
  4399. };
  4400. const closeCreateGroupModal = () => {
  4401. createGroupModalVisible.value = false;
  4402. isEditGroupMode.value = false;
  4403. editingGroupData.value = null;
  4404. groupNameError.value = '';
  4405. mainPersonError.value = '';
  4406. };
  4407. const closeAddObjectModal = () => {
  4408. addObjectModalVisible.value = false;
  4409. addObjectKeyword.value = '';
  4410. selectedAddPersonNames.value = [];
  4411. };
  4412. const handleCreateGroup = async () => {
  4413. const groupName = createGroupForm.groupName.trim();
  4414. if (!validateGroupName(true)) {
  4415. createMessage.warning(groupNameError.value || '请输入分组名称');
  4416. return;
  4417. }
  4418. if (!validateMainPerson(true)) {
  4419. createMessage.warning(mainPersonError.value || '请选择分组主体对象');
  4420. return;
  4421. }
  4422. creatingGroup.value = true;
  4423. try {
  4424. if (isEditGroupMode.value && editingGroupData.value) {
  4425. await updatePersonGroup({
  4426. id: editingGroupData.value.id,
  4427. groupName,
  4428. remark: createGroupForm.remark || undefined,
  4429. });
  4430. createMessage.success('更新分组成功');
  4431. } else {
  4432. await createPersonGroup({
  4433. groupName,
  4434. remark: createGroupForm.remark || undefined,
  4435. personList: createGroupForm.mainPerson
  4436. ? [
  4437. {
  4438. personName: createGroupForm.mainPerson,
  4439. hasMain: true,
  4440. },
  4441. ]
  4442. : undefined,
  4443. });
  4444. createMessage.success('新增分组成功');
  4445. }
  4446. createGroupModalVisible.value = false;
  4447. isEditGroupMode.value = false;
  4448. editingGroupData.value = null;
  4449. groupNameError.value = '';
  4450. mainPersonError.value = '';
  4451. await loadGroupData();
  4452. } catch (error) {
  4453. console.error(isEditGroupMode.value ? '更新分组失败' : '新增分组失败', error);
  4454. createMessage.error(isEditGroupMode.value ? '更新分组失败' : '新增分组失败');
  4455. } finally {
  4456. creatingGroup.value = false;
  4457. }
  4458. };
  4459. const handleEditGroup = () => {
  4460. if (!activeGroupId.value) {
  4461. createMessage.warning('请先选择分组');
  4462. return;
  4463. }
  4464. const current = objectGroups.value.find((item) => item.id === activeGroupId.value);
  4465. if (!current) return;
  4466. const mainMember = activeGroupMembers.value.find(
  4467. (item) => item.hasMain && item.personName && item.personName !== '-',
  4468. );
  4469. editingGroupData.value = { id: current.id, name: current.name, remark: current.remark };
  4470. isEditGroupMode.value = true;
  4471. createGroupForm.groupName = current.name;
  4472. createGroupForm.mainPerson = mainMember?.personName || undefined;
  4473. createGroupForm.remark = current.remark || '';
  4474. groupNameError.value = '';
  4475. mainPersonError.value = '';
  4476. createGroupModalVisible.value = true;
  4477. };
  4478. const handleDeleteGroup = () => {
  4479. if (!activeGroupId.value) {
  4480. createMessage.warning('请先选择分组');
  4481. return;
  4482. }
  4483. const current = objectGroups.value.find((item) => item.id === activeGroupId.value);
  4484. createConfirm({
  4485. iconType: 'warning',
  4486. centered: true,
  4487. title: '确认删除分组',
  4488. content: `确定要删除分组“${current?.name || ''}”吗?删除后无法恢复。`,
  4489. okButtonProps: { type: 'primary' },
  4490. autoFocusButton: 'ok',
  4491. async onOk() {
  4492. try {
  4493. await deletePersonGroup(activeGroupId.value!);
  4494. createMessage.success('删除分组成功');
  4495. await loadGroupData();
  4496. } catch (error) {
  4497. console.error('删除分组失败', error);
  4498. createMessage.error('删除分组失败');
  4499. }
  4500. },
  4501. });
  4502. };
  4503. const handleAddObject = () => {
  4504. if (!activeGroupId.value) {
  4505. createMessage.warning('请先选择分组');
  4506. return;
  4507. }
  4508. addObjectKeyword.value = '';
  4509. selectedAddPersonNames.value = [];
  4510. addObjectModalVisible.value = true;
  4511. };
  4512. const handleCheckAddObject = (item: { name: string; disabled: boolean }, checked: boolean) => {
  4513. if (item.disabled) return;
  4514. if (checked) {
  4515. selectedAddPersonNames.value = Array.from(new Set([...selectedAddPersonNames.value, item.name]));
  4516. return;
  4517. }
  4518. selectedAddPersonNames.value = selectedAddPersonNames.value.filter((name) => name !== item.name);
  4519. };
  4520. const handleToggleAddObject = (item: { name: string; disabled: boolean }) => {
  4521. if (item.disabled) return;
  4522. const checked = !selectedAddPersonNames.value.includes(item.name);
  4523. handleCheckAddObject(item, checked);
  4524. };
  4525. const handleConfirmAddObject = async () => {
  4526. if (!activeGroupId.value) {
  4527. createMessage.warning('请先选择分组');
  4528. return;
  4529. }
  4530. if (!selectedAddPersonNames.value.length) {
  4531. createMessage.warning('请选择要添加的对象');
  4532. return;
  4533. }
  4534. addingObjectToGroup.value = true;
  4535. try {
  4536. await addPersonToGroup({
  4537. groupId: activeGroupId.value,
  4538. personList: selectedAddPersonNames.value,
  4539. });
  4540. createMessage.success('添加对象成功');
  4541. closeAddObjectModal();
  4542. await loadGroupData();
  4543. } catch (error) {
  4544. console.error('添加对象失败', error);
  4545. createMessage.error('添加对象失败');
  4546. } finally {
  4547. addingObjectToGroup.value = false;
  4548. }
  4549. };
  4550. const handleRemoveMember = async (memberId: string) => {
  4551. try {
  4552. await remPersonFromGroup(memberId);
  4553. createMessage.success('移出分组成功');
  4554. await loadGroupData();
  4555. } catch (error) {
  4556. console.error('移出分组失败', error);
  4557. createMessage.error('移出分组失败');
  4558. }
  4559. };
  4560. const handleChangeMain = async (relId: string) => {
  4561. if (!activeGroupId.value) return;
  4562. try {
  4563. await changeMainPerson(relId, activeGroupId.value);
  4564. createMessage.success('变更主要人员成功');
  4565. await loadGroupData();
  4566. } catch (error) {
  4567. console.error('变更主要人员失败', error);
  4568. createMessage.error('变更主要人员失败');
  4569. }
  4570. };
  4571. </script>
  4572. <style lang="less">
  4573. @prefix-cls: ~'jeesite-case-portrait';
  4574. .@{prefix-cls}.case-portrait-page {
  4575. padding: 0;
  4576. background: #f5f6f8;
  4577. height: 100%;
  4578. width: calc(100% + 24px);
  4579. margin: 0 -12px;
  4580. box-sizing: border-box;
  4581. overflow: hidden;
  4582. .page-main {
  4583. position: relative;
  4584. display: flex;
  4585. flex-direction: column;
  4586. gap: 12px;
  4587. min-width: 0;
  4588. height: 100%;
  4589. padding: 16px 0;
  4590. box-sizing: border-box;
  4591. overflow: hidden;
  4592. }
  4593. .page-layout {
  4594. display: grid;
  4595. grid-template-columns: 196px minmax(0, 1fr);
  4596. gap: 16px;
  4597. align-items: start;
  4598. flex: 1 1 auto;
  4599. min-height: 0;
  4600. }
  4601. .left-panel {
  4602. position: sticky;
  4603. top: 0;
  4604. align-self: start;
  4605. height: calc(100vh - 100px);
  4606. max-height: calc(100vh - 100px);
  4607. .panel-card {
  4608. position: relative;
  4609. overflow: hidden;
  4610. background: linear-gradient(180deg, #fcfdff 0%, #f4f8ff 100%);
  4611. border: 1px solid #dfe9fb;
  4612. border-radius: 22px;
  4613. padding: 16px 14px 18px;
  4614. display: flex;
  4615. flex-direction: column;
  4616. gap: 14px;
  4617. box-shadow: 0 16px 30px rgba(15, 23, 42, 0.08);
  4618. height: 100%;
  4619. box-sizing: border-box;
  4620. &::before {
  4621. content: '';
  4622. position: absolute;
  4623. left: -12px;
  4624. top: -24px;
  4625. width: 180px;
  4626. height: 140px;
  4627. background: radial-gradient(circle, rgba(96, 165, 250, 0.18) 0%, rgba(96, 165, 250, 0) 74%);
  4628. pointer-events: none;
  4629. }
  4630. }
  4631. .search-box {
  4632. position: relative;
  4633. z-index: 1;
  4634. .ant-input,
  4635. .ant-input-affix-wrapper {
  4636. height: 40px;
  4637. border-radius: 999px;
  4638. border-color: #d8e4fb;
  4639. background: rgba(255, 255, 255, 0.92);
  4640. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86);
  4641. padding-inline: 14px;
  4642. }
  4643. .ant-input-affix-wrapper {
  4644. display: flex;
  4645. align-items: center;
  4646. overflow: hidden;
  4647. box-sizing: border-box;
  4648. }
  4649. .ant-input-affix-wrapper > .ant-input {
  4650. height: 100%;
  4651. padding: 0;
  4652. border: 0;
  4653. border-radius: 0;
  4654. background: transparent;
  4655. box-shadow: none;
  4656. }
  4657. .ant-input-affix-wrapper .ant-input-suffix,
  4658. .ant-input-affix-wrapper .ant-input-clear-icon {
  4659. display: inline-flex;
  4660. align-items: center;
  4661. }
  4662. .ant-input:focus,
  4663. .ant-input-focused,
  4664. .ant-input-affix-wrapper-focused {
  4665. border-color: #8ab4ff;
  4666. box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
  4667. }
  4668. }
  4669. .left-actions {
  4670. display: flex;
  4671. position: relative;
  4672. z-index: 1;
  4673. .ant-btn {
  4674. height: 40px;
  4675. border: 0;
  4676. border-radius: 999px;
  4677. font-weight: 600;
  4678. letter-spacing: 0.04em;
  4679. background: linear-gradient(135deg, #2f7df6 0%, #56b1ff 100%);
  4680. box-shadow: 0 12px 22px rgba(59, 130, 246, 0.24);
  4681. }
  4682. }
  4683. .person-list {
  4684. position: relative;
  4685. display: flex;
  4686. flex-direction: column;
  4687. align-items: stretch;
  4688. gap: 8px;
  4689. flex: 1;
  4690. overflow-y: auto;
  4691. overflow-x: hidden;
  4692. min-height: 0;
  4693. padding: 4px 2px 0 0;
  4694. &::before {
  4695. display: none;
  4696. }
  4697. &::-webkit-scrollbar {
  4698. width: 4px;
  4699. }
  4700. &::-webkit-scrollbar-thumb {
  4701. background: #ccc;
  4702. border-radius: 2px;
  4703. }
  4704. .empty-tip {
  4705. display: flex;
  4706. flex-direction: column;
  4707. align-items: center;
  4708. justify-content: center;
  4709. gap: 10px;
  4710. position: relative;
  4711. z-index: 1;
  4712. width: 100%;
  4713. min-height: 148px;
  4714. padding: 24px 14px;
  4715. box-sizing: border-box;
  4716. border-radius: 24px;
  4717. border: 1px dashed rgba(183, 202, 234, 0.72);
  4718. background:
  4719. radial-gradient(circle at top right, rgba(119, 177, 255, 0.12), transparent 32%),
  4720. linear-gradient(180deg, rgba(251, 253, 255, 0.96) 0%, rgba(243, 247, 255, 0.92) 100%);
  4721. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92);
  4722. &::before {
  4723. content: '';
  4724. flex: 0 0 52px;
  4725. width: 52px;
  4726. height: 52px;
  4727. border-radius: 18px;
  4728. border: 1px solid rgba(187, 208, 247, 0.82);
  4729. background:
  4730. radial-gradient(circle at 50% 16px, #5b8ff9 0 5px, transparent 6px),
  4731. linear-gradient(#d5e3ff 0 0) center 34px / 26px 3px no-repeat,
  4732. linear-gradient(180deg, #76a9ff 0%, #4f7cff 100%) 17px 30px / 6px 11px no-repeat,
  4733. linear-gradient(180deg, #8fc6ff 0%, #60a5fa 100%) 25px 26px / 6px 15px no-repeat,
  4734. linear-gradient(180deg, #c4b5fd 0%, #93c5fd 100%) 33px 23px / 6px 18px no-repeat,
  4735. linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(237, 245, 255, 0.92) 100%);
  4736. background-repeat: no-repeat;
  4737. box-shadow:
  4738. inset 0 1px 0 rgba(255, 255, 255, 0.92),
  4739. 0 14px 24px rgba(59, 130, 246, 0.1);
  4740. }
  4741. }
  4742. }
  4743. .person-card {
  4744. width: 100%;
  4745. max-width: none;
  4746. display: flex;
  4747. flex-direction: row;
  4748. align-items: center;
  4749. justify-content: flex-start;
  4750. gap: 10px;
  4751. min-height: 60px;
  4752. padding: 10px 12px;
  4753. border-radius: 12px;
  4754. background: #fafafa;
  4755. border: 1px solid #e7edf5;
  4756. cursor: pointer;
  4757. transition:
  4758. border-color 0.22s ease,
  4759. box-shadow 0.22s ease,
  4760. background 0.22s ease;
  4761. position: relative;
  4762. overflow: visible;
  4763. z-index: 1;
  4764. &::before {
  4765. display: none;
  4766. }
  4767. &::after {
  4768. display: none;
  4769. }
  4770. &:hover {
  4771. background: #fff;
  4772. border-color: #d7e4f6;
  4773. box-shadow: 0 4px 10px rgba(15, 23, 42, 0.06);
  4774. .person-avatar .jeesite-person-avatar__media {
  4775. background: linear-gradient(135deg, #4d92ff 0%, #73c0ff 100%);
  4776. box-shadow: 0 8px 14px rgba(59, 130, 246, 0.18);
  4777. }
  4778. .person-name {
  4779. color: #0f172a;
  4780. }
  4781. }
  4782. &.active {
  4783. background: #ffffff;
  4784. border-color: #9ec5fe;
  4785. box-shadow:
  4786. inset 0 0 0 1px rgba(47, 125, 246, 0.12),
  4787. 0 8px 18px rgba(59, 130, 246, 0.08);
  4788. .person-avatar .jeesite-person-avatar__media {
  4789. background: linear-gradient(135deg, #2f7df6 0%, #57b3ff 100%);
  4790. box-shadow: 0 8px 14px rgba(59, 130, 246, 0.2);
  4791. }
  4792. .person-name {
  4793. color: #0f172a;
  4794. font-weight: 600;
  4795. }
  4796. }
  4797. }
  4798. .person-avatar-wrap {
  4799. position: relative;
  4800. width: 44px;
  4801. height: 44px;
  4802. flex: 0 0 44px;
  4803. display: flex;
  4804. align-items: center;
  4805. justify-content: center;
  4806. transition: none;
  4807. &::before {
  4808. display: none;
  4809. }
  4810. }
  4811. .person-avatar {
  4812. display: flex;
  4813. position: relative;
  4814. z-index: 1;
  4815. transition: all 0.22s ease;
  4816. }
  4817. .person-name {
  4818. position: relative;
  4819. z-index: 1;
  4820. flex: 1;
  4821. min-width: 0;
  4822. max-width: none;
  4823. min-height: auto;
  4824. font-size: 13px;
  4825. line-height: 1.5;
  4826. color: #5b6472;
  4827. font-weight: 500;
  4828. text-align: left;
  4829. word-break: break-word;
  4830. overflow: hidden;
  4831. display: -webkit-box;
  4832. -webkit-box-orient: vertical;
  4833. -webkit-line-clamp: 2;
  4834. transition:
  4835. color 0.22s ease,
  4836. font-weight 0.22s ease;
  4837. }
  4838. .group-btn-wrap {
  4839. margin-top: auto;
  4840. padding-top: 14px;
  4841. border-top: 1px solid rgba(209, 223, 246, 0.88);
  4842. position: relative;
  4843. z-index: 1;
  4844. .group-btn {
  4845. width: 100%;
  4846. height: 40px;
  4847. border-radius: 999px;
  4848. border-color: #d6e4ff;
  4849. color: #205ecf;
  4850. background: rgba(255, 255, 255, 0.88);
  4851. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84);
  4852. }
  4853. }
  4854. }
  4855. .right-panel {
  4856. display: flex;
  4857. flex-direction: column;
  4858. align-self: stretch;
  4859. flex: 1 1 auto;
  4860. gap: 16px;
  4861. min-width: 0;
  4862. min-height: 0;
  4863. overflow-y: auto;
  4864. padding-right: 8px;
  4865. &::-webkit-scrollbar {
  4866. width: 6px;
  4867. }
  4868. &::-webkit-scrollbar-thumb {
  4869. background: #ccc;
  4870. border-radius: 3px;
  4871. }
  4872. }
  4873. .section-card {
  4874. background: #fff;
  4875. border-radius: 12px;
  4876. padding: 16px;
  4877. box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
  4878. }
  4879. .quick-nav-shell {
  4880. display: none;
  4881. }
  4882. #section-relation {
  4883. min-height: 312px;
  4884. }
  4885. .comm-section {
  4886. padding: 14px 18px 18px;
  4887. background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
  4888. border: 1px solid #e6eefc;
  4889. box-shadow: 0 12px 28px rgba(37, 74, 151, 0.06);
  4890. }
  4891. .comm-info {
  4892. display: flex;
  4893. flex-direction: column;
  4894. gap: 10px;
  4895. font-size: 12px;
  4896. color: #475569;
  4897. }
  4898. .comm-row {
  4899. display: flex;
  4900. align-items: center;
  4901. gap: 12px;
  4902. .label {
  4903. font-weight: 600;
  4904. color: #1f2937;
  4905. }
  4906. .value {
  4907. font-weight: 600;
  4908. }
  4909. .carrier {
  4910. color: #2563eb;
  4911. }
  4912. }
  4913. .comm-grid {
  4914. display: grid;
  4915. grid-template-columns: repeat(2, minmax(0, 1fr));
  4916. gap: 8px 24px;
  4917. .grid-item {
  4918. display: flex;
  4919. gap: 6px;
  4920. color: #475569;
  4921. }
  4922. .label {
  4923. color: #6b7280;
  4924. }
  4925. .value {
  4926. font-weight: 600;
  4927. color: #1f2937;
  4928. }
  4929. }
  4930. .comm-divider {
  4931. border-top: 1px dashed #e2e8f0;
  4932. margin: 12px 0;
  4933. }
  4934. .comm-chart-row {
  4935. display: grid;
  4936. grid-template-columns: 380px 1fr;
  4937. gap: 18px;
  4938. align-items: stretch;
  4939. }
  4940. .comm-chart-header,
  4941. .trade-chart-header,
  4942. .comm-top-header,
  4943. .trade-top-header {
  4944. display: flex;
  4945. align-items: flex-start;
  4946. justify-content: space-between;
  4947. gap: 16px;
  4948. min-height: 36px;
  4949. }
  4950. .trade-section {
  4951. padding: 14px 18px 18px;
  4952. background: linear-gradient(180deg, #ffffff 0%, #fffaf5 100%);
  4953. border: 1px solid #eef0f5;
  4954. box-shadow: 0 12px 28px rgba(59, 78, 122, 0.06);
  4955. }
  4956. .trade-summary {
  4957. font-size: 12px;
  4958. color: #475569;
  4959. }
  4960. .trade-row {
  4961. display: flex;
  4962. flex-wrap: wrap;
  4963. gap: 8px 12px;
  4964. align-items: center;
  4965. .label {
  4966. font-weight: 600;
  4967. color: #1f2937;
  4968. }
  4969. .value {
  4970. font-weight: 600;
  4971. }
  4972. }
  4973. .trade-tag {
  4974. background: #eef2ff;
  4975. color: #2563eb;
  4976. padding: 2px 8px;
  4977. border-radius: 6px;
  4978. font-size: 12px;
  4979. }
  4980. .trade-chart-row {
  4981. display: grid;
  4982. grid-template-columns: 380px 1fr;
  4983. gap: 18px;
  4984. align-items: stretch;
  4985. }
  4986. .trade-chart {
  4987. display: flex;
  4988. flex-direction: column;
  4989. gap: 12px;
  4990. min-width: 0;
  4991. height: 100%;
  4992. }
  4993. .trade-kpi {
  4994. display: flex;
  4995. align-items: center;
  4996. justify-content: space-between;
  4997. flex-wrap: wrap;
  4998. gap: 10px 24px;
  4999. font-size: 14px;
  5000. color: #374151;
  5001. .trade-kpi-item {
  5002. display: flex;
  5003. align-items: center;
  5004. gap: 4px;
  5005. }
  5006. .trade-kpi-label {
  5007. color: #374151;
  5008. }
  5009. .trade-kpi-value {
  5010. font-weight: 600;
  5011. }
  5012. .down {
  5013. color: #ff8a66;
  5014. }
  5015. .up {
  5016. color: #2ac8a0;
  5017. }
  5018. }
  5019. .legend-sub {
  5020. margin-left: 18px;
  5021. color: #64748b;
  5022. font-size: 12px;
  5023. }
  5024. .bar.clickable {
  5025. cursor: pointer;
  5026. }
  5027. .trade-top-echart {
  5028. width: 100%;
  5029. height: 320px;
  5030. }
  5031. .trade-section .chart-tools,
  5032. .trade-section .trend-tools {
  5033. display: flex;
  5034. align-items: center;
  5035. flex-wrap: wrap;
  5036. gap: 6px;
  5037. font-size: 12px;
  5038. color: #64748b;
  5039. .select-input {
  5040. width: 120px;
  5041. }
  5042. .search-input {
  5043. width: 300px;
  5044. max-width: 100%;
  5045. }
  5046. .range-date-input {
  5047. width: 260px;
  5048. }
  5049. }
  5050. .trade-top-header {
  5051. display: flex;
  5052. align-items: center;
  5053. justify-content: space-between;
  5054. gap: 18px;
  5055. flex-wrap: wrap;
  5056. }
  5057. .trade-top-tools {
  5058. display: flex;
  5059. align-items: center;
  5060. justify-content: flex-end;
  5061. flex-wrap: wrap;
  5062. gap: 10px;
  5063. font-size: 14px;
  5064. color: #1f2937;
  5065. .tool-label {
  5066. color: #1f2937;
  5067. white-space: nowrap;
  5068. font-weight: 500;
  5069. }
  5070. .metric-select {
  5071. width: 118px;
  5072. }
  5073. .trade-top-range {
  5074. width: 264px;
  5075. }
  5076. .metric-select,
  5077. .trade-top-range {
  5078. .ant-select-selector,
  5079. &.ant-picker {
  5080. border-radius: 12px;
  5081. border-color: rgba(137, 153, 204, 0.22);
  5082. background: rgba(255, 255, 255, 0.72);
  5083. backdrop-filter: blur(12px);
  5084. box-shadow:
  5085. inset 0 1px 0 rgba(255, 255, 255, 0.82),
  5086. 0 10px 24px rgba(76, 88, 128, 0.08);
  5087. }
  5088. }
  5089. }
  5090. .comm-top-header {
  5091. flex-wrap: wrap;
  5092. .chart-title {
  5093. font-size: 15px;
  5094. }
  5095. }
  5096. .comm-top-tools {
  5097. display: flex;
  5098. align-items: center;
  5099. justify-content: flex-end;
  5100. flex: 1;
  5101. flex-wrap: wrap;
  5102. gap: 10px;
  5103. font-size: 14px;
  5104. color: #1f2937;
  5105. .tool-label {
  5106. color: #1f2937;
  5107. white-space: nowrap;
  5108. font-weight: 500;
  5109. }
  5110. .comm-top-range {
  5111. width: 420px;
  5112. max-width: 100%;
  5113. &.ant-picker {
  5114. border-radius: 12px;
  5115. border-color: rgba(137, 153, 204, 0.22);
  5116. background: rgba(255, 255, 255, 0.72);
  5117. backdrop-filter: blur(12px);
  5118. box-shadow:
  5119. inset 0 1px 0 rgba(255, 255, 255, 0.82),
  5120. 0 10px 24px rgba(76, 88, 128, 0.08);
  5121. }
  5122. }
  5123. }
  5124. .trade-top-dimension {
  5125. display: inline-flex;
  5126. align-items: center;
  5127. gap: 18px;
  5128. .ant-radio-wrapper {
  5129. margin-inline-end: 0;
  5130. color: #3f4756;
  5131. }
  5132. }
  5133. .visual-chart-surface {
  5134. position: relative;
  5135. display: flex;
  5136. flex: 1;
  5137. flex-direction: column;
  5138. margin-top: 12px;
  5139. border: 1px solid rgba(137, 153, 204, 0.18);
  5140. border-radius: 22px;
  5141. background: rgba(255, 255, 255, 0.7);
  5142. backdrop-filter: blur(14px);
  5143. box-sizing: border-box;
  5144. box-shadow:
  5145. inset 0 1px 0 rgba(255, 255, 255, 0.82),
  5146. 0 18px 36px rgba(42, 66, 118, 0.1);
  5147. overflow: hidden;
  5148. &::before {
  5149. content: '';
  5150. position: absolute;
  5151. inset: 0;
  5152. pointer-events: none;
  5153. background:
  5154. radial-gradient(circle at top right, rgba(255, 255, 255, 0.76), transparent 32%),
  5155. linear-gradient(180deg, rgba(255, 255, 255, 0.24) 0%, rgba(255, 255, 255, 0) 24%);
  5156. }
  5157. &::after {
  5158. content: '';
  5159. position: absolute;
  5160. width: 180px;
  5161. height: 180px;
  5162. top: -88px;
  5163. right: -78px;
  5164. border-radius: 50%;
  5165. filter: blur(4px);
  5166. opacity: 0.6;
  5167. pointer-events: none;
  5168. }
  5169. }
  5170. .visual-chart-surface > * {
  5171. position: relative;
  5172. z-index: 1;
  5173. }
  5174. .visual-chart-surface--comm {
  5175. background: linear-gradient(
  5176. 145deg,
  5177. rgba(241, 247, 255, 0.92) 0%,
  5178. rgba(234, 246, 255, 0.8) 45%,
  5179. rgba(255, 255, 255, 0.78) 100%
  5180. );
  5181. border-color: rgba(124, 165, 255, 0.2);
  5182. &::after {
  5183. background: radial-gradient(circle, rgba(102, 199, 255, 0.18) 0%, rgba(102, 199, 255, 0) 70%);
  5184. }
  5185. }
  5186. .visual-chart-surface--trade {
  5187. background: linear-gradient(
  5188. 145deg,
  5189. rgba(255, 248, 242, 0.94) 0%,
  5190. rgba(248, 244, 255, 0.82) 44%,
  5191. rgba(255, 255, 255, 0.8) 100%
  5192. );
  5193. border-color: rgba(255, 181, 134, 0.2);
  5194. &::after {
  5195. background: radial-gradient(circle, rgba(255, 153, 102, 0.18) 0%, rgba(255, 153, 102, 0) 70%);
  5196. }
  5197. }
  5198. .visual-chart-surface--ratio {
  5199. min-height: 356px;
  5200. }
  5201. .visual-chart-surface--bar {
  5202. min-height: 356px;
  5203. }
  5204. .visual-chart-surface--trend {
  5205. min-height: 446px;
  5206. padding: 18px 18px 16px;
  5207. }
  5208. .radio {
  5209. padding: 2px 8px;
  5210. border-radius: 999px;
  5211. background: #f1f5f9;
  5212. color: #64748b;
  5213. cursor: pointer;
  5214. &.active {
  5215. background: #e0e7ff;
  5216. color: #1d4ed8;
  5217. font-weight: 600;
  5218. }
  5219. }
  5220. .trade-trend {
  5221. display: flex;
  5222. flex-direction: column;
  5223. gap: 16px;
  5224. }
  5225. .trade-trend-header {
  5226. display: flex;
  5227. align-items: flex-start;
  5228. justify-content: space-between;
  5229. gap: 16px 20px;
  5230. flex-wrap: wrap;
  5231. }
  5232. .trade-trend-heading {
  5233. display: flex;
  5234. flex-direction: column;
  5235. gap: 8px;
  5236. min-width: 0;
  5237. }
  5238. .trade-trend-subtitle {
  5239. font-size: 13px;
  5240. line-height: 1.6;
  5241. color: #7b8797;
  5242. }
  5243. .trade-trend-toolbar {
  5244. display: flex;
  5245. align-items: center;
  5246. justify-content: space-between;
  5247. flex-wrap: wrap;
  5248. gap: 14px 18px;
  5249. padding: 16px 18px;
  5250. border: 1px solid rgba(255, 198, 170, 0.58);
  5251. border-radius: 20px;
  5252. background:
  5253. radial-gradient(circle at right top, rgba(255, 189, 143, 0.18), transparent 28%),
  5254. linear-gradient(145deg, rgba(255, 251, 247, 0.96) 0%, rgba(255, 244, 238, 0.92) 100%);
  5255. box-shadow:
  5256. inset 0 1px 0 rgba(255, 255, 255, 0.9),
  5257. 0 14px 28px rgba(255, 147, 106, 0.08);
  5258. }
  5259. .trade-trend-filters {
  5260. display: flex;
  5261. align-items: center;
  5262. flex: 1;
  5263. min-width: 0;
  5264. flex-wrap: wrap;
  5265. gap: 10px;
  5266. .tool-label {
  5267. font-size: 13px;
  5268. color: #374151;
  5269. white-space: nowrap;
  5270. font-weight: 500;
  5271. }
  5272. .trend-search-input {
  5273. width: 220px;
  5274. }
  5275. .trend-range-input {
  5276. width: 286px;
  5277. max-width: 100%;
  5278. }
  5279. .trend-search-input {
  5280. :deep(.ant-select-selector) {
  5281. border-radius: 12px;
  5282. border-color: rgba(174, 157, 196, 0.24);
  5283. background: rgba(255, 255, 255, 0.82);
  5284. box-shadow:
  5285. inset 0 1px 0 rgba(255, 255, 255, 0.9),
  5286. 0 10px 24px rgba(132, 110, 144, 0.08);
  5287. }
  5288. }
  5289. .trend-range-input {
  5290. border-radius: 12px;
  5291. border-color: rgba(174, 157, 196, 0.24);
  5292. background: rgba(255, 255, 255, 0.82);
  5293. box-shadow:
  5294. inset 0 1px 0 rgba(255, 255, 255, 0.9),
  5295. 0 10px 24px rgba(132, 110, 144, 0.08);
  5296. }
  5297. }
  5298. .trade-trend-dimension {
  5299. display: inline-flex;
  5300. align-items: center;
  5301. gap: 6px;
  5302. padding: 4px;
  5303. border-radius: 999px;
  5304. background: rgba(255, 255, 255, 0.76);
  5305. border: 1px solid rgba(255, 208, 184, 0.78);
  5306. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
  5307. .radio {
  5308. min-width: 38px;
  5309. padding: 5px 12px;
  5310. text-align: center;
  5311. background: transparent;
  5312. }
  5313. }
  5314. .trade-trend-chart-box {
  5315. margin-top: 0;
  5316. }
  5317. .trade-top-chart-box {
  5318. padding: 16px;
  5319. height: 100%;
  5320. box-sizing: border-box;
  5321. }
  5322. .comm-top-chart-box {
  5323. padding: 16px;
  5324. height: 100%;
  5325. box-sizing: border-box;
  5326. }
  5327. .chart-more-link {
  5328. margin-left: auto;
  5329. height: 30px;
  5330. padding: 0 12px;
  5331. border: 1px solid rgba(133, 153, 197, 0.18);
  5332. border-radius: 999px;
  5333. background: rgba(255, 255, 255, 0.56);
  5334. backdrop-filter: blur(12px);
  5335. box-shadow: 0 8px 20px rgba(76, 88, 128, 0.08);
  5336. font-size: 13px;
  5337. color: #2a5fd6;
  5338. }
  5339. .chart-more-link.ant-btn-link {
  5340. display: inline-flex;
  5341. flex: 0 0 auto;
  5342. align-items: center;
  5343. justify-content: center;
  5344. text-decoration: none;
  5345. }
  5346. .comm-top-echart {
  5347. width: 100%;
  5348. height: 300px;
  5349. }
  5350. .trend-chart-scroll {
  5351. flex: 1;
  5352. min-height: 0;
  5353. width: 100%;
  5354. overflow-x: auto;
  5355. box-sizing: border-box;
  5356. padding: 16px 4px 0;
  5357. }
  5358. .trend-chart {
  5359. position: relative;
  5360. border: 0;
  5361. border-radius: 0;
  5362. background: transparent;
  5363. box-shadow: none;
  5364. overflow: visible;
  5365. }
  5366. .trend-echart {
  5367. width: 100%;
  5368. height: 100%;
  5369. min-height: 340px;
  5370. }
  5371. .comm-chart-box {
  5372. padding: 18px 16px;
  5373. height: 100%;
  5374. justify-content: center;
  5375. box-sizing: border-box;
  5376. }
  5377. .comm-echart {
  5378. width: 100%;
  5379. height: 300px;
  5380. }
  5381. .chart-more-btn {
  5382. position: absolute;
  5383. top: 8px;
  5384. right: 8px;
  5385. z-index: 2;
  5386. }
  5387. .comm-chart {
  5388. display: flex;
  5389. flex-direction: column;
  5390. gap: 12px;
  5391. min-width: 0;
  5392. height: 100%;
  5393. }
  5394. .chart-title {
  5395. display: flex;
  5396. align-items: center;
  5397. gap: 8px;
  5398. font-size: 15px;
  5399. font-weight: 600;
  5400. color: #1e293b;
  5401. letter-spacing: 0.01em;
  5402. .flag {
  5403. width: 3px;
  5404. height: 12px;
  5405. background: linear-gradient(180deg, #64a8ff 0%, #2866ff 100%);
  5406. border-radius: 999px;
  5407. box-shadow: 0 0 0 3px rgba(100, 168, 255, 0.1);
  5408. }
  5409. }
  5410. .trade-section {
  5411. .chart-title {
  5412. .flag {
  5413. background: linear-gradient(180deg, #ffb069 0%, #ff7f50 100%);
  5414. box-shadow: 0 0 0 3px rgba(255, 176, 105, 0.1);
  5415. }
  5416. }
  5417. }
  5418. .trend-title-row {
  5419. .trend-legend-item {
  5420. display: inline-flex;
  5421. align-items: center;
  5422. gap: 8px;
  5423. margin-left: 0;
  5424. padding: 8px 14px;
  5425. border-radius: 999px;
  5426. border: 1px solid rgba(255, 201, 177, 0.58);
  5427. background: rgba(255, 255, 255, 0.72);
  5428. box-shadow:
  5429. inset 0 1px 0 rgba(255, 255, 255, 0.9),
  5430. 0 10px 22px rgba(255, 150, 112, 0.08);
  5431. cursor: pointer;
  5432. font-weight: 500;
  5433. transition:
  5434. transform 0.2s ease,
  5435. border-color 0.2s ease,
  5436. box-shadow 0.2s ease,
  5437. opacity 0.2s ease;
  5438. &:hover {
  5439. transform: translateY(-1px);
  5440. border-color: rgba(255, 167, 128, 0.66);
  5441. box-shadow:
  5442. inset 0 1px 0 rgba(255, 255, 255, 0.92),
  5443. 0 12px 24px rgba(255, 146, 108, 0.12);
  5444. }
  5445. &.is-muted {
  5446. opacity: 0.62;
  5447. box-shadow: none;
  5448. }
  5449. }
  5450. .circles {
  5451. width: 10px;
  5452. height: 10px;
  5453. flex: 0 0 10px;
  5454. border-radius: 50%;
  5455. display: inline-block;
  5456. }
  5457. .circles-text {
  5458. color: #3f4756;
  5459. font-size: 13px;
  5460. }
  5461. }
  5462. .chart-tools {
  5463. display: flex;
  5464. align-items: center;
  5465. gap: 6px;
  5466. font-size: 12px;
  5467. color: #64748b;
  5468. .date-input {
  5469. width: 120px;
  5470. }
  5471. }
  5472. .donut-wrap {
  5473. display: flex;
  5474. align-items: center;
  5475. gap: 22px;
  5476. }
  5477. .donut-legend {
  5478. display: flex;
  5479. flex-direction: column;
  5480. flex: 1;
  5481. min-width: 0;
  5482. gap: 18px;
  5483. font-size: 14px;
  5484. color: #1f2937;
  5485. .legend-main {
  5486. display: flex;
  5487. align-items: center;
  5488. flex-wrap: wrap;
  5489. gap: 4px;
  5490. line-height: 1.2;
  5491. }
  5492. .legend-title {
  5493. color: #374151;
  5494. }
  5495. .legend-rate {
  5496. font-weight: 500;
  5497. color: #374151;
  5498. }
  5499. .legend-amount {
  5500. padding-left: 24px;
  5501. line-height: 1.3;
  5502. color: #4b5563;
  5503. word-break: break-all;
  5504. }
  5505. .dot {
  5506. width: 16px;
  5507. height: 16px;
  5508. border-radius: 50%;
  5509. display: inline-block;
  5510. flex: 0 0 16px;
  5511. &.success {
  5512. background: #2ac8a0;
  5513. }
  5514. &.warning {
  5515. background: #ff8a66;
  5516. }
  5517. }
  5518. }
  5519. .trade-ratio-panel {
  5520. padding: 18px 20px 16px;
  5521. display: flex;
  5522. flex-direction: column;
  5523. gap: 20px;
  5524. height: 100%;
  5525. justify-content: space-between;
  5526. box-sizing: border-box;
  5527. }
  5528. .legend-click {
  5529. display: flex;
  5530. flex-direction: column;
  5531. gap: 8px;
  5532. padding: 12px 14px;
  5533. border: 1px solid rgba(160, 171, 207, 0.18);
  5534. border-radius: 16px;
  5535. background: rgba(255, 255, 255, 0.44);
  5536. backdrop-filter: blur(12px);
  5537. box-shadow:
  5538. inset 0 1px 0 rgba(255, 255, 255, 0.72),
  5539. 0 10px 20px rgba(74, 86, 120, 0.06);
  5540. cursor: pointer;
  5541. transition:
  5542. opacity 0.2s ease,
  5543. border-color 0.2s ease,
  5544. transform 0.2s ease;
  5545. &:hover {
  5546. opacity: 0.92;
  5547. border-color: rgba(120, 145, 215, 0.3);
  5548. transform: translateY(-1px);
  5549. }
  5550. }
  5551. .empty-state__title {
  5552. font-size: 15px;
  5553. line-height: 1.3;
  5554. font-weight: 700;
  5555. color: #526072;
  5556. letter-spacing: 0.01em;
  5557. }
  5558. .empty-state__desc {
  5559. display: none;
  5560. }
  5561. .chart-empty,
  5562. .group-left .group-list .ant-list-empty-text,
  5563. .member-list .ant-list-empty-text {
  5564. position: relative;
  5565. display: flex;
  5566. flex-direction: column;
  5567. align-items: center;
  5568. justify-content: center;
  5569. gap: 10px;
  5570. padding: 24px 18px;
  5571. box-sizing: border-box;
  5572. text-align: center;
  5573. font-size: 14px;
  5574. line-height: 1.6;
  5575. color: #8ea0b2;
  5576. border: 1px dashed rgba(183, 202, 234, 0.72);
  5577. border-radius: 20px;
  5578. background:
  5579. radial-gradient(circle at top right, rgba(119, 177, 255, 0.12), transparent 32%),
  5580. linear-gradient(180deg, rgba(251, 253, 255, 0.96) 0%, rgba(243, 247, 255, 0.92) 100%);
  5581. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92);
  5582. overflow: hidden;
  5583. &::before {
  5584. content: '';
  5585. flex: 0 0 56px;
  5586. width: 56px;
  5587. height: 56px;
  5588. border-radius: 18px;
  5589. border: 1px solid rgba(187, 208, 247, 0.82);
  5590. background:
  5591. radial-gradient(circle at 50% 16px, #5b8ff9 0 5px, transparent 6px),
  5592. linear-gradient(#d5e3ff 0 0) center 35px / 28px 3px no-repeat,
  5593. linear-gradient(180deg, #76a9ff 0%, #4f7cff 100%) 18px 31px / 6px 12px no-repeat,
  5594. linear-gradient(180deg, #8fc6ff 0%, #60a5fa 100%) 27px 26px / 6px 17px no-repeat,
  5595. linear-gradient(180deg, #c4b5fd 0%, #93c5fd 100%) 36px 22px / 6px 21px no-repeat,
  5596. linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(237, 245, 255, 0.92) 100%);
  5597. background-repeat: no-repeat;
  5598. box-shadow:
  5599. inset 0 1px 0 rgba(255, 255, 255, 0.92),
  5600. 0 14px 24px rgba(59, 130, 246, 0.1);
  5601. }
  5602. }
  5603. .chart-empty {
  5604. min-height: 156px;
  5605. margin-top: 8px;
  5606. }
  5607. .trade-trend-empty {
  5608. min-height: 446px;
  5609. margin-top: 0;
  5610. border-radius: 22px;
  5611. border-color: rgba(255, 198, 170, 0.58);
  5612. background:
  5613. radial-gradient(circle at right top, rgba(255, 188, 143, 0.18), transparent 28%),
  5614. linear-gradient(145deg, rgba(255, 251, 247, 0.96) 0%, rgba(255, 245, 240, 0.92) 100%);
  5615. color: #94a3b8;
  5616. &::before {
  5617. border-color: rgba(255, 205, 177, 0.9);
  5618. background:
  5619. radial-gradient(circle at 50% 16px, #ff9a66 0 5px, transparent 6px),
  5620. linear-gradient(#ffe2d1 0 0) center 35px / 28px 3px no-repeat,
  5621. linear-gradient(180deg, #ffba74 0%, #ff8b66 100%) 18px 31px / 6px 12px no-repeat,
  5622. linear-gradient(180deg, #ffd28f 0%, #ffba74 100%) 27px 26px / 6px 17px no-repeat,
  5623. linear-gradient(180deg, #fdc7d7 0%, #ffbe86 100%) 36px 22px / 6px 21px no-repeat,
  5624. linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 247, 240, 0.92) 100%);
  5625. background-repeat: no-repeat;
  5626. box-shadow:
  5627. inset 0 1px 0 rgba(255, 255, 255, 0.92),
  5628. 0 14px 24px rgba(255, 153, 102, 0.12);
  5629. }
  5630. }
  5631. .station-list {
  5632. display: flex;
  5633. flex-wrap: wrap;
  5634. gap: 12px;
  5635. }
  5636. .station-item {
  5637. background: #f8fafc;
  5638. border-radius: 8px;
  5639. padding: 6px 10px;
  5640. font-size: 12px;
  5641. color: #475569;
  5642. }
  5643. .trade-ratio-echart {
  5644. width: 180px;
  5645. height: 180px;
  5646. flex: 0 0 180px;
  5647. }
  5648. .donut.trade {
  5649. background: conic-gradient(#22c55e 0 9.7591%, #f97316 9.7591% 100%);
  5650. }
  5651. .station-row {
  5652. display: flex;
  5653. align-items: center;
  5654. gap: 12px;
  5655. font-size: 12px;
  5656. color: #1f2937;
  5657. margin-bottom: 8px;
  5658. }
  5659. .section-header {
  5660. display: flex;
  5661. align-items: flex-start;
  5662. justify-content: space-between;
  5663. flex-wrap: wrap;
  5664. gap: 14px 18px;
  5665. margin-bottom: 16px;
  5666. padding-bottom: 12px;
  5667. border-bottom: 1px solid rgba(221, 230, 244, 0.92);
  5668. }
  5669. .section-title {
  5670. display: inline-flex;
  5671. align-items: center;
  5672. gap: 10px;
  5673. font-size: 18px;
  5674. font-weight: 700;
  5675. line-height: 1.2;
  5676. color: #0f172a;
  5677. letter-spacing: 0.02em;
  5678. &::before {
  5679. content: '';
  5680. width: 4px;
  5681. height: 16px;
  5682. border-radius: 999px;
  5683. background: linear-gradient(180deg, #68abff 0%, #2f7df6 100%);
  5684. box-shadow: 0 0 0 3px rgba(104, 171, 255, 0.12);
  5685. }
  5686. }
  5687. .section-tools {
  5688. display: flex;
  5689. align-items: center;
  5690. justify-content: flex-end;
  5691. flex-wrap: wrap;
  5692. gap: 12px;
  5693. margin-left: auto;
  5694. }
  5695. .section-action {
  5696. padding: 0 14px;
  5697. color: #2563eb;
  5698. font-weight: 600;
  5699. }
  5700. .section-action.ant-btn-link {
  5701. display: inline-flex;
  5702. align-items: center;
  5703. justify-content: center;
  5704. height: 32px;
  5705. border: 1px solid rgba(191, 203, 228, 0.74);
  5706. border-radius: 999px;
  5707. background: rgba(255, 255, 255, 0.82);
  5708. box-shadow:
  5709. inset 0 1px 0 rgba(255, 255, 255, 0.9),
  5710. 0 10px 22px rgba(76, 88, 128, 0.06);
  5711. text-decoration: none;
  5712. &:hover {
  5713. border-color: rgba(147, 197, 253, 0.92);
  5714. background: #eff6ff;
  5715. color: #1d4ed8;
  5716. }
  5717. }
  5718. .basic-content {
  5719. display: grid;
  5720. grid-template-columns: 108px minmax(0, 1fr);
  5721. gap: 10px;
  5722. }
  5723. .basic-profile {
  5724. display: flex;
  5725. flex-direction: column;
  5726. align-items: center;
  5727. justify-content: center;
  5728. gap: 4px;
  5729. padding: 8px 6px;
  5730. border: 1px solid #eef2f7;
  5731. border-radius: 10px;
  5732. background: #fafafa;
  5733. }
  5734. .profile-avatar {
  5735. display: flex;
  5736. }
  5737. .profile-name {
  5738. font-size: 13px;
  5739. font-weight: 600;
  5740. color: #1f2937;
  5741. }
  5742. .info-grid {
  5743. display: grid;
  5744. grid-template-columns: repeat(4, minmax(0, 1fr));
  5745. gap: 5px;
  5746. }
  5747. .info-item {
  5748. font-size: 13px;
  5749. color: #334155;
  5750. display: flex;
  5751. align-items: flex-start;
  5752. gap: 2px;
  5753. min-width: 0;
  5754. min-height: 40px;
  5755. padding: 6px 8px;
  5756. border: 1px solid #eef2f7;
  5757. border-radius: 10px;
  5758. background: #fafafa;
  5759. flex-direction: column;
  5760. }
  5761. .info-label {
  5762. color: #64748b;
  5763. font-size: 12px;
  5764. line-height: 16px;
  5765. white-space: nowrap;
  5766. flex: 0 0 auto;
  5767. }
  5768. .info-value {
  5769. min-width: 0;
  5770. white-space: normal;
  5771. word-break: break-all;
  5772. overflow-wrap: anywhere;
  5773. line-height: 16px;
  5774. }
  5775. .relation-search {
  5776. width: 220px;
  5777. border-radius: 8px;
  5778. }
  5779. .relation-list {
  5780. display: grid;
  5781. grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  5782. gap: 16px;
  5783. }
  5784. .relation-card {
  5785. border: 1px solid #e2e8f0;
  5786. border-radius: 12px;
  5787. padding: 12px;
  5788. display: flex;
  5789. flex-direction: column;
  5790. gap: 10px;
  5791. position: relative;
  5792. }
  5793. .relation-avatar {
  5794. width: 40px;
  5795. height: 40px;
  5796. border-radius: 50%;
  5797. background: #e0e7ff;
  5798. color: #1d4ed8;
  5799. display: flex;
  5800. align-items: center;
  5801. justify-content: center;
  5802. font-weight: 600;
  5803. }
  5804. .relation-main {
  5805. display: flex;
  5806. flex-direction: column;
  5807. gap: 6px;
  5808. }
  5809. .relation-name {
  5810. font-weight: 600;
  5811. color: #1f2937;
  5812. }
  5813. .relation-score {
  5814. font-size: 13px;
  5815. color: #64748b;
  5816. span {
  5817. color: #1d4ed8;
  5818. font-weight: 600;
  5819. }
  5820. }
  5821. .relation-tags {
  5822. display: flex;
  5823. flex-wrap: wrap;
  5824. gap: 6px;
  5825. }
  5826. .tag {
  5827. padding: 2px 6px;
  5828. border-radius: 6px;
  5829. background: #f1f5f9;
  5830. color: #475569;
  5831. font-size: 12px;
  5832. }
  5833. .relation-action {
  5834. padding: 0;
  5835. align-self: flex-end;
  5836. color: #3b82f6;
  5837. }
  5838. }
  5839. .quick-nav {
  5840. position: absolute;
  5841. top: 66px;
  5842. right: 0;
  5843. width: 84px;
  5844. background: #fff;
  5845. border: 1px solid #e5e7eb;
  5846. border-radius: 12px;
  5847. padding: 8px 0;
  5848. display: flex;
  5849. flex-direction: column;
  5850. gap: 4px;
  5851. box-shadow: 0 8px 16px rgba(15, 23, 42, 0.08);
  5852. z-index: 20;
  5853. .nav-item {
  5854. font-size: 12px;
  5855. color: #475569;
  5856. padding: 6px 12px;
  5857. text-align: center;
  5858. cursor: pointer;
  5859. &.active {
  5860. color: #2563eb;
  5861. font-weight: 600;
  5862. }
  5863. }
  5864. }
  5865. .portrait-modal-title-block {
  5866. display: flex;
  5867. flex-direction: column;
  5868. gap: 4px;
  5869. }
  5870. .portrait-modal-title {
  5871. font-size: 20px;
  5872. line-height: 1.3;
  5873. font-weight: 700;
  5874. color: #111827;
  5875. }
  5876. .portrait-modal-subtitle {
  5877. font-size: 12px;
  5878. line-height: 1.6;
  5879. color: #6b7280;
  5880. }
  5881. :deep(.object-group-modal .ant-modal) {
  5882. width: 960px !important;
  5883. max-width: calc(100vw - 40px);
  5884. }
  5885. :deep(.object-group-modal .ant-modal-content) {
  5886. max-height: 640px;
  5887. display: flex;
  5888. flex-direction: column;
  5889. padding: 0;
  5890. overflow: hidden;
  5891. border-radius: 16px;
  5892. background: #fff;
  5893. box-shadow: 0 18px 44px rgba(15, 23, 42, 0.16);
  5894. }
  5895. :deep(.object-group-modal .ant-modal-header) {
  5896. flex: 0 0 auto;
  5897. padding: 16px 18px;
  5898. border-bottom: 1px solid #e5e7eb;
  5899. background: @modal-header-bg-color;
  5900. }
  5901. :deep(.object-group-modal .ant-modal-title) {
  5902. color: inherit;
  5903. }
  5904. :deep(.object-group-modal .ant-modal-close) {
  5905. top: 16px;
  5906. right: 18px;
  5907. color: #64748b;
  5908. }
  5909. :deep(.object-group-modal .ant-modal-close-x) {
  5910. width: 36px;
  5911. height: 36px;
  5912. }
  5913. :deep(.object-group-modal .ant-modal-body) {
  5914. flex: 1 1 auto;
  5915. min-height: 0;
  5916. overflow-y: auto;
  5917. padding: 16px 18px 18px;
  5918. background: #fff;
  5919. }
  5920. :deep(.object-group-modal .ant-modal-body::-webkit-scrollbar) {
  5921. width: 6px;
  5922. }
  5923. :deep(.object-group-modal .ant-modal-body::-webkit-scrollbar-thumb) {
  5924. background: #cbd5e1;
  5925. border-radius: 999px;
  5926. }
  5927. .group-modal-content {
  5928. display: flex;
  5929. flex-direction: column;
  5930. gap: 12px;
  5931. padding: 0;
  5932. }
  5933. .group-modal-actions {
  5934. display: flex;
  5935. align-items: center;
  5936. gap: 12px;
  5937. flex-wrap: wrap;
  5938. padding: 12px 14px;
  5939. border: 1px solid #e5e7eb;
  5940. border-radius: 14px;
  5941. background: #fafafa;
  5942. }
  5943. .group-modal-actions__info {
  5944. margin-right: auto;
  5945. font-size: 12px;
  5946. line-height: 1.6;
  5947. color: #6b7280;
  5948. }
  5949. .group-modal-actions__buttons {
  5950. display: flex;
  5951. align-items: center;
  5952. gap: 8px;
  5953. flex-wrap: wrap;
  5954. }
  5955. .group-modal-actions .head-btn {
  5956. min-width: 112px;
  5957. height: 40px;
  5958. border-radius: 12px;
  5959. padding: 0 22px;
  5960. border: 1px solid #d6deea;
  5961. background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
  5962. color: #1f2937;
  5963. font-size: 13px;
  5964. font-weight: 600;
  5965. box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
  5966. display: inline-flex;
  5967. align-items: center;
  5968. justify-content: center;
  5969. > span {
  5970. display: inline-flex;
  5971. align-items: center;
  5972. justify-content: center;
  5973. line-height: 1;
  5974. }
  5975. }
  5976. .group-modal-actions .head-btn:hover,
  5977. .group-modal-actions .head-btn:focus {
  5978. border-color: #c4cfdd;
  5979. background: linear-gradient(180deg, #ffffff 0%, #f3f6fb 100%);
  5980. color: #111827;
  5981. }
  5982. .group-modal-actions .head-btn--primary,
  5983. .group-modal-actions .head-btn--accent {
  5984. color: #1f2937;
  5985. background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
  5986. border-color: #d6deea;
  5987. }
  5988. .group-modal-actions .head-btn--danger {
  5989. color: #1f2937;
  5990. background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
  5991. border-color: #d6deea;
  5992. }
  5993. .group-modal-main {
  5994. display: grid;
  5995. grid-template-columns: 308px minmax(0, 1fr);
  5996. gap: 14px;
  5997. height: min(500px, calc(100vh - 240px));
  5998. min-height: 400px;
  5999. }
  6000. .group-left,
  6001. .group-right {
  6002. display: flex;
  6003. flex-direction: column;
  6004. min-height: 0;
  6005. overflow: hidden;
  6006. border: 1px solid #dfe6ef;
  6007. border-radius: 16px;
  6008. background: #fff;
  6009. box-shadow: none;
  6010. }
  6011. .group-panel-head {
  6012. display: flex;
  6013. align-items: center;
  6014. justify-content: space-between;
  6015. gap: 10px;
  6016. padding: 14px 16px 10px;
  6017. border-bottom: 1px solid #eef2f7;
  6018. }
  6019. .group-panel-head--right {
  6020. padding-bottom: 10px;
  6021. }
  6022. .group-panel-title {
  6023. font-size: 15px;
  6024. font-weight: 600;
  6025. color: #111827;
  6026. }
  6027. .group-panel-title-wrap {
  6028. min-width: 0;
  6029. }
  6030. .group-panel-subtitle {
  6031. margin-top: 4px;
  6032. font-size: 12px;
  6033. line-height: 1.5;
  6034. color: #6b7280;
  6035. }
  6036. .group-panel-badge {
  6037. min-width: 28px;
  6038. height: 28px;
  6039. padding: 0 10px;
  6040. border-radius: 999px;
  6041. display: inline-flex;
  6042. align-items: center;
  6043. justify-content: center;
  6044. background: #eff6ff;
  6045. color: #2563eb;
  6046. font-size: 12px;
  6047. font-weight: 700;
  6048. }
  6049. .group-left {
  6050. padding: 0;
  6051. }
  6052. .group-left .group-list .ant-list-item {
  6053. padding: 0;
  6054. border: 0;
  6055. }
  6056. .group-left .group-list .ant-list-item + .ant-list-item {
  6057. margin-top: 8px;
  6058. }
  6059. .group-left .group-list {
  6060. flex: 1;
  6061. min-height: 0;
  6062. padding: 12px;
  6063. overflow-y: auto;
  6064. }
  6065. .group-left .group-list .ant-list-empty-text {
  6066. min-height: 164px;
  6067. }
  6068. .group-select-btn {
  6069. min-height: 44px;
  6070. height: 44px;
  6071. border-radius: 12px;
  6072. padding: 0 12px;
  6073. font-size: 14px;
  6074. line-height: 42px;
  6075. box-shadow: none;
  6076. }
  6077. .group-select-btn > span {
  6078. display: block;
  6079. width: 100%;
  6080. height: 42px;
  6081. line-height: 42px;
  6082. }
  6083. .group-select-btn__name {
  6084. display: block;
  6085. width: 100%;
  6086. height: 42px;
  6087. line-height: 42px;
  6088. min-width: 0;
  6089. overflow: hidden;
  6090. text-overflow: ellipsis;
  6091. white-space: nowrap;
  6092. text-align: center;
  6093. font-weight: 600;
  6094. }
  6095. .group-select-btn.ant-btn-default {
  6096. border-color: #d7dee8;
  6097. color: #111827;
  6098. background: #fff;
  6099. }
  6100. .group-select-btn.ant-btn-default:hover {
  6101. border-color: #c7d1dd;
  6102. color: #1f2937;
  6103. background: #fafafa;
  6104. }
  6105. .group-select-btn.ant-btn-primary {
  6106. border-color: #4665eb;
  6107. background: linear-gradient(180deg, #4b68f3 0%, #3f5de6 100%);
  6108. color: #fff;
  6109. box-shadow: 0 8px 18px rgba(63, 93, 230, 0.18);
  6110. }
  6111. .group-right {
  6112. padding: 0 14px 14px;
  6113. }
  6114. .member-list {
  6115. flex: 1;
  6116. min-height: 0;
  6117. overflow-y: auto;
  6118. padding-top: 12px;
  6119. padding-right: 4px;
  6120. }
  6121. .member-list .ant-list-items {
  6122. display: flex;
  6123. flex-direction: column;
  6124. gap: 10px;
  6125. }
  6126. .member-list .ant-list-item {
  6127. padding: 14px 16px;
  6128. border: 1px solid #edf1f5;
  6129. border-radius: 14px;
  6130. background: #fff;
  6131. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
  6132. }
  6133. .member-list .ant-list-empty-text {
  6134. min-height: 188px;
  6135. }
  6136. .member-list .ant-list-item-meta {
  6137. align-items: center;
  6138. }
  6139. .member-list .ant-list-item-meta-title {
  6140. margin-bottom: 2px;
  6141. }
  6142. .member-list .ant-list-item-action {
  6143. margin-left: 18px;
  6144. }
  6145. .member-list .ant-list-item-action > li {
  6146. padding: 0 0 0 10px;
  6147. }
  6148. .member-avatar-icon {
  6149. background: #b7b7b7;
  6150. color: #fff;
  6151. box-shadow: none;
  6152. }
  6153. .member-avatar-icon svg {
  6154. width: 18px;
  6155. height: 18px;
  6156. }
  6157. .member-name-row {
  6158. display: flex;
  6159. align-items: center;
  6160. gap: 8px;
  6161. }
  6162. .member-name {
  6163. font-size: 15px;
  6164. font-weight: 600;
  6165. color: #111827;
  6166. }
  6167. .member-main-tag {
  6168. padding: 2px 8px;
  6169. border-radius: 999px;
  6170. background: #eff6ff;
  6171. color: #1d4ed8;
  6172. font-size: 12px;
  6173. font-weight: 700;
  6174. }
  6175. .member-desc {
  6176. font-size: 12px;
  6177. color: #6b7280;
  6178. }
  6179. .member-main-btn,
  6180. .member-remove-btn {
  6181. height: auto;
  6182. padding: 0;
  6183. border: 0;
  6184. border-radius: 0;
  6185. background: transparent;
  6186. font-size: 12px;
  6187. font-weight: 600;
  6188. box-shadow: none;
  6189. text-decoration: none;
  6190. }
  6191. .member-main-btn {
  6192. color: #2563eb;
  6193. &:hover {
  6194. color: #1d4ed8;
  6195. background: transparent;
  6196. border-color: transparent;
  6197. }
  6198. &.ant-btn-link:disabled,
  6199. &.ant-btn-link.ant-btn-disabled,
  6200. &.ant-btn-link[disabled] {
  6201. color: #9ca3af;
  6202. cursor: not-allowed;
  6203. background: transparent;
  6204. border-color: transparent;
  6205. box-shadow: none;
  6206. }
  6207. }
  6208. .member-remove-btn {
  6209. color: #2563eb;
  6210. &:hover {
  6211. color: #1d4ed8;
  6212. background: transparent;
  6213. border-color: transparent;
  6214. }
  6215. }
  6216. @media (max-width: 960px) {
  6217. :deep(.object-group-modal .ant-modal) {
  6218. max-width: calc(100vw - 24px);
  6219. }
  6220. :deep(.object-group-modal .ant-modal-body) {
  6221. padding: 20px;
  6222. }
  6223. .group-modal-actions {
  6224. align-items: flex-start;
  6225. }
  6226. .group-modal-actions__info {
  6227. margin-right: 0;
  6228. }
  6229. .group-modal-actions__buttons {
  6230. width: 100%;
  6231. }
  6232. .group-modal-main {
  6233. grid-template-columns: minmax(0, 1fr);
  6234. }
  6235. .group-left .group-list {
  6236. max-height: 220px;
  6237. }
  6238. .member-list .ant-list-item {
  6239. flex-wrap: wrap;
  6240. gap: 12px;
  6241. }
  6242. .member-list .ant-list-item-action {
  6243. margin-left: 0;
  6244. }
  6245. .member-list .ant-list-item-action > li {
  6246. padding-left: 0;
  6247. }
  6248. .create-group-modal {
  6249. .ant-modal {
  6250. max-width: calc(100vw - 24px);
  6251. }
  6252. .ant-modal-body {
  6253. padding: 20px;
  6254. }
  6255. .create-group-section {
  6256. padding: 16px 16px 4px;
  6257. }
  6258. }
  6259. }
  6260. .dynamic-input-list {
  6261. display: flex;
  6262. flex-direction: column;
  6263. gap: 10px;
  6264. }
  6265. .dynamic-input-item {
  6266. display: flex;
  6267. align-items: flex-start;
  6268. gap: 10px;
  6269. .ant-form-item {
  6270. flex: 1;
  6271. min-width: 0;
  6272. margin-bottom: 0;
  6273. }
  6274. .remove-btn,
  6275. .add-btn {
  6276. width: 36px;
  6277. height: 36px;
  6278. min-width: 36px;
  6279. padding: 0;
  6280. display: flex;
  6281. align-items: center;
  6282. justify-content: center;
  6283. border-radius: 12px;
  6284. border: 1px solid #dbe5f5;
  6285. background: rgba(255, 255, 255, 0.86);
  6286. color: #64748b;
  6287. margin-top: 2px;
  6288. box-shadow:
  6289. inset 0 1px 0 rgba(255, 255, 255, 0.8),
  6290. 0 8px 18px rgba(95, 108, 143, 0.08);
  6291. &:hover {
  6292. color: #3b82f6;
  6293. background: #eff6ff;
  6294. border-color: #bfdbfe;
  6295. }
  6296. }
  6297. .remove-btn:hover {
  6298. color: #ef4444;
  6299. background: #fef2f2;
  6300. border-color: #fecaca;
  6301. }
  6302. }
  6303. .person-card-popover {
  6304. .ant-popover-inner {
  6305. border-radius: 10px;
  6306. box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14);
  6307. padding: 0;
  6308. }
  6309. .ant-popover-inner-content {
  6310. padding: 6px 0;
  6311. }
  6312. .avatar-pop-content {
  6313. min-width: 86px;
  6314. }
  6315. .edit-item {
  6316. padding: 8px 14px;
  6317. font-size: 14px;
  6318. line-height: 1;
  6319. color: #334155;
  6320. cursor: pointer;
  6321. transition: all 0.2s ease;
  6322. user-select: none;
  6323. &:hover {
  6324. background: #f1f5f9;
  6325. color: #1d4ed8;
  6326. }
  6327. &.delete {
  6328. color: #ef4444;
  6329. &:hover {
  6330. background: #fff1f2;
  6331. color: #dc2626;
  6332. }
  6333. }
  6334. }
  6335. }
  6336. .create-group-modal {
  6337. .ant-modal-content {
  6338. max-height: 720px;
  6339. display: flex;
  6340. flex-direction: column;
  6341. padding: 0;
  6342. overflow: hidden;
  6343. border-radius: 14px;
  6344. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 78%);
  6345. box-shadow: 0 18px 46px rgba(15, 23, 42, 0.16);
  6346. }
  6347. .ant-modal-header {
  6348. flex: 0 0 auto;
  6349. padding: 20px;
  6350. border-bottom: 1px solid #e5e7eb;
  6351. background: @modal-header-bg-color;
  6352. }
  6353. .ant-modal-title {
  6354. font-size: 16px;
  6355. font-weight: 600;
  6356. line-height: 24px;
  6357. color: #111827;
  6358. }
  6359. .ant-modal-close {
  6360. top: 14px;
  6361. right: 16px;
  6362. color: #64748b;
  6363. border-radius: 8px;
  6364. transition: background-color 0.2s ease;
  6365. }
  6366. .ant-modal-close:hover {
  6367. background: rgba(37, 99, 235, 0.08);
  6368. }
  6369. .ant-modal-close-x {
  6370. width: 36px;
  6371. height: 36px;
  6372. }
  6373. .ant-modal-body {
  6374. flex: 1 1 auto;
  6375. min-height: 0;
  6376. overflow-y: auto;
  6377. padding: 20px;
  6378. }
  6379. .ant-modal-body::-webkit-scrollbar {
  6380. width: 6px;
  6381. }
  6382. .ant-modal-body::-webkit-scrollbar-thumb {
  6383. background: #cbd5e1;
  6384. border-radius: 3px;
  6385. }
  6386. .ant-modal-footer {
  6387. flex: 0 0 auto;
  6388. margin: 0;
  6389. padding: 20px;
  6390. border-top: 1px solid #e5e7eb;
  6391. background: #fff;
  6392. }
  6393. .create-group-form {
  6394. display: flex;
  6395. flex-direction: column;
  6396. gap: 14px;
  6397. .ant-form-item {
  6398. margin-bottom: 10px;
  6399. }
  6400. .ant-form-item-label > label {
  6401. color: #374151;
  6402. font-weight: 600;
  6403. }
  6404. .ant-input,
  6405. .ant-select-selector,
  6406. .ant-input-affix-wrapper {
  6407. height: 42px;
  6408. border-radius: 8px;
  6409. border-color: #dbe2ea;
  6410. background: #fff;
  6411. box-shadow: none;
  6412. transition:
  6413. border-color 0.2s ease,
  6414. box-shadow 0.2s ease,
  6415. background 0.2s ease;
  6416. }
  6417. .ant-select-single .ant-select-selector {
  6418. display: flex;
  6419. align-items: center;
  6420. }
  6421. .ant-select-single .ant-select-selector .ant-select-selection-item,
  6422. .ant-select-single .ant-select-selector .ant-select-selection-placeholder {
  6423. line-height: 40px;
  6424. }
  6425. .ant-input-textarea textarea {
  6426. min-height: 108px;
  6427. border-radius: 8px;
  6428. border-color: #dbe2ea;
  6429. background: #fff;
  6430. box-shadow: none;
  6431. resize: none;
  6432. }
  6433. .ant-input:focus,
  6434. .ant-input-focused,
  6435. .ant-select-focused .ant-select-selector,
  6436. .ant-select-open .ant-select-selector,
  6437. .ant-input-affix-wrapper-focused,
  6438. .ant-input-textarea textarea:focus {
  6439. border-color: #1677ff;
  6440. box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
  6441. background: #fff;
  6442. }
  6443. }
  6444. .create-group-section {
  6445. padding: 16px 16px 4px;
  6446. border: 1px solid #e2e8f0;
  6447. border-radius: 12px;
  6448. background: linear-gradient(180deg, #ffffff 0%, #fcfdff 100%);
  6449. box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
  6450. }
  6451. .create-group-section__head {
  6452. margin-bottom: 14px;
  6453. padding-bottom: 10px;
  6454. border-bottom: 1px solid #eef2f7;
  6455. }
  6456. .create-group-section__title {
  6457. font-size: 15px;
  6458. line-height: 1.3;
  6459. color: #111827;
  6460. font-weight: 700;
  6461. }
  6462. .create-group-section__desc {
  6463. margin-top: 4px;
  6464. font-size: 12px;
  6465. line-height: 1.5;
  6466. color: #64748b;
  6467. }
  6468. }
  6469. .add-group-person-modal {
  6470. .ant-modal {
  6471. max-width: calc(100vw - 24px);
  6472. }
  6473. .ant-modal-content {
  6474. max-height: min(760px, calc(100vh - 32px));
  6475. display: flex;
  6476. flex-direction: column;
  6477. padding: 0;
  6478. overflow: hidden;
  6479. border-radius: 14px;
  6480. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 78%);
  6481. box-shadow: 0 18px 46px rgba(15, 23, 42, 0.16);
  6482. }
  6483. .ant-modal-header {
  6484. flex: 0 0 auto;
  6485. padding: 20px;
  6486. border-bottom: 1px solid #e5e7eb;
  6487. background: @modal-header-bg-color;
  6488. }
  6489. .ant-modal-title {
  6490. font-size: 16px;
  6491. font-weight: 600;
  6492. line-height: 24px;
  6493. color: #111827;
  6494. }
  6495. .ant-modal-close {
  6496. top: 14px;
  6497. right: 16px;
  6498. color: #64748b;
  6499. border-radius: 8px;
  6500. transition: background-color 0.2s ease;
  6501. }
  6502. .ant-modal-close:hover {
  6503. background: rgba(37, 99, 235, 0.08);
  6504. }
  6505. .ant-modal-close-x {
  6506. width: 36px;
  6507. height: 36px;
  6508. }
  6509. .ant-modal-body {
  6510. flex: 1 1 auto;
  6511. display: flex;
  6512. flex-direction: column;
  6513. min-height: 0;
  6514. padding: 20px;
  6515. overflow: hidden;
  6516. }
  6517. .ant-modal-footer {
  6518. flex: 0 0 auto;
  6519. margin: 0;
  6520. padding: 20px;
  6521. border-top: 1px solid #e5e7eb;
  6522. background: #fff;
  6523. }
  6524. }
  6525. .add-object-modal {
  6526. display: flex;
  6527. flex-direction: column;
  6528. flex: 1 1 auto;
  6529. gap: 14px;
  6530. min-height: 0;
  6531. overflow: hidden;
  6532. }
  6533. .add-object-modal__search {
  6534. display: flex;
  6535. flex-direction: column;
  6536. gap: 12px;
  6537. padding: 14px 16px;
  6538. border: 1px solid #e2e8f0;
  6539. border-radius: 12px;
  6540. background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
  6541. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
  6542. .ant-input,
  6543. .ant-input-affix-wrapper {
  6544. border-radius: 8px;
  6545. }
  6546. }
  6547. .add-object-modal__search-head {
  6548. display: flex;
  6549. align-items: flex-start;
  6550. justify-content: space-between;
  6551. gap: 12px;
  6552. }
  6553. .add-object-modal__title {
  6554. font-size: 15px;
  6555. font-weight: 700;
  6556. color: #111827;
  6557. }
  6558. .add-object-modal__desc,
  6559. .add-object-modal__stats {
  6560. font-size: 12px;
  6561. line-height: 1.6;
  6562. color: #64748b;
  6563. }
  6564. .add-object-modal__content {
  6565. flex: 1 1 auto;
  6566. display: flex;
  6567. flex-direction: column;
  6568. min-height: 0;
  6569. overflow: hidden;
  6570. padding: 0 2px 2px;
  6571. }
  6572. .add-object-list {
  6573. flex: 1 1 auto;
  6574. min-height: 0;
  6575. overflow-y: auto;
  6576. padding-right: 4px;
  6577. padding-bottom: 2px;
  6578. }
  6579. .add-object-item {
  6580. display: flex;
  6581. align-items: center;
  6582. gap: 14px;
  6583. padding: 14px 16px;
  6584. border: 1px solid #e5e7eb;
  6585. border-radius: 14px;
  6586. background: #fff;
  6587. cursor: pointer;
  6588. transition: all 0.2s ease;
  6589. & + .add-object-item {
  6590. margin-top: 10px;
  6591. }
  6592. &:hover {
  6593. border-color: #cbd5e1;
  6594. box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
  6595. }
  6596. &.is-selected {
  6597. border-color: #93c5fd;
  6598. background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
  6599. box-shadow: 0 10px 22px rgba(59, 130, 246, 0.08);
  6600. }
  6601. &.is-disabled {
  6602. cursor: not-allowed;
  6603. opacity: 0.72;
  6604. background: #f8fafc;
  6605. }
  6606. }
  6607. .add-object-item__avatar {
  6608. flex: 0 0 auto;
  6609. background: linear-gradient(135deg, #e0ecff 0%, #c7dbff 100%);
  6610. color: #3157d5;
  6611. font-weight: 700;
  6612. }
  6613. .add-object-item__body {
  6614. flex: 1;
  6615. min-width: 0;
  6616. }
  6617. .add-object-item__head {
  6618. display: flex;
  6619. align-items: center;
  6620. gap: 8px;
  6621. min-width: 0;
  6622. }
  6623. .add-object-item__name {
  6624. min-width: 0;
  6625. font-size: 15px;
  6626. font-weight: 700;
  6627. color: #111827;
  6628. overflow: hidden;
  6629. text-overflow: ellipsis;
  6630. white-space: nowrap;
  6631. }
  6632. .add-object-item__tag {
  6633. flex: 0 0 auto;
  6634. padding: 1px 8px;
  6635. border-radius: 999px;
  6636. background: #e2e8f0;
  6637. color: #475569;
  6638. font-size: 12px;
  6639. line-height: 20px;
  6640. }
  6641. .add-object-item__meta {
  6642. display: flex;
  6643. flex-wrap: wrap;
  6644. gap: 8px 16px;
  6645. margin-top: 6px;
  6646. font-size: 12px;
  6647. line-height: 1.7;
  6648. color: #64748b;
  6649. }
  6650. .add-object-modal__empty {
  6651. display: flex;
  6652. align-items: center;
  6653. justify-content: center;
  6654. min-height: 320px;
  6655. border: 1px dashed #d7dee8;
  6656. border-radius: 14px;
  6657. background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
  6658. color: #94a3b8;
  6659. font-size: 14px;
  6660. }
  6661. .person-edit-modal {
  6662. .ant-modal-content {
  6663. max-height: 720px;
  6664. display: flex;
  6665. flex-direction: column;
  6666. padding: 0;
  6667. overflow: hidden;
  6668. border-radius: 14px;
  6669. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 78%);
  6670. box-shadow: 0 18px 46px rgba(15, 23, 42, 0.16);
  6671. }
  6672. .ant-modal-header {
  6673. flex: 0 0 auto;
  6674. padding: 20px;
  6675. border-bottom: 1px solid #e5e7eb;
  6676. background: @modal-header-bg-color;
  6677. }
  6678. .ant-modal-title {
  6679. color: inherit;
  6680. }
  6681. .ant-modal-close {
  6682. top: 14px;
  6683. right: 16px;
  6684. color: #64748b;
  6685. border-radius: 8px;
  6686. transition: background-color 0.2s ease;
  6687. }
  6688. .ant-modal-close:hover {
  6689. background: rgba(37, 99, 235, 0.08);
  6690. }
  6691. .ant-modal-close-x {
  6692. width: 36px;
  6693. height: 36px;
  6694. }
  6695. .ant-modal-body {
  6696. flex: 1 1 auto;
  6697. min-height: 0;
  6698. overflow-y: auto;
  6699. padding: 20px;
  6700. }
  6701. .ant-modal-body::-webkit-scrollbar {
  6702. width: 6px;
  6703. }
  6704. .ant-modal-body::-webkit-scrollbar-thumb {
  6705. background: #cbd5e1;
  6706. border-radius: 3px;
  6707. }
  6708. .ant-modal-footer {
  6709. flex: 0 0 auto;
  6710. margin: 0;
  6711. padding: 20px;
  6712. border-top: 1px solid #e5e7eb;
  6713. background: #fff;
  6714. }
  6715. .person-form {
  6716. display: flex;
  6717. flex-direction: column;
  6718. gap: 14px;
  6719. }
  6720. .person-form-section {
  6721. padding: 16px 16px 4px;
  6722. border: 1px solid #e2e8f0;
  6723. border-radius: 12px;
  6724. background: linear-gradient(180deg, #ffffff 0%, #fcfdff 100%);
  6725. box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
  6726. }
  6727. .form-section-head {
  6728. display: flex;
  6729. align-items: center;
  6730. justify-content: space-between;
  6731. gap: 12px;
  6732. margin-bottom: 14px;
  6733. padding-bottom: 10px;
  6734. border-bottom: 1px solid #eef2f7;
  6735. }
  6736. .form-section-title {
  6737. font-size: 15px;
  6738. line-height: 1.3;
  6739. color: #111827;
  6740. font-weight: 700;
  6741. }
  6742. .form-section-desc {
  6743. margin-top: 4px;
  6744. font-size: 12px;
  6745. line-height: 1.5;
  6746. color: #6b7280;
  6747. }
  6748. .person-form-grid {
  6749. display: grid;
  6750. grid-template-columns: repeat(2, minmax(0, 1fr));
  6751. column-gap: 16px;
  6752. row-gap: 6px;
  6753. }
  6754. .form-item-span-2 {
  6755. grid-column: 1 / -1;
  6756. }
  6757. .person-form-grid .ant-form-item {
  6758. margin-bottom: 10px;
  6759. }
  6760. .person-form-grid .ant-form-item-label > label {
  6761. font-size: 13px;
  6762. font-weight: 600;
  6763. color: #374151;
  6764. }
  6765. .person-form-grid .ant-form-item .ant-input,
  6766. .person-form-grid .ant-form-item .ant-input-affix-wrapper,
  6767. .person-form-grid .ant-form-item .ant-picker {
  6768. width: 100%;
  6769. height: 42px;
  6770. border-radius: 8px;
  6771. border-color: #dbe2ea;
  6772. background: #fff;
  6773. box-shadow: none;
  6774. transition:
  6775. border-color 0.2s ease,
  6776. box-shadow 0.2s ease,
  6777. background 0.2s ease;
  6778. }
  6779. .person-form-grid .ant-form-item .ant-input:focus,
  6780. .person-form-grid .ant-form-item .ant-input-focused,
  6781. .person-form-grid .ant-form-item .ant-input-affix-wrapper-focused,
  6782. .person-form-grid .ant-form-item .ant-picker-focused {
  6783. border-color: #1677ff;
  6784. box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
  6785. background: #fff;
  6786. }
  6787. .person-form-grid .ant-form-item .ant-select {
  6788. width: 100%;
  6789. }
  6790. .person-form-grid .ant-form-item .ant-select-selector {
  6791. height: 42px;
  6792. border-radius: 8px;
  6793. border-color: #dbe2ea;
  6794. background: #fff;
  6795. box-shadow: none;
  6796. transition:
  6797. border-color 0.2s ease,
  6798. box-shadow 0.2s ease,
  6799. background 0.2s ease;
  6800. }
  6801. .person-form-grid .ant-form-item .ant-select-focused .ant-select-selector,
  6802. .person-form-grid .ant-form-item .ant-select-open .ant-select-selector {
  6803. border-color: #1677ff;
  6804. box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
  6805. background: #fff;
  6806. }
  6807. .person-form-grid .ant-form-item .ant-select-single .ant-select-selector .ant-select-selection-item,
  6808. .person-form-grid .ant-form-item .ant-select-single .ant-select-selector .ant-select-selection-placeholder {
  6809. line-height: 40px;
  6810. }
  6811. .person-form-grid .dynamic-input-list {
  6812. gap: 12px;
  6813. }
  6814. .person-avatar-form-item {
  6815. margin-bottom: 12px;
  6816. }
  6817. .person-avatar-upload {
  6818. display: flex;
  6819. align-items: center;
  6820. gap: 16px;
  6821. padding: 14px 16px;
  6822. border: 1px solid #e2e8f0;
  6823. border-radius: 12px;
  6824. background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
  6825. }
  6826. .person-avatar-upload__input {
  6827. display: none;
  6828. width: 0;
  6829. height: 0;
  6830. opacity: 0;
  6831. pointer-events: none;
  6832. }
  6833. .person-avatar-upload__preview {
  6834. display: inline-flex;
  6835. align-items: center;
  6836. justify-content: center;
  6837. padding: 0;
  6838. border: 0;
  6839. background: transparent;
  6840. cursor: pointer;
  6841. flex: 0 0 auto;
  6842. }
  6843. .person-avatar-upload__preview :deep(.jeesite-person-avatar) {
  6844. border-radius: 999px;
  6845. transition:
  6846. transform 0.2s ease,
  6847. box-shadow 0.2s ease;
  6848. }
  6849. .person-avatar-upload__preview:hover :deep(.jeesite-person-avatar) {
  6850. transform: translateY(-1px);
  6851. box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
  6852. }
  6853. .person-avatar-upload__tips {
  6854. min-width: 0;
  6855. flex: 1 1 auto;
  6856. }
  6857. .person-avatar-upload__title {
  6858. font-size: 14px;
  6859. line-height: 22px;
  6860. font-weight: 600;
  6861. color: #111827;
  6862. }
  6863. .person-avatar-upload__desc {
  6864. margin-top: 4px;
  6865. font-size: 12px;
  6866. line-height: 20px;
  6867. color: #6b7280;
  6868. }
  6869. .person-avatar-upload__actions {
  6870. display: flex;
  6871. align-items: center;
  6872. gap: 12px;
  6873. margin-top: 12px;
  6874. flex-wrap: wrap;
  6875. }
  6876. .person-form-grid .dynamic-input-item {
  6877. padding: 12px 12px 10px;
  6878. border: 1px solid #e2e8f0;
  6879. border-radius: 10px;
  6880. background: #fff;
  6881. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
  6882. }
  6883. .person-form-grid .dynamic-input-item .ant-form-item {
  6884. margin-bottom: 0;
  6885. }
  6886. .person-form-grid .dynamic-input-item .ant-form-item-explain {
  6887. margin-top: 6px;
  6888. }
  6889. .person-form-grid .dynamic-input-item .add-btn,
  6890. .person-form-grid .dynamic-input-item .remove-btn {
  6891. width: 32px;
  6892. height: 32px;
  6893. min-width: 32px;
  6894. padding: 0;
  6895. display: flex;
  6896. align-items: center;
  6897. justify-content: center;
  6898. border-radius: 8px;
  6899. border: 1px solid #dbe2ea;
  6900. background: #fff;
  6901. color: #475569;
  6902. margin-top: 4px;
  6903. box-shadow: none;
  6904. }
  6905. .person-form-grid .dynamic-input-item .add-btn:hover {
  6906. color: #2563eb;
  6907. border-color: #bfdbfe;
  6908. background: #eff6ff;
  6909. }
  6910. .person-form-grid .dynamic-input-item .remove-btn:hover {
  6911. color: #dc2626;
  6912. border-color: #fecaca;
  6913. background: #fff1f2;
  6914. }
  6915. }
  6916. @media (max-width: 1200px) {
  6917. .person-edit-modal {
  6918. .ant-modal-body {
  6919. padding: 20px;
  6920. }
  6921. .person-form-grid {
  6922. grid-template-columns: minmax(0, 1fr);
  6923. }
  6924. .form-item-span-2 {
  6925. grid-column: auto;
  6926. }
  6927. .person-avatar-upload {
  6928. align-items: flex-start;
  6929. flex-direction: column;
  6930. }
  6931. }
  6932. }
  6933. .call-oper-phone-modal {
  6934. .query-table-wrap {
  6935. gap: 14px;
  6936. }
  6937. .query-table-search {
  6938. margin: 6px 6px 0;
  6939. padding: 14px 16px;
  6940. border: 1px solid #e2e8f0;
  6941. border-radius: 12px;
  6942. background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
  6943. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
  6944. .label {
  6945. color: #475569;
  6946. font-size: 12px;
  6947. font-weight: 600;
  6948. }
  6949. .ant-input,
  6950. .ant-input-affix-wrapper,
  6951. .ant-picker,
  6952. .ant-select-selector {
  6953. border-radius: 8px !important;
  6954. }
  6955. .ant-btn {
  6956. height: 32px;
  6957. padding: 0 16px;
  6958. border-radius: 8px;
  6959. }
  6960. }
  6961. .ant-modal-body > .scroll-container,
  6962. .ant-modal-body > .scroll-container > .scrollbar__wrap,
  6963. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view,
  6964. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view > div {
  6965. height: 100%;
  6966. min-height: 0;
  6967. }
  6968. .ant-modal-body > .scroll-container > .scrollbar__wrap {
  6969. overflow: hidden;
  6970. }
  6971. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view {
  6972. margin: 0 !important;
  6973. }
  6974. .ant-modal-body > .scroll-container > .scrollbar__bar.is-vertical,
  6975. .ant-modal-body > .scroll-container > .is-vertical {
  6976. display: none !important;
  6977. opacity: 0 !important;
  6978. pointer-events: none;
  6979. }
  6980. }
  6981. .@{prefix-cls}.case-portrait-page {
  6982. background: #fff;
  6983. // padding: 0 8px 2px;
  6984. display: flex;
  6985. flex-direction: column;
  6986. min-height: 0;
  6987. :deep(.ant-btn) {
  6988. border-radius: 8px;
  6989. }
  6990. :deep(.ant-btn-primary) {
  6991. box-shadow: none;
  6992. }
  6993. :deep(.ant-input),
  6994. :deep(.ant-input-affix-wrapper),
  6995. :deep(.ant-picker),
  6996. :deep(.ant-select-selector) {
  6997. border-color: #dbe2ea;
  6998. border-radius: 8px;
  6999. background: #fff;
  7000. box-shadow: none;
  7001. }
  7002. :deep(.ant-input:hover),
  7003. :deep(.ant-input-affix-wrapper:hover),
  7004. :deep(.ant-picker:hover),
  7005. :deep(.ant-select:not(.ant-select-disabled):hover .ant-select-selector) {
  7006. border-color: #cbd5e1;
  7007. }
  7008. :deep(.ant-input:focus),
  7009. :deep(.ant-input-focused),
  7010. :deep(.ant-input-affix-wrapper-focused),
  7011. :deep(.ant-picker-focused),
  7012. :deep(.ant-select-focused .ant-select-selector),
  7013. :deep(.ant-select-open .ant-select-selector) {
  7014. border-color: #1677ff;
  7015. box-shadow: 0 0 0 2px rgb(22 119 255 / 12%);
  7016. }
  7017. .page-main {
  7018. display: flex;
  7019. flex-direction: column;
  7020. flex: 1 1 auto;
  7021. min-height: 0;
  7022. height: auto;
  7023. gap: 12px;
  7024. padding: 0;
  7025. overflow: hidden;
  7026. }
  7027. .page-layout {
  7028. flex: 1 1 auto;
  7029. min-height: 0;
  7030. height: auto;
  7031. align-items: stretch;
  7032. grid-template-columns: 156px minmax(0, 1fr);
  7033. gap: 2px;
  7034. padding: 2px;
  7035. background: #f3f4f6;
  7036. overflow: hidden;
  7037. box-sizing: border-box;
  7038. }
  7039. .left-panel {
  7040. position: relative;
  7041. top: auto;
  7042. align-self: stretch;
  7043. display: flex;
  7044. height: auto;
  7045. max-height: none;
  7046. min-height: 0;
  7047. overflow: hidden;
  7048. }
  7049. .left-panel .panel-card {
  7050. flex: 1 1 auto;
  7051. width: 100%;
  7052. height: auto;
  7053. min-height: 0;
  7054. max-height: 100%;
  7055. background: #fff;
  7056. border: 1px solid #e2e8f0;
  7057. border-radius: 12px;
  7058. padding: 8px;
  7059. gap: 8px;
  7060. box-shadow: none;
  7061. }
  7062. .left-panel .panel-card::before,
  7063. .person-list::before,
  7064. .person-card::before,
  7065. .person-card::after,
  7066. .person-avatar-wrap::before {
  7067. display: none;
  7068. }
  7069. .panel-heading {
  7070. display: flex;
  7071. align-items: center;
  7072. justify-content: space-between;
  7073. gap: 8px;
  7074. padding: 0 4px 2px;
  7075. }
  7076. .panel-heading__title {
  7077. font-size: 14px;
  7078. font-weight: 600;
  7079. color: #111827;
  7080. }
  7081. .panel-heading__badge {
  7082. min-width: 28px;
  7083. height: 24px;
  7084. padding: 0 8px;
  7085. border-radius: 999px;
  7086. display: inline-flex;
  7087. align-items: center;
  7088. justify-content: center;
  7089. background: #eff6ff;
  7090. color: #1d4ed8;
  7091. font-size: 12px;
  7092. font-weight: 600;
  7093. }
  7094. .search-box :deep(.ant-input),
  7095. .search-box :deep(.ant-input-affix-wrapper) {
  7096. height: 34px;
  7097. padding-inline: 10px;
  7098. border-radius: 8px;
  7099. background: #fff;
  7100. }
  7101. .search-box :deep(.ant-input-affix-wrapper) {
  7102. display: flex;
  7103. align-items: center;
  7104. overflow: hidden;
  7105. box-sizing: border-box;
  7106. }
  7107. .search-box :deep(.ant-input-affix-wrapper > .ant-input) {
  7108. height: 100%;
  7109. padding: 0;
  7110. border: 0;
  7111. border-radius: 0;
  7112. background: transparent;
  7113. box-shadow: none;
  7114. }
  7115. .search-box :deep(.ant-input-affix-wrapper .ant-input-suffix),
  7116. .search-box :deep(.ant-input-affix-wrapper .ant-input-clear-icon) {
  7117. display: inline-flex;
  7118. align-items: center;
  7119. }
  7120. .left-panel .panel-card .side-action-btn.ant-btn {
  7121. height: 34px;
  7122. border-radius: 8px;
  7123. font-weight: 600;
  7124. letter-spacing: normal;
  7125. box-shadow: none;
  7126. transition:
  7127. border-color 0.2s ease,
  7128. background-color 0.2s ease,
  7129. color 0.2s ease;
  7130. }
  7131. .left-panel .panel-card .side-action-btn--primary.ant-btn {
  7132. border-color: #1677ff;
  7133. background: #1677ff;
  7134. color: #fff;
  7135. &:hover,
  7136. &:focus {
  7137. border-color: #4096ff;
  7138. background: #4096ff;
  7139. color: #fff;
  7140. }
  7141. &:active {
  7142. border-color: #0958d9;
  7143. background: #0958d9;
  7144. color: #fff;
  7145. }
  7146. }
  7147. .person-list {
  7148. flex: 1 1 auto;
  7149. min-height: 0;
  7150. align-items: stretch;
  7151. gap: 8px;
  7152. padding: 2px 0 0;
  7153. }
  7154. .person-list .empty-tip {
  7155. width: 100%;
  7156. min-height: 148px;
  7157. padding: 0;
  7158. gap: 0;
  7159. border: 0;
  7160. border-radius: 0;
  7161. background: transparent;
  7162. box-shadow: none;
  7163. }
  7164. .person-list .empty-tip::before {
  7165. content: none;
  7166. display: none;
  7167. }
  7168. .person-card {
  7169. width: 100%;
  7170. max-width: none;
  7171. min-height: 0;
  7172. padding: 8px 10px;
  7173. border: 1px solid #eef2f7;
  7174. border-radius: 10px;
  7175. background: #fff;
  7176. flex-direction: row;
  7177. align-items: center;
  7178. justify-content: flex-start;
  7179. gap: 10px;
  7180. transition:
  7181. border-color 0.2s ease,
  7182. background 0.2s ease,
  7183. box-shadow 0.2s ease;
  7184. &:hover {
  7185. transform: none;
  7186. background: #fff;
  7187. border-color: #91caff;
  7188. box-shadow: none;
  7189. }
  7190. &.active {
  7191. transform: none;
  7192. background: rgba(247, 251, 255, 0.72);
  7193. border-color: #1677ff;
  7194. box-shadow: none;
  7195. }
  7196. }
  7197. .person-card:hover .person-avatar .jeesite-person-avatar__media,
  7198. .person-card.active .person-avatar .jeesite-person-avatar__media {
  7199. background: #1677ff;
  7200. border-color: #bfdbfe;
  7201. box-shadow: none;
  7202. }
  7203. .person-card:hover .person-name,
  7204. .person-card.active .person-name {
  7205. color: #111827;
  7206. font-weight: 600;
  7207. }
  7208. .person-avatar-wrap {
  7209. width: 40px;
  7210. height: 40px;
  7211. flex: 0 0 40px;
  7212. transform: none;
  7213. }
  7214. .person-trigger {
  7215. display: flex;
  7216. align-items: center;
  7217. gap: 10px;
  7218. flex: 1;
  7219. min-width: 0;
  7220. cursor: pointer;
  7221. }
  7222. .person-avatar {
  7223. background: transparent;
  7224. box-shadow: none;
  7225. }
  7226. .person-name {
  7227. flex: 1;
  7228. min-width: 0;
  7229. max-width: none;
  7230. min-height: auto;
  7231. font-size: 13px;
  7232. line-height: 20px;
  7233. text-align: left;
  7234. color: #374151;
  7235. letter-spacing: 0;
  7236. }
  7237. .left-panel .panel-card .group-btn-wrap {
  7238. margin-top: auto;
  7239. flex-shrink: 0;
  7240. padding-top: 8px;
  7241. padding-bottom: 2px;
  7242. border-top: 1px solid #eef2f7;
  7243. background: #fff;
  7244. position: relative;
  7245. z-index: 2;
  7246. }
  7247. .left-panel .panel-card .group-btn-wrap .group-btn.ant-btn {
  7248. width: 100%;
  7249. border-color: #dbe2ea;
  7250. background: #fff;
  7251. color: #1677ff;
  7252. &:hover,
  7253. &:focus {
  7254. border-color: #bfdbfe;
  7255. background: #f8fbff;
  7256. color: #1d4ed8;
  7257. }
  7258. &:active {
  7259. border-color: #91caff;
  7260. background: #e6f4ff;
  7261. color: #0958d9;
  7262. }
  7263. }
  7264. .right-panel {
  7265. min-height: 0;
  7266. height: auto;
  7267. gap: 2px;
  7268. padding-right: 0;
  7269. background: #f3f4f6;
  7270. }
  7271. .right-panel::-webkit-scrollbar-thumb {
  7272. background: #d1d5db;
  7273. }
  7274. .section-card,
  7275. .comm-section,
  7276. .trade-section {
  7277. padding: 16px;
  7278. border: 1px solid #e2e8f0;
  7279. border-radius: 12px;
  7280. background: #fff;
  7281. box-shadow: none;
  7282. }
  7283. .section-header {
  7284. margin-bottom: 16px;
  7285. padding-bottom: 12px;
  7286. gap: 12px 16px;
  7287. border-bottom: 1px solid #eef2f7;
  7288. }
  7289. .section-title {
  7290. gap: 8px;
  7291. font-size: 16px;
  7292. font-weight: 600;
  7293. color: #111827;
  7294. letter-spacing: 0;
  7295. &::before {
  7296. width: 3px;
  7297. height: 14px;
  7298. background: #1677ff;
  7299. box-shadow: none;
  7300. }
  7301. }
  7302. .section-tools {
  7303. gap: 8px;
  7304. }
  7305. .section-action {
  7306. padding: 0 12px;
  7307. color: #1677ff;
  7308. font-weight: 500;
  7309. }
  7310. .section-action.ant-btn-link {
  7311. height: 32px;
  7312. border: 1px solid #dbe2ea;
  7313. border-radius: 8px;
  7314. background: #fff;
  7315. box-shadow: none;
  7316. &:hover {
  7317. border-color: #bfdbfe;
  7318. background: #f9fafb;
  7319. color: #0958d9;
  7320. }
  7321. }
  7322. .basic-content {
  7323. grid-template-columns: 108px minmax(0, 1fr);
  7324. gap: 10px;
  7325. }
  7326. .basic-profile {
  7327. gap: 4px;
  7328. padding: 8px 6px;
  7329. border: 1px solid #eef2f7;
  7330. border-radius: 10px;
  7331. background: #fafafa;
  7332. justify-content: center;
  7333. }
  7334. .profile-avatar {
  7335. width: 56px;
  7336. height: 56px;
  7337. border-radius: 50%;
  7338. background: #1677ff;
  7339. box-shadow: none;
  7340. font-size: 22px;
  7341. }
  7342. .profile-name {
  7343. color: #111827;
  7344. font-size: 13px;
  7345. }
  7346. .info-grid {
  7347. grid-template-columns: repeat(4, minmax(0, 1fr));
  7348. gap: 5px;
  7349. }
  7350. .info-item {
  7351. gap: 2px;
  7352. min-height: 40px;
  7353. padding: 6px 8px;
  7354. border: 1px solid #eef2f7;
  7355. border-radius: 10px;
  7356. background: #fafafa;
  7357. flex-direction: column;
  7358. }
  7359. .info-label {
  7360. font-size: 12px;
  7361. color: #6b7280;
  7362. line-height: 16px;
  7363. }
  7364. .info-value {
  7365. color: #111827;
  7366. font-size: 13px;
  7367. line-height: 16px;
  7368. }
  7369. .relation-search {
  7370. width: 240px;
  7371. max-width: 100%;
  7372. }
  7373. .relation-list {
  7374. grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  7375. gap: 12px;
  7376. }
  7377. .relation-card {
  7378. gap: 12px;
  7379. padding: 14px;
  7380. border: 1px solid #e5e7eb;
  7381. border-radius: 12px;
  7382. background: #fff;
  7383. box-shadow: none;
  7384. transition:
  7385. border-color 0.2s ease,
  7386. box-shadow 0.2s ease;
  7387. &:hover {
  7388. border-color: #bfdbfe;
  7389. box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
  7390. }
  7391. }
  7392. .relation-avatar {
  7393. width: 42px;
  7394. height: 42px;
  7395. border-radius: 12px;
  7396. background: #eff6ff;
  7397. color: #1677ff;
  7398. }
  7399. .relation-name {
  7400. font-size: 14px;
  7401. color: #111827;
  7402. }
  7403. .relation-score {
  7404. font-size: 12px;
  7405. color: #6b7280;
  7406. span {
  7407. color: #1677ff;
  7408. }
  7409. }
  7410. .relation-tags {
  7411. gap: 8px;
  7412. }
  7413. .tag {
  7414. padding: 4px 8px;
  7415. border-radius: 999px;
  7416. background: #f3f4f6;
  7417. color: #4b5563;
  7418. font-size: 12px;
  7419. }
  7420. .relation-action {
  7421. align-self: flex-start;
  7422. color: #1677ff;
  7423. font-weight: 500;
  7424. }
  7425. .comm-info,
  7426. .trade-summary {
  7427. display: flex;
  7428. flex-direction: column;
  7429. gap: 8px;
  7430. font-size: 13px;
  7431. color: #4b5563;
  7432. }
  7433. .comm-row,
  7434. .trade-row {
  7435. display: flex;
  7436. align-items: center;
  7437. flex-wrap: wrap;
  7438. gap: 8px 12px;
  7439. padding: 10px 12px;
  7440. border: 1px solid #eef2f7;
  7441. border-radius: 10px;
  7442. background: #fafafa;
  7443. }
  7444. .comm-row .label,
  7445. .trade-row .label {
  7446. color: #374151;
  7447. font-weight: 600;
  7448. }
  7449. .comm-row .value,
  7450. .trade-row .value {
  7451. color: #111827;
  7452. font-weight: 600;
  7453. }
  7454. .comm-row .carrier {
  7455. display: inline-flex;
  7456. align-items: center;
  7457. height: 24px;
  7458. padding: 0 8px;
  7459. border-radius: 999px;
  7460. background: #eff6ff;
  7461. color: #1677ff;
  7462. font-size: 12px;
  7463. }
  7464. .comm-row .carrier--link {
  7465. border: none;
  7466. cursor: pointer;
  7467. transition:
  7468. background-color 0.2s ease,
  7469. color 0.2s ease,
  7470. box-shadow 0.2s ease;
  7471. }
  7472. .comm-row .carrier--link:hover {
  7473. background: #dbeafe;
  7474. color: #145bdb;
  7475. box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.12);
  7476. }
  7477. .trade-tag {
  7478. display: inline-flex;
  7479. align-items: center;
  7480. height: 24px;
  7481. padding: 0 8px;
  7482. border-radius: 999px;
  7483. border: none;
  7484. background: #eff6ff;
  7485. color: #1677ff;
  7486. font-size: 12px;
  7487. }
  7488. .trade-tag--link {
  7489. cursor: pointer;
  7490. transition:
  7491. background-color 0.2s ease,
  7492. color 0.2s ease,
  7493. box-shadow 0.2s ease;
  7494. }
  7495. .trade-tag--link:hover {
  7496. background: #eff6ff;
  7497. color: #1677ff;
  7498. box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.12);
  7499. }
  7500. .comm-grid {
  7501. grid-template-columns: repeat(2, minmax(0, 1fr));
  7502. gap: 8px 12px;
  7503. }
  7504. .comm-summary-card {
  7505. padding: 10px 12px;
  7506. border: 1px solid #eef2f7;
  7507. border-radius: 10px;
  7508. background: #fafafa;
  7509. }
  7510. .comm-grid .grid-item {
  7511. gap: 6px;
  7512. min-width: 0;
  7513. padding: 0;
  7514. border: 0;
  7515. border-radius: 0;
  7516. background: transparent;
  7517. flex-direction: column;
  7518. }
  7519. .comm-grid .label {
  7520. font-size: 12px;
  7521. color: #6b7280;
  7522. }
  7523. .comm-grid .value {
  7524. color: #111827;
  7525. font-weight: 600;
  7526. }
  7527. .comm-divider {
  7528. margin: 16px 0;
  7529. border-top: 1px solid #eef2f7;
  7530. }
  7531. .comm-chart-row,
  7532. .trade-chart-row {
  7533. grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1fr);
  7534. gap: 12px;
  7535. }
  7536. .comm-chart,
  7537. .trade-chart,
  7538. .trade-trend {
  7539. gap: 12px;
  7540. }
  7541. .chart-title {
  7542. gap: 8px;
  7543. font-size: 14px;
  7544. color: #111827;
  7545. .flag {
  7546. width: 3px;
  7547. height: 12px;
  7548. background: #1677ff;
  7549. box-shadow: none;
  7550. }
  7551. }
  7552. .trade-section .chart-title .flag {
  7553. background: #1677ff;
  7554. box-shadow: none;
  7555. }
  7556. .comm-top-header,
  7557. .trade-top-header,
  7558. .trade-trend-header {
  7559. align-items: flex-start;
  7560. gap: 12px 16px;
  7561. }
  7562. .comm-top-tools,
  7563. .trade-top-tools,
  7564. .trade-trend-filters {
  7565. display: flex;
  7566. align-items: center;
  7567. flex-wrap: wrap;
  7568. gap: 8px;
  7569. font-size: 12px;
  7570. color: #6b7280;
  7571. }
  7572. .comm-top-tools,
  7573. .trade-top-tools {
  7574. flex: 1;
  7575. justify-content: flex-end;
  7576. }
  7577. .comm-top-tools .tool-label,
  7578. .trade-top-tools .tool-label,
  7579. .trade-trend-filters .tool-label {
  7580. color: #6b7280;
  7581. font-size: 12px;
  7582. font-weight: 400;
  7583. }
  7584. .comm-top-tools .comm-top-range {
  7585. width: 320px;
  7586. max-width: 100%;
  7587. border-radius: 8px;
  7588. background: #fff;
  7589. box-shadow: none;
  7590. }
  7591. .trade-top-tools .metric-select {
  7592. width: 116px;
  7593. }
  7594. .trade-top-tools .trade-top-range {
  7595. width: 260px;
  7596. max-width: 100%;
  7597. border-radius: 8px;
  7598. background: #fff;
  7599. box-shadow: none;
  7600. }
  7601. .trade-top-tools .metric-select :deep(.ant-select-selector) {
  7602. border-radius: 8px;
  7603. background: #fff;
  7604. box-shadow: none;
  7605. }
  7606. .trade-top-dimension {
  7607. gap: 10px;
  7608. padding: 6px 10px;
  7609. border: 1px solid #e5e7eb;
  7610. border-radius: 8px;
  7611. background: #fafafa;
  7612. }
  7613. .trade-top-dimension .ant-radio-wrapper {
  7614. color: #374151;
  7615. font-size: 12px;
  7616. }
  7617. .visual-chart-surface,
  7618. .visual-chart-surface--comm,
  7619. .visual-chart-surface--trade {
  7620. margin-top: 0;
  7621. border: 1px solid #e5e7eb;
  7622. border-radius: 12px;
  7623. background: #fff;
  7624. backdrop-filter: none;
  7625. box-shadow: none;
  7626. }
  7627. .visual-chart-surface::before,
  7628. .visual-chart-surface::after {
  7629. display: none;
  7630. }
  7631. .visual-chart-surface--ratio,
  7632. .visual-chart-surface--bar {
  7633. min-height: 340px;
  7634. }
  7635. .visual-chart-surface--trend {
  7636. min-height: 420px;
  7637. padding: 0;
  7638. }
  7639. .comm-chart-box,
  7640. .comm-top-chart-box,
  7641. .trade-top-chart-box,
  7642. .trade-ratio-panel {
  7643. padding: 16px;
  7644. box-sizing: border-box;
  7645. height: 100%;
  7646. }
  7647. .trade-ratio-panel {
  7648. gap: 16px;
  7649. }
  7650. .chart-more-link {
  7651. height: 28px;
  7652. padding: 0 10px;
  7653. border: 1px solid #dbe2ea;
  7654. border-radius: 8px;
  7655. background: #fff;
  7656. backdrop-filter: none;
  7657. box-shadow: none;
  7658. color: #1677ff;
  7659. font-size: 12px;
  7660. }
  7661. .comm-echart,
  7662. .comm-top-echart {
  7663. height: 280px;
  7664. }
  7665. .trade-top-echart {
  7666. height: 300px;
  7667. }
  7668. .trade-ratio-echart {
  7669. width: 176px;
  7670. height: 176px;
  7671. flex: 0 0 176px;
  7672. }
  7673. .donut-wrap {
  7674. align-items: center;
  7675. gap: 16px;
  7676. }
  7677. .donut-legend {
  7678. gap: 12px;
  7679. font-size: 13px;
  7680. color: #374151;
  7681. }
  7682. .donut-legend .legend-title {
  7683. color: #374151;
  7684. }
  7685. .donut-legend .legend-rate,
  7686. .donut-legend .legend-amount {
  7687. color: #111827;
  7688. }
  7689. .legend-click {
  7690. gap: 8px;
  7691. padding: 12px;
  7692. border: 1px solid #eef2f7;
  7693. border-radius: 10px;
  7694. background: #fafafa;
  7695. backdrop-filter: none;
  7696. box-shadow: none;
  7697. &:hover {
  7698. border-color: #dbe2ea;
  7699. opacity: 1;
  7700. transform: none;
  7701. }
  7702. }
  7703. .trade-kpi {
  7704. gap: 12px;
  7705. display: grid;
  7706. grid-template-columns: repeat(2, minmax(0, 1fr));
  7707. }
  7708. .trade-kpi .trade-kpi-item {
  7709. padding: 12px 14px;
  7710. border: 1px solid #eef2f7;
  7711. border-radius: 10px;
  7712. background: #fafafa;
  7713. justify-content: space-between;
  7714. }
  7715. .trade-kpi .trade-kpi-label {
  7716. color: #6b7280;
  7717. }
  7718. .trade-kpi .trade-kpi-value {
  7719. color: #111827;
  7720. }
  7721. .trade-kpi .trade-kpi-value.down {
  7722. color: #f97316;
  7723. }
  7724. .trade-kpi .trade-kpi-value.up {
  7725. color: #10b981;
  7726. }
  7727. .trade-trend-subtitle {
  7728. color: #6b7280;
  7729. font-size: 12px;
  7730. }
  7731. .trade-trend-toolbar {
  7732. gap: 10px 12px;
  7733. padding: 12px 14px;
  7734. border: 1px solid #e5e7eb;
  7735. border-radius: 10px;
  7736. background: #fafafa;
  7737. box-shadow: none;
  7738. }
  7739. .trade-trend-filters {
  7740. gap: 8px;
  7741. }
  7742. .trade-trend-filters .trend-search-input {
  7743. width: 220px;
  7744. }
  7745. .trade-trend-filters .trend-range-input {
  7746. width: 280px;
  7747. max-width: 100%;
  7748. border-radius: 8px;
  7749. background: #fff;
  7750. box-shadow: none;
  7751. }
  7752. .trade-trend-dimension {
  7753. gap: 4px;
  7754. padding: 4px;
  7755. border: 1px solid #e5e7eb;
  7756. border-radius: 8px;
  7757. background: #fff;
  7758. box-shadow: none;
  7759. }
  7760. .radio {
  7761. padding: 4px 12px;
  7762. border-radius: 6px;
  7763. background: transparent;
  7764. color: #6b7280;
  7765. &.active {
  7766. background: #e6f4ff;
  7767. color: #1677ff;
  7768. }
  7769. }
  7770. .trend-title-row .trend-legend-item {
  7771. padding: 6px 10px;
  7772. border: 1px solid #e5e7eb;
  7773. border-radius: 8px;
  7774. background: #fff;
  7775. box-shadow: none;
  7776. &:hover {
  7777. border-color: #bfdbfe;
  7778. box-shadow: none;
  7779. transform: none;
  7780. }
  7781. &.is-muted {
  7782. opacity: 0.55;
  7783. }
  7784. }
  7785. .trend-title-row .circles-text {
  7786. color: #374151;
  7787. font-size: 12px;
  7788. }
  7789. .trend-chart-scroll {
  7790. padding: 0;
  7791. }
  7792. .trend-chart {
  7793. border: 1px solid #e5e7eb;
  7794. border-radius: 12px;
  7795. background: #fff;
  7796. box-shadow: none;
  7797. }
  7798. .trend-echart {
  7799. min-height: 320px;
  7800. }
  7801. .empty-state__title {
  7802. font-size: 14px;
  7803. font-weight: 600;
  7804. color: #4b5563;
  7805. }
  7806. .empty-state__desc {
  7807. display: block;
  7808. color: #9ca3af;
  7809. font-size: 12px;
  7810. line-height: 18px;
  7811. }
  7812. .chart-empty {
  7813. min-height: 180px;
  7814. margin-top: 0;
  7815. padding: 0;
  7816. gap: 0;
  7817. border: 0;
  7818. border-radius: 0;
  7819. background: transparent;
  7820. box-shadow: none;
  7821. color: inherit;
  7822. }
  7823. .chart-empty::before {
  7824. content: none;
  7825. display: none;
  7826. }
  7827. .trade-trend-empty {
  7828. min-height: 420px;
  7829. border: 0;
  7830. border-radius: 0;
  7831. background: transparent;
  7832. }
  7833. .trade-trend-chart-box {
  7834. border: 0;
  7835. border-radius: 0;
  7836. background: transparent;
  7837. box-shadow: none;
  7838. backdrop-filter: none;
  7839. padding: 0;
  7840. }
  7841. .station-row {
  7842. gap: 10px;
  7843. margin-bottom: 10px;
  7844. color: #374151;
  7845. }
  7846. .station-list {
  7847. gap: 8px;
  7848. }
  7849. .station-item {
  7850. padding: 6px 10px;
  7851. border: 1px solid #eef2f7;
  7852. border-radius: 8px;
  7853. background: #fafafa;
  7854. color: #4b5563;
  7855. }
  7856. }
  7857. .quick-nav {
  7858. background: rgb(255 255 255 / 96%);
  7859. backdrop-filter: blur(8px);
  7860. box-shadow: 0 8px 20px rgb(15 23 42 / 8%);
  7861. .nav-item {
  7862. padding: 6px 10px;
  7863. border-radius: 8px;
  7864. color: #6b7280;
  7865. transition:
  7866. background 0.2s ease,
  7867. color 0.2s ease;
  7868. &:hover {
  7869. background: #f9fafb;
  7870. color: #374151;
  7871. }
  7872. &.active {
  7873. background: #e6f4ff;
  7874. color: #1677ff;
  7875. }
  7876. }
  7877. }
  7878. @media (max-width: 1440px) {
  7879. .@{prefix-cls}.case-portrait-page {
  7880. .comm-chart-row,
  7881. .trade-chart-row {
  7882. grid-template-columns: 1fr;
  7883. }
  7884. .basic-content {
  7885. grid-template-columns: 92px minmax(0, 1fr);
  7886. }
  7887. .info-grid {
  7888. grid-template-columns: repeat(3, minmax(0, 1fr));
  7889. }
  7890. }
  7891. }
  7892. @media (max-width: 1200px) {
  7893. .@{prefix-cls}.case-portrait-page {
  7894. .page-layout {
  7895. grid-template-columns: 156px minmax(0, 1fr);
  7896. }
  7897. .left-panel {
  7898. height: auto;
  7899. min-width: 0;
  7900. }
  7901. .left-panel .panel-card {
  7902. max-height: 100%;
  7903. }
  7904. .info-grid,
  7905. .comm-grid {
  7906. grid-template-columns: repeat(2, minmax(0, 1fr));
  7907. }
  7908. .chart-more-link {
  7909. margin-left: auto;
  7910. }
  7911. .quick-nav-shell {
  7912. display: none;
  7913. }
  7914. }
  7915. .quick-nav {
  7916. display: none;
  7917. }
  7918. }
  7919. @media (max-width: 992px) {
  7920. .@{prefix-cls}.case-portrait-page {
  7921. padding: 12px;
  7922. .page-layout,
  7923. .right-panel {
  7924. gap: 10px;
  7925. }
  7926. .section-card,
  7927. .comm-section,
  7928. .trade-section {
  7929. padding: 14px;
  7930. }
  7931. .relation-search,
  7932. .comm-top-tools .comm-top-range,
  7933. .trade-top-tools .metric-select,
  7934. .trade-top-tools .trade-top-range,
  7935. .trade-trend-filters .trend-search-input,
  7936. .trade-trend-filters .trend-range-input {
  7937. width: 100%;
  7938. }
  7939. .info-grid,
  7940. .comm-grid,
  7941. .relation-list,
  7942. .trade-kpi {
  7943. grid-template-columns: 1fr;
  7944. }
  7945. .comm-top-tools,
  7946. .trade-top-tools,
  7947. .trade-trend-filters,
  7948. .section-tools {
  7949. width: 100%;
  7950. }
  7951. }
  7952. }
  7953. html[data-theme='dark'] {
  7954. .@{prefix-cls}.case-portrait-page {
  7955. background: #0f172a;
  7956. .panel-card,
  7957. .section-card {
  7958. background: #111827;
  7959. box-shadow: none;
  7960. }
  7961. .person-item {
  7962. background: #1f2937;
  7963. &:hover {
  7964. background: #1e293b;
  7965. }
  7966. }
  7967. .person-name,
  7968. .section-title,
  7969. .profile-name,
  7970. .relation-name {
  7971. color: #e5e7eb;
  7972. }
  7973. .info-item,
  7974. .info-label,
  7975. .relation-score,
  7976. .tag {
  7977. color: #9ca3af;
  7978. }
  7979. .relation-card {
  7980. border-color: #1f2937;
  7981. }
  7982. .tag {
  7983. background: #1f2937;
  7984. }
  7985. }
  7986. }
  7987. </style>