Editor.vue 185 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468
  1. <template>
  2. <div class="editor-page">
  3. <div class="editor-body">
  4. <!-- 左侧面板 -->
  5. <div class="left-panel" :style="{ width: leftPanelWidth + 'px' }">
  6. <!-- 顶部 Logo -->
  7. <div class="sidebar-header">
  8. <div class="sidebar-logo">
  9. <span class="logo-icon">✦</span>
  10. <span class="logo-text">灵越智报</span>
  11. </div>
  12. <div class="sidebar-header-actions">
  13. <el-button text circle size="small" title="通知"><el-icon><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></el-icon></el-button>
  14. </div>
  15. </div>
  16. <!-- 快捷操作 -->
  17. <div class="sidebar-nav">
  18. <div class="nav-item" @click="showNewProjectDialog = true">
  19. <span class="nav-icon">📝</span>
  20. <span class="nav-label">新建报告</span>
  21. </div>
  22. <div class="nav-item" @click="showAttachmentDialog = true">
  23. <span class="nav-icon">📚</span>
  24. <span class="nav-label">知识库</span>
  25. </div>
  26. </div>
  27. <!-- 我的文档 -->
  28. <div class="sidebar-section">
  29. <div class="section-header">
  30. <span class="section-title">我的文档 · {{ projects.length }}</span>
  31. <span class="section-action" @click="leftPanelTab = 'projects'">全部 ›</span>
  32. </div>
  33. <!-- 搜索框 -->
  34. <el-input
  35. v-if="projects.length > 5"
  36. v-model="projectSearchKeyword"
  37. placeholder="搜索文档..."
  38. :prefix-icon="Search"
  39. clearable
  40. class="sidebar-search"
  41. size="small"
  42. />
  43. <div class="doc-list" v-if="filteredProjects.length > 0">
  44. <div
  45. v-for="project in filteredProjects.slice(0, sidebarShowAll ? undefined : 5)"
  46. :key="project.id"
  47. class="doc-item"
  48. :class="{ active: currentProjectId === project.id }"
  49. @click="switchProject(project)"
  50. >
  51. <div class="doc-icon-wrap">
  52. <span class="doc-icon-glyph">📋</span>
  53. </div>
  54. <div class="doc-item-body">
  55. <div class="doc-item-title">{{ project.title }}</div>
  56. <div class="doc-item-meta">
  57. <el-tag
  58. size="small"
  59. :type="project.status === 'archived' ? 'success' : project.status === 'editing' ? '' : 'warning'"
  60. effect="plain"
  61. class="doc-status-tag"
  62. >{{ getStatusText(project.status) }}</el-tag>
  63. <span class="doc-item-date">{{ formatTime(project.updatedAt || project.createdAt) }}</span>
  64. <span class="doc-item-author">· {{ project.createdBy || userName }}</span>
  65. </div>
  66. </div>
  67. <el-dropdown trigger="click" @command="(cmd) => handleProjectCommand(cmd, project)" @click.stop>
  68. <el-button class="doc-more-btn" text size="small" @click.stop>
  69. <el-icon><MoreFilled /></el-icon>
  70. </el-button>
  71. <template #dropdown>
  72. <el-dropdown-menu>
  73. <el-dropdown-item command="copy">复制项目</el-dropdown-item>
  74. <el-dropdown-item command="archive">归档</el-dropdown-item>
  75. <el-dropdown-item command="delete" divided>删除</el-dropdown-item>
  76. </el-dropdown-menu>
  77. </template>
  78. </el-dropdown>
  79. </div>
  80. </div>
  81. <div class="doc-empty" v-else-if="!loadingProjects">
  82. <span class="empty-text">{{ projectSearchKeyword ? '未找到匹配的文档' : '暂无文档' }}</span>
  83. </div>
  84. <div class="doc-loading" v-if="loadingProjects">
  85. <el-icon class="is-loading"><Loading /></el-icon>
  86. <span>加载中...</span>
  87. </div>
  88. </div>
  89. <!-- 最近操作 -->
  90. <div class="sidebar-section sidebar-activity">
  91. <div class="section-header">
  92. <span class="section-title">最近操作</span>
  93. <span class="section-action" title="筛选">▽</span>
  94. </div>
  95. <div class="activity-list">
  96. <div class="activity-item" v-for="(act, idx) in recentActivities" :key="idx">
  97. <div class="activity-text">{{ act.text }}</div>
  98. <div class="activity-meta">
  99. <span class="activity-source">{{ act.source }}</span>
  100. <span class="activity-time">{{ act.time }}</span>
  101. </div>
  102. </div>
  103. <div class="activity-empty" v-if="recentActivities.length === 0">
  104. <span>暂无最近操作</span>
  105. </div>
  106. </div>
  107. </div>
  108. <!-- 底部用户信息 -->
  109. <div class="sidebar-footer">
  110. <div class="user-avatar">{{ userName.charAt(0) }}</div>
  111. <div class="user-info">
  112. <span class="user-name">{{ userName }}</span>
  113. <span class="user-role">项目经理</span>
  114. </div>
  115. <div class="footer-actions">
  116. <el-badge :value="taskRunningCount" :hidden="taskRunningCount === 0" :max="99" class="notification-badge">
  117. <el-button text circle size="small" title="任务中心" @click="taskCenterStore.toggleOpen()"><el-icon><List /></el-icon></el-button>
  118. </el-badge>
  119. <el-badge :value="3" :max="99" class="notification-badge">
  120. <el-button text circle size="small" title="消息"><el-icon><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></el-icon></el-button>
  121. </el-badge>
  122. <el-dropdown trigger="click" @command="handleFooterCommand">
  123. <el-button text circle size="small" title="设置"><el-icon><MoreFilled /></el-icon></el-button>
  124. <template #dropdown>
  125. <el-dropdown-menu>
  126. <el-dropdown-item command="settings">系统设置</el-dropdown-item>
  127. <el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
  128. </el-dropdown-menu>
  129. </template>
  130. </el-dropdown>
  131. </div>
  132. </div>
  133. </div>
  134. <!-- 左侧拖拽分隔条 -->
  135. <div class="resize-handle left-resize" @mousedown="startResizeLeft"></div>
  136. <!-- 中间主区域 -->
  137. <div class="center-panel">
  138. <!-- 欢迎页 -->
  139. <div class="welcome-page" v-if="!hasActiveProject">
  140. <div class="welcome-content">
  141. <div class="welcome-logo">灵</div>
  142. <div class="welcome">
  143. <h1>{{ greetingText }},{{ userName }}!<span>智能报告,洞察未来。</span></h1>
  144. <p>今天是个创作的好日子,开始您的智能报告之旅吧</p>
  145. </div>
  146. </div>
  147. </div>
  148. <!-- 项目详情 -->
  149. <template v-else>
  150. <div class="editor-title-bar">
  151. <div class="titlebar-left">
  152. <el-icon class="titlebar-folder-icon"><Folder /></el-icon>
  153. <span class="titlebar-sep">/</span>
  154. <span class="titlebar-project-name" :title="projectTitle">{{ projectTitle || '未命名项目' }}</span>
  155. <el-tag size="small" type="info" class="titlebar-status-tag">草稿</el-tag>
  156. </div>
  157. <div class="titlebar-right">
  158. <span class="titlebar-save-status">
  159. <span class="save-dot" :class="{ saved: saved }"></span>
  160. {{ saved ? '已自动保存' : '保存中...' }}
  161. </span>
  162. <el-divider direction="vertical" />
  163. <el-button text circle size="small" :class="{ 'is-active-view': viewMode === 'document' }" title="文档视图" @click="viewMode = 'document'"><el-icon><Document /></el-icon></el-button>
  164. <el-button text circle size="small" :class="{ 'is-active-view': viewMode === 'elements' }" title="要素视图" @click="viewMode = 'elements'"><el-icon><Grid /></el-icon></el-button>
  165. <el-button text circle size="small" title="设置" @click="ElMessage.info('设置开发中...')"><el-icon><Setting /></el-icon></el-button>
  166. <el-dropdown trigger="click" @command="handleTitlebarCommand">
  167. <el-button text circle size="small" title="更多"><el-icon><MoreFilled /></el-icon></el-button>
  168. <template #dropdown>
  169. <el-dropdown-menu>
  170. <el-dropdown-item command="save">💾 保存</el-dropdown-item>
  171. <el-dropdown-item command="copy">📋 复制项目</el-dropdown-item>
  172. <el-dropdown-item command="export" divided>📤 导出</el-dropdown-item>
  173. </el-dropdown-menu>
  174. </template>
  175. </el-dropdown>
  176. <el-divider direction="vertical" />
  177. <el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><Paperclip /></el-icon></el-button>
  178. </div>
  179. </div>
  180. <div class="editor-scroll" ref="editorRef" v-loading="loading" element-loading-text="正在加载项目...">
  181. <!-- 文档视图 -->
  182. <div class="document-view" v-if="viewMode === 'document'">
  183. <!-- 文档渲染区域(可编辑) -->
  184. <div
  185. class="doc-paper"
  186. v-if="docHtml"
  187. :style="docPaperStyle"
  188. contenteditable="true"
  189. spellcheck="false"
  190. v-html="docHtml"
  191. @input="onDocInput"
  192. @mousedown="onDocClick"
  193. ref="docPaperRef"
  194. ></div>
  195. <!-- 无内容提示 -->
  196. <div class="doc-empty" v-else-if="!docLoading">
  197. <el-empty description="暂无文档内容" />
  198. </div>
  199. <div class="doc-loading" v-if="docLoading">
  200. <el-icon class="is-loading" :size="24"><Loading /></el-icon>
  201. <span>正在加载文档内容...</span>
  202. </div>
  203. </div>
  204. <!-- 要素高亮弹出框 -->
  205. <div
  206. v-if="highlightPopover.visible"
  207. class="element-popover"
  208. :style="{ top: highlightPopover.y + 'px', left: highlightPopover.x + 'px' }"
  209. @mousedown.stop
  210. >
  211. <div class="popover-header">
  212. <span class="popover-label">{{ highlightPopover.elementName }}</span>
  213. <el-tag size="small">{{ highlightPopover.elementKey }}</el-tag>
  214. </div>
  215. <div class="popover-body">
  216. <div class="popover-field">
  217. <span class="popover-field-label">当前值:</span>
  218. <el-input
  219. v-model="highlightPopover.currentValue"
  220. size="small"
  221. placeholder="输入要素值"
  222. @keyup.enter="savePopoverValue"
  223. />
  224. </div>
  225. <div class="popover-field" v-if="highlightPopover.originalValue">
  226. <span class="popover-field-label">原始值:</span>
  227. <span class="popover-original">{{ highlightPopover.originalValue }}</span>
  228. </div>
  229. <!-- 溯源卡片 -->
  230. <div class="popover-rules" v-if="popoverRelatedRules.length > 0">
  231. <span class="popover-field-label">来源规则:</span>
  232. <div
  233. v-for="rule in popoverRelatedRules"
  234. :key="rule.id"
  235. class="rule-trace-card"
  236. >
  237. <span class="rule-trace-action" :class="'action-' + rule.actionType">{{ ruleActionLabel(rule.actionType) }}</span>
  238. <div class="rule-trace-info">
  239. <div class="rule-trace-name">{{ rule.ruleName }}</div>
  240. <div v-if="rule.inputs && rule.inputs.length" class="rule-trace-sources">
  241. <span v-for="inp in rule.inputs" :key="inp.inputId" class="rule-trace-att">📎 {{ inp.sourceName }}</span>
  242. </div>
  243. <div v-if="ruleSourceText(rule)" class="rule-trace-excerpt">
  244. <span class="rule-trace-excerpt-text">{{ ruleSourceText(rule) }}</span>
  245. </div>
  246. </div>
  247. </div>
  248. </div>
  249. </div>
  250. <div class="popover-footer">
  251. <el-button size="small" @click="highlightPopover.visible = false">关闭</el-button>
  252. <el-button size="small" @click="enterReferenceModeFromPopover">📎 从附件引用</el-button>
  253. <el-button size="small" type="primary" @click="savePopoverValue">保存</el-button>
  254. </div>
  255. </div>
  256. <!-- 要素视图:模板文档 + {{key}} 占位符 -->
  257. <div class="document-view" v-if="viewMode === 'elements'">
  258. <div
  259. class="doc-paper"
  260. v-if="docTemplateHtml"
  261. :style="docPaperStyle"
  262. contenteditable="false"
  263. spellcheck="false"
  264. v-html="docTemplateHtml"
  265. ></div>
  266. <div class="doc-empty" v-else-if="!docLoading">
  267. <el-empty description="暂无文档内容" />
  268. </div>
  269. </div>
  270. </div>
  271. </template>
  272. </div>
  273. <!-- 右侧拖拽分隔条 -->
  274. <div v-if="hasActiveProject" class="resize-handle right-resize" @mousedown="startResizeRight"></div>
  275. <!-- 右侧面板 -->
  276. <div v-if="hasActiveProject" class="right-panel" :style="{ width: rightPanelWidth + 'px' }">
  277. <!-- 报告要素区 -->
  278. <div class="rp-elements">
  279. <div class="rp-elements-header">
  280. <div class="rp-elements-title">
  281. <span class="rp-title-icon">📋</span>
  282. <span class="rp-title-text">报告要素</span>
  283. <el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><CopyDocument /></el-icon></el-button>
  284. <el-button text circle size="small" title="规则" @click="showRuleDialog = true"><el-icon><MoreFilled /></el-icon></el-button>
  285. </div>
  286. <el-button text circle size="small" title="搜索"><el-icon><Search /></el-icon></el-button>
  287. </div>
  288. <div class="rp-elements-body">
  289. <div class="rp-value-tags" v-if="filledValues.length > 0">
  290. <span
  291. v-for="val in filledValues"
  292. :key="val.valueId"
  293. class="rp-value-tag"
  294. :title="val.elementName"
  295. >{{ val.valueText }}</span>
  296. </div>
  297. <div class="rp-elements-empty" v-else>
  298. <span>暂无要素值</span>
  299. </div>
  300. </div>
  301. </div>
  302. <!-- AI 助手区 -->
  303. <div class="rp-ai">
  304. <div class="rp-ai-header">
  305. <div class="rp-ai-title">
  306. <span class="rp-ai-icon">🤖</span>
  307. <span>AI 助手</span>
  308. </div>
  309. <div class="rp-ai-actions">
  310. <el-button text size="small" @click="aiMessages = []">🔄 新对话</el-button>
  311. </div>
  312. </div>
  313. <div class="rp-ai-messages" ref="aiMessagesRef">
  314. <div class="ai-message ai-bot" v-if="aiMessages.length === 0">
  315. <div class="ai-bubble">👋 您好,我不仅是您的AI助手,更是您深度思考时的沉浸式创作搭档。你准备好开启这场创作之旅了吗?</div>
  316. </div>
  317. <div
  318. v-for="(msg, idx) in aiMessages"
  319. :key="idx"
  320. class="ai-message"
  321. :class="msg.role === 'user' ? 'ai-user' : 'ai-bot'"
  322. >
  323. <div class="ai-bubble">{{ msg.content }}</div>
  324. </div>
  325. </div>
  326. <div class="rp-ai-input">
  327. <el-input
  328. v-model="aiInputText"
  329. placeholder="发消息给 AI 助手~"
  330. :autosize="{ minRows: 1, maxRows: 3 }"
  331. type="textarea"
  332. resize="none"
  333. @keydown.enter.exact.prevent="sendAiMessage"
  334. />
  335. <div class="rp-ai-input-actions">
  336. <div class="rp-ai-input-tools">
  337. <el-button text circle size="small" title="附加">+</el-button>
  338. <el-button text circle size="small" title="提及">@</el-button>
  339. <el-button text circle size="small" title="模板">🌐</el-button>
  340. </div>
  341. <div class="rp-ai-input-right">
  342. <el-button text circle size="small" title="语音">🎤</el-button>
  343. <el-button
  344. type="primary"
  345. circle
  346. size="small"
  347. :disabled="!aiInputText.trim()"
  348. @click="sendAiMessage"
  349. title="发送"
  350. >
  351. <el-icon><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg></el-icon>
  352. </el-button>
  353. </div>
  354. </div>
  355. </div>
  356. </div>
  357. </div>
  358. </div>
  359. <!-- 附件管理弹窗 -->
  360. <el-dialog
  361. v-model="showAttachmentDialog"
  362. title="📎 附件管理"
  363. width="640"
  364. :close-on-click-modal="true"
  365. class="floating-panel-dialog"
  366. align-center
  367. >
  368. <div class="fp-toolbar">
  369. <el-upload
  370. :auto-upload="false"
  371. :on-change="handleAttachmentUpload"
  372. :show-file-list="false"
  373. accept=".pdf,.doc,.docx,.xls,.xlsx,.zip,.png,.jpg"
  374. >
  375. <el-button size="small" :icon="Plus">添加附件</el-button>
  376. </el-upload>
  377. <span class="fp-count">共 {{ attachments.length }} 个附件</span>
  378. </div>
  379. <div class="fp-list">
  380. <div
  381. v-for="att in attachments"
  382. :key="att.id"
  383. class="fp-att-item"
  384. :class="{ active: selectedAttachment?.id === att.id }"
  385. @click="selectAttachment(att)"
  386. >
  387. <div class="att-icon" :class="getFileTypeClass(att)">
  388. <span class="att-icon-text">{{ getFileTypeLabel(att) }}</span>
  389. </div>
  390. <div class="att-info">
  391. <div class="att-name" :title="att.displayName">{{ att.displayName }}</div>
  392. <div class="att-meta">
  393. <span class="att-type">{{ getFileTypeTag(att) }}</span>
  394. <span class="att-size">{{ formatFileSize(att.fileSize) }}</span>
  395. <span
  396. v-if="parseStates[att.id]?.status === 'parsing' || parseStates[att.id]?.status === 'uploading'"
  397. class="att-parse-status parsing"
  398. >
  399. <el-icon class="is-loading"><Loading /></el-icon>
  400. {{ parseStates[att.id]?.progress || '解析中...' }}
  401. </span>
  402. <span v-else-if="parseStates[att.id]?.status === 'completed'" class="att-parse-status completed" style="cursor:pointer" @click.stop="viewParseResult(att)">✅ 已解析 · 查看</span>
  403. <span v-else-if="parseStates[att.id]?.status === 'failed'" class="att-parse-status failed">❌ 失败</span>
  404. </div>
  405. </div>
  406. <el-button
  407. v-if="parseStates[att.id]?.status === 'completed'"
  408. class="att-parse-btn"
  409. size="small"
  410. type="success"
  411. text
  412. @click.stop="viewParseResult(att)"
  413. >
  414. 查看
  415. </el-button>
  416. <el-button
  417. v-else-if="canParse(att) && (!parseStates[att.id] || parseStates[att.id]?.status === 'idle' || parseStates[att.id]?.status === 'failed')"
  418. class="att-parse-btn"
  419. size="small"
  420. type="primary"
  421. text
  422. @click.stop="handleParseAttachment(att)"
  423. >
  424. {{ getFileExt(att) === 'zip' ? '打开' : '解析' }}
  425. </el-button>
  426. <el-button
  427. v-if="parseStates[att.id]?.status === 'parsing' || parseStates[att.id]?.status === 'uploading'"
  428. class="att-parse-btn"
  429. size="small"
  430. type="info"
  431. text
  432. disabled
  433. >
  434. <el-icon class="is-loading"><Loading /></el-icon>
  435. </el-button>
  436. <el-dropdown trigger="click" @command="(cmd) => handleAttachmentAction(cmd, att)" @click.stop>
  437. <el-button class="att-more-btn" size="small" text :icon="MoreFilled" @click.stop />
  438. <template #dropdown>
  439. <el-dropdown-menu>
  440. <el-dropdown-item command="preview">📄 预览</el-dropdown-item>
  441. <el-dropdown-item v-if="canParse(att)" command="parse">🔍 解析文档</el-dropdown-item>
  442. <el-dropdown-item v-if="parseStates[att.id]?.status === 'completed'" command="view_result">📋 查看解析结果</el-dropdown-item>
  443. <el-dropdown-item command="apply">📝 应用要素</el-dropdown-item>
  444. <el-dropdown-item command="download">⬇️ 下载</el-dropdown-item>
  445. <el-dropdown-item command="delete" divided>🗑️ 删除</el-dropdown-item>
  446. </el-dropdown-menu>
  447. </template>
  448. </el-dropdown>
  449. </div>
  450. <el-empty v-if="attachments.length === 0" description="暂无附件,点击添加按钮上传" :image-size="80" />
  451. </div>
  452. </el-dialog>
  453. <!-- 解析结果预览弹窗 -->
  454. <el-dialog
  455. v-model="showParseResultDialog"
  456. :title="'📋 解析结果 - ' + (parseResultAttName || '')"
  457. width="1100"
  458. :close-on-click-modal="true"
  459. class="floating-panel-dialog parse-result-dialog"
  460. align-center
  461. @close="cleanupPreviewContent"
  462. >
  463. <!-- 引用模式提示条 -->
  464. <div v-if="referenceMode" class="citation-mode-banner">
  465. <span>📌 正在为要素「<b>{{ referenceModeElementName }}</b>」选择引用内容,请选中文本后选择引用方式</span>
  466. <el-button size="small" text @click="exitReferenceMode">✕ 退出</el-button>
  467. </div>
  468. <div class="parse-result-toolbar">
  469. <span class="parse-result-info">{{ parseResultContent ? `共 ${parseResultContent.length} 字` : '' }}</span>
  470. <div class="parse-result-actions">
  471. <el-button size="small" :type="parseResultViewMode === 'rendered' ? 'primary' : ''" @click="parseResultViewMode = 'rendered'">渲染</el-button>
  472. <el-button size="small" :type="parseResultViewMode === 'source' ? 'primary' : ''" @click="parseResultViewMode = 'source'">源码</el-button>
  473. <el-button v-if="parseResultPreviewAvailable" size="small" :type="parseResultViewMode === 'preview' ? 'primary' : ''" @click="switchToPreviewMode">原件</el-button>
  474. <el-button size="small" @click="copyParseResult">📋 复制</el-button>
  475. </div>
  476. </div>
  477. <div class="parse-result-content" @mouseup="onParseResultMouseUp">
  478. <div v-if="parseResultViewMode === 'rendered'" class="parse-result-rendered markdown-body" v-html="parseResultHtml"></div>
  479. <pre v-else-if="parseResultViewMode === 'source'" class="parse-result-pre">{{ parseResultSource }}</pre>
  480. <div v-else-if="parseResultViewMode === 'preview'" class="parse-result-preview">
  481. <img v-if="previewContentType === 'image'" :src="previewContentUrl" :alt="parseResultAttName" style="max-width:100%;height:auto;display:block;margin:0 auto" />
  482. <iframe v-else-if="previewContentType === 'pdf'" :src="previewContentUrl" style="width:100%;height:75vh;border:none" />
  483. <div v-else-if="previewContentType === 'html'" class="parse-result-rendered markdown-body" v-html="previewContentHtml" />
  484. <pre v-else-if="previewContentType === 'text'" style="margin:0;white-space:pre-wrap;word-wrap:break-word;font-size:13px;line-height:1.7;color:#1f2937">{{ previewContentText }}</pre>
  485. <div v-else style="text-align:center;padding:40px;color:#999">该文件类型暂不支持原件预览</div>
  486. </div>
  487. </div>
  488. <!-- 选中文本后的浮动引用工具栏 -->
  489. <div
  490. v-if="citationToolbar.visible"
  491. class="citation-toolbar"
  492. :style="{ top: citationToolbar.y + 'px', left: citationToolbar.x + 'px' }"
  493. >
  494. <!-- 第一步:选择引用方式 -->
  495. <template v-if="citationToolbar.step === 'select_action'">
  496. <div class="citation-toolbar-title">引用到要素:</div>
  497. <div class="citation-toolbar-actions">
  498. <el-button size="small" type="primary" @click="setCitationAction('quote')">📝 直接引用</el-button>
  499. <el-button size="small" @click="setCitationAction('summary')">🤖 AI 总结</el-button>
  500. <el-button size="small" @click="setCitationAction('table_extract')">📊 表格提取</el-button>
  501. </div>
  502. </template>
  503. <!-- 第二步:选择目标要素(非引用模式时) -->
  504. <template v-if="citationToolbar.step === 'select_element'">
  505. <div class="citation-toolbar-title">选择目标要素:</div>
  506. <div class="citation-toolbar-elements">
  507. <div
  508. v-for="elem in elements"
  509. :key="elem.elementKey"
  510. class="citation-element-item"
  511. @click="confirmCitation(elem)"
  512. >
  513. <span class="citation-elem-name">{{ elem.elementName }}</span>
  514. <el-tag size="small" type="info">{{ elem.elementType }}</el-tag>
  515. </div>
  516. <div v-if="elements.length === 0" style="padding:8px;color:#999;font-size:12px">暂无要素</div>
  517. </div>
  518. </template>
  519. </div>
  520. </el-dialog>
  521. <!-- ZIP 内容展示弹窗 -->
  522. <el-dialog
  523. v-model="showZipContentsDialog"
  524. :title="'📦 ' + (zipContentsAttName || 'ZIP 内容')"
  525. width="520"
  526. :close-on-click-modal="true"
  527. class="floating-panel-dialog"
  528. align-center
  529. >
  530. <div class="fp-toolbar">
  531. <span class="fp-count">共 {{ zipFileList.length }} 个文件,{{ zipFileList.filter(f => f.parseable).length }} 个可解析</span>
  532. <el-button
  533. size="small"
  534. type="primary"
  535. :disabled="zipFileList.filter(f => f.parseable && !f.parsed && !f.parsing).length === 0"
  536. @click="parseAllZipEntries"
  537. >🔍 一键全部解析</el-button>
  538. </div>
  539. <div class="fp-list">
  540. <div v-if="zipFileList.length === 0" style="text-align:center;padding:30px;color:#999">ZIP 包为空</div>
  541. <div v-for="(zf, idx) in zipFileList" :key="idx" class="fp-att-item">
  542. <div class="att-icon" :class="getZipEntryTypeClass(zf)">
  543. <span class="att-icon-text">{{ getZipEntryTypeLabel(zf) }}</span>
  544. </div>
  545. <div class="att-info">
  546. <div class="att-name" :title="zf.name">{{ zf.name.split('/').pop() }}</div>
  547. <div class="att-meta">
  548. <span class="att-type">{{ zf.ext || '文件' }}</span>
  549. <span class="att-size">{{ formatFileSize(zf.size) }}</span>
  550. <span v-if="zf.parsing" class="att-parse-status parsing">
  551. <el-icon class="is-loading"><Loading /></el-icon> 解析中...
  552. </span>
  553. <span v-else-if="zf.parsed" class="att-parse-status completed" style="cursor:pointer" @click="viewZipEntryResult(zf)">✅ 已解析 · 查看</span>
  554. </div>
  555. </div>
  556. <el-button
  557. class="att-parse-btn"
  558. size="small"
  559. text
  560. @click="previewZipEntry(zf)"
  561. >预览</el-button>
  562. <el-button
  563. v-if="zf.parsed"
  564. class="att-parse-btn"
  565. size="small"
  566. type="success"
  567. text
  568. @click="viewZipEntryResult(zf)"
  569. >查看</el-button>
  570. <el-button
  571. v-else-if="zf.parseable && !zf.parsing"
  572. class="att-parse-btn"
  573. size="small"
  574. type="primary"
  575. text
  576. @click="parseZipEntry(zf)"
  577. >解析</el-button>
  578. <el-button
  579. v-else-if="zf.parsing"
  580. class="att-parse-btn"
  581. size="small"
  582. type="info"
  583. text
  584. disabled
  585. ><el-icon class="is-loading"><Loading /></el-icon></el-button>
  586. </div>
  587. </div>
  588. </el-dialog>
  589. <!-- 引用模式:附件选择弹窗 -->
  590. <el-dialog
  591. v-model="showRefAttSelectDialog"
  592. title="📎 选择要引用的附件"
  593. width="480"
  594. :close-on-click-modal="true"
  595. class="floating-panel-dialog"
  596. align-center
  597. >
  598. <div class="fp-list">
  599. <div
  600. v-for="att in refAttSelectList"
  601. :key="att.id"
  602. class="fp-att-item"
  603. style="cursor:pointer"
  604. @click="onRefAttSelected(att)"
  605. >
  606. <div class="att-icon" :class="getFileTypeClass(att)">
  607. <span class="att-icon-text">{{ getFileTypeLabel(att) }}</span>
  608. </div>
  609. <div class="att-info">
  610. <div class="att-name" :title="att.displayName">{{ att.displayName }}</div>
  611. <div class="att-meta">
  612. <span class="att-type">{{ getFileTypeTag(att) }}</span>
  613. <span class="att-size">{{ formatFileSize(att.fileSize) }}</span>
  614. <span class="att-parse-status completed">✅ 已解析</span>
  615. </div>
  616. </div>
  617. </div>
  618. </div>
  619. </el-dialog>
  620. <!-- 规则管理弹窗 -->
  621. <el-dialog
  622. v-model="showRuleDialog"
  623. title="⚙️ 规则管理"
  624. width="720"
  625. :close-on-click-modal="true"
  626. class="floating-panel-dialog rule-manage-dialog"
  627. align-center
  628. >
  629. <div class="fp-toolbar">
  630. <el-input
  631. v-model="ruleSearchQuery"
  632. size="small"
  633. placeholder="搜索规则名称 / 要素标识..."
  634. clearable
  635. style="width: 220px"
  636. :prefix-icon="Search"
  637. />
  638. <el-button size="small" :icon="Plus" @click="showNewRuleDialog = true">添加规则</el-button>
  639. <el-button
  640. v-if="rules.length > 0"
  641. type="success"
  642. size="small"
  643. @click="handleBatchExecuteRules"
  644. :loading="executingRules"
  645. >
  646. 批量执行
  647. </el-button>
  648. <span class="fp-count">{{ filteredRules.length }} / {{ rules.length }} 条</span>
  649. </div>
  650. <!-- 类型筛选标签栏 -->
  651. <div class="rule-filter-bar">
  652. <span
  653. class="rule-filter-tab"
  654. :class="{ active: ruleFilterType === 'all' }"
  655. @click="ruleFilterType = 'all'"
  656. >全部 <em>{{ ruleTypeStats.all || 0 }}</em></span>
  657. <span
  658. class="rule-filter-tab tab-summary"
  659. :class="{ active: ruleFilterType === 'summary' }"
  660. @click="ruleFilterType = 'summary'"
  661. >AI总结 <em>{{ ruleTypeStats.summary || 0 }}</em></span>
  662. <span
  663. class="rule-filter-tab tab-ai_extract"
  664. :class="{ active: ruleFilterType === 'ai_extract' }"
  665. @click="ruleFilterType = 'ai_extract'"
  666. >AI提取 <em>{{ ruleTypeStats.ai_extract || 0 }}</em></span>
  667. <span
  668. class="rule-filter-tab tab-table_extract"
  669. :class="{ active: ruleFilterType === 'table_extract' }"
  670. @click="ruleFilterType = 'table_extract'"
  671. >表格提取 <em>{{ ruleTypeStats.table_extract || 0 }}</em></span>
  672. <span
  673. class="rule-filter-tab tab-quote"
  674. :class="{ active: ruleFilterType === 'quote' }"
  675. @click="ruleFilterType = 'quote'"
  676. >引用 <em>{{ ruleTypeStats.quote || 0 }}</em></span>
  677. <span
  678. class="rule-filter-tab tab-use_entity_value"
  679. :class="{ active: ruleFilterType === 'use_entity_value' }"
  680. @click="ruleFilterType = 'use_entity_value'"
  681. >人工录入 <em>{{ ruleTypeStats.use_entity_value || 0 }}</em></span>
  682. </div>
  683. <!-- 规则列表 -->
  684. <div class="fp-list">
  685. <div
  686. v-for="rule in filteredRules"
  687. :key="rule.id"
  688. class="fp-rule-item"
  689. :class="{ expanded: expandedRuleId === rule.id }"
  690. @click="toggleRuleExpand(rule.id)"
  691. >
  692. <div class="rule-item-main">
  693. <span class="rule-action-badge" :class="'action-' + rule.actionType">{{ ruleActionLabel(rule.actionType) }}</span>
  694. <div class="rule-info">
  695. <div class="rule-name-row">
  696. <span class="rule-name">{{ rule.ruleName }}</span>
  697. <el-tag size="small" type="info" effect="plain" class="rule-elem-key">{{ rule.elementKey }}</el-tag>
  698. </div>
  699. <div class="rule-desc" v-if="rule.description">{{ rule.description }}</div>
  700. </div>
  701. <div class="rule-actions">
  702. <el-button size="small" type="primary" text @click.stop="handleExecuteRule(rule)" title="执行" :loading="rule._executing">▶</el-button>
  703. <el-button size="small" type="danger" text :icon="Delete" @click.stop="handleDeleteRule(rule)" title="删除" />
  704. </div>
  705. </div>
  706. <!-- 展开详情 -->
  707. <div class="rule-detail" v-if="expandedRuleId === rule.id">
  708. <div class="rule-detail-row" v-if="rule.dslContent">
  709. <span class="rule-detail-label">取值规则</span>
  710. <span class="rule-detail-value">{{ rule.dslContent }}</span>
  711. </div>
  712. <div class="rule-detail-row" v-if="rule.inputs && rule.inputs.length > 0">
  713. <span class="rule-detail-label">输入来源</span>
  714. <div class="rule-detail-inputs">
  715. <span v-for="inp in rule.inputs" :key="inp.inputId" class="rule-input-chip">
  716. 📎 {{ inp.inputName || inp.sourceName }}
  717. </span>
  718. </div>
  719. </div>
  720. <div class="rule-detail-row" v-if="rule.lastRunStatus">
  721. <span class="rule-detail-label">上次执行</span>
  722. <el-tag size="small" :type="rule.lastRunStatus === 'success' ? 'success' : 'danger'">
  723. {{ rule.lastRunStatus === 'success' ? '成功' : '失败' }}
  724. </el-tag>
  725. <span v-if="rule.lastRunTime" class="rule-detail-time">{{ rule.lastRunTime }}</span>
  726. </div>
  727. <div class="rule-detail-row" v-if="rule.lastRunError">
  728. <span class="rule-detail-label">错误信息</span>
  729. <span class="rule-detail-error">{{ rule.lastRunError }}</span>
  730. </div>
  731. </div>
  732. </div>
  733. <el-empty v-if="filteredRules.length === 0" :description="ruleSearchQuery || ruleFilterType !== 'all' ? '无匹配规则' : '暂无规则,点击添加按钮创建'" :image-size="80" />
  734. </div>
  735. </el-dialog>
  736. <!-- 新建项目对话框 -->
  737. <el-dialog v-model="showNewProjectDialog" title="新建项目" width="460" :close-on-click-modal="false">
  738. <el-form :model="newProjectForm" label-width="80px">
  739. <el-form-item label="项目名称" required>
  740. <el-input v-model="newProjectForm.title" placeholder="请输入项目名称" maxlength="100" show-word-limit />
  741. </el-form-item>
  742. <el-form-item label="项目描述">
  743. <el-input v-model="newProjectForm.description" type="textarea" :rows="3" placeholder="项目描述(可选)" />
  744. </el-form-item>
  745. </el-form>
  746. <template #footer>
  747. <el-button @click="showNewProjectDialog = false">取消</el-button>
  748. <el-button type="primary" @click="handleCreateProject" :disabled="!newProjectForm.title.trim()" :loading="creatingProject">创建</el-button>
  749. </template>
  750. </el-dialog>
  751. <!-- 添加要素对话框 -->
  752. <el-dialog v-model="showAddElementDialog" title="添加要素" width="500">
  753. <el-form :model="newElementForm" label-width="100px">
  754. <el-form-item label="要素名称" required>
  755. <el-input v-model="newElementForm.elementName" placeholder="如:项目编号" />
  756. </el-form-item>
  757. <el-form-item label="要素标识" required>
  758. <el-input v-model="newElementForm.elementKey" placeholder="如:basicInfo.projectCode" />
  759. </el-form-item>
  760. <el-form-item label="数据类型">
  761. <el-select v-model="newElementForm.dataType" style="width: 100%">
  762. <el-option label="文本" value="text" />
  763. <el-option label="数字" value="number" />
  764. <el-option label="日期" value="date" />
  765. <el-option label="金额" value="money" />
  766. </el-select>
  767. </el-form-item>
  768. <el-form-item label="描述">
  769. <el-input v-model="newElementForm.description" type="textarea" :rows="2" placeholder="要素描述" />
  770. </el-form-item>
  771. </el-form>
  772. <template #footer>
  773. <el-button @click="showAddElementDialog = false">取消</el-button>
  774. <el-button type="primary" @click="handleAddElement" :disabled="!newElementForm.elementName || !newElementForm.elementKey">添加</el-button>
  775. </template>
  776. </el-dialog>
  777. <!-- 添加规则对话框 -->
  778. <el-dialog v-model="showNewRuleDialog" title="添加规则" width="500">
  779. <el-form :model="newRuleForm" label-width="100px">
  780. <el-form-item label="规则名称" required>
  781. <el-input v-model="newRuleForm.ruleName" placeholder="如:项目编号-直接引用实体" />
  782. </el-form-item>
  783. <el-form-item label="规则类型" required>
  784. <el-select v-model="newRuleForm.ruleType" style="width: 100%">
  785. <el-option label="直接引用实体" value="direct_entity" />
  786. <el-option label="AI 提取" value="ai_extract" />
  787. <el-option label="固定值" value="fixed_value" />
  788. <el-option label="计算公式" value="formula" />
  789. </el-select>
  790. </el-form-item>
  791. <el-form-item label="目标要素">
  792. <el-select v-model="newRuleForm.targetElementKey" style="width: 100%" placeholder="选择要填充的要素">
  793. <el-option v-for="elem in elements" :key="elem.elementKey" :label="elem.elementName" :value="elem.elementKey" />
  794. </el-select>
  795. </el-form-item>
  796. </el-form>
  797. <template #footer>
  798. <el-button @click="showNewRuleDialog = false">取消</el-button>
  799. <el-button type="primary" @click="handleCreateRule" :disabled="!newRuleForm.ruleName">创建</el-button>
  800. </template>
  801. </el-dialog>
  802. </div>
  803. </template>
  804. <script setup>
  805. import { ref, reactive, computed, watch, onMounted } from 'vue'
  806. import { useRouter, useRoute } from 'vue-router'
  807. import { Plus, Delete, Search, Loading, Check, CopyDocument, MoreFilled, List, Folder, Document, Grid, Setting, Paperclip } from '@element-plus/icons-vue'
  808. import { ElMessage, ElMessageBox } from 'element-plus'
  809. import { projectApi, elementApi, valueApi, attachmentApi, ruleApi, parseApi } from '@/api'
  810. import { marked } from 'marked'
  811. import JSZip from 'jszip'
  812. import { useTaskCenterStore } from '@/stores/taskCenter'
  813. const router = useRouter()
  814. const route = useRoute()
  815. const taskCenterStore = useTaskCenterStore()
  816. const taskRunningCount = computed(() => taskCenterStore.runningCount)
  817. const currentProjectId = ref(null)
  818. const hasActiveProject = computed(() => !!currentProjectId.value)
  819. const userName = computed(() => localStorage.getItem('username') || '用户')
  820. const greetingText = computed(() => {
  821. const hour = new Date().getHours()
  822. if (hour < 6) return '凌晨好'
  823. if (hour < 9) return '早上好'
  824. if (hour < 12) return '上午好'
  825. if (hour < 14) return '中午好'
  826. if (hour < 18) return '下午好'
  827. if (hour < 22) return '晚上好'
  828. return '夜深了'
  829. })
  830. const projectTitle = ref('')
  831. const viewMode = ref('document')
  832. const saved = ref(true)
  833. const editorRef = ref(null)
  834. const loading = ref(false)
  835. const leftPanelWidth = ref(300)
  836. const rightPanelWidth = ref(340)
  837. const isResizing = ref(false)
  838. const resizeType = ref('')
  839. const leftPanelTab = ref('projects')
  840. const sidebarShowAll = ref(false)
  841. const recentActivities = computed(() => {
  842. // 基于项目列表生成最近操作(后续可接入真实 API)
  843. const acts = []
  844. for (const p of projects.value.slice(0, 5)) {
  845. acts.push({
  846. text: `${p.title}`,
  847. source: `@${userName.value}`,
  848. time: formatTime(p.updatedAt || p.createdAt)
  849. })
  850. }
  851. return acts
  852. })
  853. const projects = ref([])
  854. const loadingProjects = ref(false)
  855. const projectSearchKeyword = ref('')
  856. const elements = ref([])
  857. const values = ref([])
  858. const attachments = ref([])
  859. const rules = ref([])
  860. const selectedAttachment = ref(null)
  861. const executingRules = ref(false)
  862. // AI 助手
  863. const aiMessages = ref([])
  864. const aiInputText = ref('')
  865. const aiMessagesRef = ref(null)
  866. // 已填充的要素值(用于右侧标签云)
  867. const filledValues = computed(() => {
  868. const result = []
  869. for (const elem of elements.value) {
  870. const elemVals = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
  871. for (const val of elemVals) {
  872. if (val.valueText && val.valueText.length > 0) {
  873. result.push({ valueId: val.valueId, valueText: val.valueText, elementName: elem.elementName, elementKey: elem.elementKey })
  874. }
  875. }
  876. }
  877. return result
  878. })
  879. // 文档预览状态
  880. const docContent = ref(null)
  881. const docLoading = ref(false)
  882. const docHtml = ref('')
  883. const docTemplateHtml = ref('')
  884. const docPaperRef = ref(null)
  885. const highlightEnabled = ref(true)
  886. const elementHighlightCount = ref(0)
  887. const highlightPopover = reactive({
  888. visible: false, x: 0, y: 0,
  889. elementKey: '', fullElementKey: '', elementName: '', currentValue: '', originalValue: '', valueId: null
  890. })
  891. // 附件引用系统
  892. const citationToolbar = reactive({
  893. visible: false, x: 0, y: 0,
  894. selectedText: '',
  895. step: 'select_action', // 'select_action' | 'select_element'
  896. actionType: '', // 'quote' | 'summary' | 'table_extract'
  897. })
  898. const showRefAttSelectDialog = ref(false) // 引用模式下的附件选择弹窗
  899. const refAttSelectList = ref([]) // 可选附件列表
  900. const refAttSelectElemKey = ref('') // 暂存目标要素 key
  901. const refAttSelectElemName = ref('') // 暂存目标要素名
  902. const referenceMode = ref(false) // 是否处于「从要素引用附件」模式
  903. const referenceModeElementKey = ref('') // 引用模式下锁定的目标要素 key
  904. const referenceModeElementName = ref('') // 引用模式下锁定的目标要素名
  905. const referenceModeAttId = ref(null) // 引用来源的附件节点 ID
  906. const referenceModeAttName = ref('') // 引用来源的附件名
  907. const showNewProjectDialog = ref(false)
  908. const showAddElementDialog = ref(false)
  909. const showNewRuleDialog = ref(false)
  910. const showAttachmentDialog = ref(false)
  911. const showRuleDialog = ref(false)
  912. const ruleSearchQuery = ref('')
  913. const ruleFilterType = ref('all')
  914. const expandedRuleId = ref(null)
  915. const filteredRules = computed(() => {
  916. let list = rules.value
  917. if (ruleFilterType.value !== 'all') {
  918. list = list.filter(r => r.actionType === ruleFilterType.value)
  919. }
  920. const q = ruleSearchQuery.value.trim().toLowerCase()
  921. if (q) {
  922. list = list.filter(r =>
  923. (r.ruleName || '').toLowerCase().includes(q) ||
  924. (r.elementKey || '').toLowerCase().includes(q) ||
  925. (r.description || '').toLowerCase().includes(q)
  926. )
  927. }
  928. return list
  929. })
  930. const ruleTypeStats = computed(() => {
  931. const stats = { all: rules.value.length }
  932. for (const r of rules.value) {
  933. const t = r.actionType || 'unknown'
  934. stats[t] = (stats[t] || 0) + 1
  935. }
  936. return stats
  937. })
  938. function toggleRuleExpand(ruleId) {
  939. expandedRuleId.value = expandedRuleId.value === ruleId ? null : ruleId
  940. }
  941. function ruleInputSummary(rule) {
  942. if (!rule.inputs || rule.inputs.length === 0) return ''
  943. return rule.inputs.map(i => i.inputName || i.sourceName || '').filter(Boolean).join('、')
  944. }
  945. // 附件解析状态: { [attachmentId]: { status: 'idle'|'uploading'|'parsing'|'completed'|'failed', progress: '', markdown: '' } }
  946. const parseStates = reactive({})
  947. const showParseResultDialog = ref(false)
  948. const parseResultAttName = ref('')
  949. const parseResultContent = ref('')
  950. const parseResultViewMode = ref('rendered')
  951. const parseResultIsHtml = ref(false)
  952. const parseResultHtml = computed(() => {
  953. if (!parseResultContent.value) return ''
  954. // DOCX 解析结果已经是 HTML,直接使用
  955. if (parseResultIsHtml.value) return parseResultContent.value
  956. // PDF/图片解析结果是 markdown,用 marked 渲染
  957. try {
  958. return marked(parseResultContent.value)
  959. } catch (e) {
  960. return `<pre>${parseResultContent.value}</pre>`
  961. }
  962. })
  963. const parseResultSource = computed(() => {
  964. if (!parseResultContent.value) return ''
  965. // 源码视图:将 base64 数据替换为简短占位符
  966. return parseResultContent.value.replace(
  967. /src="data:[^"]+"/g, 'src="[图片数据已省略]"'
  968. ).replace(
  969. /!\[([^\]]*)\]\(data:[^;]+;base64,[A-Za-z0-9+/=]+\)/g,
  970. '![$1](📷 [图片数据已省略])'
  971. )
  972. })
  973. // 原件预览(集成在解析结果弹窗中)
  974. const previewContentType = ref('') // 'image' | 'pdf' | 'html' | 'text' | ''
  975. const previewContentUrl = ref('')
  976. const previewContentHtml = ref('')
  977. const previewContentText = ref('')
  978. const parseResultPreviewAvailable = ref(false) // 是否有原件可预览
  979. const parseResultOriginAtt = ref(null) // 关联的独立附件对象(非 ZIP)
  980. const parseResultOriginZf = ref(null) // 关联的 zip file entry 对象
  981. // ZIP 内容展示
  982. const showZipContentsDialog = ref(false)
  983. const zipContentsAttName = ref('')
  984. const zipFileList = ref([]) // [{ name, size, ext, parseable, parsing, parsed, parseResult, isHtml }]
  985. const zipInstance = ref(null) // 当前打开的 JSZip 实例
  986. const zipParentAtt = ref(null) // 当前打开的 ZIP 附件对象
  987. // 缓存上传的原始文件对象,用于后续解析
  988. const attachmentFileCache = new Map()
  989. const creatingProject = ref(false)
  990. const newProjectForm = reactive({ title: '', description: '' })
  991. const newElementForm = reactive({ elementName: '', elementKey: '', dataType: 'text', description: '' })
  992. const newRuleForm = reactive({ ruleName: '', ruleType: 'direct_entity', targetElementKey: '' })
  993. const filteredProjects = computed(() => {
  994. if (!projectSearchKeyword.value) return projects.value
  995. const kw = projectSearchKeyword.value.toLowerCase()
  996. return projects.value.filter(p => p.title?.toLowerCase().includes(kw))
  997. })
  998. const filledValueCount = computed(() => values.value.filter(v => v.isFilled).length)
  999. // 面板拖拽
  1000. function startResizeLeft() {
  1001. isResizing.value = true; resizeType.value = 'left'
  1002. document.addEventListener('mousemove', handleResize)
  1003. document.addEventListener('mouseup', stopResize)
  1004. document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'
  1005. }
  1006. function startResizeRight() {
  1007. isResizing.value = true; resizeType.value = 'right'
  1008. document.addEventListener('mousemove', handleResize)
  1009. document.addEventListener('mouseup', stopResize)
  1010. document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'
  1011. }
  1012. function handleResize(e) {
  1013. if (!isResizing.value) return
  1014. if (resizeType.value === 'left') leftPanelWidth.value = Math.max(240, Math.min(500, e.clientX))
  1015. else if (resizeType.value === 'right') rightPanelWidth.value = Math.max(280, Math.min(500, window.innerWidth - e.clientX))
  1016. }
  1017. function stopResize() {
  1018. isResizing.value = false; resizeType.value = ''
  1019. document.removeEventListener('mousemove', handleResize)
  1020. document.removeEventListener('mouseup', stopResize)
  1021. document.body.style.cursor = ''; document.body.style.userSelect = ''
  1022. }
  1023. // 项目操作
  1024. async function loadProjects() {
  1025. loadingProjects.value = true
  1026. try {
  1027. const data = await projectApi.list({ page: 1, size: 50 })
  1028. projects.value = data?.records || data || []
  1029. } catch (error) {
  1030. console.warn('获取项目列表失败:', error)
  1031. projects.value = []
  1032. } finally { loadingProjects.value = false }
  1033. }
  1034. async function switchProject(project) {
  1035. if (currentProjectId.value === project.id) { unselectProject(); return }
  1036. currentProjectId.value = project.id
  1037. projectTitle.value = project.title || ''
  1038. leftPanelTab.value = 'projects'
  1039. await loadProjectData(project.id)
  1040. }
  1041. function unselectProject() {
  1042. currentProjectId.value = null; projectTitle.value = ''
  1043. elements.value = []; values.value = []; attachments.value = []; rules.value = []
  1044. docContent.value = null; docHtml.value = ''
  1045. leftPanelTab.value = 'projects'
  1046. }
  1047. // 本地真实附件 mock 数据
  1048. const mockAttachments = [
  1049. { id: 'att-01', displayName: '附件1 成都院核心要素评审情况记录表.docx', fileType: 'docx', fileSize: 376919, fileUrl: '/attachments/att01-核心要素评审情况记录表.docx' },
  1050. { id: 'att-02', displayName: '附件2 成都院现场评审分工表.zip', fileType: 'zip', fileSize: 2171136, fileUrl: '/attachments/att02-现场评审分工表.zip' },
  1051. { id: 'att-03', displayName: '附件3 安全生产标准化建设通知.pdf', fileType: 'pdf', fileSize: 360940, fileUrl: '/attachments/att03-安全生产标准化通知.pdf' },
  1052. { id: 'att-04', displayName: '附件4 成都院材料真实性说明.png', fileType: 'png', fileSize: 514264, fileUrl: '/attachments/att04-材料真实性说明.png' },
  1053. { id: 'att-05', displayName: '附件5 成都院在建项目一览表.docx', fileType: 'docx', fileSize: 16056, fileUrl: '/attachments/att05-在建项目一览表.docx' },
  1054. { id: 'att-06', displayName: '附件6 成都院安全管理制度清单.docx', fileType: 'docx', fileSize: 16942, fileUrl: '/attachments/att06-安全管理制度清单.docx' },
  1055. { id: 'att-07', displayName: '附件7 成都院现场评审末次会签到表.png', fileType: 'png', fileSize: 564269, fileUrl: '/attachments/att07-现场评审末次会签到表.png' },
  1056. { id: 'att-08', displayName: '附件8 工作方案.zip', fileType: 'zip', fileSize: 299279, fileUrl: '/attachments/att08-工作方案.zip' },
  1057. { id: 'att-09', displayName: '附件9 复审问题建议表.docx', fileType: 'docx', fileSize: 29034, fileUrl: '/attachments/att09-复审问题建议表.docx' },
  1058. ]
  1059. async function loadProjectData(projectId) {
  1060. loading.value = true
  1061. try {
  1062. const [elemData, valData, attData, ruleData] = await Promise.all([
  1063. elementApi.list(projectId).catch(() => []),
  1064. valueApi.list(projectId).catch(() => []),
  1065. attachmentApi.list(projectId).catch(() => []),
  1066. ruleApi.list(projectId).catch(() => [])
  1067. ])
  1068. elements.value = elemData || []; values.value = valData || []
  1069. // 使用本地 mock 附件(含真实文件 URL,用于解析)
  1070. attachments.value = [...mockAttachments]
  1071. // 恢复已持久化的解析状态
  1072. restoreParseStates()
  1073. rules.value = ruleData || []
  1074. // 加载项目模板文档内容
  1075. loadDocContent(projectId)
  1076. } catch (error) { console.error('加载项目数据失败:', error) }
  1077. finally { loading.value = false }
  1078. }
  1079. async function handleCreateProject() {
  1080. if (!newProjectForm.title.trim()) return
  1081. creatingProject.value = true
  1082. try {
  1083. const project = await projectApi.create({ title: newProjectForm.title.trim(), description: newProjectForm.description })
  1084. showNewProjectDialog.value = false; newProjectForm.title = ''; newProjectForm.description = ''
  1085. await loadProjects()
  1086. if (project) await switchProject(project)
  1087. ElMessage.success('项目创建成功')
  1088. } catch (error) { ElMessage.error('创建失败: ' + error.message) }
  1089. finally { creatingProject.value = false }
  1090. }
  1091. function sendAiMessage() {
  1092. const text = aiInputText.value.trim()
  1093. if (!text) return
  1094. aiMessages.value.push({ role: 'user', content: text })
  1095. aiInputText.value = ''
  1096. // 模拟 AI 回复(后续接入真实 API)
  1097. setTimeout(() => {
  1098. aiMessages.value.push({ role: 'assistant', content: '收到您的消息,AI 助手功能正在开发中,敬请期待...' })
  1099. if (aiMessagesRef.value) aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
  1100. }, 500)
  1101. setTimeout(() => {
  1102. if (aiMessagesRef.value) aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
  1103. }, 50)
  1104. }
  1105. function handleFooterCommand(cmd) {
  1106. if (cmd === 'settings') {
  1107. ElMessage.info('系统设置开发中...')
  1108. } else if (cmd === 'logout') {
  1109. ElMessageBox.confirm('确定要退出登录吗?', '退出确认', {
  1110. confirmButtonText: '退出', cancelButtonText: '取消', type: 'warning'
  1111. }).then(async () => {
  1112. try { await import('@/api').then(m => m.authApi.logout()) } catch (e) { /* ignore */ }
  1113. localStorage.removeItem('accessToken')
  1114. localStorage.removeItem('refreshToken')
  1115. localStorage.removeItem('userId')
  1116. localStorage.removeItem('username')
  1117. ElMessage.success('已退出登录')
  1118. router.push('/login')
  1119. }).catch(() => {})
  1120. }
  1121. }
  1122. async function handleProjectCommand(cmd, project) {
  1123. switch (cmd) {
  1124. case 'copy':
  1125. try { await projectApi.copy(project.id); await loadProjects(); ElMessage.success('项目复制成功') }
  1126. catch (e) { ElMessage.error('复制失败: ' + e.message) }
  1127. break
  1128. case 'archive':
  1129. try { await projectApi.archive(project.id); await loadProjects(); ElMessage.success('项目已归档') }
  1130. catch (e) { ElMessage.error('归档失败: ' + e.message) }
  1131. break
  1132. case 'delete':
  1133. try {
  1134. await ElMessageBox.confirm(`确定要删除项目「${project.title}」吗?`, '删除确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
  1135. await projectApi.delete(project.id)
  1136. if (currentProjectId.value === project.id) unselectProject()
  1137. await loadProjects(); ElMessage.success('项目已删除')
  1138. } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message) }
  1139. break
  1140. }
  1141. }
  1142. async function handleCopyProject() {
  1143. if (!currentProjectId.value) return
  1144. try {
  1145. const copied = await projectApi.copy(currentProjectId.value)
  1146. await loadProjects(); ElMessage.success('项目复制成功')
  1147. if (copied) await switchProject(copied)
  1148. } catch (e) { ElMessage.error('复制失败: ' + e.message) }
  1149. }
  1150. function handleSave() { saved.value = true; ElMessage.success('保存成功') }
  1151. function handleTitlebarCommand(cmd) {
  1152. if (cmd === 'save') handleSave()
  1153. else if (cmd === 'copy') handleCopyProject()
  1154. else if (cmd === 'export') ElMessage.info('导出功能开发中...')
  1155. }
  1156. // ==================== 文档预览 + 可编辑 + 要素高亮 ====================
  1157. async function loadDocContent(projectId) {
  1158. if (!projectId) return
  1159. docLoading.value = true
  1160. docContent.value = null
  1161. docHtml.value = ''
  1162. try {
  1163. const data = await projectApi.getDocContent(projectId)
  1164. docContent.value = data
  1165. renderDocHtml()
  1166. renderDocHtmlTemplate()
  1167. } catch (e) {
  1168. console.warn('加载文档内容失败:', e)
  1169. docContent.value = null
  1170. docHtml.value = ''
  1171. } finally {
  1172. docLoading.value = false
  1173. }
  1174. }
  1175. const docPaperStyle = computed(() => {
  1176. const page = docContent.value?.page
  1177. if (!page) return {}
  1178. return {
  1179. maxWidth: `${page.widthMm * 3.78}px`,
  1180. paddingTop: `${page.marginTopMm * 3.78}px`,
  1181. paddingBottom: `${page.marginBottomMm * 3.78}px`,
  1182. paddingLeft: `${page.marginLeftMm * 3.78}px`,
  1183. paddingRight: `${page.marginRightMm * 3.78}px`,
  1184. }
  1185. })
  1186. // 从值的 elementKey 中提取不含项目前缀的 key
  1187. // 例如 "PRJ-2024-001:basicInfo.projectCode" -> "basicInfo.projectCode"
  1188. function stripValueKeyPrefix(valueElementKey) {
  1189. const idx = valueElementKey?.indexOf(':')
  1190. return idx >= 0 ? valueElementKey.substring(idx + 1) : valueElementKey
  1191. }
  1192. // 当前弹出框要素关联的规则列表
  1193. const popoverRelatedRules = computed(() => {
  1194. const key = highlightPopover.elementKey
  1195. if (!key) return []
  1196. return rules.value.filter(r => {
  1197. const rk = stripValueKeyPrefix(r.elementKey)
  1198. return rk === key
  1199. })
  1200. })
  1201. function ruleActionLabel(actionType) {
  1202. const map = {
  1203. quote: '引用',
  1204. summary: 'AI总结',
  1205. ai_extract: 'AI提取',
  1206. table_extract: '表格提取',
  1207. use_entity_value: '人工录入',
  1208. }
  1209. return map[actionType] || actionType
  1210. }
  1211. // 从规则中提取来源文本(引用原文 / 规则描述)
  1212. function ruleSourceText(rule) {
  1213. // 优先从 actionConfig.sourceText 取(前端引用创建的规则)
  1214. if (rule.actionConfig) {
  1215. try {
  1216. const cfg = typeof rule.actionConfig === 'string' ? JSON.parse(rule.actionConfig) : rule.actionConfig
  1217. if (cfg.sourceText) return cfg.sourceText
  1218. if (cfg.description) return cfg.description
  1219. } catch (_) { /* ignore */ }
  1220. }
  1221. // 其次取 dslContent(mock 规则的取值规则描述)
  1222. if (rule.dslContent) return rule.dslContent
  1223. // 最后取 description
  1224. if (rule.description) return rule.description
  1225. return ''
  1226. }
  1227. // 构建要素值映射表,分为长文本、短文本、静态文本三类
  1228. function buildElementValueMap() {
  1229. const longTexts = [] // paragraph/table 类型的长文本要素
  1230. const shortTexts = [] // text 类型的短文本要素(动态)
  1231. const staticTexts = [] // static 类型的静态要素
  1232. const colors = [
  1233. '#fff3cd', '#cce5ff', '#d4edda', '#f8d7da', '#e2d5f1',
  1234. '#d1ecf1', '#ffeeba', '#c3e6cb', '#f5c6cb', '#d6d8db'
  1235. ]
  1236. let colorIdx = 0
  1237. for (const elem of elements.value) {
  1238. const elemValues = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
  1239. const elemType = elem.elementType || 'text'
  1240. const isStatic = elemType === 'static'
  1241. for (const val of elemValues) {
  1242. const text = val.valueText
  1243. if (!text || text.length < 2) continue
  1244. const entry = {
  1245. text,
  1246. elementKey: elem.elementKey,
  1247. fullElementKey: val.elementKey,
  1248. elementName: elem.elementName,
  1249. valueId: val.valueId,
  1250. elemType,
  1251. isStatic,
  1252. color: isStatic ? '#e8e8e8' : colors[colorIdx % colors.length]
  1253. }
  1254. if (isStatic) {
  1255. staticTexts.push(entry)
  1256. } else if (elemType === 'paragraph' || elemType === 'table') {
  1257. longTexts.push(entry)
  1258. } else {
  1259. shortTexts.push(entry)
  1260. }
  1261. }
  1262. if (!isStatic) colorIdx++
  1263. }
  1264. // 长文本按长度降序
  1265. longTexts.sort((a, b) => b.text.length - a.text.length)
  1266. // 短文本按长度降序
  1267. shortTexts.sort((a, b) => b.text.length - a.text.length)
  1268. // 静态文本按长度降序
  1269. staticTexts.sort((a, b) => b.text.length - a.text.length)
  1270. return { longTexts, shortTexts, staticTexts }
  1271. }
  1272. // 将文档 blocks 渲染为 HTML 字符串(含要素高亮)
  1273. function renderDocHtml() {
  1274. if (!docContent.value?.blocks) { docHtml.value = ''; return }
  1275. const blocks = docContent.value.blocks
  1276. const { longTexts, shortTexts, staticTexts } = highlightEnabled.value ? buildElementValueMap() : { longTexts: [], shortTexts: [], staticTexts: [] }
  1277. // 合并短文本和静态文本用于 runs 级别匹配(动态优先,静态在后)
  1278. const allShortTexts = [...shortTexts, ...staticTexts]
  1279. let highlightCount = 0
  1280. const parts = []
  1281. // 预处理:为每个长文本要素,将其 valueText 按行拆分为句子集合
  1282. // 用于判断某个 block 的文本是否属于某个长文本要素
  1283. const longTextLines = longTexts.map(lt => ({
  1284. ...lt,
  1285. lines: new Set(lt.text.split('\n').map(l => l.trim()).filter(l => l.length > 0))
  1286. }))
  1287. // 收集被长文本高亮覆盖的 block IDs,这些 block 内的短文本不再单独高亮
  1288. const longHighlightedBlockIds = new Set()
  1289. // 第一遍:确定哪些 block 属于长文本要素
  1290. for (const block of blocks) {
  1291. const blockText = getBlockPlainText(block)
  1292. if (!blockText) continue
  1293. for (const lt of longTextLines) {
  1294. if (lt.lines.has(blockText)) {
  1295. longHighlightedBlockIds.add(block.id)
  1296. break
  1297. }
  1298. }
  1299. }
  1300. // 第二遍:渲染,连续属于同一长文本要素的 block 合并到一个边框内
  1301. let currentLongKey = null // 当前正在收集的长文本要素 key
  1302. let currentLongMatch = null // 当前长文本要素匹配信息
  1303. let longGroupHtml = '' // 当前长文本分组的 HTML 累积
  1304. function flushLongGroup() {
  1305. if (currentLongKey && longGroupHtml) {
  1306. const borderColor = darkenColor(currentLongMatch.color)
  1307. parts.push(`<div class="elem-highlight-wrap" data-elem-key="${currentLongMatch.elementKey}" data-value-id="${currentLongMatch.valueId || ''}" title="${escapeAttr(currentLongMatch.elementName)}" style="border:2px solid ${borderColor};border-radius:4px;padding:6px 8px;margin:4px 0;cursor:pointer;background:${currentLongMatch.color};">${longGroupHtml}</div>`)
  1308. highlightCount++
  1309. }
  1310. currentLongKey = null
  1311. currentLongMatch = null
  1312. longGroupHtml = ''
  1313. }
  1314. for (const block of blocks) {
  1315. if (block.type === 'table') {
  1316. flushLongGroup()
  1317. const tableMatch = findTableLongTextMatch(block, longTextLines)
  1318. parts.push(renderTableHtml(block, tableMatch))
  1319. if (tableMatch) highlightCount++
  1320. } else {
  1321. const blockText = getBlockPlainText(block)
  1322. const longMatch = findBlockLongTextMatch(blockText, longTextLines)
  1323. if (longMatch) {
  1324. // 如果和当前分组是同一个要素,继续累积
  1325. if (currentLongKey === longMatch.elementKey) {
  1326. longGroupHtml += renderBlockHtml(block, [], null, () => {})
  1327. } else {
  1328. // 不同要素,先 flush 上一组,再开始新组
  1329. flushLongGroup()
  1330. currentLongKey = longMatch.elementKey
  1331. currentLongMatch = longMatch
  1332. longGroupHtml = renderBlockHtml(block, [], null, () => {})
  1333. }
  1334. } else {
  1335. // 非长文本 block,先 flush 再正常渲染
  1336. flushLongGroup()
  1337. parts.push(renderBlockHtml(block, allShortTexts, null, (n) => { highlightCount += n }))
  1338. }
  1339. }
  1340. }
  1341. flushLongGroup() // flush 最后一组
  1342. elementHighlightCount.value = highlightCount
  1343. docHtml.value = parts.join('')
  1344. }
  1345. // 要素视图:将动态要素值替换为 {{key}} 占位符
  1346. function renderDocHtmlTemplate() {
  1347. if (!docContent.value?.blocks) { docTemplateHtml.value = ''; return }
  1348. const blocks = docContent.value.blocks
  1349. const { longTexts, shortTexts } = buildElementValueMap()
  1350. // 合并所有动态要素(不含静态)
  1351. const allDynamic = [...shortTexts]
  1352. const longTextLines = longTexts.map(lt => ({
  1353. ...lt,
  1354. lines: new Set(lt.text.split('\n').map(l => l.trim()).filter(l => l.length > 0))
  1355. }))
  1356. const parts = []
  1357. let currentLongKey = null
  1358. let currentLongMatch = null
  1359. let longGroupHtml = ''
  1360. function flushLongGroup() {
  1361. if (currentLongKey && longGroupHtml) {
  1362. const tag = `<span class="elem-tpl-tag" title="${escapeAttr(currentLongMatch.elementName)}">{{${currentLongMatch.elementKey}}}</span>`
  1363. parts.push(`<div class="elem-tpl-block">${tag}</div>`)
  1364. }
  1365. currentLongKey = null
  1366. currentLongMatch = null
  1367. longGroupHtml = ''
  1368. }
  1369. for (const block of blocks) {
  1370. if (block.type === 'table') {
  1371. flushLongGroup()
  1372. const tableMatch = findTableLongTextMatch(block, longTextLines)
  1373. if (tableMatch) {
  1374. const tag = `<span class="elem-tpl-tag" title="${escapeAttr(tableMatch.elementName)}">{{${tableMatch.elementKey}}}</span>`
  1375. parts.push(`<div class="elem-tpl-block">${tag}</div>`)
  1376. } else {
  1377. parts.push(renderTableHtml(block, null))
  1378. }
  1379. } else {
  1380. const blockText = getBlockPlainText(block)
  1381. const longMatch = findBlockLongTextMatch(blockText, longTextLines)
  1382. if (longMatch) {
  1383. if (currentLongKey === longMatch.elementKey) {
  1384. longGroupHtml += 'x' // just accumulate
  1385. } else {
  1386. flushLongGroup()
  1387. currentLongKey = longMatch.elementKey
  1388. currentLongMatch = longMatch
  1389. longGroupHtml = 'x'
  1390. }
  1391. } else {
  1392. flushLongGroup()
  1393. parts.push(renderBlockHtmlTemplate(block, allDynamic))
  1394. }
  1395. }
  1396. }
  1397. flushLongGroup()
  1398. docTemplateHtml.value = parts.join('')
  1399. }
  1400. // 渲染单个 block,将匹配的动态短文本替换为 {{key}} 标签
  1401. function renderBlockHtmlTemplate(block, shortMap) {
  1402. const tag = getBlockTag(block.type)
  1403. const cls = `doc-block doc-${block.type}`
  1404. const styleStr = buildStyleStr(block.style)
  1405. const styleAttr = styleStr ? ` style="${styleStr}"` : ''
  1406. let inner = ''
  1407. // 图片
  1408. if (block.images?.length > 0) {
  1409. for (const img of block.images) {
  1410. const imgStyle = []
  1411. if (img.widthInch) imgStyle.push(`width:${img.widthInch}in`)
  1412. if (img.heightInch) imgStyle.push(`height:${img.heightInch}in`)
  1413. imgStyle.push('max-width:100%', 'display:block', 'margin:8px auto')
  1414. inner += `<img src="${img.src}" style="${imgStyle.join(';')}" class="doc-inline-image" contenteditable="false" />`
  1415. }
  1416. }
  1417. // Runs - 替换动态要素值为 {{key}}
  1418. if (block.runs) {
  1419. if (shortMap.length === 0) {
  1420. for (const run of block.runs) {
  1421. const text = escapeHtml(run.text)
  1422. const rs = buildRunStyleStr(run)
  1423. inner += rs ? `<span style="${rs}">${text}</span>` : text
  1424. }
  1425. } else {
  1426. inner += templateReplaceRuns(block.runs, shortMap)
  1427. }
  1428. }
  1429. if (!inner) inner = '&nbsp;'
  1430. return `<${tag} class="${cls}"${styleAttr} data-block-id="${block.id}">${inner}</${tag}>`
  1431. }
  1432. // 在 runs 纯文本中查找动态要素值并替换为 {{key}} 标签
  1433. function templateReplaceRuns(runs, shortMap) {
  1434. // 构建纯文本
  1435. let plainText = ''
  1436. const charToRun = []
  1437. for (let ri = 0; ri < runs.length; ri++) {
  1438. const t = runs[ri].text || ''
  1439. for (let ci = 0; ci < t.length; ci++) {
  1440. charToRun.push({ runIdx: ri, offsetInRun: ci })
  1441. plainText += t[ci]
  1442. }
  1443. }
  1444. // 查找匹配
  1445. const matches = []
  1446. for (const em of shortMap) {
  1447. const val = em.text
  1448. if (!val || val.length < 2) continue
  1449. let pos = 0
  1450. while (true) {
  1451. const idx = plainText.indexOf(val, pos)
  1452. if (idx < 0) break
  1453. matches.push({ start: idx, end: idx + val.length, em })
  1454. pos = idx + val.length
  1455. }
  1456. }
  1457. matches.sort((a, b) => a.start - b.start || b.end - a.end)
  1458. const filtered = []
  1459. let lastEnd = -1
  1460. for (const m of matches) {
  1461. if (m.start >= lastEnd) {
  1462. filtered.push(m)
  1463. lastEnd = m.end
  1464. }
  1465. }
  1466. if (filtered.length === 0) {
  1467. let html = ''
  1468. for (const run of runs) {
  1469. const text = escapeHtml(run.text)
  1470. const rs = buildRunStyleStr(run)
  1471. html += rs ? `<span style="${rs}">${text}</span>` : text
  1472. }
  1473. return html
  1474. }
  1475. // 切分为普通段 + 替换段
  1476. const segments = []
  1477. let cursor = 0
  1478. for (const m of filtered) {
  1479. if (m.start > cursor) segments.push({ start: cursor, end: m.start, em: null })
  1480. segments.push({ start: m.start, end: m.end, em: m.em })
  1481. cursor = m.end
  1482. }
  1483. if (cursor < plainText.length) segments.push({ start: cursor, end: plainText.length, em: null })
  1484. let html = ''
  1485. for (const seg of segments) {
  1486. if (seg.em) {
  1487. // 替换为 {{key}} 标签
  1488. html += `<span class="elem-tpl-tag" title="${escapeAttr(seg.em.elementName)}">{{${seg.em.elementKey}}}</span>`
  1489. } else {
  1490. // 普通文本保留 run 样式
  1491. const segChars = charToRun.slice(seg.start, seg.end)
  1492. const groups = []
  1493. let curGroup = null
  1494. for (const ch of segChars) {
  1495. if (!curGroup || curGroup.runIdx !== ch.runIdx) {
  1496. curGroup = { runIdx: ch.runIdx, startOffset: ch.offsetInRun, endOffset: ch.offsetInRun + 1 }
  1497. groups.push(curGroup)
  1498. } else {
  1499. curGroup.endOffset = ch.offsetInRun + 1
  1500. }
  1501. }
  1502. for (const g of groups) {
  1503. const run = runs[g.runIdx]
  1504. const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
  1505. const rs = buildRunStyleStr(run)
  1506. html += rs ? `<span style="${rs}">${slice}</span>` : slice
  1507. }
  1508. }
  1509. }
  1510. return html
  1511. }
  1512. // 切换视图模式时触发模板渲染
  1513. watch(viewMode, (mode) => {
  1514. if (mode === 'elements' && docContent.value) {
  1515. renderDocHtmlTemplate()
  1516. }
  1517. })
  1518. // 替换文档 blocks 中的旧值文本为新值
  1519. function replaceTextInBlocks(blocks, oldText, newText) {
  1520. for (const block of blocks) {
  1521. if (block.type === 'table' && block.rows) {
  1522. // 表格:遍历每行每个单元格
  1523. for (const row of block.rows) {
  1524. for (const cell of row) {
  1525. if (cell.blocks) replaceTextInBlocks(cell.blocks, oldText, newText)
  1526. }
  1527. }
  1528. continue
  1529. }
  1530. if (!block.runs || block.runs.length === 0) continue
  1531. // 将 runs 拼接后检查是否包含旧值
  1532. const fullText = block.runs.map(r => r.text).join('')
  1533. if (!fullText.includes(oldText)) continue
  1534. // 简单情况:旧值完整存在于单个 run 中
  1535. let replaced = false
  1536. for (const run of block.runs) {
  1537. if (run.text && run.text.includes(oldText)) {
  1538. run.text = run.text.replace(oldText, newText)
  1539. replaced = true
  1540. break
  1541. }
  1542. }
  1543. if (replaced) continue
  1544. // 复杂情况:旧值跨越多个 runs —— 重建 runs
  1545. const newFullText = fullText.replace(oldText, newText)
  1546. // 保留第一个 run 的样式,将所有文本合并到第一个 run
  1547. if (block.runs.length > 0) {
  1548. block.runs[0].text = newFullText
  1549. block.runs.length = 1
  1550. }
  1551. }
  1552. }
  1553. // 获取 block 的纯文本
  1554. function getBlockPlainText(block) {
  1555. if (!block.runs) return ''
  1556. return block.runs.map(r => r.text).join('').trim()
  1557. }
  1558. // 查找 block 文本是否匹配某个长文本要素
  1559. function findBlockLongTextMatch(blockText, longTextLines) {
  1560. if (!blockText) return null
  1561. for (const lt of longTextLines) {
  1562. if (lt.lines.has(blockText)) return lt
  1563. }
  1564. return null
  1565. }
  1566. // 查找表格是否匹配某个长文本要素(通过表格第一行文本匹配)
  1567. function findTableLongTextMatch(block, longTextLines) {
  1568. if (!block.table?.data?.length) return null
  1569. const firstRowText = block.table.data[0].map(c => c.text).join(' | ')
  1570. for (const lt of longTextLines) {
  1571. if (lt.text.includes(firstRowText)) return lt
  1572. }
  1573. return null
  1574. }
  1575. function renderBlockHtml(block, shortMap, longMatch, countFn) {
  1576. const tag = getBlockTag(block.type)
  1577. const cls = `doc-block doc-${block.type}`
  1578. const styleStr = buildStyleStr(block.style)
  1579. const styleAttr = styleStr ? ` style="${styleStr}"` : ''
  1580. const isToc = block.type?.startsWith('toc')
  1581. let inner = ''
  1582. // 图片
  1583. if (block.images?.length > 0) {
  1584. for (const img of block.images) {
  1585. const imgStyle = []
  1586. if (img.widthInch) imgStyle.push(`width:${img.widthInch}in`)
  1587. if (img.heightInch) imgStyle.push(`height:${img.heightInch}in`)
  1588. imgStyle.push('max-width:100%', 'display:block', 'margin:8px auto')
  1589. inner += `<img src="${img.src}" style="${imgStyle.join(';')}" class="doc-inline-image" contenteditable="false" />`
  1590. }
  1591. }
  1592. // Runs
  1593. if (block.runs) {
  1594. if (isToc) {
  1595. // 目录项:忽略 run 内联样式,由 CSS 统一控制外观
  1596. for (const run of block.runs) {
  1597. inner += escapeHtml(run.text)
  1598. }
  1599. } else if (longMatch || shortMap.length === 0) {
  1600. // 长文本高亮或无需短文本高亮,直接渲染
  1601. for (const run of block.runs) {
  1602. const text = escapeHtml(run.text)
  1603. const rs = buildRunStyleStr(run)
  1604. inner += rs ? `<span style="${rs}">${text}</span>` : text
  1605. }
  1606. if (longMatch) countFn(1)
  1607. } else {
  1608. // 短文本高亮:基于纯文本位置匹配,支持跨 run 拆分的文本
  1609. const result = highlightRunsWithElements(block.runs, shortMap)
  1610. inner += result.html
  1611. if (result.count > 0) countFn(result.count)
  1612. }
  1613. }
  1614. if (!inner) inner = '&nbsp;'
  1615. return `<${tag} class="${cls}"${styleAttr} data-block-id="${block.id}">${inner}</${tag}>`
  1616. }
  1617. function renderTableHtml(block, longMatch) {
  1618. const t = block.table
  1619. if (!t?.data) return ''
  1620. let html = `<table class="doc-table" data-block-id="${block.id}">`
  1621. for (let ri = 0; ri < t.data.length; ri++) {
  1622. html += '<tr>'
  1623. for (const cell of t.data[ri]) {
  1624. const cs = cell.colspan > 1 ? ` colspan="${cell.colspan}"` : ''
  1625. html += `<td class="doc-table-cell"${cs}>${escapeHtml(cell.text)}</td>`
  1626. }
  1627. html += '</tr>'
  1628. }
  1629. html += '</table>'
  1630. if (longMatch) {
  1631. const borderColor = darkenColor(longMatch.color)
  1632. html = `<div class="elem-highlight-wrap" data-elem-key="${longMatch.elementKey}" data-value-id="${longMatch.valueId || ''}" title="${escapeAttr(longMatch.elementName)}" style="border:2px solid ${borderColor};border-radius:8px;padding:8px;margin:12px 0;cursor:pointer;background:${longMatch.color};">${html}</div>`
  1633. }
  1634. return html
  1635. }
  1636. function getBlockTag(type) {
  1637. if (type === 'heading1') return 'h1'
  1638. if (type === 'heading2') return 'h2'
  1639. if (type === 'heading3') return 'h3'
  1640. if (type?.startsWith('toc')) return 'div'
  1641. return 'p'
  1642. }
  1643. function buildStyleStr(style) {
  1644. if (!style) return ''
  1645. const parts = []
  1646. if (style.alignment) {
  1647. const map = { left: 'left', center: 'center', right: 'right', justify: 'justify', both: 'justify' }
  1648. parts.push(`text-align:${map[style.alignment] || style.alignment}`)
  1649. }
  1650. if (style.indentLeft) parts.push(`padding-left:${style.indentLeft / 914400}in`)
  1651. if (style.indentRight) parts.push(`padding-right:${style.indentRight / 914400}in`)
  1652. if (style.indentFirstLine) parts.push(`text-indent:${style.indentFirstLine / 914400}in`)
  1653. if (style.indentHanging) parts.push(`text-indent:-${style.indentHanging / 914400}in`)
  1654. if (style.spacingBefore) parts.push(`margin-top:${style.spacingBefore / 914400}in`)
  1655. if (style.spacingAfter) parts.push(`margin-bottom:${style.spacingAfter / 914400}in`)
  1656. if (style.lineSpacing && style.lineSpacing >= 1 && style.lineSpacing <= 5) parts.push(`line-height:${style.lineSpacing}`)
  1657. return parts.join(';')
  1658. }
  1659. function buildRunStyleStr(run) {
  1660. const parts = []
  1661. if (run.fontFamily) parts.push(`font-family:${run.fontFamily}`)
  1662. if (run.fontSize) parts.push(`font-size:${run.fontSize}pt`)
  1663. if (run.bold) parts.push('font-weight:bold')
  1664. if (run.italic) parts.push('font-style:italic')
  1665. if (run.color) parts.push(`color:${run.color.startsWith('#') ? run.color : '#' + run.color}`)
  1666. if (run.underline) parts.push('text-decoration:underline')
  1667. if (run.strikeThrough) parts.push('text-decoration:line-through')
  1668. return parts.join(';')
  1669. }
  1670. function escapeHtml(text) {
  1671. if (!text) return ''
  1672. return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
  1673. }
  1674. function escapeAttr(text) {
  1675. if (!text) return ''
  1676. return text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
  1677. }
  1678. // 基于纯文本位置的短文本高亮,支持跨 run 拆分的文本匹配
  1679. function highlightRunsWithElements(runs, shortMap) {
  1680. // 1. 构建纯文本和每个字符到 run 的映射
  1681. let plainText = ''
  1682. const charToRun = [] // charToRun[i] = { runIdx, offsetInRun }
  1683. for (let ri = 0; ri < runs.length; ri++) {
  1684. const t = runs[ri].text || ''
  1685. for (let ci = 0; ci < t.length; ci++) {
  1686. charToRun.push({ runIdx: ri, offsetInRun: ci })
  1687. plainText += t[ci]
  1688. }
  1689. }
  1690. // 2. 在纯文本中查找所有要素值的匹配位置
  1691. const matches = [] // { start, end, em }
  1692. for (const em of shortMap) {
  1693. const val = em.text
  1694. if (!val || val.length < 2) continue
  1695. let pos = 0
  1696. while (true) {
  1697. const idx = plainText.indexOf(val, pos)
  1698. if (idx < 0) break
  1699. matches.push({ start: idx, end: idx + val.length, em })
  1700. pos = idx + val.length
  1701. }
  1702. }
  1703. // 3. 去重:按 start 排序,移除被更长匹配覆盖的(已按长度降序排列的 shortMap 保证优先)
  1704. matches.sort((a, b) => a.start - b.start || b.end - a.end)
  1705. const filtered = []
  1706. let lastEnd = -1
  1707. for (const m of matches) {
  1708. if (m.start >= lastEnd) {
  1709. filtered.push(m)
  1710. lastEnd = m.end
  1711. }
  1712. }
  1713. if (filtered.length === 0) {
  1714. // 无匹配,直接渲染
  1715. let html = ''
  1716. for (const run of runs) {
  1717. const text = escapeHtml(run.text)
  1718. const rs = buildRunStyleStr(run)
  1719. html += rs ? `<span style="${rs}">${text}</span>` : text
  1720. }
  1721. return { html, count: 0 }
  1722. }
  1723. // 4. 将纯文本按匹配区间切分为:普通段 + 高亮段
  1724. const segments = [] // { start, end, em: null|object }
  1725. let cursor = 0
  1726. for (const m of filtered) {
  1727. if (m.start > cursor) {
  1728. segments.push({ start: cursor, end: m.start, em: null })
  1729. }
  1730. segments.push({ start: m.start, end: m.end, em: m.em })
  1731. cursor = m.end
  1732. }
  1733. if (cursor < plainText.length) {
  1734. segments.push({ start: cursor, end: plainText.length, em: null })
  1735. }
  1736. // 5. 对每个 segment,按 run 边界拆分并生成 HTML
  1737. let html = ''
  1738. for (const seg of segments) {
  1739. const segChars = charToRun.slice(seg.start, seg.end)
  1740. // 按 runIdx 分组
  1741. const groups = []
  1742. let curGroup = null
  1743. for (const ch of segChars) {
  1744. if (!curGroup || curGroup.runIdx !== ch.runIdx) {
  1745. curGroup = { runIdx: ch.runIdx, startOffset: ch.offsetInRun, endOffset: ch.offsetInRun + 1 }
  1746. groups.push(curGroup)
  1747. } else {
  1748. curGroup.endOffset = ch.offsetInRun + 1
  1749. }
  1750. }
  1751. if (seg.em) {
  1752. // 高亮段:静态要素用虚线淡色边框,动态要素用实线彩色边框
  1753. const em = seg.em
  1754. const isStatic = em.isStatic
  1755. const borderStyle = isStatic
  1756. ? 'border:1px dashed #ccc;border-radius:3px;padding:0 2px;cursor:pointer;opacity:0.7;background:#f5f5f5;'
  1757. : `border:1.5px solid ${darkenColor(em.color)};border-radius:3px;padding:0 2px;cursor:pointer;background:${em.color};color:${darkenColor(em.color)};`
  1758. const hlClass = isStatic ? 'elem-highlight elem-highlight-static' : 'elem-highlight'
  1759. html += `<span class="${hlClass}" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="${borderStyle}" contenteditable="false" title="${escapeAttr(em.elementName)}">`
  1760. for (const g of groups) {
  1761. const run = runs[g.runIdx]
  1762. const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
  1763. const rs = buildRunStyleStr(run)
  1764. html += rs ? `<span style="${rs}">${slice}</span>` : slice
  1765. }
  1766. html += '</span>'
  1767. } else {
  1768. // 普通段:保留 run 样式
  1769. for (const g of groups) {
  1770. const run = runs[g.runIdx]
  1771. const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
  1772. const rs = buildRunStyleStr(run)
  1773. html += rs ? `<span style="${rs}">${slice}</span>` : slice
  1774. }
  1775. }
  1776. }
  1777. return { html, count: filtered.length }
  1778. }
  1779. function darkenColor(hex) {
  1780. // 简单加深颜色用于下边框
  1781. const map = {
  1782. '#fff3cd': '#e0a800', '#cce5ff': '#3d8bfd', '#d4edda': '#28a745',
  1783. '#f8d7da': '#dc3545', '#e2d5f1': '#6f42c1', '#d1ecf1': '#17a2b8',
  1784. '#ffeeba': '#d39e00', '#c3e6cb': '#1e7e34', '#f5c6cb': '#c82333',
  1785. '#d6d8db': '#6c757d'
  1786. }
  1787. return map[hex] || '#999'
  1788. }
  1789. // 文档编辑事件
  1790. function onDocInput() {
  1791. saved.value = false
  1792. }
  1793. // 点击文档中的高亮要素(mousedown 比 click 更可靠,在 contenteditable 容器内 e.target 更准确)
  1794. function onDocClick(e) {
  1795. console.log('[onDocClick] target:', e.target.tagName, e.target.className, 'closest:', e.target.closest('.elem-highlight')?.dataset?.elemKey)
  1796. const target = e.target.closest('.elem-highlight') || e.target.closest('.elem-highlight-wrap') || e.target.closest('.elem-highlight-table')
  1797. if (!target) {
  1798. highlightPopover.visible = false
  1799. return
  1800. }
  1801. const elemKey = target.dataset.elemKey
  1802. const valueId = target.dataset.valueId
  1803. const elem = elements.value.find(el => el.elementKey === elemKey)
  1804. const val = values.value.find(v => String(v.valueId) === String(valueId)) ||
  1805. values.value.find(v => stripValueKeyPrefix(v.elementKey) === elemKey)
  1806. if (!elem || elem.elementType === 'static') return
  1807. e.preventDefault()
  1808. const rect = target.getBoundingClientRect()
  1809. const scrollEl = editorRef.value
  1810. const scrollRect = scrollEl?.getBoundingClientRect() || { top: 0, left: 0 }
  1811. highlightPopover.elementKey = elemKey
  1812. highlightPopover.fullElementKey = val?.elementKey || ''
  1813. highlightPopover.elementName = elem.elementName
  1814. highlightPopover.currentValue = val?.valueText || ''
  1815. highlightPopover.originalValue = ''
  1816. highlightPopover.valueId = val?.valueId || null
  1817. highlightPopover.x = rect.left - scrollRect.left + scrollEl.scrollLeft
  1818. highlightPopover.y = rect.bottom - scrollRect.top + scrollEl.scrollTop + 4
  1819. highlightPopover.visible = true
  1820. }
  1821. async function savePopoverValue() {
  1822. if (!highlightPopover.elementKey || !currentProjectId.value) {
  1823. ElMessage.warning('无法保存:未找到对应的值记录')
  1824. return
  1825. }
  1826. try {
  1827. // 查找本地 value 记录,获取完整的 prefixed elementKey
  1828. const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === highlightPopover.elementKey)
  1829. const apiKey = highlightPopover.fullElementKey || val?.elementKey || highlightPopover.elementKey
  1830. const oldValue = val?.valueText || ''
  1831. const newValue = highlightPopover.currentValue
  1832. // API: PUT /projects/{projectId}/values/{elementKey} with { valueText }
  1833. await valueApi.update(currentProjectId.value, apiKey, { valueText: newValue })
  1834. // 更新本地数据
  1835. if (val) {
  1836. val.valueText = newValue
  1837. val.isFilled = !!newValue
  1838. }
  1839. // 替换文档 blocks 中的旧值文本为新值
  1840. if (oldValue && newValue && oldValue !== newValue && docContent.value?.blocks) {
  1841. replaceTextInBlocks(docContent.value.blocks, oldValue, newValue)
  1842. }
  1843. highlightPopover.visible = false
  1844. renderDocHtml()
  1845. ElMessage.success('要素值已更新')
  1846. } catch (e) {
  1847. console.error('[savePopoverValue] error:', e)
  1848. ElMessage.error('保存失败: ' + (e.message || e))
  1849. }
  1850. }
  1851. // ==================== 附件引用系统 ====================
  1852. // 在解析结果中选中文本时触发
  1853. function onParseResultMouseUp(e) {
  1854. const sel = window.getSelection()
  1855. const text = sel?.toString()?.trim()
  1856. if (!text || text.length < 2) {
  1857. citationToolbar.visible = false
  1858. return
  1859. }
  1860. citationToolbar.selectedText = text
  1861. // 定位浮动工具栏到选区末端
  1862. const range = sel.getRangeAt(0)
  1863. const rect = range.getBoundingClientRect()
  1864. const dialogEl = e.currentTarget.closest('.el-dialog')
  1865. const dialogRect = dialogEl?.getBoundingClientRect() || { top: 0, left: 0 }
  1866. citationToolbar.x = rect.left - dialogRect.left + rect.width / 2 - 120
  1867. citationToolbar.y = rect.bottom - dialogRect.top + 8
  1868. // 引用模式下已锁定要素,直接进入选操作步骤
  1869. if (referenceMode.value) {
  1870. citationToolbar.step = 'select_action'
  1871. } else {
  1872. citationToolbar.step = 'select_action'
  1873. }
  1874. citationToolbar.visible = true
  1875. }
  1876. // 选择引用方式(直接引用/AI总结/表格提取)
  1877. function setCitationAction(actionType) {
  1878. citationToolbar.actionType = actionType
  1879. if (referenceMode.value) {
  1880. // 已锁定要素,直接确认创建规则
  1881. const elem = elements.value.find(el => el.elementKey === referenceModeElementKey.value)
  1882. if (elem) {
  1883. confirmCitation(elem)
  1884. } else {
  1885. ElMessage.warning('目标要素不存在')
  1886. }
  1887. } else {
  1888. // 未锁定要素,进入选择要素步骤
  1889. citationToolbar.step = 'select_element'
  1890. }
  1891. }
  1892. // 确认引用:创建规则
  1893. async function confirmCitation(elem) {
  1894. const selectedText = citationToolbar.selectedText
  1895. const actionType = citationToolbar.actionType
  1896. if (!selectedText || !actionType || !elem) return
  1897. const projectId = currentProjectId.value
  1898. if (!projectId) { ElMessage.warning('未选择项目'); return }
  1899. try {
  1900. const actionConfig = JSON.stringify({
  1901. sourceText: selectedText,
  1902. attachmentName: referenceModeAttName.value || parseResultAttName.value || '',
  1903. })
  1904. // 查找附件节点ID(如果有的话)
  1905. const attId = referenceModeAttId.value || null
  1906. const inputs = attId ? [{ sourceNodeId: attId, inputKey: 'attachment', inputType: 'ATTACHMENT', inputName: referenceModeAttName.value || parseResultAttName.value }] : []
  1907. const ruleData = {
  1908. elementKey: elem.elementKey,
  1909. ruleName: `${actionType === 'quote' ? '引用' : actionType === 'summary' ? 'AI总结' : '表格提取'} - ${elem.elementName}`,
  1910. ruleType: 'attachment_reference',
  1911. actionType: actionType,
  1912. actionConfig: actionConfig,
  1913. description: `从附件「${referenceModeAttName.value || parseResultAttName.value}」${actionType === 'quote' ? '直接引用' : actionType === 'summary' ? 'AI总结' : '表格提取'}`,
  1914. inputs: inputs
  1915. }
  1916. await ruleApi.create(projectId, ruleData)
  1917. // 如果是直接引用,同时更新要素值
  1918. if (actionType === 'quote') {
  1919. const fullKey = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)?.elementKey || elem.elementKey
  1920. await valueApi.update(projectId, fullKey, { valueText: selectedText, fillSource: 'rule' })
  1921. const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
  1922. if (val) {
  1923. val.valueText = selectedText
  1924. val.isFilled = true
  1925. val.fillSource = 'rule'
  1926. }
  1927. renderDocHtml()
  1928. }
  1929. // 刷新规则列表
  1930. rules.value = await ruleApi.list(projectId)
  1931. citationToolbar.visible = false
  1932. citationToolbar.selectedText = ''
  1933. citationToolbar.actionType = ''
  1934. window.getSelection()?.removeAllRanges()
  1935. if (referenceMode.value) exitReferenceMode()
  1936. ElMessage.success(
  1937. actionType === 'quote'
  1938. ? `已引用到「${elem.elementName}」并更新值`
  1939. : `已创建${actionType === 'summary' ? 'AI总结' : '表格提取'}规则 → ${elem.elementName}`
  1940. )
  1941. } catch (e) {
  1942. ElMessage.error('创建引用规则失败: ' + e.message)
  1943. }
  1944. }
  1945. // 从要素弹出框进入引用模式 → 打开附件选择
  1946. function enterReferenceModeFromPopover() {
  1947. const elemKey = highlightPopover.elementKey
  1948. const elemName = highlightPopover.elementName
  1949. if (!elemKey) return
  1950. highlightPopover.visible = false
  1951. // 找已解析的附件
  1952. const parsedAtts = attachments.value.filter(att => {
  1953. const state = parseStates[att.id]
  1954. return state?.status === 'completed' && state.markdown
  1955. })
  1956. if (parsedAtts.length === 0) {
  1957. ElMessage.warning('没有已解析的附件,请先解析附件')
  1958. return
  1959. }
  1960. // 如果只有一个已解析附件,直接打开
  1961. if (parsedAtts.length === 1) {
  1962. openAttachmentInReferenceMode(parsedAtts[0], elemKey, elemName)
  1963. return
  1964. }
  1965. // 多个附件,弹出选择弹窗
  1966. refAttSelectList.value = parsedAtts
  1967. refAttSelectElemKey.value = elemKey
  1968. refAttSelectElemName.value = elemName
  1969. showRefAttSelectDialog.value = true
  1970. }
  1971. function onRefAttSelected(att) {
  1972. showRefAttSelectDialog.value = false
  1973. openAttachmentInReferenceMode(att, refAttSelectElemKey.value, refAttSelectElemName.value)
  1974. }
  1975. function openAttachmentInReferenceMode(att, elemKey, elemName) {
  1976. referenceMode.value = true
  1977. referenceModeElementKey.value = elemKey
  1978. referenceModeElementName.value = elemName
  1979. referenceModeAttId.value = typeof att.id === 'number' ? att.id : null
  1980. referenceModeAttName.value = att.displayName
  1981. const state = parseStates[att.id]
  1982. parseResultAttName.value = att.displayName
  1983. parseResultContent.value = state.markdown
  1984. parseResultIsHtml.value = !!state.isHtml
  1985. parseResultPreviewAvailable.value = false
  1986. parseResultOriginAtt.value = att
  1987. parseResultOriginZf.value = null
  1988. previewContentType.value = ''
  1989. parseResultViewMode.value = 'rendered'
  1990. showParseResultDialog.value = true
  1991. }
  1992. function exitReferenceMode() {
  1993. referenceMode.value = false
  1994. referenceModeElementKey.value = ''
  1995. referenceModeElementName.value = ''
  1996. referenceModeAttId.value = null
  1997. referenceModeAttName.value = ''
  1998. citationToolbar.visible = false
  1999. }
  2000. // 要素/值
  2001. function getElementValues(elementKey) { return values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elementKey) }
  2002. function hasFilledValue(elementKey) { return values.value.some(v => stripValueKeyPrefix(v.elementKey) === elementKey && v.isFilled) }
  2003. function onValueChange(val) { saved.value = false; val.isModified = true }
  2004. async function handleAddElement() {
  2005. if (!newElementForm.elementName || !newElementForm.elementKey) return
  2006. try {
  2007. const elem = await elementApi.add(currentProjectId.value, { ...newElementForm })
  2008. elements.value.push(elem); showAddElementDialog.value = false
  2009. Object.assign(newElementForm, { elementName: '', elementKey: '', dataType: 'text', description: '' })
  2010. ElMessage.success('要素添加成功')
  2011. } catch (e) { ElMessage.error('添加失败: ' + e.message) }
  2012. }
  2013. // 附件
  2014. async function handleAttachmentUpload(file) {
  2015. if (!currentProjectId.value) return
  2016. try {
  2017. const att = await attachmentApi.upload(currentProjectId.value, file.raw, file.name)
  2018. attachments.value.push(att)
  2019. // 缓存原始文件用于后续解析
  2020. if (att?.id) attachmentFileCache.set(att.id, file.raw)
  2021. ElMessage.success('附件上传成功')
  2022. } catch (e) { ElMessage.error('上传失败: ' + e.message) }
  2023. }
  2024. function selectAttachment(att) { selectedAttachment.value = att }
  2025. async function removeAttachment(att) {
  2026. try {
  2027. await ElMessageBox.confirm(`确定删除附件「${att.displayName}」?`, '删除确认', { type: 'warning' })
  2028. await attachmentApi.delete(att.id)
  2029. attachments.value = attachments.value.filter(a => a.id !== att.id); ElMessage.success('附件已删除')
  2030. } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
  2031. }
  2032. function getFileExt(att) {
  2033. const t = att.fileType || ''
  2034. if (t) return t.toLowerCase()
  2035. const name = att.displayName || att.fileName || ''
  2036. const ext = name.split('.').pop()?.toLowerCase()
  2037. return ext || ''
  2038. }
  2039. function getFileTypeClass(att) {
  2040. const ext = getFileExt(att)
  2041. if (ext === 'pdf') return 'type-pdf'
  2042. if (ext === 'doc' || ext === 'docx') return 'type-word'
  2043. if (ext === 'xls' || ext === 'xlsx') return 'type-excel'
  2044. if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') return 'type-image'
  2045. if (ext === 'zip' || ext === 'rar' || ext === '7z') return 'type-archive'
  2046. return 'type-other'
  2047. }
  2048. function getFileTypeLabel(att) {
  2049. const ext = getFileExt(att)
  2050. if (ext === 'pdf') return 'PDF'
  2051. if (ext === 'doc' || ext === 'docx') return 'W'
  2052. if (ext === 'xls' || ext === 'xlsx') return 'X'
  2053. if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') return '🖼'
  2054. if (ext === 'zip' || ext === 'rar' || ext === '7z') return 'ZIP'
  2055. return '📄'
  2056. }
  2057. function getFileTypeTag(att) {
  2058. const ext = getFileExt(att)
  2059. if (ext === 'pdf') return 'PDF'
  2060. if (ext === 'doc' || ext === 'docx') return 'docx'
  2061. if (ext === 'xls' || ext === 'xlsx') return 'xlsx'
  2062. if (ext === 'png') return 'png'
  2063. if (ext === 'jpg' || ext === 'jpeg') return 'jpg'
  2064. if (ext === 'zip') return 'zip'
  2065. return ext || '文件'
  2066. }
  2067. function getZipEntryTypeClass(zf) {
  2068. const ext = zf.ext
  2069. if (ext === 'pdf') return 'type-pdf'
  2070. if (ext === 'doc' || ext === 'docx') return 'type-word'
  2071. if (ext === 'xls' || ext === 'xlsx') return 'type-excel'
  2072. if (ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif') return 'type-image'
  2073. return 'type-other'
  2074. }
  2075. function getZipEntryTypeLabel(zf) {
  2076. const ext = zf.ext
  2077. if (ext === 'pdf') return 'PDF'
  2078. if (ext === 'doc' || ext === 'docx') return 'W'
  2079. if (ext === 'xls' || ext === 'xlsx') return 'X'
  2080. if (['png', 'jpg', 'jpeg', 'gif'].includes(ext)) return '🖼'
  2081. return '📄'
  2082. }
  2083. async function parseAllZipEntries() {
  2084. const pending = zipFileList.value.filter(f => f.parseable && !f.parsed && !f.parsing)
  2085. if (pending.length === 0) return
  2086. ElMessage.info(`开始解析 ${pending.length} 个文件...`)
  2087. for (const zf of pending) {
  2088. await parseZipEntry(zf)
  2089. }
  2090. }
  2091. function formatFileSize(bytes) {
  2092. if (!bytes) return ''
  2093. if (bytes < 1024) return bytes + 'B'
  2094. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + 'KB'
  2095. return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
  2096. }
  2097. async function handleAttachmentAction(cmd, att) {
  2098. switch (cmd) {
  2099. case 'preview':
  2100. selectAttachment(att)
  2101. ElMessage.info('预览功能开发中')
  2102. break
  2103. case 'parse':
  2104. await handleParseAttachment(att)
  2105. break
  2106. case 'view_result':
  2107. viewParseResult(att)
  2108. break
  2109. case 'apply':
  2110. ElMessage.info('应用要素功能开发中')
  2111. break
  2112. case 'download':
  2113. ElMessage.info('下载功能开发中')
  2114. break
  2115. case 'delete':
  2116. await removeAttachment(att)
  2117. break
  2118. }
  2119. }
  2120. function viewParseResult(att) {
  2121. const state = parseStates[att.id]
  2122. if (!state || state.status !== 'completed' || !state.markdown) {
  2123. ElMessage.warning('该附件尚未解析或解析结果为空')
  2124. return
  2125. }
  2126. parseResultAttName.value = att.displayName
  2127. parseResultContent.value = state.markdown
  2128. parseResultIsHtml.value = !!state.isHtml
  2129. // 独立附件也支持原件预览(从缓存或 fileUrl 获取)
  2130. const ext = getFileExt(att)
  2131. parseResultPreviewAvailable.value = ['pdf', 'png', 'jpg', 'jpeg', 'gif', 'docx', 'doc'].includes(ext)
  2132. parseResultOriginAtt.value = att
  2133. parseResultOriginZf.value = null
  2134. previewContentType.value = ''
  2135. parseResultViewMode.value = 'rendered'
  2136. showParseResultDialog.value = true
  2137. }
  2138. function copyParseResult() {
  2139. if (!parseResultContent.value) return
  2140. navigator.clipboard.writeText(parseResultContent.value).then(() => {
  2141. ElMessage.success('已复制到剪贴板')
  2142. }).catch(() => {
  2143. ElMessage.error('复制失败')
  2144. })
  2145. }
  2146. function getParseState(attId) {
  2147. if (!parseStates[attId]) {
  2148. parseStates[attId] = { status: 'idle', progress: '', markdown: '' }
  2149. }
  2150. return parseStates[attId]
  2151. }
  2152. function saveParseState(attId) {
  2153. try {
  2154. const saved = JSON.parse(localStorage.getItem('parseStates') || '{}')
  2155. const state = parseStates[attId]
  2156. if (state?.status === 'completed' && state.markdown) {
  2157. saved[attId] = { status: 'completed', markdown: state.markdown, isHtml: !!state.isHtml }
  2158. }
  2159. try {
  2160. localStorage.setItem('parseStates', JSON.stringify(saved))
  2161. } catch (quotaErr) {
  2162. // localStorage 空间不足(base64 图片太大),保存不含图片的版本
  2163. console.warn('localStorage 空间不足,保存不含图片的精简版本')
  2164. if (state?.markdown) {
  2165. const lite = state.markdown
  2166. .replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '')
  2167. .replace(/src="data:[^"]+"/g, 'src=""')
  2168. saved[attId] = { status: 'completed', markdown: lite, isHtml: !!state.isHtml, imagesStripped: true }
  2169. }
  2170. localStorage.setItem('parseStates', JSON.stringify(saved))
  2171. }
  2172. } catch (e) { console.warn('保存解析状态失败:', e) }
  2173. }
  2174. function restoreParseStates() {
  2175. try {
  2176. const saved = JSON.parse(localStorage.getItem('parseStates') || '{}')
  2177. for (const [attId, data] of Object.entries(saved)) {
  2178. if (data.status === 'completed' && data.markdown) {
  2179. parseStates[attId] = { status: 'completed', progress: '解析完成', markdown: data.markdown, isHtml: !!data.isHtml }
  2180. }
  2181. }
  2182. } catch (e) { console.warn('恢复解析状态失败:', e) }
  2183. }
  2184. function canParse(att) {
  2185. const ext = getFileExt(att)
  2186. return ['pdf', 'png', 'jpg', 'jpeg', 'docx', 'doc', 'zip'].includes(ext)
  2187. }
  2188. async function handleZipAttachment(att) {
  2189. // 获取 ZIP 文件
  2190. let file = attachmentFileCache.get(att.id)
  2191. if (!file && att.fileUrl) {
  2192. try {
  2193. const resp = await fetch(att.fileUrl)
  2194. if (resp.ok) {
  2195. const blob = await resp.blob()
  2196. file = new File([blob], att.displayName || 'file.zip', { type: blob.type })
  2197. attachmentFileCache.set(att.id, file)
  2198. }
  2199. } catch (e) { console.warn('获取 ZIP 文件失败:', e) }
  2200. }
  2201. if (!file) {
  2202. const picked = await new Promise((resolve) => {
  2203. const input = document.createElement('input')
  2204. input.type = 'file'
  2205. input.accept = '.zip'
  2206. input.onchange = (e) => resolve(e.target.files[0] || null)
  2207. input.click()
  2208. })
  2209. if (!picked) return
  2210. file = picked
  2211. attachmentFileCache.set(att.id, file)
  2212. }
  2213. try {
  2214. const zip = await JSZip.loadAsync(file)
  2215. const parseableExts = ['pdf', 'png', 'jpg', 'jpeg', 'docx', 'doc']
  2216. const files = []
  2217. zip.forEach((relativePath, zipEntry) => {
  2218. if (zipEntry.dir) return
  2219. // 跳过 macOS 资源文件
  2220. if (relativePath.startsWith('__MACOSX/') || relativePath.includes('/.')) return
  2221. const name = relativePath
  2222. const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''
  2223. files.push({
  2224. name,
  2225. path: relativePath,
  2226. size: zipEntry._data?.uncompressedSize || 0,
  2227. ext,
  2228. parseable: parseableExts.includes(ext),
  2229. parsing: false,
  2230. parsed: false,
  2231. parseResult: '',
  2232. isHtml: false
  2233. })
  2234. })
  2235. // 按文件名排序,可解析的排前面
  2236. files.sort((a, b) => {
  2237. if (a.parseable && !b.parseable) return -1
  2238. if (!a.parseable && b.parseable) return 1
  2239. return a.name.localeCompare(b.name)
  2240. })
  2241. // 恢复之前的解析结果
  2242. restoreZipParseStates(att.id, files)
  2243. zipFileList.value = files
  2244. zipInstance.value = zip
  2245. zipParentAtt.value = att
  2246. zipContentsAttName.value = att.displayName
  2247. showZipContentsDialog.value = true
  2248. } catch (e) {
  2249. ElMessage.error('ZIP 解压失败: ' + (e.message || e))
  2250. }
  2251. }
  2252. async function parseZipEntry(zf) {
  2253. if (!zipInstance.value) return
  2254. zf.parsing = true
  2255. try {
  2256. const zipEntry = zipInstance.value.file(zf.path)
  2257. if (!zipEntry) throw new Error('文件不存在: ' + zf.path)
  2258. const ext = zf.ext
  2259. if (ext === 'docx' || ext === 'doc') {
  2260. // DOCX: 提取为 blob,发送到后端解析
  2261. const blob = await zipEntry.async('blob')
  2262. const file = new File([blob], zf.name.split('/').pop(), {
  2263. type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
  2264. })
  2265. const result = await attachmentApi.parseDocx(file)
  2266. zf.parseResult = result.html || ''
  2267. zf.isHtml = true
  2268. } else if (ext === 'pdf' || ['png', 'jpg', 'jpeg'].includes(ext)) {
  2269. // PDF/图片: 提取为 blob,发送到 GPU 解析服务
  2270. const blob = await zipEntry.async('blob')
  2271. const mimeMap = { pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg' }
  2272. const file = new File([blob], zf.name.split('/').pop(), { type: mimeMap[ext] || 'application/octet-stream' })
  2273. const submitResult = await parseApi.submit(file, { return_images: true })
  2274. const taskId = submitResult.task_id
  2275. if (!taskId) throw new Error('未返回任务ID')
  2276. // 轮询
  2277. let pollCount = 0
  2278. const maxPolls = 300
  2279. while (pollCount++ < maxPolls) {
  2280. await new Promise(r => setTimeout(r, 2000))
  2281. const statusResult = await parseApi.getStatus(taskId)
  2282. if (statusResult.status === 'completed') {
  2283. const result = await parseApi.getResult(taskId)
  2284. let markdown = result.markdown || ''
  2285. // 尝试从 zip 提取图片(如果 markdown 引用了 images/)
  2286. const imageRefs = markdown.match(/!\[[^\]]*\]\(images\/[^)]+\)/g)
  2287. if (imageRefs && imageRefs.length > 0) {
  2288. try {
  2289. const zipBlob = await parseApi.downloadZip(taskId)
  2290. const imgZip = await JSZip.loadAsync(zipBlob)
  2291. for (const ref of imageRefs) {
  2292. const match = ref.match(/!\[([^\]]*)\]\((images\/[^)]+)\)/)
  2293. if (!match) continue
  2294. const [, , imgPath] = match
  2295. let imgFile = imgZip.file(imgPath)
  2296. if (!imgFile) {
  2297. const fn = imgPath.split('/').pop()
  2298. const cands = imgZip.file(new RegExp(fn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'))
  2299. if (cands.length > 0) imgFile = cands[0]
  2300. }
  2301. if (imgFile) {
  2302. const imgData = await imgFile.async('base64')
  2303. const imgExt = imgPath.split('.').pop().toLowerCase()
  2304. const mime = imgExt === 'png' ? 'image/png' : imgExt === 'gif' ? 'image/gif' : 'image/jpeg'
  2305. markdown = markdown.replace(`(${imgPath})`, `(data:${mime};base64,${imgData})`)
  2306. }
  2307. }
  2308. } catch (zipErr) { console.warn('提取解析图片失败:', zipErr) }
  2309. }
  2310. zf.parseResult = markdown
  2311. zf.isHtml = false
  2312. break
  2313. } else if (statusResult.status === 'failed') {
  2314. throw new Error('解析失败')
  2315. }
  2316. }
  2317. if (!zf.parseResult) throw new Error('解析超时')
  2318. }
  2319. zf.parsed = true
  2320. zf.parsing = false
  2321. // 持久化 ZIP 解析结果
  2322. if (zipParentAtt.value) saveZipParseStates(zipParentAtt.value.id)
  2323. ElMessage.success(`「${zf.name.split('/').pop()}」解析完成`)
  2324. } catch (e) {
  2325. zf.parsing = false
  2326. ElMessage.error(`解析失败: ${e.message || e}`)
  2327. }
  2328. }
  2329. function viewZipEntryResult(zf) {
  2330. parseResultAttName.value = zf.name.split('/').pop()
  2331. parseResultContent.value = zf.parseResult
  2332. parseResultIsHtml.value = !!zf.isHtml
  2333. parseResultPreviewAvailable.value = !!zipInstance.value
  2334. parseResultOriginZf.value = zf
  2335. previewContentType.value = '' // 重置,切换到原件时才加载
  2336. parseResultViewMode.value = 'rendered'
  2337. showParseResultDialog.value = true
  2338. }
  2339. function saveZipParseStates(attId) {
  2340. try {
  2341. const allZip = JSON.parse(localStorage.getItem('zipParseStates') || '{}')
  2342. const entries = {}
  2343. for (const zf of zipFileList.value) {
  2344. if (zf.parsed && zf.parseResult) {
  2345. entries[zf.path] = { parseResult: zf.parseResult, isHtml: !!zf.isHtml }
  2346. }
  2347. }
  2348. allZip[attId] = entries
  2349. try {
  2350. localStorage.setItem('zipParseStates', JSON.stringify(allZip))
  2351. } catch (quotaErr) {
  2352. console.warn('localStorage 空间不足,保存 ZIP 解析精简版')
  2353. // 去掉 base64 图片数据再存
  2354. for (const key of Object.keys(entries)) {
  2355. entries[key].parseResult = entries[key].parseResult
  2356. .replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '')
  2357. .replace(/src="data:[^"]+"/g, 'src=""')
  2358. entries[key].imagesStripped = true
  2359. }
  2360. allZip[attId] = entries
  2361. localStorage.setItem('zipParseStates', JSON.stringify(allZip))
  2362. }
  2363. } catch (e) { console.warn('保存 ZIP 解析状态失败:', e) }
  2364. }
  2365. function restoreZipParseStates(attId, files) {
  2366. try {
  2367. const allZip = JSON.parse(localStorage.getItem('zipParseStates') || '{}')
  2368. const entries = allZip[attId]
  2369. if (!entries) return
  2370. for (const zf of files) {
  2371. const saved = entries[zf.path]
  2372. if (saved && saved.parseResult) {
  2373. zf.parsed = true
  2374. zf.parseResult = saved.parseResult
  2375. zf.isHtml = !!saved.isHtml
  2376. }
  2377. }
  2378. } catch (e) { console.warn('恢复 ZIP 解析状态失败:', e) }
  2379. }
  2380. async function previewZipEntry(zf) {
  2381. // 直接打开解析结果弹窗,默认切到原件预览模式
  2382. parseResultAttName.value = zf.name.split('/').pop()
  2383. parseResultContent.value = zf.parseResult || ''
  2384. parseResultIsHtml.value = !!zf.isHtml
  2385. parseResultPreviewAvailable.value = true
  2386. parseResultOriginZf.value = zf
  2387. parseResultViewMode.value = 'preview'
  2388. showParseResultDialog.value = true
  2389. await loadPreviewFromZip(zf)
  2390. }
  2391. async function switchToPreviewMode() {
  2392. parseResultViewMode.value = 'preview'
  2393. if (previewContentType.value) return // 已加载
  2394. const zf = parseResultOriginZf.value
  2395. if (zf) {
  2396. await loadPreviewFromZip(zf)
  2397. } else if (parseResultOriginAtt.value) {
  2398. await loadPreviewFromAtt(parseResultOriginAtt.value)
  2399. }
  2400. }
  2401. async function loadPreviewFromAtt(att) {
  2402. // 清理旧的 blob URL
  2403. if (previewContentUrl.value) {
  2404. URL.revokeObjectURL(previewContentUrl.value)
  2405. previewContentUrl.value = ''
  2406. }
  2407. const ext = getFileExt(att)
  2408. try {
  2409. // 获取原始文件:优先缓存,其次 fileUrl
  2410. let file = attachmentFileCache.get(att.id)
  2411. if (!file && att.fileUrl) {
  2412. const resp = await fetch(att.fileUrl)
  2413. if (resp.ok) {
  2414. const blob = await resp.blob()
  2415. file = new File([blob], att.displayName || 'file', { type: blob.type })
  2416. }
  2417. }
  2418. if (!file) {
  2419. ElMessage.warning('原始文件不可用,请重新上传后再预览')
  2420. previewContentType.value = 'unsupported'
  2421. return
  2422. }
  2423. if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
  2424. previewContentUrl.value = URL.createObjectURL(file)
  2425. previewContentType.value = 'image'
  2426. } else if (ext === 'pdf') {
  2427. const typedBlob = new Blob([file], { type: 'application/pdf' })
  2428. previewContentUrl.value = URL.createObjectURL(typedBlob)
  2429. previewContentType.value = 'pdf'
  2430. } else if (ext === 'docx' || ext === 'doc') {
  2431. // DOCX 已解析的直接用解析结果
  2432. const state = parseStates[att.id]
  2433. if (state?.isHtml && state.markdown) {
  2434. previewContentHtml.value = state.markdown
  2435. } else {
  2436. const result = await attachmentApi.parseDocx(file)
  2437. previewContentHtml.value = result.html || '<p>解析结果为空</p>'
  2438. }
  2439. previewContentType.value = 'html'
  2440. } else {
  2441. previewContentType.value = 'unsupported'
  2442. }
  2443. } catch (e) {
  2444. ElMessage.error('预览失败: ' + (e.message || e))
  2445. }
  2446. }
  2447. async function loadPreviewFromZip(zf) {
  2448. if (!zipInstance.value) return
  2449. const ext = zf.ext
  2450. try {
  2451. const zipEntry = zipInstance.value.file(zf.path)
  2452. if (!zipEntry) { ElMessage.warning('文件不存在'); return }
  2453. // 清理旧的 blob URL
  2454. if (previewContentUrl.value) {
  2455. URL.revokeObjectURL(previewContentUrl.value)
  2456. previewContentUrl.value = ''
  2457. }
  2458. if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
  2459. const blob = await zipEntry.async('blob')
  2460. const mimeMap = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml' }
  2461. const typedBlob = new Blob([blob], { type: mimeMap[ext] || 'image/png' })
  2462. previewContentUrl.value = URL.createObjectURL(typedBlob)
  2463. previewContentType.value = 'image'
  2464. } else if (ext === 'pdf') {
  2465. const blob = await zipEntry.async('blob')
  2466. const typedBlob = new Blob([blob], { type: 'application/pdf' })
  2467. previewContentUrl.value = URL.createObjectURL(typedBlob)
  2468. previewContentType.value = 'pdf'
  2469. } else if (ext === 'docx' || ext === 'doc') {
  2470. if (zf.parsed && zf.parseResult && zf.isHtml) {
  2471. previewContentHtml.value = zf.parseResult
  2472. } else {
  2473. const blob = await zipEntry.async('blob')
  2474. const file = new File([blob], zf.name.split('/').pop(), {
  2475. type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
  2476. })
  2477. const result = await attachmentApi.parseDocx(file)
  2478. previewContentHtml.value = result.html || '<p>解析结果为空</p>'
  2479. }
  2480. previewContentType.value = 'html'
  2481. } else if (['txt', 'md', 'csv', 'json', 'xml', 'yml', 'yaml', 'log', 'ini', 'cfg', 'conf', 'sh', 'bat', 'py', 'java', 'js', 'ts', 'html', 'css'].includes(ext)) {
  2482. const text = await zipEntry.async('string')
  2483. previewContentText.value = text
  2484. previewContentType.value = 'text'
  2485. } else {
  2486. previewContentType.value = 'unsupported'
  2487. }
  2488. } catch (e) {
  2489. ElMessage.error('预览失败: ' + (e.message || e))
  2490. }
  2491. }
  2492. function cleanupPreviewContent() {
  2493. if (previewContentUrl.value) {
  2494. URL.revokeObjectURL(previewContentUrl.value)
  2495. previewContentUrl.value = ''
  2496. }
  2497. previewContentHtml.value = ''
  2498. previewContentText.value = ''
  2499. previewContentType.value = ''
  2500. parseResultPreviewAvailable.value = false
  2501. parseResultOriginAtt.value = null
  2502. parseResultOriginZf.value = null
  2503. }
  2504. async function handleParseAttachment(att) {
  2505. const state = getParseState(att.id)
  2506. if (state.status === 'uploading' || state.status === 'parsing') {
  2507. ElMessage.warning('该附件正在解析中,请稍候')
  2508. return
  2509. }
  2510. const ext = getFileExt(att)
  2511. if (!canParse(att)) {
  2512. ElMessage.warning('仅支持 PDF、DOCX、ZIP 和图片文件的解析')
  2513. return
  2514. }
  2515. // ZIP 文件走解压展示流程
  2516. if (ext === 'zip') {
  2517. await handleZipAttachment(att)
  2518. return
  2519. }
  2520. try {
  2521. // 1. 获取缓存的原始文件
  2522. state.status = 'uploading'
  2523. state.progress = '正在准备文件...'
  2524. let file = attachmentFileCache.get(att.id)
  2525. if (!file && att.fileUrl) {
  2526. // 从静态资源目录获取真实文件(mock 附件或有 fileUrl 的附件)
  2527. state.progress = '正在获取文件...'
  2528. try {
  2529. const resp = await fetch(att.fileUrl)
  2530. if (resp.ok) {
  2531. const blob = await resp.blob()
  2532. file = new File([blob], att.displayName || `file.${ext}`, { type: blob.type })
  2533. attachmentFileCache.set(att.id, file)
  2534. }
  2535. } catch (fetchErr) {
  2536. console.warn('获取附件文件失败:', fetchErr)
  2537. }
  2538. }
  2539. if (!file) {
  2540. // 缓存中没有且无 fileUrl,提示用户重新选择文件
  2541. const reselected = await new Promise((resolve) => {
  2542. const input = document.createElement('input')
  2543. input.type = 'file'
  2544. input.accept = '.pdf,.png,.jpg,.jpeg,.docx,.doc'
  2545. input.onchange = (e) => resolve(e.target.files[0] || null)
  2546. input.click()
  2547. })
  2548. if (!reselected) {
  2549. state.status = 'idle'
  2550. state.progress = ''
  2551. return
  2552. }
  2553. file = reselected
  2554. attachmentFileCache.set(att.id, file)
  2555. }
  2556. // 2. DOCX 走后端 Java 解析,PDF/图片走 GPU 解析服务
  2557. if (ext === 'docx' || ext === 'doc') {
  2558. state.status = 'parsing'
  2559. state.progress = '正在解析 DOCX 文件...'
  2560. ElMessage.info(`附件「${att.displayName}」开始解析`)
  2561. try {
  2562. const result = await attachmentApi.parseDocx(file)
  2563. const html = result.html || ''
  2564. state.markdown = html
  2565. state.status = 'completed'
  2566. state.progress = '解析完成'
  2567. state.isHtml = true
  2568. if (typeof att.id === 'number' || /^\d+$/.test(att.id)) {
  2569. try { await attachmentApi.saveParsedContent(att.id, html) } catch (e) { console.warn('后端持久化失败:', e) }
  2570. }
  2571. att.parsed = true
  2572. att.parsedText = html
  2573. saveParseState(att.id)
  2574. ElMessage.success(`附件「${att.displayName}」解析完成`)
  2575. } catch (docxErr) {
  2576. state.status = 'failed'
  2577. state.progress = '解析失败'
  2578. ElMessage.error(`DOCX 解析失败: ${docxErr.message || docxErr}`)
  2579. }
  2580. return
  2581. }
  2582. // PDF/图片:提交解析任务(启用图片返回)
  2583. state.progress = '正在提交解析任务...'
  2584. const submitResult = await parseApi.submit(file, { return_images: true })
  2585. const taskId = submitResult.task_id
  2586. if (!taskId) throw new Error('未返回任务ID')
  2587. // 3. 轮询任务状态
  2588. state.status = 'parsing'
  2589. state.progress = '解析中...'
  2590. state.taskId = taskId
  2591. ElMessage.info(`附件「${att.displayName}」开始解析`)
  2592. const maxPolls = 300 // 最多轮询 300 次 (约 10 分钟)
  2593. let pollCount = 0
  2594. const pollInterval = 2000 // 2秒一次
  2595. const poll = async () => {
  2596. pollCount++
  2597. if (pollCount > maxPolls) {
  2598. state.status = 'failed'
  2599. state.progress = '解析超时'
  2600. ElMessage.error('解析超时,请稍后重试')
  2601. return
  2602. }
  2603. try {
  2604. const statusResult = await parseApi.getStatus(taskId)
  2605. const taskStatus = statusResult.status
  2606. if (taskStatus === 'completed') {
  2607. // 获取解析结果
  2608. state.progress = '正在获取结果...'
  2609. const result = await parseApi.getResult(taskId)
  2610. let markdown = result.markdown || ''
  2611. // 下载 zip 包提取图片,转为 base64 内嵌到 markdown
  2612. const imageRefs = markdown.match(/!\[[^\]]*\]\(images\/[^)]+\)/g)
  2613. if (imageRefs && imageRefs.length > 0) {
  2614. state.progress = '正在获取图片...'
  2615. try {
  2616. const zipBlob = await parseApi.downloadZip(taskId)
  2617. const zip = await JSZip.loadAsync(zipBlob)
  2618. // 遍历所有图片引用,替换为 base64 data URL
  2619. for (const ref of imageRefs) {
  2620. const match = ref.match(/!\[([^\]]*)\]\((images\/[^)]+)\)/)
  2621. if (!match) continue
  2622. const [, alt, imgPath] = match
  2623. // zip 中图片路径可能带前缀目录,尝试多种匹配
  2624. let imgFile = zip.file(imgPath)
  2625. if (!imgFile) {
  2626. // 尝试在 zip 中搜索文件名
  2627. const fileName = imgPath.split('/').pop()
  2628. const candidates = zip.file(new RegExp(fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'))
  2629. if (candidates.length > 0) imgFile = candidates[0]
  2630. }
  2631. if (imgFile) {
  2632. const imgData = await imgFile.async('base64')
  2633. const ext = imgPath.split('.').pop().toLowerCase()
  2634. const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : 'image/jpeg'
  2635. markdown = markdown.replace(`(${imgPath})`, `(data:${mime};base64,${imgData})`)
  2636. }
  2637. }
  2638. } catch (zipErr) {
  2639. console.warn('下载 zip 提取图片失败:', zipErr)
  2640. }
  2641. }
  2642. state.markdown = markdown
  2643. state.status = 'completed'
  2644. state.progress = '解析完成'
  2645. // 持久化解析结果到后端(仅真实附件,mock 附件 ID 为字符串跳过)
  2646. if (typeof att.id === 'number' || /^\d+$/.test(att.id)) {
  2647. try {
  2648. await attachmentApi.saveParsedContent(att.id, markdown)
  2649. } catch (e) {
  2650. console.warn('后端持久化解析结果失败:', e)
  2651. }
  2652. }
  2653. // 更新附件对象标记已解析
  2654. att.parsed = true
  2655. att.parsedText = markdown
  2656. // 持久化到 localStorage(刷新后恢复)
  2657. saveParseState(att.id)
  2658. ElMessage.success(`附件「${att.displayName}」解析完成`)
  2659. } else if (taskStatus === 'failed') {
  2660. const errMsg = statusResult.error || '解析失败'
  2661. state.status = 'failed'
  2662. state.progress = errMsg
  2663. ElMessage.error(`解析失败: ${errMsg}`)
  2664. } else {
  2665. // pending / processing
  2666. state.progress = statusResult.progress || `解析中... (${pollCount})`
  2667. setTimeout(poll, pollInterval)
  2668. }
  2669. } catch (e) {
  2670. state.status = 'failed'
  2671. state.progress = '查询状态失败'
  2672. ElMessage.error('查询解析状态失败: ' + e.message)
  2673. }
  2674. }
  2675. setTimeout(poll, pollInterval)
  2676. } catch (e) {
  2677. state.status = 'failed'
  2678. state.progress = '解析失败'
  2679. ElMessage.error('解析失败: ' + e.message)
  2680. }
  2681. }
  2682. // 规则
  2683. async function handleCreateRule() {
  2684. if (!newRuleForm.ruleName) return
  2685. try {
  2686. const rule = await ruleApi.create(currentProjectId.value, { ...newRuleForm })
  2687. rules.value.push(rule); showNewRuleDialog.value = false
  2688. Object.assign(newRuleForm, { ruleName: '', ruleType: 'direct_entity', targetElementKey: '' })
  2689. ElMessage.success('规则创建成功')
  2690. } catch (e) { ElMessage.error('创建失败: ' + e.message) }
  2691. }
  2692. async function handleDeleteRule(rule) {
  2693. try {
  2694. await ElMessageBox.confirm(`确定删除规则「${rule.ruleName}」?`, '删除确认', { type: 'warning' })
  2695. await ruleApi.delete(rule.id); rules.value = rules.value.filter(r => r.id !== rule.id); ElMessage.success('规则已删除')
  2696. } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
  2697. }
  2698. async function handleExecuteRule(rule) {
  2699. try { await ruleApi.execute(rule.id); ElMessage.success(`规则「${rule.ruleName}」执行成功`); await loadProjectData(currentProjectId.value) }
  2700. catch (e) { ElMessage.error('执行失败: ' + e.message) }
  2701. }
  2702. async function handleBatchExecuteRules() {
  2703. if (!currentProjectId.value) return
  2704. executingRules.value = true
  2705. try { await ruleApi.batchExecute(currentProjectId.value); ElMessage.success('批量执行完成'); await loadProjectData(currentProjectId.value) }
  2706. catch (e) { ElMessage.error('执行失败: ' + e.message) }
  2707. finally { executingRules.value = false }
  2708. }
  2709. // 工具函数
  2710. function formatTime(dateStr) {
  2711. if (!dateStr) return ''
  2712. const date = new Date(dateStr); const now = new Date()
  2713. const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24))
  2714. if (diffDays === 0) return '今天'
  2715. if (diffDays === 1) return '昨天'
  2716. if (diffDays < 7) return `${diffDays}天前`
  2717. return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
  2718. }
  2719. function getStatusText(status) {
  2720. const map = { 'draft': '草稿', 'active': '进行中', 'archived': '已归档', 'completed': '已完成' }
  2721. return map[status] || '草稿'
  2722. }
  2723. onMounted(async () => {
  2724. await loadProjects()
  2725. const pid = route.query.project
  2726. if (pid) {
  2727. const p = projects.value.find(p => String(p.id) === String(pid))
  2728. if (p) await switchProject(p)
  2729. }
  2730. })
  2731. </script>
  2732. <style lang="scss" scoped>
  2733. // ==========================================
  2734. // Editor 页面样式 - 参考 V2 原型设计
  2735. // ==========================================
  2736. .editor-page {
  2737. height: 100vh;
  2738. display: flex;
  2739. flex-direction: column;
  2740. background: var(--bg);
  2741. }
  2742. .editor-body {
  2743. flex: 1;
  2744. display: flex;
  2745. overflow: hidden;
  2746. }
  2747. // ==========================================
  2748. // 拖拽分隔条
  2749. // ==========================================
  2750. .resize-handle {
  2751. width: 4px;
  2752. background: transparent;
  2753. cursor: col-resize;
  2754. flex-shrink: 0;
  2755. position: relative;
  2756. z-index: 10;
  2757. transition: background 0.2s;
  2758. &:hover, &:active {
  2759. background: var(--primary);
  2760. }
  2761. &::before {
  2762. content: '';
  2763. position: absolute;
  2764. top: 0;
  2765. bottom: 0;
  2766. left: -3px;
  2767. right: -3px;
  2768. }
  2769. }
  2770. // ==========================================
  2771. // 左侧面板 - 参考设计风格
  2772. // ==========================================
  2773. .left-panel {
  2774. background: var(--white);
  2775. border-right: 1px solid var(--border);
  2776. display: flex;
  2777. flex-direction: column;
  2778. flex-shrink: 0;
  2779. min-width: 260px;
  2780. max-width: 420px;
  2781. overflow: hidden;
  2782. position: relative;
  2783. // ---- 顶部 Logo ----
  2784. .sidebar-header {
  2785. display: flex;
  2786. align-items: center;
  2787. justify-content: space-between;
  2788. padding: 16px 18px 12px;
  2789. flex-shrink: 0;
  2790. .sidebar-logo {
  2791. display: flex;
  2792. align-items: center;
  2793. gap: 8px;
  2794. .logo-icon {
  2795. font-size: 22px;
  2796. color: var(--primary);
  2797. line-height: 1;
  2798. }
  2799. .logo-text {
  2800. font-size: 17px;
  2801. font-weight: 700;
  2802. color: var(--text-1);
  2803. letter-spacing: 0.5px;
  2804. }
  2805. }
  2806. .sidebar-header-actions {
  2807. display: flex;
  2808. gap: 4px;
  2809. color: var(--text-3);
  2810. }
  2811. }
  2812. // ---- 快捷导航 ----
  2813. .sidebar-nav {
  2814. display: flex;
  2815. flex-direction: column;
  2816. gap: 2px;
  2817. padding: 0 14px 10px;
  2818. flex-shrink: 0;
  2819. .nav-item {
  2820. display: flex;
  2821. align-items: center;
  2822. gap: 10px;
  2823. padding: 9px 12px;
  2824. border-radius: 8px;
  2825. cursor: pointer;
  2826. transition: background 0.15s;
  2827. font-size: 14px;
  2828. color: var(--text-1);
  2829. &:hover {
  2830. background: var(--bg);
  2831. }
  2832. .nav-icon {
  2833. font-size: 16px;
  2834. flex-shrink: 0;
  2835. }
  2836. .nav-label {
  2837. font-weight: 500;
  2838. }
  2839. }
  2840. }
  2841. // ---- 区块通用 ----
  2842. .sidebar-section {
  2843. display: flex;
  2844. flex-direction: column;
  2845. padding: 0 14px;
  2846. flex-shrink: 0;
  2847. .section-header {
  2848. display: flex;
  2849. align-items: center;
  2850. justify-content: space-between;
  2851. padding: 10px 4px 8px;
  2852. .section-title {
  2853. font-size: 13px;
  2854. font-weight: 600;
  2855. color: var(--text-2);
  2856. }
  2857. .section-action {
  2858. font-size: 12px;
  2859. color: var(--text-3);
  2860. cursor: pointer;
  2861. transition: color 0.15s;
  2862. &:hover {
  2863. color: var(--primary);
  2864. }
  2865. }
  2866. }
  2867. .sidebar-search {
  2868. margin-bottom: 8px;
  2869. :deep(.el-input__wrapper) {
  2870. border-radius: 8px;
  2871. background: var(--bg);
  2872. box-shadow: none;
  2873. border: 1px solid transparent;
  2874. &:hover, &.is-focus {
  2875. border-color: var(--primary);
  2876. background: var(--white);
  2877. }
  2878. }
  2879. }
  2880. }
  2881. // ---- 文档列表 ----
  2882. .doc-list {
  2883. display: flex;
  2884. flex-direction: column;
  2885. gap: 4px;
  2886. overflow-y: auto;
  2887. max-height: 340px;
  2888. padding-bottom: 4px;
  2889. }
  2890. .doc-item {
  2891. display: flex;
  2892. align-items: flex-start;
  2893. gap: 10px;
  2894. padding: 10px 12px;
  2895. border-radius: 10px;
  2896. cursor: pointer;
  2897. transition: all 0.15s;
  2898. position: relative;
  2899. border: 1.5px solid transparent;
  2900. &:hover {
  2901. background: #f0f7ff;
  2902. }
  2903. &.active {
  2904. background: #e6f0ff;
  2905. border-color: var(--primary);
  2906. .doc-item-title {
  2907. color: var(--primary);
  2908. }
  2909. }
  2910. .doc-icon-wrap {
  2911. width: 36px;
  2912. height: 40px;
  2913. border-radius: 6px;
  2914. background: #eef3ff;
  2915. display: flex;
  2916. align-items: center;
  2917. justify-content: center;
  2918. flex-shrink: 0;
  2919. .doc-icon-glyph {
  2920. font-size: 18px;
  2921. }
  2922. }
  2923. .doc-item-body {
  2924. flex: 1;
  2925. min-width: 0;
  2926. .doc-item-title {
  2927. font-size: 13px;
  2928. font-weight: 600;
  2929. color: var(--text-1);
  2930. line-height: 1.4;
  2931. display: -webkit-box;
  2932. -webkit-line-clamp: 2;
  2933. line-clamp: 2;
  2934. -webkit-box-orient: vertical;
  2935. overflow: hidden;
  2936. margin-bottom: 4px;
  2937. }
  2938. .doc-item-meta {
  2939. display: flex;
  2940. align-items: center;
  2941. gap: 6px;
  2942. font-size: 11px;
  2943. color: var(--text-3);
  2944. flex-wrap: wrap;
  2945. .doc-status-tag {
  2946. font-size: 10px;
  2947. height: 18px;
  2948. line-height: 16px;
  2949. padding: 0 6px;
  2950. border-radius: 4px;
  2951. }
  2952. .doc-item-date {
  2953. white-space: nowrap;
  2954. }
  2955. .doc-item-author {
  2956. white-space: nowrap;
  2957. }
  2958. }
  2959. }
  2960. .doc-more-btn {
  2961. opacity: 0;
  2962. flex-shrink: 0;
  2963. transition: opacity 0.15s;
  2964. color: var(--text-3);
  2965. margin-top: 2px;
  2966. }
  2967. &:hover .doc-more-btn {
  2968. opacity: 1;
  2969. }
  2970. }
  2971. .doc-empty {
  2972. padding: 20px;
  2973. text-align: center;
  2974. color: var(--text-3);
  2975. font-size: 13px;
  2976. }
  2977. .doc-loading {
  2978. display: flex;
  2979. align-items: center;
  2980. justify-content: center;
  2981. gap: 8px;
  2982. padding: 20px;
  2983. color: var(--text-3);
  2984. font-size: 13px;
  2985. }
  2986. // ---- 最近操作 ----
  2987. .sidebar-activity {
  2988. flex: 1;
  2989. min-height: 0;
  2990. overflow: hidden;
  2991. display: flex;
  2992. flex-direction: column;
  2993. border-top: 1px solid var(--border);
  2994. margin-top: 6px;
  2995. padding-top: 4px;
  2996. .activity-list {
  2997. flex: 1;
  2998. overflow-y: auto;
  2999. display: flex;
  3000. flex-direction: column;
  3001. gap: 2px;
  3002. }
  3003. .activity-item {
  3004. padding: 8px 12px;
  3005. border-radius: 8px;
  3006. transition: background 0.15s;
  3007. &:hover {
  3008. background: var(--bg);
  3009. }
  3010. .activity-text {
  3011. font-size: 13px;
  3012. color: var(--text-1);
  3013. line-height: 1.4;
  3014. white-space: nowrap;
  3015. overflow: hidden;
  3016. text-overflow: ellipsis;
  3017. }
  3018. .activity-meta {
  3019. display: flex;
  3020. align-items: center;
  3021. gap: 8px;
  3022. margin-top: 3px;
  3023. font-size: 11px;
  3024. color: var(--text-3);
  3025. .activity-source {
  3026. color: var(--primary);
  3027. }
  3028. }
  3029. }
  3030. .activity-empty {
  3031. padding: 20px;
  3032. text-align: center;
  3033. color: var(--text-3);
  3034. font-size: 12px;
  3035. }
  3036. }
  3037. // ---- 底部用户栏 ----
  3038. .sidebar-footer {
  3039. display: flex;
  3040. align-items: center;
  3041. gap: 10px;
  3042. padding: 12px 16px;
  3043. border-top: 1px solid var(--border);
  3044. flex-shrink: 0;
  3045. background: var(--white);
  3046. .user-avatar {
  3047. width: 34px;
  3048. height: 34px;
  3049. border-radius: 50%;
  3050. background: linear-gradient(135deg, var(--primary), #69c0ff);
  3051. color: #fff;
  3052. display: flex;
  3053. align-items: center;
  3054. justify-content: center;
  3055. font-size: 14px;
  3056. font-weight: 700;
  3057. flex-shrink: 0;
  3058. }
  3059. .user-info {
  3060. flex: 1;
  3061. min-width: 0;
  3062. display: flex;
  3063. flex-direction: column;
  3064. .user-name {
  3065. font-size: 13px;
  3066. font-weight: 600;
  3067. color: var(--text-1);
  3068. line-height: 1.3;
  3069. }
  3070. .user-role {
  3071. font-size: 11px;
  3072. color: var(--text-3);
  3073. display: flex;
  3074. align-items: center;
  3075. gap: 4px;
  3076. &::before {
  3077. content: '';
  3078. width: 6px;
  3079. height: 6px;
  3080. border-radius: 50%;
  3081. background: #52c41a;
  3082. flex-shrink: 0;
  3083. }
  3084. }
  3085. }
  3086. .footer-actions {
  3087. display: flex;
  3088. align-items: center;
  3089. gap: 4px;
  3090. :deep(.el-button.is-circle) {
  3091. width: 32px;
  3092. height: 32px;
  3093. }
  3094. :deep(.el-icon) {
  3095. font-size: 18px;
  3096. }
  3097. :deep(.el-icon svg) {
  3098. width: 18px;
  3099. height: 18px;
  3100. }
  3101. .notification-badge {
  3102. :deep(.el-badge__content) {
  3103. font-size: 10px;
  3104. height: 16px;
  3105. line-height: 16px;
  3106. padding: 0 4px;
  3107. }
  3108. }
  3109. }
  3110. }
  3111. // ---- 覆盖层面板(附件/规则) ----
  3112. .sidebar-overlay-panel {
  3113. position: absolute;
  3114. top: 0;
  3115. left: 0;
  3116. right: 0;
  3117. bottom: 0;
  3118. background: var(--white);
  3119. z-index: 20;
  3120. display: flex;
  3121. flex-direction: column;
  3122. .overlay-header {
  3123. display: flex;
  3124. align-items: center;
  3125. justify-content: space-between;
  3126. padding: 12px 14px;
  3127. border-bottom: 1px solid var(--border);
  3128. flex-shrink: 0;
  3129. .overlay-title {
  3130. font-size: 14px;
  3131. font-weight: 600;
  3132. color: var(--text-1);
  3133. }
  3134. }
  3135. .overlay-body {
  3136. flex: 1;
  3137. overflow-y: auto;
  3138. padding: 12px;
  3139. }
  3140. }
  3141. // 覆盖层滑入动画
  3142. .slide-right-enter-active {
  3143. transition: transform 0.25s ease-out;
  3144. }
  3145. .slide-right-leave-active {
  3146. transition: transform 0.2s ease-in;
  3147. }
  3148. .slide-right-enter-from {
  3149. transform: translateX(-100%);
  3150. }
  3151. .slide-right-leave-to {
  3152. transform: translateX(-100%);
  3153. }
  3154. }
  3155. // ==========================================
  3156. // 上传区 - V2 风格
  3157. // ==========================================
  3158. .upload-zone {
  3159. border: 2px dashed var(--border);
  3160. border-radius: var(--radius-lg);
  3161. margin-bottom: 16px;
  3162. height: 40px;
  3163. display: flex;
  3164. align-items: center;
  3165. justify-content: center;
  3166. background: var(--white);
  3167. transition: all 0.2s;
  3168. &:hover {
  3169. border-color: var(--primary);
  3170. background: var(--primary-light);
  3171. }
  3172. :deep(.el-upload-dragger) {
  3173. padding: 0 12px;
  3174. border: none;
  3175. background: transparent;
  3176. width: 100%;
  3177. height: 100%;
  3178. display: flex;
  3179. align-items: center;
  3180. justify-content: center;
  3181. }
  3182. .upload-content {
  3183. display: flex;
  3184. align-items: center;
  3185. gap: 8px;
  3186. }
  3187. .upload-icon {
  3188. font-size: 18px;
  3189. }
  3190. .upload-text {
  3191. font-size: 14px;
  3192. font-weight: 600;
  3193. color: var(--text-1);
  3194. }
  3195. .upload-hint {
  3196. display: block;
  3197. font-size: 11px;
  3198. color: var(--text-3);
  3199. margin-top: 8px;
  3200. text-align: center;
  3201. }
  3202. }
  3203. .file-list {
  3204. margin-bottom: 16px;
  3205. display: flex;
  3206. flex-direction: column;
  3207. gap: 10px;
  3208. }
  3209. // ==========================================
  3210. // 文件项 - V2 风格
  3211. // ==========================================
  3212. .file-item {
  3213. display: flex;
  3214. align-items: center;
  3215. gap: 10px;
  3216. padding: 12px;
  3217. background: var(--white);
  3218. border: 1px solid var(--border);
  3219. border-radius: var(--radius-md);
  3220. cursor: pointer;
  3221. transition: all 0.2s;
  3222. position: relative;
  3223. &:hover {
  3224. border-color: var(--primary);
  3225. background: var(--primary-light);
  3226. }
  3227. &.active {
  3228. border-color: var(--primary);
  3229. background: var(--primary-light);
  3230. }
  3231. .file-icon {
  3232. width: 40px;
  3233. height: 40px;
  3234. border-radius: var(--radius-sm);
  3235. display: flex;
  3236. align-items: center;
  3237. justify-content: center;
  3238. color: #fff;
  3239. font-weight: 700;
  3240. font-size: 13px;
  3241. flex-shrink: 0;
  3242. &.pdf { background: #ff6b6b; }
  3243. &.docx, &.doc { background: #4dabf7; }
  3244. &.xlsx, &.xls { background: #73d13d; }
  3245. &.md { background: #9254de; }
  3246. &.default { background: var(--text-3); }
  3247. }
  3248. .file-info {
  3249. flex: 1;
  3250. min-width: 0;
  3251. display: flex;
  3252. flex-direction: column;
  3253. .file-name {
  3254. font-size: 13px;
  3255. font-weight: 600;
  3256. color: var(--text-1);
  3257. white-space: nowrap;
  3258. overflow: hidden;
  3259. text-overflow: ellipsis;
  3260. }
  3261. .file-meta {
  3262. font-size: 11px;
  3263. color: var(--text-3);
  3264. margin-top: 4px;
  3265. .required {
  3266. color: var(--danger);
  3267. }
  3268. }
  3269. }
  3270. .file-status {
  3271. font-size: 11px;
  3272. white-space: nowrap;
  3273. &.parsing { color: var(--primary); }
  3274. &.done { color: var(--success); }
  3275. }
  3276. }
  3277. .add-source-btn {
  3278. width: 100%;
  3279. border-radius: var(--radius-md);
  3280. }
  3281. // ==========================================
  3282. // 附件面板 - V2 风格
  3283. // ==========================================
  3284. .att-header {
  3285. display: flex;
  3286. align-items: center;
  3287. justify-content: space-between;
  3288. margin-bottom: 12px;
  3289. .att-count {
  3290. font-size: 13px;
  3291. color: var(--text-2);
  3292. font-weight: 500;
  3293. }
  3294. }
  3295. .att-list {
  3296. display: flex;
  3297. flex-direction: column;
  3298. gap: 2px;
  3299. }
  3300. .att-item {
  3301. display: flex;
  3302. align-items: center;
  3303. gap: 12px;
  3304. padding: 10px 12px;
  3305. border-radius: var(--radius-md);
  3306. cursor: pointer;
  3307. transition: all 0.15s;
  3308. position: relative;
  3309. &:hover {
  3310. background: #f5f7fa;
  3311. .att-more-btn { opacity: 1; }
  3312. .att-parse-btn { opacity: 1; }
  3313. }
  3314. &.active {
  3315. background: var(--primary-light);
  3316. }
  3317. .att-icon {
  3318. width: 36px;
  3319. height: 36px;
  3320. border-radius: 8px;
  3321. display: flex;
  3322. align-items: center;
  3323. justify-content: center;
  3324. color: #fff;
  3325. font-weight: 700;
  3326. font-size: 11px;
  3327. flex-shrink: 0;
  3328. letter-spacing: -0.5px;
  3329. &.type-pdf { background: #e74c3c; }
  3330. &.type-word { background: #3b82f6; }
  3331. &.type-excel { background: #22c55e; }
  3332. &.type-image { background: #f59e0b; font-size: 16px; }
  3333. &.type-archive { background: #8b5cf6; }
  3334. &.type-other { background: #94a3b8; }
  3335. }
  3336. .att-info {
  3337. flex: 1;
  3338. min-width: 0;
  3339. .att-name {
  3340. font-size: 13px;
  3341. font-weight: 500;
  3342. color: var(--text-1);
  3343. white-space: nowrap;
  3344. overflow: hidden;
  3345. text-overflow: ellipsis;
  3346. line-height: 1.4;
  3347. }
  3348. .att-meta {
  3349. display: flex;
  3350. align-items: center;
  3351. gap: 6px;
  3352. margin-top: 2px;
  3353. font-size: 11px;
  3354. color: var(--text-3);
  3355. .att-type {
  3356. color: var(--primary);
  3357. font-weight: 500;
  3358. }
  3359. .att-size {
  3360. &::before {
  3361. content: '·';
  3362. margin-right: 6px;
  3363. color: var(--text-3);
  3364. }
  3365. }
  3366. }
  3367. }
  3368. .att-parse-btn {
  3369. flex-shrink: 0;
  3370. font-size: 12px;
  3371. padding: 2px 8px;
  3372. }
  3373. .att-more-btn {
  3374. opacity: 0;
  3375. transition: opacity 0.15s;
  3376. flex-shrink: 0;
  3377. }
  3378. .att-parse-status {
  3379. display: inline-flex;
  3380. align-items: center;
  3381. gap: 3px;
  3382. font-size: 11px;
  3383. margin-left: 4px;
  3384. &.parsing {
  3385. color: #e6a23c;
  3386. }
  3387. &.completed {
  3388. color: #67c23a;
  3389. }
  3390. &.failed {
  3391. color: #f56c6c;
  3392. }
  3393. }
  3394. }
  3395. // ==========================================
  3396. // 中间面板 - V2 风格
  3397. // ==========================================
  3398. .center-panel {
  3399. flex: 1;
  3400. display: flex;
  3401. flex-direction: column;
  3402. background: var(--white);
  3403. overflow: hidden;
  3404. border-radius: var(--radius-md);
  3405. margin: 0 8px;
  3406. box-shadow: var(--shadow-sm);
  3407. // ==========================================
  3408. // 欢迎页 - V2 风格
  3409. // ==========================================
  3410. .welcome-page {
  3411. flex: 1;
  3412. display: flex;
  3413. align-items: center;
  3414. justify-content: center;
  3415. background: var(--white);
  3416. .welcome-content {
  3417. text-align: center;
  3418. max-width: 600px;
  3419. padding: 48px;
  3420. }
  3421. .welcome-logo {
  3422. width: 80px;
  3423. height: 80px;
  3424. margin: 0 auto 32px;
  3425. background: linear-gradient(135deg, var(--primary) 0%, #69c0ff 100%);
  3426. border-radius: 16px;
  3427. display: flex;
  3428. align-items: center;
  3429. justify-content: center;
  3430. font-size: 40px;
  3431. font-weight: 700;
  3432. color: white;
  3433. box-shadow: 0 12px 32px rgba(24, 144, 255, 0.3);
  3434. }
  3435. .welcome {
  3436. h1 {
  3437. font-size: 28px;
  3438. font-weight: 700;
  3439. color: var(--text-1);
  3440. margin-bottom: 12px;
  3441. line-height: 1.4;
  3442. span {
  3443. display: block;
  3444. font-size: 20px;
  3445. font-weight: 500;
  3446. background: var(--ai-gradient);
  3447. background-clip: text;
  3448. -webkit-background-clip: text;
  3449. -webkit-text-fill-color: transparent;
  3450. margin-top: 8px;
  3451. }
  3452. }
  3453. p {
  3454. font-size: 15px;
  3455. color: var(--text-3);
  3456. line-height: 1.6;
  3457. }
  3458. }
  3459. }
  3460. // ==========================================
  3461. // 编辑器标题栏 - 参考设计风格
  3462. // ==========================================
  3463. .editor-title-bar {
  3464. padding: 0 16px;
  3465. height: 48px;
  3466. border-bottom: 1px solid var(--border);
  3467. display: flex;
  3468. align-items: center;
  3469. justify-content: space-between;
  3470. background: var(--white);
  3471. flex-shrink: 0;
  3472. .titlebar-left {
  3473. display: flex;
  3474. align-items: center;
  3475. gap: 6px;
  3476. min-width: 0;
  3477. flex: 1;
  3478. .titlebar-folder-icon {
  3479. font-size: 18px;
  3480. color: var(--text-3);
  3481. flex-shrink: 0;
  3482. }
  3483. .titlebar-sep {
  3484. color: var(--text-3);
  3485. font-size: 14px;
  3486. flex-shrink: 0;
  3487. }
  3488. .titlebar-project-name {
  3489. font-size: 14px;
  3490. font-weight: 600;
  3491. color: var(--text-1);
  3492. white-space: nowrap;
  3493. overflow: hidden;
  3494. text-overflow: ellipsis;
  3495. max-width: 360px;
  3496. }
  3497. .titlebar-status-tag {
  3498. flex-shrink: 0;
  3499. margin-left: 4px;
  3500. border-radius: 4px;
  3501. font-size: 11px;
  3502. }
  3503. }
  3504. .titlebar-right {
  3505. display: flex;
  3506. align-items: center;
  3507. gap: 4px;
  3508. flex-shrink: 0;
  3509. .titlebar-save-status {
  3510. display: flex;
  3511. align-items: center;
  3512. gap: 5px;
  3513. font-size: 12px;
  3514. color: var(--text-3);
  3515. white-space: nowrap;
  3516. margin-right: 4px;
  3517. .save-dot {
  3518. width: 6px;
  3519. height: 6px;
  3520. border-radius: 50%;
  3521. background: #bbb;
  3522. &.saved {
  3523. background: #52c41a;
  3524. }
  3525. }
  3526. }
  3527. :deep(.el-button.is-circle) {
  3528. width: 32px;
  3529. height: 32px;
  3530. }
  3531. :deep(.el-icon) {
  3532. font-size: 16px;
  3533. }
  3534. :deep(.el-button.is-active-view) {
  3535. color: var(--primary);
  3536. background: var(--primary-light);
  3537. }
  3538. :deep(.el-divider--vertical) {
  3539. height: 20px;
  3540. margin: 0 4px;
  3541. }
  3542. }
  3543. }
  3544. // ==========================================
  3545. // 编辑器滚动区 - V2 风格
  3546. // ==========================================
  3547. .editor-scroll {
  3548. flex: 1;
  3549. overflow-y: auto;
  3550. padding: 40px 48px;
  3551. background: var(--white);
  3552. position: relative;
  3553. }
  3554. .editor-content {
  3555. max-width: 1000px;
  3556. margin: 0 auto;
  3557. outline: none;
  3558. // 文档块样式
  3559. :deep(.doc-block) {
  3560. position: relative;
  3561. transition: background-color 0.2s;
  3562. &:hover {
  3563. background-color: rgba(24, 144, 255, 0.02);
  3564. }
  3565. // 被选中时的样式
  3566. &.selected {
  3567. background-color: rgba(24, 144, 255, 0.08);
  3568. outline: 1px dashed var(--primary);
  3569. }
  3570. }
  3571. :deep(h1) {
  3572. font-size: 24px;
  3573. font-weight: 700;
  3574. margin-bottom: 24px;
  3575. }
  3576. :deep(h2) {
  3577. font-size: 18px;
  3578. font-weight: 600;
  3579. margin: 28px 0 16px;
  3580. }
  3581. :deep(p) {
  3582. margin-bottom: 12px;
  3583. line-height: 1.6;
  3584. }
  3585. :deep(ul) {
  3586. margin-bottom: 16px;
  3587. padding-left: 24px;
  3588. li {
  3589. margin-bottom: 8px;
  3590. }
  3591. }
  3592. // 目录样式
  3593. :deep(.doc-toc-title) {
  3594. font-size: 18pt;
  3595. font-weight: bold;
  3596. text-align: center;
  3597. margin: 20px 0 16px;
  3598. }
  3599. :deep(.doc-toc-item) {
  3600. display: flex;
  3601. align-items: baseline;
  3602. padding: 6px 0;
  3603. line-height: 1.6;
  3604. cursor: pointer;
  3605. transition: background-color 0.2s;
  3606. &:hover {
  3607. background-color: #f5f5f5;
  3608. }
  3609. .toc-title {
  3610. flex-shrink: 0;
  3611. white-space: nowrap;
  3612. }
  3613. .toc-dots {
  3614. flex: 1;
  3615. border-bottom: 1px dotted #999;
  3616. margin: 0 8px;
  3617. min-width: 20px;
  3618. height: 0.6em;
  3619. }
  3620. .toc-page {
  3621. flex-shrink: 0;
  3622. color: #666;
  3623. min-width: 20px;
  3624. text-align: right;
  3625. }
  3626. }
  3627. // 表格样式
  3628. :deep(.doc-table-container) {
  3629. margin: 16px 0;
  3630. overflow-x: auto;
  3631. }
  3632. :deep(.doc-table) {
  3633. width: 100%;
  3634. border-collapse: collapse;
  3635. font-size: 14px;
  3636. th, td {
  3637. border: 1px solid #ddd;
  3638. padding: 8px 12px;
  3639. text-align: left;
  3640. vertical-align: top;
  3641. line-height: 1.5;
  3642. }
  3643. th {
  3644. background-color: #f5f5f5;
  3645. font-weight: bold;
  3646. }
  3647. tr:nth-child(even) td {
  3648. background-color: #fafafa;
  3649. }
  3650. tr:hover td {
  3651. background-color: #f0f7ff;
  3652. }
  3653. }
  3654. :deep(.doc-table-empty) {
  3655. padding: 20px;
  3656. text-align: center;
  3657. color: #999;
  3658. border: 1px dashed #ddd;
  3659. margin: 16px 0;
  3660. }
  3661. // 列表项样式
  3662. :deep(.doc-list-item) {
  3663. position: relative;
  3664. margin-bottom: 8px;
  3665. line-height: 1.6;
  3666. &.bullet {
  3667. padding-left: 1.5em;
  3668. &::before {
  3669. content: '•';
  3670. position: absolute;
  3671. left: 0;
  3672. }
  3673. }
  3674. &.ordered {
  3675. padding-left: 2em;
  3676. counter-increment: doc-list;
  3677. &::before {
  3678. content: counter(doc-list) '.';
  3679. position: absolute;
  3680. left: 0;
  3681. }
  3682. }
  3683. }
  3684. // 重置列表计数器
  3685. :deep(p + .doc-list-item.ordered:first-of-type),
  3686. :deep(.doc-list-item.bullet + .doc-list-item.ordered) {
  3687. counter-reset: doc-list;
  3688. }
  3689. // 块引用样式
  3690. :deep(blockquote) {
  3691. margin: 16px 0;
  3692. padding: 12px 20px;
  3693. border-left: 4px solid #ddd;
  3694. background: #f9f9f9;
  3695. color: #666;
  3696. }
  3697. // 代码块样式
  3698. :deep(pre) {
  3699. margin: 16px 0;
  3700. padding: 16px;
  3701. background: #f5f5f5;
  3702. border-radius: 4px;
  3703. overflow-x: auto;
  3704. code {
  3705. font-family: 'Consolas', 'Monaco', monospace;
  3706. font-size: 13px;
  3707. }
  3708. }
  3709. // 实体高亮样式 - 匹配原型 UI(边框 + 背景)
  3710. :deep(.entity-highlight) {
  3711. display: inline;
  3712. padding: 2px 8px;
  3713. border-radius: 4px;
  3714. cursor: pointer;
  3715. transition: all 0.2s;
  3716. font-weight: 500;
  3717. border: 1px solid #1890ff;
  3718. color: #1890ff;
  3719. background: rgba(24, 144, 255, 0.1);
  3720. &:hover {
  3721. background: #1890ff;
  3722. color: white;
  3723. }
  3724. // 实体类型颜色
  3725. &.entity {
  3726. border-color: #1890ff;
  3727. color: #1890ff;
  3728. background: rgba(24, 144, 255, 0.1);
  3729. &:hover { background: #1890ff; color: white; }
  3730. }
  3731. &.concept {
  3732. border-color: #722ed1;
  3733. color: #722ed1;
  3734. background: rgba(114, 46, 209, 0.1);
  3735. &:hover { background: #722ed1; color: white; }
  3736. }
  3737. &.data {
  3738. border-color: #52c41a;
  3739. color: #52c41a;
  3740. background: rgba(82, 196, 26, 0.1);
  3741. &:hover { background: #52c41a; color: white; }
  3742. }
  3743. &.location {
  3744. border-color: #faad14;
  3745. color: #d48806;
  3746. background: rgba(250, 173, 20, 0.1);
  3747. &:hover { background: #faad14; color: white; }
  3748. }
  3749. &.asset {
  3750. border-color: #eb2f96;
  3751. color: #eb2f96;
  3752. background: rgba(235, 47, 150, 0.1);
  3753. &:hover { background: #eb2f96; color: white; }
  3754. }
  3755. &.person {
  3756. border-color: #1890ff;
  3757. color: #1890ff;
  3758. background: rgba(24, 144, 255, 0.1);
  3759. &:hover { background: #1890ff; color: white; }
  3760. }
  3761. &.org {
  3762. border-color: #722ed1;
  3763. color: #722ed1;
  3764. background: rgba(114, 46, 209, 0.1);
  3765. &:hover { background: #722ed1; color: white; }
  3766. }
  3767. &.date {
  3768. border-color: #13c2c2;
  3769. color: #13c2c2;
  3770. background: rgba(19, 194, 194, 0.1);
  3771. &:hover { background: #13c2c2; color: white; }
  3772. }
  3773. &.product {
  3774. border-color: #eb2f96;
  3775. color: #eb2f96;
  3776. background: rgba(235, 47, 150, 0.1);
  3777. &:hover { background: #eb2f96; color: white; }
  3778. }
  3779. &.event {
  3780. border-color: #fa8c16;
  3781. color: #fa8c16;
  3782. background: rgba(250, 140, 22, 0.1);
  3783. &:hover { background: #fa8c16; color: white; }
  3784. }
  3785. &.law {
  3786. border-color: #2f54eb;
  3787. color: #2f54eb;
  3788. background: rgba(47, 84, 235, 0.1);
  3789. &:hover { background: #2f54eb; color: white; }
  3790. }
  3791. // 未确认的 AI 建议(文档中虚线样式)
  3792. &.ai-suggestion-pending {
  3793. border-style: dashed;
  3794. opacity: 0.9;
  3795. }
  3796. // 点击 AI 建议后,文档中该要素的「待确认」高亮
  3797. &.entity-pending-confirm {
  3798. box-shadow: 0 0 0 2px #1890ff;
  3799. opacity: 1;
  3800. }
  3801. }
  3802. }
  3803. }
  3804. // ==========================================
  3805. // 右侧面板 - 参考设计风格
  3806. // ==========================================
  3807. .right-panel {
  3808. background: var(--white);
  3809. border-left: 1px solid var(--border);
  3810. display: flex;
  3811. flex-direction: column;
  3812. flex-shrink: 0;
  3813. min-width: 280px;
  3814. max-width: 420px;
  3815. overflow: hidden;
  3816. // ---- 报告要素区 ----
  3817. .rp-elements {
  3818. flex-shrink: 0;
  3819. display: flex;
  3820. flex-direction: column;
  3821. max-height: 45%;
  3822. border-bottom: 1px solid var(--border);
  3823. }
  3824. .rp-elements-header {
  3825. display: flex;
  3826. align-items: center;
  3827. justify-content: space-between;
  3828. padding: 14px 16px 10px;
  3829. flex-shrink: 0;
  3830. .rp-elements-title {
  3831. display: flex;
  3832. align-items: center;
  3833. gap: 6px;
  3834. .rp-title-icon { font-size: 16px; }
  3835. .rp-title-text { font-size: 14px; font-weight: 700; color: var(--text-1); }
  3836. }
  3837. }
  3838. .rp-elements-body {
  3839. flex: 1;
  3840. overflow-y: auto;
  3841. padding: 0 16px 14px;
  3842. }
  3843. .rp-value-tags {
  3844. display: flex;
  3845. flex-wrap: wrap;
  3846. gap: 8px;
  3847. }
  3848. .rp-value-tag {
  3849. display: inline-block;
  3850. padding: 5px 14px;
  3851. background: #e8f4ff;
  3852. color: #1677ff;
  3853. border-radius: 16px;
  3854. font-size: 13px;
  3855. font-weight: 500;
  3856. line-height: 1.4;
  3857. cursor: default;
  3858. transition: background 0.15s;
  3859. max-width: 100%;
  3860. overflow: hidden;
  3861. text-overflow: ellipsis;
  3862. white-space: nowrap;
  3863. &:hover {
  3864. background: #d0e8ff;
  3865. }
  3866. }
  3867. .rp-elements-empty {
  3868. padding: 20px;
  3869. text-align: center;
  3870. color: var(--text-3);
  3871. font-size: 13px;
  3872. }
  3873. // ---- AI 助手区 ----
  3874. .rp-ai {
  3875. flex: 1;
  3876. display: flex;
  3877. flex-direction: column;
  3878. min-height: 0;
  3879. }
  3880. .rp-ai-header {
  3881. display: flex;
  3882. align-items: center;
  3883. justify-content: space-between;
  3884. padding: 12px 16px 8px;
  3885. flex-shrink: 0;
  3886. .rp-ai-title {
  3887. display: flex;
  3888. align-items: center;
  3889. gap: 6px;
  3890. font-size: 14px;
  3891. font-weight: 700;
  3892. color: var(--text-1);
  3893. .rp-ai-icon { font-size: 16px; }
  3894. }
  3895. .rp-ai-actions {
  3896. display: flex;
  3897. gap: 4px;
  3898. }
  3899. }
  3900. .rp-ai-messages {
  3901. flex: 1;
  3902. overflow-y: auto;
  3903. padding: 8px 16px;
  3904. display: flex;
  3905. flex-direction: column;
  3906. gap: 12px;
  3907. }
  3908. .ai-message {
  3909. display: flex;
  3910. &.ai-user {
  3911. justify-content: flex-end;
  3912. .ai-bubble {
  3913. background: var(--primary);
  3914. color: #fff;
  3915. border-radius: 16px 16px 4px 16px;
  3916. }
  3917. }
  3918. &.ai-bot {
  3919. justify-content: flex-start;
  3920. .ai-bubble {
  3921. background: #f4f5f7;
  3922. color: var(--text-1);
  3923. border-radius: 16px 16px 16px 4px;
  3924. }
  3925. }
  3926. }
  3927. .ai-bubble {
  3928. max-width: 85%;
  3929. padding: 10px 14px;
  3930. font-size: 13px;
  3931. line-height: 1.6;
  3932. word-break: break-word;
  3933. }
  3934. .rp-ai-input {
  3935. flex-shrink: 0;
  3936. padding: 10px 14px 12px;
  3937. border-top: 1px solid var(--border);
  3938. :deep(.el-textarea__inner) {
  3939. border-radius: 12px;
  3940. background: #f7f8fa;
  3941. border: 1px solid var(--border);
  3942. padding: 10px 14px;
  3943. font-size: 13px;
  3944. line-height: 1.5;
  3945. &:focus {
  3946. border-color: var(--primary);
  3947. background: var(--white);
  3948. }
  3949. }
  3950. }
  3951. .rp-ai-input-actions {
  3952. display: flex;
  3953. align-items: center;
  3954. justify-content: space-between;
  3955. margin-top: 6px;
  3956. padding: 0 2px;
  3957. :deep(.el-button.is-circle) {
  3958. width: 32px;
  3959. height: 32px;
  3960. font-size: 18px;
  3961. }
  3962. :deep(.el-button--primary.is-circle) {
  3963. width: 34px;
  3964. height: 34px;
  3965. }
  3966. :deep(.el-icon) {
  3967. font-size: 18px;
  3968. }
  3969. :deep(.el-icon svg) {
  3970. width: 18px;
  3971. height: 18px;
  3972. }
  3973. }
  3974. .rp-ai-input-tools,
  3975. .rp-ai-input-right {
  3976. display: flex;
  3977. align-items: center;
  3978. gap: 4px;
  3979. }
  3980. }
  3981. // ==========================================
  3982. // 要素管理区 - V2 风格
  3983. // ==========================================
  3984. .element-section {
  3985. padding: 16px;
  3986. border-bottom: 1px dashed var(--border);
  3987. // 模块标题样式 - V2 风格
  3988. .module-title {
  3989. display: flex;
  3990. align-items: center;
  3991. gap: 10px;
  3992. font-size: 15px;
  3993. font-weight: 700;
  3994. color: var(--text-1);
  3995. margin-bottom: 14px;
  3996. .module-icon {
  3997. width: 36px;
  3998. height: 36px;
  3999. border-radius: 8px;
  4000. background: var(--primary-gradient);
  4001. display: flex;
  4002. align-items: center;
  4003. justify-content: center;
  4004. font-size: 18px;
  4005. color: white;
  4006. box-shadow: var(--shadow-md);
  4007. }
  4008. }
  4009. .element-header {
  4010. display: flex;
  4011. align-items: center;
  4012. justify-content: space-between;
  4013. margin-bottom: 12px;
  4014. .element-title {
  4015. font-size: 13px;
  4016. font-weight: 600;
  4017. display: flex;
  4018. align-items: center;
  4019. gap: 6px;
  4020. .element-count {
  4021. font-size: 11px;
  4022. color: var(--text-3);
  4023. font-weight: normal;
  4024. }
  4025. }
  4026. .header-actions {
  4027. display: flex;
  4028. gap: 4px;
  4029. .el-button {
  4030. padding: 4px 8px;
  4031. font-size: 12px;
  4032. }
  4033. }
  4034. }
  4035. // AI 建议区块特殊样式
  4036. &.ai-section {
  4037. background: var(--bg);
  4038. border-bottom: none;
  4039. .element-header {
  4040. .element-title {
  4041. color: var(--text-2);
  4042. }
  4043. }
  4044. .element-option.ai-highlight-option {
  4045. display: flex;
  4046. align-items: center;
  4047. gap: 8px;
  4048. padding: 8px 16px;
  4049. font-size: 12px;
  4050. color: var(--text-2);
  4051. .option-label {
  4052. flex: 1;
  4053. }
  4054. }
  4055. .element-tags-wrap {
  4056. max-height: 300px;
  4057. }
  4058. }
  4059. // 要素 Tab 切换 - V2 风格
  4060. .element-tabs {
  4061. display: flex;
  4062. gap: 8px;
  4063. .element-tab {
  4064. padding: 6px 12px;
  4065. border-radius: 12px;
  4066. background: transparent;
  4067. border: 1px solid transparent;
  4068. font-size: 13px;
  4069. cursor: pointer;
  4070. color: var(--text-2);
  4071. transition: all 0.2s;
  4072. &:hover {
  4073. background: var(--bg);
  4074. }
  4075. &.active {
  4076. background: var(--primary);
  4077. color: #fff;
  4078. border-color: rgba(0, 0, 0, 0.04);
  4079. box-shadow: var(--shadow-md);
  4080. }
  4081. }
  4082. }
  4083. .element-filter {
  4084. padding: 0 0 12px;
  4085. .entity-search {
  4086. margin-bottom: 12px;
  4087. :deep(.el-input__wrapper) {
  4088. border-radius: 18px;
  4089. background: var(--bg);
  4090. box-shadow: none;
  4091. border: 1px solid var(--border);
  4092. &:hover, &.is-focus {
  4093. border-color: var(--primary);
  4094. background: var(--white);
  4095. }
  4096. }
  4097. }
  4098. .entity-type-filter {
  4099. display: flex;
  4100. flex-wrap: wrap;
  4101. gap: 6px;
  4102. .filter-tag {
  4103. cursor: pointer;
  4104. transition: all 0.2s;
  4105. border-radius: 12px;
  4106. font-size: 11px;
  4107. &:hover {
  4108. border-color: var(--primary);
  4109. color: var(--primary);
  4110. }
  4111. &.active {
  4112. background: var(--primary);
  4113. color: white;
  4114. border-color: var(--primary);
  4115. }
  4116. &.clear {
  4117. background: transparent;
  4118. border-style: dashed;
  4119. color: var(--text-3);
  4120. &:hover {
  4121. border-color: var(--danger);
  4122. color: var(--danger);
  4123. }
  4124. }
  4125. }
  4126. }
  4127. }
  4128. .element-body {
  4129. padding: 0;
  4130. display: flex;
  4131. flex-direction: column;
  4132. gap: 16px;
  4133. }
  4134. // 要素标签容器 - V2 风格
  4135. .element-tags-wrap {
  4136. display: flex;
  4137. flex-wrap: wrap;
  4138. gap: 8px;
  4139. max-height: 200px;
  4140. overflow-y: auto;
  4141. padding-right: 4px;
  4142. padding-bottom: 16px;
  4143. &::-webkit-scrollbar {
  4144. width: 4px;
  4145. }
  4146. &::-webkit-scrollbar-track {
  4147. background: var(--bg);
  4148. border-radius: 2px;
  4149. }
  4150. &::-webkit-scrollbar-thumb {
  4151. background: var(--border);
  4152. border-radius: 2px;
  4153. &:hover {
  4154. background: var(--text-3);
  4155. }
  4156. }
  4157. }
  4158. // ==========================================
  4159. // 要素标签样式 - V2 风格
  4160. // ==========================================
  4161. .var-tag {
  4162. height: 28px;
  4163. display: inline-flex;
  4164. align-items: center;
  4165. gap: 6px;
  4166. padding: 0 12px;
  4167. border-radius: 2px;
  4168. font-size: 12px;
  4169. cursor: pointer;
  4170. transition: all 0.2s;
  4171. background: var(--bg);
  4172. border: 1px solid var(--border);
  4173. user-select: none;
  4174. &:hover {
  4175. border-color: var(--primary);
  4176. background: var(--primary-light);
  4177. transform: translateY(-1px);
  4178. }
  4179. &:active {
  4180. cursor: grabbing;
  4181. }
  4182. .tag-icon {
  4183. font-size: 12px;
  4184. }
  4185. .tag-name {
  4186. max-width: 120px;
  4187. overflow: hidden;
  4188. text-overflow: ellipsis;
  4189. white-space: nowrap;
  4190. font-weight: 500;
  4191. line-height: 28px;
  4192. }
  4193. .tag-status {
  4194. color: #52c41a;
  4195. font-size: 10px;
  4196. }
  4197. .tag-action {
  4198. color: var(--primary);
  4199. font-size: 14px;
  4200. font-weight: bold;
  4201. margin-left: 2px;
  4202. }
  4203. // 已确认的要素
  4204. &.confirmed {
  4205. background: var(--white);
  4206. border-color: var(--primary);
  4207. .tag-name {
  4208. color: var(--text-1);
  4209. }
  4210. }
  4211. // AI 建议的要素(虚线边框、淡色)
  4212. &.ai-suggestion {
  4213. background: transparent;
  4214. border-style: dashed;
  4215. border-color: var(--border);
  4216. opacity: 0.85;
  4217. .tag-name {
  4218. color: var(--text-2);
  4219. }
  4220. &:hover {
  4221. opacity: 1;
  4222. border-color: var(--primary);
  4223. border-style: solid;
  4224. background: var(--primary-light);
  4225. .tag-action {
  4226. transform: scale(1.2);
  4227. }
  4228. }
  4229. }
  4230. // 动态要素样式(圆角)
  4231. &.dynamic {
  4232. border-radius: 14px;
  4233. }
  4234. // 静态要素样式(微圆角)
  4235. &.static {
  4236. border-radius: 2px;
  4237. }
  4238. // 已确认状态
  4239. &.confirmed {
  4240. background: rgba(82, 196, 26, 0.1);
  4241. border-color: #52c41a;
  4242. .tag-name {
  4243. color: #389e0d;
  4244. }
  4245. }
  4246. // 实体类型样式 - 左边框颜色区分
  4247. &.entity-person, &.entity {
  4248. border-left: 3px solid var(--primary);
  4249. }
  4250. &.entity-org, &.concept {
  4251. border-left: 3px solid #722ed1;
  4252. }
  4253. &.entity-location, &.location {
  4254. border-left: 3px solid var(--warning);
  4255. }
  4256. &.entity-date {
  4257. border-left: 3px solid #13c2c2;
  4258. }
  4259. &.entity-data, &.data {
  4260. border-left: 3px solid var(--success);
  4261. }
  4262. &.entity-product, &.asset {
  4263. border-left: 3px solid #eb2f96;
  4264. }
  4265. &.entity-event {
  4266. border-left: 3px solid #fa8c16;
  4267. }
  4268. &.entity-law {
  4269. border-left: 3px solid #2f54eb;
  4270. }
  4271. &.entity-default {
  4272. border-left: 3px solid #8c8c8c;
  4273. }
  4274. // 当前正在确认的 AI 建议 tag
  4275. &.is-pending {
  4276. border-color: var(--primary);
  4277. background: var(--primary-light);
  4278. border-style: solid;
  4279. }
  4280. }
  4281. // AI 建议确认栏已移至「+」按钮的悬浮框内,此处样式仅作保留注释
  4282. .element-hint {
  4283. font-size: 12px;
  4284. color: var(--text-3);
  4285. text-align: center;
  4286. padding: 24px;
  4287. }
  4288. }
  4289. // 实体高亮闪烁效果
  4290. @keyframes entity-flash {
  4291. 0%, 100% { background-color: inherit; }
  4292. 50% { background-color: #ffe58f; }
  4293. }
  4294. .entity-highlight-flash {
  4295. animation: entity-flash 0.5s ease-in-out 3;
  4296. }
  4297. // 实体编辑弹窗样式
  4298. .entity-edit-form {
  4299. .entity-edit-preview {
  4300. display: flex;
  4301. align-items: center;
  4302. justify-content: center;
  4303. gap: 10px;
  4304. padding: 16px;
  4305. background: var(--primary-light);
  4306. border: 1px dashed var(--primary);
  4307. border-radius: 8px;
  4308. margin-bottom: 20px;
  4309. .preview-icon {
  4310. font-size: 24px;
  4311. }
  4312. .preview-text {
  4313. font-size: 16px;
  4314. font-weight: 600;
  4315. color: var(--primary);
  4316. }
  4317. }
  4318. }
  4319. .category-section {
  4320. padding: 12px 16px;
  4321. border-bottom: 1px solid var(--border);
  4322. .category-header {
  4323. display: flex;
  4324. align-items: center;
  4325. gap: 8px;
  4326. font-size: 12px;
  4327. font-weight: 600;
  4328. margin-bottom: 10px;
  4329. .category-dot {
  4330. width: 10px;
  4331. height: 10px;
  4332. border-radius: 50%;
  4333. }
  4334. .category-count {
  4335. color: var(--text-3);
  4336. font-weight: normal;
  4337. background: var(--bg);
  4338. padding: 2px 8px;
  4339. border-radius: 10px;
  4340. }
  4341. }
  4342. .category-items {
  4343. .category-item {
  4344. display: flex;
  4345. justify-content: space-between;
  4346. padding: 8px 12px;
  4347. background: var(--bg);
  4348. border-radius: 6px;
  4349. margin-bottom: 6px;
  4350. cursor: pointer;
  4351. font-size: 12px;
  4352. transition: all 0.2s;
  4353. &:hover {
  4354. background: var(--primary-light);
  4355. }
  4356. .item-value {
  4357. color: var(--text-3);
  4358. }
  4359. }
  4360. }
  4361. }
  4362. // ==========================================
  4363. // 右键菜单 - V2 风格
  4364. // ==========================================
  4365. .context-menu {
  4366. position: fixed;
  4367. min-width: 180px;
  4368. background: var(--white);
  4369. border-radius: var(--radius-md);
  4370. box-shadow: var(--shadow-lg);
  4371. z-index: 3000;
  4372. overflow: hidden;
  4373. .context-menu-header {
  4374. padding: 12px 14px;
  4375. background: var(--bg);
  4376. border-bottom: 1px solid var(--border);
  4377. .selected-preview {
  4378. font-size: 12px;
  4379. color: var(--primary);
  4380. font-weight: 600;
  4381. max-width: 150px;
  4382. overflow: hidden;
  4383. text-overflow: ellipsis;
  4384. white-space: nowrap;
  4385. }
  4386. }
  4387. .context-menu-section {
  4388. padding: 8px 14px 4px;
  4389. font-size: 10px;
  4390. color: var(--text-3);
  4391. font-weight: 600;
  4392. text-transform: uppercase;
  4393. letter-spacing: 0.5px;
  4394. }
  4395. .context-menu-item {
  4396. display: flex;
  4397. align-items: center;
  4398. gap: 10px;
  4399. padding: 10px 14px;
  4400. font-size: 13px;
  4401. cursor: pointer;
  4402. transition: all 0.15s;
  4403. color: var(--text-1);
  4404. position: relative;
  4405. &:hover {
  4406. background: var(--primary-light);
  4407. color: var(--primary);
  4408. }
  4409. &[disabled="true"] {
  4410. opacity: 0.5;
  4411. pointer-events: none;
  4412. }
  4413. .icon {
  4414. font-size: 14px;
  4415. width: 20px;
  4416. text-align: center;
  4417. flex-shrink: 0;
  4418. }
  4419. .shortcut {
  4420. margin-left: auto;
  4421. font-size: 11px;
  4422. color: var(--text-3);
  4423. }
  4424. .submenu-arrow {
  4425. margin-left: auto;
  4426. font-size: 14px;
  4427. color: var(--text-3);
  4428. }
  4429. &.has-submenu {
  4430. &:hover .submenu-arrow {
  4431. color: var(--primary);
  4432. }
  4433. }
  4434. }
  4435. // 子菜单
  4436. .context-submenu {
  4437. position: absolute;
  4438. left: 100%;
  4439. top: 0;
  4440. min-width: 150px;
  4441. background: var(--white);
  4442. border-radius: var(--radius-md);
  4443. box-shadow: var(--shadow-lg);
  4444. overflow: hidden;
  4445. .context-menu-item {
  4446. padding: 8px 12px;
  4447. font-size: 12px;
  4448. gap: 8px;
  4449. .icon {
  4450. font-size: 12px;
  4451. width: 16px;
  4452. }
  4453. }
  4454. }
  4455. .context-menu-divider {
  4456. height: 1px;
  4457. background: var(--border);
  4458. margin: 4px 0;
  4459. }
  4460. .context-menu-loading {
  4461. display: flex;
  4462. align-items: center;
  4463. justify-content: center;
  4464. gap: 8px;
  4465. padding: 12px;
  4466. color: var(--primary);
  4467. font-size: 12px;
  4468. border-top: 1px solid var(--border);
  4469. background: var(--bg);
  4470. }
  4471. }
  4472. // ==========================================
  4473. // 实体弹出框样式 - V2 风格
  4474. // ==========================================
  4475. .entity-popover {
  4476. .entity-popover-header {
  4477. display: flex;
  4478. align-items: center;
  4479. justify-content: space-between;
  4480. margin-bottom: 10px;
  4481. .entity-text {
  4482. font-weight: 600;
  4483. font-size: 14px;
  4484. max-width: 140px;
  4485. overflow: hidden;
  4486. text-overflow: ellipsis;
  4487. white-space: nowrap;
  4488. color: var(--text-1);
  4489. }
  4490. }
  4491. .entity-popover-type {
  4492. font-size: 12px;
  4493. color: var(--text-2);
  4494. margin-bottom: 14px;
  4495. padding: 4px 8px;
  4496. background: var(--bg);
  4497. border-radius: 4px;
  4498. display: inline-block;
  4499. }
  4500. .entity-popover-actions {
  4501. display: flex;
  4502. gap: 8px;
  4503. flex-wrap: wrap;
  4504. :deep(.el-button) {
  4505. border-radius: var(--radius-sm);
  4506. }
  4507. }
  4508. }
  4509. // ==========================================
  4510. // 知识图谱容器 - V2 风格
  4511. // ==========================================
  4512. .graph-container {
  4513. height: 500px;
  4514. position: relative;
  4515. background: linear-gradient(45deg, #f8f9fa 25%, transparent 25%),
  4516. linear-gradient(-45deg, #f8f9fa 25%, transparent 25%),
  4517. linear-gradient(45deg, transparent 75%, #f8f9fa 75%),
  4518. linear-gradient(-45deg, transparent 75%, #f8f8f8 75%);
  4519. background-size: 20px 20px;
  4520. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  4521. border-radius: var(--radius-md);
  4522. .graph-legend {
  4523. position: absolute;
  4524. top: 16px;
  4525. left: 16px;
  4526. background: var(--white);
  4527. border-radius: var(--radius-md);
  4528. padding: 14px 18px;
  4529. box-shadow: var(--shadow-md);
  4530. .legend-title {
  4531. font-size: 12px;
  4532. font-weight: 600;
  4533. margin-bottom: 10px;
  4534. color: var(--text-1);
  4535. }
  4536. .legend-item {
  4537. display: flex;
  4538. align-items: center;
  4539. gap: 8px;
  4540. font-size: 12px;
  4541. color: var(--text-2);
  4542. margin-bottom: 6px;
  4543. &:last-child {
  4544. margin-bottom: 0;
  4545. }
  4546. }
  4547. .legend-dot {
  4548. width: 12px;
  4549. height: 12px;
  4550. border-radius: 50%;
  4551. &.core, &.entity { background: var(--primary); }
  4552. &.concept { background: #722ed1; }
  4553. &.data { background: var(--success); }
  4554. &.location { background: var(--warning); }
  4555. }
  4556. }
  4557. .graph-body {
  4558. height: 100%;
  4559. display: flex;
  4560. align-items: center;
  4561. justify-content: center;
  4562. .graph-placeholder {
  4563. text-align: center;
  4564. color: var(--text-3);
  4565. .placeholder-icon {
  4566. font-size: 48px;
  4567. margin-bottom: 16px;
  4568. opacity: 0.5;
  4569. }
  4570. p {
  4571. margin-top: 12px;
  4572. font-size: 14px;
  4573. }
  4574. }
  4575. }
  4576. }
  4577. // ==========================================
  4578. // 空白编辑器占位提示样式 - V2 风格
  4579. // ==========================================
  4580. :deep(.empty-editor-placeholder) {
  4581. display: flex;
  4582. flex-direction: column;
  4583. align-items: center;
  4584. justify-content: center;
  4585. padding: 80px 40px;
  4586. text-align: center;
  4587. min-height: 400px;
  4588. .empty-icon {
  4589. font-size: 64px;
  4590. margin-bottom: 24px;
  4591. opacity: 0.8;
  4592. }
  4593. h2 {
  4594. font-size: 24px;
  4595. font-weight: 600;
  4596. margin-bottom: 12px;
  4597. color: var(--text-1);
  4598. }
  4599. .empty-subtitle {
  4600. font-size: 15px;
  4601. color: var(--text-3);
  4602. margin-bottom: 32px;
  4603. }
  4604. .empty-actions {
  4605. display: flex;
  4606. flex-direction: column;
  4607. gap: 12px;
  4608. margin-bottom: 32px;
  4609. width: 100%;
  4610. max-width: 400px;
  4611. }
  4612. .action-card {
  4613. display: flex;
  4614. align-items: center;
  4615. gap: 12px;
  4616. padding: 16px 20px;
  4617. background: var(--bg);
  4618. border: 1px solid var(--border);
  4619. border-radius: var(--radius-md);
  4620. cursor: pointer;
  4621. transition: all 0.2s;
  4622. text-align: left;
  4623. &:hover {
  4624. border-color: var(--primary);
  4625. background: var(--primary-light);
  4626. transform: translateX(4px);
  4627. }
  4628. .action-icon {
  4629. font-size: 24px;
  4630. flex-shrink: 0;
  4631. }
  4632. .action-text {
  4633. font-size: 14px;
  4634. color: var(--text-1);
  4635. font-weight: 500;
  4636. }
  4637. }
  4638. .empty-hint {
  4639. font-size: 13px;
  4640. color: var(--text-3);
  4641. padding: 12px 20px;
  4642. background: var(--bg);
  4643. border-radius: var(--radius-md);
  4644. border-left: 3px solid var(--primary);
  4645. }
  4646. }
  4647. // 高亮块动画
  4648. .highlight-block {
  4649. animation: highlight-pulse 2s ease-out;
  4650. }
  4651. @keyframes highlight-pulse {
  4652. 0% {
  4653. background: rgba(24, 144, 255, 0.3);
  4654. box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.2);
  4655. }
  4656. 100% {
  4657. background: transparent;
  4658. box-shadow: none;
  4659. }
  4660. }
  4661. // ==========================================
  4662. // 报告要素管理弹窗样式
  4663. // ==========================================
  4664. .elements-modal {
  4665. :deep(.el-dialog__header) {
  4666. padding: 16px 20px;
  4667. border-bottom: 1px solid var(--border);
  4668. margin-right: 0;
  4669. }
  4670. :deep(.el-dialog__body) {
  4671. padding: 0;
  4672. }
  4673. :deep(.el-dialog__footer) {
  4674. padding: 12px 20px;
  4675. border-top: 1px solid var(--border);
  4676. }
  4677. }
  4678. .elements-modal-content {
  4679. .elements-search {
  4680. display: flex;
  4681. align-items: center;
  4682. gap: 16px;
  4683. padding: 16px 20px;
  4684. border-bottom: 1px solid var(--border);
  4685. background: var(--bg);
  4686. .el-input {
  4687. max-width: 300px;
  4688. }
  4689. }
  4690. .elements-table-wrap {
  4691. padding: 0;
  4692. :deep(.el-table) {
  4693. .element-name {
  4694. font-weight: 500;
  4695. color: var(--text-1);
  4696. }
  4697. .element-desc {
  4698. color: var(--text-3);
  4699. font-size: 12px;
  4700. }
  4701. .original-value {
  4702. color: var(--text-2);
  4703. font-size: 12px;
  4704. }
  4705. .element-source {
  4706. color: var(--primary);
  4707. font-size: 12px;
  4708. }
  4709. .el-input__wrapper {
  4710. box-shadow: none;
  4711. background: var(--bg);
  4712. border-radius: var(--radius-sm);
  4713. &:hover, &.is-focus {
  4714. background: var(--white);
  4715. box-shadow: 0 0 0 1px var(--primary);
  4716. }
  4717. }
  4718. }
  4719. }
  4720. .elements-pagination {
  4721. display: flex;
  4722. justify-content: flex-end;
  4723. padding: 12px 20px;
  4724. border-top: 1px solid var(--border);
  4725. }
  4726. }
  4727. // ==========================================
  4728. // 新建报告对话框样式
  4729. // ==========================================
  4730. .new-report-dialog {
  4731. :deep(.el-dialog__header) {
  4732. padding: 16px 20px;
  4733. border-bottom: 1px solid var(--border);
  4734. margin-right: 0;
  4735. }
  4736. :deep(.el-dialog__body) {
  4737. padding: 20px;
  4738. }
  4739. :deep(.el-dialog__footer) {
  4740. padding: 12px 20px;
  4741. border-top: 1px solid var(--border);
  4742. }
  4743. }
  4744. .new-report-form {
  4745. .section-label {
  4746. font-size: 13px;
  4747. font-weight: 500;
  4748. color: var(--text-2);
  4749. margin-bottom: 10px;
  4750. }
  4751. .create-type-section {
  4752. margin-bottom: 20px;
  4753. }
  4754. .create-type-options {
  4755. display: flex;
  4756. gap: 12px;
  4757. }
  4758. .type-option {
  4759. flex: 1;
  4760. display: flex;
  4761. align-items: flex-start;
  4762. gap: 12px;
  4763. padding: 14px;
  4764. background: var(--bg);
  4765. border: 2px solid var(--border);
  4766. border-radius: var(--radius-md);
  4767. cursor: pointer;
  4768. transition: all 0.2s;
  4769. position: relative;
  4770. &:hover {
  4771. border-color: var(--primary-light);
  4772. background: var(--white);
  4773. }
  4774. &.active {
  4775. border-color: var(--primary);
  4776. background: var(--primary-light);
  4777. .option-title {
  4778. color: var(--primary);
  4779. }
  4780. }
  4781. .option-icon {
  4782. font-size: 24px;
  4783. flex-shrink: 0;
  4784. line-height: 1;
  4785. }
  4786. .option-content {
  4787. flex: 1;
  4788. min-width: 0;
  4789. }
  4790. .option-title {
  4791. font-size: 14px;
  4792. font-weight: 600;
  4793. color: var(--text-1);
  4794. margin-bottom: 4px;
  4795. }
  4796. .option-desc {
  4797. font-size: 12px;
  4798. color: var(--text-3);
  4799. line-height: 1.4;
  4800. }
  4801. .option-check {
  4802. position: absolute;
  4803. top: 8px;
  4804. right: 8px;
  4805. width: 20px;
  4806. height: 20px;
  4807. background: var(--primary);
  4808. color: white;
  4809. border-radius: 50%;
  4810. display: flex;
  4811. align-items: center;
  4812. justify-content: center;
  4813. font-size: 12px;
  4814. font-weight: bold;
  4815. }
  4816. }
  4817. .name-input-section {
  4818. margin-bottom: 20px;
  4819. :deep(.el-input__wrapper) {
  4820. border-radius: var(--radius-sm);
  4821. }
  4822. }
  4823. .upload-section {
  4824. .report-upload-area {
  4825. :deep(.el-upload) {
  4826. width: 100%;
  4827. }
  4828. :deep(.el-upload-dragger) {
  4829. width: 100%;
  4830. height: auto;
  4831. padding: 24px;
  4832. border-radius: var(--radius-md);
  4833. border: 2px dashed var(--border);
  4834. background: var(--bg);
  4835. &:hover {
  4836. border-color: var(--primary);
  4837. background: var(--primary-light);
  4838. }
  4839. }
  4840. .upload-content {
  4841. text-align: center;
  4842. }
  4843. .upload-icon {
  4844. font-size: 32px;
  4845. color: var(--text-3);
  4846. margin-bottom: 8px;
  4847. }
  4848. .upload-text {
  4849. font-size: 14px;
  4850. color: var(--text-2);
  4851. margin-bottom: 4px;
  4852. em {
  4853. color: var(--primary);
  4854. font-style: normal;
  4855. }
  4856. }
  4857. .upload-hint {
  4858. font-size: 12px;
  4859. color: var(--text-3);
  4860. }
  4861. }
  4862. }
  4863. }
  4864. // ==========================================
  4865. // 要素视图
  4866. // ==========================================
  4867. .elements-view {
  4868. padding: 24px;
  4869. .elements-grid {
  4870. display: grid;
  4871. grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
  4872. gap: 16px;
  4873. }
  4874. .element-card {
  4875. background: var(--white);
  4876. border: 1px solid var(--border);
  4877. border-radius: var(--radius-md);
  4878. overflow: hidden;
  4879. transition: all 0.2s;
  4880. &:hover {
  4881. border-color: var(--primary);
  4882. box-shadow: var(--shadow-sm);
  4883. }
  4884. .element-card-header {
  4885. display: flex;
  4886. align-items: center;
  4887. justify-content: space-between;
  4888. padding: 12px 16px;
  4889. background: var(--bg);
  4890. border-bottom: 1px solid var(--border);
  4891. .element-label {
  4892. font-weight: 600;
  4893. font-size: 14px;
  4894. color: var(--text-1);
  4895. }
  4896. }
  4897. .element-card-body {
  4898. padding: 12px 16px;
  4899. .element-value-row {
  4900. margin-bottom: 8px;
  4901. .value-meta {
  4902. margin-top: 4px;
  4903. font-size: 11px;
  4904. color: var(--text-3);
  4905. .original-label {
  4906. margin-right: 4px;
  4907. }
  4908. .original-value {
  4909. color: var(--text-2);
  4910. }
  4911. }
  4912. .value-status {
  4913. margin-top: 4px;
  4914. }
  4915. }
  4916. .element-empty {
  4917. padding: 16px 0;
  4918. text-align: center;
  4919. .empty-hint {
  4920. font-size: 12px;
  4921. color: var(--text-3);
  4922. }
  4923. }
  4924. }
  4925. }
  4926. .elements-empty {
  4927. padding: 60px 20px;
  4928. text-align: center;
  4929. }
  4930. }
  4931. // ==========================================
  4932. // 实体视图
  4933. // ==========================================
  4934. .entities-view {
  4935. padding: 24px;
  4936. .entity-filter-bar {
  4937. display: flex;
  4938. align-items: center;
  4939. margin-bottom: 16px;
  4940. }
  4941. .entities-empty {
  4942. padding: 60px 20px;
  4943. text-align: center;
  4944. }
  4945. }
  4946. // ==========================================
  4947. // 项目概览统计
  4948. // ==========================================
  4949. .overview-stats {
  4950. display: grid;
  4951. grid-template-columns: repeat(3, 1fr);
  4952. gap: 12px;
  4953. .stat-item {
  4954. display: flex;
  4955. flex-direction: column;
  4956. align-items: center;
  4957. padding: 12px 8px;
  4958. background: var(--bg);
  4959. border-radius: var(--radius-sm);
  4960. .stat-label {
  4961. font-size: 11px;
  4962. color: var(--text-3);
  4963. margin-bottom: 4px;
  4964. }
  4965. .stat-value {
  4966. font-size: 20px;
  4967. font-weight: 700;
  4968. color: var(--text-1);
  4969. &.filled {
  4970. color: var(--success);
  4971. }
  4972. }
  4973. }
  4974. }
  4975. // ==========================================
  4976. // 文档视图 - Word 排版还原 + 可编辑 + 要素高亮
  4977. // ==========================================
  4978. .document-view {
  4979. display: flex;
  4980. flex-direction: column;
  4981. align-items: center;
  4982. padding: 20px;
  4983. background: #e8e8e8;
  4984. min-height: 100%;
  4985. position: relative;
  4986. .doc-toolbar {
  4987. display: flex;
  4988. align-items: center;
  4989. justify-content: space-between;
  4990. gap: 12px;
  4991. margin-bottom: 16px;
  4992. padding: 8px 16px;
  4993. background: var(--white);
  4994. border-radius: var(--radius-md);
  4995. box-shadow: var(--shadow-sm);
  4996. width: 100%;
  4997. max-width: 820px;
  4998. .doc-toolbar-left, .doc-toolbar-right {
  4999. display: flex;
  5000. align-items: center;
  5001. gap: 8px;
  5002. }
  5003. .doc-title-label {
  5004. font-size: 14px;
  5005. font-weight: 600;
  5006. color: var(--text-1);
  5007. white-space: nowrap;
  5008. }
  5009. }
  5010. .doc-paper {
  5011. background: #fff;
  5012. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  5013. border-radius: 2px;
  5014. width: 100%;
  5015. max-width: 820px;
  5016. min-height: 1100px;
  5017. padding: 96px 90px;
  5018. font-family: 'Times New Roman', 'SimSun', '宋体', serif;
  5019. font-size: 12pt;
  5020. line-height: 1.6;
  5021. color: #000;
  5022. word-wrap: break-word;
  5023. overflow-wrap: break-word;
  5024. }
  5025. :deep(.doc-block) {
  5026. margin: 0;
  5027. padding: 0;
  5028. min-height: 1em;
  5029. }
  5030. // 标题样式
  5031. :deep(h1.doc-block) {
  5032. font-size: 18pt;
  5033. font-weight: bold;
  5034. margin: 16px 0 10px;
  5035. line-height: 1.4;
  5036. }
  5037. :deep(h2.doc-block) {
  5038. font-size: 16pt;
  5039. font-weight: bold;
  5040. margin: 14px 0 8px;
  5041. line-height: 1.4;
  5042. }
  5043. :deep(h3.doc-block) {
  5044. font-size: 14pt;
  5045. font-weight: bold;
  5046. margin: 12px 0 6px;
  5047. line-height: 1.4;
  5048. }
  5049. // 段落
  5050. :deep(p.doc-block) {
  5051. margin: 0 0 2px;
  5052. text-align: justify;
  5053. }
  5054. // 目录
  5055. :deep(.doc-toc1), :deep(.doc-toc2), :deep(.doc-toc3) {
  5056. position: relative;
  5057. font-size: 13pt;
  5058. margin: 0;
  5059. padding: 8px 16px;
  5060. cursor: pointer;
  5061. border-left: 3px solid transparent;
  5062. transition: background 0.15s, border-color 0.15s;
  5063. &:hover {
  5064. background: #f0f7ff;
  5065. border-left-color: #1890ff;
  5066. }
  5067. }
  5068. :deep(.doc-toc1) {
  5069. font-weight: 600;
  5070. color: #1f2937;
  5071. }
  5072. :deep(.doc-toc2) {
  5073. padding-left: 36px;
  5074. font-size: 12pt;
  5075. color: #374151;
  5076. }
  5077. :deep(.doc-toc3) {
  5078. padding-left: 56px;
  5079. font-size: 11pt;
  5080. color: #6b7280;
  5081. }
  5082. // 空段落
  5083. :deep(p.doc-paragraph:empty::after),
  5084. :deep(p.doc-block:empty::after) {
  5085. content: '\00a0';
  5086. }
  5087. // 要素模板 {{key}} 标签
  5088. :deep(.elem-tpl-tag) {
  5089. display: inline;
  5090. background: #fff7e6;
  5091. color: #d46b08;
  5092. border: 1.5px solid #ffc069;
  5093. border-radius: 3px;
  5094. padding: 1px 6px;
  5095. font-family: 'Consolas', 'Monaco', monospace;
  5096. font-size: 0.9em;
  5097. font-weight: 600;
  5098. white-space: nowrap;
  5099. cursor: default;
  5100. user-select: all;
  5101. &:hover {
  5102. background: #ffe7ba;
  5103. border-color: #fa8c16;
  5104. }
  5105. }
  5106. :deep(.elem-tpl-block) {
  5107. margin: 8px 0;
  5108. padding: 10px 14px;
  5109. background: #fffbe6;
  5110. border: 1.5px dashed #faad14;
  5111. border-radius: 6px;
  5112. text-align: center;
  5113. .elem-tpl-tag {
  5114. font-size: 1em;
  5115. padding: 2px 10px;
  5116. }
  5117. }
  5118. // 内联图片
  5119. :deep(.doc-inline-image) {
  5120. max-width: 100%;
  5121. height: auto;
  5122. display: block;
  5123. margin: 8px auto;
  5124. }
  5125. // 表格
  5126. :deep(.doc-table) {
  5127. width: 100%;
  5128. border-collapse: separate;
  5129. border-spacing: 0;
  5130. margin: 16px 0;
  5131. font-size: 10.5pt;
  5132. border: 1px solid #d0d5dd;
  5133. border-radius: 8px;
  5134. overflow: hidden;
  5135. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
  5136. .doc-table-cell {
  5137. border: 1px solid #e4e7ec;
  5138. border-top: none;
  5139. border-left: none;
  5140. padding: 10px 14px;
  5141. vertical-align: middle;
  5142. line-height: 1.6;
  5143. color: #344054;
  5144. transition: background 0.15s;
  5145. }
  5146. // 右边缘单元格去掉右边框
  5147. tr .doc-table-cell:last-child {
  5148. border-right: none;
  5149. }
  5150. // 表头行
  5151. tr:first-child .doc-table-cell {
  5152. font-weight: 700;
  5153. font-size: 10pt;
  5154. background: linear-gradient(180deg, #f8fafc 0%, #edf2f7 100%);
  5155. color: #1a202c;
  5156. border-bottom: 2px solid #cbd5e1;
  5157. letter-spacing: 0.5px;
  5158. text-align: center;
  5159. padding: 12px 14px;
  5160. }
  5161. // 斑马纹
  5162. tr:not(:first-child):nth-child(even) .doc-table-cell {
  5163. background: #f9fafb;
  5164. }
  5165. tr:not(:first-child):nth-child(odd) .doc-table-cell {
  5166. background: #fff;
  5167. }
  5168. // 行悬停
  5169. tr:not(:first-child):hover .doc-table-cell {
  5170. background: #e8f4ff;
  5171. }
  5172. }
  5173. // 高亮包裹内的表格特殊处理
  5174. :deep(.elem-highlight-wrap .doc-table) {
  5175. margin: 0;
  5176. box-shadow: none;
  5177. border-color: transparent;
  5178. }
  5179. // 空状态 & 加载
  5180. .doc-empty, .doc-loading {
  5181. display: flex;
  5182. flex-direction: column;
  5183. align-items: center;
  5184. justify-content: center;
  5185. padding: 80px 20px;
  5186. width: 100%;
  5187. max-width: 820px;
  5188. background: var(--white);
  5189. border-radius: var(--radius-md);
  5190. box-shadow: var(--shadow-sm);
  5191. }
  5192. .doc-loading {
  5193. flex-direction: row;
  5194. gap: 12px;
  5195. padding: 40px;
  5196. font-size: 14px;
  5197. color: var(--text-2);
  5198. }
  5199. }
  5200. // ==========================================
  5201. // 要素高亮弹出框
  5202. // ==========================================
  5203. .element-popover {
  5204. position: absolute;
  5205. z-index: 1000;
  5206. background: #fff;
  5207. border: 1px solid var(--border);
  5208. border-radius: var(--radius-md);
  5209. box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
  5210. width: 400px;
  5211. overflow: hidden;
  5212. .popover-header {
  5213. display: flex;
  5214. align-items: center;
  5215. justify-content: space-between;
  5216. padding: 10px 14px;
  5217. background: var(--bg);
  5218. border-bottom: 1px solid var(--border);
  5219. .popover-label {
  5220. font-weight: 600;
  5221. font-size: 14px;
  5222. color: var(--text-1);
  5223. }
  5224. }
  5225. .popover-body {
  5226. padding: 12px 14px;
  5227. .popover-field {
  5228. margin-bottom: 10px;
  5229. &:last-child {
  5230. margin-bottom: 0;
  5231. }
  5232. .popover-field-label {
  5233. display: block;
  5234. font-size: 12px;
  5235. color: var(--text-3);
  5236. margin-bottom: 4px;
  5237. }
  5238. .popover-original {
  5239. font-size: 13px;
  5240. color: var(--text-2);
  5241. background: var(--bg);
  5242. padding: 4px 8px;
  5243. border-radius: var(--radius-sm);
  5244. display: inline-block;
  5245. }
  5246. }
  5247. }
  5248. .popover-rules {
  5249. margin-top: 10px;
  5250. border-top: 1px dashed var(--border);
  5251. padding-top: 10px;
  5252. .rule-trace-card {
  5253. display: flex;
  5254. align-items: flex-start;
  5255. gap: 8px;
  5256. padding: 6px 8px;
  5257. background: var(--bg);
  5258. border-radius: var(--radius-sm);
  5259. border: 1px solid var(--border);
  5260. margin-bottom: 6px;
  5261. &:last-child { margin-bottom: 0; }
  5262. .rule-trace-action {
  5263. flex-shrink: 0;
  5264. font-size: 10px;
  5265. font-weight: 600;
  5266. padding: 2px 6px;
  5267. border-radius: 10px;
  5268. line-height: 18px;
  5269. &.action-quote { background: #e6f4ff; color: #1677ff; }
  5270. &.action-summary { background: #f6ffed; color: #52c41a; }
  5271. &.action-ai_extract { background: #e6fffb; color: #13c2c2; }
  5272. &.action-table_extract { background: #fff7e6; color: #fa8c16; }
  5273. &.action-use_entity_value { background: #f0f0f0; color: #666; }
  5274. }
  5275. .rule-trace-info {
  5276. flex: 1;
  5277. min-width: 0;
  5278. .rule-trace-name {
  5279. font-size: 12px;
  5280. color: var(--text-1);
  5281. white-space: nowrap;
  5282. overflow: hidden;
  5283. text-overflow: ellipsis;
  5284. }
  5285. .rule-trace-sources {
  5286. margin-top: 3px;
  5287. display: flex;
  5288. flex-direction: column;
  5289. gap: 2px;
  5290. .rule-trace-att {
  5291. font-size: 11px;
  5292. color: var(--text-3);
  5293. word-break: break-all;
  5294. }
  5295. }
  5296. .rule-trace-excerpt {
  5297. margin-top: 4px;
  5298. padding: 4px 6px;
  5299. background: #fafafa;
  5300. border-left: 2px solid #d9d9d9;
  5301. border-radius: 2px;
  5302. .rule-trace-excerpt-text {
  5303. font-size: 11px;
  5304. color: var(--text-2);
  5305. line-height: 1.5;
  5306. display: -webkit-box;
  5307. -webkit-line-clamp: 3;
  5308. -webkit-box-orient: vertical;
  5309. overflow: hidden;
  5310. word-break: break-all;
  5311. }
  5312. }
  5313. }
  5314. }
  5315. }
  5316. .popover-footer {
  5317. display: flex;
  5318. justify-content: flex-end;
  5319. gap: 8px;
  5320. padding: 8px 14px;
  5321. border-top: 1px solid var(--border);
  5322. background: var(--bg);
  5323. }
  5324. }
  5325. // 可编辑文档纸张的光标和选区样式
  5326. .doc-paper[contenteditable="true"] {
  5327. outline: none;
  5328. cursor: text;
  5329. &:focus {
  5330. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15), 0 0 0 2px rgba(24, 144, 255, 0.2);
  5331. }
  5332. }
  5333. // 高亮边框样式
  5334. .elem-highlight {
  5335. transition: border-color 0.2s, box-shadow 0.2s;
  5336. &:hover {
  5337. box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
  5338. border-color: #1890ff !important;
  5339. }
  5340. }
  5341. .elem-highlight-long {
  5342. display: inline !important;
  5343. transition: border-color 0.2s, box-shadow 0.2s;
  5344. &:hover {
  5345. box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
  5346. border-color: #1890ff !important;
  5347. }
  5348. }
  5349. .elem-highlight-wrap {
  5350. position: relative;
  5351. transition: border-color 0.2s, box-shadow 0.2s;
  5352. &:hover {
  5353. border-color: #1890ff !important;
  5354. box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
  5355. }
  5356. .doc-table {
  5357. margin: 0 auto;
  5358. }
  5359. }
  5360. // 静态要素淡色高亮
  5361. .elem-highlight-static {
  5362. opacity: 0.6;
  5363. transition: opacity 0.2s, border-color 0.2s;
  5364. &:hover {
  5365. opacity: 1;
  5366. border-color: #999 !important;
  5367. box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
  5368. }
  5369. }
  5370. </style>
  5371. <style lang="scss">
  5372. // ==========================================
  5373. // 附件/规则居中悬浮弹窗样式
  5374. // ==========================================
  5375. .floating-panel-dialog {
  5376. .el-dialog__header {
  5377. padding: 16px 20px 12px;
  5378. border-bottom: 1px solid var(--border);
  5379. margin-right: 0;
  5380. .el-dialog__title {
  5381. font-size: 15px;
  5382. font-weight: 700;
  5383. }
  5384. }
  5385. .el-dialog__body {
  5386. padding: 0;
  5387. max-height: 60vh;
  5388. overflow-y: auto;
  5389. }
  5390. .fp-toolbar {
  5391. display: flex;
  5392. align-items: center;
  5393. gap: 8px;
  5394. padding: 12px 20px;
  5395. border-bottom: 1px solid var(--border);
  5396. background: #fafbfc;
  5397. .fp-count {
  5398. margin-left: auto;
  5399. font-size: 12px;
  5400. color: #999;
  5401. }
  5402. }
  5403. .fp-list {
  5404. padding: 8px 12px;
  5405. }
  5406. &.rule-manage-dialog .fp-list {
  5407. max-height: 480px;
  5408. overflow-y: auto;
  5409. }
  5410. // ---- 附件项 ----
  5411. .fp-att-item {
  5412. display: flex;
  5413. align-items: center;
  5414. gap: 10px;
  5415. padding: 10px 12px;
  5416. border-radius: 8px;
  5417. cursor: pointer;
  5418. transition: background 0.15s;
  5419. &:hover {
  5420. background: #f5f7fa;
  5421. }
  5422. &.active {
  5423. background: #e8f4ff;
  5424. }
  5425. .att-icon {
  5426. width: 36px;
  5427. height: 36px;
  5428. border-radius: 6px;
  5429. display: flex;
  5430. align-items: center;
  5431. justify-content: center;
  5432. flex-shrink: 0;
  5433. font-size: 11px;
  5434. font-weight: 700;
  5435. color: #fff;
  5436. background: #1890ff;
  5437. &.pdf { background: #ff4d4f; }
  5438. &.doc, &.docx { background: #1890ff; }
  5439. &.xls, &.xlsx { background: #52c41a; }
  5440. &.img { background: #722ed1; }
  5441. &.zip { background: #fa8c16; }
  5442. }
  5443. .att-info {
  5444. flex: 1;
  5445. min-width: 0;
  5446. .att-name {
  5447. font-size: 13px;
  5448. font-weight: 500;
  5449. color: #1f2937;
  5450. white-space: nowrap;
  5451. overflow: hidden;
  5452. text-overflow: ellipsis;
  5453. }
  5454. .att-meta {
  5455. display: flex;
  5456. gap: 8px;
  5457. margin-top: 2px;
  5458. font-size: 11px;
  5459. color: #999;
  5460. }
  5461. }
  5462. }
  5463. // ---- 解析结果弹窗 ----
  5464. &.parse-result-dialog .el-dialog__body {
  5465. max-height: 85vh;
  5466. }
  5467. .parse-result-toolbar {
  5468. display: flex;
  5469. align-items: center;
  5470. justify-content: space-between;
  5471. padding: 10px 20px;
  5472. border-bottom: 1px solid var(--border);
  5473. background: #fafbfc;
  5474. .parse-result-info {
  5475. font-size: 12px;
  5476. color: #999;
  5477. }
  5478. .parse-result-actions {
  5479. display: flex;
  5480. gap: 6px;
  5481. }
  5482. }
  5483. .parse-result-content {
  5484. padding: 16px 20px;
  5485. max-height: calc(85vh - 120px);
  5486. overflow-y: auto;
  5487. .parse-result-pre {
  5488. margin: 0;
  5489. padding: 0;
  5490. font-family: 'SF Mono', 'Consolas', 'Monaco', 'Menlo', monospace;
  5491. font-size: 13px;
  5492. line-height: 1.7;
  5493. color: #1f2937;
  5494. white-space: pre-wrap;
  5495. word-wrap: break-word;
  5496. }
  5497. .parse-result-rendered {
  5498. font-size: 14px;
  5499. line-height: 1.8;
  5500. color: #1f2937;
  5501. h1 { font-size: 20px; font-weight: 700; margin: 20px 0 12px; padding-bottom: 6px; border-bottom: 1px solid #eee; }
  5502. h2 { font-size: 18px; font-weight: 700; margin: 18px 0 10px; }
  5503. h3 { font-size: 16px; font-weight: 600; margin: 14px 0 8px; }
  5504. h4, h5, h6 { font-size: 14px; font-weight: 600; margin: 10px 0 6px; }
  5505. p { margin: 8px 0; text-align: justify; }
  5506. img {
  5507. max-width: 100%;
  5508. height: auto;
  5509. display: block;
  5510. margin: 12px auto;
  5511. border-radius: 6px;
  5512. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  5513. }
  5514. table {
  5515. width: 100%;
  5516. border-collapse: separate;
  5517. border-spacing: 0;
  5518. margin: 12px 0;
  5519. border: 1px solid #d0d5dd;
  5520. border-radius: 6px;
  5521. overflow: hidden;
  5522. font-size: 13px;
  5523. th, td {
  5524. border: 1px solid #e4e7ec;
  5525. padding: 8px 12px;
  5526. vertical-align: top;
  5527. line-height: 1.5;
  5528. }
  5529. th {
  5530. background: linear-gradient(180deg, #f8fafc, #edf2f7);
  5531. font-weight: 600;
  5532. text-align: center;
  5533. }
  5534. tr:nth-child(even) td { background: #f9fafb; }
  5535. tr:hover td { background: #e8f4ff; }
  5536. }
  5537. ol, ul { padding-left: 24px; margin: 8px 0; }
  5538. li { margin: 4px 0; }
  5539. blockquote {
  5540. margin: 12px 0;
  5541. padding: 10px 16px;
  5542. border-left: 4px solid #1890ff;
  5543. background: #f0f7ff;
  5544. color: #374151;
  5545. }
  5546. code {
  5547. background: #f3f4f6;
  5548. padding: 1px 4px;
  5549. border-radius: 3px;
  5550. font-size: 0.9em;
  5551. font-family: 'SF Mono', 'Consolas', monospace;
  5552. }
  5553. pre {
  5554. background: #1f2937;
  5555. color: #e5e7eb;
  5556. padding: 14px 18px;
  5557. border-radius: 6px;
  5558. overflow-x: auto;
  5559. margin: 12px 0;
  5560. code {
  5561. background: none;
  5562. padding: 0;
  5563. color: inherit;
  5564. }
  5565. }
  5566. hr { border: none; border-top: 1px solid #e5e7eb; margin: 16px 0; }
  5567. }
  5568. }
  5569. // ---- 引用模式提示条 ----
  5570. .citation-mode-banner {
  5571. display: flex;
  5572. align-items: center;
  5573. justify-content: space-between;
  5574. padding: 8px 16px;
  5575. background: linear-gradient(90deg, #e8f5e9, #f1f8e9);
  5576. border-bottom: 1px solid #c8e6c9;
  5577. font-size: 13px;
  5578. color: #2e7d32;
  5579. }
  5580. // ---- 浮动引用工具栏 ----
  5581. .citation-toolbar {
  5582. position: absolute;
  5583. z-index: 3000;
  5584. background: #fff;
  5585. border: 1px solid #d0d5dd;
  5586. border-radius: 10px;
  5587. box-shadow: 0 6px 20px rgba(0,0,0,0.15);
  5588. padding: 10px 14px;
  5589. min-width: 240px;
  5590. max-width: 320px;
  5591. .citation-toolbar-title {
  5592. font-size: 12px;
  5593. color: #666;
  5594. margin-bottom: 8px;
  5595. font-weight: 500;
  5596. }
  5597. .citation-toolbar-actions {
  5598. display: flex;
  5599. gap: 6px;
  5600. flex-wrap: wrap;
  5601. }
  5602. .citation-toolbar-elements {
  5603. max-height: 200px;
  5604. overflow-y: auto;
  5605. }
  5606. .citation-element-item {
  5607. display: flex;
  5608. align-items: center;
  5609. justify-content: space-between;
  5610. padding: 6px 8px;
  5611. border-radius: 6px;
  5612. cursor: pointer;
  5613. transition: background 0.15s;
  5614. font-size: 13px;
  5615. &:hover {
  5616. background: #f0f7ff;
  5617. }
  5618. .citation-elem-name {
  5619. flex: 1;
  5620. min-width: 0;
  5621. overflow: hidden;
  5622. text-overflow: ellipsis;
  5623. white-space: nowrap;
  5624. color: #1f2937;
  5625. }
  5626. }
  5627. }
  5628. // ---- 规则筛选栏 ----
  5629. .rule-filter-bar {
  5630. display: flex;
  5631. gap: 4px;
  5632. padding: 0 16px 10px;
  5633. border-bottom: 1px solid #f0f0f0;
  5634. flex-wrap: wrap;
  5635. .rule-filter-tab {
  5636. font-size: 12px;
  5637. padding: 4px 10px;
  5638. border-radius: 14px;
  5639. cursor: pointer;
  5640. color: #666;
  5641. background: #f5f7fa;
  5642. transition: all 0.2s;
  5643. user-select: none;
  5644. em {
  5645. font-style: normal;
  5646. font-size: 11px;
  5647. opacity: 0.7;
  5648. margin-left: 2px;
  5649. }
  5650. &:hover { background: #e8eaed; }
  5651. &.active { background: #409eff; color: #fff; }
  5652. &.active em { opacity: 0.9; }
  5653. &.tab-summary.active { background: #52c41a; }
  5654. &.tab-ai_extract.active { background: #13c2c2; }
  5655. &.tab-table_extract.active { background: #fa8c16; }
  5656. &.tab-quote.active { background: #1677ff; }
  5657. &.tab-use_entity_value.active { background: #8c8c8c; }
  5658. }
  5659. }
  5660. // ---- 规则项 ----
  5661. .fp-rule-item {
  5662. border-radius: 8px;
  5663. transition: background 0.15s;
  5664. cursor: pointer;
  5665. &:hover { background: #f5f7fa; }
  5666. &.expanded { background: #fafbfc; }
  5667. .rule-item-main {
  5668. display: flex;
  5669. align-items: center;
  5670. gap: 10px;
  5671. padding: 10px 12px;
  5672. }
  5673. .rule-action-badge {
  5674. flex-shrink: 0;
  5675. font-size: 11px;
  5676. font-weight: 600;
  5677. padding: 3px 8px;
  5678. border-radius: 12px;
  5679. line-height: 16px;
  5680. white-space: nowrap;
  5681. &.action-quote { background: #e6f4ff; color: #1677ff; }
  5682. &.action-summary { background: #f6ffed; color: #52c41a; }
  5683. &.action-ai_extract { background: #e6fffb; color: #13c2c2; }
  5684. &.action-table_extract { background: #fff7e6; color: #fa8c16; }
  5685. &.action-use_entity_value { background: #f0f0f0; color: #666; }
  5686. }
  5687. .rule-info {
  5688. flex: 1;
  5689. min-width: 0;
  5690. .rule-name-row {
  5691. display: flex;
  5692. align-items: center;
  5693. gap: 6px;
  5694. flex-wrap: wrap;
  5695. .rule-name {
  5696. font-size: 13px;
  5697. font-weight: 500;
  5698. color: #1f2937;
  5699. }
  5700. .rule-elem-key {
  5701. font-size: 10px;
  5702. max-width: 200px;
  5703. overflow: hidden;
  5704. text-overflow: ellipsis;
  5705. }
  5706. }
  5707. .rule-desc {
  5708. font-size: 11px;
  5709. color: #999;
  5710. margin-top: 2px;
  5711. overflow: hidden;
  5712. text-overflow: ellipsis;
  5713. white-space: nowrap;
  5714. }
  5715. }
  5716. .rule-actions {
  5717. display: flex;
  5718. align-items: center;
  5719. gap: 2px;
  5720. flex-shrink: 0;
  5721. }
  5722. // 展开详情区域
  5723. .rule-detail {
  5724. padding: 0 12px 10px 12px;
  5725. border-top: 1px dashed #e8e8e8;
  5726. margin: 0 12px;
  5727. .rule-detail-row {
  5728. display: flex;
  5729. align-items: flex-start;
  5730. gap: 8px;
  5731. padding: 6px 0;
  5732. font-size: 12px;
  5733. &:not(:last-child) {
  5734. border-bottom: 1px solid #f5f5f5;
  5735. }
  5736. }
  5737. .rule-detail-label {
  5738. flex-shrink: 0;
  5739. font-weight: 600;
  5740. color: #666;
  5741. width: 60px;
  5742. text-align: right;
  5743. }
  5744. .rule-detail-value {
  5745. color: #333;
  5746. line-height: 1.5;
  5747. word-break: break-all;
  5748. }
  5749. .rule-detail-inputs {
  5750. display: flex;
  5751. flex-wrap: wrap;
  5752. gap: 4px;
  5753. }
  5754. .rule-input-chip {
  5755. font-size: 11px;
  5756. padding: 2px 8px;
  5757. background: #f0f5ff;
  5758. border-radius: 10px;
  5759. color: #1677ff;
  5760. white-space: nowrap;
  5761. }
  5762. .rule-detail-time {
  5763. font-size: 11px;
  5764. color: #999;
  5765. margin-left: 4px;
  5766. }
  5767. .rule-detail-error {
  5768. color: #ff4d4f;
  5769. line-height: 1.4;
  5770. }
  5771. }
  5772. }
  5773. }
  5774. </style>