Editor.vue 241 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205720672077208720972107211721272137214721572167217721872197220722172227223722472257226722772287229723072317232723372347235723672377238723972407241724272437244724572467247724872497250725172527253725472557256725772587259726072617262726372647265726672677268726972707271727272737274727572767277727872797280728172827283728472857286728772887289729072917292729372947295729672977298729973007301730273037304730573067307730873097310731173127313731473157316731773187319732073217322732373247325732673277328732973307331733273337334733573367337733873397340734173427343734473457346734773487349735073517352735373547355735673577358735973607361736273637364736573667367736873697370737173727373737473757376737773787379738073817382738373847385738673877388738973907391739273937394739573967397739873997400740174027403740474057406740774087409741074117412741374147415741674177418741974207421742274237424742574267427742874297430743174327433743474357436743774387439744074417442744374447445744674477448744974507451745274537454745574567457745874597460746174627463746474657466746774687469747074717472747374747475747674777478747974807481748274837484748574867487748874897490749174927493749474957496749774987499750075017502750375047505750675077508750975107511751275137514751575167517751875197520752175227523752475257526752775287529753075317532753375347535753675377538753975407541754275437544754575467547754875497550755175527553755475557556755775587559756075617562756375647565756675677568756975707571757275737574757575767577757875797580758175827583758475857586758775887589759075917592759375947595759675977598759976007601760276037604760576067607760876097610761176127613761476157616761776187619762076217622762376247625762676277628762976307631763276337634763576367637763876397640764176427643764476457646764776487649765076517652765376547655765676577658765976607661766276637664766576667667766876697670767176727673767476757676767776787679768076817682768376847685768676877688768976907691769276937694769576967697769876997700770177027703770477057706770777087709771077117712771377147715771677177718771977207721772277237724772577267727772877297730773177327733773477357736773777387739774077417742774377447745774677477748774977507751775277537754775577567757775877597760776177627763776477657766776777687769777077717772777377747775777677777778777977807781778277837784778577867787778877897790779177927793779477957796779777987799780078017802780378047805780678077808780978107811781278137814781578167817781878197820782178227823782478257826782778287829783078317832783378347835783678377838783978407841784278437844784578467847784878497850785178527853785478557856785778587859786078617862786378647865786678677868786978707871787278737874787578767877787878797880788178827883788478857886788778887889789078917892789378947895789678977898789979007901790279037904790579067907790879097910791179127913791479157916791779187919792079217922792379247925792679277928792979307931793279337934793579367937793879397940794179427943794479457946794779487949795079517952795379547955795679577958795979607961796279637964796579667967796879697970797179727973797479757976797779787979798079817982798379847985798679877988798979907991799279937994799579967997799879998000800180028003800480058006800780088009801080118012801380148015801680178018801980208021802280238024802580268027802880298030803180328033803480358036803780388039804080418042804380448045804680478048804980508051805280538054805580568057805880598060806180628063806480658066806780688069807080718072807380748075807680778078807980808081808280838084808580868087808880898090809180928093809480958096809780988099810081018102810381048105810681078108810981108111811281138114811581168117811881198120812181228123812481258126812781288129813081318132813381348135813681378138813981408141814281438144814581468147814881498150815181528153815481558156815781588159816081618162816381648165816681678168816981708171817281738174817581768177
  1. <template>
  2. <div class="editor-page">
  3. <div class="editor-body">
  4. <!-- 左侧面板 -->
  5. <div class="left-panel" :style="{ width: leftPanelWidth + 'px' }">
  6. <!-- 顶部 Logo -->
  7. <div class="sidebar-header">
  8. <div class="sidebar-logo">
  9. <span class="logo-icon">✦</span>
  10. <span class="logo-text">灵越智报</span>
  11. </div>
  12. <div class="sidebar-header-actions">
  13. <el-button text circle size="small" title="通知"><el-icon><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></el-icon></el-button>
  14. </div>
  15. </div>
  16. <!-- 快捷操作 -->
  17. <div class="sidebar-nav">
  18. <div class="nav-item" @click="showNewProjectDialog = true">
  19. <span class="nav-icon">📝</span>
  20. <span class="nav-label">新建报告</span>
  21. </div>
  22. <div class="nav-item" @click="showAttachmentDialog = true">
  23. <span class="nav-icon">📚</span>
  24. <span class="nav-label">知识库</span>
  25. </div>
  26. </div>
  27. <!-- 我的文档 -->
  28. <div class="sidebar-section">
  29. <div class="section-header">
  30. <span class="section-title">我的文档 · {{ projects.length }}</span>
  31. <span class="section-action" @click="leftPanelTab = 'projects'">全部 ›</span>
  32. </div>
  33. <!-- 搜索框 -->
  34. <el-input
  35. v-if="projects.length > 5"
  36. v-model="projectSearchKeyword"
  37. placeholder="搜索文档..."
  38. :prefix-icon="Search"
  39. clearable
  40. class="sidebar-search"
  41. size="small"
  42. />
  43. <div class="doc-list" v-if="filteredProjects.length > 0">
  44. <div
  45. v-for="project in filteredProjects.slice(0, sidebarShowAll ? undefined : 5)"
  46. :key="project.id"
  47. class="doc-item"
  48. :class="{ active: currentProjectId === project.id }"
  49. @click="switchProject(project)"
  50. >
  51. <div class="doc-icon-wrap">
  52. <span class="doc-icon-glyph">📋</span>
  53. </div>
  54. <div class="doc-item-body">
  55. <div class="doc-item-title">{{ project.title }}</div>
  56. <!-- 解析进度条 -->
  57. <div v-if="parsingProjectId === project.id" class="doc-item-progress">
  58. <el-progress
  59. :percentage="docxParseProgress"
  60. :status="docxParseStatus || undefined"
  61. :stroke-width="4"
  62. :show-text="false"
  63. />
  64. <span class="progress-text">{{ docxParseMessage }}</span>
  65. </div>
  66. <div v-else class="doc-item-meta">
  67. <el-tag
  68. size="small"
  69. :type="project.status === 'archived' ? 'success' : project.status === 'editing' ? '' : 'warning'"
  70. effect="plain"
  71. class="doc-status-tag"
  72. >{{ getStatusText(project.status) }}</el-tag>
  73. <span class="doc-item-date">{{ formatTime(project.updatedAt || project.createdAt) }}</span>
  74. <span class="doc-item-author">· {{ project.createdBy || userName }}</span>
  75. </div>
  76. </div>
  77. <el-dropdown trigger="click" @command="(cmd) => handleProjectCommand(cmd, project)" @click.stop>
  78. <el-button class="doc-more-btn" text size="small" @click.stop>
  79. <el-icon><MoreFilled /></el-icon>
  80. </el-button>
  81. <template #dropdown>
  82. <el-dropdown-menu>
  83. <el-dropdown-item command="copy">复制项目</el-dropdown-item>
  84. <el-dropdown-item command="archive">归档</el-dropdown-item>
  85. <el-dropdown-item command="delete" divided>删除</el-dropdown-item>
  86. </el-dropdown-menu>
  87. </template>
  88. </el-dropdown>
  89. </div>
  90. </div>
  91. <div class="doc-empty" v-else-if="!loadingProjects">
  92. <span class="empty-text">{{ projectSearchKeyword ? '未找到匹配的文档' : '暂无文档' }}</span>
  93. </div>
  94. <div class="doc-loading" v-if="loadingProjects">
  95. <el-icon class="is-loading"><Loading /></el-icon>
  96. <span>加载中...</span>
  97. </div>
  98. </div>
  99. <!-- 最近操作 -->
  100. <div class="sidebar-section sidebar-activity">
  101. <div class="section-header">
  102. <span class="section-title">最近操作</span>
  103. <span class="section-action" title="筛选">▽</span>
  104. </div>
  105. <div class="activity-list">
  106. <div class="activity-item" v-for="(act, idx) in recentActivities" :key="idx">
  107. <div class="activity-text">{{ act.text }}</div>
  108. <div class="activity-meta">
  109. <span class="activity-source">{{ act.source }}</span>
  110. <span class="activity-time">{{ act.time }}</span>
  111. </div>
  112. </div>
  113. <div class="activity-empty" v-if="recentActivities.length === 0">
  114. <span>暂无最近操作</span>
  115. </div>
  116. </div>
  117. </div>
  118. <!-- 底部用户信息 -->
  119. <div class="sidebar-footer">
  120. <div class="user-avatar">{{ userName.charAt(0) }}</div>
  121. <div class="user-info">
  122. <span class="user-name">{{ userName }}</span>
  123. <span class="user-role">项目经理</span>
  124. </div>
  125. <div class="footer-actions">
  126. <el-badge :value="taskRunningCount" :hidden="taskRunningCount === 0" :max="99" class="notification-badge">
  127. <el-button text circle size="small" title="任务中心" @click="taskCenterStore.toggleOpen()"><el-icon><List /></el-icon></el-button>
  128. </el-badge>
  129. <el-badge :value="3" :max="99" class="notification-badge">
  130. <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>
  131. </el-badge>
  132. <el-dropdown trigger="click" @command="handleFooterCommand">
  133. <el-button text circle size="small" title="设置"><el-icon><MoreFilled /></el-icon></el-button>
  134. <template #dropdown>
  135. <el-dropdown-menu>
  136. <el-dropdown-item command="settings">系统设置</el-dropdown-item>
  137. <el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
  138. </el-dropdown-menu>
  139. </template>
  140. </el-dropdown>
  141. </div>
  142. </div>
  143. </div>
  144. <!-- 左侧拖拽分隔条 -->
  145. <div class="resize-handle left-resize" @mousedown="startResizeLeft"></div>
  146. <!-- 中间主区域 -->
  147. <div class="center-panel">
  148. <!-- 欢迎页 -->
  149. <div class="welcome-page" v-if="!hasActiveProject">
  150. <div class="welcome-content">
  151. <div class="welcome-logo">灵</div>
  152. <div class="welcome">
  153. <h1>{{ greetingText }},{{ userName }}!<span>智能报告,洞察未来。</span></h1>
  154. <p>今天是个创作的好日子,开始您的智能报告之旅吧</p>
  155. <p class="welcome-version">v0.2.2</p>
  156. </div>
  157. </div>
  158. </div>
  159. <!-- 项目详情 -->
  160. <template v-else>
  161. <div class="editor-title-bar">
  162. <div class="titlebar-left">
  163. <el-icon class="titlebar-folder-icon"><Folder /></el-icon>
  164. <span class="titlebar-sep">/</span>
  165. <span class="titlebar-project-name" :title="projectTitle">{{ projectTitle || '未命名项目' }}</span>
  166. <el-tag size="small" type="info" class="titlebar-status-tag">草稿</el-tag>
  167. </div>
  168. <div class="titlebar-right">
  169. <span class="titlebar-save-status">
  170. <span class="save-dot" :class="{ saved: saved }"></span>
  171. {{ saved ? '已自动保存' : '保存中...' }}
  172. </span>
  173. <el-divider direction="vertical" />
  174. <el-button text circle size="small" :class="{ 'is-active-view': viewMode === 'document' }" title="文档视图" @click="viewMode = 'document'"><el-icon><Document /></el-icon></el-button>
  175. <el-button text circle size="small" :class="{ 'is-active-view': viewMode === 'elements' }" title="要素视图" @click="viewMode = 'elements'"><el-icon><Grid /></el-icon></el-button>
  176. <el-button text circle size="small" title="设置" @click="ElMessage.info('设置开发中...')"><el-icon><Setting /></el-icon></el-button>
  177. <el-dropdown trigger="click" @command="handleTitlebarCommand">
  178. <el-button text circle size="small" title="更多"><el-icon><MoreFilled /></el-icon></el-button>
  179. <template #dropdown>
  180. <el-dropdown-menu>
  181. <el-dropdown-item command="save">💾 保存</el-dropdown-item>
  182. <el-dropdown-item command="copy">📋 复制项目</el-dropdown-item>
  183. <el-dropdown-item command="export" divided>📤 导出</el-dropdown-item>
  184. </el-dropdown-menu>
  185. </template>
  186. </el-dropdown>
  187. <el-divider direction="vertical" />
  188. <el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><Paperclip /></el-icon></el-button>
  189. <el-button text circle size="small" title="规则" @click="showRuleDialog = true"><el-icon><List /></el-icon></el-button>
  190. </div>
  191. </div>
  192. <div class="editor-scroll" ref="editorRef" v-loading="loading" element-loading-text="正在加载项目...">
  193. <!-- 文档视图 -->
  194. <div class="document-view" v-if="viewMode === 'document'">
  195. <!-- 文档渲染区域(可编辑) -->
  196. <div
  197. class="doc-paper"
  198. v-if="docHtml"
  199. :style="docPaperStyle"
  200. contenteditable="true"
  201. spellcheck="false"
  202. v-html="docHtml"
  203. @input="onDocInput"
  204. @mousedown="onDocClick"
  205. ref="docPaperRef"
  206. ></div>
  207. <!-- 无内容提示 -->
  208. <div class="doc-empty" v-else-if="!docLoading">
  209. <el-empty description="暂无文档内容" />
  210. </div>
  211. <div class="doc-loading" v-if="docLoading">
  212. <el-icon class="is-loading" :size="24"><Loading /></el-icon>
  213. <span>正在加载文档内容...</span>
  214. </div>
  215. </div>
  216. <!-- 要素高亮弹出框 -->
  217. <div
  218. v-if="highlightPopover.visible"
  219. class="element-popover"
  220. :style="{ top: highlightPopover.y + 'px', left: highlightPopover.x + 'px' }"
  221. @mousedown.stop
  222. >
  223. <div class="popover-header">
  224. <span class="popover-label">{{ highlightPopover.elementName }}</span>
  225. <el-tag size="small">{{ highlightPopover.elementKey }}</el-tag>
  226. </div>
  227. <div class="popover-body">
  228. <div class="popover-field">
  229. <span class="popover-field-label">当前值:</span>
  230. <el-input
  231. v-model="highlightPopover.currentValue"
  232. size="small"
  233. placeholder="输入要素值"
  234. @keyup.enter="savePopoverValue"
  235. />
  236. </div>
  237. <div class="popover-field" v-if="highlightPopover.originalValue">
  238. <span class="popover-field-label">原始值:</span>
  239. <span class="popover-original">{{ highlightPopover.originalValue }}</span>
  240. </div>
  241. <!-- 溯源卡片 -->
  242. <div class="popover-rules" v-if="popoverRelatedRules.length > 0">
  243. <span class="popover-field-label">来源规则:</span>
  244. <div
  245. v-for="rule in popoverRelatedRules"
  246. :key="rule.id"
  247. class="rule-trace-card clickable"
  248. @click="openRuleWorkflow(rule)"
  249. title="点击编辑规则"
  250. >
  251. <span class="rule-trace-action" :class="'action-' + rule.actionType">{{ ruleActionLabel(rule.actionType) }}</span>
  252. <div class="rule-trace-info">
  253. <div class="rule-trace-name">{{ rule.ruleName }}</div>
  254. <div v-if="rule.inputs && rule.inputs.length" class="rule-trace-sources">
  255. <span
  256. v-for="inp in rule.inputs"
  257. :key="inp.inputId"
  258. class="rule-trace-att clickable"
  259. @click.stop="openSourceInViewer(inp, rule)"
  260. title="点击查看来源"
  261. >📎 {{ formatInputSource(inp) }}</span>
  262. </div>
  263. <!-- 显示来源摘要 -->
  264. <div v-if="getInputSourceText(rule)" class="rule-trace-source-text">
  265. <span class="source-text-label">来源段落:</span>
  266. <span class="source-text-content">{{ getInputSourceText(rule) }}</span>
  267. </div>
  268. <div v-else-if="ruleSourceText(rule)" class="rule-trace-excerpt">
  269. <span class="rule-trace-excerpt-text">{{ ruleSourceText(rule) }}</span>
  270. </div>
  271. </div>
  272. </div>
  273. </div>
  274. </div>
  275. <div class="popover-footer">
  276. <el-button size="small" @click="highlightPopover.visible = false">关闭</el-button>
  277. <el-button size="small" type="primary" @click="savePopoverValue">保存</el-button>
  278. </div>
  279. </div>
  280. <!-- 要素视图:模板文档 + {{key}} 占位符 -->
  281. <div class="document-view" v-if="viewMode === 'elements'">
  282. <div
  283. class="doc-paper"
  284. v-if="docTemplateHtml"
  285. :style="docPaperStyle"
  286. contenteditable="false"
  287. spellcheck="false"
  288. v-html="docTemplateHtml"
  289. ></div>
  290. <div class="doc-empty" v-else-if="!docLoading">
  291. <el-empty description="暂无文档内容" />
  292. </div>
  293. </div>
  294. </div>
  295. </template>
  296. </div>
  297. <!-- 右侧拖拽分隔条 -->
  298. <div v-if="hasActiveProject" class="resize-handle right-resize" @mousedown="startResizeRight"></div>
  299. <!-- 右侧面板 -->
  300. <div v-if="hasActiveProject" class="right-panel" :style="{ width: rightPanelWidth + 'px' }">
  301. <!-- 报告要素区 -->
  302. <div class="rp-elements">
  303. <div class="rp-elements-header">
  304. <div class="rp-elements-title">
  305. <span class="rp-title-icon">📋</span>
  306. <span class="rp-title-text">报告要素</span>
  307. <span class="rp-title-count">{{ filledValues.length }}</span>
  308. </div>
  309. <div class="rp-header-actions">
  310. <el-input
  311. v-if="elementSearchVisible"
  312. v-model="elementSearchQuery"
  313. size="small"
  314. placeholder="搜索要素..."
  315. clearable
  316. style="width: 120px"
  317. @blur="elementSearchVisible = elementSearchQuery.length > 0"
  318. />
  319. <el-button v-else text circle size="small" title="搜索" @click="elementSearchVisible = true"><el-icon><Search /></el-icon></el-button>
  320. </div>
  321. </div>
  322. <div class="rp-elements-body">
  323. <div class="rp-element-list" v-if="groupedElements.length > 0">
  324. <div
  325. v-for="group in groupedElements"
  326. :key="group.namespace"
  327. class="rp-element-group"
  328. >
  329. <div class="rp-group-header" @click="toggleElementGroup(group.namespace)">
  330. <span class="rp-group-icon">{{ elementGroupExpanded[group.namespace] ? '▼' : '▶' }}</span>
  331. <span class="rp-group-name">{{ group.label }}</span>
  332. <span class="rp-group-count">{{ group.items.length }}</span>
  333. </div>
  334. <div class="rp-group-items" v-show="elementGroupExpanded[group.namespace]">
  335. <div
  336. v-for="item in group.items"
  337. :key="item.elementKey"
  338. class="rp-element-item"
  339. :class="{
  340. 'is-text': item.elementType === 'text',
  341. 'is-paragraph': item.elementType === 'paragraph',
  342. 'is-table': item.elementType === 'table',
  343. 'has-value': item.hasValue,
  344. 'is-active': highlightPopover.elementKey === item.elementKey
  345. }"
  346. @click="scrollToElement(item)"
  347. :title="item.valueText || '暂无值'"
  348. >
  349. <span class="rp-item-type">{{ item.elementType === 'text' ? 'T' : item.elementType === 'paragraph' ? 'P' : '▦' }}</span>
  350. <span class="rp-item-name">{{ item.elementName }}</span>
  351. <span v-if="item.hasValue" class="rp-item-preview">{{ truncateValue(item.valueText, 20) }}</span>
  352. </div>
  353. </div>
  354. </div>
  355. </div>
  356. <div class="rp-elements-empty" v-else>
  357. <span>暂无要素</span>
  358. </div>
  359. </div>
  360. </div>
  361. <!-- AI 助手区 -->
  362. <div class="rp-ai">
  363. <div class="rp-ai-header">
  364. <div class="rp-ai-title">
  365. <span class="rp-ai-icon">🤖</span>
  366. <span>AI 助手</span>
  367. </div>
  368. <div class="rp-ai-actions">
  369. <el-button text size="small" @click="aiMessages = []">🔄 新对话</el-button>
  370. </div>
  371. </div>
  372. <div class="rp-ai-messages" ref="aiMessagesRef">
  373. <div class="ai-message ai-bot" v-if="aiMessages.length === 0">
  374. <div class="ai-bubble">👋 您好,我不仅是您的AI助手,更是您深度思考时的沉浸式创作搭档。你准备好开启这场创作之旅了吗?</div>
  375. </div>
  376. <div
  377. v-for="(msg, idx) in aiMessages"
  378. :key="idx"
  379. class="ai-message"
  380. :class="msg.role === 'user' ? 'ai-user' : 'ai-bot'"
  381. >
  382. <div class="ai-bubble">{{ msg.content }}</div>
  383. </div>
  384. </div>
  385. <div class="rp-ai-input">
  386. <el-input
  387. v-model="aiInputText"
  388. placeholder="发消息给 AI 助手~"
  389. :autosize="{ minRows: 1, maxRows: 3 }"
  390. type="textarea"
  391. resize="none"
  392. @keydown.enter.exact.prevent="sendAiMessage"
  393. />
  394. <div class="rp-ai-input-actions">
  395. <div class="rp-ai-input-tools">
  396. <el-button text circle size="small" title="附加">+</el-button>
  397. <el-button text circle size="small" title="提及">@</el-button>
  398. <el-button text circle size="small" title="模板">🌐</el-button>
  399. </div>
  400. <div class="rp-ai-input-right">
  401. <el-button text circle size="small" title="语音">🎤</el-button>
  402. <el-button
  403. type="primary"
  404. circle
  405. size="small"
  406. :disabled="!aiInputText.trim()"
  407. @click="sendAiMessage"
  408. title="发送"
  409. >
  410. <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>
  411. </el-button>
  412. </div>
  413. </div>
  414. </div>
  415. </div>
  416. </div>
  417. </div>
  418. <!-- 附件管理弹窗 -->
  419. <el-dialog
  420. v-model="showAttachmentDialog"
  421. title="📎 附件管理"
  422. width="640"
  423. :close-on-click-modal="true"
  424. class="floating-panel-dialog"
  425. align-center
  426. >
  427. <div class="fp-toolbar">
  428. <el-upload
  429. :auto-upload="false"
  430. :on-change="handleAttachmentUpload"
  431. :show-file-list="false"
  432. accept=".pdf,.doc,.docx,.xls,.xlsx,.zip,.png,.jpg"
  433. >
  434. <el-button size="small" :icon="Plus">添加附件</el-button>
  435. </el-upload>
  436. <span class="fp-count">共 {{ attachments.length }} 个附件</span>
  437. </div>
  438. <div class="fp-list">
  439. <div
  440. v-for="att in attachments"
  441. :key="att.id"
  442. class="fp-att-item"
  443. :class="{ active: selectedAttachment?.id === att.id }"
  444. @click="selectAttachment(att)"
  445. >
  446. <div class="att-icon" :class="getFileTypeClass(att)">
  447. <span class="att-icon-text">{{ getFileTypeLabel(att) }}</span>
  448. </div>
  449. <div class="att-info">
  450. <div class="att-name" :title="att.displayName">{{ att.displayName }}</div>
  451. <div class="att-meta">
  452. <span class="att-type">{{ getFileTypeTag(att) }}</span>
  453. <span class="att-size">{{ formatFileSize(att.fileSize) }}</span>
  454. <span
  455. v-if="parseStates[att.id]?.status === 'parsing' || parseStates[att.id]?.status === 'uploading'"
  456. class="att-parse-status parsing"
  457. >
  458. <el-icon class="is-loading"><Loading /></el-icon>
  459. {{ parseStates[att.id]?.progress || '解析中...' }}
  460. </span>
  461. <span v-else-if="parseStates[att.id]?.status === 'completed'" class="att-parse-status completed" style="cursor:pointer" @click.stop="viewParseResult(att)">✅ 已解析 · 查看</span>
  462. <span v-else-if="parseStates[att.id]?.status === 'failed'" class="att-parse-status failed">❌ 失败</span>
  463. </div>
  464. </div>
  465. <el-button
  466. v-if="parseStates[att.id]?.status === 'completed'"
  467. class="att-parse-btn"
  468. size="small"
  469. type="success"
  470. text
  471. @click.stop="viewParseResult(att)"
  472. >
  473. 查看
  474. </el-button>
  475. <el-button
  476. v-else-if="canParse(att) && (!parseStates[att.id] || parseStates[att.id]?.status === 'idle' || parseStates[att.id]?.status === 'failed')"
  477. class="att-parse-btn"
  478. size="small"
  479. type="primary"
  480. text
  481. @click.stop="handleParseAttachment(att)"
  482. >
  483. {{ getFileExt(att) === 'zip' ? '打开' : '解析' }}
  484. </el-button>
  485. <el-button
  486. v-if="parseStates[att.id]?.status === 'parsing' || parseStates[att.id]?.status === 'uploading'"
  487. class="att-parse-btn"
  488. size="small"
  489. type="info"
  490. text
  491. disabled
  492. >
  493. <el-icon class="is-loading"><Loading /></el-icon>
  494. </el-button>
  495. <el-dropdown trigger="click" @command="(cmd) => handleAttachmentAction(cmd, att)" @click.stop>
  496. <el-button class="att-more-btn" size="small" text :icon="MoreFilled" @click.stop />
  497. <template #dropdown>
  498. <el-dropdown-menu>
  499. <el-dropdown-item command="preview">📄 预览</el-dropdown-item>
  500. <el-dropdown-item v-if="canParse(att)" command="parse">🔍 解析文档</el-dropdown-item>
  501. <el-dropdown-item v-if="parseStates[att.id]?.status === 'completed'" command="view_result">📋 查看解析结果</el-dropdown-item>
  502. <el-dropdown-item command="apply">📝 应用要素</el-dropdown-item>
  503. <el-dropdown-item command="download">⬇️ 下载</el-dropdown-item>
  504. <el-dropdown-item command="delete" divided>🗑️ 删除</el-dropdown-item>
  505. </el-dropdown-menu>
  506. </template>
  507. </el-dropdown>
  508. </div>
  509. <el-empty v-if="attachments.length === 0" description="暂无附件,点击添加按钮上传" :image-size="80" />
  510. </div>
  511. </el-dialog>
  512. <!-- 解析结果预览弹窗 -->
  513. <el-dialog
  514. v-model="showParseResultDialog"
  515. :title="'📋 解析结果 - ' + (parseResultAttName || '')"
  516. width="1100"
  517. :close-on-click-modal="true"
  518. class="floating-panel-dialog parse-result-dialog"
  519. align-center
  520. @close="cleanupPreviewContent"
  521. >
  522. <!-- 引用模式提示条 -->
  523. <div v-if="referenceMode" class="citation-mode-banner">
  524. <span>📌 正在为要素「<b>{{ referenceModeElementName }}</b>」选择引用内容,请选中文本后选择引用方式</span>
  525. <el-button size="small" text @click="exitReferenceMode">✕ 退出</el-button>
  526. </div>
  527. <div class="parse-result-toolbar">
  528. <span class="parse-result-info">{{ parseResultContent ? `共 ${parseResultContent.length} 字` : '' }}</span>
  529. <div class="parse-result-actions">
  530. <el-button size="small" :type="parseResultViewMode === 'rendered' ? 'primary' : ''" @click="parseResultViewMode = 'rendered'">渲染</el-button>
  531. <el-button size="small" :type="parseResultViewMode === 'source' ? 'primary' : ''" @click="parseResultViewMode = 'source'">源码</el-button>
  532. <el-button v-if="parseResultPreviewAvailable" size="small" :type="parseResultViewMode === 'preview' ? 'primary' : ''" @click="switchToPreviewMode">原件</el-button>
  533. <el-button size="small" @click="copyParseResult">📋 复制</el-button>
  534. </div>
  535. </div>
  536. <div class="parse-result-content" @mouseup="onParseResultMouseUp">
  537. <div v-if="parseResultViewMode === 'rendered'" class="parse-result-rendered markdown-body" v-html="parseResultHtml"></div>
  538. <pre v-else-if="parseResultViewMode === 'source'" class="parse-result-pre">{{ parseResultSource }}</pre>
  539. <div v-else-if="parseResultViewMode === 'preview'" class="parse-result-preview">
  540. <img v-if="previewContentType === 'image'" :src="previewContentUrl" :alt="parseResultAttName" style="max-width:100%;height:auto;display:block;margin:0 auto" />
  541. <iframe v-else-if="previewContentType === 'pdf'" :src="previewContentUrl" style="width:100%;height:75vh;border:none" />
  542. <div v-else-if="previewContentType === 'html'" class="parse-result-rendered markdown-body" v-html="previewContentHtml" />
  543. <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>
  544. <div v-else style="text-align:center;padding:40px;color:#999">该文件类型暂不支持原件预览</div>
  545. </div>
  546. </div>
  547. <!-- 选中文本后的浮动引用工具栏 -->
  548. <div
  549. v-if="citationToolbar.visible"
  550. class="citation-toolbar"
  551. :style="{ top: citationToolbar.y + 'px', left: citationToolbar.x + 'px' }"
  552. >
  553. <!-- 第一步:选择引用方式 -->
  554. <template v-if="citationToolbar.step === 'select_action'">
  555. <div class="citation-toolbar-title">引用到要素:</div>
  556. <div class="citation-toolbar-actions">
  557. <el-button size="small" type="primary" @click="setCitationAction('quote')">📝 直接引用</el-button>
  558. <el-button size="small" @click="setCitationAction('summary')">🤖 AI 总结</el-button>
  559. <el-button size="small" @click="setCitationAction('table_extract')">📊 表格提取</el-button>
  560. </div>
  561. </template>
  562. <!-- 第二步:选择目标要素(非引用模式时) -->
  563. <template v-if="citationToolbar.step === 'select_element'">
  564. <div class="citation-toolbar-title">选择目标要素:</div>
  565. <div class="citation-toolbar-elements">
  566. <div
  567. v-for="elem in elements"
  568. :key="elem.elementKey"
  569. class="citation-element-item"
  570. @click="confirmCitation(elem)"
  571. >
  572. <span class="citation-elem-name">{{ elem.elementName }}</span>
  573. <el-tag size="small" type="info">{{ elem.elementType }}</el-tag>
  574. </div>
  575. <div v-if="elements.length === 0" style="padding:8px;color:#999;font-size:12px">暂无要素</div>
  576. </div>
  577. </template>
  578. </div>
  579. </el-dialog>
  580. <!-- ZIP 内容展示弹窗 -->
  581. <el-dialog
  582. v-model="showZipContentsDialog"
  583. :title="'📦 ' + (zipContentsAttName || 'ZIP 内容')"
  584. width="520"
  585. :close-on-click-modal="true"
  586. class="floating-panel-dialog"
  587. align-center
  588. >
  589. <div class="fp-toolbar">
  590. <span class="fp-count">共 {{ zipFileList.length }} 个文件,{{ zipFileList.filter(f => f.parseable).length }} 个可解析</span>
  591. <el-button
  592. size="small"
  593. type="primary"
  594. :disabled="zipFileList.filter(f => f.parseable && !f.parsed && !f.parsing).length === 0"
  595. @click="parseAllZipEntries"
  596. >🔍 一键全部解析</el-button>
  597. </div>
  598. <div class="fp-list">
  599. <div v-if="zipFileList.length === 0" style="text-align:center;padding:30px;color:#999">ZIP 包为空</div>
  600. <div v-for="(zf, idx) in zipFileList" :key="idx" class="fp-att-item">
  601. <div class="att-icon" :class="getZipEntryTypeClass(zf)">
  602. <span class="att-icon-text">{{ getZipEntryTypeLabel(zf) }}</span>
  603. </div>
  604. <div class="att-info">
  605. <div class="att-name" :title="zf.name">{{ zf.name.split('/').pop() }}</div>
  606. <div class="att-meta">
  607. <span class="att-type">{{ zf.ext || '文件' }}</span>
  608. <span class="att-size">{{ formatFileSize(zf.size) }}</span>
  609. <span v-if="zf.parsing" class="att-parse-status parsing">
  610. <el-icon class="is-loading"><Loading /></el-icon> 解析中...
  611. </span>
  612. <span v-else-if="zf.parsed" class="att-parse-status completed" style="cursor:pointer" @click="viewZipEntryResult(zf)">✅ 已解析 · 查看</span>
  613. </div>
  614. </div>
  615. <el-button
  616. class="att-parse-btn"
  617. size="small"
  618. text
  619. @click="previewZipEntry(zf)"
  620. >预览</el-button>
  621. <el-button
  622. v-if="zf.parsed"
  623. class="att-parse-btn"
  624. size="small"
  625. type="success"
  626. text
  627. @click="viewZipEntryResult(zf)"
  628. >查看</el-button>
  629. <el-button
  630. v-else-if="zf.parseable && !zf.parsing"
  631. class="att-parse-btn"
  632. size="small"
  633. type="primary"
  634. text
  635. @click="parseZipEntry(zf)"
  636. >解析</el-button>
  637. <el-button
  638. v-else-if="zf.parsing"
  639. class="att-parse-btn"
  640. size="small"
  641. type="info"
  642. text
  643. disabled
  644. ><el-icon class="is-loading"><Loading /></el-icon></el-button>
  645. </div>
  646. </div>
  647. </el-dialog>
  648. <!-- 引用模式:附件选择弹窗 -->
  649. <el-dialog
  650. v-model="showRefAttSelectDialog"
  651. title="📎 选择要引用的附件"
  652. width="480"
  653. :close-on-click-modal="true"
  654. class="floating-panel-dialog"
  655. align-center
  656. >
  657. <div class="fp-list">
  658. <div
  659. v-for="att in refAttSelectList"
  660. :key="att.id"
  661. class="fp-att-item"
  662. style="cursor:pointer"
  663. @click="onRefAttSelected(att)"
  664. >
  665. <div class="att-icon" :class="getFileTypeClass(att)">
  666. <span class="att-icon-text">{{ getFileTypeLabel(att) }}</span>
  667. </div>
  668. <div class="att-info">
  669. <div class="att-name" :title="att.displayName">{{ att.displayName }}</div>
  670. <div class="att-meta">
  671. <span class="att-type">{{ getFileTypeTag(att) }}</span>
  672. <span class="att-size">{{ formatFileSize(att.fileSize) }}</span>
  673. <span class="att-parse-status completed">✅ 已解析</span>
  674. </div>
  675. </div>
  676. </div>
  677. </div>
  678. </el-dialog>
  679. <!-- 规则管理弹窗 -->
  680. <el-dialog
  681. v-model="showRuleDialog"
  682. title="⚙️ 规则管理"
  683. width="800"
  684. :close-on-click-modal="true"
  685. class="floating-panel-dialog rule-manage-dialog"
  686. align-center
  687. >
  688. <div class="fp-toolbar">
  689. <el-input
  690. v-model="ruleSearchQuery"
  691. size="small"
  692. placeholder="搜索规则名称 / 要素标识..."
  693. clearable
  694. style="width: 280px"
  695. :prefix-icon="Search"
  696. />
  697. <el-button size="small" :icon="Plus" @click="openWorkflowForNewRule">添加规则</el-button>
  698. <el-button
  699. v-if="rules.length > 0"
  700. type="success"
  701. size="small"
  702. @click="handleBatchExecuteRules"
  703. :loading="executingRules"
  704. >
  705. 批量执行
  706. </el-button>
  707. <span class="fp-count">{{ filteredRules.length }} / {{ rules.length }} 条</span>
  708. </div>
  709. <!-- 类型筛选标签栏 -->
  710. <div class="rule-filter-bar">
  711. <span
  712. class="rule-filter-tab"
  713. :class="{ active: ruleFilterType === 'all' }"
  714. @click="ruleFilterType = 'all'"
  715. >全部 <em>{{ ruleTypeStats.all || 0 }}</em></span>
  716. <span
  717. class="rule-filter-tab tab-summary"
  718. :class="{ active: ruleFilterType === 'summary' }"
  719. @click="ruleFilterType = 'summary'"
  720. >AI总结 <em>{{ ruleTypeStats.summary || 0 }}</em></span>
  721. <span
  722. class="rule-filter-tab tab-ai_extract"
  723. :class="{ active: ruleFilterType === 'ai_extract' }"
  724. @click="ruleFilterType = 'ai_extract'"
  725. >AI提取 <em>{{ ruleTypeStats.ai_extract || 0 }}</em></span>
  726. <span
  727. class="rule-filter-tab tab-table_extract"
  728. :class="{ active: ruleFilterType === 'table_extract' }"
  729. @click="ruleFilterType = 'table_extract'"
  730. >表格提取 <em>{{ ruleTypeStats.table_extract || 0 }}</em></span>
  731. <span
  732. class="rule-filter-tab tab-quote"
  733. :class="{ active: ruleFilterType === 'quote' }"
  734. @click="ruleFilterType = 'quote'"
  735. >引用 <em>{{ ruleTypeStats.quote || 0 }}</em></span>
  736. <span
  737. class="rule-filter-tab tab-use_entity_value"
  738. :class="{ active: ruleFilterType === 'use_entity_value' }"
  739. @click="ruleFilterType = 'use_entity_value'"
  740. >人工录入 <em>{{ ruleTypeStats.use_entity_value || 0 }}</em></span>
  741. </div>
  742. <!-- 规则列表 -->
  743. <div class="fp-list">
  744. <div
  745. v-for="rule in filteredRules"
  746. :key="rule.id"
  747. class="fp-rule-item"
  748. :class="{ expanded: expandedRuleId === rule.id }"
  749. @click="openWorkflowForRule(rule)"
  750. >
  751. <div class="rule-item-main">
  752. <span class="rule-action-badge" :class="'action-' + rule.actionType">{{ ruleActionLabel(rule.actionType) }}</span>
  753. <div class="rule-info">
  754. <div class="rule-name-row">
  755. <span class="rule-name">{{ rule.ruleName }}</span>
  756. <el-tag size="small" type="info" effect="plain" class="rule-elem-key">{{ rule.elementKey }}</el-tag>
  757. </div>
  758. <div class="rule-desc" v-if="ruleSourceSummary(rule)">来源:{{ ruleSourceSummary(rule) }}</div>
  759. </div>
  760. <div class="rule-actions">
  761. <el-button v-if="rule.actionType !== 'use_entity_value'" size="small" type="primary" text @click.stop="handleExecuteRule(rule)" title="执行" :loading="rule._executing">▶</el-button>
  762. <el-button size="small" type="danger" text :icon="Delete" @click.stop="handleDeleteRule(rule)" title="删除" />
  763. </div>
  764. </div>
  765. <!-- 展开详情 -->
  766. <div class="rule-detail" v-if="expandedRuleId === rule.id">
  767. <div class="rule-detail-row" v-if="rule.dslContent">
  768. <span class="rule-detail-label">取值规则</span>
  769. <span class="rule-detail-value">{{ rule.dslContent }}</span>
  770. </div>
  771. <div class="rule-detail-row" v-if="ruleInputDisplayList(rule).length > 0">
  772. <span class="rule-detail-label">输入来源</span>
  773. <div class="rule-detail-inputs">
  774. <span v-for="(src, idx) in ruleInputDisplayList(rule)" :key="`${rule.id}-src-${idx}`" class="rule-input-chip">
  775. 📎 {{ src }}
  776. </span>
  777. </div>
  778. </div>
  779. <div class="rule-detail-row" v-if="rule.lastRunStatus">
  780. <span class="rule-detail-label">上次执行</span>
  781. <el-tag size="small" :type="rule.lastRunStatus === 'success' ? 'success' : 'danger'">
  782. {{ rule.lastRunStatus === 'success' ? '成功' : '失败' }}
  783. </el-tag>
  784. <span v-if="rule.lastRunTime" class="rule-detail-time">{{ rule.lastRunTime }}</span>
  785. </div>
  786. <div class="rule-detail-row" v-if="rule.lastRunError">
  787. <span class="rule-detail-label">错误信息</span>
  788. <span class="rule-detail-error">{{ rule.lastRunError }}</span>
  789. </div>
  790. </div>
  791. </div>
  792. <el-empty v-if="filteredRules.length === 0" :description="ruleSearchQuery || ruleFilterType !== 'all' ? '无匹配规则' : '暂无规则,点击添加按钮创建'" :image-size="80" />
  793. </div>
  794. </el-dialog>
  795. <!-- 新建项目对话框 -->
  796. <el-dialog v-model="showNewProjectDialog" title="新建项目" width="520" :close-on-click-modal="false">
  797. <el-form :model="newProjectForm" label-width="100px">
  798. <el-form-item label="项目名称" required>
  799. <el-input v-model="newProjectForm.title" placeholder="请输入项目名称" maxlength="100" show-word-limit />
  800. </el-form-item>
  801. <el-form-item label="项目描述">
  802. <el-input v-model="newProjectForm.description" type="textarea" :rows="2" placeholder="项目描述(可选)" />
  803. </el-form-item>
  804. <!-- 可选:上传DOCX文档自动提取要素 -->
  805. <el-form-item label="导入文档">
  806. <div class="upload-docx-area">
  807. <el-upload
  808. ref="docxUploadRef"
  809. :auto-upload="false"
  810. :show-file-list="true"
  811. :limit="1"
  812. accept=".docx"
  813. :on-change="handleDocxFileChange"
  814. :on-remove="handleDocxFileRemove"
  815. >
  816. <template #trigger>
  817. <el-button type="primary" plain size="small">
  818. <el-icon><Upload /></el-icon>
  819. 选择DOCX文件
  820. </el-button>
  821. </template>
  822. <template #tip>
  823. <div class="el-upload__tip">
  824. 可选:上传DOCX文件自动解析并提取要素
  825. </div>
  826. </template>
  827. </el-upload>
  828. </div>
  829. </el-form-item>
  830. <!-- 解析进度 -->
  831. <el-form-item v-if="docxParseProgress > 0" label="解析进度">
  832. <el-progress :percentage="docxParseProgress" :status="docxParseStatus" />
  833. <div class="parse-message">{{ docxParseMessage }}</div>
  834. </el-form-item>
  835. </el-form>
  836. <template #footer>
  837. <el-button @click="handleCancelNewProject">取消</el-button>
  838. <el-button
  839. type="primary"
  840. @click="handleCreateProject"
  841. :disabled="!newProjectForm.title.trim()"
  842. :loading="creatingProject"
  843. >
  844. {{ newProjectForm.docxFile ? '创建并解析' : '创建' }}
  845. </el-button>
  846. </template>
  847. </el-dialog>
  848. <!-- 添加要素对话框 -->
  849. <el-dialog v-model="showAddElementDialog" title="添加要素" width="500">
  850. <el-form :model="newElementForm" label-width="100px">
  851. <el-form-item label="要素名称" required>
  852. <el-input v-model="newElementForm.elementName" placeholder="如:项目编号" />
  853. </el-form-item>
  854. <el-form-item label="要素标识" required>
  855. <el-input v-model="newElementForm.elementKey" placeholder="如:basicInfo.projectCode" />
  856. </el-form-item>
  857. <el-form-item label="数据类型">
  858. <el-select v-model="newElementForm.dataType" style="width: 100%">
  859. <el-option label="文本" value="text" />
  860. <el-option label="数字" value="number" />
  861. <el-option label="日期" value="date" />
  862. <el-option label="金额" value="money" />
  863. </el-select>
  864. </el-form-item>
  865. <el-form-item label="描述">
  866. <el-input v-model="newElementForm.description" type="textarea" :rows="2" placeholder="要素描述" />
  867. </el-form-item>
  868. </el-form>
  869. <template #footer>
  870. <el-button @click="showAddElementDialog = false">取消</el-button>
  871. <el-button type="primary" @click="handleAddElement" :disabled="!newElementForm.elementName || !newElementForm.elementKey">添加</el-button>
  872. </template>
  873. </el-dialog>
  874. <!-- 添加规则对话框 -->
  875. <el-dialog v-model="showNewRuleDialog" title="添加规则" width="600">
  876. <el-form :model="newRuleForm" label-width="100px">
  877. <el-form-item label="规则名称" required>
  878. <el-input v-model="newRuleForm.ruleName" placeholder="如:项目编号-直接引用实体" />
  879. </el-form-item>
  880. <el-form-item label="规则类型" required>
  881. <el-select v-model="newRuleForm.ruleType" style="width: 100%">
  882. <el-option label="直接引用实体" value="direct_entity" />
  883. <el-option label="AI 提取" value="ai_extract" />
  884. <el-option label="AI 总结" value="summary" />
  885. <el-option label="表格提取" value="table_extract" />
  886. <el-option label="固定值" value="fixed_value" />
  887. <el-option label="计算公式" value="formula" />
  888. </el-select>
  889. </el-form-item>
  890. <el-form-item label="目标要素">
  891. <el-select v-model="newRuleForm.targetElementKey" style="width: 100%" placeholder="选择要填充的要素">
  892. <el-option v-for="elem in elements" :key="elem.elementKey" :label="elem.elementName" :value="elem.elementKey" />
  893. </el-select>
  894. </el-form-item>
  895. <el-form-item label="来源附件" v-if="['ai_extract', 'summary', 'table_extract', 'direct_entity'].includes(newRuleForm.ruleType)">
  896. <el-select v-model="newRuleForm.sourceAttachmentId" style="width: 100%" placeholder="选择来源附件" clearable>
  897. <el-option v-for="att in attachments" :key="att.id" :label="att.fileName" :value="att.id" />
  898. </el-select>
  899. </el-form-item>
  900. <el-form-item label="内容定位" v-if="['ai_extract', 'summary', 'table_extract'].includes(newRuleForm.ruleType)">
  901. <el-select v-model="newRuleForm.locatorType" style="width: 100%" placeholder="选择内容定位方式">
  902. <el-option label="全文" value="full_text" />
  903. <el-option label="章节定位" value="chapter" />
  904. <el-option label="评审代码定位" value="review_code" />
  905. <el-option label="表格定位" value="table" />
  906. </el-select>
  907. </el-form-item>
  908. <el-form-item label="章节标题" v-if="newRuleForm.locatorType === 'chapter'">
  909. <el-input v-model="newRuleForm.chapterTitle" placeholder="如:一、工作目的" />
  910. </el-form-item>
  911. <el-form-item label="评审代码" v-if="newRuleForm.locatorType === 'review_code'">
  912. <el-input v-model="newRuleForm.reviewCode" placeholder="如:5.1.5" />
  913. </el-form-item>
  914. <el-form-item label="表格选择器" v-if="newRuleForm.locatorType === 'table'">
  915. <el-input v-model="newRuleForm.tableSelector" placeholder="如:核心要素评审情况记录表" />
  916. </el-form-item>
  917. <el-form-item label="AI 提示词" v-if="['ai_extract', 'summary'].includes(newRuleForm.ruleType)">
  918. <el-input
  919. v-model="newRuleForm.prompt"
  920. type="textarea"
  921. :rows="3"
  922. placeholder="如:从原文提取评审对象公司的全称,只输出公司名称"
  923. />
  924. </el-form-item>
  925. </el-form>
  926. <template #footer>
  927. <el-button @click="showNewRuleDialog = false">取消</el-button>
  928. <el-button type="primary" @click="handleCreateRule" :disabled="!newRuleForm.ruleName">创建</el-button>
  929. </template>
  930. </el-dialog>
  931. <!-- 规则引擎数据弹窗 -->
  932. <el-dialog v-model="showRuleEngineDialog" :title="pendingExecuteRule ? '执行规则' : '批量执行规则'" width="900">
  933. <div class="rule-engine-preview">
  934. <p class="rule-engine-desc">{{ pendingExecuteRule ? '即将执行以下规则:' : '以下是非人工录入要素的规则信息,可用于规则引擎处理:' }}</p>
  935. <div class="rule-engine-stats" v-if="!pendingExecuteRule">
  936. <span>共 <strong>{{ ruleEngineData.length }}</strong> 条自动规则</span>
  937. </div>
  938. <div class="rule-engine-list">
  939. <div
  940. v-for="rule in displayRulesForEngine"
  941. :key="rule.id"
  942. class="rule-engine-item"
  943. >
  944. <div class="rule-engine-header">
  945. <span class="rule-engine-name">{{ rule.ruleName }}</span>
  946. <el-tag size="small" :type="getActionTypeTagType(rule.actionType)">{{ rule.actionType }}</el-tag>
  947. <el-tag size="small" type="info">{{ rule.ruleType }}</el-tag>
  948. </div>
  949. <div class="rule-engine-element">
  950. <span class="label">目标要素:</span>
  951. <code>{{ rule.elementKey }}</code>
  952. </div>
  953. <div class="rule-engine-inputs" v-if="rule.inputs && rule.inputs.length > 0">
  954. <span class="label">输入来源:</span>
  955. <div class="input-list">
  956. <div v-for="(inp, idx) in rule.inputs" :key="idx" class="input-item">
  957. <span class="input-source">📎 {{ inp.sourceName || inp.inputName }}</span>
  958. <span v-if="inp.entryPath" class="input-entry">→ {{ inp.entryPath }}</span>
  959. <div v-if="inp.sourceText" class="input-text">
  960. <span class="text-label">引用文本:</span>
  961. <span class="text-content">{{ inp.sourceText.slice(0, 200) }}{{ inp.sourceText.length > 200 ? '...' : '' }}</span>
  962. </div>
  963. </div>
  964. </div>
  965. </div>
  966. <div class="rule-engine-source" v-else-if="rule.sourceText">
  967. <span class="label">引用文本:</span>
  968. <span class="source-text">{{ (rule.sourceText || '').slice(0, 200) }}{{ (rule.sourceText || '').length > 200 ? '...' : '' }}</span>
  969. </div>
  970. <div class="rule-engine-code" v-if="rule.reviewCode">
  971. <span class="label">评审代码:</span>
  972. <code>{{ rule.reviewCode }}</code>
  973. </div>
  974. <div class="rule-engine-desc-text" v-if="rule.description">
  975. <span class="label">描述:</span>
  976. <span>{{ rule.description }}</span>
  977. </div>
  978. </div>
  979. </div>
  980. <div class="rule-engine-json">
  981. <div class="json-header">
  982. <span class="json-title">JSON 数据</span>
  983. <el-button size="small" @click="copyRuleEngineJson">复制</el-button>
  984. </div>
  985. <pre class="json-content">{{ JSON.stringify(ruleEngineAdaptedData, null, 2) }}</pre>
  986. </div>
  987. </div>
  988. <template #footer>
  989. <el-button @click="showRuleEngineDialog = false">取消</el-button>
  990. <el-button type="primary" @click="handleConfirmExecute" :loading="executingRules">
  991. {{ pendingExecuteRule ? '确认执行' : `确认执行 (${ruleEngineData.length} 条规则)` }}
  992. </el-button>
  993. </template>
  994. </el-dialog>
  995. <!-- 规则工作流弹窗 -->
  996. <el-dialog
  997. v-model="showRuleWorkflow"
  998. fullscreen
  999. :close-on-click-modal="false"
  1000. :show-close="false"
  1001. class="rule-workflow-dialog"
  1002. >
  1003. <template #header="{ }">
  1004. <span style="display: none;"></span>
  1005. </template>
  1006. <RuleWorkflow
  1007. v-if="showRuleWorkflow && currentProjectId"
  1008. :key="workflowTargetRule?.id || 'new'"
  1009. :project-id="currentProjectId"
  1010. :attachments="attachments"
  1011. :elements="elements"
  1012. :rules="rules"
  1013. :target-rule="workflowTargetRule"
  1014. :target-element="workflowTargetElement"
  1015. :workflow-title="workflowTargetRule ? `编辑规则 - ${workflowTargetElement?.elementName || workflowTargetRule.elementKey}` : '新建规则'"
  1016. @save="handleWorkflowSave"
  1017. @close="showRuleWorkflow = false"
  1018. />
  1019. </el-dialog>
  1020. </div>
  1021. </template>
  1022. <script setup>
  1023. import { ref, reactive, computed, watch, onMounted } from 'vue'
  1024. import { useRouter, useRoute } from 'vue-router'
  1025. import { Plus, Delete, Search, Loading, Check, CopyDocument, MoreFilled, List, Folder, Document, Grid, Setting, Paperclip, Upload } from '@element-plus/icons-vue'
  1026. import { ElMessage, ElMessageBox } from 'element-plus'
  1027. import { projectApi, elementApi, valueApi, attachmentApi, ruleApi, parseApi, extractApi, entityApi } from '@/api'
  1028. import { marked } from 'marked'
  1029. import JSZip from 'jszip'
  1030. import { useTaskCenterStore } from '@/stores/taskCenter'
  1031. import RuleWorkflow from '@/components/workflow/RuleWorkflow.vue'
  1032. const router = useRouter()
  1033. const route = useRoute()
  1034. const taskCenterStore = useTaskCenterStore()
  1035. const taskRunningCount = computed(() => taskCenterStore.runningCount)
  1036. const currentProjectId = ref(null)
  1037. const hasActiveProject = computed(() => !!currentProjectId.value)
  1038. const userName = computed(() => localStorage.getItem('username') || '用户')
  1039. const greetingText = computed(() => {
  1040. const hour = new Date().getHours()
  1041. if (hour < 6) return '凌晨好'
  1042. if (hour < 9) return '早上好'
  1043. if (hour < 12) return '上午好'
  1044. if (hour < 14) return '中午好'
  1045. if (hour < 18) return '下午好'
  1046. if (hour < 22) return '晚上好'
  1047. return '夜深了'
  1048. })
  1049. const projectTitle = ref('')
  1050. const viewMode = ref('document')
  1051. const saved = ref(true)
  1052. const editorRef = ref(null)
  1053. const loading = ref(false)
  1054. const leftPanelWidth = ref(300)
  1055. const rightPanelWidth = ref(340)
  1056. const isResizing = ref(false)
  1057. const resizeType = ref('')
  1058. const leftPanelTab = ref('projects')
  1059. const sidebarShowAll = ref(false)
  1060. const recentActivities = computed(() => {
  1061. // 基于项目列表生成最近操作 + 额外 mock 数据
  1062. const acts = []
  1063. // 从项目列表生成
  1064. for (const p of projects.value.slice(0, 3)) {
  1065. acts.push({
  1066. text: `打开项目「${p.title}」`,
  1067. source: `@${userName.value}`,
  1068. time: formatTime(p.updatedAt || p.createdAt)
  1069. })
  1070. }
  1071. // 额外 mock 数据 - 模拟各种操作类型
  1072. const mockActivities = [
  1073. { text: '修改要素「评审对象」的值', source: '@张三', time: '10分钟前' },
  1074. { text: '执行规则「评审得分提取」', source: '@系统', time: '15分钟前' },
  1075. { text: '上传附件「核心要素评审记录表.docx」', source: '@李四', time: '30分钟前' },
  1076. { text: '新建规则「评审期自动计算」', source: '@张三', time: '1小时前' },
  1077. { text: '导出报告「成都院复审报告」', source: '@王五', time: '2小时前' },
  1078. { text: '修改要素「评审结论级别」的值', source: '@李四', time: '3小时前' },
  1079. { text: '删除附件「旧版评审表.xlsx」', source: '@张三', time: '昨天 16:30' },
  1080. { text: '创建项目「西南院标准化复审」', source: '@管理员', time: '昨天 10:15' },
  1081. ]
  1082. return [...acts, ...mockActivities].slice(0, 10)
  1083. })
  1084. const projects = ref([])
  1085. const loadingProjects = ref(false)
  1086. const projectSearchKeyword = ref('')
  1087. const elements = ref([])
  1088. const values = ref([])
  1089. const attachments = ref([])
  1090. const rules = ref([])
  1091. const selectedAttachment = ref(null)
  1092. const executingRules = ref(false)
  1093. // AI 助手
  1094. const aiMessages = ref([])
  1095. const aiInputText = ref('')
  1096. const aiMessagesRef = ref(null)
  1097. // 已填充的要素值(用于右侧标签云)
  1098. const filledValues = computed(() => {
  1099. const result = []
  1100. for (const elem of elements.value) {
  1101. const elemVals = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
  1102. for (const val of elemVals) {
  1103. if (val.valueText && val.valueText.length > 0) {
  1104. result.push({ valueId: val.valueId, valueText: val.valueText, elementName: elem.elementName, elementKey: elem.elementKey })
  1105. }
  1106. }
  1107. }
  1108. return result
  1109. })
  1110. // 要素搜索和分组
  1111. const elementSearchVisible = ref(false)
  1112. const elementSearchQuery = ref('')
  1113. const elementGroupExpanded = reactive({})
  1114. const NAMESPACE_LABELS = {
  1115. 'project': '项目信息',
  1116. 'basicInfo': '基本信息',
  1117. 'review': '评审信息',
  1118. 'score': '评分信息',
  1119. '+': '扩展要素',
  1120. 'other': '其他'
  1121. }
  1122. const groupedElements = computed(() => {
  1123. const groups = {}
  1124. const query = elementSearchQuery.value.toLowerCase()
  1125. for (const elem of elements.value) {
  1126. // 搜索过滤
  1127. if (query && !elem.elementName.toLowerCase().includes(query) && !elem.elementKey.toLowerCase().includes(query)) {
  1128. continue
  1129. }
  1130. // 获取命名空间
  1131. let namespace = 'other'
  1132. if (elem.elementKey.startsWith('+')) {
  1133. namespace = '+'
  1134. } else if (elem.elementKey.includes('.')) {
  1135. namespace = elem.elementKey.split('.')[0]
  1136. }
  1137. if (!groups[namespace]) {
  1138. groups[namespace] = {
  1139. namespace,
  1140. label: NAMESPACE_LABELS[namespace] || namespace,
  1141. items: []
  1142. }
  1143. // 默认展开第一个分组
  1144. if (elementGroupExpanded[namespace] === undefined) {
  1145. elementGroupExpanded[namespace] = Object.keys(groups).length === 1
  1146. }
  1147. }
  1148. // 查找对应的值
  1149. const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
  1150. groups[namespace].items.push({
  1151. elementKey: elem.elementKey,
  1152. elementName: elem.elementName,
  1153. elementType: elem.elementType || 'text',
  1154. hasValue: !!(val?.valueText),
  1155. valueText: val?.valueText || ''
  1156. })
  1157. }
  1158. // 按顺序排列
  1159. const order = ['project', 'basicInfo', 'review', 'score', '+', 'other']
  1160. return order.filter(ns => groups[ns]).map(ns => groups[ns])
  1161. })
  1162. function toggleElementGroup(namespace) {
  1163. elementGroupExpanded[namespace] = !elementGroupExpanded[namespace]
  1164. }
  1165. function truncateValue(text, maxLen = 20) {
  1166. if (!text) return ''
  1167. const s = String(text).replace(/\n/g, ' ').trim()
  1168. return s.length > maxLen ? s.slice(0, maxLen) + '...' : s
  1169. }
  1170. function scrollToElement(item) {
  1171. // 查找文档中对应的高亮元素并滚动到视图
  1172. const docPaper = docPaperRef.value
  1173. if (!docPaper) return
  1174. // 高亮系统使用 data-elem-key 属性
  1175. const selector = `[data-elem-key="${item.elementKey}"]`
  1176. const el = docPaper.querySelector(selector)
  1177. if (el) {
  1178. // 使用 IntersectionObserver 监听元素进入视口后再闪烁
  1179. const observer = new IntersectionObserver((entries) => {
  1180. entries.forEach(entry => {
  1181. if (entry.isIntersecting) {
  1182. observer.disconnect()
  1183. // 元素已在视口中,延迟一点再闪烁确保滚动稳定
  1184. setTimeout(() => {
  1185. el.classList.add('elem-flash')
  1186. setTimeout(() => el.classList.remove('elem-flash'), 1500)
  1187. // 触发点击以打开弹窗
  1188. el.click()
  1189. }, 100)
  1190. }
  1191. })
  1192. }, { threshold: 0.5 })
  1193. observer.observe(el)
  1194. el.scrollIntoView({ behavior: 'smooth', block: 'center' })
  1195. // 超时保护:如果 3 秒内没有触发,强制执行
  1196. setTimeout(() => {
  1197. observer.disconnect()
  1198. }, 3000)
  1199. } else {
  1200. // 如果没有找到高亮元素,尝试在文档中搜索值文本
  1201. if (item.valueText) {
  1202. const textToFind = item.valueText.slice(0, 50) // 取前50个字符搜索
  1203. const walker = document.createTreeWalker(docPaper, NodeFilter.SHOW_TEXT, null, false)
  1204. let node
  1205. while (node = walker.nextNode()) {
  1206. if (node.textContent.includes(textToFind)) {
  1207. const range = document.createRange()
  1208. range.selectNodeContents(node)
  1209. const rect = range.getBoundingClientRect()
  1210. node.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' })
  1211. break
  1212. }
  1213. }
  1214. }
  1215. ElMessage.info(`未找到要素「${item.elementName}」的高亮位置`)
  1216. }
  1217. }
  1218. // 文档预览状态
  1219. const docContent = ref(null)
  1220. const docLoading = ref(false)
  1221. const docHtml = ref('')
  1222. const docTemplateHtml = ref('')
  1223. const docPaperRef = ref(null)
  1224. const highlightEnabled = ref(true)
  1225. const elementHighlightCount = ref(0)
  1226. const highlightPopover = reactive({
  1227. visible: false, x: 0, y: 0,
  1228. elementKey: '', fullElementKey: '', elementName: '', currentValue: '', originalValue: '', valueId: null
  1229. })
  1230. // 附件引用系统
  1231. const citationToolbar = reactive({
  1232. visible: false, x: 0, y: 0,
  1233. selectedText: '',
  1234. step: 'select_action', // 'select_action' | 'select_element'
  1235. actionType: '', // 'quote' | 'summary' | 'table_extract'
  1236. })
  1237. const showRefAttSelectDialog = ref(false) // 引用模式下的附件选择弹窗
  1238. const refAttSelectList = ref([]) // 可选附件列表
  1239. const refAttSelectElemKey = ref('') // 暂存目标要素 key
  1240. const refAttSelectElemName = ref('') // 暂存目标要素名
  1241. const referenceMode = ref(false) // 是否处于「从要素引用附件」模式
  1242. const referenceModeElementKey = ref('') // 引用模式下锁定的目标要素 key
  1243. const referenceModeElementName = ref('') // 引用模式下锁定的目标要素名
  1244. const referenceModeAttId = ref(null) // 引用来源的附件节点 ID
  1245. const referenceModeAttName = ref('') // 引用来源的附件名
  1246. const showNewProjectDialog = ref(false)
  1247. const showAddElementDialog = ref(false)
  1248. const showNewRuleDialog = ref(false)
  1249. const showAttachmentDialog = ref(false)
  1250. const showRuleDialog = ref(false)
  1251. const showRuleWorkflow = ref(false)
  1252. const workflowTargetRule = ref(null) // 当前编辑的规则(null 表示新建)
  1253. const workflowTargetElement = ref(null) // 当前编辑的目标要素
  1254. const ruleSearchQuery = ref('')
  1255. const ruleFilterType = ref('all')
  1256. const expandedRuleId = ref(null)
  1257. const filteredRules = computed(() => {
  1258. let list = rules.value
  1259. if (ruleFilterType.value !== 'all') {
  1260. list = list.filter(r => r.actionType === ruleFilterType.value)
  1261. }
  1262. const q = ruleSearchQuery.value.trim().toLowerCase()
  1263. if (q) {
  1264. list = list.filter(r =>
  1265. (r.ruleName || '').toLowerCase().includes(q) ||
  1266. (r.elementKey || '').toLowerCase().includes(q) ||
  1267. (r.description || '').toLowerCase().includes(q) ||
  1268. ruleSourceSummary(r).toLowerCase().includes(q)
  1269. )
  1270. }
  1271. return list
  1272. })
  1273. const ruleTypeStats = computed(() => {
  1274. const stats = { all: rules.value.length }
  1275. for (const r of rules.value) {
  1276. const t = r.actionType || 'unknown'
  1277. stats[t] = (stats[t] || 0) + 1
  1278. }
  1279. return stats
  1280. })
  1281. // 规则引擎准备数据:获取非人工录入要素的规则信息
  1282. const ruleEngineData = computed(() => {
  1283. // 过滤掉人工录入类型的规则
  1284. const autoRules = rules.value.filter(r => r.actionType !== 'use_entity_value')
  1285. return autoRules.map(rule => {
  1286. // 获取输入文本
  1287. const inputs = Array.isArray(rule.inputs) ? rule.inputs : []
  1288. const inputTexts = inputs.map(inp => {
  1289. return {
  1290. sourceName: inp.sourceName || inp.inputName || '',
  1291. sourceText: inp.sourceText || '',
  1292. entryPath: inp.entryPath || '',
  1293. inputType: inp.inputType || ''
  1294. }
  1295. }).filter(inp => inp.sourceName || inp.sourceText)
  1296. // 从 actionConfig 中提取额外信息
  1297. let actionConfigData = {}
  1298. try {
  1299. actionConfigData = typeof rule.actionConfig === 'string'
  1300. ? JSON.parse(rule.actionConfig)
  1301. : (rule.actionConfig || {})
  1302. } catch (e) {}
  1303. return {
  1304. id: rule.id,
  1305. ruleName: rule.ruleName,
  1306. elementKey: rule.elementKey,
  1307. ruleType: rule.ruleType,
  1308. actionType: rule.actionType,
  1309. actionConfig: rule.actionConfig,
  1310. description: rule.description,
  1311. inputs: inputTexts,
  1312. sourceText: actionConfigData.sourceText || inputTexts[0]?.sourceText || '',
  1313. reviewCode: actionConfigData.description?.match(/评审代码\s*(\d+\.\d+(?:\.\d+)*)/)?.[1] || ''
  1314. }
  1315. })
  1316. })
  1317. // 显示规则引擎数据弹窗
  1318. const showRuleEngineDialog = ref(false)
  1319. // 当前要执行的单条规则(用于弹窗展示)
  1320. const pendingExecuteRule = ref(null)
  1321. // 弹窗中显示的规则列表(单条或批量)
  1322. const displayRulesForEngine = computed(() => {
  1323. if (pendingExecuteRule.value) {
  1324. // 单条规则模式:转换为统一格式
  1325. const rule = pendingExecuteRule.value
  1326. const inputs = Array.isArray(rule.inputs) ? rule.inputs : []
  1327. let actionConfigData = {}
  1328. try {
  1329. actionConfigData = typeof rule.actionConfig === 'string'
  1330. ? JSON.parse(rule.actionConfig)
  1331. : (rule.actionConfig || {})
  1332. } catch (e) {}
  1333. return [{
  1334. id: rule.id,
  1335. ruleName: rule.ruleName,
  1336. elementKey: rule.elementKey,
  1337. ruleType: rule.ruleType,
  1338. actionType: rule.actionType,
  1339. actionConfig: rule.actionConfig,
  1340. description: rule.description,
  1341. inputs: inputs,
  1342. sourceText: actionConfigData.sourceText || '',
  1343. reviewCode: actionConfigData.description?.match(/评审代码\s*(\d+\.\d+(?:\.\d+)*)/)?.[1] || ''
  1344. }]
  1345. }
  1346. // 批量模式
  1347. return ruleEngineData.value
  1348. })
  1349. // 生成规则引擎适配格式的数据
  1350. const ruleEngineAdaptedData = computed(() => {
  1351. return displayRulesForEngine.value.map(rule => {
  1352. // 提取输入资源 ID 列表
  1353. const inputs = Array.isArray(rule.inputs) ? rule.inputs : []
  1354. const resourceIds = inputs
  1355. .filter(inp => inp.sourceNodeId)
  1356. .map(inp => ({
  1357. var_name: `resource_${inp.sourceNodeId}_id`,
  1358. id: inp.sourceNodeId,
  1359. name: inp.sourceName || inp.inputName || '',
  1360. entry_path: inp.entryPath || null
  1361. }))
  1362. // 从 actionConfig 中提取详细配置
  1363. let actionConfigData = {}
  1364. try {
  1365. actionConfigData = typeof rule.actionConfig === 'string'
  1366. ? JSON.parse(rule.actionConfig)
  1367. : (rule.actionConfig || {})
  1368. } catch (e) {}
  1369. // 提取 prompt(AI 提示词)
  1370. let prompt = actionConfigData.prompt || ''
  1371. const configDesc = actionConfigData.description || ''
  1372. if (!prompt) {
  1373. if (rule.actionType === 'summary') {
  1374. prompt = configDesc || `从原文生成${rule.ruleName}的摘要描述`
  1375. } else if (rule.actionType === 'ai_extract') {
  1376. prompt = configDesc || `从原文提取${rule.ruleName}相关信息`
  1377. } else if (rule.actionType === 'table_extract') {
  1378. prompt = configDesc || `从表格中提取${rule.ruleName}数据`
  1379. } else if (rule.actionType === 'quote') {
  1380. prompt = configDesc || `直接引用原文中的${rule.ruleName}内容`
  1381. }
  1382. }
  1383. // 确定函数类型
  1384. let funcType = 'export_resource' // 默认直接导出
  1385. if (rule.actionType === 'summary' || rule.actionType === 'ai_extract') {
  1386. funcType = 'ai_assistant'
  1387. } else if (rule.actionType === 'table_extract') {
  1388. funcType = 'table_extract'
  1389. } else if (rule.actionType === 'quote') {
  1390. funcType = 'export_resource'
  1391. }
  1392. // 提取引用的原文内容
  1393. const sourceText = actionConfigData.sourceText || rule.sourceText || ''
  1394. // 提取评审代码
  1395. const reviewCode = rule.reviewCode ||
  1396. actionConfigData.reviewCode ||
  1397. (configDesc.match(/评审代码\s*(\d+\.\d+(?:\.\d+)*)/)?.[1]) || null
  1398. // 内容定位信息
  1399. const contentLocator = {
  1400. type: actionConfigData.locatorType || 'full_text',
  1401. chapter_title: actionConfigData.chapterTitle || null,
  1402. review_code: reviewCode,
  1403. table_selector: actionConfigData.tableSelector || null
  1404. }
  1405. // 根据定位类型自动推断
  1406. if (!actionConfigData.locatorType) {
  1407. if (reviewCode) {
  1408. contentLocator.type = 'review_code'
  1409. } else if (rule.actionType === 'table_extract') {
  1410. contentLocator.type = 'table'
  1411. }
  1412. }
  1413. return {
  1414. // 规则基本信息
  1415. rule_id: rule.id,
  1416. rule_name: rule.ruleName,
  1417. element_key: rule.elementKey,
  1418. // 规则引擎适配参数
  1419. func_type: funcType,
  1420. action_type: rule.actionType,
  1421. // 输入资源(对应 *_ch_id 等参数)
  1422. resource_ids: resourceIds,
  1423. // 内容定位方式
  1424. content_locator: contentLocator,
  1425. // AI 提示词(对应 prompt 参数)
  1426. prompt: prompt,
  1427. // 引用的原文内容(用于定位和上下文)
  1428. source_text: sourceText,
  1429. // 原始描述(来源说明)
  1430. source_desc: rule.description
  1431. }
  1432. })
  1433. })
  1434. // 统一的确认执行函数
  1435. function handleConfirmExecute() {
  1436. if (pendingExecuteRule.value) {
  1437. confirmExecuteSingleRule()
  1438. } else {
  1439. confirmExecuteRules()
  1440. }
  1441. }
  1442. function toggleRuleExpand(ruleId) {
  1443. expandedRuleId.value = expandedRuleId.value === ruleId ? null : ruleId
  1444. }
  1445. // 打开工作流编辑器 - 新建规则
  1446. function openWorkflowForNewRule() {
  1447. workflowTargetRule.value = null
  1448. workflowTargetElement.value = null
  1449. showRuleDialog.value = false
  1450. showRuleWorkflow.value = true
  1451. }
  1452. // 打开工作流编辑器 - 编辑现有规则
  1453. async function openWorkflowForRule(rule) {
  1454. try {
  1455. // 获取规则完整详情(包含 inputs)
  1456. const fullRule = await ruleApi.getById(rule.id)
  1457. workflowTargetRule.value = fullRule
  1458. // 找到对应的要素
  1459. const elem = elements.value.find(e => e.elementKey === fullRule.elementKey)
  1460. workflowTargetElement.value = elem || { elementKey: fullRule.elementKey, elementName: fullRule.elementKey }
  1461. showRuleDialog.value = false
  1462. showRuleWorkflow.value = true
  1463. } catch (e) {
  1464. console.error('获取规则详情失败:', e)
  1465. // 降级使用列表中的规则数据
  1466. workflowTargetRule.value = rule
  1467. const elem = elements.value.find(e => e.elementKey === rule.elementKey)
  1468. workflowTargetElement.value = elem || { elementKey: rule.elementKey, elementName: rule.elementKey }
  1469. showRuleDialog.value = false
  1470. showRuleWorkflow.value = true
  1471. }
  1472. }
  1473. // 从弹窗中打开规则工作流编辑器
  1474. async function openRuleWorkflow(rule) {
  1475. // 关闭弹窗
  1476. highlightPopover.visible = false
  1477. // 复用现有的打开工作流函数
  1478. await openWorkflowForRule(rule)
  1479. }
  1480. function normalizeRuleSourceName(name) {
  1481. const s = String(name || '').trim()
  1482. if (!s) return ''
  1483. return s.replace(/^来源[::]\s*/i, '')
  1484. }
  1485. function formatInputSource(inp) {
  1486. const sourceName = inp?.sourceName || inp?.inputName || ''
  1487. const entryPath = inp?.entryPath
  1488. if (entryPath) {
  1489. // 有 entryPath 时,显示 "附件名 → 文件名"
  1490. const fileName = entryPath.split('/').pop()
  1491. return `${sourceName} → ${fileName}`
  1492. }
  1493. return sourceName
  1494. }
  1495. function getInputSourceText(rule) {
  1496. // 从规则的 inputs 中获取 sourceText
  1497. const inputs = Array.isArray(rule?.inputs) ? rule.inputs : []
  1498. for (const inp of inputs) {
  1499. if (inp.sourceText) return inp.sourceText
  1500. }
  1501. return ''
  1502. }
  1503. async function openSourceInViewer(inp, rule = null) {
  1504. console.log('[openSourceInViewer] 开始溯源定位')
  1505. console.log('[openSourceInViewer] inp:', inp)
  1506. console.log('[openSourceInViewer] rule:', rule)
  1507. // 关闭弹窗
  1508. highlightPopover.visible = false
  1509. const attId = inp.sourceNodeId
  1510. const entryPath = inp.entryPath
  1511. let sourceText = inp.sourceText || ''
  1512. console.log('[openSourceInViewer] attId:', attId, 'entryPath:', entryPath, 'sourceText:', sourceText)
  1513. // 如果没有 sourceText,尝试从规则的各个字段中提取评审代码(如 5.1.5)
  1514. if (!sourceText && rule) {
  1515. // 尝试从 description、actionConfig、ruleName 等字段提取
  1516. const searchFields = [
  1517. rule.description,
  1518. rule.actionConfig,
  1519. rule.ruleName
  1520. ].filter(Boolean).join(' ')
  1521. console.log('[openSourceInViewer] 尝试从规则字段提取评审代码:', searchFields)
  1522. // 匹配 "评审代码5.1.5" 或 "代码5.1.5" 或直接匹配 "5.1.5" 格式
  1523. let codeMatch = searchFields.match(/评审代码\s*(\d+\.\d+(?:\.\d+)*)/i)
  1524. if (!codeMatch) {
  1525. codeMatch = searchFields.match(/代码\s*(\d+\.\d+(?:\.\d+)*)/i)
  1526. }
  1527. if (!codeMatch) {
  1528. // 尝试从 actionConfig JSON 中提取
  1529. try {
  1530. const cfg = typeof rule.actionConfig === 'string' ? JSON.parse(rule.actionConfig) : rule.actionConfig
  1531. if (cfg?.reviewCode) {
  1532. sourceText = cfg.reviewCode
  1533. console.log('[openSourceInViewer] 从 actionConfig.reviewCode 提取:', sourceText)
  1534. } else if (cfg?.sourceText) {
  1535. sourceText = cfg.sourceText
  1536. console.log('[openSourceInViewer] 从 actionConfig.sourceText 提取:', sourceText)
  1537. }
  1538. } catch (e) {}
  1539. }
  1540. if (codeMatch) {
  1541. sourceText = codeMatch[1]
  1542. console.log('[openSourceInViewer] 提取到评审代码:', sourceText)
  1543. }
  1544. // 如果还是没有,尝试根据 ruleName 匹配评审代码(核心要素评审表的项目名称)
  1545. if (!sourceText && rule.ruleName) {
  1546. const ruleNameToCode = {
  1547. '安全文化': '5.1.5',
  1548. '安全文化建设': '5.1.5',
  1549. '安全投入': '5.1.4',
  1550. '安全生产投入': '5.1.4',
  1551. '目标职责': '5.1',
  1552. '目标': '5.1.1',
  1553. '目标制定': '5.1.1.1',
  1554. '目标落实': '5.1.1.2',
  1555. '目标考核': '5.1.1.3',
  1556. '机构和职责': '5.1.2',
  1557. '机构设置': '5.1.2.1',
  1558. '全员参与': '5.1.3',
  1559. '信息化建设': '5.1.6',
  1560. '安全生产信息化建设': '5.1.6',
  1561. '制度化管理': '5.2',
  1562. '法规标准识别': '5.2.1',
  1563. '规章制度': '5.2.2',
  1564. '操作规程': '5.2.3',
  1565. '评估和修订': '5.2.4',
  1566. '文档管理': '5.2.5',
  1567. '设备设施管理': '5.4.1',
  1568. '设备设施': '5.4.1',
  1569. }
  1570. const code = ruleNameToCode[rule.ruleName]
  1571. if (code) {
  1572. sourceText = code
  1573. console.log('[openSourceInViewer] 根据 ruleName 映射到评审代码:', rule.ruleName, '->', code)
  1574. }
  1575. }
  1576. }
  1577. // 查找附件(兼容 id 类型差异)
  1578. const att = attachments.value.find(a => String(a.id) === String(attId))
  1579. console.log('[openSourceInViewer] 找到附件:', att, '| attachments:', attachments.value.map(a => ({ id: a.id, type: typeof a.id })))
  1580. if (!att) {
  1581. ElMessage.warning('未找到来源附件,sourceNodeId=' + attId)
  1582. return
  1583. }
  1584. // 设置高亮文本(用于在查看器中高亮)
  1585. highlightSourceText.value = sourceText || ''
  1586. console.log('[openSourceInViewer] 设置 highlightSourceText:', highlightSourceText.value)
  1587. // 判断是否是 ZIP 文件
  1588. const ext = (att.fileType || '').toLowerCase()
  1589. if (ext === 'zip' && entryPath) {
  1590. // 打开 ZIP 内容查看器,并定位到指定文件
  1591. await handleZipAttachment(att)
  1592. // 等待 ZIP 内容加载完成后,自动打开指定的 entry
  1593. setTimeout(() => {
  1594. const fileName = entryPath.split('/').pop()
  1595. const zf = zipFileList.value.find(f => f.name === entryPath || f.name.endsWith('/' + fileName) || f.name.endsWith(fileName))
  1596. if (zf && zf.parsed) {
  1597. viewZipEntryResult(zf)
  1598. } else if (zf) {
  1599. // 如果还没解析,先解析
  1600. parseZipEntry(zf)
  1601. }
  1602. }, 800)
  1603. } else {
  1604. // 直接打开附件查看器
  1605. const state = parseStates[att.id]
  1606. console.log('[openSourceInViewer] 附件解析状态:', state?.status)
  1607. if (state?.status === 'completed') {
  1608. console.log('[openSourceInViewer] 调用 viewParseResult')
  1609. viewParseResult(att)
  1610. } else {
  1611. // 先解析再查看
  1612. console.log('[openSourceInViewer] 附件未解析,调用 handleParseAttachment')
  1613. await handleParseAttachment(att)
  1614. }
  1615. }
  1616. }
  1617. function ruleSourceFromActionConfig(rule) {
  1618. if (!rule.actionConfig) return ''
  1619. try {
  1620. const cfg = typeof rule.actionConfig === 'string' ? JSON.parse(rule.actionConfig) : rule.actionConfig
  1621. const zipName = normalizeRuleSourceName(cfg.zipName || cfg.zipFileName || cfg.archiveName)
  1622. const entryName = normalizeRuleSourceName(cfg.zipEntryName || cfg.entryName || cfg.fileName || cfg.attachmentName)
  1623. if (zipName && entryName && zipName !== entryName) return `${zipName}/${entryName}`
  1624. return entryName || zipName || ''
  1625. } catch (_) {
  1626. return ''
  1627. }
  1628. }
  1629. function ruleInputDisplayList(rule) {
  1630. const list = []
  1631. const inputs = Array.isArray(rule?.inputs) ? rule.inputs : []
  1632. for (const inp of inputs) {
  1633. // 优先使用 entryPath(ZIP内文件路径),否则使用 inputName/sourceName
  1634. const entryPath = inp?.entryPath
  1635. const inputName = normalizeRuleSourceName(inp?.inputName || inp?.sourceName || '')
  1636. if (entryPath) {
  1637. // 有 entryPath 时,显示 "附件名 → 文件名"
  1638. const fileName = entryPath.split('/').pop()
  1639. const value = inputName ? `${inputName} → ${fileName}` : fileName
  1640. if (value && !list.includes(value)) list.push(value)
  1641. } else if (inputName) {
  1642. if (!list.includes(inputName)) list.push(inputName)
  1643. }
  1644. }
  1645. return list
  1646. }
  1647. function ruleSourceSummary(rule) {
  1648. const fromInputs = ruleInputDisplayList(rule)
  1649. if (fromInputs.length) return fromInputs.join('、')
  1650. const fromCfg = ruleSourceFromActionConfig(rule)
  1651. if (fromCfg) return fromCfg
  1652. const desc = normalizeRuleSourceName(rule?.description)
  1653. if (desc.startsWith('从附件「')) {
  1654. const m = desc.match(/从附件「([^」]+)」/)
  1655. if (m?.[1]) return m[1]
  1656. }
  1657. if (desc.startsWith('来源:') || desc.startsWith('来源:')) {
  1658. return desc.replace(/^来源[::]\s*/, '')
  1659. }
  1660. return desc
  1661. }
  1662. // 附件解析状态: { [attachmentId]: { status: 'idle'|'uploading'|'parsing'|'completed'|'failed', progress: '', markdown: '' } }
  1663. const parseStates = reactive({})
  1664. const highlightSourceText = ref('') // 用于在附件查看器中高亮的来源文本
  1665. const showParseResultDialog = ref(false)
  1666. const parseResultAttName = ref('')
  1667. const parseResultContent = ref('')
  1668. const parseResultViewMode = ref('rendered')
  1669. const parseResultIsHtml = ref(false)
  1670. const parseResultHtml = computed(() => {
  1671. if (!parseResultContent.value) return ''
  1672. let html = ''
  1673. // DOCX 解析结果已经是 HTML,直接使用
  1674. if (parseResultIsHtml.value) {
  1675. html = parseResultContent.value
  1676. } else {
  1677. // PDF/图片解析结果是 markdown,用 marked 渲染
  1678. try {
  1679. html = marked(parseResultContent.value)
  1680. } catch (e) {
  1681. html = `<pre>${parseResultContent.value}</pre>`
  1682. }
  1683. }
  1684. // 如果有高亮文本,在 HTML 中高亮显示(评审代码如 5.1.5 长度为5,所以用 >= 3)
  1685. if (highlightSourceText.value && highlightSourceText.value.length >= 3) {
  1686. html = highlightSourceInHtml(html, highlightSourceText.value)
  1687. }
  1688. return html
  1689. })
  1690. // 智能高亮来源文本
  1691. function highlightSourceInHtml(html, sourceText) {
  1692. console.log('[highlightSourceInHtml] 开始高亮, sourceText:', sourceText, 'html长度:', html?.length)
  1693. if (!sourceText || sourceText.length < 3) {
  1694. console.log('[highlightSourceInHtml] sourceText 太短,跳过')
  1695. return html
  1696. }
  1697. // 0. 检测评审代码模式(如 5.1.5, 5.1.4.1 等),高亮整个表格行
  1698. const codeMatch = sourceText.match(/\b(\d+\.\d+(?:\.\d+)*)\b/)
  1699. console.log('[highlightSourceInHtml] 评审代码匹配结果:', codeMatch)
  1700. if (codeMatch) {
  1701. const code = codeMatch[1]
  1702. console.log('[highlightSourceInHtml] 检测到评审代码:', code)
  1703. // 直接高亮评审代码本身(更简单可靠的方式)
  1704. const codeEscaped = code.replace(/\./g, '\\.')
  1705. // 匹配 >5.1.5< 格式(在标签之间的评审代码)
  1706. const codeRegex = new RegExp(`(>[^<]*?)(${codeEscaped})([^<]*?<)`, 'gi')
  1707. if (codeRegex.test(html)) {
  1708. console.log('[highlightSourceInHtml] 高亮评审代码')
  1709. return html.replace(new RegExp(`(>[^<]*?)(${codeEscaped})([^<]*?<)`, 'gi'),
  1710. '$1<mark class="source-highlight">$2</mark>$3')
  1711. }
  1712. }
  1713. // 1. 先尝试精确匹配
  1714. const escaped = sourceText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  1715. const exactRegex = new RegExp(`(${escaped})`, 'gi')
  1716. if (exactRegex.test(html)) {
  1717. return html.replace(exactRegex, '<mark class="source-highlight">$1</mark>')
  1718. }
  1719. // 2. 尝试匹配前50个字符(处理截断的情况)
  1720. const prefix = sourceText.slice(0, 50).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  1721. const prefixRegex = new RegExp(`(${prefix}[^<]*)`, 'gi')
  1722. if (prefixRegex.test(html)) {
  1723. return html.replace(prefixRegex, '<mark class="source-highlight">$1</mark>')
  1724. }
  1725. // 3. 对于表格内容,尝试高亮包含关键词的表格行
  1726. // 提取关键词(去除常见词,取前几个有意义的词)
  1727. const keywords = sourceText
  1728. .replace(/[,。、:;""''()\[\]【】\n\r]/g, ' ')
  1729. .split(/\s+/)
  1730. .filter(w => w.length >= 2)
  1731. .slice(0, 5)
  1732. if (keywords.length > 0) {
  1733. // 高亮包含关键词的 <tr> 或 <td>
  1734. let result = html
  1735. for (const kw of keywords) {
  1736. const kwEscaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  1737. // 高亮关键词本身
  1738. const kwRegex = new RegExp(`(${kwEscaped})`, 'gi')
  1739. result = result.replace(kwRegex, '<mark class="source-highlight">$1</mark>')
  1740. }
  1741. return result
  1742. }
  1743. return html
  1744. }
  1745. const parseResultSource = computed(() => {
  1746. if (!parseResultContent.value) return ''
  1747. // 源码视图:将 base64 数据替换为简短占位符
  1748. return parseResultContent.value.replace(
  1749. /src="data:[^"]+"/g, 'src="[图片数据已省略]"'
  1750. ).replace(
  1751. /!\[([^\]]*)\]\(data:[^;]+;base64,[A-Za-z0-9+/=]+\)/g,
  1752. '![$1](📷 [图片数据已省略])'
  1753. )
  1754. })
  1755. // 原件预览(集成在解析结果弹窗中)
  1756. const previewContentType = ref('') // 'image' | 'pdf' | 'html' | 'text' | ''
  1757. const previewContentUrl = ref('')
  1758. const previewContentHtml = ref('')
  1759. const previewContentText = ref('')
  1760. const parseResultPreviewAvailable = ref(false) // 是否有原件可预览
  1761. const parseResultOriginAtt = ref(null) // 关联的独立附件对象(非 ZIP)
  1762. const parseResultOriginZf = ref(null) // 关联的 zip file entry 对象
  1763. // ZIP 内容展示
  1764. const showZipContentsDialog = ref(false)
  1765. const zipContentsAttName = ref('')
  1766. const zipFileList = ref([]) // [{ name, size, ext, parseable, parsing, parsed, parseResult, isHtml }]
  1767. const zipInstance = ref(null) // 当前打开的 JSZip 实例
  1768. const zipParentAtt = ref(null) // 当前打开的 ZIP 附件对象
  1769. // 缓存上传的原始文件对象,用于后续解析
  1770. const attachmentFileCache = new Map()
  1771. const creatingProject = ref(false)
  1772. const newProjectForm = reactive({ title: '', description: '', docxFile: null })
  1773. const docxUploadRef = ref(null)
  1774. const docxParseProgress = ref(0)
  1775. const docxParseStatus = ref('')
  1776. const docxParseMessage = ref('')
  1777. const parsingProjectId = ref(null) // 正在解析的项目ID
  1778. const newElementForm = reactive({ elementName: '', elementKey: '', dataType: 'text', description: '' })
  1779. const newRuleForm = reactive({
  1780. ruleName: '',
  1781. ruleType: 'direct_entity',
  1782. targetElementKey: '',
  1783. sourceAttachmentId: null,
  1784. locatorType: 'full_text',
  1785. chapterTitle: '',
  1786. reviewCode: '',
  1787. tableSelector: '',
  1788. prompt: ''
  1789. })
  1790. const filteredProjects = computed(() => {
  1791. if (!projectSearchKeyword.value) return projects.value
  1792. const kw = projectSearchKeyword.value.toLowerCase()
  1793. return projects.value.filter(p => p.title?.toLowerCase().includes(kw))
  1794. })
  1795. const filledValueCount = computed(() => values.value.filter(v => v.isFilled).length)
  1796. // 面板拖拽
  1797. function startResizeLeft() {
  1798. isResizing.value = true; resizeType.value = 'left'
  1799. document.addEventListener('mousemove', handleResize)
  1800. document.addEventListener('mouseup', stopResize)
  1801. document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'
  1802. }
  1803. function startResizeRight() {
  1804. isResizing.value = true; resizeType.value = 'right'
  1805. document.addEventListener('mousemove', handleResize)
  1806. document.addEventListener('mouseup', stopResize)
  1807. document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'
  1808. }
  1809. function handleResize(e) {
  1810. if (!isResizing.value) return
  1811. if (resizeType.value === 'left') leftPanelWidth.value = Math.max(240, Math.min(500, e.clientX))
  1812. else if (resizeType.value === 'right') rightPanelWidth.value = Math.max(280, Math.min(500, window.innerWidth - e.clientX))
  1813. }
  1814. function stopResize() {
  1815. isResizing.value = false; resizeType.value = ''
  1816. document.removeEventListener('mousemove', handleResize)
  1817. document.removeEventListener('mouseup', stopResize)
  1818. document.body.style.cursor = ''; document.body.style.userSelect = ''
  1819. }
  1820. // 项目操作
  1821. async function loadProjects() {
  1822. loadingProjects.value = true
  1823. try {
  1824. const data = await projectApi.list({ page: 1, size: 50 })
  1825. projects.value = data?.records || data || []
  1826. } catch (error) {
  1827. console.warn('获取项目列表失败:', error)
  1828. projects.value = []
  1829. } finally { loadingProjects.value = false }
  1830. }
  1831. async function switchProject(project) {
  1832. if (currentProjectId.value === project.id) { unselectProject(); return }
  1833. currentProjectId.value = project.id
  1834. projectTitle.value = project.title || ''
  1835. leftPanelTab.value = 'projects'
  1836. await loadProjectData(project.id)
  1837. }
  1838. function unselectProject() {
  1839. currentProjectId.value = null; projectTitle.value = ''
  1840. elements.value = []; values.value = []; attachments.value = []; rules.value = []
  1841. docContent.value = null; docHtml.value = ''
  1842. leftPanelTab.value = 'projects'
  1843. }
  1844. async function loadProjectData(projectId) {
  1845. loading.value = true
  1846. try {
  1847. const [elemData, valData, attData, ruleData] = await Promise.all([
  1848. elementApi.list(projectId).catch(() => []),
  1849. valueApi.list(projectId).catch(() => []),
  1850. attachmentApi.list(projectId).catch(() => []),
  1851. ruleApi.list(projectId).catch(() => [])
  1852. ])
  1853. elements.value = elemData || []; values.value = valData || []
  1854. attachments.value = attData || []
  1855. // 恢复已持久化的解析状态
  1856. restoreParseStates()
  1857. rules.value = ruleData || []
  1858. // 加载项目模板文档内容
  1859. loadDocContent(projectId)
  1860. } catch (error) { console.error('加载项目数据失败:', error) }
  1861. finally { loading.value = false }
  1862. }
  1863. // DOCX文件选择处理
  1864. function handleDocxFileChange(file) {
  1865. if (file && file.raw) {
  1866. newProjectForm.docxFile = file.raw
  1867. }
  1868. }
  1869. function handleDocxFileRemove() {
  1870. newProjectForm.docxFile = null
  1871. docxParseProgress.value = 0
  1872. docxParseStatus.value = ''
  1873. docxParseMessage.value = ''
  1874. }
  1875. function handleCancelNewProject() {
  1876. showNewProjectDialog.value = false
  1877. newProjectForm.title = ''
  1878. newProjectForm.description = ''
  1879. newProjectForm.docxFile = null
  1880. docxParseProgress.value = 0
  1881. docxParseStatus.value = ''
  1882. docxParseMessage.value = ''
  1883. if (docxUploadRef.value) {
  1884. docxUploadRef.value.clearFiles()
  1885. }
  1886. }
  1887. async function handleCreateProject() {
  1888. if (!newProjectForm.title.trim()) return
  1889. creatingProject.value = true
  1890. const hasDocxFile = !!newProjectForm.docxFile
  1891. const docxFile = newProjectForm.docxFile // 保存文件引用
  1892. try {
  1893. // 1. 创建项目
  1894. const project = await projectApi.create({ title: newProjectForm.title.trim(), description: newProjectForm.description })
  1895. // 2. 立即关闭弹窗并刷新项目列表
  1896. handleCancelNewProject()
  1897. await loadProjects()
  1898. // 3. 如果有DOCX文件,在文档列表中显示解析进度
  1899. if (hasDocxFile && project) {
  1900. parsingProjectId.value = project.id
  1901. docxParseProgress.value = 5
  1902. docxParseMessage.value = '正在上传文档...'
  1903. try {
  1904. // 使用智能提取(自动选择同步/异步)
  1905. const result = await extractApi.smartExtract(
  1906. docxFile,
  1907. 0, // attachmentId暂时为0
  1908. false, // 先不使用LLM,快速测试
  1909. (progress, message) => {
  1910. docxParseProgress.value = 5 + Math.floor(progress * 0.85)
  1911. docxParseMessage.value = message
  1912. }
  1913. )
  1914. if (result.success) {
  1915. docxParseProgress.value = 92
  1916. docxParseMessage.value = '正在保存...'
  1917. // 保存doc_content到项目
  1918. if (result.doc_content) {
  1919. await projectApi.saveDocContent(project.id, result.doc_content)
  1920. }
  1921. // 保存提取的实体
  1922. const entityCount = result.entities?.length || 0
  1923. const llmCount = result.llm_extractions?.length || 0
  1924. console.log(`NER提取: ${entityCount} 个实体`)
  1925. console.log(`LLM提取: ${llmCount} 个内容`)
  1926. if (result.entities && result.entities.length > 0) {
  1927. docxParseMessage.value = `正在保存 ${entityCount} 个实体...`
  1928. try {
  1929. // 转换为后端需要的格式
  1930. const entitiesToSave = result.entities.map(e => ({
  1931. name: e.text,
  1932. entityType: e.type,
  1933. value: e.text,
  1934. confidence: e.confidence || 0.9,
  1935. position: e.position ? JSON.stringify(e.position) : null
  1936. }))
  1937. await entityApi.batchCreate(project.id, entitiesToSave)
  1938. console.log(`已保存 ${entityCount} 个实体到数据库`)
  1939. } catch (saveError) {
  1940. console.error('保存实体失败:', saveError)
  1941. // 不阻断流程,继续执行
  1942. }
  1943. }
  1944. docxParseProgress.value = 100
  1945. docxParseStatus.value = 'success'
  1946. docxParseMessage.value = `完成!识别 ${entityCount} 个实体`
  1947. ElMessage.success(`解析完成,识别 ${entityCount} 个实体`)
  1948. // 切换到该项目并刷新数据
  1949. await switchProject(project)
  1950. } else {
  1951. docxParseStatus.value = 'warning'
  1952. docxParseMessage.value = '解析失败'
  1953. ElMessage.warning('文档解析失败')
  1954. }
  1955. } catch (parseError) {
  1956. console.error('DOCX解析失败:', parseError)
  1957. docxParseStatus.value = 'exception'
  1958. docxParseMessage.value = '解析出错'
  1959. ElMessage.warning('文档解析失败: ' + parseError.message)
  1960. }
  1961. // 3秒后清除进度显示
  1962. setTimeout(() => {
  1963. if (parsingProjectId.value === project.id) {
  1964. parsingProjectId.value = null
  1965. docxParseProgress.value = 0
  1966. docxParseStatus.value = ''
  1967. docxParseMessage.value = ''
  1968. }
  1969. }, 3000)
  1970. } else {
  1971. ElMessage.success('项目创建成功')
  1972. if (project) await switchProject(project)
  1973. }
  1974. } catch (error) {
  1975. ElMessage.error('创建失败: ' + error.message)
  1976. }
  1977. finally { creatingProject.value = false }
  1978. }
  1979. function sendAiMessage() {
  1980. const text = aiInputText.value.trim()
  1981. if (!text) return
  1982. aiMessages.value.push({ role: 'user', content: text })
  1983. aiInputText.value = ''
  1984. // 模拟 AI 回复(后续接入真实 API)
  1985. setTimeout(() => {
  1986. aiMessages.value.push({ role: 'assistant', content: '收到您的消息,AI 助手功能正在开发中,敬请期待...' })
  1987. if (aiMessagesRef.value) aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
  1988. }, 500)
  1989. setTimeout(() => {
  1990. if (aiMessagesRef.value) aiMessagesRef.value.scrollTop = aiMessagesRef.value.scrollHeight
  1991. }, 50)
  1992. }
  1993. function handleFooterCommand(cmd) {
  1994. if (cmd === 'settings') {
  1995. ElMessage.info('系统设置开发中...')
  1996. } else if (cmd === 'logout') {
  1997. ElMessageBox.confirm('确定要退出登录吗?', '退出确认', {
  1998. confirmButtonText: '退出', cancelButtonText: '取消', type: 'warning'
  1999. }).then(async () => {
  2000. try { await import('@/api').then(m => m.authApi.logout()) } catch (e) { /* ignore */ }
  2001. localStorage.removeItem('accessToken')
  2002. localStorage.removeItem('refreshToken')
  2003. localStorage.removeItem('userId')
  2004. localStorage.removeItem('username')
  2005. ElMessage.success('已退出登录')
  2006. router.push('/login')
  2007. }).catch(() => {})
  2008. }
  2009. }
  2010. async function handleProjectCommand(cmd, project) {
  2011. switch (cmd) {
  2012. case 'copy':
  2013. try { await projectApi.copy(project.id); await loadProjects(); ElMessage.success('项目复制成功') }
  2014. catch (e) { ElMessage.error('复制失败: ' + e.message) }
  2015. break
  2016. case 'archive':
  2017. try { await projectApi.archive(project.id); await loadProjects(); ElMessage.success('项目已归档') }
  2018. catch (e) { ElMessage.error('归档失败: ' + e.message) }
  2019. break
  2020. case 'delete':
  2021. try {
  2022. await ElMessageBox.confirm(`确定要删除项目「${project.title}」吗?`, '删除确认', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
  2023. await projectApi.delete(project.id)
  2024. if (currentProjectId.value === project.id) unselectProject()
  2025. await loadProjects(); ElMessage.success('项目已删除')
  2026. } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message) }
  2027. break
  2028. }
  2029. }
  2030. async function handleCopyProject() {
  2031. if (!currentProjectId.value) return
  2032. try {
  2033. const copied = await projectApi.copy(currentProjectId.value)
  2034. await loadProjects(); ElMessage.success('项目复制成功')
  2035. if (copied) await switchProject(copied)
  2036. } catch (e) { ElMessage.error('复制失败: ' + e.message) }
  2037. }
  2038. function handleSave() { saved.value = true; ElMessage.success('保存成功') }
  2039. function handleTitlebarCommand(cmd) {
  2040. if (cmd === 'save') handleSave()
  2041. else if (cmd === 'copy') handleCopyProject()
  2042. else if (cmd === 'export') ElMessage.info('导出功能开发中...')
  2043. }
  2044. // ==================== 文档预览 + 可编辑 + 要素高亮 ====================
  2045. async function loadDocContent(projectId) {
  2046. if (!projectId) return
  2047. docLoading.value = true
  2048. docContent.value = null
  2049. docHtml.value = ''
  2050. try {
  2051. const data = await projectApi.getDocContent(projectId)
  2052. docContent.value = data
  2053. renderDocHtml()
  2054. renderDocHtmlTemplate()
  2055. } catch (e) {
  2056. console.warn('加载文档内容失败:', e)
  2057. docContent.value = null
  2058. docHtml.value = ''
  2059. } finally {
  2060. docLoading.value = false
  2061. }
  2062. }
  2063. const docPaperStyle = computed(() => {
  2064. const page = docContent.value?.page
  2065. if (!page) return {}
  2066. return {
  2067. maxWidth: `${page.widthMm * 3.78}px`,
  2068. paddingTop: `${page.marginTopMm * 3.78}px`,
  2069. paddingBottom: `${page.marginBottomMm * 3.78}px`,
  2070. paddingLeft: `${page.marginLeftMm * 3.78}px`,
  2071. paddingRight: `${page.marginRightMm * 3.78}px`,
  2072. }
  2073. })
  2074. // 从值的 elementKey 中提取不含项目前缀的 key
  2075. // 例如 "PRJ-2024-001:basicInfo.projectCode" -> "basicInfo.projectCode"
  2076. function stripValueKeyPrefix(valueElementKey) {
  2077. const idx = valueElementKey?.indexOf(':')
  2078. return idx >= 0 ? valueElementKey.substring(idx + 1) : valueElementKey
  2079. }
  2080. // 当前弹出框要素关联的规则列表
  2081. const popoverRelatedRules = computed(() => {
  2082. const key = highlightPopover.elementKey
  2083. if (!key) return []
  2084. return rules.value.filter(r => {
  2085. const rk = stripValueKeyPrefix(r.elementKey)
  2086. return rk === key
  2087. })
  2088. })
  2089. function ruleActionLabel(actionType) {
  2090. const map = {
  2091. quote: '引用',
  2092. summary: 'AI总结',
  2093. ai_extract: 'AI提取',
  2094. table_extract: '表格提取',
  2095. use_entity_value: '人工录入',
  2096. }
  2097. return map[actionType] || actionType
  2098. }
  2099. function getActionTypeTagType(actionType) {
  2100. const map = {
  2101. quote: '',
  2102. summary: 'success',
  2103. ai_extract: 'warning',
  2104. table_extract: 'danger',
  2105. use_entity_value: 'info',
  2106. }
  2107. return map[actionType] || ''
  2108. }
  2109. function copyRuleEngineJson() {
  2110. const json = JSON.stringify(ruleEngineAdaptedData.value, null, 2)
  2111. navigator.clipboard.writeText(json).then(() => {
  2112. ElMessage.success('已复制规则引擎数据到剪贴板')
  2113. }).catch(() => {
  2114. ElMessage.error('复制失败')
  2115. })
  2116. }
  2117. // 从规则中提取来源文本(引用原文 / 规则描述)
  2118. function ruleSourceText(rule) {
  2119. // 优先从 actionConfig.sourceText 取(前端引用创建的规则)
  2120. if (rule.actionConfig) {
  2121. try {
  2122. const cfg = typeof rule.actionConfig === 'string' ? JSON.parse(rule.actionConfig) : rule.actionConfig
  2123. if (cfg.sourceText) return cfg.sourceText
  2124. if (cfg.description) return cfg.description
  2125. } catch (_) { /* ignore */ }
  2126. }
  2127. // 其次取 dslContent(mock 规则的取值规则描述)
  2128. if (rule.dslContent) return rule.dslContent
  2129. // 最后取 description
  2130. if (rule.description) return rule.description
  2131. return ''
  2132. }
  2133. // 构建要素值映射表,分为长文本、短文本、静态文本三类
  2134. function buildElementValueMap() {
  2135. const longTexts = [] // paragraph/table 类型的长文本要素
  2136. const shortTexts = [] // text 类型的短文本要素(动态)
  2137. const staticTexts = [] // static 类型的静态要素
  2138. const colors = [
  2139. '#fff3cd', '#cce5ff', '#d4edda', '#f8d7da', '#e2d5f1',
  2140. '#d1ecf1', '#ffeeba', '#c3e6cb', '#f5c6cb', '#d6d8db'
  2141. ]
  2142. let colorIdx = 0
  2143. for (const elem of elements.value) {
  2144. const elemValues = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
  2145. const elemType = elem.elementType || 'text'
  2146. const isStatic = elemType === 'static'
  2147. for (const val of elemValues) {
  2148. const text = val.valueText
  2149. if (!text || text.length < 2) continue
  2150. const entry = {
  2151. text,
  2152. elementKey: elem.elementKey,
  2153. fullElementKey: val.elementKey,
  2154. elementName: elem.elementName,
  2155. valueId: val.valueId,
  2156. elemType,
  2157. isStatic,
  2158. color: isStatic ? '#e8e8e8' : colors[colorIdx % colors.length]
  2159. }
  2160. if (isStatic) {
  2161. staticTexts.push(entry)
  2162. } else if (elemType === 'paragraph' || elemType === 'table') {
  2163. longTexts.push(entry)
  2164. } else {
  2165. shortTexts.push(entry)
  2166. }
  2167. }
  2168. if (!isStatic) colorIdx++
  2169. }
  2170. // 长文本按长度降序
  2171. longTexts.sort((a, b) => b.text.length - a.text.length)
  2172. // 短文本按长度降序
  2173. shortTexts.sort((a, b) => b.text.length - a.text.length)
  2174. // 静态文本按长度降序
  2175. staticTexts.sort((a, b) => b.text.length - a.text.length)
  2176. return { longTexts, shortTexts, staticTexts }
  2177. }
  2178. // 将文档 blocks 渲染为 HTML 字符串(含要素高亮)
  2179. function renderDocHtml() {
  2180. if (!docContent.value?.blocks) { docHtml.value = ''; return }
  2181. const blocks = docContent.value.blocks
  2182. const { longTexts, shortTexts, staticTexts } = highlightEnabled.value ? buildElementValueMap() : { longTexts: [], shortTexts: [], staticTexts: [] }
  2183. // 合并短文本和静态文本用于 runs 级别匹配(动态优先,静态在后)
  2184. const allShortTexts = [...shortTexts, ...staticTexts]
  2185. let highlightCount = 0
  2186. const parts = []
  2187. // 预处理:为每个长文本要素,将其 valueText 按行拆分为句子集合
  2188. // 用于判断某个 block 的文本是否属于某个长文本要素
  2189. const longTextLines = longTexts.map(lt => ({
  2190. ...lt,
  2191. lines: new Set(lt.text.split('\n').map(l => l.trim()).filter(l => l.length > 0))
  2192. }))
  2193. // 收集被长文本高亮覆盖的 block IDs,这些 block 内的短文本不再单独高亮
  2194. const longHighlightedBlockIds = new Set()
  2195. // 第一遍:确定哪些 block 属于长文本要素
  2196. for (const block of blocks) {
  2197. const blockText = getBlockPlainText(block)
  2198. if (!blockText) continue
  2199. for (const lt of longTextLines) {
  2200. if (lt.lines.has(blockText)) {
  2201. longHighlightedBlockIds.add(block.id)
  2202. break
  2203. }
  2204. }
  2205. }
  2206. // 第二遍:渲染,连续属于同一长文本要素的 block 合并到一个边框内
  2207. let currentLongKey = null // 当前正在收集的长文本要素 key
  2208. let currentLongMatch = null // 当前长文本要素匹配信息
  2209. let longGroupHtml = '' // 当前长文本分组的 HTML 累积
  2210. function flushLongGroup() {
  2211. if (currentLongKey && longGroupHtml) {
  2212. const borderColor = darkenColor(currentLongMatch.color)
  2213. 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>`)
  2214. highlightCount++
  2215. }
  2216. currentLongKey = null
  2217. currentLongMatch = null
  2218. longGroupHtml = ''
  2219. }
  2220. for (const block of blocks) {
  2221. if (block.type === 'table') {
  2222. flushLongGroup()
  2223. const tableMatch = findTableLongTextMatch(block, longTextLines)
  2224. parts.push(renderTableHtml(block, tableMatch))
  2225. if (tableMatch) highlightCount++
  2226. } else {
  2227. const blockText = getBlockPlainText(block)
  2228. const longMatch = findBlockLongTextMatch(blockText, longTextLines)
  2229. if (longMatch) {
  2230. // 如果和当前分组是同一个要素,继续累积
  2231. if (currentLongKey === longMatch.elementKey) {
  2232. longGroupHtml += renderBlockHtml(block, [], null, () => {})
  2233. } else {
  2234. // 不同要素,先 flush 上一组,再开始新组
  2235. flushLongGroup()
  2236. currentLongKey = longMatch.elementKey
  2237. currentLongMatch = longMatch
  2238. longGroupHtml = renderBlockHtml(block, [], null, () => {})
  2239. }
  2240. } else {
  2241. // 非长文本 block,先 flush 再正常渲染
  2242. flushLongGroup()
  2243. parts.push(renderBlockHtml(block, allShortTexts, null, (n) => { highlightCount += n }))
  2244. }
  2245. }
  2246. }
  2247. flushLongGroup() // flush 最后一组
  2248. elementHighlightCount.value = highlightCount
  2249. docHtml.value = parts.join('')
  2250. }
  2251. // 要素视图:将动态要素值替换为 {{key}} 占位符
  2252. function renderDocHtmlTemplate() {
  2253. if (!docContent.value?.blocks) { docTemplateHtml.value = ''; return }
  2254. const blocks = docContent.value.blocks
  2255. const { longTexts, shortTexts } = buildElementValueMap()
  2256. // 合并所有动态要素(不含静态)
  2257. const allDynamic = [...shortTexts]
  2258. const longTextLines = longTexts.map(lt => ({
  2259. ...lt,
  2260. lines: new Set(lt.text.split('\n').map(l => l.trim()).filter(l => l.length > 0))
  2261. }))
  2262. const parts = []
  2263. let currentLongKey = null
  2264. let currentLongMatch = null
  2265. let longGroupHtml = ''
  2266. function flushLongGroup() {
  2267. if (currentLongKey && longGroupHtml) {
  2268. const tag = `<span class="elem-tpl-tag" title="${escapeAttr(currentLongMatch.elementName)}">{{${currentLongMatch.elementKey}}}</span>`
  2269. parts.push(`<div class="elem-tpl-block">${tag}</div>`)
  2270. }
  2271. currentLongKey = null
  2272. currentLongMatch = null
  2273. longGroupHtml = ''
  2274. }
  2275. for (const block of blocks) {
  2276. if (block.type === 'table') {
  2277. flushLongGroup()
  2278. const tableMatch = findTableLongTextMatch(block, longTextLines)
  2279. if (tableMatch) {
  2280. const tag = `<span class="elem-tpl-tag" title="${escapeAttr(tableMatch.elementName)}">{{${tableMatch.elementKey}}}</span>`
  2281. parts.push(`<div class="elem-tpl-block">${tag}</div>`)
  2282. } else {
  2283. parts.push(renderTableHtml(block, null))
  2284. }
  2285. } else {
  2286. const blockText = getBlockPlainText(block)
  2287. const longMatch = findBlockLongTextMatch(blockText, longTextLines)
  2288. if (longMatch) {
  2289. if (currentLongKey === longMatch.elementKey) {
  2290. longGroupHtml += 'x' // just accumulate
  2291. } else {
  2292. flushLongGroup()
  2293. currentLongKey = longMatch.elementKey
  2294. currentLongMatch = longMatch
  2295. longGroupHtml = 'x'
  2296. }
  2297. } else {
  2298. flushLongGroup()
  2299. parts.push(renderBlockHtmlTemplate(block, allDynamic))
  2300. }
  2301. }
  2302. }
  2303. flushLongGroup()
  2304. docTemplateHtml.value = parts.join('')
  2305. }
  2306. // 渲染单个 block,将匹配的动态短文本替换为 {{key}} 标签
  2307. function renderBlockHtmlTemplate(block, shortMap) {
  2308. const tag = getBlockTag(block.type)
  2309. const cls = `doc-block doc-${block.type}`
  2310. const styleStr = buildStyleStr(block.style)
  2311. const styleAttr = styleStr ? ` style="${styleStr}"` : ''
  2312. let inner = ''
  2313. // 图片
  2314. if (block.images?.length > 0) {
  2315. for (const img of block.images) {
  2316. const imgStyle = []
  2317. if (img.widthInch) imgStyle.push(`width:${img.widthInch}in`)
  2318. if (img.heightInch) imgStyle.push(`height:${img.heightInch}in`)
  2319. imgStyle.push('max-width:100%', 'display:block', 'margin:8px auto')
  2320. inner += `<img src="${img.src}" style="${imgStyle.join(';')}" class="doc-inline-image" contenteditable="false" />`
  2321. }
  2322. }
  2323. // Runs - 替换动态要素值为 {{key}}
  2324. if (block.runs) {
  2325. if (shortMap.length === 0) {
  2326. for (const run of block.runs) {
  2327. const text = escapeHtml(run.text)
  2328. const rs = buildRunStyleStr(run)
  2329. inner += rs ? `<span style="${rs}">${text}</span>` : text
  2330. }
  2331. } else {
  2332. inner += templateReplaceRuns(block.runs, shortMap)
  2333. }
  2334. }
  2335. if (!inner) inner = '&nbsp;'
  2336. return `<${tag} class="${cls}"${styleAttr} data-block-id="${block.id}">${inner}</${tag}>`
  2337. }
  2338. // 在 runs 纯文本中查找动态要素值并替换为 {{key}} 标签
  2339. function templateReplaceRuns(runs, shortMap) {
  2340. // 构建纯文本
  2341. let plainText = ''
  2342. const charToRun = []
  2343. for (let ri = 0; ri < runs.length; ri++) {
  2344. const t = runs[ri].text || ''
  2345. for (let ci = 0; ci < t.length; ci++) {
  2346. charToRun.push({ runIdx: ri, offsetInRun: ci })
  2347. plainText += t[ci]
  2348. }
  2349. }
  2350. // 查找匹配
  2351. const matches = []
  2352. for (const em of shortMap) {
  2353. const val = em.text
  2354. if (!val || val.length < 2) continue
  2355. let pos = 0
  2356. while (true) {
  2357. const idx = plainText.indexOf(val, pos)
  2358. if (idx < 0) break
  2359. matches.push({ start: idx, end: idx + val.length, em })
  2360. pos = idx + val.length
  2361. }
  2362. }
  2363. matches.sort((a, b) => a.start - b.start || b.end - a.end)
  2364. const filtered = []
  2365. let lastEnd = -1
  2366. for (const m of matches) {
  2367. if (m.start >= lastEnd) {
  2368. filtered.push(m)
  2369. lastEnd = m.end
  2370. }
  2371. }
  2372. if (filtered.length === 0) {
  2373. let html = ''
  2374. for (const run of runs) {
  2375. const text = escapeHtml(run.text)
  2376. const rs = buildRunStyleStr(run)
  2377. html += rs ? `<span style="${rs}">${text}</span>` : text
  2378. }
  2379. return html
  2380. }
  2381. // 切分为普通段 + 替换段
  2382. const segments = []
  2383. let cursor = 0
  2384. for (const m of filtered) {
  2385. if (m.start > cursor) segments.push({ start: cursor, end: m.start, em: null })
  2386. segments.push({ start: m.start, end: m.end, em: m.em })
  2387. cursor = m.end
  2388. }
  2389. if (cursor < plainText.length) segments.push({ start: cursor, end: plainText.length, em: null })
  2390. let html = ''
  2391. for (const seg of segments) {
  2392. if (seg.em) {
  2393. // 替换为 {{key}} 标签
  2394. html += `<span class="elem-tpl-tag" title="${escapeAttr(seg.em.elementName)}">{{${seg.em.elementKey}}}</span>`
  2395. } else {
  2396. // 普通文本保留 run 样式
  2397. const segChars = charToRun.slice(seg.start, seg.end)
  2398. const groups = []
  2399. let curGroup = null
  2400. for (const ch of segChars) {
  2401. if (!curGroup || curGroup.runIdx !== ch.runIdx) {
  2402. curGroup = { runIdx: ch.runIdx, startOffset: ch.offsetInRun, endOffset: ch.offsetInRun + 1 }
  2403. groups.push(curGroup)
  2404. } else {
  2405. curGroup.endOffset = ch.offsetInRun + 1
  2406. }
  2407. }
  2408. for (const g of groups) {
  2409. const run = runs[g.runIdx]
  2410. const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
  2411. const rs = buildRunStyleStr(run)
  2412. html += rs ? `<span style="${rs}">${slice}</span>` : slice
  2413. }
  2414. }
  2415. }
  2416. return html
  2417. }
  2418. // 切换视图模式时触发模板渲染
  2419. watch(viewMode, (mode) => {
  2420. if (mode === 'elements' && docContent.value) {
  2421. renderDocHtmlTemplate()
  2422. }
  2423. })
  2424. // 替换文档 blocks 中的旧值文本为新值
  2425. function replaceTextInBlocks(blocks, oldText, newText) {
  2426. for (const block of blocks) {
  2427. if (block.type === 'table' && block.rows) {
  2428. // 表格:遍历每行每个单元格
  2429. for (const row of block.rows) {
  2430. for (const cell of row) {
  2431. if (cell.blocks) replaceTextInBlocks(cell.blocks, oldText, newText)
  2432. }
  2433. }
  2434. continue
  2435. }
  2436. if (!block.runs || block.runs.length === 0) continue
  2437. // 将 runs 拼接后检查是否包含旧值
  2438. const fullText = block.runs.map(r => r.text).join('')
  2439. if (!fullText.includes(oldText)) continue
  2440. // 简单情况:旧值完整存在于单个 run 中
  2441. let replaced = false
  2442. for (const run of block.runs) {
  2443. if (run.text && run.text.includes(oldText)) {
  2444. run.text = run.text.replace(oldText, newText)
  2445. replaced = true
  2446. break
  2447. }
  2448. }
  2449. if (replaced) continue
  2450. // 复杂情况:旧值跨越多个 runs —— 重建 runs
  2451. const newFullText = fullText.replace(oldText, newText)
  2452. // 保留第一个 run 的样式,将所有文本合并到第一个 run
  2453. if (block.runs.length > 0) {
  2454. block.runs[0].text = newFullText
  2455. block.runs.length = 1
  2456. }
  2457. }
  2458. }
  2459. // 获取 block 的纯文本
  2460. function getBlockPlainText(block) {
  2461. if (!block.runs) return ''
  2462. return block.runs.map(r => r.text).join('').trim()
  2463. }
  2464. // 查找 block 文本是否匹配某个长文本要素
  2465. function findBlockLongTextMatch(blockText, longTextLines) {
  2466. if (!blockText) return null
  2467. for (const lt of longTextLines) {
  2468. if (lt.lines.has(blockText)) return lt
  2469. }
  2470. return null
  2471. }
  2472. // 查找表格是否匹配某个长文本要素(通过表格第一行文本匹配)
  2473. function findTableLongTextMatch(block, longTextLines) {
  2474. if (!block.table?.data?.length) return null
  2475. const firstRowText = block.table.data[0].map(c => c.text).join(' | ')
  2476. for (const lt of longTextLines) {
  2477. if (lt.text.includes(firstRowText)) return lt
  2478. }
  2479. return null
  2480. }
  2481. function renderBlockHtml(block, shortMap, longMatch, countFn) {
  2482. const tag = getBlockTag(block.type)
  2483. const cls = `doc-block doc-${block.type}`
  2484. const styleStr = buildStyleStr(block.style)
  2485. const styleAttr = styleStr ? ` style="${styleStr}"` : ''
  2486. const isToc = block.type?.startsWith('toc')
  2487. let inner = ''
  2488. // 图片
  2489. if (block.images?.length > 0) {
  2490. for (const img of block.images) {
  2491. const imgStyle = []
  2492. if (img.widthInch) imgStyle.push(`width:${img.widthInch}in`)
  2493. if (img.heightInch) imgStyle.push(`height:${img.heightInch}in`)
  2494. imgStyle.push('max-width:100%', 'display:block', 'margin:8px auto')
  2495. inner += `<img src="${img.src}" style="${imgStyle.join(';')}" class="doc-inline-image" contenteditable="false" />`
  2496. }
  2497. }
  2498. // Runs
  2499. if (block.runs) {
  2500. if (isToc) {
  2501. // 目录项:忽略 run 内联样式,由 CSS 统一控制外观
  2502. for (const run of block.runs) {
  2503. inner += escapeHtml(run.text)
  2504. }
  2505. } else if (longMatch || shortMap.length === 0) {
  2506. // 长文本高亮或无需短文本高亮,直接渲染
  2507. for (const run of block.runs) {
  2508. const text = escapeHtml(run.text)
  2509. const rs = buildRunStyleStr(run)
  2510. inner += rs ? `<span style="${rs}">${text}</span>` : text
  2511. }
  2512. if (longMatch) countFn(1)
  2513. } else {
  2514. // 短文本高亮:基于纯文本位置匹配,支持跨 run 拆分的文本
  2515. const result = highlightRunsWithElements(block.runs, shortMap)
  2516. inner += result.html
  2517. if (result.count > 0) countFn(result.count)
  2518. }
  2519. }
  2520. if (!inner) inner = '&nbsp;'
  2521. return `<${tag} class="${cls}"${styleAttr} data-block-id="${block.id}">${inner}</${tag}>`
  2522. }
  2523. function renderTableHtml(block, longMatch) {
  2524. const t = block.table
  2525. if (!t?.data) return ''
  2526. let html = `<table class="doc-table" data-block-id="${block.id}">`
  2527. for (let ri = 0; ri < t.data.length; ri++) {
  2528. html += '<tr>'
  2529. for (const cell of t.data[ri]) {
  2530. const cs = cell.colspan > 1 ? ` colspan="${cell.colspan}"` : ''
  2531. html += `<td class="doc-table-cell"${cs}>${escapeHtml(cell.text)}</td>`
  2532. }
  2533. html += '</tr>'
  2534. }
  2535. html += '</table>'
  2536. if (longMatch) {
  2537. const borderColor = darkenColor(longMatch.color)
  2538. 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>`
  2539. }
  2540. return html
  2541. }
  2542. function getBlockTag(type) {
  2543. if (type === 'heading1') return 'h1'
  2544. if (type === 'heading2') return 'h2'
  2545. if (type === 'heading3') return 'h3'
  2546. if (type?.startsWith('toc')) return 'div'
  2547. return 'p'
  2548. }
  2549. function buildStyleStr(style) {
  2550. if (!style) return ''
  2551. const parts = []
  2552. if (style.alignment) {
  2553. const map = { left: 'left', center: 'center', right: 'right', justify: 'justify', both: 'justify' }
  2554. parts.push(`text-align:${map[style.alignment] || style.alignment}`)
  2555. }
  2556. if (style.indentLeft) parts.push(`padding-left:${style.indentLeft / 914400}in`)
  2557. if (style.indentRight) parts.push(`padding-right:${style.indentRight / 914400}in`)
  2558. if (style.indentFirstLine) parts.push(`text-indent:${style.indentFirstLine / 914400}in`)
  2559. if (style.indentHanging) parts.push(`text-indent:-${style.indentHanging / 914400}in`)
  2560. if (style.spacingBefore) parts.push(`margin-top:${style.spacingBefore / 914400}in`)
  2561. if (style.spacingAfter) parts.push(`margin-bottom:${style.spacingAfter / 914400}in`)
  2562. if (style.lineSpacing && style.lineSpacing >= 1 && style.lineSpacing <= 5) parts.push(`line-height:${style.lineSpacing}`)
  2563. return parts.join(';')
  2564. }
  2565. function buildRunStyleStr(run) {
  2566. const parts = []
  2567. if (run.fontFamily) parts.push(`font-family:${run.fontFamily}`)
  2568. if (run.fontSize) parts.push(`font-size:${run.fontSize}pt`)
  2569. if (run.bold) parts.push('font-weight:bold')
  2570. if (run.italic) parts.push('font-style:italic')
  2571. if (run.color) parts.push(`color:${run.color.startsWith('#') ? run.color : '#' + run.color}`)
  2572. if (run.underline) parts.push('text-decoration:underline')
  2573. if (run.strikeThrough) parts.push('text-decoration:line-through')
  2574. return parts.join(';')
  2575. }
  2576. function escapeHtml(text) {
  2577. if (!text) return ''
  2578. return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
  2579. }
  2580. function escapeAttr(text) {
  2581. if (!text) return ''
  2582. return text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
  2583. }
  2584. // 基于纯文本位置的短文本高亮,支持跨 run 拆分的文本匹配
  2585. function highlightRunsWithElements(runs, shortMap) {
  2586. // 1. 构建纯文本和每个字符到 run 的映射
  2587. let plainText = ''
  2588. const charToRun = [] // charToRun[i] = { runIdx, offsetInRun }
  2589. for (let ri = 0; ri < runs.length; ri++) {
  2590. const t = runs[ri].text || ''
  2591. for (let ci = 0; ci < t.length; ci++) {
  2592. charToRun.push({ runIdx: ri, offsetInRun: ci })
  2593. plainText += t[ci]
  2594. }
  2595. }
  2596. // 2. 在纯文本中查找所有要素值的匹配位置
  2597. const matches = [] // { start, end, em }
  2598. for (const em of shortMap) {
  2599. const val = em.text
  2600. if (!val || val.length < 2) continue
  2601. let pos = 0
  2602. while (true) {
  2603. const idx = plainText.indexOf(val, pos)
  2604. if (idx < 0) break
  2605. matches.push({ start: idx, end: idx + val.length, em })
  2606. pos = idx + val.length
  2607. }
  2608. }
  2609. // 3. 去重:按 start 排序,移除被更长匹配覆盖的(已按长度降序排列的 shortMap 保证优先)
  2610. matches.sort((a, b) => a.start - b.start || b.end - a.end)
  2611. const filtered = []
  2612. let lastEnd = -1
  2613. for (const m of matches) {
  2614. if (m.start >= lastEnd) {
  2615. filtered.push(m)
  2616. lastEnd = m.end
  2617. }
  2618. }
  2619. if (filtered.length === 0) {
  2620. // 无匹配,直接渲染
  2621. let html = ''
  2622. for (const run of runs) {
  2623. const text = escapeHtml(run.text)
  2624. const rs = buildRunStyleStr(run)
  2625. html += rs ? `<span style="${rs}">${text}</span>` : text
  2626. }
  2627. return { html, count: 0 }
  2628. }
  2629. // 4. 将纯文本按匹配区间切分为:普通段 + 高亮段
  2630. const segments = [] // { start, end, em: null|object }
  2631. let cursor = 0
  2632. for (const m of filtered) {
  2633. if (m.start > cursor) {
  2634. segments.push({ start: cursor, end: m.start, em: null })
  2635. }
  2636. segments.push({ start: m.start, end: m.end, em: m.em })
  2637. cursor = m.end
  2638. }
  2639. if (cursor < plainText.length) {
  2640. segments.push({ start: cursor, end: plainText.length, em: null })
  2641. }
  2642. // 5. 对每个 segment,按 run 边界拆分并生成 HTML
  2643. let html = ''
  2644. for (const seg of segments) {
  2645. const segChars = charToRun.slice(seg.start, seg.end)
  2646. // 按 runIdx 分组
  2647. const groups = []
  2648. let curGroup = null
  2649. for (const ch of segChars) {
  2650. if (!curGroup || curGroup.runIdx !== ch.runIdx) {
  2651. curGroup = { runIdx: ch.runIdx, startOffset: ch.offsetInRun, endOffset: ch.offsetInRun + 1 }
  2652. groups.push(curGroup)
  2653. } else {
  2654. curGroup.endOffset = ch.offsetInRun + 1
  2655. }
  2656. }
  2657. if (seg.em) {
  2658. // 高亮段:静态要素用虚线淡色边框,动态要素用实线彩色边框
  2659. const em = seg.em
  2660. const isStatic = em.isStatic
  2661. const borderStyle = isStatic
  2662. ? 'border:1px dashed #ccc;border-radius:4px;padding:2px 6px;cursor:pointer;opacity:0.7;background:#f5f5f5;'
  2663. : `border:1.5px solid ${darkenColor(em.color)};border-radius:4px;padding:2px 6px;cursor:pointer;background:${em.color};color:${darkenColor(em.color)};`
  2664. const hlClass = isStatic ? 'elem-highlight elem-highlight-static' : 'elem-highlight'
  2665. html += `<span class="${hlClass}" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="${borderStyle}" title="${escapeAttr(em.elementName)}">`
  2666. for (const g of groups) {
  2667. const run = runs[g.runIdx]
  2668. const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
  2669. const rs = buildRunStyleStr(run)
  2670. html += rs ? `<span style="${rs}">${slice}</span>` : slice
  2671. }
  2672. html += '</span>'
  2673. } else {
  2674. // 普通段:保留 run 样式
  2675. for (const g of groups) {
  2676. const run = runs[g.runIdx]
  2677. const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
  2678. const rs = buildRunStyleStr(run)
  2679. html += rs ? `<span style="${rs}">${slice}</span>` : slice
  2680. }
  2681. }
  2682. }
  2683. return { html, count: filtered.length }
  2684. }
  2685. function darkenColor(hex) {
  2686. // 简单加深颜色用于下边框
  2687. const map = {
  2688. '#fff3cd': '#e0a800', '#cce5ff': '#3d8bfd', '#d4edda': '#28a745',
  2689. '#f8d7da': '#dc3545', '#e2d5f1': '#6f42c1', '#d1ecf1': '#17a2b8',
  2690. '#ffeeba': '#d39e00', '#c3e6cb': '#1e7e34', '#f5c6cb': '#c82333',
  2691. '#d6d8db': '#6c757d'
  2692. }
  2693. return map[hex] || '#999'
  2694. }
  2695. // 文档编辑事件
  2696. function onDocInput() {
  2697. saved.value = false
  2698. }
  2699. // 点击文档中的高亮要素(mousedown 比 click 更可靠,在 contenteditable 容器内 e.target 更准确)
  2700. function onDocClick(e) {
  2701. const target = e.target.closest('.elem-highlight') || e.target.closest('.elem-highlight-wrap') || e.target.closest('.elem-highlight-table')
  2702. if (!target) {
  2703. highlightPopover.visible = false
  2704. return
  2705. }
  2706. const elemKey = target.dataset.elemKey
  2707. const valueId = target.dataset.valueId
  2708. const elem = elements.value.find(el => el.elementKey === elemKey)
  2709. const val = values.value.find(v => String(v.valueId) === String(valueId)) ||
  2710. values.value.find(v => stripValueKeyPrefix(v.elementKey) === elemKey)
  2711. if (!elem || elem.elementType === 'static') return
  2712. e.preventDefault()
  2713. const rect = target.getBoundingClientRect()
  2714. const scrollEl = editorRef.value
  2715. const scrollRect = scrollEl?.getBoundingClientRect() || { top: 0, left: 0 }
  2716. highlightPopover.elementKey = elemKey
  2717. highlightPopover.fullElementKey = val?.elementKey || ''
  2718. highlightPopover.elementName = elem.elementName
  2719. highlightPopover.currentValue = val?.valueText || ''
  2720. highlightPopover.originalValue = ''
  2721. highlightPopover.valueId = val?.valueId || null
  2722. highlightPopover.x = rect.left - scrollRect.left + scrollEl.scrollLeft
  2723. highlightPopover.y = rect.bottom - scrollRect.top + scrollEl.scrollTop + 4
  2724. highlightPopover.visible = true
  2725. }
  2726. async function savePopoverValue() {
  2727. if (!highlightPopover.elementKey || !currentProjectId.value) {
  2728. ElMessage.warning('无法保存:未找到对应的值记录')
  2729. return
  2730. }
  2731. try {
  2732. // 查找本地 value 记录,获取完整的 prefixed elementKey
  2733. const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === highlightPopover.elementKey)
  2734. const apiKey = highlightPopover.fullElementKey || val?.elementKey || highlightPopover.elementKey
  2735. const oldValue = val?.valueText || ''
  2736. const newValue = highlightPopover.currentValue
  2737. // API: PUT /projects/{projectId}/values/{elementKey} with { valueText }
  2738. await valueApi.update(currentProjectId.value, apiKey, { valueText: newValue })
  2739. // 更新本地数据
  2740. if (val) {
  2741. val.valueText = newValue
  2742. val.isFilled = !!newValue
  2743. }
  2744. // 替换文档 blocks 中的旧值文本为新值
  2745. if (oldValue && newValue && oldValue !== newValue && docContent.value?.blocks) {
  2746. replaceTextInBlocks(docContent.value.blocks, oldValue, newValue)
  2747. }
  2748. highlightPopover.visible = false
  2749. renderDocHtml()
  2750. ElMessage.success('要素值已更新')
  2751. } catch (e) {
  2752. ElMessage.error('保存失败: ' + (e.message || e))
  2753. }
  2754. }
  2755. // ==================== 附件引用系统 ====================
  2756. // 在解析结果中选中文本时触发
  2757. function onParseResultMouseUp(e) {
  2758. const sel = window.getSelection()
  2759. const text = sel?.toString()?.trim()
  2760. if (!text || text.length < 2) {
  2761. citationToolbar.visible = false
  2762. return
  2763. }
  2764. citationToolbar.selectedText = text
  2765. // 定位浮动工具栏到选区末端
  2766. const range = sel.getRangeAt(0)
  2767. const rect = range.getBoundingClientRect()
  2768. const dialogEl = e.currentTarget.closest('.el-dialog')
  2769. const dialogRect = dialogEl?.getBoundingClientRect() || { top: 0, left: 0 }
  2770. citationToolbar.x = rect.left - dialogRect.left + rect.width / 2 - 120
  2771. citationToolbar.y = rect.bottom - dialogRect.top + 8
  2772. // 引用模式下已锁定要素,直接进入选操作步骤
  2773. if (referenceMode.value) {
  2774. citationToolbar.step = 'select_action'
  2775. } else {
  2776. citationToolbar.step = 'select_action'
  2777. }
  2778. citationToolbar.visible = true
  2779. }
  2780. // 选择引用方式(直接引用/AI总结/表格提取)
  2781. function setCitationAction(actionType) {
  2782. citationToolbar.actionType = actionType
  2783. if (referenceMode.value) {
  2784. // 已锁定要素,直接确认创建规则
  2785. const elem = elements.value.find(el => el.elementKey === referenceModeElementKey.value)
  2786. if (elem) {
  2787. confirmCitation(elem)
  2788. } else {
  2789. ElMessage.warning('目标要素不存在')
  2790. }
  2791. } else {
  2792. // 未锁定要素,进入选择要素步骤
  2793. citationToolbar.step = 'select_element'
  2794. }
  2795. }
  2796. // 确认引用:创建规则
  2797. async function confirmCitation(elem) {
  2798. const selectedText = citationToolbar.selectedText
  2799. const actionType = citationToolbar.actionType
  2800. if (!selectedText || !actionType || !elem) return
  2801. const projectId = currentProjectId.value
  2802. if (!projectId) { ElMessage.warning('未选择项目'); return }
  2803. try {
  2804. const actionConfig = JSON.stringify({
  2805. sourceText: selectedText,
  2806. attachmentName: referenceModeAttName.value || parseResultAttName.value || '',
  2807. })
  2808. // 查找附件节点ID(如果有的话)
  2809. const attId = referenceModeAttId.value || null
  2810. const attName = referenceModeAttName.value || parseResultAttName.value || ''
  2811. const inputs = attId ? [{
  2812. sourceNodeId: attId,
  2813. inputKey: 'attachment',
  2814. inputType: 'ATTACHMENT',
  2815. inputName: attName,
  2816. sourceName: attName,
  2817. sourceText: selectedText // 传入来源段落文本,用于溯源定位
  2818. }] : []
  2819. const ruleData = {
  2820. elementKey: elem.elementKey,
  2821. ruleName: `${actionType === 'quote' ? '引用' : actionType === 'summary' ? 'AI总结' : '表格提取'} - ${elem.elementName}`,
  2822. ruleType: 'attachment_reference',
  2823. actionType: actionType,
  2824. actionConfig: actionConfig,
  2825. description: `从附件「${referenceModeAttName.value || parseResultAttName.value}」${actionType === 'quote' ? '直接引用' : actionType === 'summary' ? 'AI总结' : '表格提取'}`,
  2826. inputs: inputs
  2827. }
  2828. await ruleApi.create(projectId, ruleData)
  2829. // 如果是直接引用,同时更新要素值
  2830. if (actionType === 'quote') {
  2831. const fullKey = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)?.elementKey || elem.elementKey
  2832. await valueApi.update(projectId, fullKey, { valueText: selectedText, fillSource: 'rule' })
  2833. const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
  2834. if (val) {
  2835. val.valueText = selectedText
  2836. val.isFilled = true
  2837. val.fillSource = 'rule'
  2838. }
  2839. renderDocHtml()
  2840. }
  2841. // 刷新规则列表
  2842. rules.value = await ruleApi.list(projectId)
  2843. citationToolbar.visible = false
  2844. citationToolbar.selectedText = ''
  2845. citationToolbar.actionType = ''
  2846. window.getSelection()?.removeAllRanges()
  2847. if (referenceMode.value) exitReferenceMode()
  2848. ElMessage.success(
  2849. actionType === 'quote'
  2850. ? `已引用到「${elem.elementName}」并更新值`
  2851. : `已创建${actionType === 'summary' ? 'AI总结' : '表格提取'}规则 → ${elem.elementName}`
  2852. )
  2853. } catch (e) {
  2854. ElMessage.error('创建引用规则失败: ' + e.message)
  2855. }
  2856. }
  2857. // 从要素弹出框进入引用模式 → 打开附件选择
  2858. function enterReferenceModeFromPopover() {
  2859. const elemKey = highlightPopover.elementKey
  2860. const elemName = highlightPopover.elementName
  2861. if (!elemKey) return
  2862. highlightPopover.visible = false
  2863. // 找已解析的附件
  2864. const parsedAtts = attachments.value.filter(att => {
  2865. const state = parseStates[att.id]
  2866. return state?.status === 'completed' && state.markdown
  2867. })
  2868. if (parsedAtts.length === 0) {
  2869. ElMessage.warning('没有已解析的附件,请先解析附件')
  2870. return
  2871. }
  2872. // 如果只有一个已解析附件,直接打开
  2873. if (parsedAtts.length === 1) {
  2874. openAttachmentInReferenceMode(parsedAtts[0], elemKey, elemName)
  2875. return
  2876. }
  2877. // 多个附件,弹出选择弹窗
  2878. refAttSelectList.value = parsedAtts
  2879. refAttSelectElemKey.value = elemKey
  2880. refAttSelectElemName.value = elemName
  2881. showRefAttSelectDialog.value = true
  2882. }
  2883. function onRefAttSelected(att) {
  2884. showRefAttSelectDialog.value = false
  2885. openAttachmentInReferenceMode(att, refAttSelectElemKey.value, refAttSelectElemName.value)
  2886. }
  2887. function openAttachmentInReferenceMode(att, elemKey, elemName) {
  2888. referenceMode.value = true
  2889. referenceModeElementKey.value = elemKey
  2890. referenceModeElementName.value = elemName
  2891. referenceModeAttId.value = typeof att.id === 'number' ? att.id : null
  2892. referenceModeAttName.value = att.displayName
  2893. const state = parseStates[att.id]
  2894. parseResultAttName.value = att.displayName
  2895. parseResultContent.value = state.markdown
  2896. parseResultIsHtml.value = !!state.isHtml
  2897. parseResultPreviewAvailable.value = false
  2898. parseResultOriginAtt.value = att
  2899. parseResultOriginZf.value = null
  2900. previewContentType.value = ''
  2901. parseResultViewMode.value = 'rendered'
  2902. showParseResultDialog.value = true
  2903. }
  2904. function exitReferenceMode() {
  2905. referenceMode.value = false
  2906. referenceModeElementKey.value = ''
  2907. referenceModeElementName.value = ''
  2908. referenceModeAttId.value = null
  2909. referenceModeAttName.value = ''
  2910. citationToolbar.visible = false
  2911. }
  2912. // 要素/值
  2913. function getElementValues(elementKey) { return values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elementKey) }
  2914. function hasFilledValue(elementKey) { return values.value.some(v => stripValueKeyPrefix(v.elementKey) === elementKey && v.isFilled) }
  2915. function onValueChange(val) { saved.value = false; val.isModified = true }
  2916. async function handleAddElement() {
  2917. if (!newElementForm.elementName || !newElementForm.elementKey) return
  2918. try {
  2919. const elem = await elementApi.add(currentProjectId.value, { ...newElementForm })
  2920. elements.value.push(elem); showAddElementDialog.value = false
  2921. Object.assign(newElementForm, { elementName: '', elementKey: '', dataType: 'text', description: '' })
  2922. ElMessage.success('要素添加成功')
  2923. } catch (e) { ElMessage.error('添加失败: ' + e.message) }
  2924. }
  2925. // 附件
  2926. async function handleAttachmentUpload(file) {
  2927. if (!currentProjectId.value) return
  2928. try {
  2929. const att = await attachmentApi.upload(currentProjectId.value, file.raw, file.name)
  2930. attachments.value.push(att)
  2931. // 缓存原始文件用于后续解析
  2932. if (att?.id) attachmentFileCache.set(att.id, file.raw)
  2933. ElMessage.success('附件上传成功')
  2934. } catch (e) { ElMessage.error('上传失败: ' + e.message) }
  2935. }
  2936. function selectAttachment(att) { selectedAttachment.value = att }
  2937. async function removeAttachment(att) {
  2938. try {
  2939. await ElMessageBox.confirm(`确定删除附件「${att.displayName}」?`, '删除确认', { type: 'warning' })
  2940. await attachmentApi.delete(att.id)
  2941. attachments.value = attachments.value.filter(a => a.id !== att.id); ElMessage.success('附件已删除')
  2942. } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
  2943. }
  2944. function getFileExt(att) {
  2945. const t = att.fileType || ''
  2946. if (t) return t.toLowerCase()
  2947. const name = att.displayName || att.fileName || ''
  2948. const ext = name.split('.').pop()?.toLowerCase()
  2949. return ext || ''
  2950. }
  2951. function getAttachmentFetchUrl(att) {
  2952. if (att?.fileUrl) return att.fileUrl
  2953. if (att?.fileKey) return `/api/v1/files/${encodeURIComponent(att.fileKey)}`
  2954. if (att?.filePath && /^https?:\/\//i.test(att.filePath)) return att.filePath
  2955. return ''
  2956. }
  2957. async function loadAttachmentFile(att, fallbackName = 'file') {
  2958. const cached = attachmentFileCache.get(att.id)
  2959. if (cached) return cached
  2960. const url = getAttachmentFetchUrl(att)
  2961. if (!url) return null
  2962. try {
  2963. const token = localStorage.getItem('accessToken')
  2964. const headers = token ? { Authorization: `Bearer ${token}` } : {}
  2965. const resp = await fetch(url, { headers })
  2966. if (!resp.ok) throw new Error(`下载失败(${resp.status})`)
  2967. const blob = await resp.blob()
  2968. // 优先使用带扩展名的fileName,否则用displayName+扩展名
  2969. let fileName = att.fileName || fallbackName
  2970. if (!fileName && att.displayName && att.fileType) {
  2971. fileName = `${att.displayName}.${att.fileType}`
  2972. } else if (!fileName && att.displayName) {
  2973. fileName = att.displayName
  2974. }
  2975. const file = new File([blob], fileName, {
  2976. type: blob.type || 'application/octet-stream'
  2977. })
  2978. attachmentFileCache.set(att.id, file)
  2979. return file
  2980. } catch (e) {
  2981. console.warn('获取附件文件失败:', e)
  2982. return null
  2983. }
  2984. }
  2985. function getFileTypeClass(att) {
  2986. const ext = getFileExt(att)
  2987. if (ext === 'pdf') return 'type-pdf'
  2988. if (ext === 'doc' || ext === 'docx') return 'type-word'
  2989. if (ext === 'xls' || ext === 'xlsx') return 'type-excel'
  2990. if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') return 'type-image'
  2991. if (ext === 'zip' || ext === 'rar' || ext === '7z') return 'type-archive'
  2992. return 'type-other'
  2993. }
  2994. function getFileTypeLabel(att) {
  2995. const ext = getFileExt(att)
  2996. if (ext === 'pdf') return 'PDF'
  2997. if (ext === 'doc' || ext === 'docx') return 'W'
  2998. if (ext === 'xls' || ext === 'xlsx') return 'X'
  2999. if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') return '🖼'
  3000. if (ext === 'zip' || ext === 'rar' || ext === '7z') return 'ZIP'
  3001. return '📄'
  3002. }
  3003. function getFileTypeTag(att) {
  3004. const ext = getFileExt(att)
  3005. if (ext === 'pdf') return 'PDF'
  3006. if (ext === 'doc' || ext === 'docx') return 'docx'
  3007. if (ext === 'xls' || ext === 'xlsx') return 'xlsx'
  3008. if (ext === 'png') return 'png'
  3009. if (ext === 'jpg' || ext === 'jpeg') return 'jpg'
  3010. if (ext === 'zip') return 'zip'
  3011. return ext || '文件'
  3012. }
  3013. function getZipEntryTypeClass(zf) {
  3014. const ext = zf.ext
  3015. if (ext === 'pdf') return 'type-pdf'
  3016. if (ext === 'doc' || ext === 'docx') return 'type-word'
  3017. if (ext === 'xls' || ext === 'xlsx') return 'type-excel'
  3018. if (ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif') return 'type-image'
  3019. return 'type-other'
  3020. }
  3021. function getZipEntryTypeLabel(zf) {
  3022. const ext = zf.ext
  3023. if (ext === 'pdf') return 'PDF'
  3024. if (ext === 'doc' || ext === 'docx') return 'W'
  3025. if (ext === 'xls' || ext === 'xlsx') return 'X'
  3026. if (['png', 'jpg', 'jpeg', 'gif'].includes(ext)) return '🖼'
  3027. return '📄'
  3028. }
  3029. async function parseAllZipEntries() {
  3030. const pending = zipFileList.value.filter(f => f.parseable && !f.parsed && !f.parsing)
  3031. if (pending.length === 0) return
  3032. ElMessage.info(`开始解析 ${pending.length} 个文件...`)
  3033. for (const zf of pending) {
  3034. await parseZipEntry(zf)
  3035. }
  3036. }
  3037. function formatFileSize(bytes) {
  3038. if (!bytes) return ''
  3039. if (bytes < 1024) return bytes + 'B'
  3040. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + 'KB'
  3041. return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
  3042. }
  3043. async function handleAttachmentAction(cmd, att) {
  3044. switch (cmd) {
  3045. case 'preview':
  3046. selectAttachment(att)
  3047. ElMessage.info('预览功能开发中')
  3048. break
  3049. case 'parse':
  3050. await handleParseAttachment(att)
  3051. break
  3052. case 'view_result':
  3053. viewParseResult(att)
  3054. break
  3055. case 'apply':
  3056. ElMessage.info('应用要素功能开发中')
  3057. break
  3058. case 'download':
  3059. ElMessage.info('下载功能开发中')
  3060. break
  3061. case 'delete':
  3062. await removeAttachment(att)
  3063. break
  3064. }
  3065. }
  3066. function viewParseResult(att) {
  3067. const state = parseStates[att.id]
  3068. if (!state || state.status !== 'completed' || !state.markdown) {
  3069. ElMessage.warning('该附件尚未解析或解析结果为空')
  3070. return
  3071. }
  3072. parseResultAttName.value = att.displayName
  3073. parseResultContent.value = state.markdown
  3074. parseResultIsHtml.value = !!state.isHtml
  3075. // 独立附件也支持原件预览(从缓存或 fileUrl 获取)
  3076. const ext = getFileExt(att)
  3077. parseResultPreviewAvailable.value = ['pdf', 'png', 'jpg', 'jpeg', 'gif', 'docx', 'doc'].includes(ext)
  3078. parseResultOriginAtt.value = att
  3079. parseResultOriginZf.value = null
  3080. previewContentType.value = ''
  3081. parseResultViewMode.value = 'rendered'
  3082. showParseResultDialog.value = true
  3083. // 如果有高亮文本,延迟滚动到高亮位置
  3084. if (highlightSourceText.value) {
  3085. setTimeout(scrollToHighlight, 300)
  3086. }
  3087. }
  3088. function scrollToHighlight(retries = 3) {
  3089. console.log('[scrollToHighlight] 尝试滚动定位, retries:', retries)
  3090. // 查找高亮元素
  3091. const highlight = document.querySelector('.parse-result-rendered mark.source-highlight')
  3092. console.log('[scrollToHighlight] 找到高亮元素:', highlight)
  3093. if (highlight) {
  3094. highlight.scrollIntoView({ behavior: 'smooth', block: 'center' })
  3095. // 添加闪烁效果
  3096. highlight.classList.add('highlight-flash')
  3097. setTimeout(() => highlight.classList.remove('highlight-flash'), 2000)
  3098. console.log('[scrollToHighlight] 滚动完成')
  3099. } else if (retries > 0) {
  3100. // 如果没找到,可能是 DOM 还没渲染完,重试
  3101. console.log('[scrollToHighlight] 未找到元素,重试...')
  3102. setTimeout(() => scrollToHighlight(retries - 1), 200)
  3103. } else {
  3104. console.log('[scrollToHighlight] 重试次数用尽,未找到高亮元素')
  3105. }
  3106. }
  3107. function copyParseResult() {
  3108. if (!parseResultContent.value) return
  3109. navigator.clipboard.writeText(parseResultContent.value).then(() => {
  3110. ElMessage.success('已复制到剪贴板')
  3111. }).catch(() => {
  3112. ElMessage.error('复制失败')
  3113. })
  3114. }
  3115. function getParseState(attId) {
  3116. if (!parseStates[attId]) {
  3117. parseStates[attId] = { status: 'idle', progress: '', markdown: '' }
  3118. }
  3119. return parseStates[attId]
  3120. }
  3121. function saveParseState(attId) {
  3122. try {
  3123. const saved = JSON.parse(localStorage.getItem('parseStates') || '{}')
  3124. const state = parseStates[attId]
  3125. if (state?.status === 'completed' && state.markdown) {
  3126. saved[attId] = { status: 'completed', markdown: state.markdown, isHtml: !!state.isHtml }
  3127. }
  3128. try {
  3129. localStorage.setItem('parseStates', JSON.stringify(saved))
  3130. } catch (quotaErr) {
  3131. // localStorage 空间不足(base64 图片太大),保存不含图片的版本
  3132. console.warn('localStorage 空间不足,保存不含图片的精简版本')
  3133. if (state?.markdown) {
  3134. const lite = state.markdown
  3135. .replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '')
  3136. .replace(/src="data:[^"]+"/g, 'src=""')
  3137. saved[attId] = { status: 'completed', markdown: lite, isHtml: !!state.isHtml, imagesStripped: true }
  3138. }
  3139. localStorage.setItem('parseStates', JSON.stringify(saved))
  3140. }
  3141. } catch (e) { console.warn('保存解析状态失败:', e) }
  3142. }
  3143. function restoreParseStates() {
  3144. try {
  3145. const saved = JSON.parse(localStorage.getItem('parseStates') || '{}')
  3146. for (const [attId, data] of Object.entries(saved)) {
  3147. if (data.status === 'completed' && data.markdown) {
  3148. parseStates[attId] = { status: 'completed', progress: '解析完成', markdown: data.markdown, isHtml: !!data.isHtml }
  3149. }
  3150. }
  3151. } catch (e) { console.warn('恢复解析状态失败:', e) }
  3152. }
  3153. function canParse(att) {
  3154. const ext = getFileExt(att)
  3155. return ['pdf', 'png', 'jpg', 'jpeg', 'docx', 'doc', 'zip'].includes(ext)
  3156. }
  3157. async function handleZipAttachment(att) {
  3158. // 获取 ZIP 文件
  3159. let file = attachmentFileCache.get(att.id)
  3160. if (!file) file = await loadAttachmentFile(att, 'file.zip')
  3161. if (!file) {
  3162. ElMessage.error('未找到后端附件文件,请先确认附件文件已在后端持久化')
  3163. return
  3164. }
  3165. try {
  3166. const zip = await JSZip.loadAsync(file)
  3167. const parseableExts = ['pdf', 'png', 'jpg', 'jpeg', 'docx', 'doc']
  3168. const files = []
  3169. zip.forEach((relativePath, zipEntry) => {
  3170. if (zipEntry.dir) return
  3171. // 跳过 macOS 资源文件
  3172. if (relativePath.startsWith('__MACOSX/') || relativePath.includes('/.')) return
  3173. const name = relativePath
  3174. const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''
  3175. files.push({
  3176. name,
  3177. path: relativePath,
  3178. size: zipEntry._data?.uncompressedSize || 0,
  3179. ext,
  3180. parseable: parseableExts.includes(ext),
  3181. parsing: false,
  3182. parsed: false,
  3183. parseResult: '',
  3184. isHtml: false
  3185. })
  3186. })
  3187. // 按文件名排序,可解析的排前面
  3188. files.sort((a, b) => {
  3189. if (a.parseable && !b.parseable) return -1
  3190. if (!a.parseable && b.parseable) return 1
  3191. return a.name.localeCompare(b.name)
  3192. })
  3193. // 恢复之前的解析结果
  3194. restoreZipParseStates(att.id, files)
  3195. zipFileList.value = files
  3196. zipInstance.value = zip
  3197. zipParentAtt.value = att
  3198. zipContentsAttName.value = att.displayName
  3199. showZipContentsDialog.value = true
  3200. } catch (e) {
  3201. ElMessage.error('ZIP 解压失败: ' + (e.message || e))
  3202. }
  3203. }
  3204. async function parseZipEntry(zf) {
  3205. if (!zipInstance.value) return
  3206. zf.parsing = true
  3207. try {
  3208. const zipEntry = zipInstance.value.file(zf.path)
  3209. if (!zipEntry) throw new Error('文件不存在: ' + zf.path)
  3210. const ext = zf.ext
  3211. if (ext === 'docx' || ext === 'doc') {
  3212. // DOCX: 提取为 blob,发送到后端解析
  3213. const blob = await zipEntry.async('blob')
  3214. const file = new File([blob], zf.name.split('/').pop(), {
  3215. type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
  3216. })
  3217. const result = await attachmentApi.parseDocx(file)
  3218. zf.parseResult = result.html || ''
  3219. zf.isHtml = true
  3220. } else if (ext === 'pdf' || ['png', 'jpg', 'jpeg'].includes(ext)) {
  3221. const blob = await zipEntry.async('blob')
  3222. const mimeMap = { pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg' }
  3223. const file = new File([blob], zf.name.split('/').pop(), { type: mimeMap[ext] || 'application/octet-stream' })
  3224. const submitResult = await parseApi.submit(file, { return_images: true })
  3225. const taskId = submitResult.task_id
  3226. if (!taskId) throw new Error('未返回任务ID')
  3227. // 轮询
  3228. let pollCount = 0
  3229. const maxPolls = 300
  3230. while (pollCount++ < maxPolls) {
  3231. await new Promise(r => setTimeout(r, 2000))
  3232. const statusResult = await parseApi.getStatus(taskId)
  3233. const taskStatus = statusResult.status
  3234. // 更新进度提示
  3235. if (taskStatus === 'pending') {
  3236. zf.parseProgress = `任务排队中... (${pollCount}/${maxPolls})`
  3237. } else if (taskStatus === 'processing') {
  3238. zf.parseProgress = statusResult.progress || `正在解析中... (${pollCount}/${maxPolls})`
  3239. }
  3240. if (taskStatus === 'completed') {
  3241. const result = await parseApi.getResult(taskId)
  3242. let markdown = result.markdown || ''
  3243. // 尝试从 zip 提取图片(如果 markdown 引用了 images/)
  3244. const imageRefs = markdown.match(/!\[[^\]]*\]\(images\/[^)]+\)/g)
  3245. if (imageRefs && imageRefs.length > 0) {
  3246. try {
  3247. const zipBlob = await parseApi.downloadZip(taskId)
  3248. const imgZip = await JSZip.loadAsync(zipBlob)
  3249. for (const ref of imageRefs) {
  3250. const match = ref.match(/!\[([^\]]*)\]\((images\/[^)]+)\)/)
  3251. if (!match) continue
  3252. const [, , imgPath] = match
  3253. let imgFile = imgZip.file(imgPath)
  3254. if (!imgFile) {
  3255. const fn = imgPath.split('/').pop()
  3256. const cands = imgZip.file(new RegExp(fn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'))
  3257. if (cands.length > 0) imgFile = cands[0]
  3258. }
  3259. if (imgFile) {
  3260. const imgData = await imgFile.async('base64')
  3261. const imgExt = imgPath.split('.').pop().toLowerCase()
  3262. const mime = imgExt === 'png' ? 'image/png' : imgExt === 'gif' ? 'image/gif' : 'image/jpeg'
  3263. markdown = markdown.replace(`(${imgPath})`, `(data:${mime};base64,${imgData})`)
  3264. }
  3265. }
  3266. } catch (zipErr) { console.warn('提取解析图片失败:', zipErr) }
  3267. }
  3268. zf.parseResult = markdown
  3269. zf.isHtml = false
  3270. break
  3271. } else if (statusResult.status === 'failed') {
  3272. throw new Error('解析失败')
  3273. }
  3274. }
  3275. if (!zf.parseResult) throw new Error('解析超时')
  3276. }
  3277. zf.parsed = true
  3278. zf.parsing = false
  3279. // 持久化 ZIP 解析结果
  3280. if (zipParentAtt.value) saveZipParseStates(zipParentAtt.value.id)
  3281. ElMessage.success(`「${zf.name.split('/').pop()}」解析完成`)
  3282. } catch (e) {
  3283. zf.parsing = false
  3284. ElMessage.error(`解析失败: ${e.message || e}`)
  3285. }
  3286. }
  3287. function viewZipEntryResult(zf) {
  3288. parseResultAttName.value = zf.name.split('/').pop()
  3289. parseResultContent.value = zf.parseResult
  3290. parseResultIsHtml.value = !!zf.isHtml
  3291. parseResultPreviewAvailable.value = !!zipInstance.value
  3292. parseResultOriginZf.value = zf
  3293. previewContentType.value = '' // 重置,切换到原件时才加载
  3294. parseResultViewMode.value = 'rendered'
  3295. showParseResultDialog.value = true
  3296. }
  3297. function saveZipParseStates(attId) {
  3298. try {
  3299. const allZip = JSON.parse(localStorage.getItem('zipParseStates') || '{}')
  3300. const entries = {}
  3301. for (const zf of zipFileList.value) {
  3302. if (zf.parsed && zf.parseResult) {
  3303. entries[zf.path] = { parseResult: zf.parseResult, isHtml: !!zf.isHtml }
  3304. }
  3305. }
  3306. allZip[attId] = entries
  3307. try {
  3308. localStorage.setItem('zipParseStates', JSON.stringify(allZip))
  3309. } catch (quotaErr) {
  3310. console.warn('localStorage 空间不足,保存 ZIP 解析精简版')
  3311. // 去掉 base64 图片数据再存
  3312. for (const key of Object.keys(entries)) {
  3313. entries[key].parseResult = entries[key].parseResult
  3314. .replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/g, '')
  3315. .replace(/src="data:[^"]+"/g, 'src=""')
  3316. entries[key].imagesStripped = true
  3317. }
  3318. allZip[attId] = entries
  3319. localStorage.setItem('zipParseStates', JSON.stringify(allZip))
  3320. }
  3321. } catch (e) { console.warn('保存 ZIP 解析状态失败:', e) }
  3322. }
  3323. function restoreZipParseStates(attId, files) {
  3324. try {
  3325. const allZip = JSON.parse(localStorage.getItem('zipParseStates') || '{}')
  3326. const entries = allZip[attId]
  3327. if (!entries) return
  3328. for (const zf of files) {
  3329. const saved = entries[zf.path]
  3330. if (saved && saved.parseResult) {
  3331. zf.parsed = true
  3332. zf.parseResult = saved.parseResult
  3333. zf.isHtml = !!saved.isHtml
  3334. }
  3335. }
  3336. } catch (e) { console.warn('恢复 ZIP 解析状态失败:', e) }
  3337. }
  3338. async function previewZipEntry(zf) {
  3339. // 直接打开解析结果弹窗,默认切到原件预览模式
  3340. parseResultAttName.value = zf.name.split('/').pop()
  3341. parseResultContent.value = zf.parseResult || ''
  3342. parseResultIsHtml.value = !!zf.isHtml
  3343. parseResultPreviewAvailable.value = true
  3344. parseResultOriginZf.value = zf
  3345. parseResultViewMode.value = 'preview'
  3346. showParseResultDialog.value = true
  3347. await loadPreviewFromZip(zf)
  3348. }
  3349. async function switchToPreviewMode() {
  3350. parseResultViewMode.value = 'preview'
  3351. if (previewContentType.value) return // 已加载
  3352. const zf = parseResultOriginZf.value
  3353. if (zf) {
  3354. await loadPreviewFromZip(zf)
  3355. } else if (parseResultOriginAtt.value) {
  3356. await loadPreviewFromAtt(parseResultOriginAtt.value)
  3357. }
  3358. }
  3359. async function loadPreviewFromAtt(att) {
  3360. // 清理旧的 blob URL
  3361. if (previewContentUrl.value) {
  3362. URL.revokeObjectURL(previewContentUrl.value)
  3363. previewContentUrl.value = ''
  3364. }
  3365. const ext = getFileExt(att)
  3366. try {
  3367. // 获取原始文件:优先缓存,其次后端文件服务
  3368. const file = await loadAttachmentFile(att)
  3369. if (!file) {
  3370. ElMessage.warning('原始文件不可用,请确认后端附件文件已持久化')
  3371. previewContentType.value = 'unsupported'
  3372. return
  3373. }
  3374. if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
  3375. previewContentUrl.value = URL.createObjectURL(file)
  3376. previewContentType.value = 'image'
  3377. } else if (ext === 'pdf') {
  3378. const typedBlob = new Blob([file], { type: 'application/pdf' })
  3379. previewContentUrl.value = URL.createObjectURL(typedBlob)
  3380. previewContentType.value = 'pdf'
  3381. } else if (ext === 'docx' || ext === 'doc') {
  3382. // DOCX 已解析的直接用解析结果
  3383. const state = parseStates[att.id]
  3384. if (state?.isHtml && state.markdown) {
  3385. previewContentHtml.value = state.markdown
  3386. } else {
  3387. const result = await attachmentApi.parseDocx(file)
  3388. previewContentHtml.value = result.html || '<p>解析结果为空</p>'
  3389. }
  3390. previewContentType.value = 'html'
  3391. } else {
  3392. previewContentType.value = 'unsupported'
  3393. }
  3394. } catch (e) {
  3395. ElMessage.error('预览失败: ' + (e.message || e))
  3396. }
  3397. }
  3398. async function loadPreviewFromZip(zf) {
  3399. if (!zipInstance.value) return
  3400. const ext = zf.ext
  3401. try {
  3402. const zipEntry = zipInstance.value.file(zf.path)
  3403. if (!zipEntry) { ElMessage.warning('文件不存在'); return }
  3404. // 清理旧的 blob URL
  3405. if (previewContentUrl.value) {
  3406. URL.revokeObjectURL(previewContentUrl.value)
  3407. previewContentUrl.value = ''
  3408. }
  3409. if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
  3410. const blob = await zipEntry.async('blob')
  3411. const mimeMap = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml' }
  3412. const typedBlob = new Blob([blob], { type: mimeMap[ext] || 'image/png' })
  3413. previewContentUrl.value = URL.createObjectURL(typedBlob)
  3414. previewContentType.value = 'image'
  3415. } else if (ext === 'pdf') {
  3416. const blob = await zipEntry.async('blob')
  3417. const typedBlob = new Blob([blob], { type: 'application/pdf' })
  3418. previewContentUrl.value = URL.createObjectURL(typedBlob)
  3419. previewContentType.value = 'pdf'
  3420. } else if (ext === 'docx' || ext === 'doc') {
  3421. if (zf.parsed && zf.parseResult && zf.isHtml) {
  3422. previewContentHtml.value = zf.parseResult
  3423. } else {
  3424. const blob = await zipEntry.async('blob')
  3425. const file = new File([blob], zf.name.split('/').pop(), {
  3426. type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
  3427. })
  3428. const result = await attachmentApi.parseDocx(file)
  3429. previewContentHtml.value = result.html || '<p>解析结果为空</p>'
  3430. }
  3431. previewContentType.value = 'html'
  3432. } else if (['txt', 'md', 'csv', 'json', 'xml', 'yml', 'yaml', 'log', 'ini', 'cfg', 'conf', 'sh', 'bat', 'py', 'java', 'js', 'ts', 'html', 'css'].includes(ext)) {
  3433. const text = await zipEntry.async('string')
  3434. previewContentText.value = text
  3435. previewContentType.value = 'text'
  3436. } else {
  3437. previewContentType.value = 'unsupported'
  3438. }
  3439. } catch (e) {
  3440. ElMessage.error('预览失败: ' + (e.message || e))
  3441. }
  3442. }
  3443. function cleanupPreviewContent() {
  3444. if (previewContentUrl.value) {
  3445. URL.revokeObjectURL(previewContentUrl.value)
  3446. previewContentUrl.value = ''
  3447. }
  3448. previewContentHtml.value = ''
  3449. previewContentText.value = ''
  3450. previewContentType.value = ''
  3451. parseResultPreviewAvailable.value = false
  3452. parseResultOriginAtt.value = null
  3453. parseResultOriginZf.value = null
  3454. highlightSourceText.value = '' // 清除高亮文本
  3455. }
  3456. async function handleParseAttachment(att) {
  3457. const state = getParseState(att.id)
  3458. if (state.status === 'uploading' || state.status === 'parsing') {
  3459. ElMessage.warning('该附件正在解析中,请稍候')
  3460. return
  3461. }
  3462. const ext = getFileExt(att)
  3463. if (!canParse(att)) {
  3464. ElMessage.warning('仅支持 PDF、DOCX、ZIP 和图片文件的解析')
  3465. return
  3466. }
  3467. // ZIP 文件走解压展示流程
  3468. if (ext === 'zip') {
  3469. await handleZipAttachment(att)
  3470. return
  3471. }
  3472. try {
  3473. // 1. 获取后端持久化的原始文件
  3474. state.status = 'uploading'
  3475. state.progress = '正在准备文件...'
  3476. state.progress = '正在获取文件...'
  3477. const file = await loadAttachmentFile(att, `file.${ext}`)
  3478. if (!file) {
  3479. state.status = 'failed'
  3480. state.progress = '未找到后端附件文件'
  3481. ElMessage.error('未找到后端附件文件,请先确认附件文件已在后端持久化')
  3482. return
  3483. }
  3484. // 2. DOCX 走后端 Java 解析,PDF/图片走 GPU 解析服务
  3485. if (ext === 'docx' || ext === 'doc') {
  3486. state.status = 'parsing'
  3487. state.progress = '正在解析 DOCX 文件...'
  3488. ElMessage.info(`附件「${att.displayName}」开始解析`)
  3489. try {
  3490. const result = await attachmentApi.parseDocx(file)
  3491. const html = result.html || ''
  3492. state.markdown = html
  3493. state.status = 'completed'
  3494. state.progress = '解析完成'
  3495. state.isHtml = true
  3496. if (typeof att.id === 'number' || /^\d+$/.test(att.id)) {
  3497. try { await attachmentApi.saveParsedContent(att.id, html) } catch (e) { console.warn('后端持久化失败:', e) }
  3498. }
  3499. att.parsed = true
  3500. att.parsedText = html
  3501. saveParseState(att.id)
  3502. ElMessage.success(`附件「${att.displayName}」解析完成`)
  3503. } catch (docxErr) {
  3504. state.status = 'failed'
  3505. state.progress = '解析失败'
  3506. ElMessage.error(`DOCX 解析失败: ${docxErr.message || docxErr}`)
  3507. }
  3508. return
  3509. }
  3510. // PDF/图片:提交解析任务(启用图片返回)
  3511. state.progress = '正在提交解析任务...'
  3512. const submitResult = await parseApi.submit(file, { return_images: true })
  3513. const taskId = submitResult.task_id
  3514. if (!taskId) throw new Error('未返回任务ID')
  3515. // 3. 轮询任务状态
  3516. state.status = 'parsing'
  3517. state.progress = '解析中...'
  3518. state.taskId = taskId
  3519. ElMessage.info(`附件「${att.displayName}」开始解析`)
  3520. const maxPolls = 300 // 最多轮询 300 次 (约 10 分钟)
  3521. let pollCount = 0
  3522. const pollInterval = 2000 // 2秒一次
  3523. const poll = async () => {
  3524. pollCount++
  3525. if (pollCount > maxPolls) {
  3526. state.status = 'failed'
  3527. state.progress = '解析超时'
  3528. ElMessage.error('解析超时,请稍后重试')
  3529. return
  3530. }
  3531. try {
  3532. const statusResult = await parseApi.getStatus(taskId)
  3533. const taskStatus = statusResult.status
  3534. if (taskStatus === 'completed') {
  3535. // 获取解析结果
  3536. state.progress = '正在获取结果...'
  3537. const result = await parseApi.getResult(taskId)
  3538. let markdown = result.markdown || ''
  3539. // 下载 zip 包提取图片,转为 base64 内嵌到 markdown
  3540. const imageRefs = markdown.match(/!\[[^\]]*\]\(images\/[^)]+\)/g)
  3541. if (imageRefs && imageRefs.length > 0) {
  3542. state.progress = '正在获取图片...'
  3543. try {
  3544. const zipBlob = await parseApi.downloadZip(taskId)
  3545. const zip = await JSZip.loadAsync(zipBlob)
  3546. // 遍历所有图片引用,替换为 base64 data URL
  3547. for (const ref of imageRefs) {
  3548. const match = ref.match(/!\[([^\]]*)\]\((images\/[^)]+)\)/)
  3549. if (!match) continue
  3550. const [, alt, imgPath] = match
  3551. // zip 中图片路径可能带前缀目录,尝试多种匹配
  3552. let imgFile = zip.file(imgPath)
  3553. if (!imgFile) {
  3554. // 尝试在 zip 中搜索文件名
  3555. const fileName = imgPath.split('/').pop()
  3556. const candidates = zip.file(new RegExp(fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'))
  3557. if (candidates.length > 0) imgFile = candidates[0]
  3558. }
  3559. if (imgFile) {
  3560. const imgData = await imgFile.async('base64')
  3561. const ext = imgPath.split('.').pop().toLowerCase()
  3562. const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : 'image/jpeg'
  3563. markdown = markdown.replace(`(${imgPath})`, `(data:${mime};base64,${imgData})`)
  3564. }
  3565. }
  3566. } catch (zipErr) {
  3567. console.warn('下载 zip 提取图片失败:', zipErr)
  3568. }
  3569. }
  3570. state.markdown = markdown
  3571. state.status = 'completed'
  3572. state.progress = '解析完成'
  3573. // 持久化解析结果到后端(仅真实附件,mock 附件 ID 为字符串跳过)
  3574. if (typeof att.id === 'number' || /^\d+$/.test(att.id)) {
  3575. try {
  3576. await attachmentApi.saveParsedContent(att.id, markdown)
  3577. } catch (e) {
  3578. console.warn('后端持久化解析结果失败:', e)
  3579. }
  3580. }
  3581. // 更新附件对象标记已解析
  3582. att.parsed = true
  3583. att.parsedText = markdown
  3584. // 持久化到 localStorage(刷新后恢复)
  3585. saveParseState(att.id)
  3586. ElMessage.success(`附件「${att.displayName}」解析完成`)
  3587. } else if (taskStatus === 'failed') {
  3588. const errMsg = statusResult.error || '解析失败'
  3589. state.status = 'failed'
  3590. state.progress = errMsg
  3591. ElMessage.error(`解析失败: ${errMsg}`)
  3592. } else {
  3593. // pending / processing
  3594. let progressMsg = ''
  3595. if (taskStatus === 'pending') {
  3596. progressMsg = `任务排队中,请稍候... (${pollCount}/${maxPolls})`
  3597. } else if (taskStatus === 'processing') {
  3598. progressMsg = statusResult.progress || `正在解析中... (${pollCount}/${maxPolls})`
  3599. } else {
  3600. progressMsg = `解析中... (${pollCount}/${maxPolls})`
  3601. }
  3602. state.progress = progressMsg
  3603. // 每30秒提示一次用户任务仍在处理
  3604. if (pollCount % 15 === 0) {
  3605. ElMessage.info(`解析任务仍在处理中,已等待 ${Math.floor(pollCount * 2 / 60)} 分钟,请耐心等待...`)
  3606. }
  3607. setTimeout(poll, pollInterval)
  3608. }
  3609. } catch (e) {
  3610. state.status = 'failed'
  3611. state.progress = '查询状态失败'
  3612. ElMessage.error('查询解析状态失败: ' + e.message)
  3613. }
  3614. }
  3615. setTimeout(poll, pollInterval)
  3616. } catch (e) {
  3617. state.status = 'failed'
  3618. state.progress = '解析失败'
  3619. ElMessage.error('解析失败: ' + e.message)
  3620. }
  3621. }
  3622. // 规则
  3623. async function handleCreateRule() {
  3624. if (!newRuleForm.ruleName) return
  3625. try {
  3626. const rule = await ruleApi.create(currentProjectId.value, { ...newRuleForm })
  3627. rules.value.push(rule); showNewRuleDialog.value = false
  3628. Object.assign(newRuleForm, {
  3629. ruleName: '',
  3630. ruleType: 'direct_entity',
  3631. targetElementKey: '',
  3632. sourceAttachmentId: null,
  3633. locatorType: 'full_text',
  3634. chapterTitle: '',
  3635. reviewCode: '',
  3636. tableSelector: '',
  3637. prompt: ''
  3638. })
  3639. ElMessage.success('规则创建成功')
  3640. } catch (e) { ElMessage.error('创建失败: ' + e.message) }
  3641. }
  3642. async function handleDeleteRule(rule) {
  3643. try {
  3644. await ElMessageBox.confirm(`确定删除规则「${rule.ruleName}」?`, '删除确认', { type: 'warning' })
  3645. await ruleApi.delete(rule.id); rules.value = rules.value.filter(r => r.id !== rule.id); ElMessage.success('规则已删除')
  3646. } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
  3647. }
  3648. async function handleExecuteRule(rule) {
  3649. // 弹出规则详情预览
  3650. pendingExecuteRule.value = rule
  3651. showRuleEngineDialog.value = true
  3652. }
  3653. async function confirmExecuteSingleRule() {
  3654. const rule = pendingExecuteRule.value
  3655. if (!rule) return
  3656. showRuleEngineDialog.value = false
  3657. rule._executing = true
  3658. try {
  3659. await ruleApi.execute(rule.id)
  3660. ElMessage.success(`规则「${rule.ruleName}」执行成功`)
  3661. await loadProjectData(currentProjectId.value)
  3662. }
  3663. catch (e) { ElMessage.error('执行失败: ' + e.message) }
  3664. finally {
  3665. rule._executing = false
  3666. pendingExecuteRule.value = null
  3667. }
  3668. }
  3669. async function handleBatchExecuteRules() {
  3670. if (!currentProjectId.value) return
  3671. // 先弹出规则引擎数据预览(批量模式)
  3672. pendingExecuteRule.value = null // 清空单条规则,表示批量模式
  3673. if (ruleEngineData.value.length > 0) {
  3674. showRuleEngineDialog.value = true
  3675. } else {
  3676. ElMessage.warning('没有可执行的自动规则')
  3677. }
  3678. }
  3679. async function confirmExecuteRules() {
  3680. if (!currentProjectId.value) return
  3681. showRuleEngineDialog.value = false
  3682. executingRules.value = true
  3683. try {
  3684. await ruleApi.batchExecute(currentProjectId.value)
  3685. ElMessage.success('批量执行完成')
  3686. await loadProjectData(currentProjectId.value)
  3687. }
  3688. catch (e) { ElMessage.error('执行失败: ' + e.message) }
  3689. finally { executingRules.value = false }
  3690. }
  3691. // 工作流保存
  3692. async function handleWorkflowSave(workflowData) {
  3693. if (!currentProjectId.value || !workflowData) return
  3694. try {
  3695. const rulesToCreate = convertWorkflowToRules(workflowData)
  3696. for (const ruleDTO of rulesToCreate) {
  3697. await ruleApi.create(currentProjectId.value, ruleDTO)
  3698. }
  3699. ElMessage.success(`成功创建 ${rulesToCreate.length} 条规则`)
  3700. showRuleWorkflow.value = false
  3701. await loadProjectData(currentProjectId.value)
  3702. } catch (e) {
  3703. ElMessage.error('保存失败: ' + e.message)
  3704. }
  3705. }
  3706. function convertWorkflowToRules(workflowData) {
  3707. const { nodes, edges } = workflowData
  3708. const rules = []
  3709. const elementNodes = nodes.filter(n => n.type === 'element')
  3710. // 辅助函数:递归查找数据流路径
  3711. function traceDataFlow(nodeId, visited = new Set()) {
  3712. if (visited.has(nodeId)) return { sources: [], actions: [] }
  3713. visited.add(nodeId)
  3714. const node = nodes.find(n => n.id === nodeId)
  3715. if (!node) return { sources: [], actions: [] }
  3716. if (node.type === 'source') {
  3717. return { sources: [node], actions: [] }
  3718. }
  3719. if (node.type === 'action') {
  3720. const inEdges = edges.filter(e => e.target === nodeId)
  3721. let allSources = []
  3722. let allActions = [node]
  3723. for (const edge of inEdges) {
  3724. const upstream = traceDataFlow(edge.source, visited)
  3725. allSources = [...allSources, ...upstream.sources]
  3726. allActions = [...allActions, ...upstream.actions]
  3727. }
  3728. return { sources: allSources, actions: allActions }
  3729. }
  3730. return { sources: [], actions: [] }
  3731. }
  3732. for (const elementNode of elementNodes) {
  3733. if (!elementNode.data.elementKey) continue
  3734. const incomingEdges = edges.filter(e => e.target === elementNode.id)
  3735. if (incomingEdges.length === 0) continue
  3736. // 收集所有输入路径
  3737. let allSources = []
  3738. let allActions = []
  3739. for (const edge of incomingEdges) {
  3740. const { sources, actions } = traceDataFlow(edge.source)
  3741. allSources = [...allSources, ...sources]
  3742. allActions = [...allActions, ...actions]
  3743. }
  3744. // 去重
  3745. const uniqueSources = [...new Map(allSources.map(s => [s.id, s])).values()]
  3746. const uniqueActions = [...new Map(allActions.map(a => [a.id, a])).values()]
  3747. // 确定主要动作(最接近输出的动作节点)
  3748. const directInputEdge = incomingEdges[0]
  3749. const directInputNode = nodes.find(n => n.id === directInputEdge.source)
  3750. let primaryAction = null
  3751. if (directInputNode?.type === 'action') {
  3752. primaryAction = directInputNode
  3753. } else if (uniqueActions.length > 0) {
  3754. primaryAction = uniqueActions[0]
  3755. }
  3756. const actionType = primaryAction?.data?.actionType || primaryAction?.data?.subType || 'quote'
  3757. const prompt = primaryAction?.data?.prompt || ''
  3758. // 构建输入列表
  3759. const inputs = uniqueSources.map(source => ({
  3760. sourceNodeId: source.data.sourceNodeId || null,
  3761. inputType: source.data.subType || 'attachment',
  3762. inputName: source.data.sourceName || source.data.label || '来源',
  3763. sourceText: source.data.sourceText || null,
  3764. locatorType: source.data.locatorType || 'full_text',
  3765. chapterTitle: source.data.chapterTitle || null,
  3766. reviewCode: source.data.reviewCode || null
  3767. })).filter(inp => inp.sourceNodeId || inp.sourceText)
  3768. // 构建动作配置
  3769. const actionConfig = {
  3770. locatorType: uniqueSources[0]?.data?.locatorType || 'full_text',
  3771. chapterTitle: uniqueSources[0]?.data?.chapterTitle || null,
  3772. reviewCode: uniqueSources[0]?.data?.reviewCode || null,
  3773. tableSelector: primaryAction?.data?.tableSelector || null,
  3774. prompt: prompt || null,
  3775. outputFormat: primaryAction?.data?.outputFormat || 'text'
  3776. }
  3777. // 如果有多个动作节点,记录动作链
  3778. if (uniqueActions.length > 1) {
  3779. actionConfig.actionChain = uniqueActions.map(a => ({
  3780. actionType: a.data.actionType || a.data.subType,
  3781. prompt: a.data.prompt || null
  3782. }))
  3783. }
  3784. const rule = {
  3785. elementKey: elementNode.data.elementKey,
  3786. ruleName: `${elementNode.data.elementName || elementNode.data.elementKey}-${getActionLabel(actionType)}`,
  3787. ruleType: 'extraction',
  3788. actionType: actionType,
  3789. actionConfig: JSON.stringify(actionConfig),
  3790. inputs: inputs
  3791. }
  3792. rules.push(rule)
  3793. }
  3794. return rules
  3795. }
  3796. function getActionLabel(actionType) {
  3797. const labels = {
  3798. quote: '引用',
  3799. summary: 'AI总结',
  3800. ai_extract: 'AI提取',
  3801. table_extract: '表格提取'
  3802. }
  3803. return labels[actionType] || actionType
  3804. }
  3805. // 工具函数
  3806. function formatTime(dateStr) {
  3807. if (!dateStr) return ''
  3808. const date = new Date(dateStr); const now = new Date()
  3809. const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24))
  3810. if (diffDays === 0) return '今天'
  3811. if (diffDays === 1) return '昨天'
  3812. if (diffDays < 7) return `${diffDays}天前`
  3813. return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
  3814. }
  3815. function getStatusText(status) {
  3816. const map = { 'draft': '草稿', 'active': '进行中', 'archived': '已归档', 'completed': '已完成' }
  3817. return map[status] || '草稿'
  3818. }
  3819. onMounted(async () => {
  3820. await loadProjects()
  3821. const pid = route.query.project
  3822. if (pid) {
  3823. const p = projects.value.find(p => String(p.id) === String(pid))
  3824. if (p) await switchProject(p)
  3825. }
  3826. })
  3827. </script>
  3828. <style lang="scss" scoped>
  3829. // ==========================================
  3830. // Editor 页面样式 - 参考 V2 原型设计
  3831. // ==========================================
  3832. .editor-page {
  3833. height: 100vh;
  3834. display: flex;
  3835. flex-direction: column;
  3836. background: var(--bg);
  3837. }
  3838. .editor-body {
  3839. flex: 1;
  3840. display: flex;
  3841. overflow: hidden;
  3842. }
  3843. // ==========================================
  3844. // 拖拽分隔条
  3845. // ==========================================
  3846. .resize-handle {
  3847. width: 4px;
  3848. background: transparent;
  3849. cursor: col-resize;
  3850. flex-shrink: 0;
  3851. position: relative;
  3852. z-index: 10;
  3853. transition: background 0.2s;
  3854. &:hover, &:active {
  3855. background: var(--primary);
  3856. }
  3857. &::before {
  3858. content: '';
  3859. position: absolute;
  3860. top: 0;
  3861. bottom: 0;
  3862. left: -3px;
  3863. right: -3px;
  3864. }
  3865. }
  3866. // ==========================================
  3867. // 左侧面板 - 参考设计风格
  3868. // ==========================================
  3869. .left-panel {
  3870. background: var(--white);
  3871. border-right: 1px solid var(--border);
  3872. display: flex;
  3873. flex-direction: column;
  3874. flex-shrink: 0;
  3875. min-width: 260px;
  3876. max-width: 420px;
  3877. overflow: hidden;
  3878. position: relative;
  3879. // ---- 顶部 Logo ----
  3880. .sidebar-header {
  3881. display: flex;
  3882. align-items: center;
  3883. justify-content: space-between;
  3884. padding: 16px 18px 12px;
  3885. flex-shrink: 0;
  3886. .sidebar-logo {
  3887. display: flex;
  3888. align-items: center;
  3889. gap: 8px;
  3890. .logo-icon {
  3891. font-size: 22px;
  3892. color: var(--primary);
  3893. line-height: 1;
  3894. }
  3895. .logo-text {
  3896. font-size: 17px;
  3897. font-weight: 700;
  3898. color: var(--text-1);
  3899. letter-spacing: 0.5px;
  3900. }
  3901. }
  3902. .sidebar-header-actions {
  3903. display: flex;
  3904. gap: 4px;
  3905. color: var(--text-3);
  3906. }
  3907. }
  3908. // ---- 快捷导航 ----
  3909. .sidebar-nav {
  3910. display: flex;
  3911. flex-direction: column;
  3912. gap: 2px;
  3913. padding: 0 14px 10px;
  3914. flex-shrink: 0;
  3915. .nav-item {
  3916. display: flex;
  3917. align-items: center;
  3918. gap: 12px;
  3919. padding: 10px 14px;
  3920. border-radius: 10px;
  3921. cursor: pointer;
  3922. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  3923. font-size: 14px;
  3924. color: var(--text-1);
  3925. border: 1.5px solid transparent;
  3926. &:hover {
  3927. background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
  3928. border-color: #e6f0ff;
  3929. transform: translateX(2px);
  3930. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.06);
  3931. }
  3932. .nav-icon {
  3933. font-size: 18px;
  3934. flex-shrink: 0;
  3935. }
  3936. .nav-label {
  3937. font-weight: 600;
  3938. letter-spacing: 0.2px;
  3939. }
  3940. }
  3941. }
  3942. // ---- 区块通用 ----
  3943. .sidebar-section {
  3944. display: flex;
  3945. flex-direction: column;
  3946. padding: 0 14px;
  3947. flex-shrink: 0;
  3948. .section-header {
  3949. display: flex;
  3950. align-items: center;
  3951. justify-content: space-between;
  3952. padding: 10px 4px 8px;
  3953. .section-title {
  3954. font-size: 13px;
  3955. font-weight: 600;
  3956. color: var(--text-2);
  3957. }
  3958. .section-action {
  3959. font-size: 12px;
  3960. color: var(--text-3);
  3961. cursor: pointer;
  3962. transition: color 0.15s;
  3963. &:hover {
  3964. color: var(--primary);
  3965. }
  3966. }
  3967. }
  3968. .sidebar-search {
  3969. margin-bottom: 10px;
  3970. :deep(.el-input__wrapper) {
  3971. border-radius: 10px;
  3972. background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
  3973. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
  3974. border: 1.5px solid #e8ecf0;
  3975. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  3976. &:hover {
  3977. border-color: #409eff;
  3978. background: #fff;
  3979. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.08);
  3980. }
  3981. &.is-focus {
  3982. border-color: #409eff;
  3983. background: #fff;
  3984. box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
  3985. }
  3986. }
  3987. }
  3988. }
  3989. // ---- 文档列表 ----
  3990. .doc-list {
  3991. display: flex;
  3992. flex-direction: column;
  3993. gap: 4px;
  3994. overflow-y: auto;
  3995. max-height: 340px;
  3996. padding-bottom: 4px;
  3997. }
  3998. .doc-item {
  3999. display: flex;
  4000. align-items: flex-start;
  4001. gap: 12px;
  4002. padding: 12px;
  4003. border-radius: 12px;
  4004. cursor: pointer;
  4005. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  4006. position: relative;
  4007. border: 1.5px solid transparent;
  4008. background: transparent;
  4009. &:hover {
  4010. background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
  4011. transform: translateY(-1px);
  4012. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.08);
  4013. .doc-icon-wrap {
  4014. transform: scale(1.05);
  4015. box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
  4016. }
  4017. }
  4018. &.active {
  4019. background: linear-gradient(135deg, #e6f0ff 0%, #d9e9ff 100%);
  4020. border-color: var(--primary);
  4021. box-shadow: 0 2px 12px rgba(64, 158, 255, 0.12);
  4022. .doc-item-title {
  4023. color: var(--primary);
  4024. font-weight: 700;
  4025. }
  4026. .doc-icon-wrap {
  4027. background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
  4028. .doc-icon-glyph {
  4029. filter: brightness(0) invert(1);
  4030. }
  4031. }
  4032. }
  4033. .doc-icon-wrap {
  4034. width: 40px;
  4035. height: 40px;
  4036. border-radius: 10px;
  4037. background: linear-gradient(135deg, #eef3ff 0%, #e1ecff 100%);
  4038. display: flex;
  4039. align-items: center;
  4040. justify-content: center;
  4041. flex-shrink: 0;
  4042. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  4043. box-shadow: 0 2px 6px rgba(64, 158, 255, 0.06);
  4044. .doc-icon-glyph {
  4045. font-size: 20px;
  4046. transition: filter 0.2s;
  4047. }
  4048. }
  4049. .doc-item-body {
  4050. flex: 1;
  4051. min-width: 0;
  4052. .doc-item-title {
  4053. font-size: 13px;
  4054. font-weight: 600;
  4055. color: var(--text-1);
  4056. line-height: 1.4;
  4057. display: -webkit-box;
  4058. -webkit-line-clamp: 2;
  4059. line-clamp: 2;
  4060. -webkit-box-orient: vertical;
  4061. overflow: hidden;
  4062. margin-bottom: 4px;
  4063. }
  4064. .doc-item-meta {
  4065. display: flex;
  4066. align-items: center;
  4067. gap: 6px;
  4068. font-size: 11px;
  4069. color: var(--text-3);
  4070. flex-wrap: wrap;
  4071. .doc-status-tag {
  4072. font-size: 10px;
  4073. height: 18px;
  4074. line-height: 16px;
  4075. padding: 0 6px;
  4076. border-radius: 4px;
  4077. }
  4078. .doc-item-date {
  4079. white-space: nowrap;
  4080. }
  4081. .doc-item-author {
  4082. white-space: nowrap;
  4083. }
  4084. }
  4085. .doc-item-progress {
  4086. display: flex;
  4087. flex-direction: column;
  4088. gap: 4px;
  4089. .el-progress {
  4090. width: 100%;
  4091. }
  4092. .progress-text {
  4093. font-size: 11px;
  4094. color: var(--primary);
  4095. white-space: nowrap;
  4096. overflow: hidden;
  4097. text-overflow: ellipsis;
  4098. }
  4099. }
  4100. }
  4101. .doc-more-btn {
  4102. opacity: 0;
  4103. flex-shrink: 0;
  4104. transition: opacity 0.15s;
  4105. color: var(--text-3);
  4106. margin-top: 2px;
  4107. }
  4108. &:hover .doc-more-btn {
  4109. opacity: 1;
  4110. }
  4111. }
  4112. .doc-empty {
  4113. padding: 20px;
  4114. text-align: center;
  4115. color: var(--text-3);
  4116. font-size: 13px;
  4117. }
  4118. .doc-loading {
  4119. display: flex;
  4120. align-items: center;
  4121. justify-content: center;
  4122. gap: 8px;
  4123. padding: 20px;
  4124. color: var(--text-3);
  4125. font-size: 13px;
  4126. }
  4127. // ---- 最近操作 ----
  4128. .sidebar-activity {
  4129. flex: 1;
  4130. min-height: 0;
  4131. overflow: hidden;
  4132. display: flex;
  4133. flex-direction: column;
  4134. border-top: 1px solid var(--border);
  4135. margin-top: 6px;
  4136. padding-top: 4px;
  4137. .activity-list {
  4138. flex: 1;
  4139. overflow-y: auto;
  4140. display: flex;
  4141. flex-direction: column;
  4142. gap: 2px;
  4143. }
  4144. .activity-item {
  4145. padding: 8px 12px;
  4146. border-radius: 8px;
  4147. transition: background 0.15s;
  4148. &:hover {
  4149. background: var(--bg);
  4150. }
  4151. .activity-text {
  4152. font-size: 13px;
  4153. color: var(--text-1);
  4154. line-height: 1.4;
  4155. white-space: nowrap;
  4156. overflow: hidden;
  4157. text-overflow: ellipsis;
  4158. }
  4159. .activity-meta {
  4160. display: flex;
  4161. align-items: center;
  4162. gap: 8px;
  4163. margin-top: 3px;
  4164. font-size: 11px;
  4165. color: var(--text-3);
  4166. .activity-source {
  4167. color: var(--primary);
  4168. }
  4169. }
  4170. }
  4171. .activity-empty {
  4172. padding: 20px;
  4173. text-align: center;
  4174. color: var(--text-3);
  4175. font-size: 12px;
  4176. }
  4177. }
  4178. // ---- 底部用户栏 ----
  4179. .sidebar-footer {
  4180. display: flex;
  4181. align-items: center;
  4182. gap: 10px;
  4183. padding: 12px 16px;
  4184. border-top: 1px solid var(--border);
  4185. flex-shrink: 0;
  4186. background: var(--white);
  4187. .user-avatar {
  4188. width: 34px;
  4189. height: 34px;
  4190. border-radius: 50%;
  4191. background: linear-gradient(135deg, var(--primary), #69c0ff);
  4192. color: #fff;
  4193. display: flex;
  4194. align-items: center;
  4195. justify-content: center;
  4196. font-size: 14px;
  4197. font-weight: 700;
  4198. flex-shrink: 0;
  4199. }
  4200. .user-info {
  4201. flex: 1;
  4202. min-width: 0;
  4203. display: flex;
  4204. flex-direction: column;
  4205. .user-name {
  4206. font-size: 13px;
  4207. font-weight: 600;
  4208. color: var(--text-1);
  4209. line-height: 1.3;
  4210. }
  4211. .user-role {
  4212. font-size: 11px;
  4213. color: var(--text-3);
  4214. display: flex;
  4215. align-items: center;
  4216. gap: 4px;
  4217. &::before {
  4218. content: '';
  4219. width: 6px;
  4220. height: 6px;
  4221. border-radius: 50%;
  4222. background: #52c41a;
  4223. flex-shrink: 0;
  4224. }
  4225. }
  4226. }
  4227. .footer-actions {
  4228. display: flex;
  4229. align-items: center;
  4230. gap: 4px;
  4231. :deep(.el-button.is-circle) {
  4232. width: 32px;
  4233. height: 32px;
  4234. }
  4235. :deep(.el-icon) {
  4236. font-size: 18px;
  4237. }
  4238. :deep(.el-icon svg) {
  4239. width: 18px;
  4240. height: 18px;
  4241. }
  4242. .notification-badge {
  4243. :deep(.el-badge__content) {
  4244. font-size: 10px;
  4245. height: 16px;
  4246. line-height: 16px;
  4247. padding: 0 4px;
  4248. }
  4249. }
  4250. }
  4251. }
  4252. // ---- 覆盖层面板(附件/规则) ----
  4253. .sidebar-overlay-panel {
  4254. position: absolute;
  4255. top: 0;
  4256. left: 0;
  4257. right: 0;
  4258. bottom: 0;
  4259. background: var(--white);
  4260. z-index: 20;
  4261. display: flex;
  4262. flex-direction: column;
  4263. .overlay-header {
  4264. display: flex;
  4265. align-items: center;
  4266. justify-content: space-between;
  4267. padding: 12px 14px;
  4268. border-bottom: 1px solid var(--border);
  4269. flex-shrink: 0;
  4270. .overlay-title {
  4271. font-size: 14px;
  4272. font-weight: 600;
  4273. color: var(--text-1);
  4274. }
  4275. }
  4276. .overlay-body {
  4277. flex: 1;
  4278. overflow-y: auto;
  4279. padding: 12px;
  4280. }
  4281. }
  4282. // 覆盖层滑入动画
  4283. .slide-right-enter-active {
  4284. transition: transform 0.25s ease-out;
  4285. }
  4286. .slide-right-leave-active {
  4287. transition: transform 0.2s ease-in;
  4288. }
  4289. .slide-right-enter-from {
  4290. transform: translateX(-100%);
  4291. }
  4292. .slide-right-leave-to {
  4293. transform: translateX(-100%);
  4294. }
  4295. }
  4296. // ==========================================
  4297. // 上传区 - V2 风格
  4298. // ==========================================
  4299. .upload-zone {
  4300. border: 2px dashed var(--border);
  4301. border-radius: var(--radius-lg);
  4302. margin-bottom: 16px;
  4303. height: 40px;
  4304. display: flex;
  4305. align-items: center;
  4306. justify-content: center;
  4307. background: var(--white);
  4308. transition: all 0.2s;
  4309. &:hover {
  4310. border-color: var(--primary);
  4311. background: var(--primary-light);
  4312. }
  4313. :deep(.el-upload-dragger) {
  4314. padding: 0 12px;
  4315. border: none;
  4316. background: transparent;
  4317. width: 100%;
  4318. height: 100%;
  4319. display: flex;
  4320. align-items: center;
  4321. justify-content: center;
  4322. }
  4323. .upload-content {
  4324. display: flex;
  4325. align-items: center;
  4326. gap: 8px;
  4327. }
  4328. .upload-icon {
  4329. font-size: 18px;
  4330. }
  4331. .upload-text {
  4332. font-size: 14px;
  4333. font-weight: 600;
  4334. color: var(--text-1);
  4335. }
  4336. .upload-hint {
  4337. display: block;
  4338. font-size: 11px;
  4339. color: var(--text-3);
  4340. margin-top: 8px;
  4341. text-align: center;
  4342. }
  4343. }
  4344. .file-list {
  4345. margin-bottom: 16px;
  4346. display: flex;
  4347. flex-direction: column;
  4348. gap: 10px;
  4349. }
  4350. // ==========================================
  4351. // 文件项 - V2 风格
  4352. // ==========================================
  4353. .file-item {
  4354. display: flex;
  4355. align-items: center;
  4356. gap: 10px;
  4357. padding: 12px;
  4358. background: var(--white);
  4359. border: 1px solid var(--border);
  4360. border-radius: var(--radius-md);
  4361. cursor: pointer;
  4362. transition: all 0.2s;
  4363. position: relative;
  4364. &:hover {
  4365. border-color: var(--primary);
  4366. background: var(--primary-light);
  4367. }
  4368. &.active {
  4369. border-color: var(--primary);
  4370. background: var(--primary-light);
  4371. }
  4372. .file-icon {
  4373. width: 40px;
  4374. height: 40px;
  4375. border-radius: var(--radius-sm);
  4376. display: flex;
  4377. align-items: center;
  4378. justify-content: center;
  4379. color: #fff;
  4380. font-weight: 700;
  4381. font-size: 13px;
  4382. flex-shrink: 0;
  4383. &.pdf { background: #ff6b6b; }
  4384. &.docx, &.doc { background: #4dabf7; }
  4385. &.xlsx, &.xls { background: #73d13d; }
  4386. &.md { background: #9254de; }
  4387. &.default { background: var(--text-3); }
  4388. }
  4389. .file-info {
  4390. flex: 1;
  4391. min-width: 0;
  4392. display: flex;
  4393. flex-direction: column;
  4394. .file-name {
  4395. font-size: 13px;
  4396. font-weight: 600;
  4397. color: var(--text-1);
  4398. white-space: nowrap;
  4399. overflow: hidden;
  4400. text-overflow: ellipsis;
  4401. }
  4402. .file-meta {
  4403. font-size: 11px;
  4404. color: var(--text-3);
  4405. margin-top: 4px;
  4406. .required {
  4407. color: var(--danger);
  4408. }
  4409. }
  4410. }
  4411. .file-status {
  4412. font-size: 11px;
  4413. white-space: nowrap;
  4414. &.parsing { color: var(--primary); }
  4415. &.done { color: var(--success); }
  4416. }
  4417. }
  4418. .add-source-btn {
  4419. width: 100%;
  4420. border-radius: var(--radius-md);
  4421. }
  4422. // ==========================================
  4423. // 附件面板 - V2 风格
  4424. // ==========================================
  4425. .att-header {
  4426. display: flex;
  4427. align-items: center;
  4428. justify-content: space-between;
  4429. margin-bottom: 12px;
  4430. .att-count {
  4431. font-size: 13px;
  4432. color: var(--text-2);
  4433. font-weight: 500;
  4434. }
  4435. }
  4436. .att-list {
  4437. display: flex;
  4438. flex-direction: column;
  4439. gap: 2px;
  4440. }
  4441. .att-item {
  4442. display: flex;
  4443. align-items: center;
  4444. gap: 12px;
  4445. padding: 10px 12px;
  4446. border-radius: var(--radius-md);
  4447. cursor: pointer;
  4448. transition: all 0.15s;
  4449. position: relative;
  4450. &:hover {
  4451. background: #f5f7fa;
  4452. .att-more-btn { opacity: 1; }
  4453. .att-parse-btn { opacity: 1; }
  4454. }
  4455. &.active {
  4456. background: var(--primary-light);
  4457. }
  4458. .att-icon {
  4459. width: 36px;
  4460. height: 36px;
  4461. border-radius: 8px;
  4462. display: flex;
  4463. align-items: center;
  4464. justify-content: center;
  4465. color: #fff;
  4466. font-weight: 700;
  4467. font-size: 11px;
  4468. flex-shrink: 0;
  4469. letter-spacing: -0.5px;
  4470. &.type-pdf { background: #e74c3c; }
  4471. &.type-word { background: #3b82f6; }
  4472. &.type-excel { background: #22c55e; }
  4473. &.type-image { background: #f59e0b; font-size: 16px; }
  4474. &.type-archive { background: #8b5cf6; }
  4475. &.type-other { background: #94a3b8; }
  4476. }
  4477. .att-info {
  4478. flex: 1;
  4479. min-width: 0;
  4480. .att-name {
  4481. font-size: 13px;
  4482. font-weight: 500;
  4483. color: var(--text-1);
  4484. white-space: nowrap;
  4485. overflow: hidden;
  4486. text-overflow: ellipsis;
  4487. line-height: 1.4;
  4488. }
  4489. .att-meta {
  4490. display: flex;
  4491. align-items: center;
  4492. gap: 6px;
  4493. margin-top: 2px;
  4494. font-size: 11px;
  4495. color: var(--text-3);
  4496. .att-type {
  4497. color: var(--primary);
  4498. font-weight: 500;
  4499. }
  4500. .att-size {
  4501. &::before {
  4502. content: '·';
  4503. margin-right: 6px;
  4504. color: var(--text-3);
  4505. }
  4506. }
  4507. }
  4508. }
  4509. .att-parse-btn {
  4510. flex-shrink: 0;
  4511. font-size: 12px;
  4512. padding: 2px 8px;
  4513. }
  4514. .att-more-btn {
  4515. opacity: 0;
  4516. transition: opacity 0.15s;
  4517. flex-shrink: 0;
  4518. }
  4519. .att-parse-status {
  4520. display: inline-flex;
  4521. align-items: center;
  4522. gap: 3px;
  4523. font-size: 11px;
  4524. margin-left: 4px;
  4525. &.parsing {
  4526. color: #e6a23c;
  4527. }
  4528. &.completed {
  4529. color: #67c23a;
  4530. }
  4531. &.failed {
  4532. color: #f56c6c;
  4533. }
  4534. }
  4535. }
  4536. // ==========================================
  4537. // 中间面板 - V2 风格
  4538. // ==========================================
  4539. .center-panel {
  4540. flex: 1;
  4541. display: flex;
  4542. flex-direction: column;
  4543. background: var(--white);
  4544. overflow: hidden;
  4545. border-radius: var(--radius-md);
  4546. margin: 0 8px;
  4547. box-shadow: var(--shadow-sm);
  4548. // ==========================================
  4549. // 欢迎页 - V2 风格
  4550. // ==========================================
  4551. .welcome-page {
  4552. flex: 1;
  4553. display: flex;
  4554. align-items: center;
  4555. justify-content: center;
  4556. background: var(--white);
  4557. .welcome-content {
  4558. text-align: center;
  4559. max-width: 600px;
  4560. padding: 48px;
  4561. }
  4562. .welcome-logo {
  4563. width: 80px;
  4564. height: 80px;
  4565. margin: 0 auto 32px;
  4566. background: linear-gradient(135deg, var(--primary) 0%, #69c0ff 100%);
  4567. border-radius: 16px;
  4568. display: flex;
  4569. align-items: center;
  4570. justify-content: center;
  4571. font-size: 40px;
  4572. font-weight: 700;
  4573. color: white;
  4574. box-shadow: 0 12px 32px rgba(24, 144, 255, 0.3);
  4575. }
  4576. .welcome {
  4577. h1 {
  4578. font-size: 28px;
  4579. font-weight: 700;
  4580. color: var(--text-1);
  4581. margin-bottom: 12px;
  4582. line-height: 1.4;
  4583. span {
  4584. display: block;
  4585. font-size: 20px;
  4586. font-weight: 500;
  4587. background: var(--ai-gradient);
  4588. background-clip: text;
  4589. -webkit-background-clip: text;
  4590. -webkit-text-fill-color: transparent;
  4591. margin-top: 8px;
  4592. }
  4593. }
  4594. p {
  4595. font-size: 15px;
  4596. color: var(--text-3);
  4597. line-height: 1.6;
  4598. }
  4599. .welcome-version {
  4600. margin-top: 24px;
  4601. font-size: 12px;
  4602. color: var(--text-4, #bbb);
  4603. }
  4604. }
  4605. }
  4606. // ==========================================
  4607. // 编辑器标题栏 - 参考设计风格
  4608. // ==========================================
  4609. .editor-title-bar {
  4610. padding: 0 20px;
  4611. height: 54px;
  4612. border-bottom: 1.5px solid #e8ecf0;
  4613. display: flex;
  4614. align-items: center;
  4615. justify-content: space-between;
  4616. background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
  4617. flex-shrink: 0;
  4618. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
  4619. .titlebar-left {
  4620. display: flex;
  4621. align-items: center;
  4622. gap: 8px;
  4623. min-width: 0;
  4624. flex: 1;
  4625. .titlebar-folder-icon {
  4626. font-size: 20px;
  4627. color: #909399;
  4628. flex-shrink: 0;
  4629. }
  4630. .titlebar-sep {
  4631. color: #d0d7de;
  4632. font-size: 16px;
  4633. flex-shrink: 0;
  4634. font-weight: 300;
  4635. }
  4636. .titlebar-project-name {
  4637. font-size: 15px;
  4638. font-weight: 700;
  4639. color: #1f2937;
  4640. white-space: nowrap;
  4641. overflow: hidden;
  4642. text-overflow: ellipsis;
  4643. max-width: 360px;
  4644. letter-spacing: 0.2px;
  4645. }
  4646. .titlebar-status-tag {
  4647. flex-shrink: 0;
  4648. margin-left: 6px;
  4649. border-radius: 6px;
  4650. font-size: 11px;
  4651. font-weight: 600;
  4652. padding: 3px 10px;
  4653. background: linear-gradient(135deg, #e6f0ff 0%, #d9e9ff 100%);
  4654. color: #409eff;
  4655. border: 1px solid #d9e9ff;
  4656. }
  4657. }
  4658. .titlebar-right {
  4659. display: flex;
  4660. align-items: center;
  4661. gap: 6px;
  4662. flex-shrink: 0;
  4663. .titlebar-save-status {
  4664. display: flex;
  4665. align-items: center;
  4666. gap: 6px;
  4667. font-size: 12px;
  4668. color: #606266;
  4669. white-space: nowrap;
  4670. margin-right: 6px;
  4671. padding: 6px 12px;
  4672. border-radius: 8px;
  4673. background: #f5f7fa;
  4674. font-weight: 500;
  4675. .save-dot {
  4676. width: 7px;
  4677. height: 7px;
  4678. border-radius: 50%;
  4679. background: #d0d7de;
  4680. box-shadow: 0 0 0 2px rgba(208, 215, 222, 0.2);
  4681. &.saved {
  4682. background: #52c41a;
  4683. box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.2);
  4684. }
  4685. }
  4686. }
  4687. :deep(.el-button.is-circle) {
  4688. width: 36px;
  4689. height: 36px;
  4690. border-radius: 10px;
  4691. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  4692. border: 1.5px solid transparent;
  4693. &:hover {
  4694. background: linear-gradient(135deg, #f0f7ff 0%, #e6f0ff 100%);
  4695. border-color: #d9e9ff;
  4696. transform: translateY(-1px);
  4697. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.08);
  4698. }
  4699. }
  4700. :deep(.el-icon) {
  4701. font-size: 18px;
  4702. }
  4703. :deep(.el-button.is-active-view) {
  4704. color: white;
  4705. background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
  4706. border-color: #409eff;
  4707. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
  4708. &:hover {
  4709. background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
  4710. transform: translateY(-1px);
  4711. box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
  4712. }
  4713. }
  4714. :deep(.el-divider--vertical) {
  4715. height: 24px;
  4716. margin: 0 6px;
  4717. background: #e8ecf0;
  4718. }
  4719. }
  4720. }
  4721. // ==========================================
  4722. // 编辑器滚动区 - V2 风格
  4723. // ==========================================
  4724. .editor-scroll {
  4725. flex: 1;
  4726. overflow-y: auto;
  4727. padding: 40px 48px;
  4728. background: var(--white);
  4729. position: relative;
  4730. }
  4731. .editor-content {
  4732. max-width: 1000px;
  4733. margin: 0 auto;
  4734. outline: none;
  4735. // 文档块样式
  4736. :deep(.doc-block) {
  4737. position: relative;
  4738. transition: background-color 0.2s;
  4739. &:hover {
  4740. background-color: rgba(24, 144, 255, 0.02);
  4741. }
  4742. // 被选中时的样式
  4743. &.selected {
  4744. background-color: rgba(24, 144, 255, 0.08);
  4745. outline: 1px dashed var(--primary);
  4746. }
  4747. }
  4748. :deep(h1) {
  4749. font-size: 24px;
  4750. font-weight: 700;
  4751. margin-bottom: 24px;
  4752. }
  4753. :deep(h2) {
  4754. font-size: 18px;
  4755. font-weight: 600;
  4756. margin: 28px 0 16px;
  4757. }
  4758. :deep(p) {
  4759. margin-bottom: 12px;
  4760. line-height: 1.6;
  4761. }
  4762. :deep(ul) {
  4763. margin-bottom: 16px;
  4764. padding-left: 24px;
  4765. li {
  4766. margin-bottom: 8px;
  4767. }
  4768. }
  4769. // 目录样式
  4770. :deep(.doc-toc-title) {
  4771. font-size: 18pt;
  4772. font-weight: bold;
  4773. text-align: center;
  4774. margin: 20px 0 16px;
  4775. }
  4776. :deep(.doc-toc-item) {
  4777. display: flex;
  4778. align-items: baseline;
  4779. padding: 6px 0;
  4780. line-height: 1.6;
  4781. cursor: pointer;
  4782. transition: background-color 0.2s;
  4783. &:hover {
  4784. background-color: #f5f5f5;
  4785. }
  4786. .toc-title {
  4787. flex-shrink: 0;
  4788. white-space: nowrap;
  4789. }
  4790. .toc-dots {
  4791. flex: 1;
  4792. border-bottom: 1px dotted #999;
  4793. margin: 0 8px;
  4794. min-width: 20px;
  4795. height: 0.6em;
  4796. }
  4797. .toc-page {
  4798. flex-shrink: 0;
  4799. color: #666;
  4800. min-width: 20px;
  4801. text-align: right;
  4802. }
  4803. }
  4804. // 表格样式
  4805. :deep(.doc-table-container) {
  4806. margin: 16px 0;
  4807. overflow-x: auto;
  4808. }
  4809. :deep(.doc-table) {
  4810. width: 100%;
  4811. border-collapse: collapse;
  4812. font-size: 14px;
  4813. th, td {
  4814. border: 1px solid #ddd;
  4815. padding: 8px 12px;
  4816. text-align: left;
  4817. vertical-align: top;
  4818. line-height: 1.5;
  4819. }
  4820. th {
  4821. background-color: #f5f5f5;
  4822. font-weight: bold;
  4823. }
  4824. tr:nth-child(even) td {
  4825. background-color: #fafafa;
  4826. }
  4827. tr:hover td {
  4828. background-color: #f0f7ff;
  4829. }
  4830. }
  4831. :deep(.doc-table-empty) {
  4832. padding: 20px;
  4833. text-align: center;
  4834. color: #999;
  4835. border: 1px dashed #ddd;
  4836. margin: 16px 0;
  4837. }
  4838. // 列表项样式
  4839. :deep(.doc-list-item) {
  4840. position: relative;
  4841. margin-bottom: 8px;
  4842. line-height: 1.6;
  4843. &.bullet {
  4844. padding-left: 1.5em;
  4845. &::before {
  4846. content: '•';
  4847. position: absolute;
  4848. left: 0;
  4849. }
  4850. }
  4851. &.ordered {
  4852. padding-left: 2em;
  4853. counter-increment: doc-list;
  4854. &::before {
  4855. content: counter(doc-list) '.';
  4856. position: absolute;
  4857. left: 0;
  4858. }
  4859. }
  4860. }
  4861. // 重置列表计数器
  4862. :deep(p + .doc-list-item.ordered:first-of-type),
  4863. :deep(.doc-list-item.bullet + .doc-list-item.ordered) {
  4864. counter-reset: doc-list;
  4865. }
  4866. // 块引用样式
  4867. :deep(blockquote) {
  4868. margin: 16px 0;
  4869. padding: 12px 20px;
  4870. border-left: 4px solid #ddd;
  4871. background: #f9f9f9;
  4872. color: #666;
  4873. }
  4874. // 代码块样式
  4875. :deep(pre) {
  4876. margin: 16px 0;
  4877. padding: 16px;
  4878. background: #f5f5f5;
  4879. border-radius: 4px;
  4880. overflow-x: auto;
  4881. code {
  4882. font-family: 'Consolas', 'Monaco', monospace;
  4883. font-size: 13px;
  4884. }
  4885. }
  4886. // 实体高亮样式 - 匹配原型 UI(边框 + 背景)
  4887. :deep(.entity-highlight) {
  4888. display: inline;
  4889. padding: 2px 8px;
  4890. border-radius: 4px;
  4891. cursor: pointer;
  4892. transition: all 0.2s;
  4893. font-weight: 500;
  4894. border: 1px solid #1890ff;
  4895. color: #1890ff;
  4896. background: rgba(24, 144, 255, 0.1);
  4897. &:hover {
  4898. background: #1890ff;
  4899. color: white;
  4900. }
  4901. // 实体类型颜色
  4902. &.entity {
  4903. border-color: #1890ff;
  4904. color: #1890ff;
  4905. background: rgba(24, 144, 255, 0.1);
  4906. &:hover { background: #1890ff; color: white; }
  4907. }
  4908. &.concept {
  4909. border-color: #722ed1;
  4910. color: #722ed1;
  4911. background: rgba(114, 46, 209, 0.1);
  4912. &:hover { background: #722ed1; color: white; }
  4913. }
  4914. &.data {
  4915. border-color: #52c41a;
  4916. color: #52c41a;
  4917. background: rgba(82, 196, 26, 0.1);
  4918. &:hover { background: #52c41a; color: white; }
  4919. }
  4920. &.location {
  4921. border-color: #faad14;
  4922. color: #d48806;
  4923. background: rgba(250, 173, 20, 0.1);
  4924. &:hover { background: #faad14; color: white; }
  4925. }
  4926. &.asset {
  4927. border-color: #eb2f96;
  4928. color: #eb2f96;
  4929. background: rgba(235, 47, 150, 0.1);
  4930. &:hover { background: #eb2f96; color: white; }
  4931. }
  4932. &.person {
  4933. border-color: #1890ff;
  4934. color: #1890ff;
  4935. background: rgba(24, 144, 255, 0.1);
  4936. &:hover { background: #1890ff; color: white; }
  4937. }
  4938. &.org {
  4939. border-color: #722ed1;
  4940. color: #722ed1;
  4941. background: rgba(114, 46, 209, 0.1);
  4942. &:hover { background: #722ed1; color: white; }
  4943. }
  4944. &.date {
  4945. border-color: #13c2c2;
  4946. color: #13c2c2;
  4947. background: rgba(19, 194, 194, 0.1);
  4948. &:hover { background: #13c2c2; color: white; }
  4949. }
  4950. &.product {
  4951. border-color: #eb2f96;
  4952. color: #eb2f96;
  4953. background: rgba(235, 47, 150, 0.1);
  4954. &:hover { background: #eb2f96; color: white; }
  4955. }
  4956. &.event {
  4957. border-color: #fa8c16;
  4958. color: #fa8c16;
  4959. background: rgba(250, 140, 22, 0.1);
  4960. &:hover { background: #fa8c16; color: white; }
  4961. }
  4962. &.law {
  4963. border-color: #2f54eb;
  4964. color: #2f54eb;
  4965. background: rgba(47, 84, 235, 0.1);
  4966. &:hover { background: #2f54eb; color: white; }
  4967. }
  4968. // 未确认的 AI 建议(文档中虚线样式)
  4969. &.ai-suggestion-pending {
  4970. border-style: dashed;
  4971. opacity: 0.9;
  4972. }
  4973. // 点击 AI 建议后,文档中该要素的「待确认」高亮
  4974. &.entity-pending-confirm {
  4975. box-shadow: 0 0 0 2px #1890ff;
  4976. opacity: 1;
  4977. }
  4978. }
  4979. }
  4980. }
  4981. // ==========================================
  4982. // 右侧面板 - 参考设计风格
  4983. // ==========================================
  4984. .right-panel {
  4985. background: var(--white);
  4986. border-left: 1px solid var(--border);
  4987. display: flex;
  4988. flex-direction: column;
  4989. flex-shrink: 0;
  4990. min-width: 280px;
  4991. max-width: 420px;
  4992. overflow: hidden;
  4993. // ---- 报告要素区 ----
  4994. .rp-elements {
  4995. flex-shrink: 0;
  4996. display: flex;
  4997. flex-direction: column;
  4998. max-height: 45%;
  4999. border-bottom: 1px solid var(--border);
  5000. }
  5001. .rp-elements-header {
  5002. display: flex;
  5003. align-items: center;
  5004. justify-content: space-between;
  5005. padding: 12px 14px 8px;
  5006. flex-shrink: 0;
  5007. .rp-elements-title {
  5008. display: flex;
  5009. align-items: center;
  5010. gap: 6px;
  5011. .rp-title-icon { font-size: 15px; }
  5012. .rp-title-text { font-size: 13px; font-weight: 600; color: var(--text-1); }
  5013. .rp-title-count {
  5014. font-size: 11px;
  5015. color: #fff;
  5016. background: var(--el-color-primary);
  5017. padding: 1px 6px;
  5018. border-radius: 10px;
  5019. font-weight: 500;
  5020. }
  5021. }
  5022. .rp-header-actions {
  5023. display: flex;
  5024. align-items: center;
  5025. gap: 2px;
  5026. }
  5027. }
  5028. .rp-elements-body {
  5029. flex: 1;
  5030. overflow-y: auto;
  5031. padding: 0 8px 12px;
  5032. }
  5033. .rp-element-list {
  5034. display: flex;
  5035. flex-direction: column;
  5036. gap: 4px;
  5037. }
  5038. .rp-element-group {
  5039. .rp-group-header {
  5040. display: flex;
  5041. align-items: center;
  5042. gap: 6px;
  5043. padding: 6px 8px;
  5044. cursor: pointer;
  5045. border-radius: 4px;
  5046. transition: background 0.15s;
  5047. &:hover { background: var(--bg-2); }
  5048. .rp-group-icon {
  5049. font-size: 10px;
  5050. color: var(--text-3);
  5051. width: 12px;
  5052. }
  5053. .rp-group-name {
  5054. font-size: 12px;
  5055. font-weight: 600;
  5056. color: var(--text-2);
  5057. flex: 1;
  5058. }
  5059. .rp-group-count {
  5060. font-size: 10px;
  5061. color: var(--text-3);
  5062. background: var(--bg-2);
  5063. padding: 1px 5px;
  5064. border-radius: 8px;
  5065. }
  5066. }
  5067. .rp-group-items {
  5068. padding-left: 12px;
  5069. }
  5070. }
  5071. .rp-element-item {
  5072. display: flex;
  5073. align-items: center;
  5074. gap: 6px;
  5075. padding: 5px 8px;
  5076. border-radius: 4px;
  5077. cursor: pointer;
  5078. transition: all 0.15s;
  5079. border-left: 2px solid transparent;
  5080. &:hover {
  5081. background: var(--bg-2);
  5082. }
  5083. &.is-active {
  5084. background: #e6f4ff;
  5085. border-left-color: var(--el-color-primary);
  5086. }
  5087. &.has-value {
  5088. .rp-item-name { color: var(--text-1); }
  5089. }
  5090. .rp-item-type {
  5091. font-size: 9px;
  5092. font-weight: 600;
  5093. width: 14px;
  5094. height: 14px;
  5095. display: flex;
  5096. align-items: center;
  5097. justify-content: center;
  5098. border-radius: 3px;
  5099. flex-shrink: 0;
  5100. }
  5101. &.is-text .rp-item-type {
  5102. background: #e6f7ff;
  5103. color: #1890ff;
  5104. }
  5105. &.is-paragraph .rp-item-type {
  5106. background: #f6ffed;
  5107. color: #52c41a;
  5108. }
  5109. &.is-table .rp-item-type {
  5110. background: #fff7e6;
  5111. color: #fa8c16;
  5112. }
  5113. .rp-item-name {
  5114. font-size: 12px;
  5115. color: var(--text-3);
  5116. flex: 1;
  5117. overflow: hidden;
  5118. text-overflow: ellipsis;
  5119. white-space: nowrap;
  5120. }
  5121. .rp-item-preview {
  5122. font-size: 11px;
  5123. color: var(--text-3);
  5124. max-width: 80px;
  5125. overflow: hidden;
  5126. text-overflow: ellipsis;
  5127. white-space: nowrap;
  5128. opacity: 0.7;
  5129. }
  5130. }
  5131. .rp-elements-empty {
  5132. padding: 20px;
  5133. text-align: center;
  5134. color: var(--text-3);
  5135. font-size: 12px;
  5136. }
  5137. // ---- AI 助手区 ----
  5138. .rp-ai {
  5139. flex: 1;
  5140. display: flex;
  5141. flex-direction: column;
  5142. min-height: 0;
  5143. }
  5144. .rp-ai-header {
  5145. display: flex;
  5146. align-items: center;
  5147. justify-content: space-between;
  5148. padding: 12px 16px 8px;
  5149. flex-shrink: 0;
  5150. .rp-ai-title {
  5151. display: flex;
  5152. align-items: center;
  5153. gap: 6px;
  5154. font-size: 14px;
  5155. font-weight: 700;
  5156. color: var(--text-1);
  5157. .rp-ai-icon { font-size: 16px; }
  5158. }
  5159. .rp-ai-actions {
  5160. display: flex;
  5161. gap: 4px;
  5162. }
  5163. }
  5164. .rp-ai-messages {
  5165. flex: 1;
  5166. overflow-y: auto;
  5167. padding: 8px 16px;
  5168. display: flex;
  5169. flex-direction: column;
  5170. gap: 12px;
  5171. }
  5172. .ai-message {
  5173. display: flex;
  5174. &.ai-user {
  5175. justify-content: flex-end;
  5176. .ai-bubble {
  5177. background: var(--primary);
  5178. color: #fff;
  5179. border-radius: 16px 16px 4px 16px;
  5180. }
  5181. }
  5182. &.ai-bot {
  5183. justify-content: flex-start;
  5184. .ai-bubble {
  5185. background: #f4f5f7;
  5186. color: var(--text-1);
  5187. border-radius: 16px 16px 16px 4px;
  5188. }
  5189. }
  5190. }
  5191. .ai-bubble {
  5192. max-width: 85%;
  5193. padding: 10px 14px;
  5194. font-size: 13px;
  5195. line-height: 1.6;
  5196. word-break: break-word;
  5197. }
  5198. .rp-ai-input {
  5199. flex-shrink: 0;
  5200. padding: 10px 14px 12px;
  5201. border-top: 1px solid var(--border);
  5202. :deep(.el-textarea__inner) {
  5203. border-radius: 12px;
  5204. background: #f7f8fa;
  5205. border: 1px solid var(--border);
  5206. padding: 10px 14px;
  5207. font-size: 13px;
  5208. line-height: 1.5;
  5209. &:focus {
  5210. border-color: var(--primary);
  5211. background: var(--white);
  5212. }
  5213. }
  5214. }
  5215. .rp-ai-input-actions {
  5216. display: flex;
  5217. align-items: center;
  5218. justify-content: space-between;
  5219. margin-top: 6px;
  5220. padding: 0 2px;
  5221. :deep(.el-button.is-circle) {
  5222. width: 32px;
  5223. height: 32px;
  5224. font-size: 18px;
  5225. }
  5226. :deep(.el-button--primary.is-circle) {
  5227. width: 34px;
  5228. height: 34px;
  5229. }
  5230. :deep(.el-icon) {
  5231. font-size: 18px;
  5232. }
  5233. :deep(.el-icon svg) {
  5234. width: 18px;
  5235. height: 18px;
  5236. }
  5237. }
  5238. .rp-ai-input-tools,
  5239. .rp-ai-input-right {
  5240. display: flex;
  5241. align-items: center;
  5242. gap: 4px;
  5243. }
  5244. }
  5245. // ==========================================
  5246. // 要素管理区 - V2 风格
  5247. // ==========================================
  5248. .element-section {
  5249. padding: 16px;
  5250. border-bottom: 1px dashed var(--border);
  5251. // 模块标题样式 - V2 风格
  5252. .module-title {
  5253. display: flex;
  5254. align-items: center;
  5255. gap: 10px;
  5256. font-size: 15px;
  5257. font-weight: 700;
  5258. color: var(--text-1);
  5259. margin-bottom: 14px;
  5260. .module-icon {
  5261. width: 36px;
  5262. height: 36px;
  5263. border-radius: 8px;
  5264. background: var(--primary-gradient);
  5265. display: flex;
  5266. align-items: center;
  5267. justify-content: center;
  5268. font-size: 18px;
  5269. color: white;
  5270. box-shadow: var(--shadow-md);
  5271. }
  5272. }
  5273. .element-header {
  5274. display: flex;
  5275. align-items: center;
  5276. justify-content: space-between;
  5277. margin-bottom: 12px;
  5278. .element-title {
  5279. font-size: 13px;
  5280. font-weight: 600;
  5281. display: flex;
  5282. align-items: center;
  5283. gap: 6px;
  5284. .element-count {
  5285. font-size: 11px;
  5286. color: var(--text-3);
  5287. font-weight: normal;
  5288. }
  5289. }
  5290. .header-actions {
  5291. display: flex;
  5292. gap: 4px;
  5293. .el-button {
  5294. padding: 4px 8px;
  5295. font-size: 12px;
  5296. }
  5297. }
  5298. }
  5299. // AI 建议区块特殊样式
  5300. &.ai-section {
  5301. background: var(--bg);
  5302. border-bottom: none;
  5303. .element-header {
  5304. .element-title {
  5305. color: var(--text-2);
  5306. }
  5307. }
  5308. .element-option.ai-highlight-option {
  5309. display: flex;
  5310. align-items: center;
  5311. gap: 8px;
  5312. padding: 8px 16px;
  5313. font-size: 12px;
  5314. color: var(--text-2);
  5315. .option-label {
  5316. flex: 1;
  5317. }
  5318. }
  5319. .element-tags-wrap {
  5320. max-height: 300px;
  5321. }
  5322. }
  5323. // 要素 Tab 切换 - V2 风格
  5324. .element-tabs {
  5325. display: flex;
  5326. gap: 8px;
  5327. .element-tab {
  5328. padding: 6px 12px;
  5329. border-radius: 12px;
  5330. background: transparent;
  5331. border: 1px solid transparent;
  5332. font-size: 13px;
  5333. cursor: pointer;
  5334. color: var(--text-2);
  5335. transition: all 0.2s;
  5336. &:hover {
  5337. background: var(--bg);
  5338. }
  5339. &.active {
  5340. background: var(--primary);
  5341. color: #fff;
  5342. border-color: rgba(0, 0, 0, 0.04);
  5343. box-shadow: var(--shadow-md);
  5344. }
  5345. }
  5346. }
  5347. .element-filter {
  5348. padding: 0 0 12px;
  5349. .entity-search {
  5350. margin-bottom: 12px;
  5351. :deep(.el-input__wrapper) {
  5352. border-radius: 18px;
  5353. background: var(--bg);
  5354. box-shadow: none;
  5355. border: 1px solid var(--border);
  5356. &:hover, &.is-focus {
  5357. border-color: var(--primary);
  5358. background: var(--white);
  5359. }
  5360. }
  5361. }
  5362. .entity-type-filter {
  5363. display: flex;
  5364. flex-wrap: wrap;
  5365. gap: 6px;
  5366. .filter-tag {
  5367. cursor: pointer;
  5368. transition: all 0.2s;
  5369. border-radius: 12px;
  5370. font-size: 11px;
  5371. &:hover {
  5372. border-color: var(--primary);
  5373. color: var(--primary);
  5374. }
  5375. &.active {
  5376. background: var(--primary);
  5377. color: white;
  5378. border-color: var(--primary);
  5379. }
  5380. &.clear {
  5381. background: transparent;
  5382. border-style: dashed;
  5383. color: var(--text-3);
  5384. &:hover {
  5385. border-color: var(--danger);
  5386. color: var(--danger);
  5387. }
  5388. }
  5389. }
  5390. }
  5391. }
  5392. .element-body {
  5393. padding: 0;
  5394. display: flex;
  5395. flex-direction: column;
  5396. gap: 16px;
  5397. }
  5398. // 要素标签容器 - V2 风格
  5399. .element-tags-wrap {
  5400. display: flex;
  5401. flex-wrap: wrap;
  5402. gap: 8px;
  5403. max-height: 200px;
  5404. overflow-y: auto;
  5405. padding-right: 4px;
  5406. padding-bottom: 16px;
  5407. &::-webkit-scrollbar {
  5408. width: 4px;
  5409. }
  5410. &::-webkit-scrollbar-track {
  5411. background: var(--bg);
  5412. border-radius: 2px;
  5413. }
  5414. &::-webkit-scrollbar-thumb {
  5415. background: var(--border);
  5416. border-radius: 2px;
  5417. &:hover {
  5418. background: var(--text-3);
  5419. }
  5420. }
  5421. }
  5422. // ==========================================
  5423. // 要素标签样式 - V2 风格
  5424. // ==========================================
  5425. .var-tag {
  5426. height: 28px;
  5427. display: inline-flex;
  5428. align-items: center;
  5429. gap: 6px;
  5430. padding: 0 12px;
  5431. border-radius: 2px;
  5432. font-size: 12px;
  5433. cursor: pointer;
  5434. transition: all 0.2s;
  5435. background: var(--bg);
  5436. border: 1px solid var(--border);
  5437. user-select: none;
  5438. &:hover {
  5439. border-color: var(--primary);
  5440. background: var(--primary-light);
  5441. transform: translateY(-1px);
  5442. }
  5443. &:active {
  5444. cursor: grabbing;
  5445. }
  5446. .tag-icon {
  5447. font-size: 12px;
  5448. }
  5449. .tag-name {
  5450. max-width: 120px;
  5451. overflow: hidden;
  5452. text-overflow: ellipsis;
  5453. white-space: nowrap;
  5454. font-weight: 500;
  5455. line-height: 28px;
  5456. }
  5457. .tag-status {
  5458. color: #52c41a;
  5459. font-size: 10px;
  5460. }
  5461. .tag-action {
  5462. color: var(--primary);
  5463. font-size: 14px;
  5464. font-weight: bold;
  5465. margin-left: 2px;
  5466. }
  5467. // 已确认的要素
  5468. &.confirmed {
  5469. background: var(--white);
  5470. border-color: var(--primary);
  5471. .tag-name {
  5472. color: var(--text-1);
  5473. }
  5474. }
  5475. // AI 建议的要素(虚线边框、淡色)
  5476. &.ai-suggestion {
  5477. background: transparent;
  5478. border-style: dashed;
  5479. border-color: var(--border);
  5480. opacity: 0.85;
  5481. .tag-name {
  5482. color: var(--text-2);
  5483. }
  5484. &:hover {
  5485. opacity: 1;
  5486. border-color: var(--primary);
  5487. border-style: solid;
  5488. background: var(--primary-light);
  5489. .tag-action {
  5490. transform: scale(1.2);
  5491. }
  5492. }
  5493. }
  5494. // 动态要素样式(圆角)
  5495. &.dynamic {
  5496. border-radius: 14px;
  5497. }
  5498. // 静态要素样式(微圆角)
  5499. &.static {
  5500. border-radius: 2px;
  5501. }
  5502. // 已确认状态
  5503. &.confirmed {
  5504. background: rgba(82, 196, 26, 0.1);
  5505. border-color: #52c41a;
  5506. .tag-name {
  5507. color: #389e0d;
  5508. }
  5509. }
  5510. // 实体类型样式 - 左边框颜色区分
  5511. &.entity-person, &.entity {
  5512. border-left: 3px solid var(--primary);
  5513. }
  5514. &.entity-org, &.concept {
  5515. border-left: 3px solid #722ed1;
  5516. }
  5517. &.entity-location, &.location {
  5518. border-left: 3px solid var(--warning);
  5519. }
  5520. &.entity-date {
  5521. border-left: 3px solid #13c2c2;
  5522. }
  5523. &.entity-data, &.data {
  5524. border-left: 3px solid var(--success);
  5525. }
  5526. &.entity-product, &.asset {
  5527. border-left: 3px solid #eb2f96;
  5528. }
  5529. &.entity-event {
  5530. border-left: 3px solid #fa8c16;
  5531. }
  5532. &.entity-law {
  5533. border-left: 3px solid #2f54eb;
  5534. }
  5535. &.entity-default {
  5536. border-left: 3px solid #8c8c8c;
  5537. }
  5538. // 当前正在确认的 AI 建议 tag
  5539. &.is-pending {
  5540. border-color: var(--primary);
  5541. background: var(--primary-light);
  5542. border-style: solid;
  5543. }
  5544. }
  5545. // AI 建议确认栏已移至「+」按钮的悬浮框内,此处样式仅作保留注释
  5546. .element-hint {
  5547. font-size: 12px;
  5548. color: var(--text-3);
  5549. text-align: center;
  5550. padding: 24px;
  5551. }
  5552. }
  5553. // 实体高亮闪烁效果
  5554. @keyframes entity-flash {
  5555. 0%, 100% { background-color: inherit; }
  5556. 50% { background-color: #ffe58f; }
  5557. }
  5558. .entity-highlight-flash {
  5559. animation: entity-flash 0.5s ease-in-out 3;
  5560. }
  5561. // 实体编辑弹窗样式
  5562. .entity-edit-form {
  5563. .entity-edit-preview {
  5564. display: flex;
  5565. align-items: center;
  5566. justify-content: center;
  5567. gap: 10px;
  5568. padding: 16px;
  5569. background: var(--primary-light);
  5570. border: 1px dashed var(--primary);
  5571. border-radius: 8px;
  5572. margin-bottom: 20px;
  5573. .preview-icon {
  5574. font-size: 24px;
  5575. }
  5576. .preview-text {
  5577. font-size: 16px;
  5578. font-weight: 600;
  5579. color: var(--primary);
  5580. }
  5581. }
  5582. }
  5583. .category-section {
  5584. padding: 12px 16px;
  5585. border-bottom: 1px solid var(--border);
  5586. .category-header {
  5587. display: flex;
  5588. align-items: center;
  5589. gap: 8px;
  5590. font-size: 12px;
  5591. font-weight: 600;
  5592. margin-bottom: 10px;
  5593. .category-dot {
  5594. width: 10px;
  5595. height: 10px;
  5596. border-radius: 50%;
  5597. }
  5598. .category-count {
  5599. color: var(--text-3);
  5600. font-weight: normal;
  5601. background: var(--bg);
  5602. padding: 2px 8px;
  5603. border-radius: 10px;
  5604. }
  5605. }
  5606. .category-items {
  5607. .category-item {
  5608. display: flex;
  5609. justify-content: space-between;
  5610. padding: 8px 12px;
  5611. background: var(--bg);
  5612. border-radius: 6px;
  5613. margin-bottom: 6px;
  5614. cursor: pointer;
  5615. font-size: 12px;
  5616. transition: all 0.2s;
  5617. &:hover {
  5618. background: var(--primary-light);
  5619. }
  5620. .item-value {
  5621. color: var(--text-3);
  5622. }
  5623. }
  5624. }
  5625. }
  5626. // ==========================================
  5627. // 右键菜单 - V2 风格
  5628. // ==========================================
  5629. .context-menu {
  5630. position: fixed;
  5631. min-width: 180px;
  5632. background: var(--white);
  5633. border-radius: var(--radius-md);
  5634. box-shadow: var(--shadow-lg);
  5635. z-index: 3000;
  5636. overflow: hidden;
  5637. .context-menu-header {
  5638. padding: 12px 14px;
  5639. background: var(--bg);
  5640. border-bottom: 1px solid var(--border);
  5641. .selected-preview {
  5642. font-size: 12px;
  5643. color: var(--primary);
  5644. font-weight: 600;
  5645. max-width: 150px;
  5646. overflow: hidden;
  5647. text-overflow: ellipsis;
  5648. white-space: nowrap;
  5649. }
  5650. }
  5651. .context-menu-section {
  5652. padding: 8px 14px 4px;
  5653. font-size: 10px;
  5654. color: var(--text-3);
  5655. font-weight: 600;
  5656. text-transform: uppercase;
  5657. letter-spacing: 0.5px;
  5658. }
  5659. .context-menu-item {
  5660. display: flex;
  5661. align-items: center;
  5662. gap: 10px;
  5663. padding: 10px 14px;
  5664. font-size: 13px;
  5665. cursor: pointer;
  5666. transition: all 0.15s;
  5667. color: var(--text-1);
  5668. position: relative;
  5669. &:hover {
  5670. background: var(--primary-light);
  5671. color: var(--primary);
  5672. }
  5673. &[disabled="true"] {
  5674. opacity: 0.5;
  5675. pointer-events: none;
  5676. }
  5677. .icon {
  5678. font-size: 14px;
  5679. width: 20px;
  5680. text-align: center;
  5681. flex-shrink: 0;
  5682. }
  5683. .shortcut {
  5684. margin-left: auto;
  5685. font-size: 11px;
  5686. color: var(--text-3);
  5687. }
  5688. .submenu-arrow {
  5689. margin-left: auto;
  5690. font-size: 14px;
  5691. color: var(--text-3);
  5692. }
  5693. &.has-submenu {
  5694. &:hover .submenu-arrow {
  5695. color: var(--primary);
  5696. }
  5697. }
  5698. }
  5699. // 子菜单
  5700. .context-submenu {
  5701. position: absolute;
  5702. left: 100%;
  5703. top: 0;
  5704. min-width: 150px;
  5705. background: var(--white);
  5706. border-radius: var(--radius-md);
  5707. box-shadow: var(--shadow-lg);
  5708. overflow: hidden;
  5709. .context-menu-item {
  5710. padding: 8px 12px;
  5711. font-size: 12px;
  5712. gap: 8px;
  5713. .icon {
  5714. font-size: 12px;
  5715. width: 16px;
  5716. }
  5717. }
  5718. }
  5719. .context-menu-divider {
  5720. height: 1px;
  5721. background: var(--border);
  5722. margin: 4px 0;
  5723. }
  5724. .context-menu-loading {
  5725. display: flex;
  5726. align-items: center;
  5727. justify-content: center;
  5728. gap: 8px;
  5729. padding: 12px;
  5730. color: var(--primary);
  5731. font-size: 12px;
  5732. border-top: 1px solid var(--border);
  5733. background: var(--bg);
  5734. }
  5735. }
  5736. // ==========================================
  5737. // 实体弹出框样式 - V2 风格
  5738. // ==========================================
  5739. .entity-popover {
  5740. .entity-popover-header {
  5741. display: flex;
  5742. align-items: center;
  5743. justify-content: space-between;
  5744. margin-bottom: 10px;
  5745. .entity-text {
  5746. font-weight: 600;
  5747. font-size: 14px;
  5748. max-width: 140px;
  5749. overflow: hidden;
  5750. text-overflow: ellipsis;
  5751. white-space: nowrap;
  5752. color: var(--text-1);
  5753. }
  5754. }
  5755. .entity-popover-type {
  5756. font-size: 12px;
  5757. color: var(--text-2);
  5758. margin-bottom: 14px;
  5759. padding: 4px 8px;
  5760. background: var(--bg);
  5761. border-radius: 4px;
  5762. display: inline-block;
  5763. }
  5764. .entity-popover-actions {
  5765. display: flex;
  5766. gap: 8px;
  5767. flex-wrap: wrap;
  5768. :deep(.el-button) {
  5769. border-radius: var(--radius-sm);
  5770. }
  5771. }
  5772. }
  5773. // ==========================================
  5774. // 知识图谱容器 - V2 风格
  5775. // ==========================================
  5776. .graph-container {
  5777. height: 500px;
  5778. position: relative;
  5779. background: linear-gradient(45deg, #f8f9fa 25%, transparent 25%),
  5780. linear-gradient(-45deg, #f8f9fa 25%, transparent 25%),
  5781. linear-gradient(45deg, transparent 75%, #f8f9fa 75%),
  5782. linear-gradient(-45deg, transparent 75%, #f8f8f8 75%);
  5783. background-size: 20px 20px;
  5784. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  5785. border-radius: var(--radius-md);
  5786. .graph-legend {
  5787. position: absolute;
  5788. top: 16px;
  5789. left: 16px;
  5790. background: var(--white);
  5791. border-radius: var(--radius-md);
  5792. padding: 14px 18px;
  5793. box-shadow: var(--shadow-md);
  5794. .legend-title {
  5795. font-size: 12px;
  5796. font-weight: 600;
  5797. margin-bottom: 10px;
  5798. color: var(--text-1);
  5799. }
  5800. .legend-item {
  5801. display: flex;
  5802. align-items: center;
  5803. gap: 8px;
  5804. font-size: 12px;
  5805. color: var(--text-2);
  5806. margin-bottom: 6px;
  5807. &:last-child {
  5808. margin-bottom: 0;
  5809. }
  5810. }
  5811. .legend-dot {
  5812. width: 12px;
  5813. height: 12px;
  5814. border-radius: 50%;
  5815. &.core, &.entity { background: var(--primary); }
  5816. &.concept { background: #722ed1; }
  5817. &.data { background: var(--success); }
  5818. &.location { background: var(--warning); }
  5819. }
  5820. }
  5821. .graph-body {
  5822. height: 100%;
  5823. display: flex;
  5824. align-items: center;
  5825. justify-content: center;
  5826. .graph-placeholder {
  5827. text-align: center;
  5828. color: var(--text-3);
  5829. .placeholder-icon {
  5830. font-size: 48px;
  5831. margin-bottom: 16px;
  5832. opacity: 0.5;
  5833. }
  5834. p {
  5835. margin-top: 12px;
  5836. font-size: 14px;
  5837. }
  5838. }
  5839. }
  5840. }
  5841. // ==========================================
  5842. // 空白编辑器占位提示样式 - V2 风格
  5843. // ==========================================
  5844. :deep(.empty-editor-placeholder) {
  5845. display: flex;
  5846. flex-direction: column;
  5847. align-items: center;
  5848. justify-content: center;
  5849. padding: 80px 40px;
  5850. text-align: center;
  5851. min-height: 400px;
  5852. .empty-icon {
  5853. font-size: 64px;
  5854. margin-bottom: 24px;
  5855. opacity: 0.8;
  5856. }
  5857. h2 {
  5858. font-size: 24px;
  5859. font-weight: 600;
  5860. margin-bottom: 12px;
  5861. color: var(--text-1);
  5862. }
  5863. .empty-subtitle {
  5864. font-size: 15px;
  5865. color: var(--text-3);
  5866. margin-bottom: 32px;
  5867. }
  5868. .empty-actions {
  5869. display: flex;
  5870. flex-direction: column;
  5871. gap: 12px;
  5872. margin-bottom: 32px;
  5873. width: 100%;
  5874. max-width: 400px;
  5875. }
  5876. .action-card {
  5877. display: flex;
  5878. align-items: center;
  5879. gap: 12px;
  5880. padding: 16px 20px;
  5881. background: var(--bg);
  5882. border: 1px solid var(--border);
  5883. border-radius: var(--radius-md);
  5884. cursor: pointer;
  5885. transition: all 0.2s;
  5886. text-align: left;
  5887. &:hover {
  5888. border-color: var(--primary);
  5889. background: var(--primary-light);
  5890. transform: translateX(4px);
  5891. }
  5892. .action-icon {
  5893. font-size: 24px;
  5894. flex-shrink: 0;
  5895. }
  5896. .action-text {
  5897. font-size: 14px;
  5898. color: var(--text-1);
  5899. font-weight: 500;
  5900. }
  5901. }
  5902. .empty-hint {
  5903. font-size: 13px;
  5904. color: var(--text-3);
  5905. padding: 12px 20px;
  5906. background: var(--bg);
  5907. border-radius: var(--radius-md);
  5908. border-left: 3px solid var(--primary);
  5909. }
  5910. }
  5911. // 高亮块动画
  5912. .highlight-block {
  5913. animation: highlight-pulse 2s ease-out;
  5914. }
  5915. @keyframes highlight-pulse {
  5916. 0% {
  5917. background: rgba(24, 144, 255, 0.3);
  5918. box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.2);
  5919. }
  5920. 100% {
  5921. background: transparent;
  5922. box-shadow: none;
  5923. }
  5924. }
  5925. // ==========================================
  5926. // 报告要素管理弹窗样式
  5927. // ==========================================
  5928. .elements-modal {
  5929. :deep(.el-dialog__header) {
  5930. padding: 16px 20px;
  5931. border-bottom: 1px solid var(--border);
  5932. margin-right: 0;
  5933. }
  5934. :deep(.el-dialog__body) {
  5935. padding: 0;
  5936. }
  5937. :deep(.el-dialog__footer) {
  5938. padding: 12px 20px;
  5939. border-top: 1px solid var(--border);
  5940. }
  5941. }
  5942. .elements-modal-content {
  5943. .elements-search {
  5944. display: flex;
  5945. align-items: center;
  5946. gap: 16px;
  5947. padding: 16px 20px;
  5948. border-bottom: 1px solid var(--border);
  5949. background: var(--bg);
  5950. .el-input {
  5951. max-width: 300px;
  5952. }
  5953. }
  5954. .elements-table-wrap {
  5955. padding: 0;
  5956. :deep(.el-table) {
  5957. .element-name {
  5958. font-weight: 500;
  5959. color: var(--text-1);
  5960. }
  5961. .element-desc {
  5962. color: var(--text-3);
  5963. font-size: 12px;
  5964. }
  5965. .original-value {
  5966. color: var(--text-2);
  5967. font-size: 12px;
  5968. }
  5969. .element-source {
  5970. color: var(--primary);
  5971. font-size: 12px;
  5972. }
  5973. .el-input__wrapper {
  5974. box-shadow: none;
  5975. background: var(--bg);
  5976. border-radius: var(--radius-sm);
  5977. &:hover, &.is-focus {
  5978. background: var(--white);
  5979. box-shadow: 0 0 0 1px var(--primary);
  5980. }
  5981. }
  5982. }
  5983. }
  5984. .elements-pagination {
  5985. display: flex;
  5986. justify-content: flex-end;
  5987. padding: 12px 20px;
  5988. border-top: 1px solid var(--border);
  5989. }
  5990. }
  5991. // ==========================================
  5992. // DOCX上传区域样式
  5993. // ==========================================
  5994. .upload-docx-area {
  5995. .el-upload__tip {
  5996. font-size: 12px;
  5997. color: var(--text-3);
  5998. margin-top: 6px;
  5999. }
  6000. :deep(.el-upload-list) {
  6001. max-width: 100%;
  6002. }
  6003. :deep(.el-upload-list__item) {
  6004. max-width: 100%;
  6005. }
  6006. :deep(.el-upload-list__item-file-name) {
  6007. max-width: 280px;
  6008. overflow: hidden;
  6009. text-overflow: ellipsis;
  6010. white-space: nowrap;
  6011. }
  6012. }
  6013. .parse-message {
  6014. font-size: 12px;
  6015. color: var(--text-2);
  6016. margin-top: 6px;
  6017. }
  6018. // ==========================================
  6019. // 新建报告对话框样式
  6020. // ==========================================
  6021. .new-report-dialog {
  6022. :deep(.el-dialog__header) {
  6023. padding: 16px 20px;
  6024. border-bottom: 1px solid var(--border);
  6025. margin-right: 0;
  6026. }
  6027. :deep(.el-dialog__body) {
  6028. padding: 20px;
  6029. }
  6030. :deep(.el-dialog__footer) {
  6031. padding: 12px 20px;
  6032. border-top: 1px solid var(--border);
  6033. }
  6034. }
  6035. .new-report-form {
  6036. .section-label {
  6037. font-size: 13px;
  6038. font-weight: 500;
  6039. color: var(--text-2);
  6040. margin-bottom: 10px;
  6041. }
  6042. .create-type-section {
  6043. margin-bottom: 20px;
  6044. }
  6045. .create-type-options {
  6046. display: flex;
  6047. gap: 12px;
  6048. }
  6049. .type-option {
  6050. flex: 1;
  6051. display: flex;
  6052. align-items: flex-start;
  6053. gap: 12px;
  6054. padding: 14px;
  6055. background: var(--bg);
  6056. border: 2px solid var(--border);
  6057. border-radius: var(--radius-md);
  6058. cursor: pointer;
  6059. transition: all 0.2s;
  6060. position: relative;
  6061. &:hover {
  6062. border-color: var(--primary-light);
  6063. background: var(--white);
  6064. }
  6065. &.active {
  6066. border-color: var(--primary);
  6067. background: var(--primary-light);
  6068. .option-title {
  6069. color: var(--primary);
  6070. }
  6071. }
  6072. .option-icon {
  6073. font-size: 24px;
  6074. flex-shrink: 0;
  6075. line-height: 1;
  6076. }
  6077. .option-content {
  6078. flex: 1;
  6079. min-width: 0;
  6080. }
  6081. .option-title {
  6082. font-size: 14px;
  6083. font-weight: 600;
  6084. color: var(--text-1);
  6085. margin-bottom: 4px;
  6086. }
  6087. .option-desc {
  6088. font-size: 12px;
  6089. color: var(--text-3);
  6090. line-height: 1.4;
  6091. }
  6092. .option-check {
  6093. position: absolute;
  6094. top: 8px;
  6095. right: 8px;
  6096. width: 20px;
  6097. height: 20px;
  6098. background: var(--primary);
  6099. color: white;
  6100. border-radius: 50%;
  6101. display: flex;
  6102. align-items: center;
  6103. justify-content: center;
  6104. font-size: 12px;
  6105. font-weight: bold;
  6106. }
  6107. }
  6108. .name-input-section {
  6109. margin-bottom: 20px;
  6110. :deep(.el-input__wrapper) {
  6111. border-radius: var(--radius-sm);
  6112. }
  6113. }
  6114. .upload-section {
  6115. .report-upload-area {
  6116. :deep(.el-upload) {
  6117. width: 100%;
  6118. }
  6119. :deep(.el-upload-dragger) {
  6120. width: 100%;
  6121. height: auto;
  6122. padding: 24px;
  6123. border-radius: var(--radius-md);
  6124. border: 2px dashed var(--border);
  6125. background: var(--bg);
  6126. &:hover {
  6127. border-color: var(--primary);
  6128. background: var(--primary-light);
  6129. }
  6130. }
  6131. .upload-content {
  6132. text-align: center;
  6133. }
  6134. .upload-icon {
  6135. font-size: 32px;
  6136. color: var(--text-3);
  6137. margin-bottom: 8px;
  6138. }
  6139. .upload-text {
  6140. font-size: 14px;
  6141. color: var(--text-2);
  6142. margin-bottom: 4px;
  6143. em {
  6144. color: var(--primary);
  6145. font-style: normal;
  6146. }
  6147. }
  6148. .upload-hint {
  6149. font-size: 12px;
  6150. color: var(--text-3);
  6151. }
  6152. }
  6153. }
  6154. }
  6155. // ==========================================
  6156. // 要素视图
  6157. // ==========================================
  6158. .elements-view {
  6159. padding: 24px;
  6160. .elements-grid {
  6161. display: grid;
  6162. grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
  6163. gap: 16px;
  6164. }
  6165. .element-card {
  6166. background: #fff;
  6167. border: 1.5px solid #e8ecf0;
  6168. border-radius: 12px;
  6169. overflow: hidden;
  6170. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  6171. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
  6172. &:hover {
  6173. border-color: #409eff;
  6174. box-shadow: 0 4px 12px rgba(64, 158, 255, 0.08);
  6175. transform: translateY(-1px);
  6176. }
  6177. .element-card-header {
  6178. display: flex;
  6179. align-items: center;
  6180. justify-content: space-between;
  6181. padding: 14px 16px;
  6182. background: linear-gradient(135deg, #fafbfc 0%, #f8fafc 100%);
  6183. border-bottom: 1.5px solid #e8ecf0;
  6184. .element-label {
  6185. font-weight: 700;
  6186. font-size: 14px;
  6187. color: #1f2937;
  6188. letter-spacing: 0.2px;
  6189. }
  6190. }
  6191. .element-card-body {
  6192. padding: 12px 16px;
  6193. .element-value-row {
  6194. margin-bottom: 8px;
  6195. .value-meta {
  6196. margin-top: 4px;
  6197. font-size: 11px;
  6198. color: var(--text-3);
  6199. .original-label {
  6200. margin-right: 4px;
  6201. }
  6202. .original-value {
  6203. color: var(--text-2);
  6204. }
  6205. }
  6206. .value-status {
  6207. margin-top: 4px;
  6208. }
  6209. }
  6210. .element-empty {
  6211. padding: 16px 0;
  6212. text-align: center;
  6213. .empty-hint {
  6214. font-size: 12px;
  6215. color: var(--text-3);
  6216. }
  6217. }
  6218. }
  6219. }
  6220. .elements-empty {
  6221. padding: 60px 20px;
  6222. text-align: center;
  6223. }
  6224. }
  6225. // ==========================================
  6226. // 实体视图
  6227. // ==========================================
  6228. .entities-view {
  6229. padding: 24px;
  6230. .entity-filter-bar {
  6231. display: flex;
  6232. align-items: center;
  6233. margin-bottom: 16px;
  6234. }
  6235. .entities-empty {
  6236. padding: 60px 20px;
  6237. text-align: center;
  6238. }
  6239. }
  6240. // ==========================================
  6241. // 项目概览统计
  6242. // ==========================================
  6243. .overview-stats {
  6244. display: grid;
  6245. grid-template-columns: repeat(3, 1fr);
  6246. gap: 12px;
  6247. .stat-item {
  6248. display: flex;
  6249. flex-direction: column;
  6250. align-items: center;
  6251. padding: 12px 8px;
  6252. background: var(--bg);
  6253. border-radius: var(--radius-sm);
  6254. .stat-label {
  6255. font-size: 11px;
  6256. color: var(--text-3);
  6257. margin-bottom: 4px;
  6258. }
  6259. .stat-value {
  6260. font-size: 20px;
  6261. font-weight: 700;
  6262. color: var(--text-1);
  6263. &.filled {
  6264. color: var(--success);
  6265. }
  6266. }
  6267. }
  6268. }
  6269. // ==========================================
  6270. // 文档视图 - Word 排版还原 + 可编辑 + 要素高亮
  6271. // ==========================================
  6272. .document-view {
  6273. display: flex;
  6274. flex-direction: column;
  6275. align-items: center;
  6276. padding: 20px;
  6277. background: #e8e8e8;
  6278. min-height: 100%;
  6279. position: relative;
  6280. .doc-toolbar {
  6281. display: flex;
  6282. align-items: center;
  6283. justify-content: space-between;
  6284. gap: 12px;
  6285. margin-bottom: 16px;
  6286. padding: 8px 16px;
  6287. background: var(--white);
  6288. border-radius: var(--radius-md);
  6289. box-shadow: var(--shadow-sm);
  6290. width: 100%;
  6291. max-width: 820px;
  6292. .doc-toolbar-left, .doc-toolbar-right {
  6293. display: flex;
  6294. align-items: center;
  6295. gap: 8px;
  6296. }
  6297. .doc-title-label {
  6298. font-size: 14px;
  6299. font-weight: 600;
  6300. color: var(--text-1);
  6301. white-space: nowrap;
  6302. }
  6303. }
  6304. .doc-paper {
  6305. background: #fff;
  6306. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  6307. border-radius: 2px;
  6308. width: 100%;
  6309. max-width: 820px;
  6310. min-height: 1100px;
  6311. padding: 96px 90px;
  6312. font-family: 'Times New Roman', 'SimSun', '宋体', serif;
  6313. font-size: 12pt;
  6314. line-height: 1.6;
  6315. color: #000;
  6316. word-wrap: break-word;
  6317. overflow-wrap: break-word;
  6318. }
  6319. :deep(.doc-block) {
  6320. margin: 0;
  6321. padding: 0;
  6322. min-height: 1em;
  6323. }
  6324. // 标题样式
  6325. :deep(h1.doc-block) {
  6326. font-size: 18pt;
  6327. font-weight: bold;
  6328. margin: 16px 0 10px;
  6329. line-height: 1.4;
  6330. }
  6331. :deep(h2.doc-block) {
  6332. font-size: 16pt;
  6333. font-weight: bold;
  6334. margin: 14px 0 8px;
  6335. line-height: 1.4;
  6336. }
  6337. :deep(h3.doc-block) {
  6338. font-size: 14pt;
  6339. font-weight: bold;
  6340. margin: 12px 0 6px;
  6341. line-height: 1.4;
  6342. }
  6343. // 段落
  6344. :deep(p.doc-block) {
  6345. margin: 0 0 2px;
  6346. text-align: justify;
  6347. }
  6348. // 目录
  6349. :deep(.doc-toc1), :deep(.doc-toc2), :deep(.doc-toc3) {
  6350. position: relative;
  6351. font-size: 13pt;
  6352. margin: 0;
  6353. padding: 8px 16px;
  6354. cursor: pointer;
  6355. border-left: 3px solid transparent;
  6356. transition: background 0.15s, border-color 0.15s;
  6357. &:hover {
  6358. background: #f0f7ff;
  6359. border-left-color: #1890ff;
  6360. }
  6361. }
  6362. :deep(.doc-toc1) {
  6363. font-weight: 600;
  6364. color: #1f2937;
  6365. }
  6366. :deep(.doc-toc2) {
  6367. padding-left: 36px;
  6368. font-size: 12pt;
  6369. color: #374151;
  6370. }
  6371. :deep(.doc-toc3) {
  6372. padding-left: 56px;
  6373. font-size: 11pt;
  6374. color: #6b7280;
  6375. }
  6376. // 空段落
  6377. :deep(p.doc-paragraph:empty::after),
  6378. :deep(p.doc-block:empty::after) {
  6379. content: '\00a0';
  6380. }
  6381. // 要素模板 {{key}} 标签
  6382. :deep(.elem-tpl-tag) {
  6383. display: inline;
  6384. background: #fff7e6;
  6385. color: #d46b08;
  6386. border: 1.5px solid #ffc069;
  6387. border-radius: 3px;
  6388. padding: 1px 6px;
  6389. font-family: 'Consolas', 'Monaco', monospace;
  6390. font-size: 0.9em;
  6391. font-weight: 600;
  6392. white-space: nowrap;
  6393. cursor: default;
  6394. user-select: all;
  6395. &:hover {
  6396. background: #ffe7ba;
  6397. border-color: #fa8c16;
  6398. }
  6399. }
  6400. :deep(.elem-tpl-block) {
  6401. margin: 8px 0;
  6402. padding: 10px 14px;
  6403. background: #fffbe6;
  6404. border: 1.5px dashed #faad14;
  6405. border-radius: 6px;
  6406. text-align: center;
  6407. .elem-tpl-tag {
  6408. font-size: 1em;
  6409. padding: 2px 10px;
  6410. }
  6411. }
  6412. // 内联图片
  6413. :deep(.doc-inline-image) {
  6414. max-width: 100%;
  6415. height: auto;
  6416. display: block;
  6417. margin: 8px auto;
  6418. }
  6419. // 表格
  6420. :deep(.doc-table) {
  6421. width: 100%;
  6422. border-collapse: separate;
  6423. border-spacing: 0;
  6424. margin: 16px 0;
  6425. font-size: 10.5pt;
  6426. border: 1px solid #d0d5dd;
  6427. border-radius: 8px;
  6428. overflow: hidden;
  6429. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
  6430. .doc-table-cell {
  6431. border: 1px solid #e4e7ec;
  6432. border-top: none;
  6433. border-left: none;
  6434. padding: 10px 14px;
  6435. vertical-align: middle;
  6436. line-height: 1.6;
  6437. color: #344054;
  6438. transition: background 0.15s;
  6439. }
  6440. // 右边缘单元格去掉右边框
  6441. tr .doc-table-cell:last-child {
  6442. border-right: none;
  6443. }
  6444. // 表头行
  6445. tr:first-child .doc-table-cell {
  6446. font-weight: 700;
  6447. font-size: 10pt;
  6448. background: linear-gradient(180deg, #f8fafc 0%, #edf2f7 100%);
  6449. color: #1a202c;
  6450. border-bottom: 2px solid #cbd5e1;
  6451. letter-spacing: 0.5px;
  6452. text-align: center;
  6453. padding: 12px 14px;
  6454. }
  6455. // 斑马纹
  6456. tr:not(:first-child):nth-child(even) .doc-table-cell {
  6457. background: #f9fafb;
  6458. }
  6459. tr:not(:first-child):nth-child(odd) .doc-table-cell {
  6460. background: #fff;
  6461. }
  6462. // 行悬停
  6463. tr:not(:first-child):hover .doc-table-cell {
  6464. background: #e8f4ff;
  6465. }
  6466. }
  6467. // 高亮包裹内的表格特殊处理
  6468. :deep(.elem-highlight-wrap .doc-table) {
  6469. margin: 0;
  6470. box-shadow: none;
  6471. border-color: transparent;
  6472. }
  6473. // 空状态 & 加载
  6474. .doc-empty, .doc-loading {
  6475. display: flex;
  6476. flex-direction: column;
  6477. align-items: center;
  6478. justify-content: center;
  6479. padding: 80px 20px;
  6480. width: 100%;
  6481. max-width: 820px;
  6482. background: var(--white);
  6483. border-radius: var(--radius-md);
  6484. box-shadow: var(--shadow-sm);
  6485. }
  6486. .doc-loading {
  6487. flex-direction: row;
  6488. gap: 12px;
  6489. padding: 40px;
  6490. font-size: 14px;
  6491. color: var(--text-2);
  6492. }
  6493. }
  6494. // ==========================================
  6495. // 要素高亮弹出框
  6496. // ==========================================
  6497. .element-popover {
  6498. position: absolute;
  6499. z-index: 1000;
  6500. background: #fff;
  6501. border: 1px solid var(--border);
  6502. border-radius: var(--radius-md);
  6503. box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
  6504. width: 400px;
  6505. overflow: hidden;
  6506. .popover-header {
  6507. display: flex;
  6508. align-items: center;
  6509. justify-content: space-between;
  6510. padding: 10px 14px;
  6511. background: var(--bg);
  6512. border-bottom: 1px solid var(--border);
  6513. .popover-label {
  6514. font-weight: 600;
  6515. font-size: 14px;
  6516. color: var(--text-1);
  6517. }
  6518. }
  6519. .popover-body {
  6520. padding: 12px 14px;
  6521. .popover-field {
  6522. margin-bottom: 10px;
  6523. &:last-child {
  6524. margin-bottom: 0;
  6525. }
  6526. .popover-field-label {
  6527. display: block;
  6528. font-size: 12px;
  6529. color: var(--text-3);
  6530. margin-bottom: 4px;
  6531. }
  6532. .popover-original {
  6533. font-size: 13px;
  6534. color: var(--text-2);
  6535. background: var(--bg);
  6536. padding: 4px 8px;
  6537. border-radius: var(--radius-sm);
  6538. display: inline-block;
  6539. }
  6540. }
  6541. }
  6542. .popover-rules {
  6543. margin-top: 10px;
  6544. border-top: 1px dashed var(--border);
  6545. padding-top: 10px;
  6546. .rule-trace-card {
  6547. display: flex;
  6548. align-items: flex-start;
  6549. gap: 8px;
  6550. padding: 8px 10px;
  6551. background: var(--bg);
  6552. border-radius: 8px;
  6553. border: 1.5px solid var(--border);
  6554. margin-bottom: 6px;
  6555. cursor: pointer;
  6556. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  6557. &:last-child { margin-bottom: 0; }
  6558. &:hover {
  6559. background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
  6560. border-color: #409eff;
  6561. transform: translateX(2px);
  6562. box-shadow: 0 2px 8px rgba(64, 158, 255, 0.08);
  6563. }
  6564. .rule-trace-action {
  6565. flex-shrink: 0;
  6566. font-size: 10px;
  6567. font-weight: 600;
  6568. padding: 2px 6px;
  6569. border-radius: 10px;
  6570. line-height: 18px;
  6571. &.action-quote { background: #e6f4ff; color: #1677ff; }
  6572. &.action-summary { background: #f6ffed; color: #52c41a; }
  6573. &.action-ai_extract { background: #e6fffb; color: #13c2c2; }
  6574. &.action-table_extract { background: #fff7e6; color: #fa8c16; }
  6575. &.action-use_entity_value { background: #f0f0f0; color: #666; }
  6576. }
  6577. .rule-trace-info {
  6578. flex: 1;
  6579. min-width: 0;
  6580. .rule-trace-name {
  6581. font-size: 12px;
  6582. color: var(--text-1);
  6583. white-space: nowrap;
  6584. overflow: hidden;
  6585. text-overflow: ellipsis;
  6586. }
  6587. .rule-trace-sources {
  6588. margin-top: 3px;
  6589. display: flex;
  6590. flex-direction: column;
  6591. gap: 2px;
  6592. .rule-trace-att {
  6593. font-size: 11px;
  6594. color: var(--text-3);
  6595. word-break: break-all;
  6596. &.clickable {
  6597. cursor: pointer;
  6598. color: var(--el-color-primary);
  6599. &:hover {
  6600. text-decoration: underline;
  6601. }
  6602. }
  6603. }
  6604. }
  6605. .rule-trace-source-text {
  6606. margin-top: 4px;
  6607. padding: 6px 8px;
  6608. background: #fffbe6;
  6609. border-left: 3px solid #faad14;
  6610. border-radius: 2px;
  6611. .source-text-label {
  6612. font-size: 10px;
  6613. color: #d48806;
  6614. font-weight: 500;
  6615. display: block;
  6616. margin-bottom: 2px;
  6617. }
  6618. .source-text-content {
  6619. font-size: 12px;
  6620. color: #614700;
  6621. line-height: 1.5;
  6622. display: -webkit-box;
  6623. -webkit-line-clamp: 4;
  6624. line-clamp: 4;
  6625. -webkit-box-orient: vertical;
  6626. overflow: hidden;
  6627. word-break: break-all;
  6628. }
  6629. }
  6630. .rule-trace-excerpt {
  6631. margin-top: 4px;
  6632. padding: 4px 6px;
  6633. background: #fafafa;
  6634. border-left: 2px solid #d9d9d9;
  6635. border-radius: 2px;
  6636. .rule-trace-excerpt-text {
  6637. font-size: 11px;
  6638. color: var(--text-2);
  6639. line-height: 1.5;
  6640. display: -webkit-box;
  6641. -webkit-line-clamp: 3;
  6642. -webkit-box-orient: vertical;
  6643. overflow: hidden;
  6644. word-break: break-all;
  6645. }
  6646. }
  6647. }
  6648. }
  6649. }
  6650. .popover-footer {
  6651. display: flex;
  6652. justify-content: flex-end;
  6653. gap: 8px;
  6654. padding: 8px 14px;
  6655. border-top: 1px solid var(--border);
  6656. background: var(--bg);
  6657. }
  6658. }
  6659. // 可编辑文档纸张的光标和选区样式
  6660. .doc-paper[contenteditable="true"] {
  6661. outline: none;
  6662. cursor: text;
  6663. &:focus {
  6664. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15), 0 0 0 2px rgba(24, 144, 255, 0.2);
  6665. }
  6666. }
  6667. // 高亮边框样式
  6668. .elem-highlight {
  6669. transition: border-color 0.2s, box-shadow 0.2s;
  6670. &:hover {
  6671. box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
  6672. border-color: #1890ff !important;
  6673. }
  6674. }
  6675. .elem-highlight-long {
  6676. display: inline !important;
  6677. transition: border-color 0.2s, box-shadow 0.2s;
  6678. &:hover {
  6679. box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
  6680. border-color: #1890ff !important;
  6681. }
  6682. }
  6683. .elem-highlight-wrap {
  6684. position: relative;
  6685. transition: border-color 0.2s, box-shadow 0.2s;
  6686. &:hover {
  6687. border-color: #1890ff !important;
  6688. box-shadow: 0 0 0 1px rgba(24, 144, 255, 0.4);
  6689. }
  6690. .doc-table {
  6691. margin: 0 auto;
  6692. }
  6693. }
  6694. // 静态要素淡色高亮
  6695. .elem-highlight-static {
  6696. opacity: 0.6;
  6697. transition: opacity 0.2s, border-color 0.2s;
  6698. &:hover {
  6699. opacity: 1;
  6700. border-color: #999 !important;
  6701. box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
  6702. }
  6703. }
  6704. </style>
  6705. <style lang="scss">
  6706. // ==========================================
  6707. // 点击要素时的闪烁效果(非 scoped,应用于动态生成的 HTML)
  6708. // ==========================================
  6709. .elem-flash {
  6710. animation: elem-flash-anim 0.4s ease-in-out 3 !important;
  6711. position: relative;
  6712. }
  6713. @keyframes elem-flash-anim {
  6714. 0%, 100% {
  6715. outline: 3px solid rgba(24, 144, 255, 0.4);
  6716. outline-offset: 2px;
  6717. background-color: rgba(24, 144, 255, 0.1) !important;
  6718. }
  6719. 50% {
  6720. outline: 5px solid rgba(24, 144, 255, 0.9);
  6721. outline-offset: 4px;
  6722. background-color: rgba(24, 144, 255, 0.25) !important;
  6723. }
  6724. }
  6725. // ==========================================
  6726. // 附件/规则居中悬浮弹窗样式
  6727. // ==========================================
  6728. .floating-panel-dialog {
  6729. .el-dialog__header {
  6730. padding: 16px 20px 12px;
  6731. border-bottom: 1px solid var(--border);
  6732. margin-right: 0;
  6733. .el-dialog__title {
  6734. font-size: 15px;
  6735. font-weight: 700;
  6736. }
  6737. }
  6738. .el-dialog__body {
  6739. padding: 0;
  6740. max-height: 60vh;
  6741. overflow-y: auto;
  6742. }
  6743. .fp-toolbar {
  6744. display: flex;
  6745. align-items: center;
  6746. gap: 10px;
  6747. padding: 14px 20px;
  6748. border-bottom: 1px solid var(--border);
  6749. background: #fafbfc;
  6750. flex-wrap: wrap;
  6751. .fp-count {
  6752. margin-left: auto;
  6753. font-size: 12px;
  6754. color: #999;
  6755. line-height: 1;
  6756. }
  6757. }
  6758. .fp-list {
  6759. padding: 12px 16px 14px;
  6760. }
  6761. &.rule-manage-dialog .el-dialog__body {
  6762. max-height: 72vh;
  6763. display: flex;
  6764. flex-direction: column;
  6765. }
  6766. &.rule-manage-dialog .fp-list {
  6767. max-height: none;
  6768. flex: 1;
  6769. overflow-y: auto;
  6770. }
  6771. // ---- 附件项 ----
  6772. .fp-att-item {
  6773. display: flex;
  6774. align-items: center;
  6775. gap: 10px;
  6776. padding: 10px 12px;
  6777. border-radius: 8px;
  6778. cursor: pointer;
  6779. transition: background 0.15s;
  6780. &:hover {
  6781. background: #f5f7fa;
  6782. }
  6783. &.active {
  6784. background: #e8f4ff;
  6785. }
  6786. .att-icon {
  6787. width: 36px;
  6788. height: 36px;
  6789. border-radius: 6px;
  6790. display: flex;
  6791. align-items: center;
  6792. justify-content: center;
  6793. flex-shrink: 0;
  6794. font-size: 11px;
  6795. font-weight: 700;
  6796. color: #fff;
  6797. background: #1890ff;
  6798. &.pdf { background: #ff4d4f; }
  6799. &.doc, &.docx { background: #1890ff; }
  6800. &.xls, &.xlsx { background: #52c41a; }
  6801. &.img { background: #722ed1; }
  6802. &.zip { background: #fa8c16; }
  6803. }
  6804. .att-info {
  6805. flex: 1;
  6806. min-width: 0;
  6807. .att-name {
  6808. font-size: 13px;
  6809. font-weight: 500;
  6810. color: #1f2937;
  6811. white-space: nowrap;
  6812. overflow: hidden;
  6813. text-overflow: ellipsis;
  6814. }
  6815. .att-meta {
  6816. display: flex;
  6817. gap: 8px;
  6818. margin-top: 2px;
  6819. font-size: 11px;
  6820. color: #999;
  6821. }
  6822. }
  6823. }
  6824. // ---- 解析结果弹窗 ----
  6825. &.parse-result-dialog .el-dialog__body {
  6826. max-height: 85vh;
  6827. }
  6828. .parse-result-toolbar {
  6829. display: flex;
  6830. align-items: center;
  6831. justify-content: space-between;
  6832. padding: 10px 20px;
  6833. border-bottom: 1px solid var(--border);
  6834. background: #fafbfc;
  6835. .parse-result-info {
  6836. font-size: 12px;
  6837. color: #999;
  6838. }
  6839. .parse-result-actions {
  6840. display: flex;
  6841. gap: 6px;
  6842. }
  6843. }
  6844. .parse-result-content {
  6845. padding: 16px 20px;
  6846. max-height: calc(85vh - 120px);
  6847. overflow-y: auto;
  6848. .parse-result-pre {
  6849. margin: 0;
  6850. padding: 0;
  6851. font-family: 'SF Mono', 'Consolas', 'Monaco', 'Menlo', monospace;
  6852. font-size: 13px;
  6853. line-height: 1.7;
  6854. color: #1f2937;
  6855. white-space: pre-wrap;
  6856. word-wrap: break-word;
  6857. }
  6858. .parse-result-rendered {
  6859. font-size: 14px;
  6860. line-height: 1.8;
  6861. color: #1f2937;
  6862. h1 { font-size: 20px; font-weight: 700; margin: 20px 0 12px; padding-bottom: 6px; border-bottom: 1px solid #eee; }
  6863. h2 { font-size: 18px; font-weight: 700; margin: 18px 0 10px; }
  6864. h3 { font-size: 16px; font-weight: 600; margin: 14px 0 8px; }
  6865. h4, h5, h6 { font-size: 14px; font-weight: 600; margin: 10px 0 6px; }
  6866. p { margin: 8px 0; text-align: justify; }
  6867. img {
  6868. max-width: 100%;
  6869. height: auto;
  6870. display: block;
  6871. margin: 12px auto;
  6872. border-radius: 6px;
  6873. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  6874. }
  6875. table {
  6876. width: 100%;
  6877. border-collapse: separate;
  6878. border-spacing: 0;
  6879. margin: 12px 0;
  6880. border: 1px solid #d0d5dd;
  6881. border-radius: 6px;
  6882. overflow: hidden;
  6883. font-size: 13px;
  6884. th, td {
  6885. border: 1px solid #e4e7ec;
  6886. padding: 8px 12px;
  6887. vertical-align: top;
  6888. line-height: 1.5;
  6889. }
  6890. th {
  6891. background: linear-gradient(180deg, #f8fafc, #edf2f7);
  6892. font-weight: 600;
  6893. text-align: center;
  6894. }
  6895. tr:nth-child(even) td { background: #f9fafb; }
  6896. tr:hover td { background: #e8f4ff; }
  6897. }
  6898. ol, ul { padding-left: 24px; margin: 8px 0; }
  6899. li { margin: 4px 0; }
  6900. blockquote {
  6901. margin: 12px 0;
  6902. padding: 10px 16px;
  6903. border-left: 4px solid #1890ff;
  6904. background: #f0f7ff;
  6905. color: #374151;
  6906. }
  6907. // 来源文本高亮样式
  6908. mark.source-highlight {
  6909. background: linear-gradient(180deg, #fff3cd, #ffeeba);
  6910. color: #856404;
  6911. padding: 2px 4px;
  6912. border-radius: 3px;
  6913. box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  6914. animation: highlight-pulse 2s ease-in-out;
  6915. }
  6916. @keyframes highlight-pulse {
  6917. 0%, 100% { background: linear-gradient(180deg, #fff3cd, #ffeeba); }
  6918. 50% { background: linear-gradient(180deg, #ffe066, #ffc107); }
  6919. }
  6920. /* 溯源定位时的闪烁效果 */
  6921. mark.source-highlight.highlight-flash {
  6922. animation: highlight-flash 0.5s ease-in-out 3;
  6923. outline: 2px solid #f59e0b;
  6924. outline-offset: 2px;
  6925. }
  6926. @keyframes highlight-flash {
  6927. 0%, 100% { background: linear-gradient(180deg, #ffc107, #ff9800); }
  6928. 50% { background: linear-gradient(180deg, #fff3cd, #ffeeba); }
  6929. }
  6930. /* 表格行高亮样式(用于评审代码定位) */
  6931. mark.source-highlight-row {
  6932. display: contents;
  6933. }
  6934. mark.source-highlight-row td {
  6935. background: linear-gradient(180deg, #fff3cd, #ffeeba) !important;
  6936. border-left: 3px solid #f59e0b !important;
  6937. }
  6938. tr:has(mark.source-highlight-row) {
  6939. outline: 2px solid #f59e0b;
  6940. outline-offset: -1px;
  6941. }
  6942. code {
  6943. background: #f3f4f6;
  6944. padding: 1px 4px;
  6945. border-radius: 3px;
  6946. font-size: 0.9em;
  6947. font-family: 'SF Mono', 'Consolas', monospace;
  6948. }
  6949. pre {
  6950. background: #1f2937;
  6951. color: #e5e7eb;
  6952. padding: 14px 18px;
  6953. border-radius: 6px;
  6954. overflow-x: auto;
  6955. margin: 12px 0;
  6956. code {
  6957. background: none;
  6958. padding: 0;
  6959. color: inherit;
  6960. }
  6961. }
  6962. hr { border: none; border-top: 1px solid #e5e7eb; margin: 16px 0; }
  6963. }
  6964. }
  6965. // ---- 引用模式提示条 ----
  6966. .citation-mode-banner {
  6967. display: flex;
  6968. align-items: center;
  6969. justify-content: space-between;
  6970. padding: 8px 16px;
  6971. background: linear-gradient(90deg, #e8f5e9, #f1f8e9);
  6972. border-bottom: 1px solid #c8e6c9;
  6973. font-size: 13px;
  6974. color: #2e7d32;
  6975. }
  6976. // ---- 浮动引用工具栏 ----
  6977. .citation-toolbar {
  6978. position: absolute;
  6979. z-index: 3000;
  6980. background: #fff;
  6981. border: 1px solid #d0d5dd;
  6982. border-radius: 10px;
  6983. box-shadow: 0 6px 20px rgba(0,0,0,0.15);
  6984. padding: 10px 14px;
  6985. min-width: 240px;
  6986. max-width: 320px;
  6987. .citation-toolbar-title {
  6988. font-size: 12px;
  6989. color: #666;
  6990. margin-bottom: 8px;
  6991. font-weight: 500;
  6992. }
  6993. .citation-toolbar-actions {
  6994. display: flex;
  6995. gap: 6px;
  6996. flex-wrap: wrap;
  6997. }
  6998. .citation-toolbar-elements {
  6999. max-height: 200px;
  7000. overflow-y: auto;
  7001. }
  7002. .citation-element-item {
  7003. display: flex;
  7004. align-items: center;
  7005. justify-content: space-between;
  7006. padding: 6px 8px;
  7007. border-radius: 6px;
  7008. cursor: pointer;
  7009. transition: background 0.15s;
  7010. font-size: 13px;
  7011. &:hover {
  7012. background: #f0f7ff;
  7013. }
  7014. .citation-elem-name {
  7015. flex: 1;
  7016. min-width: 0;
  7017. overflow: hidden;
  7018. text-overflow: ellipsis;
  7019. white-space: nowrap;
  7020. color: #1f2937;
  7021. }
  7022. }
  7023. }
  7024. // ---- 规则引擎预览 ----
  7025. .rule-engine-preview {
  7026. .rule-engine-desc {
  7027. color: #666;
  7028. font-size: 13px;
  7029. margin-bottom: 12px;
  7030. }
  7031. .rule-engine-stats {
  7032. margin-bottom: 16px;
  7033. font-size: 14px;
  7034. strong { color: #409eff; }
  7035. }
  7036. .rule-engine-list {
  7037. max-height: 500px;
  7038. overflow-y: auto;
  7039. }
  7040. .rule-engine-item {
  7041. padding: 12px;
  7042. margin-bottom: 12px;
  7043. background: #f9fafb;
  7044. border-radius: 8px;
  7045. border: 1px solid #e5e7eb;
  7046. .rule-engine-header {
  7047. display: flex;
  7048. align-items: center;
  7049. gap: 8px;
  7050. margin-bottom: 8px;
  7051. .rule-engine-name {
  7052. font-weight: 600;
  7053. font-size: 14px;
  7054. }
  7055. }
  7056. .rule-engine-element, .rule-engine-inputs, .rule-engine-source, .rule-engine-code, .rule-engine-desc-text {
  7057. font-size: 13px;
  7058. margin-top: 6px;
  7059. .label {
  7060. color: #666;
  7061. margin-right: 4px;
  7062. }
  7063. code {
  7064. background: #e5e7eb;
  7065. padding: 2px 6px;
  7066. border-radius: 4px;
  7067. font-size: 12px;
  7068. }
  7069. }
  7070. .input-list {
  7071. margin-top: 4px;
  7072. padding-left: 12px;
  7073. .input-item {
  7074. margin-bottom: 6px;
  7075. .input-source { color: #409eff; }
  7076. .input-entry { color: #666; margin-left: 4px; }
  7077. .input-text {
  7078. margin-top: 4px;
  7079. padding: 6px 8px;
  7080. background: #fff;
  7081. border-radius: 4px;
  7082. border: 1px solid #e5e7eb;
  7083. .text-label { color: #999; font-size: 12px; }
  7084. .text-content { color: #333; font-size: 12px; display: block; margin-top: 2px; }
  7085. }
  7086. }
  7087. }
  7088. .source-text {
  7089. color: #333;
  7090. background: #fff;
  7091. padding: 4px 8px;
  7092. border-radius: 4px;
  7093. display: inline-block;
  7094. }
  7095. }
  7096. .rule-engine-json {
  7097. margin-top: 16px;
  7098. .json-header {
  7099. display: flex;
  7100. justify-content: space-between;
  7101. align-items: center;
  7102. margin-bottom: 8px;
  7103. .json-title {
  7104. font-weight: 600;
  7105. font-size: 13px;
  7106. color: #333;
  7107. }
  7108. }
  7109. .json-content {
  7110. background: #1e1e1e;
  7111. color: #d4d4d4;
  7112. padding: 12px;
  7113. border-radius: 6px;
  7114. font-size: 12px;
  7115. font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
  7116. max-height: 300px;
  7117. overflow: auto;
  7118. white-space: pre-wrap;
  7119. word-break: break-all;
  7120. }
  7121. }
  7122. }
  7123. // ---- 规则筛选栏 ----
  7124. .rule-filter-bar {
  7125. display: flex;
  7126. gap: 8px;
  7127. padding: 8px 16px 12px;
  7128. border-bottom: 1px solid #f0f0f0;
  7129. flex-wrap: wrap;
  7130. .rule-filter-tab {
  7131. font-size: 12px;
  7132. padding: 5px 12px;
  7133. border-radius: 14px;
  7134. cursor: pointer;
  7135. color: #666;
  7136. background: #f5f7fa;
  7137. transition: all 0.2s;
  7138. user-select: none;
  7139. em {
  7140. font-style: normal;
  7141. font-size: 11px;
  7142. opacity: 0.7;
  7143. margin-left: 2px;
  7144. }
  7145. &:hover { background: #e8eaed; }
  7146. &.active { background: #409eff; color: #fff; }
  7147. &.active em { opacity: 0.9; }
  7148. &.tab-summary.active { background: #52c41a; }
  7149. &.tab-ai_extract.active { background: #13c2c2; }
  7150. &.tab-table_extract.active { background: #fa8c16; }
  7151. &.tab-quote.active { background: #1677ff; }
  7152. &.tab-use_entity_value.active { background: #8c8c8c; }
  7153. }
  7154. }
  7155. // ---- 规则项 ----
  7156. .fp-rule-item {
  7157. border-radius: 12px;
  7158. border: 1.5px solid #e8ecf0;
  7159. background: #fff;
  7160. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  7161. cursor: pointer;
  7162. margin-bottom: 12px;
  7163. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
  7164. &:hover {
  7165. background: linear-gradient(135deg, #fafbfc 0%, #f8fafc 100%);
  7166. border-color: #d9e2ec;
  7167. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
  7168. transform: translateY(-1px);
  7169. }
  7170. &.expanded {
  7171. background: #fafbfc;
  7172. border-color: #d0dae6;
  7173. box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
  7174. }
  7175. .rule-item-main {
  7176. display: flex;
  7177. align-items: center;
  7178. gap: 12px;
  7179. padding: 14px 16px;
  7180. }
  7181. .rule-action-badge {
  7182. flex-shrink: 0;
  7183. font-size: 11px;
  7184. font-weight: 700;
  7185. padding: 5px 12px;
  7186. border-radius: 16px;
  7187. line-height: 18px;
  7188. white-space: nowrap;
  7189. text-transform: uppercase;
  7190. letter-spacing: 0.3px;
  7191. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  7192. &.action-quote { background: linear-gradient(135deg, #e6f4ff 0%, #d9ecff 100%); color: #1677ff; }
  7193. &.action-summary { background: linear-gradient(135deg, #f6ffed 0%, #e8f9e0 100%); color: #52c41a; }
  7194. &.action-ai_extract { background: linear-gradient(135deg, #e6fffb 0%, #d9f7f2 100%); color: #13c2c2; }
  7195. &.action-table_extract { background: linear-gradient(135deg, #fff7e6 0%, #ffefd9 100%); color: #fa8c16; }
  7196. &.action-use_entity_value { background: linear-gradient(135deg, #f5f5f5 0%, #ececec 100%); color: #666; }
  7197. }
  7198. .rule-info {
  7199. flex: 1;
  7200. min-width: 0;
  7201. .rule-name-row {
  7202. display: flex;
  7203. align-items: center;
  7204. gap: 6px;
  7205. flex-wrap: wrap;
  7206. .rule-name {
  7207. font-size: 14px;
  7208. font-weight: 500;
  7209. color: #1f2937;
  7210. }
  7211. .rule-elem-key {
  7212. font-size: 10px;
  7213. max-width: 200px;
  7214. overflow: hidden;
  7215. text-overflow: ellipsis;
  7216. }
  7217. }
  7218. .rule-desc {
  7219. font-size: 12px;
  7220. line-height: 1.5;
  7221. color: #999;
  7222. margin-top: 4px;
  7223. overflow: hidden;
  7224. text-overflow: ellipsis;
  7225. display: -webkit-box;
  7226. line-clamp: 2;
  7227. -webkit-line-clamp: 2;
  7228. -webkit-box-orient: vertical;
  7229. }
  7230. }
  7231. .rule-actions {
  7232. display: flex;
  7233. align-items: center;
  7234. gap: 6px;
  7235. flex-shrink: 0;
  7236. }
  7237. // 展开详情区域
  7238. .rule-detail {
  7239. padding: 2px 14px 12px;
  7240. border-top: 1px dashed #e8e8e8;
  7241. margin: 0 12px 4px;
  7242. .rule-detail-row {
  7243. display: flex;
  7244. align-items: flex-start;
  7245. gap: 8px;
  7246. padding: 6px 0;
  7247. font-size: 12px;
  7248. &:not(:last-child) {
  7249. border-bottom: 1px solid #f5f5f5;
  7250. }
  7251. }
  7252. .rule-detail-label {
  7253. flex-shrink: 0;
  7254. font-weight: 600;
  7255. color: #666;
  7256. width: 60px;
  7257. text-align: right;
  7258. }
  7259. .rule-detail-value {
  7260. color: #333;
  7261. line-height: 1.5;
  7262. word-break: break-all;
  7263. }
  7264. .rule-detail-inputs {
  7265. display: flex;
  7266. flex-wrap: wrap;
  7267. gap: 4px;
  7268. }
  7269. .rule-input-chip {
  7270. font-size: 11px;
  7271. padding: 2px 8px;
  7272. background: #f0f5ff;
  7273. border-radius: 10px;
  7274. color: #1677ff;
  7275. white-space: nowrap;
  7276. }
  7277. .rule-detail-time {
  7278. font-size: 11px;
  7279. color: #999;
  7280. margin-left: 4px;
  7281. }
  7282. .rule-detail-error {
  7283. color: #ff4d4f;
  7284. line-height: 1.4;
  7285. }
  7286. }
  7287. }
  7288. }
  7289. // 规则工作流弹窗样式 - 完全隐藏 header
  7290. .rule-workflow-dialog {
  7291. &.el-dialog .el-dialog__header,
  7292. .el-dialog__header,
  7293. > .el-dialog__header,
  7294. > header {
  7295. display: none !important;
  7296. }
  7297. &.el-dialog .el-dialog__body,
  7298. .el-dialog__body,
  7299. > .el-dialog__body {
  7300. padding: 0 !important;
  7301. height: 100vh !important;
  7302. overflow: hidden !important;
  7303. }
  7304. }
  7305. </style>