| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468 |
- <template>
- <div class="editor-page">
- <div class="editor-body">
- <!-- 左侧面板 -->
- <div class="left-panel" :style="{ width: leftPanelWidth + 'px' }">
- <!-- 顶部 Logo -->
- <div class="sidebar-header">
- <div class="sidebar-logo">
- <span class="logo-icon">✦</span>
- <span class="logo-text">灵越智报</span>
- </div>
- <div class="sidebar-header-actions">
- <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>
- </div>
- </div>
- <!-- 快捷操作 -->
- <div class="sidebar-nav">
- <div class="nav-item" @click="showNewProjectDialog = true">
- <span class="nav-icon">📝</span>
- <span class="nav-label">新建报告</span>
- </div>
- <div class="nav-item" @click="showAttachmentDialog = true">
- <span class="nav-icon">📚</span>
- <span class="nav-label">知识库</span>
- </div>
- </div>
- <!-- 我的文档 -->
- <div class="sidebar-section">
- <div class="section-header">
- <span class="section-title">我的文档 · {{ projects.length }}</span>
- <span class="section-action" @click="leftPanelTab = 'projects'">全部 ›</span>
- </div>
- <!-- 搜索框 -->
- <el-input
- v-if="projects.length > 5"
- v-model="projectSearchKeyword"
- placeholder="搜索文档..."
- :prefix-icon="Search"
- clearable
- class="sidebar-search"
- size="small"
- />
- <div class="doc-list" v-if="filteredProjects.length > 0">
- <div
- v-for="project in filteredProjects.slice(0, sidebarShowAll ? undefined : 5)"
- :key="project.id"
- class="doc-item"
- :class="{ active: currentProjectId === project.id }"
- @click="switchProject(project)"
- >
- <div class="doc-icon-wrap">
- <span class="doc-icon-glyph">📋</span>
- </div>
- <div class="doc-item-body">
- <div class="doc-item-title">{{ project.title }}</div>
- <div class="doc-item-meta">
- <el-tag
- size="small"
- :type="project.status === 'archived' ? 'success' : project.status === 'editing' ? '' : 'warning'"
- effect="plain"
- class="doc-status-tag"
- >{{ getStatusText(project.status) }}</el-tag>
- <span class="doc-item-date">{{ formatTime(project.updatedAt || project.createdAt) }}</span>
- <span class="doc-item-author">· {{ project.createdBy || userName }}</span>
- </div>
- </div>
- <el-dropdown trigger="click" @command="(cmd) => handleProjectCommand(cmd, project)" @click.stop>
- <el-button class="doc-more-btn" text size="small" @click.stop>
- <el-icon><MoreFilled /></el-icon>
- </el-button>
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item command="copy">复制项目</el-dropdown-item>
- <el-dropdown-item command="archive">归档</el-dropdown-item>
- <el-dropdown-item command="delete" divided>删除</el-dropdown-item>
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- </div>
- </div>
- <div class="doc-empty" v-else-if="!loadingProjects">
- <span class="empty-text">{{ projectSearchKeyword ? '未找到匹配的文档' : '暂无文档' }}</span>
- </div>
- <div class="doc-loading" v-if="loadingProjects">
- <el-icon class="is-loading"><Loading /></el-icon>
- <span>加载中...</span>
- </div>
- </div>
- <!-- 最近操作 -->
- <div class="sidebar-section sidebar-activity">
- <div class="section-header">
- <span class="section-title">最近操作</span>
- <span class="section-action" title="筛选">▽</span>
- </div>
- <div class="activity-list">
- <div class="activity-item" v-for="(act, idx) in recentActivities" :key="idx">
- <div class="activity-text">{{ act.text }}</div>
- <div class="activity-meta">
- <span class="activity-source">{{ act.source }}</span>
- <span class="activity-time">{{ act.time }}</span>
- </div>
- </div>
- <div class="activity-empty" v-if="recentActivities.length === 0">
- <span>暂无最近操作</span>
- </div>
- </div>
- </div>
- <!-- 底部用户信息 -->
- <div class="sidebar-footer">
- <div class="user-avatar">{{ userName.charAt(0) }}</div>
- <div class="user-info">
- <span class="user-name">{{ userName }}</span>
- <span class="user-role">项目经理</span>
- </div>
- <div class="footer-actions">
- <el-badge :value="taskRunningCount" :hidden="taskRunningCount === 0" :max="99" class="notification-badge">
- <el-button text circle size="small" title="任务中心" @click="taskCenterStore.toggleOpen()"><el-icon><List /></el-icon></el-button>
- </el-badge>
- <el-badge :value="3" :max="99" class="notification-badge">
- <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>
- </el-badge>
- <el-dropdown trigger="click" @command="handleFooterCommand">
- <el-button text circle size="small" title="设置"><el-icon><MoreFilled /></el-icon></el-button>
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item command="settings">系统设置</el-dropdown-item>
- <el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- </div>
- </div>
- </div>
- <!-- 左侧拖拽分隔条 -->
- <div class="resize-handle left-resize" @mousedown="startResizeLeft"></div>
- <!-- 中间主区域 -->
- <div class="center-panel">
- <!-- 欢迎页 -->
- <div class="welcome-page" v-if="!hasActiveProject">
- <div class="welcome-content">
- <div class="welcome-logo">灵</div>
- <div class="welcome">
- <h1>{{ greetingText }},{{ userName }}!<span>智能报告,洞察未来。</span></h1>
- <p>今天是个创作的好日子,开始您的智能报告之旅吧</p>
- </div>
- </div>
- </div>
- <!-- 项目详情 -->
- <template v-else>
- <div class="editor-title-bar">
- <div class="titlebar-left">
- <el-icon class="titlebar-folder-icon"><Folder /></el-icon>
- <span class="titlebar-sep">/</span>
- <span class="titlebar-project-name" :title="projectTitle">{{ projectTitle || '未命名项目' }}</span>
- <el-tag size="small" type="info" class="titlebar-status-tag">草稿</el-tag>
- </div>
- <div class="titlebar-right">
- <span class="titlebar-save-status">
- <span class="save-dot" :class="{ saved: saved }"></span>
- {{ saved ? '已自动保存' : '保存中...' }}
- </span>
- <el-divider direction="vertical" />
- <el-button text circle size="small" :class="{ 'is-active-view': viewMode === 'document' }" title="文档视图" @click="viewMode = 'document'"><el-icon><Document /></el-icon></el-button>
- <el-button text circle size="small" :class="{ 'is-active-view': viewMode === 'elements' }" title="要素视图" @click="viewMode = 'elements'"><el-icon><Grid /></el-icon></el-button>
- <el-button text circle size="small" title="设置" @click="ElMessage.info('设置开发中...')"><el-icon><Setting /></el-icon></el-button>
- <el-dropdown trigger="click" @command="handleTitlebarCommand">
- <el-button text circle size="small" title="更多"><el-icon><MoreFilled /></el-icon></el-button>
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item command="save">💾 保存</el-dropdown-item>
- <el-dropdown-item command="copy">📋 复制项目</el-dropdown-item>
- <el-dropdown-item command="export" divided>📤 导出</el-dropdown-item>
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- <el-divider direction="vertical" />
- <el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><Paperclip /></el-icon></el-button>
- </div>
- </div>
- <div class="editor-scroll" ref="editorRef" v-loading="loading" element-loading-text="正在加载项目...">
- <!-- 文档视图 -->
- <div class="document-view" v-if="viewMode === 'document'">
- <!-- 文档渲染区域(可编辑) -->
- <div
- class="doc-paper"
- v-if="docHtml"
- :style="docPaperStyle"
- contenteditable="true"
- spellcheck="false"
- v-html="docHtml"
- @input="onDocInput"
- @mousedown="onDocClick"
- ref="docPaperRef"
- ></div>
- <!-- 无内容提示 -->
- <div class="doc-empty" v-else-if="!docLoading">
- <el-empty description="暂无文档内容" />
- </div>
- <div class="doc-loading" v-if="docLoading">
- <el-icon class="is-loading" :size="24"><Loading /></el-icon>
- <span>正在加载文档内容...</span>
- </div>
- </div>
- <!-- 要素高亮弹出框 -->
- <div
- v-if="highlightPopover.visible"
- class="element-popover"
- :style="{ top: highlightPopover.y + 'px', left: highlightPopover.x + 'px' }"
- @mousedown.stop
- >
- <div class="popover-header">
- <span class="popover-label">{{ highlightPopover.elementName }}</span>
- <el-tag size="small">{{ highlightPopover.elementKey }}</el-tag>
- </div>
- <div class="popover-body">
- <div class="popover-field">
- <span class="popover-field-label">当前值:</span>
- <el-input
- v-model="highlightPopover.currentValue"
- size="small"
- placeholder="输入要素值"
- @keyup.enter="savePopoverValue"
- />
- </div>
- <div class="popover-field" v-if="highlightPopover.originalValue">
- <span class="popover-field-label">原始值:</span>
- <span class="popover-original">{{ highlightPopover.originalValue }}</span>
- </div>
- <!-- 溯源卡片 -->
- <div class="popover-rules" v-if="popoverRelatedRules.length > 0">
- <span class="popover-field-label">来源规则:</span>
- <div
- v-for="rule in popoverRelatedRules"
- :key="rule.id"
- class="rule-trace-card"
- >
- <span class="rule-trace-action" :class="'action-' + rule.actionType">{{ ruleActionLabel(rule.actionType) }}</span>
- <div class="rule-trace-info">
- <div class="rule-trace-name">{{ rule.ruleName }}</div>
- <div v-if="rule.inputs && rule.inputs.length" class="rule-trace-sources">
- <span v-for="inp in rule.inputs" :key="inp.inputId" class="rule-trace-att">📎 {{ inp.sourceName }}</span>
- </div>
- <div v-if="ruleSourceText(rule)" class="rule-trace-excerpt">
- <span class="rule-trace-excerpt-text">{{ ruleSourceText(rule) }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="popover-footer">
- <el-button size="small" @click="highlightPopover.visible = false">关闭</el-button>
- <el-button size="small" @click="enterReferenceModeFromPopover">📎 从附件引用</el-button>
- <el-button size="small" type="primary" @click="savePopoverValue">保存</el-button>
- </div>
- </div>
- <!-- 要素视图:模板文档 + {{key}} 占位符 -->
- <div class="document-view" v-if="viewMode === 'elements'">
- <div
- class="doc-paper"
- v-if="docTemplateHtml"
- :style="docPaperStyle"
- contenteditable="false"
- spellcheck="false"
- v-html="docTemplateHtml"
- ></div>
- <div class="doc-empty" v-else-if="!docLoading">
- <el-empty description="暂无文档内容" />
- </div>
- </div>
- </div>
- </template>
- </div>
- <!-- 右侧拖拽分隔条 -->
- <div v-if="hasActiveProject" class="resize-handle right-resize" @mousedown="startResizeRight"></div>
- <!-- 右侧面板 -->
- <div v-if="hasActiveProject" class="right-panel" :style="{ width: rightPanelWidth + 'px' }">
- <!-- 报告要素区 -->
- <div class="rp-elements">
- <div class="rp-elements-header">
- <div class="rp-elements-title">
- <span class="rp-title-icon">📋</span>
- <span class="rp-title-text">报告要素</span>
- <el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><CopyDocument /></el-icon></el-button>
- <el-button text circle size="small" title="规则" @click="showRuleDialog = true"><el-icon><MoreFilled /></el-icon></el-button>
- </div>
- <el-button text circle size="small" title="搜索"><el-icon><Search /></el-icon></el-button>
- </div>
- <div class="rp-elements-body">
- <div class="rp-value-tags" v-if="filledValues.length > 0">
- <span
- v-for="val in filledValues"
- :key="val.valueId"
- class="rp-value-tag"
- :title="val.elementName"
- >{{ val.valueText }}</span>
- </div>
- <div class="rp-elements-empty" v-else>
- <span>暂无要素值</span>
- </div>
- </div>
- </div>
- <!-- AI 助手区 -->
- <div class="rp-ai">
- <div class="rp-ai-header">
- <div class="rp-ai-title">
- <span class="rp-ai-icon">🤖</span>
- <span>AI 助手</span>
- </div>
- <div class="rp-ai-actions">
- <el-button text size="small" @click="aiMessages = []">🔄 新对话</el-button>
- </div>
- </div>
- <div class="rp-ai-messages" ref="aiMessagesRef">
- <div class="ai-message ai-bot" v-if="aiMessages.length === 0">
- <div class="ai-bubble">👋 您好,我不仅是您的AI助手,更是您深度思考时的沉浸式创作搭档。你准备好开启这场创作之旅了吗?</div>
- </div>
- <div
- v-for="(msg, idx) in aiMessages"
- :key="idx"
- class="ai-message"
- :class="msg.role === 'user' ? 'ai-user' : 'ai-bot'"
- >
- <div class="ai-bubble">{{ msg.content }}</div>
- </div>
- </div>
- <div class="rp-ai-input">
- <el-input
- v-model="aiInputText"
- placeholder="发消息给 AI 助手~"
- :autosize="{ minRows: 1, maxRows: 3 }"
- type="textarea"
- resize="none"
- @keydown.enter.exact.prevent="sendAiMessage"
- />
- <div class="rp-ai-input-actions">
- <div class="rp-ai-input-tools">
- <el-button text circle size="small" title="附加">+</el-button>
- <el-button text circle size="small" title="提及">@</el-button>
- <el-button text circle size="small" title="模板">🌐</el-button>
- </div>
- <div class="rp-ai-input-right">
- <el-button text circle size="small" title="语音">🎤</el-button>
- <el-button
- type="primary"
- circle
- size="small"
- :disabled="!aiInputText.trim()"
- @click="sendAiMessage"
- title="发送"
- >
- <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>
- </el-button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 附件管理弹窗 -->
- <el-dialog
- v-model="showAttachmentDialog"
- title="📎 附件管理"
- width="640"
- :close-on-click-modal="true"
- class="floating-panel-dialog"
- align-center
- >
- <div class="fp-toolbar">
- <el-upload
- :auto-upload="false"
- :on-change="handleAttachmentUpload"
- :show-file-list="false"
- accept=".pdf,.doc,.docx,.xls,.xlsx,.zip,.png,.jpg"
- >
- <el-button size="small" :icon="Plus">添加附件</el-button>
- </el-upload>
- <span class="fp-count">共 {{ attachments.length }} 个附件</span>
- </div>
- <div class="fp-list">
- <div
- v-for="att in attachments"
- :key="att.id"
- class="fp-att-item"
- :class="{ active: selectedAttachment?.id === att.id }"
- @click="selectAttachment(att)"
- >
- <div class="att-icon" :class="getFileTypeClass(att)">
- <span class="att-icon-text">{{ getFileTypeLabel(att) }}</span>
- </div>
- <div class="att-info">
- <div class="att-name" :title="att.displayName">{{ att.displayName }}</div>
- <div class="att-meta">
- <span class="att-type">{{ getFileTypeTag(att) }}</span>
- <span class="att-size">{{ formatFileSize(att.fileSize) }}</span>
- <span
- v-if="parseStates[att.id]?.status === 'parsing' || parseStates[att.id]?.status === 'uploading'"
- class="att-parse-status parsing"
- >
- <el-icon class="is-loading"><Loading /></el-icon>
- {{ parseStates[att.id]?.progress || '解析中...' }}
- </span>
- <span v-else-if="parseStates[att.id]?.status === 'completed'" class="att-parse-status completed" style="cursor:pointer" @click.stop="viewParseResult(att)">✅ 已解析 · 查看</span>
- <span v-else-if="parseStates[att.id]?.status === 'failed'" class="att-parse-status failed">❌ 失败</span>
- </div>
- </div>
- <el-button
- v-if="parseStates[att.id]?.status === 'completed'"
- class="att-parse-btn"
- size="small"
- type="success"
- text
- @click.stop="viewParseResult(att)"
- >
- 查看
- </el-button>
- <el-button
- v-else-if="canParse(att) && (!parseStates[att.id] || parseStates[att.id]?.status === 'idle' || parseStates[att.id]?.status === 'failed')"
- class="att-parse-btn"
- size="small"
- type="primary"
- text
- @click.stop="handleParseAttachment(att)"
- >
- {{ getFileExt(att) === 'zip' ? '打开' : '解析' }}
- </el-button>
- <el-button
- v-if="parseStates[att.id]?.status === 'parsing' || parseStates[att.id]?.status === 'uploading'"
- class="att-parse-btn"
- size="small"
- type="info"
- text
- disabled
- >
- <el-icon class="is-loading"><Loading /></el-icon>
- </el-button>
- <el-dropdown trigger="click" @command="(cmd) => handleAttachmentAction(cmd, att)" @click.stop>
- <el-button class="att-more-btn" size="small" text :icon="MoreFilled" @click.stop />
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item command="preview">📄 预览</el-dropdown-item>
- <el-dropdown-item v-if="canParse(att)" command="parse">🔍 解析文档</el-dropdown-item>
- <el-dropdown-item v-if="parseStates[att.id]?.status === 'completed'" command="view_result">📋 查看解析结果</el-dropdown-item>
- <el-dropdown-item command="apply">📝 应用要素</el-dropdown-item>
- <el-dropdown-item command="download">⬇️ 下载</el-dropdown-item>
- <el-dropdown-item command="delete" divided>🗑️ 删除</el-dropdown-item>
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- </div>
- <el-empty v-if="attachments.length === 0" description="暂无附件,点击添加按钮上传" :image-size="80" />
- </div>
- </el-dialog>
- <!-- 解析结果预览弹窗 -->
- <el-dialog
- v-model="showParseResultDialog"
- :title="'📋 解析结果 - ' + (parseResultAttName || '')"
- width="1100"
- :close-on-click-modal="true"
- class="floating-panel-dialog parse-result-dialog"
- align-center
- @close="cleanupPreviewContent"
- >
- <!-- 引用模式提示条 -->
- <div v-if="referenceMode" class="citation-mode-banner">
- <span>📌 正在为要素「<b>{{ referenceModeElementName }}</b>」选择引用内容,请选中文本后选择引用方式</span>
- <el-button size="small" text @click="exitReferenceMode">✕ 退出</el-button>
- </div>
- <div class="parse-result-toolbar">
- <span class="parse-result-info">{{ parseResultContent ? `共 ${parseResultContent.length} 字` : '' }}</span>
- <div class="parse-result-actions">
- <el-button size="small" :type="parseResultViewMode === 'rendered' ? 'primary' : ''" @click="parseResultViewMode = 'rendered'">渲染</el-button>
- <el-button size="small" :type="parseResultViewMode === 'source' ? 'primary' : ''" @click="parseResultViewMode = 'source'">源码</el-button>
- <el-button v-if="parseResultPreviewAvailable" size="small" :type="parseResultViewMode === 'preview' ? 'primary' : ''" @click="switchToPreviewMode">原件</el-button>
- <el-button size="small" @click="copyParseResult">📋 复制</el-button>
- </div>
- </div>
- <div class="parse-result-content" @mouseup="onParseResultMouseUp">
- <div v-if="parseResultViewMode === 'rendered'" class="parse-result-rendered markdown-body" v-html="parseResultHtml"></div>
- <pre v-else-if="parseResultViewMode === 'source'" class="parse-result-pre">{{ parseResultSource }}</pre>
- <div v-else-if="parseResultViewMode === 'preview'" class="parse-result-preview">
- <img v-if="previewContentType === 'image'" :src="previewContentUrl" :alt="parseResultAttName" style="max-width:100%;height:auto;display:block;margin:0 auto" />
- <iframe v-else-if="previewContentType === 'pdf'" :src="previewContentUrl" style="width:100%;height:75vh;border:none" />
- <div v-else-if="previewContentType === 'html'" class="parse-result-rendered markdown-body" v-html="previewContentHtml" />
- <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>
- <div v-else style="text-align:center;padding:40px;color:#999">该文件类型暂不支持原件预览</div>
- </div>
- </div>
- <!-- 选中文本后的浮动引用工具栏 -->
- <div
- v-if="citationToolbar.visible"
- class="citation-toolbar"
- :style="{ top: citationToolbar.y + 'px', left: citationToolbar.x + 'px' }"
- >
- <!-- 第一步:选择引用方式 -->
- <template v-if="citationToolbar.step === 'select_action'">
- <div class="citation-toolbar-title">引用到要素:</div>
- <div class="citation-toolbar-actions">
- <el-button size="small" type="primary" @click="setCitationAction('quote')">📝 直接引用</el-button>
- <el-button size="small" @click="setCitationAction('summary')">🤖 AI 总结</el-button>
- <el-button size="small" @click="setCitationAction('table_extract')">📊 表格提取</el-button>
- </div>
- </template>
- <!-- 第二步:选择目标要素(非引用模式时) -->
- <template v-if="citationToolbar.step === 'select_element'">
- <div class="citation-toolbar-title">选择目标要素:</div>
- <div class="citation-toolbar-elements">
- <div
- v-for="elem in elements"
- :key="elem.elementKey"
- class="citation-element-item"
- @click="confirmCitation(elem)"
- >
- <span class="citation-elem-name">{{ elem.elementName }}</span>
- <el-tag size="small" type="info">{{ elem.elementType }}</el-tag>
- </div>
- <div v-if="elements.length === 0" style="padding:8px;color:#999;font-size:12px">暂无要素</div>
- </div>
- </template>
- </div>
- </el-dialog>
- <!-- ZIP 内容展示弹窗 -->
- <el-dialog
- v-model="showZipContentsDialog"
- :title="'📦 ' + (zipContentsAttName || 'ZIP 内容')"
- width="520"
- :close-on-click-modal="true"
- class="floating-panel-dialog"
- align-center
- >
- <div class="fp-toolbar">
- <span class="fp-count">共 {{ zipFileList.length }} 个文件,{{ zipFileList.filter(f => f.parseable).length }} 个可解析</span>
- <el-button
- size="small"
- type="primary"
- :disabled="zipFileList.filter(f => f.parseable && !f.parsed && !f.parsing).length === 0"
- @click="parseAllZipEntries"
- >🔍 一键全部解析</el-button>
- </div>
- <div class="fp-list">
- <div v-if="zipFileList.length === 0" style="text-align:center;padding:30px;color:#999">ZIP 包为空</div>
- <div v-for="(zf, idx) in zipFileList" :key="idx" class="fp-att-item">
- <div class="att-icon" :class="getZipEntryTypeClass(zf)">
- <span class="att-icon-text">{{ getZipEntryTypeLabel(zf) }}</span>
- </div>
- <div class="att-info">
- <div class="att-name" :title="zf.name">{{ zf.name.split('/').pop() }}</div>
- <div class="att-meta">
- <span class="att-type">{{ zf.ext || '文件' }}</span>
- <span class="att-size">{{ formatFileSize(zf.size) }}</span>
- <span v-if="zf.parsing" class="att-parse-status parsing">
- <el-icon class="is-loading"><Loading /></el-icon> 解析中...
- </span>
- <span v-else-if="zf.parsed" class="att-parse-status completed" style="cursor:pointer" @click="viewZipEntryResult(zf)">✅ 已解析 · 查看</span>
- </div>
- </div>
- <el-button
- class="att-parse-btn"
- size="small"
- text
- @click="previewZipEntry(zf)"
- >预览</el-button>
- <el-button
- v-if="zf.parsed"
- class="att-parse-btn"
- size="small"
- type="success"
- text
- @click="viewZipEntryResult(zf)"
- >查看</el-button>
- <el-button
- v-else-if="zf.parseable && !zf.parsing"
- class="att-parse-btn"
- size="small"
- type="primary"
- text
- @click="parseZipEntry(zf)"
- >解析</el-button>
- <el-button
- v-else-if="zf.parsing"
- class="att-parse-btn"
- size="small"
- type="info"
- text
- disabled
- ><el-icon class="is-loading"><Loading /></el-icon></el-button>
- </div>
- </div>
- </el-dialog>
- <!-- 引用模式:附件选择弹窗 -->
- <el-dialog
- v-model="showRefAttSelectDialog"
- title="📎 选择要引用的附件"
- width="480"
- :close-on-click-modal="true"
- class="floating-panel-dialog"
- align-center
- >
- <div class="fp-list">
- <div
- v-for="att in refAttSelectList"
- :key="att.id"
- class="fp-att-item"
- style="cursor:pointer"
- @click="onRefAttSelected(att)"
- >
- <div class="att-icon" :class="getFileTypeClass(att)">
- <span class="att-icon-text">{{ getFileTypeLabel(att) }}</span>
- </div>
- <div class="att-info">
- <div class="att-name" :title="att.displayName">{{ att.displayName }}</div>
- <div class="att-meta">
- <span class="att-type">{{ getFileTypeTag(att) }}</span>
- <span class="att-size">{{ formatFileSize(att.fileSize) }}</span>
- <span class="att-parse-status completed">✅ 已解析</span>
- </div>
- </div>
- </div>
- </div>
- </el-dialog>
- <!-- 规则管理弹窗 -->
- <el-dialog
- v-model="showRuleDialog"
- title="⚙️ 规则管理"
- width="720"
- :close-on-click-modal="true"
- class="floating-panel-dialog rule-manage-dialog"
- align-center
- >
- <div class="fp-toolbar">
- <el-input
- v-model="ruleSearchQuery"
- size="small"
- placeholder="搜索规则名称 / 要素标识..."
- clearable
- style="width: 220px"
- :prefix-icon="Search"
- />
- <el-button size="small" :icon="Plus" @click="showNewRuleDialog = true">添加规则</el-button>
- <el-button
- v-if="rules.length > 0"
- type="success"
- size="small"
- @click="handleBatchExecuteRules"
- :loading="executingRules"
- >
- 批量执行
- </el-button>
- <span class="fp-count">{{ filteredRules.length }} / {{ rules.length }} 条</span>
- </div>
- <!-- 类型筛选标签栏 -->
- <div class="rule-filter-bar">
- <span
- class="rule-filter-tab"
- :class="{ active: ruleFilterType === 'all' }"
- @click="ruleFilterType = 'all'"
- >全部 <em>{{ ruleTypeStats.all || 0 }}</em></span>
- <span
- class="rule-filter-tab tab-summary"
- :class="{ active: ruleFilterType === 'summary' }"
- @click="ruleFilterType = 'summary'"
- >AI总结 <em>{{ ruleTypeStats.summary || 0 }}</em></span>
- <span
- class="rule-filter-tab tab-ai_extract"
- :class="{ active: ruleFilterType === 'ai_extract' }"
- @click="ruleFilterType = 'ai_extract'"
- >AI提取 <em>{{ ruleTypeStats.ai_extract || 0 }}</em></span>
- <span
- class="rule-filter-tab tab-table_extract"
- :class="{ active: ruleFilterType === 'table_extract' }"
- @click="ruleFilterType = 'table_extract'"
- >表格提取 <em>{{ ruleTypeStats.table_extract || 0 }}</em></span>
- <span
- class="rule-filter-tab tab-quote"
- :class="{ active: ruleFilterType === 'quote' }"
- @click="ruleFilterType = 'quote'"
- >引用 <em>{{ ruleTypeStats.quote || 0 }}</em></span>
- <span
- class="rule-filter-tab tab-use_entity_value"
- :class="{ active: ruleFilterType === 'use_entity_value' }"
- @click="ruleFilterType = 'use_entity_value'"
- >人工录入 <em>{{ ruleTypeStats.use_entity_value || 0 }}</em></span>
- </div>
- <!-- 规则列表 -->
- <div class="fp-list">
- <div
- v-for="rule in filteredRules"
- :key="rule.id"
- class="fp-rule-item"
- :class="{ expanded: expandedRuleId === rule.id }"
- @click="toggleRuleExpand(rule.id)"
- >
- <div class="rule-item-main">
- <span class="rule-action-badge" :class="'action-' + rule.actionType">{{ ruleActionLabel(rule.actionType) }}</span>
- <div class="rule-info">
- <div class="rule-name-row">
- <span class="rule-name">{{ rule.ruleName }}</span>
- <el-tag size="small" type="info" effect="plain" class="rule-elem-key">{{ rule.elementKey }}</el-tag>
- </div>
- <div class="rule-desc" v-if="rule.description">{{ rule.description }}</div>
- </div>
- <div class="rule-actions">
- <el-button size="small" type="primary" text @click.stop="handleExecuteRule(rule)" title="执行" :loading="rule._executing">▶</el-button>
- <el-button size="small" type="danger" text :icon="Delete" @click.stop="handleDeleteRule(rule)" title="删除" />
- </div>
- </div>
- <!-- 展开详情 -->
- <div class="rule-detail" v-if="expandedRuleId === rule.id">
- <div class="rule-detail-row" v-if="rule.dslContent">
- <span class="rule-detail-label">取值规则</span>
- <span class="rule-detail-value">{{ rule.dslContent }}</span>
- </div>
- <div class="rule-detail-row" v-if="rule.inputs && rule.inputs.length > 0">
- <span class="rule-detail-label">输入来源</span>
- <div class="rule-detail-inputs">
- <span v-for="inp in rule.inputs" :key="inp.inputId" class="rule-input-chip">
- 📎 {{ inp.inputName || inp.sourceName }}
- </span>
- </div>
- </div>
- <div class="rule-detail-row" v-if="rule.lastRunStatus">
- <span class="rule-detail-label">上次执行</span>
- <el-tag size="small" :type="rule.lastRunStatus === 'success' ? 'success' : 'danger'">
- {{ rule.lastRunStatus === 'success' ? '成功' : '失败' }}
- </el-tag>
- <span v-if="rule.lastRunTime" class="rule-detail-time">{{ rule.lastRunTime }}</span>
- </div>
- <div class="rule-detail-row" v-if="rule.lastRunError">
- <span class="rule-detail-label">错误信息</span>
- <span class="rule-detail-error">{{ rule.lastRunError }}</span>
- </div>
- </div>
- </div>
- <el-empty v-if="filteredRules.length === 0" :description="ruleSearchQuery || ruleFilterType !== 'all' ? '无匹配规则' : '暂无规则,点击添加按钮创建'" :image-size="80" />
- </div>
- </el-dialog>
- <!-- 新建项目对话框 -->
- <el-dialog v-model="showNewProjectDialog" title="新建项目" width="460" :close-on-click-modal="false">
- <el-form :model="newProjectForm" label-width="80px">
- <el-form-item label="项目名称" required>
- <el-input v-model="newProjectForm.title" placeholder="请输入项目名称" maxlength="100" show-word-limit />
- </el-form-item>
- <el-form-item label="项目描述">
- <el-input v-model="newProjectForm.description" type="textarea" :rows="3" placeholder="项目描述(可选)" />
- </el-form-item>
- </el-form>
- <template #footer>
- <el-button @click="showNewProjectDialog = false">取消</el-button>
- <el-button type="primary" @click="handleCreateProject" :disabled="!newProjectForm.title.trim()" :loading="creatingProject">创建</el-button>
- </template>
- </el-dialog>
- <!-- 添加要素对话框 -->
- <el-dialog v-model="showAddElementDialog" title="添加要素" width="500">
- <el-form :model="newElementForm" label-width="100px">
- <el-form-item label="要素名称" required>
- <el-input v-model="newElementForm.elementName" placeholder="如:项目编号" />
- </el-form-item>
- <el-form-item label="要素标识" required>
- <el-input v-model="newElementForm.elementKey" placeholder="如:basicInfo.projectCode" />
- </el-form-item>
- <el-form-item label="数据类型">
- <el-select v-model="newElementForm.dataType" style="width: 100%">
- <el-option label="文本" value="text" />
- <el-option label="数字" value="number" />
- <el-option label="日期" value="date" />
- <el-option label="金额" value="money" />
- </el-select>
- </el-form-item>
- <el-form-item label="描述">
- <el-input v-model="newElementForm.description" type="textarea" :rows="2" placeholder="要素描述" />
- </el-form-item>
- </el-form>
- <template #footer>
- <el-button @click="showAddElementDialog = false">取消</el-button>
- <el-button type="primary" @click="handleAddElement" :disabled="!newElementForm.elementName || !newElementForm.elementKey">添加</el-button>
- </template>
- </el-dialog>
- <!-- 添加规则对话框 -->
- <el-dialog v-model="showNewRuleDialog" title="添加规则" width="500">
- <el-form :model="newRuleForm" label-width="100px">
- <el-form-item label="规则名称" required>
- <el-input v-model="newRuleForm.ruleName" placeholder="如:项目编号-直接引用实体" />
- </el-form-item>
- <el-form-item label="规则类型" required>
- <el-select v-model="newRuleForm.ruleType" style="width: 100%">
- <el-option label="直接引用实体" value="direct_entity" />
- <el-option label="AI 提取" value="ai_extract" />
- <el-option label="固定值" value="fixed_value" />
- <el-option label="计算公式" value="formula" />
- </el-select>
- </el-form-item>
- <el-form-item label="目标要素">
- <el-select v-model="newRuleForm.targetElementKey" style="width: 100%" placeholder="选择要填充的要素">
- <el-option v-for="elem in elements" :key="elem.elementKey" :label="elem.elementName" :value="elem.elementKey" />
- </el-select>
- </el-form-item>
- </el-form>
- <template #footer>
- <el-button @click="showNewRuleDialog = false">取消</el-button>
- <el-button type="primary" @click="handleCreateRule" :disabled="!newRuleForm.ruleName">创建</el-button>
- </template>
- </el-dialog>
- </div>
- </template>
- <script setup>
- import { ref, reactive, computed, watch, onMounted } from 'vue'
- import { useRouter, useRoute } from 'vue-router'
- import { Plus, Delete, Search, Loading, Check, CopyDocument, MoreFilled, List, Folder, Document, Grid, Setting, Paperclip } from '@element-plus/icons-vue'
- import { ElMessage, ElMessageBox } from 'element-plus'
- import { projectApi, elementApi, valueApi, attachmentApi, ruleApi, parseApi } from '@/api'
- import { marked } from 'marked'
- import JSZip from 'jszip'
- import { useTaskCenterStore } from '@/stores/taskCenter'
- const router = useRouter()
- const route = useRoute()
- const taskCenterStore = useTaskCenterStore()
- const taskRunningCount = computed(() => taskCenterStore.runningCount)
- const currentProjectId = ref(null)
- const hasActiveProject = computed(() => !!currentProjectId.value)
- const userName = computed(() => localStorage.getItem('username') || '用户')
- const greetingText = computed(() => {
- const hour = new Date().getHours()
- if (hour < 6) return '凌晨好'
- if (hour < 9) return '早上好'
- if (hour < 12) return '上午好'
- if (hour < 14) return '中午好'
- if (hour < 18) return '下午好'
- if (hour < 22) return '晚上好'
- return '夜深了'
- })
- const projectTitle = ref('')
- const viewMode = ref('document')
- const saved = ref(true)
- const editorRef = ref(null)
- const loading = ref(false)
- const leftPanelWidth = ref(300)
- const rightPanelWidth = ref(340)
- const isResizing = ref(false)
- const resizeType = ref('')
- const leftPanelTab = ref('projects')
- const sidebarShowAll = ref(false)
- const recentActivities = computed(() => {
- // 基于项目列表生成最近操作(后续可接入真实 API)
- const acts = []
- for (const p of projects.value.slice(0, 5)) {
- acts.push({
- text: `${p.title}`,
- source: `@${userName.value}`,
- time: formatTime(p.updatedAt || p.createdAt)
- })
- }
- return acts
- })
- const projects = ref([])
- const loadingProjects = ref(false)
- const projectSearchKeyword = ref('')
- const elements = ref([])
- const values = ref([])
- const attachments = ref([])
- const rules = ref([])
- const selectedAttachment = ref(null)
- const executingRules = ref(false)
- // AI 助手
- const aiMessages = ref([])
- const aiInputText = ref('')
- const aiMessagesRef = ref(null)
- // 已填充的要素值(用于右侧标签云)
- const filledValues = computed(() => {
- const result = []
- for (const elem of elements.value) {
- const elemVals = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
- for (const val of elemVals) {
- if (val.valueText && val.valueText.length > 0) {
- result.push({ valueId: val.valueId, valueText: val.valueText, elementName: elem.elementName, elementKey: elem.elementKey })
- }
- }
- }
- return result
- })
- // 文档预览状态
- const docContent = ref(null)
- const docLoading = ref(false)
- const docHtml = ref('')
- const docTemplateHtml = ref('')
- const docPaperRef = ref(null)
- const highlightEnabled = ref(true)
- const elementHighlightCount = ref(0)
- const highlightPopover = reactive({
- visible: false, x: 0, y: 0,
- elementKey: '', fullElementKey: '', elementName: '', currentValue: '', originalValue: '', valueId: null
- })
- // 附件引用系统
- const citationToolbar = reactive({
- visible: false, x: 0, y: 0,
- selectedText: '',
- step: 'select_action', // 'select_action' | 'select_element'
- actionType: '', // 'quote' | 'summary' | 'table_extract'
- })
- const showRefAttSelectDialog = ref(false) // 引用模式下的附件选择弹窗
- const refAttSelectList = ref([]) // 可选附件列表
- const refAttSelectElemKey = ref('') // 暂存目标要素 key
- const refAttSelectElemName = ref('') // 暂存目标要素名
- const referenceMode = ref(false) // 是否处于「从要素引用附件」模式
- const referenceModeElementKey = ref('') // 引用模式下锁定的目标要素 key
- const referenceModeElementName = ref('') // 引用模式下锁定的目标要素名
- const referenceModeAttId = ref(null) // 引用来源的附件节点 ID
- const referenceModeAttName = ref('') // 引用来源的附件名
- const showNewProjectDialog = ref(false)
- const showAddElementDialog = ref(false)
- const showNewRuleDialog = ref(false)
- const showAttachmentDialog = ref(false)
- const showRuleDialog = ref(false)
- const ruleSearchQuery = ref('')
- const ruleFilterType = ref('all')
- const expandedRuleId = ref(null)
- const filteredRules = computed(() => {
- let list = rules.value
- if (ruleFilterType.value !== 'all') {
- list = list.filter(r => r.actionType === ruleFilterType.value)
- }
- const q = ruleSearchQuery.value.trim().toLowerCase()
- if (q) {
- list = list.filter(r =>
- (r.ruleName || '').toLowerCase().includes(q) ||
- (r.elementKey || '').toLowerCase().includes(q) ||
- (r.description || '').toLowerCase().includes(q)
- )
- }
- return list
- })
- const ruleTypeStats = computed(() => {
- const stats = { all: rules.value.length }
- for (const r of rules.value) {
- const t = r.actionType || 'unknown'
- stats[t] = (stats[t] || 0) + 1
- }
- return stats
- })
- function toggleRuleExpand(ruleId) {
- expandedRuleId.value = expandedRuleId.value === ruleId ? null : ruleId
- }
- function ruleInputSummary(rule) {
- if (!rule.inputs || rule.inputs.length === 0) return ''
- return rule.inputs.map(i => i.inputName || i.sourceName || '').filter(Boolean).join('、')
- }
- // 附件解析状态: { [attachmentId]: { status: 'idle'|'uploading'|'parsing'|'completed'|'failed', progress: '', markdown: '' } }
- const parseStates = reactive({})
- const showParseResultDialog = ref(false)
- const parseResultAttName = ref('')
- const parseResultContent = ref('')
- const parseResultViewMode = ref('rendered')
- const parseResultIsHtml = ref(false)
- const parseResultHtml = computed(() => {
- if (!parseResultContent.value) return ''
- // DOCX 解析结果已经是 HTML,直接使用
- if (parseResultIsHtml.value) return parseResultContent.value
- // PDF/图片解析结果是 markdown,用 marked 渲染
- try {
- return marked(parseResultContent.value)
- } catch (e) {
- return `<pre>${parseResultContent.value}</pre>`
- }
- })
- const parseResultSource = computed(() => {
- if (!parseResultContent.value) return ''
- // 源码视图:将 base64 数据替换为简短占位符
- return parseResultContent.value.replace(
- /src="data:[^"]+"/g, 'src="[图片数据已省略]"'
- ).replace(
- /!\[([^\]]*)\]\(data:[^;]+;base64,[A-Za-z0-9+/=]+\)/g,
- ''
- )
- })
- // 原件预览(集成在解析结果弹窗中)
- const previewContentType = ref('') // 'image' | 'pdf' | 'html' | 'text' | ''
- const previewContentUrl = ref('')
- const previewContentHtml = ref('')
- const previewContentText = ref('')
- const parseResultPreviewAvailable = ref(false) // 是否有原件可预览
- const parseResultOriginAtt = ref(null) // 关联的独立附件对象(非 ZIP)
- const parseResultOriginZf = ref(null) // 关联的 zip file entry 对象
- // ZIP 内容展示
- const showZipContentsDialog = ref(false)
- const zipContentsAttName = ref('')
- const zipFileList = ref([]) // [{ name, size, ext, parseable, parsing, parsed, parseResult, isHtml }]
- const zipInstance = ref(null) // 当前打开的 JSZip 实例
- const zipParentAtt = ref(null) // 当前打开的 ZIP 附件对象
- // 缓存上传的原始文件对象,用于后续解析
- const attachmentFileCache = new Map()
- const creatingProject = ref(false)
- const newProjectForm = reactive({ title: '', description: '' })
- const newElementForm = reactive({ elementName: '', elementKey: '', dataType: 'text', description: '' })
- const newRuleForm = reactive({ ruleName: '', ruleType: 'direct_entity', targetElementKey: '' })
- const filteredProjects = computed(() => {
- if (!projectSearchKeyword.value) return projects.value
- const kw = projectSearchKeyword.value.toLowerCase()
- return projects.value.filter(p => p.title?.toLowerCase().includes(kw))
- })
- const filledValueCount = computed(() => values.value.filter(v => v.isFilled).length)
- // 面板拖拽
- function startResizeLeft() {
- isResizing.value = true; resizeType.value = 'left'
- document.addEventListener('mousemove', handleResize)
- document.addEventListener('mouseup', stopResize)
- document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'
- }
- function startResizeRight() {
- isResizing.value = true; resizeType.value = 'right'
- document.addEventListener('mousemove', handleResize)
- document.addEventListener('mouseup', stopResize)
- document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'
- }
- function handleResize(e) {
- if (!isResizing.value) return
- if (resizeType.value === 'left') leftPanelWidth.value = Math.max(240, Math.min(500, e.clientX))
- else if (resizeType.value === 'right') rightPanelWidth.value = Math.max(280, Math.min(500, window.innerWidth - e.clientX))
- }
- function stopResize() {
- isResizing.value = false; resizeType.value = ''
- document.removeEventListener('mousemove', handleResize)
- document.removeEventListener('mouseup', stopResize)
- document.body.style.cursor = ''; document.body.style.userSelect = ''
- }
- // 项目操作
- async function loadProjects() {
- loadingProjects.value = true
- try {
- const data = await projectApi.list({ page: 1, size: 50 })
- projects.value = data?.records || data || []
- } catch (error) {
- console.warn('获取项目列表失败:', error)
- projects.value = []
- } finally { loadingProjects.value = false }
- }
- async function switchProject(project) {
- if (currentProjectId.value === project.id) { unselectProject(); return }
- currentProjectId.value = project.id
- projectTitle.value = project.title || ''
- leftPanelTab.value = 'projects'
- await loadProjectData(project.id)
- }
- function unselectProject() {
- currentProjectId.value = null; projectTitle.value = ''
- elements.value = []; values.value = []; attachments.value = []; rules.value = []
- docContent.value = null; docHtml.value = ''
- leftPanelTab.value = 'projects'
- }
- // 本地真实附件 mock 数据
- const mockAttachments = [
- { id: 'att-01', displayName: '附件1 成都院核心要素评审情况记录表.docx', fileType: 'docx', fileSize: 376919, fileUrl: '/attachments/att01-核心要素评审情况记录表.docx' },
- { id: 'att-02', displayName: '附件2 成都院现场评审分工表.zip', fileType: 'zip', fileSize: 2171136, fileUrl: '/attachments/att02-现场评审分工表.zip' },
- { id: 'att-03', displayName: '附件3 安全生产标准化建设通知.pdf', fileType: 'pdf', fileSize: 360940, fileUrl: '/attachments/att03-安全生产标准化通知.pdf' },
- { id: 'att-04', displayName: '附件4 成都院材料真实性说明.png', fileType: 'png', fileSize: 514264, fileUrl: '/attachments/att04-材料真实性说明.png' },
- { id: 'att-05', displayName: '附件5 成都院在建项目一览表.docx', fileType: 'docx', fileSize: 16056, fileUrl: '/attachments/att05-在建项目一览表.docx' },
- { id: 'att-06', displayName: '附件6 成都院安全管理制度清单.docx', fileType: 'docx', fileSize: 16942, fileUrl: '/attachments/att06-安全管理制度清单.docx' },
- { id: 'att-07', displayName: '附件7 成都院现场评审末次会签到表.png', fileType: 'png', fileSize: 564269, fileUrl: '/attachments/att07-现场评审末次会签到表.png' },
- { id: 'att-08', displayName: '附件8 工作方案.zip', fileType: 'zip', fileSize: 299279, fileUrl: '/attachments/att08-工作方案.zip' },
- { id: 'att-09', displayName: '附件9 复审问题建议表.docx', fileType: 'docx', fileSize: 29034, fileUrl: '/attachments/att09-复审问题建议表.docx' },
- ]
- async function loadProjectData(projectId) {
- loading.value = true
- try {
- const [elemData, valData, attData, ruleData] = await Promise.all([
- elementApi.list(projectId).catch(() => []),
- valueApi.list(projectId).catch(() => []),
- attachmentApi.list(projectId).catch(() => []),
- ruleApi.list(projectId).catch(() => [])
- ])
- elements.value = elemData || []; values.value = valData || []
- // 使用本地 mock 附件(含真实文件 URL,用于解析)
- attachments.value = [...mockAttachments]
- // 恢复已持久化的解析状态
- restoreParseStates()
- rules.value = ruleData || []
- // 加载项目模板文档内容
- loadDocContent(projectId)
- } catch (error) { console.error('加载项目数据失败:', error) }
- finally { loading.value = false }
- }
- async function handleCreateProject() {
- if (!newProjectForm.title.trim()) return
- creatingProject.value = true
- try {
- const project = await projectApi.create({ title: newProjectForm.title.trim(), description: newProjectForm.description })
- showNewProjectDialog.value = false; newProjectForm.title = ''; newProjectForm.description = ''
- await loadProjects()
- if (project) await switchProject(project)
- ElMessage.success('项目创建成功')
- } catch (error) { ElMessage.error('创建失败: ' + error.message) }
- finally { creatingProject.value = false }
- }
- function sendAiMessage() {
- const text = aiInputText.value.trim()
- if (!text) return
- aiMessages.value.push({ role: 'user', content: text })
- aiInputText.value = ''
- // 模拟 AI 回复(后续接入真实 API)
- setTimeout(() => {
- aiMessages.value.push({ role: 'assistant', content: '收到您的消息,AI 助手功能正在开发中,敬请期待...' })
- if (aiMessagesRef.value) aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
- }, 500)
- setTimeout(() => {
- if (aiMessagesRef.value) aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
- }, 50)
- }
- function handleFooterCommand(cmd) {
- if (cmd === 'settings') {
- ElMessage.info('系统设置开发中...')
- } else if (cmd === 'logout') {
- ElMessageBox.confirm('确定要退出登录吗?', '退出确认', {
- confirmButtonText: '退出', cancelButtonText: '取消', type: 'warning'
- }).then(async () => {
- try { await import('@/api').then(m => m.authApi.logout()) } catch (e) { /* ignore */ }
- localStorage.removeItem('accessToken')
- localStorage.removeItem('refreshToken')
- localStorage.removeItem('userId')
- localStorage.removeItem('username')
- ElMessage.success('已退出登录')
- router.push('/login')
- }).catch(() => {})
- }
- }
- async function handleProjectCommand(cmd, project) {
- switch (cmd) {
- case 'copy':
- try { await projectApi.copy(project.id); await loadProjects(); ElMessage.success('项目复制成功') }
- catch (e) { ElMessage.error('复制失败: ' + e.message) }
- break
- case 'archive':
- try { await projectApi.archive(project.id); await loadProjects(); ElMessage.success('项目已归档') }
- catch (e) { ElMessage.error('归档失败: ' + e.message) }
- break
- case 'delete':
- try {
- await ElMessageBox.confirm(`确定要删除项目「${project.title}」吗?`, '删除确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
- await projectApi.delete(project.id)
- if (currentProjectId.value === project.id) unselectProject()
- await loadProjects(); ElMessage.success('项目已删除')
- } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message) }
- break
- }
- }
- async function handleCopyProject() {
- if (!currentProjectId.value) return
- try {
- const copied = await projectApi.copy(currentProjectId.value)
- await loadProjects(); ElMessage.success('项目复制成功')
- if (copied) await switchProject(copied)
- } catch (e) { ElMessage.error('复制失败: ' + e.message) }
- }
- function handleSave() { saved.value = true; ElMessage.success('保存成功') }
- function handleTitlebarCommand(cmd) {
- if (cmd === 'save') handleSave()
- else if (cmd === 'copy') handleCopyProject()
- else if (cmd === 'export') ElMessage.info('导出功能开发中...')
- }
- // ==================== 文档预览 + 可编辑 + 要素高亮 ====================
- async function loadDocContent(projectId) {
- if (!projectId) return
- docLoading.value = true
- docContent.value = null
- docHtml.value = ''
- try {
- const data = await projectApi.getDocContent(projectId)
- docContent.value = data
- renderDocHtml()
- renderDocHtmlTemplate()
- } catch (e) {
- console.warn('加载文档内容失败:', e)
- docContent.value = null
- docHtml.value = ''
- } finally {
- docLoading.value = false
- }
- }
- const docPaperStyle = computed(() => {
- const page = docContent.value?.page
- if (!page) return {}
- return {
- maxWidth: `${page.widthMm * 3.78}px`,
- paddingTop: `${page.marginTopMm * 3.78}px`,
- paddingBottom: `${page.marginBottomMm * 3.78}px`,
- paddingLeft: `${page.marginLeftMm * 3.78}px`,
- paddingRight: `${page.marginRightMm * 3.78}px`,
- }
- })
- // 从值的 elementKey 中提取不含项目前缀的 key
- // 例如 "PRJ-2024-001:basicInfo.projectCode" -> "basicInfo.projectCode"
- function stripValueKeyPrefix(valueElementKey) {
- const idx = valueElementKey?.indexOf(':')
- return idx >= 0 ? valueElementKey.substring(idx + 1) : valueElementKey
- }
- // 当前弹出框要素关联的规则列表
- const popoverRelatedRules = computed(() => {
- const key = highlightPopover.elementKey
- if (!key) return []
- return rules.value.filter(r => {
- const rk = stripValueKeyPrefix(r.elementKey)
- return rk === key
- })
- })
- function ruleActionLabel(actionType) {
- const map = {
- quote: '引用',
- summary: 'AI总结',
- ai_extract: 'AI提取',
- table_extract: '表格提取',
- use_entity_value: '人工录入',
- }
- return map[actionType] || actionType
- }
- // 从规则中提取来源文本(引用原文 / 规则描述)
- function ruleSourceText(rule) {
- // 优先从 actionConfig.sourceText 取(前端引用创建的规则)
- if (rule.actionConfig) {
- try {
- const cfg = typeof rule.actionConfig === 'string' ? JSON.parse(rule.actionConfig) : rule.actionConfig
- if (cfg.sourceText) return cfg.sourceText
- if (cfg.description) return cfg.description
- } catch (_) { /* ignore */ }
- }
- // 其次取 dslContent(mock 规则的取值规则描述)
- if (rule.dslContent) return rule.dslContent
- // 最后取 description
- if (rule.description) return rule.description
- return ''
- }
- // 构建要素值映射表,分为长文本、短文本、静态文本三类
- function buildElementValueMap() {
- const longTexts = [] // paragraph/table 类型的长文本要素
- const shortTexts = [] // text 类型的短文本要素(动态)
- const staticTexts = [] // static 类型的静态要素
- const colors = [
- '#fff3cd', '#cce5ff', '#d4edda', '#f8d7da', '#e2d5f1',
- '#d1ecf1', '#ffeeba', '#c3e6cb', '#f5c6cb', '#d6d8db'
- ]
- let colorIdx = 0
- for (const elem of elements.value) {
- const elemValues = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
- const elemType = elem.elementType || 'text'
- const isStatic = elemType === 'static'
- for (const val of elemValues) {
- const text = val.valueText
- if (!text || text.length < 2) continue
- const entry = {
- text,
- elementKey: elem.elementKey,
- fullElementKey: val.elementKey,
- elementName: elem.elementName,
- valueId: val.valueId,
- elemType,
- isStatic,
- color: isStatic ? '#e8e8e8' : colors[colorIdx % colors.length]
- }
- if (isStatic) {
- staticTexts.push(entry)
- } else if (elemType === 'paragraph' || elemType === 'table') {
- longTexts.push(entry)
- } else {
- shortTexts.push(entry)
- }
- }
- if (!isStatic) colorIdx++
- }
- // 长文本按长度降序
- longTexts.sort((a, b) => b.text.length - a.text.length)
- // 短文本按长度降序
- shortTexts.sort((a, b) => b.text.length - a.text.length)
- // 静态文本按长度降序
- staticTexts.sort((a, b) => b.text.length - a.text.length)
- return { longTexts, shortTexts, staticTexts }
- }
- // 将文档 blocks 渲染为 HTML 字符串(含要素高亮)
- function renderDocHtml() {
- if (!docContent.value?.blocks) { docHtml.value = ''; return }
- const blocks = docContent.value.blocks
- const { longTexts, shortTexts, staticTexts } = highlightEnabled.value ? buildElementValueMap() : { longTexts: [], shortTexts: [], staticTexts: [] }
- // 合并短文本和静态文本用于 runs 级别匹配(动态优先,静态在后)
- const allShortTexts = [...shortTexts, ...staticTexts]
- let highlightCount = 0
- const parts = []
- // 预处理:为每个长文本要素,将其 valueText 按行拆分为句子集合
- // 用于判断某个 block 的文本是否属于某个长文本要素
- const longTextLines = longTexts.map(lt => ({
- ...lt,
- lines: new Set(lt.text.split('\n').map(l => l.trim()).filter(l => l.length > 0))
- }))
- // 收集被长文本高亮覆盖的 block IDs,这些 block 内的短文本不再单独高亮
- const longHighlightedBlockIds = new Set()
- // 第一遍:确定哪些 block 属于长文本要素
- for (const block of blocks) {
- const blockText = getBlockPlainText(block)
- if (!blockText) continue
- for (const lt of longTextLines) {
- if (lt.lines.has(blockText)) {
- longHighlightedBlockIds.add(block.id)
- break
- }
- }
- }
- // 第二遍:渲染,连续属于同一长文本要素的 block 合并到一个边框内
- let currentLongKey = null // 当前正在收集的长文本要素 key
- let currentLongMatch = null // 当前长文本要素匹配信息
- let longGroupHtml = '' // 当前长文本分组的 HTML 累积
- function flushLongGroup() {
- if (currentLongKey && longGroupHtml) {
- const borderColor = darkenColor(currentLongMatch.color)
- 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>`)
- highlightCount++
- }
- currentLongKey = null
- currentLongMatch = null
- longGroupHtml = ''
- }
- for (const block of blocks) {
- if (block.type === 'table') {
- flushLongGroup()
- const tableMatch = findTableLongTextMatch(block, longTextLines)
- parts.push(renderTableHtml(block, tableMatch))
- if (tableMatch) highlightCount++
- } else {
- const blockText = getBlockPlainText(block)
- const longMatch = findBlockLongTextMatch(blockText, longTextLines)
- if (longMatch) {
- // 如果和当前分组是同一个要素,继续累积
- if (currentLongKey === longMatch.elementKey) {
- longGroupHtml += renderBlockHtml(block, [], null, () => {})
- } else {
- // 不同要素,先 flush 上一组,再开始新组
- flushLongGroup()
- currentLongKey = longMatch.elementKey
- currentLongMatch = longMatch
- longGroupHtml = renderBlockHtml(block, [], null, () => {})
- }
- } else {
- // 非长文本 block,先 flush 再正常渲染
- flushLongGroup()
- parts.push(renderBlockHtml(block, allShortTexts, null, (n) => { highlightCount += n }))
- }
- }
- }
- flushLongGroup() // flush 最后一组
- elementHighlightCount.value = highlightCount
- docHtml.value = parts.join('')
- }
- // 要素视图:将动态要素值替换为 {{key}} 占位符
- function renderDocHtmlTemplate() {
- if (!docContent.value?.blocks) { docTemplateHtml.value = ''; return }
- const blocks = docContent.value.blocks
- const { longTexts, shortTexts } = buildElementValueMap()
- // 合并所有动态要素(不含静态)
- const allDynamic = [...shortTexts]
- const longTextLines = longTexts.map(lt => ({
- ...lt,
- lines: new Set(lt.text.split('\n').map(l => l.trim()).filter(l => l.length > 0))
- }))
- const parts = []
- let currentLongKey = null
- let currentLongMatch = null
- let longGroupHtml = ''
- function flushLongGroup() {
- if (currentLongKey && longGroupHtml) {
- const tag = `<span class="elem-tpl-tag" title="${escapeAttr(currentLongMatch.elementName)}">{{${currentLongMatch.elementKey}}}</span>`
- parts.push(`<div class="elem-tpl-block">${tag}</div>`)
- }
- currentLongKey = null
- currentLongMatch = null
- longGroupHtml = ''
- }
- for (const block of blocks) {
- if (block.type === 'table') {
- flushLongGroup()
- const tableMatch = findTableLongTextMatch(block, longTextLines)
- if (tableMatch) {
- const tag = `<span class="elem-tpl-tag" title="${escapeAttr(tableMatch.elementName)}">{{${tableMatch.elementKey}}}</span>`
- parts.push(`<div class="elem-tpl-block">${tag}</div>`)
- } else {
- parts.push(renderTableHtml(block, null))
- }
- } else {
- const blockText = getBlockPlainText(block)
- const longMatch = findBlockLongTextMatch(blockText, longTextLines)
- if (longMatch) {
- if (currentLongKey === longMatch.elementKey) {
- longGroupHtml += 'x' // just accumulate
- } else {
- flushLongGroup()
- currentLongKey = longMatch.elementKey
- currentLongMatch = longMatch
- longGroupHtml = 'x'
- }
- } else {
- flushLongGroup()
- parts.push(renderBlockHtmlTemplate(block, allDynamic))
- }
- }
- }
- flushLongGroup()
- docTemplateHtml.value = parts.join('')
- }
- // 渲染单个 block,将匹配的动态短文本替换为 {{key}} 标签
- function renderBlockHtmlTemplate(block, shortMap) {
- const tag = getBlockTag(block.type)
- const cls = `doc-block doc-${block.type}`
- const styleStr = buildStyleStr(block.style)
- const styleAttr = styleStr ? ` style="${styleStr}"` : ''
- let inner = ''
- // 图片
- if (block.images?.length > 0) {
- for (const img of block.images) {
- const imgStyle = []
- if (img.widthInch) imgStyle.push(`width:${img.widthInch}in`)
- if (img.heightInch) imgStyle.push(`height:${img.heightInch}in`)
- imgStyle.push('max-width:100%', 'display:block', 'margin:8px auto')
- inner += `<img src="${img.src}" style="${imgStyle.join(';')}" class="doc-inline-image" contenteditable="false" />`
- }
- }
- // Runs - 替换动态要素值为 {{key}}
- if (block.runs) {
- if (shortMap.length === 0) {
- for (const run of block.runs) {
- const text = escapeHtml(run.text)
- const rs = buildRunStyleStr(run)
- inner += rs ? `<span style="${rs}">${text}</span>` : text
- }
- } else {
- inner += templateReplaceRuns(block.runs, shortMap)
- }
- }
- if (!inner) inner = ' '
- return `<${tag} class="${cls}"${styleAttr} data-block-id="${block.id}">${inner}</${tag}>`
- }
- // 在 runs 纯文本中查找动态要素值并替换为 {{key}} 标签
- function templateReplaceRuns(runs, shortMap) {
- // 构建纯文本
- let plainText = ''
- const charToRun = []
- for (let ri = 0; ri < runs.length; ri++) {
- const t = runs[ri].text || ''
- for (let ci = 0; ci < t.length; ci++) {
- charToRun.push({ runIdx: ri, offsetInRun: ci })
- plainText += t[ci]
- }
- }
- // 查找匹配
- const matches = []
- for (const em of shortMap) {
- const val = em.text
- if (!val || val.length < 2) continue
- let pos = 0
- while (true) {
- const idx = plainText.indexOf(val, pos)
- if (idx < 0) break
- matches.push({ start: idx, end: idx + val.length, em })
- pos = idx + val.length
- }
- }
- matches.sort((a, b) => a.start - b.start || b.end - a.end)
- const filtered = []
- let lastEnd = -1
- for (const m of matches) {
- if (m.start >= lastEnd) {
- filtered.push(m)
- lastEnd = m.end
- }
- }
- if (filtered.length === 0) {
- let html = ''
- for (const run of runs) {
- const text = escapeHtml(run.text)
- const rs = buildRunStyleStr(run)
- html += rs ? `<span style="${rs}">${text}</span>` : text
- }
- return html
- }
- // 切分为普通段 + 替换段
- const segments = []
- let cursor = 0
- for (const m of filtered) {
- if (m.start > cursor) segments.push({ start: cursor, end: m.start, em: null })
- segments.push({ start: m.start, end: m.end, em: m.em })
- cursor = m.end
- }
- if (cursor < plainText.length) segments.push({ start: cursor, end: plainText.length, em: null })
- let html = ''
- for (const seg of segments) {
- if (seg.em) {
- // 替换为 {{key}} 标签
- html += `<span class="elem-tpl-tag" title="${escapeAttr(seg.em.elementName)}">{{${seg.em.elementKey}}}</span>`
- } else {
- // 普通文本保留 run 样式
- const segChars = charToRun.slice(seg.start, seg.end)
- const groups = []
- let curGroup = null
- for (const ch of segChars) {
- if (!curGroup || curGroup.runIdx !== ch.runIdx) {
- curGroup = { runIdx: ch.runIdx, startOffset: ch.offsetInRun, endOffset: ch.offsetInRun + 1 }
- groups.push(curGroup)
- } else {
- curGroup.endOffset = ch.offsetInRun + 1
- }
- }
- for (const g of groups) {
- const run = runs[g.runIdx]
- const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
- const rs = buildRunStyleStr(run)
- html += rs ? `<span style="${rs}">${slice}</span>` : slice
- }
- }
- }
- return html
- }
- // 切换视图模式时触发模板渲染
- watch(viewMode, (mode) => {
- if (mode === 'elements' && docContent.value) {
- renderDocHtmlTemplate()
- }
- })
- // 替换文档 blocks 中的旧值文本为新值
- function replaceTextInBlocks(blocks, oldText, newText) {
- for (const block of blocks) {
- if (block.type === 'table' && block.rows) {
- // 表格:遍历每行每个单元格
- for (const row of block.rows) {
- for (const cell of row) {
- if (cell.blocks) replaceTextInBlocks(cell.blocks, oldText, newText)
- }
- }
- continue
- }
- if (!block.runs || block.runs.length === 0) continue
- // 将 runs 拼接后检查是否包含旧值
- const fullText = block.runs.map(r => r.text).join('')
- if (!fullText.includes(oldText)) continue
- // 简单情况:旧值完整存在于单个 run 中
- let replaced = false
- for (const run of block.runs) {
- if (run.text && run.text.includes(oldText)) {
- run.text = run.text.replace(oldText, newText)
- replaced = true
- break
- }
- }
- if (replaced) continue
- // 复杂情况:旧值跨越多个 runs —— 重建 runs
- const newFullText = fullText.replace(oldText, newText)
- // 保留第一个 run 的样式,将所有文本合并到第一个 run
- if (block.runs.length > 0) {
- block.runs[0].text = newFullText
- block.runs.length = 1
- }
- }
- }
- // 获取 block 的纯文本
- function getBlockPlainText(block) {
- if (!block.runs) return ''
- return block.runs.map(r => r.text).join('').trim()
- }
- // 查找 block 文本是否匹配某个长文本要素
- function findBlockLongTextMatch(blockText, longTextLines) {
- if (!blockText) return null
- for (const lt of longTextLines) {
- if (lt.lines.has(blockText)) return lt
- }
- return null
- }
- // 查找表格是否匹配某个长文本要素(通过表格第一行文本匹配)
- function findTableLongTextMatch(block, longTextLines) {
- if (!block.table?.data?.length) return null
- const firstRowText = block.table.data[0].map(c => c.text).join(' | ')
- for (const lt of longTextLines) {
- if (lt.text.includes(firstRowText)) return lt
- }
- return null
- }
- function renderBlockHtml(block, shortMap, longMatch, countFn) {
- const tag = getBlockTag(block.type)
- const cls = `doc-block doc-${block.type}`
- const styleStr = buildStyleStr(block.style)
- const styleAttr = styleStr ? ` style="${styleStr}"` : ''
- const isToc = block.type?.startsWith('toc')
- let inner = ''
- // 图片
- if (block.images?.length > 0) {
- for (const img of block.images) {
- const imgStyle = []
- if (img.widthInch) imgStyle.push(`width:${img.widthInch}in`)
- if (img.heightInch) imgStyle.push(`height:${img.heightInch}in`)
- imgStyle.push('max-width:100%', 'display:block', 'margin:8px auto')
- inner += `<img src="${img.src}" style="${imgStyle.join(';')}" class="doc-inline-image" contenteditable="false" />`
- }
- }
- // Runs
- if (block.runs) {
- if (isToc) {
- // 目录项:忽略 run 内联样式,由 CSS 统一控制外观
- for (const run of block.runs) {
- inner += escapeHtml(run.text)
- }
- } else if (longMatch || shortMap.length === 0) {
- // 长文本高亮或无需短文本高亮,直接渲染
- for (const run of block.runs) {
- const text = escapeHtml(run.text)
- const rs = buildRunStyleStr(run)
- inner += rs ? `<span style="${rs}">${text}</span>` : text
- }
- if (longMatch) countFn(1)
- } else {
- // 短文本高亮:基于纯文本位置匹配,支持跨 run 拆分的文本
- const result = highlightRunsWithElements(block.runs, shortMap)
- inner += result.html
- if (result.count > 0) countFn(result.count)
- }
- }
- if (!inner) inner = ' '
- return `<${tag} class="${cls}"${styleAttr} data-block-id="${block.id}">${inner}</${tag}>`
- }
- function renderTableHtml(block, longMatch) {
- const t = block.table
- if (!t?.data) return ''
- let html = `<table class="doc-table" data-block-id="${block.id}">`
- for (let ri = 0; ri < t.data.length; ri++) {
- html += '<tr>'
- for (const cell of t.data[ri]) {
- const cs = cell.colspan > 1 ? ` colspan="${cell.colspan}"` : ''
- html += `<td class="doc-table-cell"${cs}>${escapeHtml(cell.text)}</td>`
- }
- html += '</tr>'
- }
- html += '</table>'
- if (longMatch) {
- const borderColor = darkenColor(longMatch.color)
- 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>`
- }
- return html
- }
- function getBlockTag(type) {
- if (type === 'heading1') return 'h1'
- if (type === 'heading2') return 'h2'
- if (type === 'heading3') return 'h3'
- if (type?.startsWith('toc')) return 'div'
- return 'p'
- }
- function buildStyleStr(style) {
- if (!style) return ''
- const parts = []
- if (style.alignment) {
- const map = { left: 'left', center: 'center', right: 'right', justify: 'justify', both: 'justify' }
- parts.push(`text-align:${map[style.alignment] || style.alignment}`)
- }
- if (style.indentLeft) parts.push(`padding-left:${style.indentLeft / 914400}in`)
- if (style.indentRight) parts.push(`padding-right:${style.indentRight / 914400}in`)
- if (style.indentFirstLine) parts.push(`text-indent:${style.indentFirstLine / 914400}in`)
- if (style.indentHanging) parts.push(`text-indent:-${style.indentHanging / 914400}in`)
- if (style.spacingBefore) parts.push(`margin-top:${style.spacingBefore / 914400}in`)
- if (style.spacingAfter) parts.push(`margin-bottom:${style.spacingAfter / 914400}in`)
- if (style.lineSpacing && style.lineSpacing >= 1 && style.lineSpacing <= 5) parts.push(`line-height:${style.lineSpacing}`)
- return parts.join(';')
- }
- function buildRunStyleStr(run) {
- const parts = []
- if (run.fontFamily) parts.push(`font-family:${run.fontFamily}`)
- if (run.fontSize) parts.push(`font-size:${run.fontSize}pt`)
- if (run.bold) parts.push('font-weight:bold')
- if (run.italic) parts.push('font-style:italic')
- if (run.color) parts.push(`color:${run.color.startsWith('#') ? run.color : '#' + run.color}`)
- if (run.underline) parts.push('text-decoration:underline')
- if (run.strikeThrough) parts.push('text-decoration:line-through')
- return parts.join(';')
- }
- function escapeHtml(text) {
- if (!text) return ''
- return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
- }
- function escapeAttr(text) {
- if (!text) return ''
- return text.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
- }
- // 基于纯文本位置的短文本高亮,支持跨 run 拆分的文本匹配
- function highlightRunsWithElements(runs, shortMap) {
- // 1. 构建纯文本和每个字符到 run 的映射
- let plainText = ''
- const charToRun = [] // charToRun[i] = { runIdx, offsetInRun }
- for (let ri = 0; ri < runs.length; ri++) {
- const t = runs[ri].text || ''
- for (let ci = 0; ci < t.length; ci++) {
- charToRun.push({ runIdx: ri, offsetInRun: ci })
- plainText += t[ci]
- }
- }
- // 2. 在纯文本中查找所有要素值的匹配位置
- const matches = [] // { start, end, em }
- for (const em of shortMap) {
- const val = em.text
- if (!val || val.length < 2) continue
- let pos = 0
- while (true) {
- const idx = plainText.indexOf(val, pos)
- if (idx < 0) break
- matches.push({ start: idx, end: idx + val.length, em })
- pos = idx + val.length
- }
- }
- // 3. 去重:按 start 排序,移除被更长匹配覆盖的(已按长度降序排列的 shortMap 保证优先)
- matches.sort((a, b) => a.start - b.start || b.end - a.end)
- const filtered = []
- let lastEnd = -1
- for (const m of matches) {
- if (m.start >= lastEnd) {
- filtered.push(m)
- lastEnd = m.end
- }
- }
- if (filtered.length === 0) {
- // 无匹配,直接渲染
- let html = ''
- for (const run of runs) {
- const text = escapeHtml(run.text)
- const rs = buildRunStyleStr(run)
- html += rs ? `<span style="${rs}">${text}</span>` : text
- }
- return { html, count: 0 }
- }
- // 4. 将纯文本按匹配区间切分为:普通段 + 高亮段
- const segments = [] // { start, end, em: null|object }
- let cursor = 0
- for (const m of filtered) {
- if (m.start > cursor) {
- segments.push({ start: cursor, end: m.start, em: null })
- }
- segments.push({ start: m.start, end: m.end, em: m.em })
- cursor = m.end
- }
- if (cursor < plainText.length) {
- segments.push({ start: cursor, end: plainText.length, em: null })
- }
- // 5. 对每个 segment,按 run 边界拆分并生成 HTML
- let html = ''
- for (const seg of segments) {
- const segChars = charToRun.slice(seg.start, seg.end)
- // 按 runIdx 分组
- const groups = []
- let curGroup = null
- for (const ch of segChars) {
- if (!curGroup || curGroup.runIdx !== ch.runIdx) {
- curGroup = { runIdx: ch.runIdx, startOffset: ch.offsetInRun, endOffset: ch.offsetInRun + 1 }
- groups.push(curGroup)
- } else {
- curGroup.endOffset = ch.offsetInRun + 1
- }
- }
- if (seg.em) {
- // 高亮段:静态要素用虚线淡色边框,动态要素用实线彩色边框
- const em = seg.em
- const isStatic = em.isStatic
- const borderStyle = isStatic
- ? 'border:1px dashed #ccc;border-radius:3px;padding:0 2px;cursor:pointer;opacity:0.7;background:#f5f5f5;'
- : `border:1.5px solid ${darkenColor(em.color)};border-radius:3px;padding:0 2px;cursor:pointer;background:${em.color};color:${darkenColor(em.color)};`
- const hlClass = isStatic ? 'elem-highlight elem-highlight-static' : 'elem-highlight'
- html += `<span class="${hlClass}" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="${borderStyle}" contenteditable="false" title="${escapeAttr(em.elementName)}">`
- for (const g of groups) {
- const run = runs[g.runIdx]
- const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
- const rs = buildRunStyleStr(run)
- html += rs ? `<span style="${rs}">${slice}</span>` : slice
- }
- html += '</span>'
- } else {
- // 普通段:保留 run 样式
- for (const g of groups) {
- const run = runs[g.runIdx]
- const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
- const rs = buildRunStyleStr(run)
- html += rs ? `<span style="${rs}">${slice}</span>` : slice
- }
- }
- }
- return { html, count: filtered.length }
- }
- function darkenColor(hex) {
- // 简单加深颜色用于下边框
- const map = {
- '#fff3cd': '#e0a800', '#cce5ff': '#3d8bfd', '#d4edda': '#28a745',
- '#f8d7da': '#dc3545', '#e2d5f1': '#6f42c1', '#d1ecf1': '#17a2b8',
- '#ffeeba': '#d39e00', '#c3e6cb': '#1e7e34', '#f5c6cb': '#c82333',
- '#d6d8db': '#6c757d'
- }
- return map[hex] || '#999'
- }
- // 文档编辑事件
- function onDocInput() {
- saved.value = false
- }
- // 点击文档中的高亮要素(mousedown 比 click 更可靠,在 contenteditable 容器内 e.target 更准确)
- function onDocClick(e) {
- console.log('[onDocClick] target:', e.target.tagName, e.target.className, 'closest:', e.target.closest('.elem-highlight')?.dataset?.elemKey)
- const target = e.target.closest('.elem-highlight') || e.target.closest('.elem-highlight-wrap') || e.target.closest('.elem-highlight-table')
- if (!target) {
- highlightPopover.visible = false
- return
- }
- const elemKey = target.dataset.elemKey
- const valueId = target.dataset.valueId
- const elem = elements.value.find(el => el.elementKey === elemKey)
- const val = values.value.find(v => String(v.valueId) === String(valueId)) ||
- values.value.find(v => stripValueKeyPrefix(v.elementKey) === elemKey)
- if (!elem || elem.elementType === 'static') return
- e.preventDefault()
- const rect = target.getBoundingClientRect()
- const scrollEl = editorRef.value
- const scrollRect = scrollEl?.getBoundingClientRect() || { top: 0, left: 0 }
- highlightPopover.elementKey = elemKey
- highlightPopover.fullElementKey = val?.elementKey || ''
- highlightPopover.elementName = elem.elementName
- highlightPopover.currentValue = val?.valueText || ''
- highlightPopover.originalValue = ''
- highlightPopover.valueId = val?.valueId || null
- highlightPopover.x = rect.left - scrollRect.left + scrollEl.scrollLeft
- highlightPopover.y = rect.bottom - scrollRect.top + scrollEl.scrollTop + 4
- highlightPopover.visible = true
- }
- async function savePopoverValue() {
- if (!highlightPopover.elementKey || !currentProjectId.value) {
- ElMessage.warning('无法保存:未找到对应的值记录')
- return
- }
- try {
- // 查找本地 value 记录,获取完整的 prefixed elementKey
- const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === highlightPopover.elementKey)
- const apiKey = highlightPopover.fullElementKey || val?.elementKey || highlightPopover.elementKey
- const oldValue = val?.valueText || ''
- const newValue = highlightPopover.currentValue
- // API: PUT /projects/{projectId}/values/{elementKey} with { valueText }
- await valueApi.update(currentProjectId.value, apiKey, { valueText: newValue })
- // 更新本地数据
- if (val) {
- val.valueText = newValue
- val.isFilled = !!newValue
- }
- // 替换文档 blocks 中的旧值文本为新值
- if (oldValue && newValue && oldValue !== newValue && docContent.value?.blocks) {
- replaceTextInBlocks(docContent.value.blocks, oldValue, newValue)
- }
- highlightPopover.visible = false
- renderDocHtml()
- ElMessage.success('要素值已更新')
- } catch (e) {
- console.error('[savePopoverValue] error:', e)
- ElMessage.error('保存失败: ' + (e.message || e))
- }
- }
- // ==================== 附件引用系统 ====================
- // 在解析结果中选中文本时触发
- function onParseResultMouseUp(e) {
- const sel = window.getSelection()
- const text = sel?.toString()?.trim()
- if (!text || text.length < 2) {
- citationToolbar.visible = false
- return
- }
- citationToolbar.selectedText = text
- // 定位浮动工具栏到选区末端
- const range = sel.getRangeAt(0)
- const rect = range.getBoundingClientRect()
- const dialogEl = e.currentTarget.closest('.el-dialog')
- const dialogRect = dialogEl?.getBoundingClientRect() || { top: 0, left: 0 }
- citationToolbar.x = rect.left - dialogRect.left + rect.width / 2 - 120
- citationToolbar.y = rect.bottom - dialogRect.top + 8
- // 引用模式下已锁定要素,直接进入选操作步骤
- if (referenceMode.value) {
- citationToolbar.step = 'select_action'
- } else {
- citationToolbar.step = 'select_action'
- }
- citationToolbar.visible = true
- }
- // 选择引用方式(直接引用/AI总结/表格提取)
- function setCitationAction(actionType) {
- citationToolbar.actionType = actionType
- if (referenceMode.value) {
- // 已锁定要素,直接确认创建规则
- const elem = elements.value.find(el => el.elementKey === referenceModeElementKey.value)
- if (elem) {
- confirmCitation(elem)
- } else {
- ElMessage.warning('目标要素不存在')
- }
- } else {
- // 未锁定要素,进入选择要素步骤
- citationToolbar.step = 'select_element'
- }
- }
- // 确认引用:创建规则
- async function confirmCitation(elem) {
- const selectedText = citationToolbar.selectedText
- const actionType = citationToolbar.actionType
- if (!selectedText || !actionType || !elem) return
- const projectId = currentProjectId.value
- if (!projectId) { ElMessage.warning('未选择项目'); return }
- try {
- const actionConfig = JSON.stringify({
- sourceText: selectedText,
- attachmentName: referenceModeAttName.value || parseResultAttName.value || '',
- })
- // 查找附件节点ID(如果有的话)
- const attId = referenceModeAttId.value || null
- const inputs = attId ? [{ sourceNodeId: attId, inputKey: 'attachment', inputType: 'ATTACHMENT', inputName: referenceModeAttName.value || parseResultAttName.value }] : []
- const ruleData = {
- elementKey: elem.elementKey,
- ruleName: `${actionType === 'quote' ? '引用' : actionType === 'summary' ? 'AI总结' : '表格提取'} - ${elem.elementName}`,
- ruleType: 'attachment_reference',
- actionType: actionType,
- actionConfig: actionConfig,
- description: `从附件「${referenceModeAttName.value || parseResultAttName.value}」${actionType === 'quote' ? '直接引用' : actionType === 'summary' ? 'AI总结' : '表格提取'}`,
- inputs: inputs
- }
- await ruleApi.create(projectId, ruleData)
- // 如果是直接引用,同时更新要素值
- if (actionType === 'quote') {
- const fullKey = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)?.elementKey || elem.elementKey
- await valueApi.update(projectId, fullKey, { valueText: selectedText, fillSource: 'rule' })
- const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
- if (val) {
- val.valueText = selectedText
- val.isFilled = true
- val.fillSource = 'rule'
- }
- renderDocHtml()
- }
- // 刷新规则列表
- rules.value = await ruleApi.list(projectId)
- citationToolbar.visible = false
- citationToolbar.selectedText = ''
- citationToolbar.actionType = ''
- window.getSelection()?.removeAllRanges()
- if (referenceMode.value) exitReferenceMode()
- ElMessage.success(
- actionType === 'quote'
- ? `已引用到「${elem.elementName}」并更新值`
- : `已创建${actionType === 'summary' ? 'AI总结' : '表格提取'}规则 → ${elem.elementName}`
- )
- } catch (e) {
- ElMessage.error('创建引用规则失败: ' + e.message)
- }
- }
- // 从要素弹出框进入引用模式 → 打开附件选择
- function enterReferenceModeFromPopover() {
- const elemKey = highlightPopover.elementKey
- const elemName = highlightPopover.elementName
- if (!elemKey) return
- highlightPopover.visible = false
- // 找已解析的附件
- const parsedAtts = attachments.value.filter(att => {
- const state = parseStates[att.id]
- return state?.status === 'completed' && state.markdown
- })
- if (parsedAtts.length === 0) {
- ElMessage.warning('没有已解析的附件,请先解析附件')
- return
- }
- // 如果只有一个已解析附件,直接打开
- if (parsedAtts.length === 1) {
- openAttachmentInReferenceMode(parsedAtts[0], elemKey, elemName)
- return
- }
- // 多个附件,弹出选择弹窗
- refAttSelectList.value = parsedAtts
- refAttSelectElemKey.value = elemKey
- refAttSelectElemName.value = elemName
- showRefAttSelectDialog.value = true
- }
- function onRefAttSelected(att) {
- showRefAttSelectDialog.value = false
- openAttachmentInReferenceMode(att, refAttSelectElemKey.value, refAttSelectElemName.value)
- }
- function openAttachmentInReferenceMode(att, elemKey, elemName) {
- referenceMode.value = true
- referenceModeElementKey.value = elemKey
- referenceModeElementName.value = elemName
- referenceModeAttId.value = typeof att.id === 'number' ? att.id : null
- referenceModeAttName.value = att.displayName
- const state = parseStates[att.id]
- parseResultAttName.value = att.displayName
- parseResultContent.value = state.markdown
- parseResultIsHtml.value = !!state.isHtml
- parseResultPreviewAvailable.value = false
- parseResultOriginAtt.value = att
- parseResultOriginZf.value = null
- previewContentType.value = ''
- parseResultViewMode.value = 'rendered'
- showParseResultDialog.value = true
- }
- function exitReferenceMode() {
- referenceMode.value = false
- referenceModeElementKey.value = ''
- referenceModeElementName.value = ''
- referenceModeAttId.value = null
- referenceModeAttName.value = ''
- citationToolbar.visible = false
- }
- // 要素/值
- function getElementValues(elementKey) { return values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elementKey) }
- function hasFilledValue(elementKey) { return values.value.some(v => stripValueKeyPrefix(v.elementKey) === elementKey && v.isFilled) }
- function onValueChange(val) { saved.value = false; val.isModified = true }
- async function handleAddElement() {
- if (!newElementForm.elementName || !newElementForm.elementKey) return
- try {
- const elem = await elementApi.add(currentProjectId.value, { ...newElementForm })
- elements.value.push(elem); showAddElementDialog.value = false
- Object.assign(newElementForm, { elementName: '', elementKey: '', dataType: 'text', description: '' })
- ElMessage.success('要素添加成功')
- } catch (e) { ElMessage.error('添加失败: ' + e.message) }
- }
- // 附件
- async function handleAttachmentUpload(file) {
- if (!currentProjectId.value) return
- try {
- const att = await attachmentApi.upload(currentProjectId.value, file.raw, file.name)
- attachments.value.push(att)
- // 缓存原始文件用于后续解析
- if (att?.id) attachmentFileCache.set(att.id, file.raw)
- ElMessage.success('附件上传成功')
- } catch (e) { ElMessage.error('上传失败: ' + e.message) }
- }
- function selectAttachment(att) { selectedAttachment.value = att }
- async function removeAttachment(att) {
- try {
- await ElMessageBox.confirm(`确定删除附件「${att.displayName}」?`, '删除确认', { type: 'warning' })
- await attachmentApi.delete(att.id)
- attachments.value = attachments.value.filter(a => a.id !== att.id); ElMessage.success('附件已删除')
- } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
- }
- function getFileExt(att) {
- const t = att.fileType || ''
- if (t) return t.toLowerCase()
- const name = att.displayName || att.fileName || ''
- const ext = name.split('.').pop()?.toLowerCase()
- return ext || ''
- }
- function getFileTypeClass(att) {
- const ext = getFileExt(att)
- if (ext === 'pdf') return 'type-pdf'
- if (ext === 'doc' || ext === 'docx') return 'type-word'
- if (ext === 'xls' || ext === 'xlsx') return 'type-excel'
- if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') return 'type-image'
- if (ext === 'zip' || ext === 'rar' || ext === '7z') return 'type-archive'
- return 'type-other'
- }
- function getFileTypeLabel(att) {
- const ext = getFileExt(att)
- if (ext === 'pdf') return 'PDF'
- if (ext === 'doc' || ext === 'docx') return 'W'
- if (ext === 'xls' || ext === 'xlsx') return 'X'
- if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') return '🖼'
- if (ext === 'zip' || ext === 'rar' || ext === '7z') return 'ZIP'
- return '📄'
- }
- function getFileTypeTag(att) {
- const ext = getFileExt(att)
- if (ext === 'pdf') return 'PDF'
- if (ext === 'doc' || ext === 'docx') return 'docx'
- if (ext === 'xls' || ext === 'xlsx') return 'xlsx'
- if (ext === 'png') return 'png'
- if (ext === 'jpg' || ext === 'jpeg') return 'jpg'
- if (ext === 'zip') return 'zip'
- return ext || '文件'
- }
- function getZipEntryTypeClass(zf) {
- const ext = zf.ext
- if (ext === 'pdf') return 'type-pdf'
- if (ext === 'doc' || ext === 'docx') return 'type-word'
- if (ext === 'xls' || ext === 'xlsx') return 'type-excel'
- if (ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif') return 'type-image'
- return 'type-other'
- }
- function getZipEntryTypeLabel(zf) {
- const ext = zf.ext
- if (ext === 'pdf') return 'PDF'
- if (ext === 'doc' || ext === 'docx') return 'W'
- if (ext === 'xls' || ext === 'xlsx') return 'X'
- if (['png', 'jpg', 'jpeg', 'gif'].includes(ext)) return '🖼'
- return '📄'
- }
- async function parseAllZipEntries() {
- const pending = zipFileList.value.filter(f => f.parseable && !f.parsed && !f.parsing)
- if (pending.length === 0) return
- ElMessage.info(`开始解析 ${pending.length} 个文件...`)
- for (const zf of pending) {
- await parseZipEntry(zf)
- }
- }
- function formatFileSize(bytes) {
- if (!bytes) return ''
- if (bytes < 1024) return bytes + 'B'
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + 'KB'
- return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
- }
- async function handleAttachmentAction(cmd, att) {
- switch (cmd) {
- case 'preview':
- selectAttachment(att)
- ElMessage.info('预览功能开发中')
- break
- case 'parse':
- await handleParseAttachment(att)
- break
- case 'view_result':
- viewParseResult(att)
- break
- case 'apply':
- ElMessage.info('应用要素功能开发中')
- break
- case 'download':
- ElMessage.info('下载功能开发中')
- break
- case 'delete':
- await removeAttachment(att)
- break
- }
- }
- function viewParseResult(att) {
- const state = parseStates[att.id]
- if (!state || state.status !== 'completed' || !state.markdown) {
- ElMessage.warning('该附件尚未解析或解析结果为空')
- return
- }
- parseResultAttName.value = att.displayName
- parseResultContent.value = state.markdown
- parseResultIsHtml.value = !!state.isHtml
- // 独立附件也支持原件预览(从缓存或 fileUrl 获取)
- const ext = getFileExt(att)
- parseResultPreviewAvailable.value = ['pdf', 'png', 'jpg', 'jpeg', 'gif', 'docx', 'doc'].includes(ext)
- parseResultOriginAtt.value = att
- parseResultOriginZf.value = null
- previewContentType.value = ''
- parseResultViewMode.value = 'rendered'
- showParseResultDialog.value = true
- }
- function copyParseResult() {
- if (!parseResultContent.value) return
- navigator.clipboard.writeText(parseResultContent.value).then(() => {
- ElMessage.success('已复制到剪贴板')
- }).catch(() => {
- ElMessage.error('复制失败')
- })
- }
- function getParseState(attId) {
- if (!parseStates[attId]) {
- parseStates[attId] = { status: 'idle', progress: '', markdown: '' }
- }
- return parseStates[attId]
- }
- function saveParseState(attId) {
- try {
- const saved = JSON.parse(localStorage.getItem('parseStates') || '{}')
- const state = parseStates[attId]
- if (state?.status === 'completed' && state.markdown) {
- saved[attId] = { status: 'completed', markdown: state.markdown, isHtml: !!state.isHtml }
- }
- try {
- localStorage.setItem('parseStates', JSON.stringify(saved))
- } catch (quotaErr) {
- // localStorage 空间不足(base64 图片太大),保存不含图片的版本
- console.warn('localStorage 空间不足,保存不含图片的精简版本')
- if (state?.markdown) {
- const lite = state.markdown
- .replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '')
- .replace(/src="data:[^"]+"/g, 'src=""')
- saved[attId] = { status: 'completed', markdown: lite, isHtml: !!state.isHtml, imagesStripped: true }
- }
- localStorage.setItem('parseStates', JSON.stringify(saved))
- }
- } catch (e) { console.warn('保存解析状态失败:', e) }
- }
- function restoreParseStates() {
- try {
- const saved = JSON.parse(localStorage.getItem('parseStates') || '{}')
- for (const [attId, data] of Object.entries(saved)) {
- if (data.status === 'completed' && data.markdown) {
- parseStates[attId] = { status: 'completed', progress: '解析完成', markdown: data.markdown, isHtml: !!data.isHtml }
- }
- }
- } catch (e) { console.warn('恢复解析状态失败:', e) }
- }
- function canParse(att) {
- const ext = getFileExt(att)
- return ['pdf', 'png', 'jpg', 'jpeg', 'docx', 'doc', 'zip'].includes(ext)
- }
- async function handleZipAttachment(att) {
- // 获取 ZIP 文件
- let file = attachmentFileCache.get(att.id)
- if (!file && att.fileUrl) {
- try {
- const resp = await fetch(att.fileUrl)
- if (resp.ok) {
- const blob = await resp.blob()
- file = new File([blob], att.displayName || 'file.zip', { type: blob.type })
- attachmentFileCache.set(att.id, file)
- }
- } catch (e) { console.warn('获取 ZIP 文件失败:', e) }
- }
- if (!file) {
- const picked = await new Promise((resolve) => {
- const input = document.createElement('input')
- input.type = 'file'
- input.accept = '.zip'
- input.onchange = (e) => resolve(e.target.files[0] || null)
- input.click()
- })
- if (!picked) return
- file = picked
- attachmentFileCache.set(att.id, file)
- }
- try {
- const zip = await JSZip.loadAsync(file)
- const parseableExts = ['pdf', 'png', 'jpg', 'jpeg', 'docx', 'doc']
- const files = []
- zip.forEach((relativePath, zipEntry) => {
- if (zipEntry.dir) return
- // 跳过 macOS 资源文件
- if (relativePath.startsWith('__MACOSX/') || relativePath.includes('/.')) return
- const name = relativePath
- const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''
- files.push({
- name,
- path: relativePath,
- size: zipEntry._data?.uncompressedSize || 0,
- ext,
- parseable: parseableExts.includes(ext),
- parsing: false,
- parsed: false,
- parseResult: '',
- isHtml: false
- })
- })
- // 按文件名排序,可解析的排前面
- files.sort((a, b) => {
- if (a.parseable && !b.parseable) return -1
- if (!a.parseable && b.parseable) return 1
- return a.name.localeCompare(b.name)
- })
- // 恢复之前的解析结果
- restoreZipParseStates(att.id, files)
- zipFileList.value = files
- zipInstance.value = zip
- zipParentAtt.value = att
- zipContentsAttName.value = att.displayName
- showZipContentsDialog.value = true
- } catch (e) {
- ElMessage.error('ZIP 解压失败: ' + (e.message || e))
- }
- }
- async function parseZipEntry(zf) {
- if (!zipInstance.value) return
- zf.parsing = true
- try {
- const zipEntry = zipInstance.value.file(zf.path)
- if (!zipEntry) throw new Error('文件不存在: ' + zf.path)
- const ext = zf.ext
- if (ext === 'docx' || ext === 'doc') {
- // DOCX: 提取为 blob,发送到后端解析
- const blob = await zipEntry.async('blob')
- const file = new File([blob], zf.name.split('/').pop(), {
- type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
- })
- const result = await attachmentApi.parseDocx(file)
- zf.parseResult = result.html || ''
- zf.isHtml = true
- } else if (ext === 'pdf' || ['png', 'jpg', 'jpeg'].includes(ext)) {
- // PDF/图片: 提取为 blob,发送到 GPU 解析服务
- const blob = await zipEntry.async('blob')
- const mimeMap = { pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg' }
- const file = new File([blob], zf.name.split('/').pop(), { type: mimeMap[ext] || 'application/octet-stream' })
- const submitResult = await parseApi.submit(file, { return_images: true })
- const taskId = submitResult.task_id
- if (!taskId) throw new Error('未返回任务ID')
- // 轮询
- let pollCount = 0
- const maxPolls = 300
- while (pollCount++ < maxPolls) {
- await new Promise(r => setTimeout(r, 2000))
- const statusResult = await parseApi.getStatus(taskId)
- if (statusResult.status === 'completed') {
- const result = await parseApi.getResult(taskId)
- let markdown = result.markdown || ''
- // 尝试从 zip 提取图片(如果 markdown 引用了 images/)
- const imageRefs = markdown.match(/!\[[^\]]*\]\(images\/[^)]+\)/g)
- if (imageRefs && imageRefs.length > 0) {
- try {
- const zipBlob = await parseApi.downloadZip(taskId)
- const imgZip = await JSZip.loadAsync(zipBlob)
- for (const ref of imageRefs) {
- const match = ref.match(/!\[([^\]]*)\]\((images\/[^)]+)\)/)
- if (!match) continue
- const [, , imgPath] = match
- let imgFile = imgZip.file(imgPath)
- if (!imgFile) {
- const fn = imgPath.split('/').pop()
- const cands = imgZip.file(new RegExp(fn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'))
- if (cands.length > 0) imgFile = cands[0]
- }
- if (imgFile) {
- const imgData = await imgFile.async('base64')
- const imgExt = imgPath.split('.').pop().toLowerCase()
- const mime = imgExt === 'png' ? 'image/png' : imgExt === 'gif' ? 'image/gif' : 'image/jpeg'
- markdown = markdown.replace(`(${imgPath})`, `(data:${mime};base64,${imgData})`)
- }
- }
- } catch (zipErr) { console.warn('提取解析图片失败:', zipErr) }
- }
- zf.parseResult = markdown
- zf.isHtml = false
- break
- } else if (statusResult.status === 'failed') {
- throw new Error('解析失败')
- }
- }
- if (!zf.parseResult) throw new Error('解析超时')
- }
- zf.parsed = true
- zf.parsing = false
- // 持久化 ZIP 解析结果
- if (zipParentAtt.value) saveZipParseStates(zipParentAtt.value.id)
- ElMessage.success(`「${zf.name.split('/').pop()}」解析完成`)
- } catch (e) {
- zf.parsing = false
- ElMessage.error(`解析失败: ${e.message || e}`)
- }
- }
- function viewZipEntryResult(zf) {
- parseResultAttName.value = zf.name.split('/').pop()
- parseResultContent.value = zf.parseResult
- parseResultIsHtml.value = !!zf.isHtml
- parseResultPreviewAvailable.value = !!zipInstance.value
- parseResultOriginZf.value = zf
- previewContentType.value = '' // 重置,切换到原件时才加载
- parseResultViewMode.value = 'rendered'
- showParseResultDialog.value = true
- }
- function saveZipParseStates(attId) {
- try {
- const allZip = JSON.parse(localStorage.getItem('zipParseStates') || '{}')
- const entries = {}
- for (const zf of zipFileList.value) {
- if (zf.parsed && zf.parseResult) {
- entries[zf.path] = { parseResult: zf.parseResult, isHtml: !!zf.isHtml }
- }
- }
- allZip[attId] = entries
- try {
- localStorage.setItem('zipParseStates', JSON.stringify(allZip))
- } catch (quotaErr) {
- console.warn('localStorage 空间不足,保存 ZIP 解析精简版')
- // 去掉 base64 图片数据再存
- for (const key of Object.keys(entries)) {
- entries[key].parseResult = entries[key].parseResult
- .replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '')
- .replace(/src="data:[^"]+"/g, 'src=""')
- entries[key].imagesStripped = true
- }
- allZip[attId] = entries
- localStorage.setItem('zipParseStates', JSON.stringify(allZip))
- }
- } catch (e) { console.warn('保存 ZIP 解析状态失败:', e) }
- }
- function restoreZipParseStates(attId, files) {
- try {
- const allZip = JSON.parse(localStorage.getItem('zipParseStates') || '{}')
- const entries = allZip[attId]
- if (!entries) return
- for (const zf of files) {
- const saved = entries[zf.path]
- if (saved && saved.parseResult) {
- zf.parsed = true
- zf.parseResult = saved.parseResult
- zf.isHtml = !!saved.isHtml
- }
- }
- } catch (e) { console.warn('恢复 ZIP 解析状态失败:', e) }
- }
- async function previewZipEntry(zf) {
- // 直接打开解析结果弹窗,默认切到原件预览模式
- parseResultAttName.value = zf.name.split('/').pop()
- parseResultContent.value = zf.parseResult || ''
- parseResultIsHtml.value = !!zf.isHtml
- parseResultPreviewAvailable.value = true
- parseResultOriginZf.value = zf
- parseResultViewMode.value = 'preview'
- showParseResultDialog.value = true
- await loadPreviewFromZip(zf)
- }
- async function switchToPreviewMode() {
- parseResultViewMode.value = 'preview'
- if (previewContentType.value) return // 已加载
- const zf = parseResultOriginZf.value
- if (zf) {
- await loadPreviewFromZip(zf)
- } else if (parseResultOriginAtt.value) {
- await loadPreviewFromAtt(parseResultOriginAtt.value)
- }
- }
- async function loadPreviewFromAtt(att) {
- // 清理旧的 blob URL
- if (previewContentUrl.value) {
- URL.revokeObjectURL(previewContentUrl.value)
- previewContentUrl.value = ''
- }
- const ext = getFileExt(att)
- try {
- // 获取原始文件:优先缓存,其次 fileUrl
- let file = attachmentFileCache.get(att.id)
- if (!file && att.fileUrl) {
- const resp = await fetch(att.fileUrl)
- if (resp.ok) {
- const blob = await resp.blob()
- file = new File([blob], att.displayName || 'file', { type: blob.type })
- }
- }
- if (!file) {
- ElMessage.warning('原始文件不可用,请重新上传后再预览')
- previewContentType.value = 'unsupported'
- return
- }
- if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
- previewContentUrl.value = URL.createObjectURL(file)
- previewContentType.value = 'image'
- } else if (ext === 'pdf') {
- const typedBlob = new Blob([file], { type: 'application/pdf' })
- previewContentUrl.value = URL.createObjectURL(typedBlob)
- previewContentType.value = 'pdf'
- } else if (ext === 'docx' || ext === 'doc') {
- // DOCX 已解析的直接用解析结果
- const state = parseStates[att.id]
- if (state?.isHtml && state.markdown) {
- previewContentHtml.value = state.markdown
- } else {
- const result = await attachmentApi.parseDocx(file)
- previewContentHtml.value = result.html || '<p>解析结果为空</p>'
- }
- previewContentType.value = 'html'
- } else {
- previewContentType.value = 'unsupported'
- }
- } catch (e) {
- ElMessage.error('预览失败: ' + (e.message || e))
- }
- }
- async function loadPreviewFromZip(zf) {
- if (!zipInstance.value) return
- const ext = zf.ext
- try {
- const zipEntry = zipInstance.value.file(zf.path)
- if (!zipEntry) { ElMessage.warning('文件不存在'); return }
- // 清理旧的 blob URL
- if (previewContentUrl.value) {
- URL.revokeObjectURL(previewContentUrl.value)
- previewContentUrl.value = ''
- }
- if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
- const blob = await zipEntry.async('blob')
- const mimeMap = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml' }
- const typedBlob = new Blob([blob], { type: mimeMap[ext] || 'image/png' })
- previewContentUrl.value = URL.createObjectURL(typedBlob)
- previewContentType.value = 'image'
- } else if (ext === 'pdf') {
- const blob = await zipEntry.async('blob')
- const typedBlob = new Blob([blob], { type: 'application/pdf' })
- previewContentUrl.value = URL.createObjectURL(typedBlob)
- previewContentType.value = 'pdf'
- } else if (ext === 'docx' || ext === 'doc') {
- if (zf.parsed && zf.parseResult && zf.isHtml) {
- previewContentHtml.value = zf.parseResult
- } else {
- const blob = await zipEntry.async('blob')
- const file = new File([blob], zf.name.split('/').pop(), {
- type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
- })
- const result = await attachmentApi.parseDocx(file)
- previewContentHtml.value = result.html || '<p>解析结果为空</p>'
- }
- previewContentType.value = 'html'
- } else if (['txt', 'md', 'csv', 'json', 'xml', 'yml', 'yaml', 'log', 'ini', 'cfg', 'conf', 'sh', 'bat', 'py', 'java', 'js', 'ts', 'html', 'css'].includes(ext)) {
- const text = await zipEntry.async('string')
- previewContentText.value = text
- previewContentType.value = 'text'
- } else {
- previewContentType.value = 'unsupported'
- }
- } catch (e) {
- ElMessage.error('预览失败: ' + (e.message || e))
- }
- }
- function cleanupPreviewContent() {
- if (previewContentUrl.value) {
- URL.revokeObjectURL(previewContentUrl.value)
- previewContentUrl.value = ''
- }
- previewContentHtml.value = ''
- previewContentText.value = ''
- previewContentType.value = ''
- parseResultPreviewAvailable.value = false
- parseResultOriginAtt.value = null
- parseResultOriginZf.value = null
- }
- async function handleParseAttachment(att) {
- const state = getParseState(att.id)
- if (state.status === 'uploading' || state.status === 'parsing') {
- ElMessage.warning('该附件正在解析中,请稍候')
- return
- }
- const ext = getFileExt(att)
- if (!canParse(att)) {
- ElMessage.warning('仅支持 PDF、DOCX、ZIP 和图片文件的解析')
- return
- }
- // ZIP 文件走解压展示流程
- if (ext === 'zip') {
- await handleZipAttachment(att)
- return
- }
- try {
- // 1. 获取缓存的原始文件
- state.status = 'uploading'
- state.progress = '正在准备文件...'
- let file = attachmentFileCache.get(att.id)
- if (!file && att.fileUrl) {
- // 从静态资源目录获取真实文件(mock 附件或有 fileUrl 的附件)
- state.progress = '正在获取文件...'
- try {
- const resp = await fetch(att.fileUrl)
- if (resp.ok) {
- const blob = await resp.blob()
- file = new File([blob], att.displayName || `file.${ext}`, { type: blob.type })
- attachmentFileCache.set(att.id, file)
- }
- } catch (fetchErr) {
- console.warn('获取附件文件失败:', fetchErr)
- }
- }
- if (!file) {
- // 缓存中没有且无 fileUrl,提示用户重新选择文件
- const reselected = await new Promise((resolve) => {
- const input = document.createElement('input')
- input.type = 'file'
- input.accept = '.pdf,.png,.jpg,.jpeg,.docx,.doc'
- input.onchange = (e) => resolve(e.target.files[0] || null)
- input.click()
- })
- if (!reselected) {
- state.status = 'idle'
- state.progress = ''
- return
- }
- file = reselected
- attachmentFileCache.set(att.id, file)
- }
- // 2. DOCX 走后端 Java 解析,PDF/图片走 GPU 解析服务
- if (ext === 'docx' || ext === 'doc') {
- state.status = 'parsing'
- state.progress = '正在解析 DOCX 文件...'
- ElMessage.info(`附件「${att.displayName}」开始解析`)
- try {
- const result = await attachmentApi.parseDocx(file)
- const html = result.html || ''
- state.markdown = html
- state.status = 'completed'
- state.progress = '解析完成'
- state.isHtml = true
- if (typeof att.id === 'number' || /^\d+$/.test(att.id)) {
- try { await attachmentApi.saveParsedContent(att.id, html) } catch (e) { console.warn('后端持久化失败:', e) }
- }
- att.parsed = true
- att.parsedText = html
- saveParseState(att.id)
- ElMessage.success(`附件「${att.displayName}」解析完成`)
- } catch (docxErr) {
- state.status = 'failed'
- state.progress = '解析失败'
- ElMessage.error(`DOCX 解析失败: ${docxErr.message || docxErr}`)
- }
- return
- }
- // PDF/图片:提交解析任务(启用图片返回)
- state.progress = '正在提交解析任务...'
- const submitResult = await parseApi.submit(file, { return_images: true })
- const taskId = submitResult.task_id
- if (!taskId) throw new Error('未返回任务ID')
- // 3. 轮询任务状态
- state.status = 'parsing'
- state.progress = '解析中...'
- state.taskId = taskId
- ElMessage.info(`附件「${att.displayName}」开始解析`)
- const maxPolls = 300 // 最多轮询 300 次 (约 10 分钟)
- let pollCount = 0
- const pollInterval = 2000 // 2秒一次
- const poll = async () => {
- pollCount++
- if (pollCount > maxPolls) {
- state.status = 'failed'
- state.progress = '解析超时'
- ElMessage.error('解析超时,请稍后重试')
- return
- }
- try {
- const statusResult = await parseApi.getStatus(taskId)
- const taskStatus = statusResult.status
- if (taskStatus === 'completed') {
- // 获取解析结果
- state.progress = '正在获取结果...'
- const result = await parseApi.getResult(taskId)
- let markdown = result.markdown || ''
- // 下载 zip 包提取图片,转为 base64 内嵌到 markdown
- const imageRefs = markdown.match(/!\[[^\]]*\]\(images\/[^)]+\)/g)
- if (imageRefs && imageRefs.length > 0) {
- state.progress = '正在获取图片...'
- try {
- const zipBlob = await parseApi.downloadZip(taskId)
- const zip = await JSZip.loadAsync(zipBlob)
- // 遍历所有图片引用,替换为 base64 data URL
- for (const ref of imageRefs) {
- const match = ref.match(/!\[([^\]]*)\]\((images\/[^)]+)\)/)
- if (!match) continue
- const [, alt, imgPath] = match
- // zip 中图片路径可能带前缀目录,尝试多种匹配
- let imgFile = zip.file(imgPath)
- if (!imgFile) {
- // 尝试在 zip 中搜索文件名
- const fileName = imgPath.split('/').pop()
- const candidates = zip.file(new RegExp(fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'))
- if (candidates.length > 0) imgFile = candidates[0]
- }
- if (imgFile) {
- const imgData = await imgFile.async('base64')
- const ext = imgPath.split('.').pop().toLowerCase()
- const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : 'image/jpeg'
- markdown = markdown.replace(`(${imgPath})`, `(data:${mime};base64,${imgData})`)
- }
- }
- } catch (zipErr) {
- console.warn('下载 zip 提取图片失败:', zipErr)
- }
- }
- state.markdown = markdown
- state.status = 'completed'
- state.progress = '解析完成'
- // 持久化解析结果到后端(仅真实附件,mock 附件 ID 为字符串跳过)
- if (typeof att.id === 'number' || /^\d+$/.test(att.id)) {
- try {
- await attachmentApi.saveParsedContent(att.id, markdown)
- } catch (e) {
- console.warn('后端持久化解析结果失败:', e)
- }
- }
- // 更新附件对象标记已解析
- att.parsed = true
- att.parsedText = markdown
- // 持久化到 localStorage(刷新后恢复)
- saveParseState(att.id)
- ElMessage.success(`附件「${att.displayName}」解析完成`)
- } else if (taskStatus === 'failed') {
- const errMsg = statusResult.error || '解析失败'
- state.status = 'failed'
- state.progress = errMsg
- ElMessage.error(`解析失败: ${errMsg}`)
- } else {
- // pending / processing
- state.progress = statusResult.progress || `解析中... (${pollCount})`
- setTimeout(poll, pollInterval)
- }
- } catch (e) {
- state.status = 'failed'
- state.progress = '查询状态失败'
- ElMessage.error('查询解析状态失败: ' + e.message)
- }
- }
- setTimeout(poll, pollInterval)
- } catch (e) {
- state.status = 'failed'
- state.progress = '解析失败'
- ElMessage.error('解析失败: ' + e.message)
- }
- }
- // 规则
- async function handleCreateRule() {
- if (!newRuleForm.ruleName) return
- try {
- const rule = await ruleApi.create(currentProjectId.value, { ...newRuleForm })
- rules.value.push(rule); showNewRuleDialog.value = false
- Object.assign(newRuleForm, { ruleName: '', ruleType: 'direct_entity', targetElementKey: '' })
- ElMessage.success('规则创建成功')
- } catch (e) { ElMessage.error('创建失败: ' + e.message) }
- }
- async function handleDeleteRule(rule) {
- try {
- await ElMessageBox.confirm(`确定删除规则「${rule.ruleName}」?`, '删除确认', { type: 'warning' })
- await ruleApi.delete(rule.id); rules.value = rules.value.filter(r => r.id !== rule.id); ElMessage.success('规则已删除')
- } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
- }
- async function handleExecuteRule(rule) {
- try { await ruleApi.execute(rule.id); ElMessage.success(`规则「${rule.ruleName}」执行成功`); await loadProjectData(currentProjectId.value) }
- catch (e) { ElMessage.error('执行失败: ' + e.message) }
- }
- async function handleBatchExecuteRules() {
- if (!currentProjectId.value) return
- executingRules.value = true
- try { await ruleApi.batchExecute(currentProjectId.value); ElMessage.success('批量执行完成'); await loadProjectData(currentProjectId.value) }
- catch (e) { ElMessage.error('执行失败: ' + e.message) }
- finally { executingRules.value = false }
- }
- // 工具函数
- function formatTime(dateStr) {
- if (!dateStr) return ''
- const date = new Date(dateStr); const now = new Date()
- const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24))
- if (diffDays === 0) return '今天'
- if (diffDays === 1) return '昨天'
- if (diffDays < 7) return `${diffDays}天前`
- return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
- }
- function getStatusText(status) {
- const map = { 'draft': '草稿', 'active': '进行中', 'archived': '已归档', 'completed': '已完成' }
- return map[status] || '草稿'
- }
- onMounted(async () => {
- await loadProjects()
- const pid = route.query.project
- if (pid) {
- const p = projects.value.find(p => String(p.id) === String(pid))
- if (p) await switchProject(p)
- }
- })
- </script>
- <style lang="scss" scoped>
- // ==========================================
- // Editor 页面样式 - 参考 V2 原型设计
- // ==========================================
- .editor-page {
- height: 100vh;
- display: flex;
- flex-direction: column;
- background: var(--bg);
- }
- .editor-body {
- flex: 1;
- display: flex;
- overflow: hidden;
- }
- // ==========================================
- // 拖拽分隔条
- // ==========================================
- .resize-handle {
- width: 4px;
- background: transparent;
- cursor: col-resize;
- flex-shrink: 0;
- position: relative;
- z-index: 10;
- transition: background 0.2s;
-
- &:hover, &:active {
- background: var(--primary);
- }
-
- &::before {
- content: '';
- position: absolute;
- top: 0;
- bottom: 0;
- left: -3px;
- right: -3px;
- }
- }
- // ==========================================
- // 左侧面板 - 参考设计风格
- // ==========================================
- .left-panel {
- background: var(--white);
- border-right: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- min-width: 260px;
- max-width: 420px;
- overflow: hidden;
- position: relative;
- // ---- 顶部 Logo ----
- .sidebar-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16px 18px 12px;
- flex-shrink: 0;
- .sidebar-logo {
- display: flex;
- align-items: center;
- gap: 8px;
- .logo-icon {
- font-size: 22px;
- color: var(--primary);
- line-height: 1;
- }
- .logo-text {
- font-size: 17px;
- font-weight: 700;
- color: var(--text-1);
- letter-spacing: 0.5px;
- }
- }
- .sidebar-header-actions {
- display: flex;
- gap: 4px;
- color: var(--text-3);
- }
- }
- // ---- 快捷导航 ----
- .sidebar-nav {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 0 14px 10px;
- flex-shrink: 0;
- .nav-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 9px 12px;
- border-radius: 8px;
- cursor: pointer;
- transition: background 0.15s;
- font-size: 14px;
- color: var(--text-1);
- &:hover {
- background: var(--bg);
- }
- .nav-icon {
- font-size: 16px;
- flex-shrink: 0;
- }
- .nav-label {
- font-weight: 500;
- }
- }
- }
- // ---- 区块通用 ----
- .sidebar-section {
- display: flex;
- flex-direction: column;
- padding: 0 14px;
- flex-shrink: 0;
- .section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 4px 8px;
- .section-title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-2);
- }
- .section-action {
- font-size: 12px;
- color: var(--text-3);
- cursor: pointer;
- transition: color 0.15s;
- &:hover {
- color: var(--primary);
- }
- }
- }
- .sidebar-search {
- margin-bottom: 8px;
- :deep(.el-input__wrapper) {
- border-radius: 8px;
- background: var(--bg);
- box-shadow: none;
- border: 1px solid transparent;
- &:hover, &.is-focus {
- border-color: var(--primary);
- background: var(--white);
- }
- }
- }
- }
- // ---- 文档列表 ----
- .doc-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
- overflow-y: auto;
- max-height: 340px;
- padding-bottom: 4px;
- }
- .doc-item {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- padding: 10px 12px;
- border-radius: 10px;
- cursor: pointer;
- transition: all 0.15s;
- position: relative;
- border: 1.5px solid transparent;
- &:hover {
- background: #f0f7ff;
- }
- &.active {
- background: #e6f0ff;
- border-color: var(--primary);
- .doc-item-title {
- color: var(--primary);
- }
- }
- .doc-icon-wrap {
- width: 36px;
- height: 40px;
- border-radius: 6px;
- background: #eef3ff;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- .doc-icon-glyph {
- font-size: 18px;
- }
- }
- .doc-item-body {
- flex: 1;
- min-width: 0;
- .doc-item-title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-1);
- line-height: 1.4;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- margin-bottom: 4px;
- }
- .doc-item-meta {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
- color: var(--text-3);
- flex-wrap: wrap;
- .doc-status-tag {
- font-size: 10px;
- height: 18px;
- line-height: 16px;
- padding: 0 6px;
- border-radius: 4px;
- }
- .doc-item-date {
- white-space: nowrap;
- }
- .doc-item-author {
- white-space: nowrap;
- }
- }
- }
- .doc-more-btn {
- opacity: 0;
- flex-shrink: 0;
- transition: opacity 0.15s;
- color: var(--text-3);
- margin-top: 2px;
- }
- &:hover .doc-more-btn {
- opacity: 1;
- }
- }
- .doc-empty {
- padding: 20px;
- text-align: center;
- color: var(--text-3);
- font-size: 13px;
- }
- .doc-loading {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- padding: 20px;
- color: var(--text-3);
- font-size: 13px;
- }
- // ---- 最近操作 ----
- .sidebar-activity {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- border-top: 1px solid var(--border);
- margin-top: 6px;
- padding-top: 4px;
- .activity-list {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .activity-item {
- padding: 8px 12px;
- border-radius: 8px;
- transition: background 0.15s;
- &:hover {
- background: var(--bg);
- }
- .activity-text {
- font-size: 13px;
- color: var(--text-1);
- line-height: 1.4;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .activity-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 3px;
- font-size: 11px;
- color: var(--text-3);
- .activity-source {
- color: var(--primary);
- }
- }
- }
- .activity-empty {
- padding: 20px;
- text-align: center;
- color: var(--text-3);
- font-size: 12px;
- }
- }
- // ---- 底部用户栏 ----
- .sidebar-footer {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px 16px;
- border-top: 1px solid var(--border);
- flex-shrink: 0;
- background: var(--white);
- .user-avatar {
- width: 34px;
- height: 34px;
- border-radius: 50%;
- background: linear-gradient(135deg, var(--primary), #69c0ff);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 14px;
- font-weight: 700;
- flex-shrink: 0;
- }
- .user-info {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- .user-name {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-1);
- line-height: 1.3;
- }
- .user-role {
- font-size: 11px;
- color: var(--text-3);
- display: flex;
- align-items: center;
- gap: 4px;
- &::before {
- content: '';
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #52c41a;
- flex-shrink: 0;
- }
- }
- }
- .footer-actions {
- display: flex;
- align-items: center;
- gap: 4px;
- :deep(.el-button.is-circle) {
- width: 32px;
- height: 32px;
- }
- :deep(.el-icon) {
- font-size: 18px;
- }
- :deep(.el-icon svg) {
- width: 18px;
- height: 18px;
- }
- .notification-badge {
- :deep(.el-badge__content) {
- font-size: 10px;
- height: 16px;
- line-height: 16px;
- padding: 0 4px;
- }
- }
- }
- }
- // ---- 覆盖层面板(附件/规则) ----
- .sidebar-overlay-panel {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: var(--white);
- z-index: 20;
- display: flex;
- flex-direction: column;
- .overlay-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 14px;
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
- .overlay-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-1);
- }
- }
- .overlay-body {
- flex: 1;
- overflow-y: auto;
- padding: 12px;
- }
- }
- // 覆盖层滑入动画
- .slide-right-enter-active {
- transition: transform 0.25s ease-out;
- }
- .slide-right-leave-active {
- transition: transform 0.2s ease-in;
- }
- .slide-right-enter-from {
- transform: translateX(-100%);
- }
- .slide-right-leave-to {
- transform: translateX(-100%);
- }
- }
- // ==========================================
- // 上传区 - V2 风格
- // ==========================================
- .upload-zone {
- border: 2px dashed var(--border);
- border-radius: var(--radius-lg);
- margin-bottom: 16px;
- height: 40px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--white);
- transition: all 0.2s;
- &:hover {
- border-color: var(--primary);
- background: var(--primary-light);
- }
- :deep(.el-upload-dragger) {
- padding: 0 12px;
- border: none;
- background: transparent;
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .upload-content {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .upload-icon {
- font-size: 18px;
- }
- .upload-text {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-1);
- }
- .upload-hint {
- display: block;
- font-size: 11px;
- color: var(--text-3);
- margin-top: 8px;
- text-align: center;
- }
- }
- .file-list {
- margin-bottom: 16px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
- // ==========================================
- // 文件项 - V2 风格
- // ==========================================
- .file-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px;
- background: var(--white);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- cursor: pointer;
- transition: all 0.2s;
- position: relative;
- &:hover {
- border-color: var(--primary);
- background: var(--primary-light);
- }
-
- &.active {
- border-color: var(--primary);
- background: var(--primary-light);
- }
- .file-icon {
- width: 40px;
- height: 40px;
- border-radius: var(--radius-sm);
- display: flex;
- align-items: center;
- justify-content: center;
- color: #fff;
- font-weight: 700;
- font-size: 13px;
- flex-shrink: 0;
-
- &.pdf { background: #ff6b6b; }
- &.docx, &.doc { background: #4dabf7; }
- &.xlsx, &.xls { background: #73d13d; }
- &.md { background: #9254de; }
- &.default { background: var(--text-3); }
- }
- .file-info {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- .file-name {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-1);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .file-meta {
- font-size: 11px;
- color: var(--text-3);
- margin-top: 4px;
- .required {
- color: var(--danger);
- }
- }
- }
-
- .file-status {
- font-size: 11px;
- white-space: nowrap;
-
- &.parsing { color: var(--primary); }
- &.done { color: var(--success); }
- }
- }
- .add-source-btn {
- width: 100%;
- border-radius: var(--radius-md);
- }
- // ==========================================
- // 附件面板 - V2 风格
- // ==========================================
- .att-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 12px;
- .att-count {
- font-size: 13px;
- color: var(--text-2);
- font-weight: 500;
- }
- }
- .att-list {
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .att-item {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 12px;
- border-radius: var(--radius-md);
- cursor: pointer;
- transition: all 0.15s;
- position: relative;
- &:hover {
- background: #f5f7fa;
- .att-more-btn { opacity: 1; }
- .att-parse-btn { opacity: 1; }
- }
- &.active {
- background: var(--primary-light);
- }
- .att-icon {
- width: 36px;
- height: 36px;
- border-radius: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #fff;
- font-weight: 700;
- font-size: 11px;
- flex-shrink: 0;
- letter-spacing: -0.5px;
- &.type-pdf { background: #e74c3c; }
- &.type-word { background: #3b82f6; }
- &.type-excel { background: #22c55e; }
- &.type-image { background: #f59e0b; font-size: 16px; }
- &.type-archive { background: #8b5cf6; }
- &.type-other { background: #94a3b8; }
- }
- .att-info {
- flex: 1;
- min-width: 0;
- .att-name {
- font-size: 13px;
- font-weight: 500;
- color: var(--text-1);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.4;
- }
- .att-meta {
- display: flex;
- align-items: center;
- gap: 6px;
- margin-top: 2px;
- font-size: 11px;
- color: var(--text-3);
- .att-type {
- color: var(--primary);
- font-weight: 500;
- }
- .att-size {
- &::before {
- content: '·';
- margin-right: 6px;
- color: var(--text-3);
- }
- }
- }
- }
- .att-parse-btn {
- flex-shrink: 0;
- font-size: 12px;
- padding: 2px 8px;
- }
- .att-more-btn {
- opacity: 0;
- transition: opacity 0.15s;
- flex-shrink: 0;
- }
- .att-parse-status {
- display: inline-flex;
- align-items: center;
- gap: 3px;
- font-size: 11px;
- margin-left: 4px;
- &.parsing {
- color: #e6a23c;
- }
- &.completed {
- color: #67c23a;
- }
- &.failed {
- color: #f56c6c;
- }
- }
- }
- // ==========================================
- // 中间面板 - V2 风格
- // ==========================================
- .center-panel {
- flex: 1;
- display: flex;
- flex-direction: column;
- background: var(--white);
- overflow: hidden;
- border-radius: var(--radius-md);
- margin: 0 8px;
- box-shadow: var(--shadow-sm);
- // ==========================================
- // 欢迎页 - V2 风格
- // ==========================================
- .welcome-page {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--white);
-
- .welcome-content {
- text-align: center;
- max-width: 600px;
- padding: 48px;
- }
-
- .welcome-logo {
- width: 80px;
- height: 80px;
- margin: 0 auto 32px;
- background: linear-gradient(135deg, var(--primary) 0%, #69c0ff 100%);
- border-radius: 16px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 40px;
- font-weight: 700;
- color: white;
- box-shadow: 0 12px 32px rgba(24, 144, 255, 0.3);
- }
-
- .welcome {
- h1 {
- font-size: 28px;
- font-weight: 700;
- color: var(--text-1);
- margin-bottom: 12px;
- line-height: 1.4;
-
- span {
- display: block;
- font-size: 20px;
- font-weight: 500;
- background: var(--ai-gradient);
- background-clip: text;
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- margin-top: 8px;
- }
- }
-
- p {
- font-size: 15px;
- color: var(--text-3);
- line-height: 1.6;
- }
- }
- }
- // ==========================================
- // 编辑器标题栏 - 参考设计风格
- // ==========================================
- .editor-title-bar {
- padding: 0 16px;
- height: 48px;
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- justify-content: space-between;
- background: var(--white);
- flex-shrink: 0;
- .titlebar-left {
- display: flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- flex: 1;
- .titlebar-folder-icon {
- font-size: 18px;
- color: var(--text-3);
- flex-shrink: 0;
- }
- .titlebar-sep {
- color: var(--text-3);
- font-size: 14px;
- flex-shrink: 0;
- }
- .titlebar-project-name {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-1);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 360px;
- }
- .titlebar-status-tag {
- flex-shrink: 0;
- margin-left: 4px;
- border-radius: 4px;
- font-size: 11px;
- }
- }
- .titlebar-right {
- display: flex;
- align-items: center;
- gap: 4px;
- flex-shrink: 0;
- .titlebar-save-status {
- display: flex;
- align-items: center;
- gap: 5px;
- font-size: 12px;
- color: var(--text-3);
- white-space: nowrap;
- margin-right: 4px;
- .save-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: #bbb;
- &.saved {
- background: #52c41a;
- }
- }
- }
- :deep(.el-button.is-circle) {
- width: 32px;
- height: 32px;
- }
- :deep(.el-icon) {
- font-size: 16px;
- }
- :deep(.el-button.is-active-view) {
- color: var(--primary);
- background: var(--primary-light);
- }
- :deep(.el-divider--vertical) {
- height: 20px;
- margin: 0 4px;
- }
- }
- }
- // ==========================================
- // 编辑器滚动区 - V2 风格
- // ==========================================
- .editor-scroll {
- flex: 1;
- overflow-y: auto;
- padding: 40px 48px;
- background: var(--white);
- position: relative;
- }
- .editor-content {
- max-width: 1000px;
- margin: 0 auto;
- outline: none;
-
- // 文档块样式
- :deep(.doc-block) {
- position: relative;
- transition: background-color 0.2s;
-
- &:hover {
- background-color: rgba(24, 144, 255, 0.02);
- }
-
- // 被选中时的样式
- &.selected {
- background-color: rgba(24, 144, 255, 0.08);
- outline: 1px dashed var(--primary);
- }
- }
- :deep(h1) {
- font-size: 24px;
- font-weight: 700;
- margin-bottom: 24px;
- }
- :deep(h2) {
- font-size: 18px;
- font-weight: 600;
- margin: 28px 0 16px;
- }
- :deep(p) {
- margin-bottom: 12px;
- line-height: 1.6;
- }
- :deep(ul) {
- margin-bottom: 16px;
- padding-left: 24px;
- li {
- margin-bottom: 8px;
- }
- }
-
- // 目录样式
- :deep(.doc-toc-title) {
- font-size: 18pt;
- font-weight: bold;
- text-align: center;
- margin: 20px 0 16px;
- }
-
- :deep(.doc-toc-item) {
- display: flex;
- align-items: baseline;
- padding: 6px 0;
- line-height: 1.6;
- cursor: pointer;
- transition: background-color 0.2s;
-
- &:hover {
- background-color: #f5f5f5;
- }
-
- .toc-title {
- flex-shrink: 0;
- white-space: nowrap;
- }
-
- .toc-dots {
- flex: 1;
- border-bottom: 1px dotted #999;
- margin: 0 8px;
- min-width: 20px;
- height: 0.6em;
- }
-
- .toc-page {
- flex-shrink: 0;
- color: #666;
- min-width: 20px;
- text-align: right;
- }
- }
-
- // 表格样式
- :deep(.doc-table-container) {
- margin: 16px 0;
- overflow-x: auto;
- }
-
- :deep(.doc-table) {
- width: 100%;
- border-collapse: collapse;
- font-size: 14px;
-
- th, td {
- border: 1px solid #ddd;
- padding: 8px 12px;
- text-align: left;
- vertical-align: top;
- line-height: 1.5;
- }
-
- th {
- background-color: #f5f5f5;
- font-weight: bold;
- }
-
- tr:nth-child(even) td {
- background-color: #fafafa;
- }
-
- tr:hover td {
- background-color: #f0f7ff;
- }
- }
-
- :deep(.doc-table-empty) {
- padding: 20px;
- text-align: center;
- color: #999;
- border: 1px dashed #ddd;
- margin: 16px 0;
- }
-
- // 列表项样式
- :deep(.doc-list-item) {
- position: relative;
- margin-bottom: 8px;
- line-height: 1.6;
-
- &.bullet {
- padding-left: 1.5em;
- &::before {
- content: '•';
- position: absolute;
- left: 0;
- }
- }
-
- &.ordered {
- padding-left: 2em;
- counter-increment: doc-list;
- &::before {
- content: counter(doc-list) '.';
- position: absolute;
- left: 0;
- }
- }
- }
-
- // 重置列表计数器
- :deep(p + .doc-list-item.ordered:first-of-type),
- :deep(.doc-list-item.bullet + .doc-list-item.ordered) {
- counter-reset: doc-list;
- }
-
- // 块引用样式
- :deep(blockquote) {
- margin: 16px 0;
- padding: 12px 20px;
- border-left: 4px solid #ddd;
- background: #f9f9f9;
- color: #666;
- }
-
- // 代码块样式
- :deep(pre) {
- margin: 16px 0;
- padding: 16px;
- background: #f5f5f5;
- border-radius: 4px;
- overflow-x: auto;
-
- code {
- font-family: 'Consolas', 'Monaco', monospace;
- font-size: 13px;
- }
- }
-
- // 实体高亮样式 - 匹配原型 UI(边框 + 背景)
- :deep(.entity-highlight) {
- display: inline;
- padding: 2px 8px;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.2s;
- font-weight: 500;
- border: 1px solid #1890ff;
- color: #1890ff;
- background: rgba(24, 144, 255, 0.1);
-
- &:hover {
- background: #1890ff;
- color: white;
- }
-
- // 实体类型颜色
- &.entity {
- border-color: #1890ff;
- color: #1890ff;
- background: rgba(24, 144, 255, 0.1);
- &:hover { background: #1890ff; color: white; }
- }
-
- &.concept {
- border-color: #722ed1;
- color: #722ed1;
- background: rgba(114, 46, 209, 0.1);
- &:hover { background: #722ed1; color: white; }
- }
-
- &.data {
- border-color: #52c41a;
- color: #52c41a;
- background: rgba(82, 196, 26, 0.1);
- &:hover { background: #52c41a; color: white; }
- }
-
- &.location {
- border-color: #faad14;
- color: #d48806;
- background: rgba(250, 173, 20, 0.1);
- &:hover { background: #faad14; color: white; }
- }
-
- &.asset {
- border-color: #eb2f96;
- color: #eb2f96;
- background: rgba(235, 47, 150, 0.1);
- &:hover { background: #eb2f96; color: white; }
- }
-
- &.person {
- border-color: #1890ff;
- color: #1890ff;
- background: rgba(24, 144, 255, 0.1);
- &:hover { background: #1890ff; color: white; }
- }
-
- &.org {
- border-color: #722ed1;
- color: #722ed1;
- background: rgba(114, 46, 209, 0.1);
- &:hover { background: #722ed1; color: white; }
- }
-
- &.date {
- border-color: #13c2c2;
- color: #13c2c2;
- background: rgba(19, 194, 194, 0.1);
- &:hover { background: #13c2c2; color: white; }
- }
-
- &.product {
- border-color: #eb2f96;
- color: #eb2f96;
- background: rgba(235, 47, 150, 0.1);
- &:hover { background: #eb2f96; color: white; }
- }
-
- &.event {
- border-color: #fa8c16;
- color: #fa8c16;
- background: rgba(250, 140, 22, 0.1);
- &:hover { background: #fa8c16; color: white; }
- }
-
- &.law {
- border-color: #2f54eb;
- color: #2f54eb;
- background: rgba(47, 84, 235, 0.1);
- &:hover { background: #2f54eb; color: white; }
- }
-
- // 未确认的 AI 建议(文档中虚线样式)
- &.ai-suggestion-pending {
- border-style: dashed;
- opacity: 0.9;
- }
-
- // 点击 AI 建议后,文档中该要素的「待确认」高亮
- &.entity-pending-confirm {
- box-shadow: 0 0 0 2px #1890ff;
- opacity: 1;
- }
- }
- }
- }
- // ==========================================
- // 右侧面板 - 参考设计风格
- // ==========================================
- .right-panel {
- background: var(--white);
- border-left: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- min-width: 280px;
- max-width: 420px;
- overflow: hidden;
- // ---- 报告要素区 ----
- .rp-elements {
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- max-height: 45%;
- border-bottom: 1px solid var(--border);
- }
- .rp-elements-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 16px 10px;
- flex-shrink: 0;
- .rp-elements-title {
- display: flex;
- align-items: center;
- gap: 6px;
- .rp-title-icon { font-size: 16px; }
- .rp-title-text { font-size: 14px; font-weight: 700; color: var(--text-1); }
- }
- }
- .rp-elements-body {
- flex: 1;
- overflow-y: auto;
- padding: 0 16px 14px;
- }
- .rp-value-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- }
- .rp-value-tag {
- display: inline-block;
- padding: 5px 14px;
- background: #e8f4ff;
- color: #1677ff;
- border-radius: 16px;
- font-size: 13px;
- font-weight: 500;
- line-height: 1.4;
- cursor: default;
- transition: background 0.15s;
- max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- &:hover {
- background: #d0e8ff;
- }
- }
- .rp-elements-empty {
- padding: 20px;
- text-align: center;
- color: var(--text-3);
- font-size: 13px;
- }
- // ---- AI 助手区 ----
- .rp-ai {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0;
- }
- .rp-ai-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px 8px;
- flex-shrink: 0;
- .rp-ai-title {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 14px;
- font-weight: 700;
- color: var(--text-1);
- .rp-ai-icon { font-size: 16px; }
- }
- .rp-ai-actions {
- display: flex;
- gap: 4px;
- }
- }
- .rp-ai-messages {
- flex: 1;
- overflow-y: auto;
- padding: 8px 16px;
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- .ai-message {
- display: flex;
- &.ai-user {
- justify-content: flex-end;
- .ai-bubble {
- background: var(--primary);
- color: #fff;
- border-radius: 16px 16px 4px 16px;
- }
- }
- &.ai-bot {
- justify-content: flex-start;
- .ai-bubble {
- background: #f4f5f7;
- color: var(--text-1);
- border-radius: 16px 16px 16px 4px;
- }
- }
- }
- .ai-bubble {
- max-width: 85%;
- padding: 10px 14px;
- font-size: 13px;
- line-height: 1.6;
- word-break: break-word;
- }
- .rp-ai-input {
- flex-shrink: 0;
- padding: 10px 14px 12px;
- border-top: 1px solid var(--border);
- :deep(.el-textarea__inner) {
- border-radius: 12px;
- background: #f7f8fa;
- border: 1px solid var(--border);
- padding: 10px 14px;
- font-size: 13px;
- line-height: 1.5;
- &:focus {
- border-color: var(--primary);
- background: var(--white);
- }
- }
- }
- .rp-ai-input-actions {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-top: 6px;
- padding: 0 2px;
- :deep(.el-button.is-circle) {
- width: 32px;
- height: 32px;
- font-size: 18px;
- }
- :deep(.el-button--primary.is-circle) {
- width: 34px;
- height: 34px;
- }
- :deep(.el-icon) {
- font-size: 18px;
- }
- :deep(.el-icon svg) {
- width: 18px;
- height: 18px;
- }
- }
- .rp-ai-input-tools,
- .rp-ai-input-right {
- display: flex;
- align-items: center;
- gap: 4px;
- }
- }
- // ==========================================
- // 要素管理区 - V2 风格
- // ==========================================
- .element-section {
- padding: 16px;
- border-bottom: 1px dashed var(--border);
-
- // 模块标题样式 - V2 风格
- .module-title {
- display: flex;
- align-items: center;
- gap: 10px;
- font-size: 15px;
- font-weight: 700;
- color: var(--text-1);
- margin-bottom: 14px;
-
- .module-icon {
- width: 36px;
- height: 36px;
- border-radius: 8px;
- background: var(--primary-gradient);
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 18px;
- color: white;
- box-shadow: var(--shadow-md);
- }
- }
- .element-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 12px;
- .element-title {
- font-size: 13px;
- font-weight: 600;
- display: flex;
- align-items: center;
- gap: 6px;
- .element-count {
- font-size: 11px;
- color: var(--text-3);
- font-weight: normal;
- }
- }
-
- .header-actions {
- display: flex;
- gap: 4px;
-
- .el-button {
- padding: 4px 8px;
- font-size: 12px;
- }
- }
- }
-
- // AI 建议区块特殊样式
- &.ai-section {
- background: var(--bg);
- border-bottom: none;
-
- .element-header {
- .element-title {
- color: var(--text-2);
- }
- }
-
- .element-option.ai-highlight-option {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 16px;
- font-size: 12px;
- color: var(--text-2);
- .option-label {
- flex: 1;
- }
- }
-
- .element-tags-wrap {
- max-height: 300px;
- }
- }
-
- // 要素 Tab 切换 - V2 风格
- .element-tabs {
- display: flex;
- gap: 8px;
-
- .element-tab {
- padding: 6px 12px;
- border-radius: 12px;
- background: transparent;
- border: 1px solid transparent;
- font-size: 13px;
- cursor: pointer;
- color: var(--text-2);
- transition: all 0.2s;
-
- &:hover {
- background: var(--bg);
- }
-
- &.active {
- background: var(--primary);
- color: #fff;
- border-color: rgba(0, 0, 0, 0.04);
- box-shadow: var(--shadow-md);
- }
- }
- }
- .element-filter {
- padding: 0 0 12px;
-
- .entity-search {
- margin-bottom: 12px;
-
- :deep(.el-input__wrapper) {
- border-radius: 18px;
- background: var(--bg);
- box-shadow: none;
- border: 1px solid var(--border);
-
- &:hover, &.is-focus {
- border-color: var(--primary);
- background: var(--white);
- }
- }
- }
-
- .entity-type-filter {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-
- .filter-tag {
- cursor: pointer;
- transition: all 0.2s;
- border-radius: 12px;
- font-size: 11px;
-
- &:hover {
- border-color: var(--primary);
- color: var(--primary);
- }
-
- &.active {
- background: var(--primary);
- color: white;
- border-color: var(--primary);
- }
-
- &.clear {
- background: transparent;
- border-style: dashed;
- color: var(--text-3);
-
- &:hover {
- border-color: var(--danger);
- color: var(--danger);
- }
- }
- }
- }
- }
- .element-body {
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 16px;
- }
- // 要素标签容器 - V2 风格
- .element-tags-wrap {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- max-height: 200px;
- overflow-y: auto;
- padding-right: 4px;
- padding-bottom: 16px;
-
- &::-webkit-scrollbar {
- width: 4px;
- }
-
- &::-webkit-scrollbar-track {
- background: var(--bg);
- border-radius: 2px;
- }
-
- &::-webkit-scrollbar-thumb {
- background: var(--border);
- border-radius: 2px;
-
- &:hover {
- background: var(--text-3);
- }
- }
- }
-
- // ==========================================
- // 要素标签样式 - V2 风格
- // ==========================================
- .var-tag {
- height: 28px;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 0 12px;
- border-radius: 2px;
- font-size: 12px;
- cursor: pointer;
- transition: all 0.2s;
- background: var(--bg);
- border: 1px solid var(--border);
- user-select: none;
-
- &:hover {
- border-color: var(--primary);
- background: var(--primary-light);
- transform: translateY(-1px);
- }
-
- &:active {
- cursor: grabbing;
- }
-
- .tag-icon {
- font-size: 12px;
- }
-
- .tag-name {
- max-width: 120px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-weight: 500;
- line-height: 28px;
- }
-
- .tag-status {
- color: #52c41a;
- font-size: 10px;
- }
-
- .tag-action {
- color: var(--primary);
- font-size: 14px;
- font-weight: bold;
- margin-left: 2px;
- }
-
- // 已确认的要素
- &.confirmed {
- background: var(--white);
- border-color: var(--primary);
-
- .tag-name {
- color: var(--text-1);
- }
- }
-
- // AI 建议的要素(虚线边框、淡色)
- &.ai-suggestion {
- background: transparent;
- border-style: dashed;
- border-color: var(--border);
- opacity: 0.85;
-
- .tag-name {
- color: var(--text-2);
- }
-
- &:hover {
- opacity: 1;
- border-color: var(--primary);
- border-style: solid;
- background: var(--primary-light);
-
- .tag-action {
- transform: scale(1.2);
- }
- }
- }
-
- // 动态要素样式(圆角)
- &.dynamic {
- border-radius: 14px;
- }
-
- // 静态要素样式(微圆角)
- &.static {
- border-radius: 2px;
- }
-
- // 已确认状态
- &.confirmed {
- background: rgba(82, 196, 26, 0.1);
- border-color: #52c41a;
-
- .tag-name {
- color: #389e0d;
- }
- }
-
- // 实体类型样式 - 左边框颜色区分
- &.entity-person, &.entity {
- border-left: 3px solid var(--primary);
- }
- &.entity-org, &.concept {
- border-left: 3px solid #722ed1;
- }
- &.entity-location, &.location {
- border-left: 3px solid var(--warning);
- }
- &.entity-date {
- border-left: 3px solid #13c2c2;
- }
- &.entity-data, &.data {
- border-left: 3px solid var(--success);
- }
- &.entity-product, &.asset {
- border-left: 3px solid #eb2f96;
- }
- &.entity-event {
- border-left: 3px solid #fa8c16;
- }
- &.entity-law {
- border-left: 3px solid #2f54eb;
- }
- &.entity-default {
- border-left: 3px solid #8c8c8c;
- }
-
- // 当前正在确认的 AI 建议 tag
- &.is-pending {
- border-color: var(--primary);
- background: var(--primary-light);
- border-style: solid;
- }
- }
-
- // AI 建议确认栏已移至「+」按钮的悬浮框内,此处样式仅作保留注释
- .element-hint {
- font-size: 12px;
- color: var(--text-3);
- text-align: center;
- padding: 24px;
- }
- }
- // 实体高亮闪烁效果
- @keyframes entity-flash {
- 0%, 100% { background-color: inherit; }
- 50% { background-color: #ffe58f; }
- }
- .entity-highlight-flash {
- animation: entity-flash 0.5s ease-in-out 3;
- }
- // 实体编辑弹窗样式
- .entity-edit-form {
- .entity-edit-preview {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 10px;
- padding: 16px;
- background: var(--primary-light);
- border: 1px dashed var(--primary);
- border-radius: 8px;
- margin-bottom: 20px;
-
- .preview-icon {
- font-size: 24px;
- }
-
- .preview-text {
- font-size: 16px;
- font-weight: 600;
- color: var(--primary);
- }
- }
- }
- .category-section {
- padding: 12px 16px;
- border-bottom: 1px solid var(--border);
- .category-header {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 12px;
- font-weight: 600;
- margin-bottom: 10px;
- .category-dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- }
- .category-count {
- color: var(--text-3);
- font-weight: normal;
- background: var(--bg);
- padding: 2px 8px;
- border-radius: 10px;
- }
- }
- .category-items {
- .category-item {
- display: flex;
- justify-content: space-between;
- padding: 8px 12px;
- background: var(--bg);
- border-radius: 6px;
- margin-bottom: 6px;
- cursor: pointer;
- font-size: 12px;
- transition: all 0.2s;
- &:hover {
- background: var(--primary-light);
- }
- .item-value {
- color: var(--text-3);
- }
- }
- }
- }
- // ==========================================
- // 右键菜单 - V2 风格
- // ==========================================
- .context-menu {
- position: fixed;
- min-width: 180px;
- background: var(--white);
- border-radius: var(--radius-md);
- box-shadow: var(--shadow-lg);
- z-index: 3000;
- overflow: hidden;
- .context-menu-header {
- padding: 12px 14px;
- background: var(--bg);
- border-bottom: 1px solid var(--border);
-
- .selected-preview {
- font-size: 12px;
- color: var(--primary);
- font-weight: 600;
- max-width: 150px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
-
- .context-menu-section {
- padding: 8px 14px 4px;
- font-size: 10px;
- color: var(--text-3);
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- }
- .context-menu-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 14px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- color: var(--text-1);
- position: relative;
- &:hover {
- background: var(--primary-light);
- color: var(--primary);
- }
-
- &[disabled="true"] {
- opacity: 0.5;
- pointer-events: none;
- }
- .icon {
- font-size: 14px;
- width: 20px;
- text-align: center;
- flex-shrink: 0;
- }
-
- .shortcut {
- margin-left: auto;
- font-size: 11px;
- color: var(--text-3);
- }
-
- .submenu-arrow {
- margin-left: auto;
- font-size: 14px;
- color: var(--text-3);
- }
-
- &.has-submenu {
- &:hover .submenu-arrow {
- color: var(--primary);
- }
- }
- }
-
- // 子菜单
- .context-submenu {
- position: absolute;
- left: 100%;
- top: 0;
- min-width: 150px;
- background: var(--white);
- border-radius: var(--radius-md);
- box-shadow: var(--shadow-lg);
- overflow: hidden;
-
- .context-menu-item {
- padding: 8px 12px;
- font-size: 12px;
- gap: 8px;
-
- .icon {
- font-size: 12px;
- width: 16px;
- }
- }
- }
-
- .context-menu-divider {
- height: 1px;
- background: var(--border);
- margin: 4px 0;
- }
-
- .context-menu-loading {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- padding: 12px;
- color: var(--primary);
- font-size: 12px;
- border-top: 1px solid var(--border);
- background: var(--bg);
- }
- }
- // ==========================================
- // 实体弹出框样式 - V2 风格
- // ==========================================
- .entity-popover {
- .entity-popover-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 10px;
-
- .entity-text {
- font-weight: 600;
- font-size: 14px;
- max-width: 140px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--text-1);
- }
- }
-
- .entity-popover-type {
- font-size: 12px;
- color: var(--text-2);
- margin-bottom: 14px;
- padding: 4px 8px;
- background: var(--bg);
- border-radius: 4px;
- display: inline-block;
- }
-
- .entity-popover-actions {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
-
- :deep(.el-button) {
- border-radius: var(--radius-sm);
- }
- }
- }
- // ==========================================
- // 知识图谱容器 - V2 风格
- // ==========================================
- .graph-container {
- height: 500px;
- position: relative;
- background: linear-gradient(45deg, #f8f9fa 25%, transparent 25%),
- linear-gradient(-45deg, #f8f9fa 25%, transparent 25%),
- linear-gradient(45deg, transparent 75%, #f8f9fa 75%),
- linear-gradient(-45deg, transparent 75%, #f8f8f8 75%);
- background-size: 20px 20px;
- background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
- border-radius: var(--radius-md);
- .graph-legend {
- position: absolute;
- top: 16px;
- left: 16px;
- background: var(--white);
- border-radius: var(--radius-md);
- padding: 14px 18px;
- box-shadow: var(--shadow-md);
- .legend-title {
- font-size: 12px;
- font-weight: 600;
- margin-bottom: 10px;
- color: var(--text-1);
- }
- .legend-item {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 12px;
- color: var(--text-2);
- margin-bottom: 6px;
-
- &:last-child {
- margin-bottom: 0;
- }
- }
- .legend-dot {
- width: 12px;
- height: 12px;
- border-radius: 50%;
- &.core, &.entity { background: var(--primary); }
- &.concept { background: #722ed1; }
- &.data { background: var(--success); }
- &.location { background: var(--warning); }
- }
- }
- .graph-body {
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- .graph-placeholder {
- text-align: center;
- color: var(--text-3);
-
- .placeholder-icon {
- font-size: 48px;
- margin-bottom: 16px;
- opacity: 0.5;
- }
- p {
- margin-top: 12px;
- font-size: 14px;
- }
- }
- }
- }
- // ==========================================
- // 空白编辑器占位提示样式 - V2 风格
- // ==========================================
- :deep(.empty-editor-placeholder) {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 80px 40px;
- text-align: center;
- min-height: 400px;
- .empty-icon {
- font-size: 64px;
- margin-bottom: 24px;
- opacity: 0.8;
- }
- h2 {
- font-size: 24px;
- font-weight: 600;
- margin-bottom: 12px;
- color: var(--text-1);
- }
- .empty-subtitle {
- font-size: 15px;
- color: var(--text-3);
- margin-bottom: 32px;
- }
- .empty-actions {
- display: flex;
- flex-direction: column;
- gap: 12px;
- margin-bottom: 32px;
- width: 100%;
- max-width: 400px;
- }
- .action-card {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 16px 20px;
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- cursor: pointer;
- transition: all 0.2s;
- text-align: left;
- &:hover {
- border-color: var(--primary);
- background: var(--primary-light);
- transform: translateX(4px);
- }
- .action-icon {
- font-size: 24px;
- flex-shrink: 0;
- }
- .action-text {
- font-size: 14px;
- color: var(--text-1);
- font-weight: 500;
- }
- }
- .empty-hint {
- font-size: 13px;
- color: var(--text-3);
- padding: 12px 20px;
- background: var(--bg);
- border-radius: var(--radius-md);
- border-left: 3px solid var(--primary);
- }
- }
- // 高亮块动画
- .highlight-block {
- animation: highlight-pulse 2s ease-out;
- }
- @keyframes highlight-pulse {
- 0% {
- background: rgba(24, 144, 255, 0.3);
- box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.2);
- }
- 100% {
- background: transparent;
- box-shadow: none;
- }
- }
- // ==========================================
- // 报告要素管理弹窗样式
- // ==========================================
- .elements-modal {
- :deep(.el-dialog__header) {
- padding: 16px 20px;
- border-bottom: 1px solid var(--border);
- margin-right: 0;
- }
-
- :deep(.el-dialog__body) {
- padding: 0;
- }
-
- :deep(.el-dialog__footer) {
- padding: 12px 20px;
- border-top: 1px solid var(--border);
- }
- }
- .elements-modal-content {
- .elements-search {
- display: flex;
- align-items: center;
- gap: 16px;
- padding: 16px 20px;
- border-bottom: 1px solid var(--border);
- background: var(--bg);
-
- .el-input {
- max-width: 300px;
- }
- }
-
- .elements-table-wrap {
- padding: 0;
-
- :deep(.el-table) {
- .element-name {
- font-weight: 500;
- color: var(--text-1);
- }
-
- .element-desc {
- color: var(--text-3);
- font-size: 12px;
- }
-
- .original-value {
- color: var(--text-2);
- font-size: 12px;
- }
-
- .element-source {
- color: var(--primary);
- font-size: 12px;
- }
-
- .el-input__wrapper {
- box-shadow: none;
- background: var(--bg);
- border-radius: var(--radius-sm);
-
- &:hover, &.is-focus {
- background: var(--white);
- box-shadow: 0 0 0 1px var(--primary);
- }
- }
- }
- }
-
- .elements-pagination {
- display: flex;
- justify-content: flex-end;
- padding: 12px 20px;
- border-top: 1px solid var(--border);
- }
- }
- // ==========================================
- // 新建报告对话框样式
- // ==========================================
- .new-report-dialog {
- :deep(.el-dialog__header) {
- padding: 16px 20px;
- border-bottom: 1px solid var(--border);
- margin-right: 0;
- }
-
- :deep(.el-dialog__body) {
- padding: 20px;
- }
-
- :deep(.el-dialog__footer) {
- padding: 12px 20px;
- border-top: 1px solid var(--border);
- }
- }
- .new-report-form {
- .section-label {
- font-size: 13px;
- font-weight: 500;
- color: var(--text-2);
- margin-bottom: 10px;
- }
-
- .create-type-section {
- margin-bottom: 20px;
- }
-
- .create-type-options {
- display: flex;
- gap: 12px;
- }
-
- .type-option {
- flex: 1;
- display: flex;
- align-items: flex-start;
- gap: 12px;
- padding: 14px;
- background: var(--bg);
- border: 2px solid var(--border);
- border-radius: var(--radius-md);
- cursor: pointer;
- transition: all 0.2s;
- position: relative;
-
- &:hover {
- border-color: var(--primary-light);
- background: var(--white);
- }
-
- &.active {
- border-color: var(--primary);
- background: var(--primary-light);
-
- .option-title {
- color: var(--primary);
- }
- }
-
- .option-icon {
- font-size: 24px;
- flex-shrink: 0;
- line-height: 1;
- }
-
- .option-content {
- flex: 1;
- min-width: 0;
- }
-
- .option-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-1);
- margin-bottom: 4px;
- }
-
- .option-desc {
- font-size: 12px;
- color: var(--text-3);
- line-height: 1.4;
- }
-
- .option-check {
- position: absolute;
- top: 8px;
- right: 8px;
- width: 20px;
- height: 20px;
- background: var(--primary);
- color: white;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- font-weight: bold;
- }
- }
-
- .name-input-section {
- margin-bottom: 20px;
-
- :deep(.el-input__wrapper) {
- border-radius: var(--radius-sm);
- }
- }
-
- .upload-section {
- .report-upload-area {
- :deep(.el-upload) {
- width: 100%;
- }
-
- :deep(.el-upload-dragger) {
- width: 100%;
- height: auto;
- padding: 24px;
- border-radius: var(--radius-md);
- border: 2px dashed var(--border);
- background: var(--bg);
-
- &:hover {
- border-color: var(--primary);
- background: var(--primary-light);
- }
- }
-
- .upload-content {
- text-align: center;
- }
-
- .upload-icon {
- font-size: 32px;
- color: var(--text-3);
- margin-bottom: 8px;
- }
-
- .upload-text {
- font-size: 14px;
- color: var(--text-2);
- margin-bottom: 4px;
-
- em {
- color: var(--primary);
- font-style: normal;
- }
- }
-
- .upload-hint {
- font-size: 12px;
- color: var(--text-3);
- }
- }
- }
- }
- // ==========================================
- // 要素视图
- // ==========================================
- .elements-view {
- padding: 24px;
-
- .elements-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
- gap: 16px;
- }
-
- .element-card {
- background: var(--white);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- overflow: hidden;
- transition: all 0.2s;
-
- &:hover {
- border-color: var(--primary);
- box-shadow: var(--shadow-sm);
- }
-
- .element-card-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- background: var(--bg);
- border-bottom: 1px solid var(--border);
-
- .element-label {
- font-weight: 600;
- font-size: 14px;
- color: var(--text-1);
- }
- }
-
- .element-card-body {
- padding: 12px 16px;
-
- .element-value-row {
- margin-bottom: 8px;
-
- .value-meta {
- margin-top: 4px;
- font-size: 11px;
- color: var(--text-3);
-
- .original-label {
- margin-right: 4px;
- }
- .original-value {
- color: var(--text-2);
- }
- }
-
- .value-status {
- margin-top: 4px;
- }
- }
-
- .element-empty {
- padding: 16px 0;
- text-align: center;
-
- .empty-hint {
- font-size: 12px;
- color: var(--text-3);
- }
- }
- }
- }
-
- .elements-empty {
- padding: 60px 20px;
- text-align: center;
- }
- }
- // ==========================================
- // 实体视图
- // ==========================================
- .entities-view {
- padding: 24px;
-
- .entity-filter-bar {
- display: flex;
- align-items: center;
- margin-bottom: 16px;
- }
-
- .entities-empty {
- padding: 60px 20px;
- text-align: center;
- }
- }
- // ==========================================
- // 项目概览统计
- // ==========================================
- .overview-stats {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 12px;
-
- .stat-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 12px 8px;
- background: var(--bg);
- border-radius: var(--radius-sm);
-
- .stat-label {
- font-size: 11px;
- color: var(--text-3);
- margin-bottom: 4px;
- }
-
- .stat-value {
- font-size: 20px;
- font-weight: 700;
- color: var(--text-1);
-
- &.filled {
- color: var(--success);
- }
- }
- }
- }
- // ==========================================
- // 文档视图 - Word 排版还原 + 可编辑 + 要素高亮
- // ==========================================
- .document-view {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 20px;
- background: #e8e8e8;
- min-height: 100%;
- position: relative;
- .doc-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 16px;
- padding: 8px 16px;
- background: var(--white);
- border-radius: var(--radius-md);
- box-shadow: var(--shadow-sm);
- width: 100%;
- max-width: 820px;
- .doc-toolbar-left, .doc-toolbar-right {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .doc-title-label {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-1);
- white-space: nowrap;
- }
- }
- .doc-paper {
- background: #fff;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
- border-radius: 2px;
- width: 100%;
- max-width: 820px;
- min-height: 1100px;
- padding: 96px 90px;
- font-family: 'Times New Roman', 'SimSun', '宋体', serif;
- font-size: 12pt;
- line-height: 1.6;
- color: #000;
- word-wrap: break-word;
- overflow-wrap: break-word;
- }
- :deep(.doc-block) {
- margin: 0;
- padding: 0;
- min-height: 1em;
- }
- // 标题样式
- :deep(h1.doc-block) {
- font-size: 18pt;
- font-weight: bold;
- margin: 16px 0 10px;
- line-height: 1.4;
- }
- :deep(h2.doc-block) {
- font-size: 16pt;
- font-weight: bold;
- margin: 14px 0 8px;
- line-height: 1.4;
- }
- :deep(h3.doc-block) {
- font-size: 14pt;
- font-weight: bold;
- margin: 12px 0 6px;
- line-height: 1.4;
- }
- // 段落
- :deep(p.doc-block) {
- margin: 0 0 2px;
- text-align: justify;
- }
- // 目录
- :deep(.doc-toc1), :deep(.doc-toc2), :deep(.doc-toc3) {
- position: relative;
- font-size: 13pt;
- margin: 0;
- padding: 8px 16px;
- cursor: pointer;
- border-left: 3px solid transparent;
- transition: background 0.15s, border-color 0.15s;
- &:hover {
- background: #f0f7ff;
- border-left-color: #1890ff;
- }
- }
- :deep(.doc-toc1) {
- font-weight: 600;
- color: #1f2937;
- }
- :deep(.doc-toc2) {
- padding-left: 36px;
- font-size: 12pt;
- color: #374151;
- }
- :deep(.doc-toc3) {
- padding-left: 56px;
- font-size: 11pt;
- color: #6b7280;
- }
- // 空段落
- :deep(p.doc-paragraph:empty::after),
- :deep(p.doc-block:empty::after) {
- content: '\00a0';
- }
- // 要素模板 {{key}} 标签
- :deep(.elem-tpl-tag) {
- display: inline;
- background: #fff7e6;
- color: #d46b08;
- border: 1.5px solid #ffc069;
- border-radius: 3px;
- padding: 1px 6px;
- font-family: 'Consolas', 'Monaco', monospace;
- font-size: 0.9em;
- font-weight: 600;
- white-space: nowrap;
- cursor: default;
- user-select: all;
- &:hover {
- background: #ffe7ba;
- border-color: #fa8c16;
- }
- }
- :deep(.elem-tpl-block) {
- margin: 8px 0;
- padding: 10px 14px;
- background: #fffbe6;
- border: 1.5px dashed #faad14;
- border-radius: 6px;
- text-align: center;
- .elem-tpl-tag {
- font-size: 1em;
- padding: 2px 10px;
- }
- }
- // 内联图片
- :deep(.doc-inline-image) {
- max-width: 100%;
- height: auto;
- display: block;
- margin: 8px auto;
- }
- // 表格
- :deep(.doc-table) {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0;
- margin: 16px 0;
- font-size: 10.5pt;
- border: 1px solid #d0d5dd;
- border-radius: 8px;
- overflow: hidden;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
- .doc-table-cell {
- border: 1px solid #e4e7ec;
- border-top: none;
- border-left: none;
- padding: 10px 14px;
- vertical-align: middle;
- line-height: 1.6;
- color: #344054;
- transition: background 0.15s;
- }
- // 右边缘单元格去掉右边框
- tr .doc-table-cell:last-child {
- border-right: none;
- }
- // 表头行
- tr:first-child .doc-table-cell {
- font-weight: 700;
- font-size: 10pt;
- background: linear-gradient(180deg, #f8fafc 0%, #edf2f7 100%);
- color: #1a202c;
- border-bottom: 2px solid #cbd5e1;
- letter-spacing: 0.5px;
- text-align: center;
- padding: 12px 14px;
- }
- // 斑马纹
- tr:not(:first-child):nth-child(even) .doc-table-cell {
- background: #f9fafb;
- }
- tr:not(:first-child):nth-child(odd) .doc-table-cell {
- background: #fff;
- }
- // 行悬停
- tr:not(:first-child):hover .doc-table-cell {
- background: #e8f4ff;
- }
- }
- // 高亮包裹内的表格特殊处理
- :deep(.elem-highlight-wrap .doc-table) {
- margin: 0;
- box-shadow: none;
- border-color: transparent;
- }
- // 空状态 & 加载
- .doc-empty, .doc-loading {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 80px 20px;
- width: 100%;
- max-width: 820px;
- background: var(--white);
- border-radius: var(--radius-md);
- box-shadow: var(--shadow-sm);
- }
- .doc-loading {
- flex-direction: row;
- gap: 12px;
- padding: 40px;
- font-size: 14px;
- color: var(--text-2);
- }
- }
- // ==========================================
- // 要素高亮弹出框
- // ==========================================
- .element-popover {
- position: absolute;
- z-index: 1000;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
- width: 400px;
- overflow: hidden;
- .popover-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 14px;
- background: var(--bg);
- border-bottom: 1px solid var(--border);
- .popover-label {
- font-weight: 600;
- font-size: 14px;
- color: var(--text-1);
- }
- }
- .popover-body {
- padding: 12px 14px;
- .popover-field {
- margin-bottom: 10px;
- &:last-child {
- margin-bottom: 0;
- }
- .popover-field-label {
- display: block;
- font-size: 12px;
- color: var(--text-3);
- margin-bottom: 4px;
- }
- .popover-original {
- font-size: 13px;
- color: var(--text-2);
- background: var(--bg);
- padding: 4px 8px;
- border-radius: var(--radius-sm);
- display: inline-block;
- }
- }
- }
- .popover-rules {
- margin-top: 10px;
- border-top: 1px dashed var(--border);
- padding-top: 10px;
- .rule-trace-card {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- padding: 6px 8px;
- background: var(--bg);
- border-radius: var(--radius-sm);
- border: 1px solid var(--border);
- margin-bottom: 6px;
- &:last-child { margin-bottom: 0; }
- .rule-trace-action {
- flex-shrink: 0;
- font-size: 10px;
- font-weight: 600;
- padding: 2px 6px;
- border-radius: 10px;
- line-height: 18px;
- &.action-quote { background: #e6f4ff; color: #1677ff; }
- &.action-summary { background: #f6ffed; color: #52c41a; }
- &.action-ai_extract { background: #e6fffb; color: #13c2c2; }
- &.action-table_extract { background: #fff7e6; color: #fa8c16; }
- &.action-use_entity_value { background: #f0f0f0; color: #666; }
- }
- .rule-trace-info {
- flex: 1;
- min-width: 0;
- .rule-trace-name {
- font-size: 12px;
- color: var(--text-1);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .rule-trace-sources {
- margin-top: 3px;
- display: flex;
- flex-direction: column;
- gap: 2px;
- .rule-trace-att {
- font-size: 11px;
- color: var(--text-3);
- word-break: break-all;
- }
- }
- .rule-trace-excerpt {
- margin-top: 4px;
- padding: 4px 6px;
- background: #fafafa;
- border-left: 2px solid #d9d9d9;
- border-radius: 2px;
- .rule-trace-excerpt-text {
- font-size: 11px;
- color: var(--text-2);
- line-height: 1.5;
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
- word-break: break-all;
- }
- }
- }
- }
- }
- .popover-footer {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- padding: 8px 14px;
- border-top: 1px solid var(--border);
- background: var(--bg);
- }
- }
- // 可编辑文档纸张的光标和选区样式
- .doc-paper[contenteditable="true"] {
- outline: none;
- cursor: text;
- &:focus {
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15), 0 0 0 2px rgba(24, 144, 255, 0.2);
- }
- }
- // 高亮边框样式
- .elem-highlight {
- transition: border-color 0.2s, box-shadow 0.2s;
- &:hover {
- box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
- border-color: #1890ff !important;
- }
- }
- .elem-highlight-long {
- display: inline !important;
- transition: border-color 0.2s, box-shadow 0.2s;
- &:hover {
- box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
- border-color: #1890ff !important;
- }
- }
- .elem-highlight-wrap {
- position: relative;
- transition: border-color 0.2s, box-shadow 0.2s;
- &:hover {
- border-color: #1890ff !important;
- box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
- }
- .doc-table {
- margin: 0 auto;
- }
- }
- // 静态要素淡色高亮
- .elem-highlight-static {
- opacity: 0.6;
- transition: opacity 0.2s, border-color 0.2s;
- &:hover {
- opacity: 1;
- border-color: #999 !important;
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
- }
- }
- </style>
- <style lang="scss">
- // ==========================================
- // 附件/规则居中悬浮弹窗样式
- // ==========================================
- .floating-panel-dialog {
- .el-dialog__header {
- padding: 16px 20px 12px;
- border-bottom: 1px solid var(--border);
- margin-right: 0;
- .el-dialog__title {
- font-size: 15px;
- font-weight: 700;
- }
- }
- .el-dialog__body {
- padding: 0;
- max-height: 60vh;
- overflow-y: auto;
- }
- .fp-toolbar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 20px;
- border-bottom: 1px solid var(--border);
- background: #fafbfc;
- .fp-count {
- margin-left: auto;
- font-size: 12px;
- color: #999;
- }
- }
- .fp-list {
- padding: 8px 12px;
- }
- &.rule-manage-dialog .fp-list {
- max-height: 480px;
- overflow-y: auto;
- }
- // ---- 附件项 ----
- .fp-att-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 12px;
- border-radius: 8px;
- cursor: pointer;
- transition: background 0.15s;
- &:hover {
- background: #f5f7fa;
- }
- &.active {
- background: #e8f4ff;
- }
- .att-icon {
- width: 36px;
- height: 36px;
- border-radius: 6px;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- font-size: 11px;
- font-weight: 700;
- color: #fff;
- background: #1890ff;
- &.pdf { background: #ff4d4f; }
- &.doc, &.docx { background: #1890ff; }
- &.xls, &.xlsx { background: #52c41a; }
- &.img { background: #722ed1; }
- &.zip { background: #fa8c16; }
- }
- .att-info {
- flex: 1;
- min-width: 0;
- .att-name {
- font-size: 13px;
- font-weight: 500;
- color: #1f2937;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .att-meta {
- display: flex;
- gap: 8px;
- margin-top: 2px;
- font-size: 11px;
- color: #999;
- }
- }
- }
- // ---- 解析结果弹窗 ----
- &.parse-result-dialog .el-dialog__body {
- max-height: 85vh;
- }
- .parse-result-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 20px;
- border-bottom: 1px solid var(--border);
- background: #fafbfc;
- .parse-result-info {
- font-size: 12px;
- color: #999;
- }
- .parse-result-actions {
- display: flex;
- gap: 6px;
- }
- }
- .parse-result-content {
- padding: 16px 20px;
- max-height: calc(85vh - 120px);
- overflow-y: auto;
- .parse-result-pre {
- margin: 0;
- padding: 0;
- font-family: 'SF Mono', 'Consolas', 'Monaco', 'Menlo', monospace;
- font-size: 13px;
- line-height: 1.7;
- color: #1f2937;
- white-space: pre-wrap;
- word-wrap: break-word;
- }
- .parse-result-rendered {
- font-size: 14px;
- line-height: 1.8;
- color: #1f2937;
- h1 { font-size: 20px; font-weight: 700; margin: 20px 0 12px; padding-bottom: 6px; border-bottom: 1px solid #eee; }
- h2 { font-size: 18px; font-weight: 700; margin: 18px 0 10px; }
- h3 { font-size: 16px; font-weight: 600; margin: 14px 0 8px; }
- h4, h5, h6 { font-size: 14px; font-weight: 600; margin: 10px 0 6px; }
- p { margin: 8px 0; text-align: justify; }
- img {
- max-width: 100%;
- height: auto;
- display: block;
- margin: 12px auto;
- border-radius: 6px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
- }
- table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0;
- margin: 12px 0;
- border: 1px solid #d0d5dd;
- border-radius: 6px;
- overflow: hidden;
- font-size: 13px;
- th, td {
- border: 1px solid #e4e7ec;
- padding: 8px 12px;
- vertical-align: top;
- line-height: 1.5;
- }
- th {
- background: linear-gradient(180deg, #f8fafc, #edf2f7);
- font-weight: 600;
- text-align: center;
- }
- tr:nth-child(even) td { background: #f9fafb; }
- tr:hover td { background: #e8f4ff; }
- }
- ol, ul { padding-left: 24px; margin: 8px 0; }
- li { margin: 4px 0; }
- blockquote {
- margin: 12px 0;
- padding: 10px 16px;
- border-left: 4px solid #1890ff;
- background: #f0f7ff;
- color: #374151;
- }
- code {
- background: #f3f4f6;
- padding: 1px 4px;
- border-radius: 3px;
- font-size: 0.9em;
- font-family: 'SF Mono', 'Consolas', monospace;
- }
- pre {
- background: #1f2937;
- color: #e5e7eb;
- padding: 14px 18px;
- border-radius: 6px;
- overflow-x: auto;
- margin: 12px 0;
- code {
- background: none;
- padding: 0;
- color: inherit;
- }
- }
- hr { border: none; border-top: 1px solid #e5e7eb; margin: 16px 0; }
- }
- }
- // ---- 引用模式提示条 ----
- .citation-mode-banner {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 16px;
- background: linear-gradient(90deg, #e8f5e9, #f1f8e9);
- border-bottom: 1px solid #c8e6c9;
- font-size: 13px;
- color: #2e7d32;
- }
- // ---- 浮动引用工具栏 ----
- .citation-toolbar {
- position: absolute;
- z-index: 3000;
- background: #fff;
- border: 1px solid #d0d5dd;
- border-radius: 10px;
- box-shadow: 0 6px 20px rgba(0,0,0,0.15);
- padding: 10px 14px;
- min-width: 240px;
- max-width: 320px;
- .citation-toolbar-title {
- font-size: 12px;
- color: #666;
- margin-bottom: 8px;
- font-weight: 500;
- }
- .citation-toolbar-actions {
- display: flex;
- gap: 6px;
- flex-wrap: wrap;
- }
- .citation-toolbar-elements {
- max-height: 200px;
- overflow-y: auto;
- }
- .citation-element-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 6px 8px;
- border-radius: 6px;
- cursor: pointer;
- transition: background 0.15s;
- font-size: 13px;
- &:hover {
- background: #f0f7ff;
- }
- .citation-elem-name {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: #1f2937;
- }
- }
- }
- // ---- 规则筛选栏 ----
- .rule-filter-bar {
- display: flex;
- gap: 4px;
- padding: 0 16px 10px;
- border-bottom: 1px solid #f0f0f0;
- flex-wrap: wrap;
- .rule-filter-tab {
- font-size: 12px;
- padding: 4px 10px;
- border-radius: 14px;
- cursor: pointer;
- color: #666;
- background: #f5f7fa;
- transition: all 0.2s;
- user-select: none;
- em {
- font-style: normal;
- font-size: 11px;
- opacity: 0.7;
- margin-left: 2px;
- }
- &:hover { background: #e8eaed; }
- &.active { background: #409eff; color: #fff; }
- &.active em { opacity: 0.9; }
- &.tab-summary.active { background: #52c41a; }
- &.tab-ai_extract.active { background: #13c2c2; }
- &.tab-table_extract.active { background: #fa8c16; }
- &.tab-quote.active { background: #1677ff; }
- &.tab-use_entity_value.active { background: #8c8c8c; }
- }
- }
- // ---- 规则项 ----
- .fp-rule-item {
- border-radius: 8px;
- transition: background 0.15s;
- cursor: pointer;
- &:hover { background: #f5f7fa; }
- &.expanded { background: #fafbfc; }
- .rule-item-main {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 12px;
- }
- .rule-action-badge {
- flex-shrink: 0;
- font-size: 11px;
- font-weight: 600;
- padding: 3px 8px;
- border-radius: 12px;
- line-height: 16px;
- white-space: nowrap;
- &.action-quote { background: #e6f4ff; color: #1677ff; }
- &.action-summary { background: #f6ffed; color: #52c41a; }
- &.action-ai_extract { background: #e6fffb; color: #13c2c2; }
- &.action-table_extract { background: #fff7e6; color: #fa8c16; }
- &.action-use_entity_value { background: #f0f0f0; color: #666; }
- }
- .rule-info {
- flex: 1;
- min-width: 0;
- .rule-name-row {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-wrap: wrap;
- .rule-name {
- font-size: 13px;
- font-weight: 500;
- color: #1f2937;
- }
- .rule-elem-key {
- font-size: 10px;
- max-width: 200px;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- }
- .rule-desc {
- font-size: 11px;
- color: #999;
- margin-top: 2px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
- .rule-actions {
- display: flex;
- align-items: center;
- gap: 2px;
- flex-shrink: 0;
- }
- // 展开详情区域
- .rule-detail {
- padding: 0 12px 10px 12px;
- border-top: 1px dashed #e8e8e8;
- margin: 0 12px;
- .rule-detail-row {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- padding: 6px 0;
- font-size: 12px;
- &:not(:last-child) {
- border-bottom: 1px solid #f5f5f5;
- }
- }
- .rule-detail-label {
- flex-shrink: 0;
- font-weight: 600;
- color: #666;
- width: 60px;
- text-align: right;
- }
- .rule-detail-value {
- color: #333;
- line-height: 1.5;
- word-break: break-all;
- }
- .rule-detail-inputs {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- }
- .rule-input-chip {
- font-size: 11px;
- padding: 2px 8px;
- background: #f0f5ff;
- border-radius: 10px;
- color: #1677ff;
- white-space: nowrap;
- }
- .rule-detail-time {
- font-size: 11px;
- color: #999;
- margin-left: 4px;
- }
- .rule-detail-error {
- color: #ff4d4f;
- line-height: 1.4;
- }
- }
- }
- }
- </style>
|