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