Editor.vue 113 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367
  1. <template>
  2. <div class="editor-page">
  3. <!-- 主体 -->
  4. <div class="editor-body">
  5. <!-- 左侧面板 -->
  6. <div class="left-panel" :style="{ width: leftPanelWidth + 'px' }">
  7. <!-- Tab 切换 -->
  8. <div class="panel-tabs">
  9. <div
  10. class="panel-tab"
  11. :class="{ active: leftPanelTab === 'reports' }"
  12. @click="leftPanelTab = 'reports'"
  13. >
  14. 📄 报告
  15. <span class="tab-count">{{ myReports?.length || 0 }}</span>
  16. </div>
  17. <transition name="tab-slide">
  18. <div
  19. v-if="hasActiveDocument"
  20. class="panel-tab"
  21. :class="{ active: leftPanelTab === 'files' }"
  22. @click="leftPanelTab = 'files'"
  23. >
  24. 📁 资源
  25. <span class="tab-count">{{ sourceFiles?.length || 0 }}</span>
  26. </div>
  27. </transition>
  28. <transition name="tab-slide">
  29. <div
  30. v-if="hasActiveDocument"
  31. class="panel-tab"
  32. :class="{ active: leftPanelTab === 'toc' }"
  33. @click="leftPanelTab = 'toc'"
  34. >
  35. 📑 目录
  36. <span class="tab-count">{{ tocItems?.length || 0 }}</span>
  37. </div>
  38. </transition>
  39. </div>
  40. <!-- 我的报告面板 -->
  41. <div class="panel-body reports-panel" v-show="leftPanelTab === 'reports'">
  42. <!-- 新建报告按钮(带下拉菜单) -->
  43. <el-popover
  44. placement="bottom-start"
  45. :width="240"
  46. trigger="click"
  47. popper-class="new-report-popover"
  48. >
  49. <template #reference>
  50. <el-button
  51. class="new-report-btn"
  52. type="primary"
  53. :icon="Plus"
  54. >
  55. 新建报告
  56. </el-button>
  57. </template>
  58. <div class="new-report-menu">
  59. <div class="menu-item" @click="handleCreateReport">
  60. <div class="menu-icon">📄</div>
  61. <div class="menu-content">
  62. <div class="menu-title">创建空白报告</div>
  63. <div class="menu-desc">从零开始创建新报告</div>
  64. </div>
  65. </div>
  66. <div class="menu-item" @click="handleUploadFile">
  67. <div class="menu-icon">📁</div>
  68. <div class="menu-content">
  69. <div class="menu-title">上传文件创建</div>
  70. <div class="menu-desc">上传 PDF/Word 自动解析</div>
  71. </div>
  72. </div>
  73. </div>
  74. </el-popover>
  75. <!-- 报告搜索 -->
  76. <el-input
  77. v-model="reportSearchKeyword"
  78. placeholder="搜索报告..."
  79. :prefix-icon="Search"
  80. clearable
  81. class="report-search"
  82. />
  83. <!-- 报告列表 -->
  84. <div class="report-list" v-if="filteredReports.length > 0">
  85. <div
  86. v-for="report in filteredReports"
  87. :key="report.id"
  88. class="report-item"
  89. :class="{ active: currentReportId === report.id }"
  90. @click="switchReport(report)"
  91. >
  92. <div class="report-icon">📄</div>
  93. <div class="report-info">
  94. <div class="report-name">{{ report.name }}</div>
  95. <div class="report-meta">
  96. <span class="report-time">{{ formatReportTime(report.updatedAt || report.createdAt) }}</span>
  97. <span class="report-status" :class="report.status">
  98. {{ getReportStatusText(report.status) }}
  99. </span>
  100. </div>
  101. </div>
  102. </div>
  103. </div>
  104. <!-- 空状态 -->
  105. <div class="report-empty" v-else-if="!loadingReports">
  106. <div class="empty-icon">📄</div>
  107. <div class="empty-text">{{ reportSearchKeyword ? '未找到匹配的报告' : '暂无报告' }}</div>
  108. <div class="empty-hint">点击上方按钮创建新报告</div>
  109. </div>
  110. <!-- 加载状态 -->
  111. <div class="report-loading" v-if="loadingReports">
  112. <el-icon class="is-loading"><Loading /></el-icon>
  113. <span>加载中...</span>
  114. </div>
  115. </div>
  116. <!-- 来源文件面板 -->
  117. <div class="panel-body" v-show="leftPanelTab === 'files'">
  118. <!-- 上传区 -->
  119. <el-upload
  120. class="upload-zone"
  121. drag
  122. action="/api/v1/parse/upload"
  123. :on-success="handleFileUpload"
  124. :show-file-list="false"
  125. >
  126. <div class="upload-content">
  127. <div class="upload-icon">📄</div>
  128. <div class="upload-text">拖拽或点击上传</div>
  129. <div class="upload-hint">支持 PDF / Word / Excel</div>
  130. </div>
  131. </el-upload>
  132. <!-- 来源文件列表 -->
  133. <div class="file-list">
  134. <div
  135. v-for="file in sourceFiles"
  136. :key="file.id"
  137. class="file-item"
  138. :class="{ active: selectedFile?.id === file.id }"
  139. @click="selectFile(file)"
  140. >
  141. <span class="file-icon">{{ getFileIcon(file) }}</span>
  142. <div class="file-info">
  143. <div class="file-name">{{ file.alias }}</div>
  144. <div class="file-meta">
  145. <span v-if="file.required" class="required">必需</span>
  146. <span v-else>可选</span>
  147. </div>
  148. </div>
  149. <el-button
  150. size="small"
  151. :icon="Delete"
  152. circle
  153. @click.stop="removeSourceFile(file)"
  154. />
  155. </div>
  156. </div>
  157. <!-- 添加来源文件定义 -->
  158. <el-button
  159. class="add-source-btn"
  160. :icon="Plus"
  161. @click="showAddSourceDialog = true"
  162. >
  163. 添加来源文件定义
  164. </el-button>
  165. </div>
  166. <!-- 目录面板 -->
  167. <div class="panel-body toc-panel" v-show="leftPanelTab === 'toc'">
  168. <div class="toc-list" v-if="tocItems && tocItems.length > 0">
  169. <div
  170. v-for="(item, index) in tocItems"
  171. :key="item.index || index"
  172. class="toc-item"
  173. :class="['toc-level-' + item.level]"
  174. @click="scrollToHeading(item)"
  175. >
  176. <span class="toc-bullet">{{ getTocBullet(item.level) }}</span>
  177. <span class="toc-text">{{ item.title || item.text }}</span>
  178. </div>
  179. </div>
  180. <div class="toc-empty" v-else>
  181. <div class="empty-icon">📑</div>
  182. <div class="empty-text">暂无目录</div>
  183. <div class="empty-hint">文档解析后将自动生成目录</div>
  184. </div>
  185. </div>
  186. </div>
  187. <!-- 左侧拖拽分隔条 -->
  188. <div
  189. class="resize-handle left-resize"
  190. @mousedown="startResizeLeft"
  191. ></div>
  192. <!-- 中间编辑区 -->
  193. <div class="center-panel">
  194. <!-- 欢迎页:没有选中文档时显示 -->
  195. <div class="welcome-page" v-if="!hasActiveDocument">
  196. <div class="welcome-content">
  197. <div class="welcome-logo">灵</div>
  198. <div class="welcome">
  199. <h1>{{ greetingText }},{{ userName }}!<span>智能报告,洞察未来。</span></h1>
  200. <p>今天是个创作的好日子,开始您的智能报告之旅吧</p>
  201. </div>
  202. </div>
  203. </div>
  204. <!-- 编辑器:有选中文档时显示 -->
  205. <template v-else>
  206. <div class="editor-title-bar">
  207. <!-- 左侧:标题和保存状态 -->
  208. <div class="title-section">
  209. <div class="title-input-wrapper" :style="{ width: titleInputWidth + 'px' }">
  210. <el-input
  211. v-model="reportTitle"
  212. class="title-input"
  213. placeholder="请输入报告标题"
  214. />
  215. <span class="title-measure" ref="titleMeasure">{{ reportTitle || '请输入报告标题' }}</span>
  216. </div>
  217. <span class="save-status" v-if="saved">✓ 已保存</span>
  218. </div>
  219. <!-- 中间:视图切换 -->
  220. <div class="view-toggle">
  221. <el-radio-group v-model="viewMode" size="small">
  222. <el-radio-button value="edit">📝 编辑</el-radio-button>
  223. <el-radio-button value="preview">👁 预览</el-radio-button>
  224. </el-radio-group>
  225. </div>
  226. <!-- 右侧:操作按钮 -->
  227. <div class="toolbar-actions">
  228. <el-button
  229. size="small"
  230. :icon="Refresh"
  231. :loading="regenerating"
  232. @click="handleRegenerateBlocks"
  233. title="重新生成文档结构"
  234. >
  235. 重新生成
  236. </el-button>
  237. <el-button size="small" :icon="Clock" title="版本历史">版本</el-button>
  238. <el-button size="small" :icon="Share" circle @click="showGraphModal = true" title="知识图谱" />
  239. <el-divider direction="vertical" />
  240. <el-button size="small" type="primary" :icon="Check" @click="handleSave">保存</el-button>
  241. </div>
  242. </div>
  243. <div class="editor-scroll" ref="editorRef">
  244. <div
  245. class="editor-content"
  246. contenteditable="true"
  247. @contextmenu.prevent="handleContextMenu"
  248. ref="editorContentRef"
  249. />
  250. </div>
  251. </template>
  252. </div>
  253. <!-- 右侧拖拽分隔条:仅在选中文档时显示 -->
  254. <div
  255. v-if="hasActiveDocument"
  256. class="resize-handle right-resize"
  257. @mousedown="startResizeRight"
  258. ></div>
  259. <!-- 右侧要素面板:仅在选中文档时显示 -->
  260. <div v-if="hasActiveDocument" class="right-panel" :style="{ width: rightPanelWidth + 'px' }">
  261. <!-- 要素管理(展示文档中识别的实体) -->
  262. <div class="element-section">
  263. <div class="element-header">
  264. <span class="element-title">
  265. 🏷️ 要素管理
  266. <span class="element-count">({{ allFilteredEntities?.length || 0 }}/{{ entities?.length || 0 }})</span>
  267. </span>
  268. <el-button size="small" :icon="Plus" @click="showAddVariableDialog = true">
  269. 添加
  270. </el-button>
  271. </div>
  272. <!-- 搜索和筛选 -->
  273. <div class="element-filter" v-if="entities && entities.length > 0">
  274. <el-input
  275. v-model="entitySearchKeyword"
  276. placeholder="搜索要素..."
  277. size="small"
  278. :prefix-icon="Search"
  279. clearable
  280. class="entity-search"
  281. />
  282. <div class="entity-type-filter">
  283. <el-tag
  284. v-for="(count, type) in entityTypeCounts"
  285. :key="type"
  286. :class="['filter-tag', { active: entityTypeFilter === type }]"
  287. size="small"
  288. @click="toggleEntityTypeFilter(type)"
  289. >
  290. {{ getEntityTypeIcon(type) }} {{ getEntityTypeName(type) }} ({{ count }})
  291. </el-tag>
  292. <el-tag
  293. v-if="entityTypeFilter"
  294. class="filter-tag clear"
  295. size="small"
  296. @click="entityTypeFilter = ''"
  297. >
  298. 清除筛选
  299. </el-tag>
  300. </div>
  301. </div>
  302. <div class="element-body">
  303. <div class="element-tags-wrap" v-if="filteredEntities && filteredEntities.length > 0">
  304. <div
  305. v-for="entity in filteredEntities"
  306. :key="entity.id"
  307. class="var-tag"
  308. :class="[getEntityTypeClass(entity.type), { confirmed: entity.confirmed }]"
  309. :title="`${getEntityTypeName(entity.type)}: ${entity.text}`"
  310. @click="scrollToEntity(entity.id)"
  311. @dblclick="openEntityEditModal(entity)"
  312. >
  313. <span class="tag-icon">{{ getEntityTypeIcon(entity.type) }}</span>
  314. <span class="tag-name">{{ entity.text }}</span>
  315. <span class="tag-status" v-if="entity.confirmed">✓</span>
  316. </div>
  317. </div>
  318. <!-- 加载更多按钮 -->
  319. <div class="load-more-wrap" v-if="hasMoreEntities">
  320. <el-button
  321. size="small"
  322. text
  323. @click="loadMoreEntities"
  324. >
  325. 加载更多 (还有 {{ remainingEntitiesCount }} 个)
  326. </el-button>
  327. </div>
  328. <div class="element-hint" v-if="!entities || entities.length === 0">
  329. 选中文本后右键标记为实体
  330. </div>
  331. <div class="element-hint" v-else-if="(!filteredEntities || filteredEntities.length === 0) && !hasMoreEntities">
  332. 没有匹配的要素
  333. </div>
  334. </div>
  335. </div>
  336. <!-- 按类别分组显示 -->
  337. <div class="category-section" v-for="(vars, category) in groupedVariables" :key="category">
  338. <div class="category-header">
  339. <span
  340. class="category-dot"
  341. :style="{ background: getCategoryColor(category) }"
  342. />
  343. <span>{{ getCategoryLabel(category) }}</span>
  344. <span class="category-count">{{ vars.length }}</span>
  345. </div>
  346. <div class="category-items">
  347. <div
  348. v-for="v in vars"
  349. :key="v.id"
  350. class="category-item"
  351. @click="editVariable(v)"
  352. >
  353. <span>{{ v.displayName }}</span>
  354. <span class="item-value">{{ v.exampleValue || '-' }}</span>
  355. </div>
  356. </div>
  357. </div>
  358. </div>
  359. </div>
  360. <!-- 右键菜单 -->
  361. <div
  362. v-show="contextMenuVisible"
  363. class="context-menu"
  364. :style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
  365. >
  366. <div class="context-menu-header" v-if="selectedText">
  367. <span class="selected-preview">"{{ selectedText.slice(0, 20) }}{{ selectedText.length > 20 ? '...' : '' }}"</span>
  368. </div>
  369. <div class="context-menu-section">标记为实体</div>
  370. <div class="context-menu-item" @click="markAsVariable('person')" :disabled="markingEntity">
  371. <span class="icon">👤</span>
  372. <span>人物</span>
  373. </div>
  374. <div class="context-menu-item" @click="markAsVariable('org')" :disabled="markingEntity">
  375. <span class="icon">🏢</span>
  376. <span>组织机构</span>
  377. </div>
  378. <div class="context-menu-item" @click="markAsVariable('location')" :disabled="markingEntity">
  379. <span class="icon">📍</span>
  380. <span>地点</span>
  381. </div>
  382. <div class="context-menu-item" @click="markAsVariable('date')" :disabled="markingEntity">
  383. <span class="icon">📅</span>
  384. <span>日期/时间</span>
  385. </div>
  386. <div class="context-menu-item" @click="markAsVariable('data')" :disabled="markingEntity">
  387. <span class="icon">📊</span>
  388. <span>数据/指标</span>
  389. </div>
  390. <div class="context-menu-item" @click="markAsVariable('concept')" :disabled="markingEntity">
  391. <span class="icon">💡</span>
  392. <span>概念/技术</span>
  393. </div>
  394. <div class="context-menu-item" @click="markAsVariable('entity')" :disabled="markingEntity">
  395. <span class="icon">🏷️</span>
  396. <span>其他实体</span>
  397. </div>
  398. <div class="context-menu-loading" v-if="markingEntity">
  399. <el-icon class="is-loading"><Loading /></el-icon>
  400. <span>标记中...</span>
  401. </div>
  402. </div>
  403. <!-- 添加来源文件对话框 -->
  404. <el-dialog v-model="showAddSourceDialog" title="添加来源文件定义" width="400">
  405. <el-form :model="newSourceFile" label-width="80px">
  406. <el-form-item label="文件别名" required>
  407. <el-input v-model="newSourceFile.alias" placeholder="如:可研批复" />
  408. </el-form-item>
  409. <el-form-item label="描述">
  410. <el-input v-model="newSourceFile.description" placeholder="文件描述" />
  411. </el-form-item>
  412. <el-form-item label="是否必需">
  413. <el-switch v-model="newSourceFile.required" />
  414. </el-form-item>
  415. </el-form>
  416. <template #footer>
  417. <el-button @click="showAddSourceDialog = false">取消</el-button>
  418. <el-button type="primary" @click="addSourceFile">添加</el-button>
  419. </template>
  420. </el-dialog>
  421. <!-- 添加/编辑变量对话框 -->
  422. <el-dialog v-model="showVariableDialog" :title="editingVariable ? '编辑变量' : '添加变量'" width="500">
  423. <el-form :model="variableForm" label-width="100px">
  424. <el-form-item label="变量名" required>
  425. <el-input v-model="variableForm.name" placeholder="程序用名,如 project_name" />
  426. </el-form-item>
  427. <el-form-item label="显示名称" required>
  428. <el-input v-model="variableForm.displayName" placeholder="用户可见名称" />
  429. </el-form-item>
  430. <el-form-item label="类别">
  431. <el-select v-model="variableForm.category" style="width: 100%">
  432. <el-option label="核心实体" value="entity" />
  433. <el-option label="概念/技术" value="concept" />
  434. <el-option label="数据/指标" value="data" />
  435. <el-option label="地点/组织" value="location" />
  436. <el-option label="资源模板" value="asset" />
  437. </el-select>
  438. </el-form-item>
  439. <el-form-item label="示例值">
  440. <el-input v-model="variableForm.exampleValue" placeholder="文档中的原始值" />
  441. </el-form-item>
  442. <el-form-item label="来源类型">
  443. <el-select v-model="variableForm.sourceType" style="width: 100%">
  444. <el-option label="从来源文件提取" value="document" />
  445. <el-option label="手动输入" value="manual" />
  446. <el-option label="引用其他变量" value="reference" />
  447. <el-option label="固定值" value="fixed" />
  448. </el-select>
  449. </el-form-item>
  450. <el-form-item label="来源文件" v-if="variableForm.sourceType === 'document'">
  451. <el-select v-model="variableForm.sourceFileAlias" style="width: 100%">
  452. <el-option
  453. v-for="sf in sourceFiles"
  454. :key="sf.id"
  455. :label="sf.alias"
  456. :value="sf.alias"
  457. />
  458. </el-select>
  459. </el-form-item>
  460. <el-form-item label="提取方式" v-if="variableForm.sourceType === 'document'">
  461. <el-select v-model="variableForm.extractType" style="width: 100%">
  462. <el-option label="直接提取" value="direct" />
  463. <el-option label="AI 字段提取" value="ai_extract" />
  464. <el-option label="AI 总结" value="ai_summarize" />
  465. </el-select>
  466. </el-form-item>
  467. </el-form>
  468. <template #footer>
  469. <el-button @click="showVariableDialog = false">取消</el-button>
  470. <el-button
  471. v-if="editingVariable"
  472. type="danger"
  473. @click="deleteVariable"
  474. >
  475. 删除
  476. </el-button>
  477. <el-button type="primary" @click="saveVariable">保存</el-button>
  478. </template>
  479. </el-dialog>
  480. <!-- 知识图谱弹窗 -->
  481. <el-dialog v-model="showGraphModal" title="🔗 知识图谱" width="900">
  482. <div class="graph-container">
  483. <div class="graph-legend">
  484. <div class="legend-title">图例</div>
  485. <div class="legend-item">
  486. <span class="legend-dot entity"></span>
  487. <span>核心实体</span>
  488. </div>
  489. <div class="legend-item">
  490. <span class="legend-dot concept"></span>
  491. <span>概念/技术</span>
  492. </div>
  493. <div class="legend-item">
  494. <span class="legend-dot data"></span>
  495. <span>数据/指标</span>
  496. </div>
  497. <div class="legend-item">
  498. <span class="legend-dot location"></span>
  499. <span>地点/组织</span>
  500. </div>
  501. </div>
  502. <div class="graph-body">
  503. <div class="graph-placeholder">
  504. <el-icon size="64" color="#ccc"><Connection /></el-icon>
  505. <p>知识图谱可视化(开发中)</p>
  506. </div>
  507. </div>
  508. </div>
  509. </el-dialog>
  510. <!-- 实体编辑弹窗 -->
  511. <el-dialog v-model="showEntityEditModal" title="编辑实体" width="400">
  512. <div class="entity-edit-form" v-if="editingEntity">
  513. <div class="entity-edit-preview">
  514. <span class="preview-icon">{{ getEntityTypeIcon(editingEntity.type) }}</span>
  515. <span class="preview-text">"{{ editingEntity.text }}"</span>
  516. </div>
  517. <el-form label-width="80px">
  518. <el-form-item label="实体类型">
  519. <el-select v-model="editingEntity.type" style="width: 100%">
  520. <el-option label="👤 人物" value="PERSON" />
  521. <el-option label="🏢 组织机构" value="ORGANIZATION" />
  522. <el-option label="📍 地点" value="LOCATION" />
  523. <el-option label="📅 日期/时间" value="DATE" />
  524. <el-option label="📊 数据/指标" value="DATA" />
  525. <el-option label="💡 概念/技术" value="CONCEPT" />
  526. <el-option label="🏷️ 其他实体" value="ENTITY" />
  527. </el-select>
  528. </el-form-item>
  529. <el-form-item label="状态">
  530. <el-tag :type="editingEntity.confirmed ? 'success' : 'info'">
  531. {{ editingEntity.confirmed ? '已确认' : '待确认' }}
  532. </el-tag>
  533. </el-form-item>
  534. </el-form>
  535. </div>
  536. <template #footer>
  537. <el-button @click="showEntityEditModal = false">取消</el-button>
  538. <el-button
  539. type="primary"
  540. v-if="editingEntity && !editingEntity.confirmed"
  541. @click="confirmEditingEntity"
  542. >
  543. 确认实体
  544. </el-button>
  545. <el-button type="danger" @click="deleteEditingEntity">删除</el-button>
  546. <el-button type="primary" @click="saveEditingEntity">保存</el-button>
  547. </template>
  548. </el-dialog>
  549. </div>
  550. </template>
  551. <script setup>
  552. import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
  553. import { useRouter, useRoute } from 'vue-router'
  554. import {
  555. ArrowLeft, Clock, Share, Check, Plus, Delete, Connection, Refresh, Search, Loading
  556. } from '@element-plus/icons-vue'
  557. import { ElMessage, ElMessageBox } from 'element-plus'
  558. import { useTemplateStore } from '@/stores/template'
  559. import { documentApi, templateApi } from '@/api'
  560. const router = useRouter()
  561. const route = useRoute()
  562. const templateStore = useTemplateStore()
  563. // 从 URL 查询参数获取文档ID
  564. const currentDocumentId = ref(route.query.doc || null)
  565. // 当前选中的报告ID(用于标记左侧列表的选中状态)
  566. const currentReportId = ref(null)
  567. // 是否有当前激活的文档
  568. const hasActiveDocument = computed(() => !!currentDocumentId.value)
  569. // 欢迎页问候语
  570. const userName = computed(() => localStorage.getItem('username') || '用户')
  571. const greetingText = computed(() => {
  572. const hour = new Date().getHours()
  573. if (hour < 6) return '凌晨好'
  574. if (hour < 9) return '早上好'
  575. if (hour < 12) return '上午好'
  576. if (hour < 14) return '中午好'
  577. if (hour < 18) return '下午好'
  578. if (hour < 22) return '晚上好'
  579. return '夜深了'
  580. })
  581. const reportTitle = ref('')
  582. const titleMeasure = ref(null)
  583. const titleInputWidth = ref(120)
  584. const viewMode = ref('edit')
  585. const saved = ref(true)
  586. const editorRef = ref(null)
  587. const editorContentRef = ref(null)
  588. const loading = ref(false)
  589. const regenerating = ref(false)
  590. // 面板宽度(可拖拽调整)
  591. const leftPanelWidth = ref(300)
  592. const rightPanelWidth = ref(380)
  593. const isResizing = ref(false)
  594. const resizeType = ref('') // 'left' or 'right'
  595. // 开始拖拽左侧分隔条
  596. function startResizeLeft(e) {
  597. isResizing.value = true
  598. resizeType.value = 'left'
  599. document.addEventListener('mousemove', handleResize)
  600. document.addEventListener('mouseup', stopResize)
  601. document.body.style.cursor = 'col-resize'
  602. document.body.style.userSelect = 'none'
  603. }
  604. // 开始拖拽右侧分隔条
  605. function startResizeRight(e) {
  606. isResizing.value = true
  607. resizeType.value = 'right'
  608. document.addEventListener('mousemove', handleResize)
  609. document.addEventListener('mouseup', stopResize)
  610. document.body.style.cursor = 'col-resize'
  611. document.body.style.userSelect = 'none'
  612. }
  613. // 处理拖拽
  614. function handleResize(e) {
  615. if (!isResizing.value) return
  616. if (resizeType.value === 'left') {
  617. const newWidth = e.clientX
  618. leftPanelWidth.value = Math.max(240, Math.min(500, newWidth))
  619. } else if (resizeType.value === 'right') {
  620. const newWidth = window.innerWidth - e.clientX
  621. rightPanelWidth.value = Math.max(280, Math.min(500, newWidth))
  622. }
  623. }
  624. // 停止拖拽
  625. function stopResize() {
  626. isResizing.value = false
  627. resizeType.value = ''
  628. document.removeEventListener('mousemove', handleResize)
  629. document.removeEventListener('mouseup', stopResize)
  630. document.body.style.cursor = ''
  631. document.body.style.userSelect = ''
  632. }
  633. // 动态计算标题输入框宽度
  634. watch(reportTitle, () => {
  635. nextTick(() => {
  636. if (titleMeasure.value) {
  637. const measuredWidth = titleMeasure.value.offsetWidth + 30 // 额外边距
  638. titleInputWidth.value = Math.max(120, Math.min(400, measuredWidth))
  639. }
  640. })
  641. })
  642. // 左侧面板 Tab(默认显示报告列表)
  643. const leftPanelTab = ref('reports')
  644. // 我的报告列表
  645. const myReports = ref([])
  646. const loadingReports = ref(false)
  647. const reportSearchKeyword = ref('')
  648. // 筛选后的报告列表
  649. const filteredReports = computed(() => {
  650. if (!reportSearchKeyword.value) {
  651. return myReports.value
  652. }
  653. const keyword = reportSearchKeyword.value.toLowerCase()
  654. return myReports.value.filter(r =>
  655. r.name?.toLowerCase().includes(keyword)
  656. )
  657. })
  658. // 加载我的报告列表
  659. async function loadMyReports() {
  660. loadingReports.value = true
  661. try {
  662. const data = await templateApi.list(1, 50)
  663. myReports.value = data.records || data || []
  664. } catch (error) {
  665. console.warn('获取报告列表失败:', error)
  666. myReports.value = []
  667. } finally {
  668. loadingReports.value = false
  669. }
  670. }
  671. // 切换报告
  672. async function switchReport(report) {
  673. // 如果点击的是当前已选中的报告,则取消选中
  674. if (currentDocumentId.value && currentDocumentId.value === report.baseDocumentId) {
  675. unselectReport()
  676. return
  677. }
  678. // 如果是当前选中的空白报告(无 baseDocumentId),也取消选中
  679. if (!report.baseDocumentId && currentReportId.value === report.id) {
  680. unselectReport()
  681. return
  682. }
  683. // 更新当前报告ID(用于标记选中状态)
  684. currentReportId.value = report.id
  685. if (!report.baseDocumentId) {
  686. // 没有关联文档,进入空白编辑页面
  687. currentDocumentId.value = 'empty-' + report.id // 使用特殊标识表示空白文档
  688. reportTitle.value = report.name || '未命名报告'
  689. // 清空文档内容,显示空白提示
  690. blocks.value = []
  691. tocItems.value = []
  692. documentContent.value = emptyPlaceholder
  693. entities.value = []
  694. sourceFiles.value = []
  695. return
  696. }
  697. // 更新 URL(不刷新页面)
  698. const newUrl = new URL(window.location.href)
  699. newUrl.searchParams.set('doc', report.baseDocumentId)
  700. window.history.replaceState({}, '', newUrl.toString())
  701. // 更新当前文档ID
  702. currentDocumentId.value = report.baseDocumentId
  703. // 加载新文档
  704. await loadDocumentById(report.baseDocumentId)
  705. // 更新报告标题
  706. reportTitle.value = report.name || '未命名报告'
  707. }
  708. // 取消选中报告,回到初始状态
  709. function unselectReport() {
  710. // 清除 URL 参数
  711. const newUrl = new URL(window.location.href)
  712. newUrl.searchParams.delete('doc')
  713. window.history.replaceState({}, '', newUrl.toString())
  714. // 清除当前文档和报告
  715. currentDocumentId.value = null
  716. currentReportId.value = null
  717. reportTitle.value = '新建报告'
  718. // 清空文档内容
  719. blocks.value = []
  720. tocItems.value = []
  721. documentContent.value = emptyPlaceholder
  722. entities.value = []
  723. sourceFiles.value = []
  724. // 切换回报告 Tab
  725. leftPanelTab.value = 'reports'
  726. }
  727. // 根据文档ID加载文档
  728. async function loadDocumentById(documentId) {
  729. if (!documentId) return
  730. loading.value = true
  731. try {
  732. const [structuredDoc] = await Promise.all([
  733. documentApi.getStructured(documentId),
  734. loadToc(documentId)
  735. ])
  736. if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
  737. blocks.value = structuredDoc.blocks
  738. documentContent.value = renderStructuredDocument(structuredDoc)
  739. entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
  740. } else {
  741. blocks.value = []
  742. documentContent.value = emptyPlaceholder
  743. entities.value = []
  744. }
  745. } catch (error) {
  746. console.warn('加载文档失败:', error)
  747. blocks.value = []
  748. documentContent.value = emptyPlaceholder
  749. entities.value = []
  750. } finally {
  751. loading.value = false
  752. }
  753. }
  754. // 新建报告
  755. async function handleCreateReport() {
  756. try {
  757. const { value: reportName } = await ElMessageBox.prompt('请输入报告名称', '新建报告', {
  758. confirmButtonText: '创建',
  759. cancelButtonText: '取消',
  760. inputPattern: /\S+/,
  761. inputErrorMessage: '报告名称不能为空'
  762. })
  763. if (reportName) {
  764. const newReport = await templateApi.create({ name: reportName })
  765. ElMessage.success('报告创建成功')
  766. // 刷新报告列表
  767. await loadMyReports()
  768. // 如果新报告有文档,切换到该报告
  769. if (newReport && newReport.baseDocumentId) {
  770. await switchReport(newReport)
  771. }
  772. }
  773. } catch (error) {
  774. if (error !== 'cancel') {
  775. console.error('创建报告失败:', error)
  776. ElMessage.error('创建报告失败')
  777. }
  778. }
  779. }
  780. // 上传文件入口
  781. function handleUploadFile() {
  782. // 先选择一个报告,然后切换到资源 Tab
  783. if (myReports.value.length > 0) {
  784. leftPanelTab.value = 'reports'
  785. ElMessage.info('请先选择一个报告,然后在资源面板上传文件')
  786. } else {
  787. ElMessage.info('请先创建一个报告')
  788. }
  789. }
  790. // AI 辅助入口
  791. function handleAiAssist() {
  792. ElMessage.info('AI 辅助功能开发中...')
  793. }
  794. // 格式化报告时间
  795. function formatReportTime(dateStr) {
  796. if (!dateStr) return ''
  797. const date = new Date(dateStr)
  798. const now = new Date()
  799. const diffMs = now - date
  800. const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
  801. if (diffDays === 0) {
  802. return '今天'
  803. } else if (diffDays === 1) {
  804. return '昨天'
  805. } else if (diffDays < 7) {
  806. return `${diffDays}天前`
  807. } else {
  808. return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
  809. }
  810. }
  811. // 获取报告状态文本
  812. function getReportStatusText(status) {
  813. const statusMap = {
  814. 'draft': '草稿',
  815. 'published': '已发布',
  816. 'archived': '已归档',
  817. 'editing': '编辑中'
  818. }
  819. return statusMap[status] || '草稿'
  820. }
  821. // 来源文件(从 API 获取)
  822. const sourceFiles = ref([])
  823. const selectedFile = ref(null)
  824. const showAddSourceDialog = ref(false)
  825. const newSourceFile = reactive({
  826. alias: '',
  827. description: '',
  828. required: true
  829. })
  830. // 文档结构块(用于生成目录等)
  831. const blocks = ref([])
  832. // 目录数据(从 API 获取)
  833. const tocItems = ref([])
  834. // 加载文档目录
  835. async function loadToc(documentId) {
  836. try {
  837. const items = await documentApi.getToc(documentId)
  838. tocItems.value = items || []
  839. } catch (error) {
  840. console.warn('获取文档目录失败:', error)
  841. tocItems.value = []
  842. }
  843. }
  844. // 获取目录项的项目符号
  845. function getTocBullet(level) {
  846. const bullets = ['●', '○', '◦']
  847. return bullets[Math.min(level - 1, bullets.length - 1)]
  848. }
  849. // 规范化文本用于比较
  850. function normalizeForCompare(text) {
  851. if (!text) return ''
  852. return text
  853. .replace(/\s+/g, ' ') // 多个空格合并
  854. .replace(/[\u00A0\u202F\u2009]/g, ' ') // 特殊空格转普通空格
  855. .trim()
  856. }
  857. // 滚动到指定章节(只匹配 h1-h6 标题元素)
  858. function scrollToHeading(item) {
  859. const titleText = normalizeForCompare(item.title || item.text)
  860. if (!titleText) return
  861. // 在文档内容区域中查找标题元素
  862. const editorContent = document.querySelector('.editor-content')
  863. if (!editorContent) return
  864. // 查找 h1-h6 标题元素
  865. const headings = editorContent.querySelectorAll('h1, h2, h3, h4, h5, h6')
  866. for (const heading of headings) {
  867. const headingText = normalizeForCompare(heading.textContent)
  868. if (headingText === titleText) {
  869. heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
  870. highlightElement(heading)
  871. return
  872. }
  873. }
  874. ElMessage.info(`章节「${titleText}」在当前文档中不存在`)
  875. }
  876. // 高亮元素
  877. function highlightElement(el) {
  878. el.classList.add('highlight-block')
  879. setTimeout(() => {
  880. el.classList.remove('highlight-block')
  881. }, 2000)
  882. }
  883. // 旧的滚动方法(用于 blockId)
  884. function scrollToBlock(blockId) {
  885. if (!blockId) return
  886. const blockEl = document.querySelector(`[data-block-id="${blockId}"]`)
  887. if (blockEl) {
  888. blockEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
  889. // 高亮一下
  890. blockEl.classList.add('highlight-block')
  891. setTimeout(() => {
  892. blockEl.classList.remove('highlight-block')
  893. }, 2000)
  894. }
  895. }
  896. // 变量(从 API 获取)
  897. const variables = ref([])
  898. // 文档中的实体(从 blocks 的 elements 中提取)
  899. const entities = ref([])
  900. // 要素搜索和筛选
  901. const entitySearchKeyword = ref('')
  902. const entityTypeFilter = ref('')
  903. // 计算属性:按类型统计要素数量
  904. const entityTypeCounts = computed(() => {
  905. const counts = {}
  906. if (!entities.value || !Array.isArray(entities.value)) return counts
  907. entities.value.forEach(entity => {
  908. if (!entity) return
  909. const type = entity.type || 'default'
  910. counts[type] = (counts[type] || 0) + 1
  911. })
  912. return counts
  913. })
  914. // 实体显示数量限制(性能优化)
  915. const entityDisplayLimit = ref(50)
  916. const ENTITY_PAGE_SIZE = 50
  917. // 计算属性:筛选后的要素列表(全部)
  918. const allFilteredEntities = computed(() => {
  919. if (!entities.value || !Array.isArray(entities.value)) return []
  920. let result = entities.value.filter(e => e != null)
  921. // 按类型筛选
  922. if (entityTypeFilter.value) {
  923. result = result.filter(e => e.type === entityTypeFilter.value)
  924. }
  925. // 按关键词搜索
  926. if (entitySearchKeyword.value) {
  927. const keyword = entitySearchKeyword.value.toLowerCase()
  928. result = result.filter(e =>
  929. e.text?.toLowerCase()?.includes(keyword) ||
  930. e.type?.toLowerCase()?.includes(keyword)
  931. )
  932. }
  933. return result
  934. })
  935. // 计算属性:限制显示数量的要素列表
  936. const filteredEntities = computed(() => {
  937. const all = allFilteredEntities.value || []
  938. return all.slice(0, entityDisplayLimit.value)
  939. })
  940. // 是否还有更多实体可显示
  941. const hasMoreEntities = computed(() => {
  942. const all = allFilteredEntities.value || []
  943. return all.length > entityDisplayLimit.value
  944. })
  945. // 剩余未显示的实体数量
  946. const remainingEntitiesCount = computed(() => {
  947. const all = allFilteredEntities.value || []
  948. return Math.max(0, all.length - entityDisplayLimit.value)
  949. })
  950. // 加载更多实体
  951. function loadMoreEntities() {
  952. entityDisplayLimit.value += ENTITY_PAGE_SIZE
  953. }
  954. // 重置实体显示数量(筛选条件变化时)
  955. watch([entityTypeFilter, entitySearchKeyword], () => {
  956. entityDisplayLimit.value = ENTITY_PAGE_SIZE
  957. })
  958. // 切换类型筛选
  959. function toggleEntityTypeFilter(type) {
  960. if (entityTypeFilter.value === type) {
  961. entityTypeFilter.value = ''
  962. } else {
  963. entityTypeFilter.value = type
  964. }
  965. }
  966. // 获取实体类型名称(支持后端返回的英文类型)
  967. function getEntityTypeName(type) {
  968. const typeNames = {
  969. // 中文类型
  970. 'entity': '实体',
  971. 'concept': '概念',
  972. 'data': '数据',
  973. 'location': '地点',
  974. 'asset': '资产',
  975. 'person': '人物',
  976. 'org': '组织',
  977. 'date': '日期',
  978. 'product': '产品',
  979. 'event': '事件',
  980. 'law': '法规',
  981. 'default': '其他',
  982. // 后端返回的英文类型
  983. 'DOC_ID': '文档编号',
  984. 'ORG': '组织机构',
  985. 'ORGANIZATION': '组织机构',
  986. 'PERSON': '人物',
  987. 'LOCATION': '地点',
  988. 'LOC': '地点',
  989. 'DATE': '日期',
  990. 'TIME': '时间',
  991. 'MONEY': '金额',
  992. 'PERCENT': '百分比',
  993. 'PRODUCT': '产品',
  994. 'EVENT': '事件',
  995. 'LAW': '法规',
  996. 'WORK_OF_ART': '作品',
  997. 'LANGUAGE': '语言',
  998. 'NORP': '民族/宗教/政治团体',
  999. 'FAC': '设施',
  1000. 'FACILITY': '设施',
  1001. 'GPE': '地理政治实体',
  1002. 'CARDINAL': '数量',
  1003. 'ORDINAL': '序数',
  1004. 'QUANTITY': '数量单位',
  1005. 'TITLE': '职务/头衔',
  1006. 'STANDARD': '标准规范',
  1007. 'RATING': '评级',
  1008. 'PERIOD': '时间段',
  1009. 'SCORE': '评分',
  1010. 'LEVEL': '等级',
  1011. // 业务相关类型
  1012. 'CERT': '证书/资质',
  1013. 'NUMBER': '编号/数值',
  1014. 'METHOD': '方法/流程',
  1015. 'PROJECT': '项目',
  1016. 'POLICY': '政策/制度',
  1017. 'DEVICE': '设备',
  1018. 'MATERIAL': '材料',
  1019. 'TECHNOLOGY': '技术',
  1020. 'REQUIREMENT': '要求',
  1021. 'INDICATOR': '指标',
  1022. 'RESULT': '结果',
  1023. 'PROBLEM': '问题',
  1024. 'SOLUTION': '解决方案',
  1025. 'RISK': '风险',
  1026. 'MEASURE': '措施',
  1027. 'DEPARTMENT': '部门',
  1028. 'ROLE': '角色',
  1029. 'DOCUMENT': '文档',
  1030. 'REGULATION': '法规',
  1031. 'PROCEDURE': '程序',
  1032. 'ACTIVITY': '活动',
  1033. 'TASK': '任务',
  1034. 'GOAL': '目标',
  1035. 'RESOURCE': '资源',
  1036. 'SYSTEM': '系统',
  1037. 'AREA': '区域',
  1038. 'EQUIPMENT': '设备',
  1039. 'TOOL': '工具',
  1040. 'SOFTWARE': '软件',
  1041. 'DATA': '数据',
  1042. 'RECORD': '记录',
  1043. 'REPORT': '报告',
  1044. 'PLAN': '计划',
  1045. 'SCHEDULE': '日程',
  1046. 'BUDGET': '预算',
  1047. 'COST': '成本',
  1048. 'UNIT': '单位',
  1049. 'COMPANY': '公司',
  1050. 'INSTITUTION': '机构'
  1051. }
  1052. const upperType = type?.toUpperCase()
  1053. return typeNames[type] || typeNames[upperType] || type || '其他'
  1054. }
  1055. /**
  1056. * 从结构化文档的 blocks 中提取所有实体
  1057. */
  1058. function extractEntitiesFromBlocks(blocks) {
  1059. const entityList = []
  1060. const entityMap = new Map() // 用于去重
  1061. if (!blocks || !Array.isArray(blocks)) {
  1062. return entityList
  1063. }
  1064. for (const block of blocks) {
  1065. if (!block.elements || !Array.isArray(block.elements)) {
  1066. continue
  1067. }
  1068. for (const element of block.elements) {
  1069. if (element.type === 'entity' && element.entityId) {
  1070. // 使用 entityId 去重
  1071. if (!entityMap.has(element.entityId)) {
  1072. entityMap.set(element.entityId, true)
  1073. entityList.push({
  1074. id: element.entityId,
  1075. text: element.entityText || '',
  1076. type: element.entityType || 'ENTITY',
  1077. confirmed: element.confirmed || false
  1078. })
  1079. }
  1080. }
  1081. }
  1082. }
  1083. return entityList
  1084. }
  1085. // 加载模板数据
  1086. onMounted(async () => {
  1087. try {
  1088. // 加载报告列表
  1089. await loadMyReports()
  1090. // 检查 URL 是否有指定文档ID
  1091. const docId = route.query.doc
  1092. if (docId) {
  1093. // 只有 URL 明确指定了文档才加载
  1094. currentDocumentId.value = docId
  1095. await loadDocumentById(docId)
  1096. // 找到对应的报告设置标题和ID
  1097. const report = myReports.value.find(r => r.baseDocumentId === docId)
  1098. if (report) {
  1099. reportTitle.value = report.name || '未命名报告'
  1100. currentReportId.value = report.id
  1101. }
  1102. } else {
  1103. // 没有指定文档,显示欢迎页,不自动选中任何报告
  1104. reportTitle.value = '新建报告'
  1105. currentReportId.value = null
  1106. documentContent.value = emptyPlaceholder
  1107. }
  1108. } catch (err) {
  1109. console.error('初始化失败:', err)
  1110. documentContent.value = emptyPlaceholder
  1111. }
  1112. })
  1113. const showVariableDialog = ref(false)
  1114. const showAddVariableDialog = ref(false)
  1115. const editingVariable = ref(null)
  1116. const variableForm = reactive({
  1117. name: '',
  1118. displayName: '',
  1119. category: 'entity',
  1120. exampleValue: '',
  1121. sourceType: 'document',
  1122. sourceFileAlias: '',
  1123. extractType: 'direct'
  1124. })
  1125. // 右键菜单
  1126. const contextMenuVisible = ref(false)
  1127. const contextMenuPos = reactive({ x: 0, y: 0 })
  1128. const selectedText = ref('')
  1129. const selectionRange = ref(null)
  1130. // 知识图谱
  1131. const showGraphModal = ref(false)
  1132. // 实体编辑弹窗
  1133. const showEntityEditModal = ref(false)
  1134. const editingEntity = ref(null)
  1135. /**
  1136. * 打开实体编辑弹窗
  1137. */
  1138. function openEntityEditModal(entity) {
  1139. editingEntity.value = { ...entity }
  1140. showEntityEditModal.value = true
  1141. }
  1142. /**
  1143. * 确认编辑中的实体
  1144. */
  1145. async function confirmEditingEntity() {
  1146. if (!editingEntity.value) return
  1147. await handleConfirmEntity(editingEntity.value)
  1148. editingEntity.value.confirmed = true
  1149. }
  1150. /**
  1151. * 删除编辑中的实体
  1152. */
  1153. async function deleteEditingEntity() {
  1154. if (!editingEntity.value) return
  1155. await handleUnmarkEntity(editingEntity.value)
  1156. showEntityEditModal.value = false
  1157. editingEntity.value = null
  1158. }
  1159. /**
  1160. * 保存编辑中的实体
  1161. */
  1162. async function saveEditingEntity() {
  1163. if (!editingEntity.value) return
  1164. // 查找原实体
  1165. const idx = entities.value.findIndex(e => e.id === editingEntity.value.id)
  1166. if (idx === -1) {
  1167. showEntityEditModal.value = false
  1168. return
  1169. }
  1170. const originalEntity = entities.value[idx]
  1171. // 如果类型发生变化,调用更新API
  1172. if (originalEntity.type !== editingEntity.value.type) {
  1173. await handleUpdateEntityType(originalEntity, editingEntity.value.type)
  1174. }
  1175. showEntityEditModal.value = false
  1176. editingEntity.value = null
  1177. }
  1178. /**
  1179. * 全局函数:从文档内点击实体时调用
  1180. * 注意:函数名与 ref 变量不同,避免冲突
  1181. */
  1182. window.handleEntityClick = function(event, entityId) {
  1183. event.stopPropagation()
  1184. const entity = entities.value.find(e => e.id === entityId)
  1185. if (entity) {
  1186. openEntityEditModal(entity)
  1187. }
  1188. }
  1189. // 文档内容(从 API 获取或空白)
  1190. const documentContent = ref('')
  1191. // 监听文档内容变化,更新 DOM
  1192. watch(documentContent, (newContent) => {
  1193. nextTick(() => {
  1194. if (editorContentRef.value) {
  1195. editorContentRef.value.innerHTML = newContent || ''
  1196. }
  1197. })
  1198. })
  1199. // 空白模板时的占位提示 - V2 风格
  1200. const emptyPlaceholder = `
  1201. <div class="empty-editor-placeholder">
  1202. <div class="empty-icon">📄</div>
  1203. <h2>开始编辑您的报告</h2>
  1204. <p class="empty-subtitle">当前报告还没有内容,您可以:</p>
  1205. <div class="empty-actions">
  1206. <div class="action-card">
  1207. <span class="action-icon">📁</span>
  1208. <span class="action-text">上传来源文件自动解析生成内容</span>
  1209. </div>
  1210. <div class="action-card">
  1211. <span class="action-icon">✏️</span>
  1212. <span class="action-text">直接在此处开始编辑</span>
  1213. </div>
  1214. <div class="action-card">
  1215. <span class="action-icon">🤖</span>
  1216. <span class="action-text">使用 AI 助手辅助撰写</span>
  1217. </div>
  1218. </div>
  1219. <p class="empty-hint">💡 提示:选中文本后右键可将其标记为要素</p>
  1220. </div>
  1221. `
  1222. /**
  1223. * 渲染结构化文档(合并 blocks 和 images)
  1224. * 根据 index 排序,将图片插入到正确的位置
  1225. */
  1226. function renderStructuredDocument(structuredDoc) {
  1227. const blocks = structuredDoc.blocks || []
  1228. const images = structuredDoc.images || []
  1229. const tables = structuredDoc.tables || []
  1230. const paragraphs = structuredDoc.paragraphs || []
  1231. // 将所有元素合并
  1232. const allElements = []
  1233. // 从 blocks 中提取实体映射(按文本内容匹配)
  1234. const entityMap = buildEntityMap(blocks)
  1235. // 检查 paragraphs 是否有 runs(带格式信息)
  1236. const hasParagraphsWithRuns = paragraphs.some(p => p.runs && p.runs.length > 0)
  1237. if (hasParagraphsWithRuns) {
  1238. // 使用 paragraphs 渲染(保留格式 + 合并实体高亮)
  1239. paragraphs.forEach(para => {
  1240. // 查找对应的块ID(通过 index 匹配)
  1241. const matchingBlock = blocks.find(b => b.index === para.index)
  1242. const blockId = matchingBlock?.id || matchingBlock?.blockId || `para_${para.index}`
  1243. const content = renderParagraphWithRunsAndEntities(para, entityMap)
  1244. allElements.push({
  1245. type: 'paragraph',
  1246. index: para.index,
  1247. html: `<div data-block-id="${blockId}" class="doc-block">${content}</div>`
  1248. })
  1249. })
  1250. } else if (blocks.length > 0) {
  1251. // 回退到 blocks 渲染(带实体标记,但无格式)
  1252. blocks.forEach(block => {
  1253. if (block.type === 'page') return // 跳过根节点
  1254. // 添加 data-block-id 属性以支持实体标记定位
  1255. const blockId = block.id || block.blockId
  1256. const content = block.markedHtml || block.html || block.plainText || ''
  1257. allElements.push({
  1258. type: 'block',
  1259. index: block.index,
  1260. html: `<div data-block-id="${blockId}" class="doc-block">${content}</div>`
  1261. })
  1262. })
  1263. }
  1264. // 添加图片(保持原始尺寸,不显示说明文字)
  1265. images.forEach(img => {
  1266. // 图片样式:保持原始尺寸,不强制居中
  1267. const imgStyle = img.width && img.height
  1268. ? `width:${img.width}px; height:${img.height}px;`
  1269. : 'max-width: 100%; height: auto;'
  1270. allElements.push({
  1271. type: 'image',
  1272. index: img.index,
  1273. html: `<div class="doc-image" style="margin: 8px 0;">
  1274. <img src="${img.url}" alt="${img.alt || '图片'}" style="${imgStyle}" />
  1275. </div>`
  1276. })
  1277. })
  1278. // 添加表格
  1279. tables.forEach(table => {
  1280. allElements.push({
  1281. type: 'table',
  1282. index: table.index,
  1283. html: renderTable(table, entityMap)
  1284. })
  1285. })
  1286. // 按 index 排序
  1287. allElements.sort((a, b) => (a.index || 0) - (b.index || 0))
  1288. // 合并 HTML
  1289. return allElements.map(el => el.html).join('')
  1290. }
  1291. /**
  1292. * 渲染表格
  1293. */
  1294. function renderTable(table, entityMap) {
  1295. if (!table.rows || table.rows.length === 0) {
  1296. return '<div class="doc-table-empty">空表格</div>'
  1297. }
  1298. let html = '<div class="doc-table-container"><table class="doc-table">'
  1299. table.rows.forEach((row, rowIndex) => {
  1300. html += '<tr>'
  1301. row.forEach((cell, colIndex) => {
  1302. const tag = rowIndex === 0 ? 'th' : 'td'
  1303. const attrs = []
  1304. if (cell.rowSpan && cell.rowSpan > 1) {
  1305. attrs.push(`rowspan="${cell.rowSpan}"`)
  1306. }
  1307. if (cell.colSpan && cell.colSpan > 1) {
  1308. attrs.push(`colspan="${cell.colSpan}"`)
  1309. }
  1310. // 单元格样式
  1311. const styleAttrs = []
  1312. if (cell.style) {
  1313. if (cell.style.alignment) {
  1314. const alignMap = { 'left': 'left', 'center': 'center', 'right': 'right', 'both': 'justify' }
  1315. styleAttrs.push(`text-align:${alignMap[cell.style.alignment] || cell.style.alignment}`)
  1316. }
  1317. if (cell.style.backgroundColor) {
  1318. styleAttrs.push(`background-color:#${cell.style.backgroundColor}`)
  1319. }
  1320. }
  1321. if (styleAttrs.length > 0) {
  1322. attrs.push(`style="${styleAttrs.join(';')}"`)
  1323. }
  1324. // 单元格内容(支持 runs 格式)
  1325. let content = ''
  1326. if (cell.runs && cell.runs.length > 0) {
  1327. content = cell.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
  1328. } else {
  1329. content = highlightEntitiesInText(cell.text || '', entityMap)
  1330. }
  1331. html += `<${tag} ${attrs.join(' ')}>${content}</${tag}>`
  1332. })
  1333. html += '</tr>'
  1334. })
  1335. html += '</table></div>'
  1336. return html
  1337. }
  1338. /**
  1339. * 从 blocks 中构建实体映射
  1340. * 返回 { entityText: { entityId, entityType, confirmed } }
  1341. */
  1342. function buildEntityMap(blocks) {
  1343. const entityMap = new Map()
  1344. blocks.forEach(block => {
  1345. if (!block.elements) return
  1346. block.elements.forEach(el => {
  1347. if (el.type === 'entity' && el.entityText) {
  1348. // 使用实体文本作为 key(可能有多个相同文本的实体)
  1349. if (!entityMap.has(el.entityText)) {
  1350. entityMap.set(el.entityText, [])
  1351. }
  1352. entityMap.get(el.entityText).push({
  1353. entityId: el.entityId,
  1354. entityType: el.entityType,
  1355. confirmed: el.confirmed
  1356. })
  1357. }
  1358. })
  1359. })
  1360. return entityMap
  1361. }
  1362. /**
  1363. * 渲染带格式和实体高亮的段落
  1364. */
  1365. function renderParagraphWithRunsAndEntities(para, entityMap) {
  1366. if (!para.runs || para.runs.length === 0) {
  1367. // 没有 runs,使用纯文本
  1368. const content = highlightEntitiesInText(para.content || '', entityMap)
  1369. return wrapWithParagraphTag(content, para.type, para.style)
  1370. }
  1371. // 渲染每个 run,同时应用实体高亮
  1372. const runsHtml = para.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
  1373. return wrapWithParagraphTag(runsHtml, para.type, para.style)
  1374. }
  1375. /**
  1376. * 渲染带格式的段落(使用 runs)- 保留兼容
  1377. */
  1378. function renderParagraphWithRuns(para) {
  1379. if (!para.runs || para.runs.length === 0) {
  1380. const content = escapeHtml(para.content || '').replace(/\n/g, '<br>')
  1381. return wrapWithParagraphTag(content, para.type, para.style)
  1382. }
  1383. const runsHtml = para.runs.map(run => renderTextRun(run)).join('')
  1384. return wrapWithParagraphTag(runsHtml, para.type, para.style)
  1385. }
  1386. /**
  1387. * 渲染单个文本片段(Run)并高亮实体
  1388. */
  1389. function renderTextRunWithEntities(run, entityMap) {
  1390. if (!run || !run.text) return ''
  1391. // 先在文本中查找并高亮实体
  1392. const highlightedText = highlightEntitiesInText(run.text, entityMap)
  1393. // 如果文本被实体高亮处理过(包含 span 标签),需要特殊处理样式
  1394. const hasEntityHighlight = highlightedText.includes('entity-highlight')
  1395. // 构建样式
  1396. const styles = buildRunStyles(run)
  1397. // 如果没有样式,直接返回高亮后的文本
  1398. if (styles.length === 0) {
  1399. return highlightedText.replace(/\n/g, '<br>')
  1400. }
  1401. // 如果有实体高亮,需要用 span 包裹整体样式
  1402. if (hasEntityHighlight) {
  1403. return `<span style="${styles.join(';')}">${highlightedText.replace(/\n/g, '<br>')}</span>`
  1404. }
  1405. // 普通文本,处理换行并应用样式
  1406. const text = escapeHtml(run.text).replace(/\n/g, '<br>')
  1407. // 上下标特殊处理
  1408. if (run.verticalAlign === 'superscript') {
  1409. return `<sup style="${styles.join(';')}">${text}</sup>`
  1410. } else if (run.verticalAlign === 'subscript') {
  1411. return `<sub style="${styles.join(';')}">${text}</sub>`
  1412. }
  1413. return `<span style="${styles.join(';')}">${text}</span>`
  1414. }
  1415. /**
  1416. * 在文本中查找并高亮实体
  1417. */
  1418. function highlightEntitiesInText(text, entityMap) {
  1419. if (!text || !entityMap || entityMap.size === 0) {
  1420. return escapeHtml(text || '')
  1421. }
  1422. // 按实体文本长度降序排序(优先匹配长的)
  1423. const sortedEntities = Array.from(entityMap.keys()).sort((a, b) => b.length - a.length)
  1424. let result = text
  1425. const replacements = []
  1426. // 找出所有需要替换的位置
  1427. for (const entityText of sortedEntities) {
  1428. const entities = entityMap.get(entityText)
  1429. if (!entities || entities.length === 0) continue
  1430. let searchStart = 0
  1431. let entityIndex = 0
  1432. while (true) {
  1433. const pos = result.indexOf(entityText, searchStart)
  1434. if (pos === -1) break
  1435. // 获取对应的实体信息(循环使用)
  1436. const entity = entities[entityIndex % entities.length]
  1437. replacements.push({
  1438. start: pos,
  1439. end: pos + entityText.length,
  1440. text: entityText,
  1441. entity: entity
  1442. })
  1443. searchStart = pos + entityText.length
  1444. entityIndex++
  1445. }
  1446. }
  1447. // 按位置排序,从后往前替换(避免位置偏移)
  1448. replacements.sort((a, b) => b.start - a.start)
  1449. // 检查重叠,移除被包含的替换
  1450. const finalReplacements = []
  1451. for (const rep of replacements) {
  1452. const hasOverlap = finalReplacements.some(
  1453. existing => rep.start < existing.end && rep.end > existing.start
  1454. )
  1455. if (!hasOverlap) {
  1456. finalReplacements.push(rep)
  1457. }
  1458. }
  1459. // 执行替换
  1460. for (const rep of finalReplacements) {
  1461. const before = result.substring(0, rep.start)
  1462. const after = result.substring(rep.end)
  1463. const highlighted = renderEntityHighlight(rep.text, rep.entity)
  1464. result = before + highlighted + after
  1465. }
  1466. // 对非实体部分进行 HTML 转义
  1467. // 由于实体部分已经包含 HTML,需要分段处理
  1468. return escapeNonEntityText(result)
  1469. }
  1470. /**
  1471. * 转义非实体部分的文本
  1472. */
  1473. function escapeNonEntityText(text) {
  1474. // 分割出实体标签和普通文本
  1475. const parts = text.split(/(<span class="entity-highlight[^>]*>.*?<\/span>)/g)
  1476. return parts.map(part => {
  1477. if (part.startsWith('<span class="entity-highlight')) {
  1478. return part // 保留实体标签
  1479. }
  1480. return escapeHtml(part) // 转义普通文本
  1481. }).join('')
  1482. }
  1483. /**
  1484. * 渲染实体高亮标签
  1485. */
  1486. function renderEntityHighlight(text, entity) {
  1487. const cssClass = getEntityCssClass(entity.entityType)
  1488. const confirmedMark = entity.confirmed ? ' ✓' : ''
  1489. return `<span class="${cssClass}" ` +
  1490. `data-entity-id="${entity.entityId || ''}" ` +
  1491. `data-type="${entity.entityType || ''}" ` +
  1492. `onclick="handleEntityClick(event,'${entity.entityId || ''}')" ` +
  1493. `contenteditable="false">${escapeHtml(text)}${confirmedMark}</span>`
  1494. }
  1495. /**
  1496. * 获取实体类型对应的 CSS 类
  1497. */
  1498. function getEntityCssClass(entityType) {
  1499. const typeMap = {
  1500. 'PERSON': 'entity-highlight person',
  1501. 'ORG': 'entity-highlight org',
  1502. 'ORGANIZATION': 'entity-highlight org',
  1503. 'LOC': 'entity-highlight location',
  1504. 'LOCATION': 'entity-highlight location',
  1505. 'GPE': 'entity-highlight location',
  1506. 'DATE': 'entity-highlight date',
  1507. 'TIME': 'entity-highlight date',
  1508. 'MONEY': 'entity-highlight data',
  1509. 'NUMBER': 'entity-highlight data',
  1510. 'PERCENT': 'entity-highlight data',
  1511. 'DATA': 'entity-highlight data',
  1512. 'CONCEPT': 'entity-highlight concept',
  1513. 'PRODUCT': 'entity-highlight product',
  1514. 'EVENT': 'entity-highlight event'
  1515. }
  1516. return typeMap[entityType?.toUpperCase()] || 'entity-highlight entity'
  1517. }
  1518. /**
  1519. * 构建 Run 的 CSS 样式数组
  1520. */
  1521. function buildRunStyles(run) {
  1522. const styles = []
  1523. if (run.fontFamily) {
  1524. styles.push(`font-family:${run.fontFamily}`)
  1525. }
  1526. if (run.fontSize && run.fontSize > 0) {
  1527. styles.push(`font-size:${run.fontSize}pt`)
  1528. }
  1529. if (run.color) {
  1530. const color = run.color.startsWith('#') ? run.color : `#${run.color}`
  1531. styles.push(`color:${color}`)
  1532. }
  1533. if (run.highlightColor) {
  1534. const bgColor = getHighlightColor(run.highlightColor)
  1535. styles.push(`background-color:${bgColor}`)
  1536. }
  1537. if (run.bold) {
  1538. styles.push('font-weight:bold')
  1539. }
  1540. if (run.italic) {
  1541. styles.push('font-style:italic')
  1542. }
  1543. const textDecorations = []
  1544. if (run.underline && run.underline !== 'none') {
  1545. const underlineStyle = run.underline === 'double' ? 'double' :
  1546. run.underline === 'wave' || run.underline === 'wavy' ? 'wavy' :
  1547. run.underline === 'dotted' ? 'dotted' :
  1548. run.underline === 'dashed' ? 'dashed' : 'solid'
  1549. textDecorations.push(`underline ${underlineStyle}`)
  1550. }
  1551. if (run.strikeThrough) {
  1552. textDecorations.push('line-through')
  1553. }
  1554. if (textDecorations.length > 0) {
  1555. styles.push(`text-decoration:${textDecorations.join(' ')}`)
  1556. }
  1557. return styles
  1558. }
  1559. /**
  1560. * 渲染单个文本片段(Run)- 保留兼容
  1561. */
  1562. function renderTextRun(run) {
  1563. if (!run || !run.text) return ''
  1564. // 转义 HTML 并将换行符转换为 <br>
  1565. let text = escapeHtml(run.text).replace(/\n/g, '<br>')
  1566. const styles = buildRunStyles(run)
  1567. // 上下标
  1568. if (run.verticalAlign === 'superscript') {
  1569. return styles.length > 0 ? `<sup style="${styles.join(';')}">${text}</sup>` : `<sup>${text}</sup>`
  1570. } else if (run.verticalAlign === 'subscript') {
  1571. return styles.length > 0 ? `<sub style="${styles.join(';')}">${text}</sub>` : `<sub>${text}</sub>`
  1572. }
  1573. // 如果没有样式,直接返回文本
  1574. if (styles.length === 0) {
  1575. return text
  1576. }
  1577. return `<span style="${styles.join(';')}">${text}</span>`
  1578. }
  1579. /**
  1580. * 获取高亮颜色对应的 CSS 颜色
  1581. */
  1582. function getHighlightColor(colorName) {
  1583. const colors = {
  1584. 'yellow': '#ffff00',
  1585. 'green': '#00ff00',
  1586. 'cyan': '#00ffff',
  1587. 'magenta': '#ff00ff',
  1588. 'blue': '#0000ff',
  1589. 'red': '#ff0000',
  1590. 'darkblue': '#000080',
  1591. 'darkcyan': '#008080',
  1592. 'darkgreen': '#008000',
  1593. 'darkmagenta': '#800080',
  1594. 'darkred': '#800000',
  1595. 'darkyellow': '#808000',
  1596. 'darkgray': '#808080',
  1597. 'lightgray': '#c0c0c0',
  1598. 'black': '#000000'
  1599. }
  1600. return colors[colorName.toLowerCase()] || colorName
  1601. }
  1602. /**
  1603. * 用段落标签包裹内容
  1604. */
  1605. function wrapWithParagraphTag(content, type, style) {
  1606. // 段落样式
  1607. const styleAttrs = []
  1608. if (style) {
  1609. // 对齐方式
  1610. if (style.alignment) {
  1611. const alignMap = {
  1612. 'left': 'left',
  1613. 'center': 'center',
  1614. 'right': 'right',
  1615. 'both': 'justify', // 两端对齐
  1616. 'justify': 'justify'
  1617. }
  1618. styleAttrs.push(`text-align:${alignMap[style.alignment] || style.alignment}`)
  1619. }
  1620. // 左缩进(twips -> pt,1 twip = 1/20 pt)
  1621. if (style.indentLeft) {
  1622. styleAttrs.push(`padding-left:${style.indentLeft / 20}pt`)
  1623. }
  1624. // 右缩进
  1625. if (style.indentRight) {
  1626. styleAttrs.push(`padding-right:${style.indentRight / 20}pt`)
  1627. }
  1628. // 首行缩进
  1629. if (style.indentFirstLine) {
  1630. styleAttrs.push(`text-indent:${style.indentFirstLine / 20}pt`)
  1631. }
  1632. // 悬挂缩进(负的首行缩进 + 增加左边距)
  1633. if (style.indentHanging) {
  1634. const hangingPt = style.indentHanging / 20
  1635. styleAttrs.push(`text-indent:-${hangingPt}pt`)
  1636. // 如果没有左缩进,需要增加左边距来补偿
  1637. if (!style.indentLeft) {
  1638. styleAttrs.push(`margin-left:${hangingPt}pt`)
  1639. }
  1640. }
  1641. // 段前间距
  1642. if (style.spacingBefore) {
  1643. styleAttrs.push(`margin-top:${style.spacingBefore / 20}pt`)
  1644. }
  1645. // 段后间距
  1646. if (style.spacingAfter) {
  1647. styleAttrs.push(`margin-bottom:${style.spacingAfter / 20}pt`)
  1648. }
  1649. // 行距处理
  1650. if (style.lineSpacing) {
  1651. // getSpacingBetween 返回的是倍数(如 1.0, 1.5, 2.0)
  1652. if (style.lineSpacing >= 1 && style.lineSpacing <= 5) {
  1653. styleAttrs.push(`line-height:${style.lineSpacing}`)
  1654. }
  1655. } else if (style.lineSpacingValue && style.lineSpacingRule) {
  1656. // 精确行距值处理
  1657. const rule = style.lineSpacingRule.toLowerCase()
  1658. if (rule === 'exact') {
  1659. // 固定行距(twips -> pt)
  1660. styleAttrs.push(`line-height:${style.lineSpacingValue / 20}pt`)
  1661. } else if (rule === 'atleast' || rule === 'at_least') {
  1662. // 最小行距
  1663. styleAttrs.push(`min-height:${style.lineSpacingValue / 20}pt`)
  1664. } else if (rule === 'auto') {
  1665. // 倍数行距(240 twips = 1 倍行距)
  1666. styleAttrs.push(`line-height:${style.lineSpacingValue / 240}`)
  1667. }
  1668. }
  1669. // 字体信息(段落级别的默认字体)
  1670. if (style.fontFamily) {
  1671. styleAttrs.push(`font-family:${style.fontFamily}`)
  1672. }
  1673. if (style.fontSize) {
  1674. styleAttrs.push(`font-size:${style.fontSize}pt`)
  1675. }
  1676. }
  1677. const styleAttr = styleAttrs.length > 0 ? ` style="${styleAttrs.join(';')}"` : ''
  1678. // 目录项特殊处理
  1679. if (type === 'toc_item') {
  1680. const pageNum = style?.tocPageNum || ''
  1681. // 计算缩进级别(根据章节号判断)
  1682. let level = 0
  1683. const levelMatch = content.match(/^(\d+(?:\.\d+)*)/)
  1684. if (levelMatch) {
  1685. level = (levelMatch[1].match(/\./g) || []).length
  1686. }
  1687. const indentStyle = level > 0 ? ` style="padding-left:${level * 20}px"` : ''
  1688. return `<div class="doc-toc-item"${indentStyle}><span class="toc-title">${content}</span><span class="toc-dots"></span><span class="toc-page">${pageNum}</span></div>`
  1689. }
  1690. switch (type) {
  1691. case 'heading1':
  1692. return `<h1${styleAttr}>${content}</h1>`
  1693. case 'heading2':
  1694. return `<h2${styleAttr}>${content}</h2>`
  1695. case 'heading3':
  1696. return `<h3${styleAttr}>${content}</h3>`
  1697. case 'heading':
  1698. return `<h2${styleAttr}>${content}</h2>`
  1699. case 'toc':
  1700. return `<div class="doc-toc-title"${styleAttr}>${content}</div>`
  1701. case 'bullet':
  1702. case 'list_item':
  1703. return `<div class="doc-list-item bullet"${styleAttr}>${content}</div>`
  1704. case 'ordered':
  1705. return `<div class="doc-list-item ordered"${styleAttr}>${content}</div>`
  1706. case 'quote':
  1707. return `<blockquote${styleAttr}>${content}</blockquote>`
  1708. case 'code':
  1709. return `<pre><code>${content}</code></pre>`
  1710. case 'title':
  1711. return `<h1 class="doc-title"${styleAttr}>${content}</h1>`
  1712. default:
  1713. return `<p${styleAttr}>${content}</p>`
  1714. }
  1715. }
  1716. /**
  1717. * HTML 转义
  1718. */
  1719. function escapeHtml(text) {
  1720. if (!text) return ''
  1721. return text
  1722. .replace(/&/g, '&amp;')
  1723. .replace(/</g, '&lt;')
  1724. .replace(/>/g, '&gt;')
  1725. .replace(/"/g, '&quot;')
  1726. }
  1727. // 计算属性
  1728. const groupedVariables = computed(() => {
  1729. const groups = {}
  1730. if (!variables.value || !Array.isArray(variables.value)) return groups
  1731. variables.value.forEach(v => {
  1732. if (!v) return
  1733. const cat = v.category || 'other'
  1734. if (!groups[cat]) groups[cat] = []
  1735. groups[cat].push(v)
  1736. })
  1737. return groups
  1738. })
  1739. // 方法
  1740. function goBack() {
  1741. router.back()
  1742. }
  1743. function handleSave() {
  1744. saved.value = true
  1745. ElMessage.success('保存成功')
  1746. }
  1747. // 重新生成文档块结构
  1748. async function handleRegenerateBlocks() {
  1749. const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
  1750. if (!baseDocumentId) {
  1751. ElMessage.warning('没有关联的示例文档')
  1752. return
  1753. }
  1754. regenerating.value = true
  1755. try {
  1756. const result = await documentApi.regenerateBlocks(baseDocumentId)
  1757. ElMessage.success(`重新生成成功: ${result.blockCount} 个文档块, ${result.entityCount} 个实体`)
  1758. // 重新加载文档内容
  1759. const structuredDoc = await documentApi.getStructured(baseDocumentId)
  1760. if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
  1761. blocks.value = structuredDoc.blocks // 更新 blocks
  1762. documentContent.value = renderStructuredDocument(structuredDoc)
  1763. // 重新提取实体
  1764. entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
  1765. }
  1766. } catch (error) {
  1767. console.error('重新生成失败:', error)
  1768. ElMessage.error('重新生成失败: ' + (error.message || '未知错误'))
  1769. } finally {
  1770. regenerating.value = false
  1771. }
  1772. }
  1773. function getFileIcon(file) {
  1774. return '📄'
  1775. }
  1776. function selectFile(file) {
  1777. selectedFile.value = file
  1778. }
  1779. async function removeSourceFile(file) {
  1780. try {
  1781. await templateStore.deleteSourceFile(file.id)
  1782. sourceFiles.value = sourceFiles.value.filter(f => f.id !== file.id)
  1783. ElMessage.success('删除成功')
  1784. } catch (error) {
  1785. ElMessage.error('删除失败: ' + error.message)
  1786. }
  1787. }
  1788. async function addSourceFile() {
  1789. if (!newSourceFile.alias) {
  1790. ElMessage.warning('请输入文件别名')
  1791. return
  1792. }
  1793. try {
  1794. const sf = await templateStore.addSourceFile(templateId, newSourceFile)
  1795. sourceFiles.value.push(sf)
  1796. showAddSourceDialog.value = false
  1797. Object.assign(newSourceFile, { alias: '', description: '', required: true })
  1798. ElMessage.success('添加成功')
  1799. } catch (error) {
  1800. ElMessage.error('添加失败: ' + error.message)
  1801. }
  1802. }
  1803. function getCategoryIcon(category) {
  1804. const icons = {
  1805. entity: '🏢',
  1806. concept: '💡',
  1807. data: '📊',
  1808. location: '📍',
  1809. asset: '📑'
  1810. }
  1811. return icons[category] || '📌'
  1812. }
  1813. function getCategoryColor(category) {
  1814. const colors = {
  1815. entity: '#1890ff',
  1816. concept: '#722ed1',
  1817. data: '#52c41a',
  1818. location: '#faad14',
  1819. asset: '#eb2f96'
  1820. }
  1821. return colors[category] || '#8c8c8c'
  1822. }
  1823. function getCategoryLabel(category) {
  1824. const labels = {
  1825. entity: '核心实体',
  1826. concept: '概念/技术',
  1827. data: '数据/指标',
  1828. location: '地点/组织',
  1829. asset: '资源模板'
  1830. }
  1831. return labels[category] || '其他'
  1832. }
  1833. /**
  1834. * 根据实体类型获取图标
  1835. */
  1836. function getEntityTypeIcon(type) {
  1837. const icons = {
  1838. 'PERSON': '👤',
  1839. 'ORGANIZATION': '🏢',
  1840. 'ORG': '🏢',
  1841. 'LOCATION': '📍',
  1842. 'LOC': '📍',
  1843. 'DATE': '📅',
  1844. 'TIME': '⏰',
  1845. 'PERIOD': '📆',
  1846. 'MONEY': '💰',
  1847. 'PERCENT': '📊',
  1848. 'PRODUCT': '📦',
  1849. 'EVENT': '📋',
  1850. 'FACILITY': '🏭',
  1851. 'FAC': '🏭',
  1852. 'GPE': '🌍',
  1853. 'LAW': '⚖️',
  1854. 'WORK_OF_ART': '🎨',
  1855. 'LANGUAGE': '🗣️',
  1856. 'QUANTITY': '🔢',
  1857. 'ORDINAL': '🔢',
  1858. 'CARDINAL': '🔢',
  1859. 'ENTITY': '🏷️',
  1860. 'DOC_ID': '📄',
  1861. 'NORP': '👥',
  1862. 'TITLE': '🎖️',
  1863. 'STANDARD': '📋',
  1864. 'RATING': '⭐',
  1865. 'SCORE': '💯',
  1866. 'LEVEL': '📊',
  1867. // 业务相关类型图标
  1868. 'CERT': '📜',
  1869. 'NUMBER': '🔢',
  1870. 'METHOD': '⚙️',
  1871. 'PROJECT': '📁',
  1872. 'POLICY': '📑',
  1873. 'DEVICE': '🔧',
  1874. 'MATERIAL': '🧱',
  1875. 'TECHNOLOGY': '💡',
  1876. 'REQUIREMENT': '📝',
  1877. 'INDICATOR': '📈',
  1878. 'RESULT': '✅',
  1879. 'PROBLEM': '⚠️',
  1880. 'SOLUTION': '💡',
  1881. 'RISK': '🚨',
  1882. 'MEASURE': '📏',
  1883. 'DEPARTMENT': '🏛️',
  1884. 'ROLE': '👔',
  1885. 'DOCUMENT': '📄',
  1886. 'REGULATION': '⚖️',
  1887. 'PROCEDURE': '📋',
  1888. 'ACTIVITY': '🎯',
  1889. 'TASK': '✔️',
  1890. 'GOAL': '🎯',
  1891. 'RESOURCE': '📦',
  1892. 'SYSTEM': '🖥️',
  1893. 'AREA': '📍',
  1894. 'EQUIPMENT': '🔧',
  1895. 'TOOL': '🛠️',
  1896. 'SOFTWARE': '💻',
  1897. 'DATA': '📊',
  1898. 'RECORD': '📝',
  1899. 'REPORT': '📊',
  1900. 'PLAN': '📅',
  1901. 'SCHEDULE': '📆',
  1902. 'BUDGET': '💵',
  1903. 'COST': '💰',
  1904. 'UNIT': '📐',
  1905. 'COMPANY': '🏢',
  1906. 'INSTITUTION': '🏛️'
  1907. }
  1908. return icons[type?.toUpperCase()] || '🏷️'
  1909. }
  1910. /**
  1911. * 根据实体类型获取样式类名
  1912. */
  1913. function getEntityTypeClass(type) {
  1914. const typeMap = {
  1915. 'PERSON': 'entity-person',
  1916. 'ORGANIZATION': 'entity-org',
  1917. 'LOCATION': 'entity-location',
  1918. 'DATE': 'entity-date',
  1919. 'TIME': 'entity-date',
  1920. 'MONEY': 'entity-data',
  1921. 'PERCENT': 'entity-data',
  1922. 'PRODUCT': 'entity-product',
  1923. 'EVENT': 'entity-event',
  1924. 'FACILITY': 'entity-org',
  1925. 'GPE': 'entity-location',
  1926. 'LAW': 'entity-law'
  1927. }
  1928. return typeMap[type?.toUpperCase()] || 'entity-default'
  1929. }
  1930. /**
  1931. * 滚动到文档中的指定实体
  1932. */
  1933. function scrollToEntity(entityId) {
  1934. const editorEl = document.querySelector('.editor-content')
  1935. if (!editorEl) return
  1936. const entitySpan = editorEl.querySelector(`[data-entity-id="${entityId}"]`)
  1937. if (entitySpan) {
  1938. entitySpan.scrollIntoView({ behavior: 'smooth', block: 'center' })
  1939. // 添加高亮闪烁效果
  1940. entitySpan.classList.add('entity-highlight-flash')
  1941. setTimeout(() => {
  1942. entitySpan.classList.remove('entity-highlight-flash')
  1943. }, 2000)
  1944. }
  1945. }
  1946. function editVariable(variable) {
  1947. editingVariable.value = variable
  1948. Object.assign(variableForm, variable)
  1949. showVariableDialog.value = true
  1950. }
  1951. async function saveVariable() {
  1952. if (!variableForm.name || !variableForm.displayName) {
  1953. ElMessage.warning('请填写必要字段')
  1954. return
  1955. }
  1956. try {
  1957. if (editingVariable.value) {
  1958. // 更新
  1959. const updated = await templateStore.updateVariable(editingVariable.value.id, variableForm)
  1960. Object.assign(editingVariable.value, updated)
  1961. ElMessage.success('更新成功')
  1962. } else {
  1963. // 新增
  1964. const newVar = await templateStore.addVariable(templateId, variableForm)
  1965. variables.value.push(newVar)
  1966. ElMessage.success('添加成功')
  1967. }
  1968. showVariableDialog.value = false
  1969. resetVariableForm()
  1970. } catch (error) {
  1971. ElMessage.error('保存失败: ' + error.message)
  1972. }
  1973. }
  1974. async function deleteVariable() {
  1975. if (editingVariable.value) {
  1976. try {
  1977. await templateStore.deleteVariable(editingVariable.value.id)
  1978. variables.value = variables.value.filter(v => v.id !== editingVariable.value.id)
  1979. showVariableDialog.value = false
  1980. resetVariableForm()
  1981. ElMessage.success('删除成功')
  1982. } catch (error) {
  1983. ElMessage.error('删除失败: ' + error.message)
  1984. }
  1985. }
  1986. }
  1987. function resetVariableForm() {
  1988. editingVariable.value = null
  1989. Object.assign(variableForm, {
  1990. name: '',
  1991. displayName: '',
  1992. category: 'entity',
  1993. exampleValue: '',
  1994. sourceType: 'document',
  1995. sourceFileAlias: '',
  1996. extractType: 'direct'
  1997. })
  1998. }
  1999. // 实体标记相关状态
  2000. const markingEntity = ref(false)
  2001. const selectionInfo = reactive({
  2002. blockId: null,
  2003. elementIndex: null,
  2004. startOffset: null,
  2005. endOffset: null,
  2006. text: ''
  2007. })
  2008. /**
  2009. * 处理右键菜单事件(阻止浏览器默认菜单)
  2010. */
  2011. function handleContextMenu(event) {
  2012. const selection = window.getSelection()
  2013. const text = selection.toString().trim()
  2014. if (text && text.length > 0) {
  2015. // 有选中文本时,显示自定义菜单
  2016. event.preventDefault()
  2017. selectedText.value = text
  2018. selectionRange.value = selection.rangeCount > 0 ? selection.getRangeAt(0) : null
  2019. // 尝试获取选中位置的块信息
  2020. const blockInfo = getSelectionBlockInfo(selection)
  2021. if (blockInfo) {
  2022. Object.assign(selectionInfo, {
  2023. blockId: blockInfo.blockId,
  2024. elementIndex: blockInfo.elementIndex,
  2025. startOffset: blockInfo.startOffset,
  2026. endOffset: blockInfo.endOffset,
  2027. text: text
  2028. })
  2029. } else {
  2030. Object.assign(selectionInfo, {
  2031. blockId: null,
  2032. elementIndex: null,
  2033. startOffset: null,
  2034. endOffset: null,
  2035. text: text
  2036. })
  2037. }
  2038. // 计算菜单位置,确保不超出屏幕
  2039. const menuWidth = 220
  2040. const menuHeight = 350
  2041. let x = event.clientX
  2042. let y = event.clientY
  2043. if (x + menuWidth > window.innerWidth) {
  2044. x = window.innerWidth - menuWidth - 10
  2045. }
  2046. if (y + menuHeight > window.innerHeight) {
  2047. y = window.innerHeight - menuHeight - 10
  2048. }
  2049. contextMenuPos.x = x
  2050. contextMenuPos.y = y
  2051. contextMenuVisible.value = true
  2052. } else {
  2053. // 没有选中文本时,允许浏览器默认右键菜单
  2054. contextMenuVisible.value = false
  2055. }
  2056. }
  2057. /**
  2058. * 获取选中文本所在的块信息
  2059. */
  2060. function getSelectionBlockInfo(selection) {
  2061. if (!selection.rangeCount) return null
  2062. const range = selection.getRangeAt(0)
  2063. let node = range.startContainer
  2064. // 向上查找带有 data-block-id 的元素
  2065. while (node && node !== document) {
  2066. if (node.nodeType === Node.ELEMENT_NODE) {
  2067. const blockId = node.getAttribute?.('data-block-id')
  2068. if (blockId) {
  2069. // 找到块,现在需要确定元素索引和偏移量
  2070. const elementIndex = findElementIndex(node, range.startContainer)
  2071. return {
  2072. blockId,
  2073. elementIndex: elementIndex,
  2074. startOffset: range.startOffset,
  2075. endOffset: range.endOffset
  2076. }
  2077. }
  2078. }
  2079. node = node.parentNode
  2080. }
  2081. return null
  2082. }
  2083. /**
  2084. * 查找元素在块中的索引
  2085. */
  2086. function findElementIndex(blockElement, textNode) {
  2087. // 简化实现:遍历块内的文本节点
  2088. const walker = document.createTreeWalker(
  2089. blockElement,
  2090. NodeFilter.SHOW_TEXT,
  2091. null,
  2092. false
  2093. )
  2094. let index = 0
  2095. let node
  2096. while (node = walker.nextNode()) {
  2097. if (node === textNode) {
  2098. return index
  2099. }
  2100. index++
  2101. }
  2102. return 0
  2103. }
  2104. /**
  2105. * 标记为实体(调用后端 API)
  2106. */
  2107. async function markAsVariable(entityType) {
  2108. if (!selectedText.value) return
  2109. const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
  2110. if (!baseDocumentId) {
  2111. ElMessage.warning('没有关联的文档')
  2112. contextMenuVisible.value = false
  2113. return
  2114. }
  2115. // 映射前端类别到后端实体类型
  2116. const entityTypeMap = {
  2117. 'entity': 'ENTITY',
  2118. 'concept': 'CONCEPT',
  2119. 'data': 'DATA',
  2120. 'location': 'LOCATION',
  2121. 'asset': 'ASSET',
  2122. 'person': 'PERSON',
  2123. 'org': 'ORGANIZATION',
  2124. 'date': 'DATE'
  2125. }
  2126. const backendEntityType = entityTypeMap[entityType] || entityType.toUpperCase()
  2127. // 如果有完整的块信息,调用后端 API
  2128. if (selectionInfo.blockId && selectionInfo.elementIndex !== null) {
  2129. markingEntity.value = true
  2130. try {
  2131. const entityId = await documentApi.markEntity(baseDocumentId, selectionInfo.blockId, {
  2132. elementIndex: selectionInfo.elementIndex,
  2133. startOffset: selectionInfo.startOffset,
  2134. endOffset: selectionInfo.endOffset,
  2135. entityType: backendEntityType
  2136. })
  2137. // 添加到本地实体列表
  2138. entities.value.push({
  2139. id: entityId,
  2140. text: selectedText.value,
  2141. type: backendEntityType,
  2142. confirmed: false
  2143. })
  2144. // 刷新文档内容以显示新标记
  2145. await refreshDocumentContent()
  2146. ElMessage.success('实体标记成功')
  2147. } catch (error) {
  2148. console.error('标记实体失败:', error)
  2149. ElMessage.error('标记失败: ' + (error.message || '未知错误'))
  2150. } finally {
  2151. markingEntity.value = false
  2152. }
  2153. } else {
  2154. // 没有块信息时,使用本地标记(临时方案)
  2155. const newEntity = {
  2156. id: 'local_' + Date.now(),
  2157. text: selectedText.value,
  2158. type: backendEntityType,
  2159. confirmed: false,
  2160. isLocal: true
  2161. }
  2162. entities.value.push(newEntity)
  2163. ElMessage.success('实体已添加(本地)')
  2164. }
  2165. // 关闭菜单
  2166. contextMenuVisible.value = false
  2167. selectedText.value = ''
  2168. }
  2169. /**
  2170. * 刷新文档内容
  2171. */
  2172. async function refreshDocumentContent() {
  2173. const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
  2174. if (!baseDocumentId) return
  2175. try {
  2176. const structuredDoc = await documentApi.getStructured(baseDocumentId)
  2177. if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
  2178. blocks.value = structuredDoc.blocks
  2179. documentContent.value = renderStructuredDocument(structuredDoc)
  2180. entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
  2181. }
  2182. } catch (error) {
  2183. console.error('刷新文档内容失败:', error)
  2184. }
  2185. }
  2186. /**
  2187. * 取消实体标记
  2188. */
  2189. async function handleUnmarkEntity(entity) {
  2190. if (!entity || !entity.id) return
  2191. // 本地实体直接删除
  2192. if (entity.isLocal || entity.id.startsWith('local_')) {
  2193. entities.value = entities.value.filter(e => e.id !== entity.id)
  2194. ElMessage.success('已取消标记')
  2195. return
  2196. }
  2197. const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
  2198. if (!baseDocumentId) return
  2199. // 需要找到实体所在的块
  2200. const blockId = findEntityBlockId(entity.id)
  2201. if (!blockId) {
  2202. ElMessage.warning('找不到实体所在的块')
  2203. return
  2204. }
  2205. try {
  2206. await ElMessageBox.confirm('确定要取消该实体的标记吗?', '提示', {
  2207. confirmButtonText: '确定',
  2208. cancelButtonText: '取消',
  2209. type: 'warning'
  2210. })
  2211. await documentApi.unmarkEntity(baseDocumentId, blockId, entity.id)
  2212. // 刷新内容
  2213. await refreshDocumentContent()
  2214. ElMessage.success('已取消标记')
  2215. } catch (error) {
  2216. if (error !== 'cancel') {
  2217. console.error('取消标记失败:', error)
  2218. ElMessage.error('取消失败: ' + (error.message || '未知错误'))
  2219. }
  2220. }
  2221. }
  2222. /**
  2223. * 确认实体
  2224. */
  2225. async function handleConfirmEntity(entity) {
  2226. if (!entity || !entity.id) return
  2227. const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
  2228. if (!baseDocumentId) return
  2229. const blockId = findEntityBlockId(entity.id)
  2230. if (!blockId) {
  2231. // 本地实体直接标记确认
  2232. entity.confirmed = true
  2233. ElMessage.success('已确认')
  2234. return
  2235. }
  2236. try {
  2237. await documentApi.confirmEntity(baseDocumentId, blockId, entity.id)
  2238. // 更新本地状态
  2239. const idx = entities.value.findIndex(e => e.id === entity.id)
  2240. if (idx !== -1) {
  2241. entities.value[idx].confirmed = true
  2242. }
  2243. ElMessage.success('实体已确认')
  2244. } catch (error) {
  2245. console.error('确认实体失败:', error)
  2246. ElMessage.error('确认失败: ' + (error.message || '未知错误'))
  2247. }
  2248. }
  2249. /**
  2250. * 更新实体类型
  2251. */
  2252. async function handleUpdateEntityType(entity, newType) {
  2253. if (!entity || !entity.id) return
  2254. const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
  2255. if (!baseDocumentId) return
  2256. const blockId = findEntityBlockId(entity.id)
  2257. if (!blockId) {
  2258. // 本地实体直接更新
  2259. entity.type = newType
  2260. ElMessage.success('类型已更新')
  2261. return
  2262. }
  2263. try {
  2264. await documentApi.updateEntity(baseDocumentId, blockId, entity.id, newType)
  2265. // 更新本地状态
  2266. const idx = entities.value.findIndex(e => e.id === entity.id)
  2267. if (idx !== -1) {
  2268. entities.value[idx].type = newType
  2269. }
  2270. ElMessage.success('实体类型已更新')
  2271. } catch (error) {
  2272. console.error('更新实体类型失败:', error)
  2273. ElMessage.error('更新失败: ' + (error.message || '未知错误'))
  2274. }
  2275. }
  2276. /**
  2277. * 从 blocks 中查找实体所在的块ID
  2278. */
  2279. function findEntityBlockId(entityId) {
  2280. for (const block of blocks.value) {
  2281. if (!block.elements) continue
  2282. for (const el of block.elements) {
  2283. if (el.type === 'entity' && el.entityId === entityId) {
  2284. return block.id
  2285. }
  2286. }
  2287. }
  2288. return null
  2289. }
  2290. function handleFileUpload(response) {
  2291. if (response.code === 200) {
  2292. ElMessage.success('文件上传成功')
  2293. // 可以在这里处理上传成功后的逻辑,如关联到项目
  2294. }
  2295. }
  2296. // 点击其他地方关闭右键菜单
  2297. function handleClickOutside(event) {
  2298. if (contextMenuVisible.value && !event.target.closest('.context-menu')) {
  2299. contextMenuVisible.value = false
  2300. }
  2301. }
  2302. // ESC 键关闭右键菜单
  2303. function handleKeyDown(event) {
  2304. if (event.key === 'Escape' && contextMenuVisible.value) {
  2305. contextMenuVisible.value = false
  2306. }
  2307. }
  2308. onMounted(() => {
  2309. document.addEventListener('click', handleClickOutside)
  2310. document.addEventListener('keydown', handleKeyDown)
  2311. })
  2312. onUnmounted(() => {
  2313. document.removeEventListener('click', handleClickOutside)
  2314. document.removeEventListener('keydown', handleKeyDown)
  2315. // 清理全局函数
  2316. delete window.handleEntityClick
  2317. })
  2318. </script>
  2319. <style lang="scss" scoped>
  2320. // ==========================================
  2321. // Editor 页面样式 - 参考 V2 原型设计
  2322. // ==========================================
  2323. .editor-page {
  2324. height: calc(100vh - 56px);
  2325. display: flex;
  2326. flex-direction: column;
  2327. background: var(--bg);
  2328. }
  2329. .editor-body {
  2330. flex: 1;
  2331. display: flex;
  2332. overflow: hidden;
  2333. }
  2334. // ==========================================
  2335. // 拖拽分隔条
  2336. // ==========================================
  2337. .resize-handle {
  2338. width: 4px;
  2339. background: transparent;
  2340. cursor: col-resize;
  2341. flex-shrink: 0;
  2342. position: relative;
  2343. z-index: 10;
  2344. transition: background 0.2s;
  2345. &:hover, &:active {
  2346. background: var(--primary);
  2347. }
  2348. &::before {
  2349. content: '';
  2350. position: absolute;
  2351. top: 0;
  2352. bottom: 0;
  2353. left: -3px;
  2354. right: -3px;
  2355. }
  2356. }
  2357. // ==========================================
  2358. // 左侧面板 - V2 风格
  2359. // ==========================================
  2360. .left-panel {
  2361. background: var(--white);
  2362. border-right: 1px solid var(--border);
  2363. display: flex;
  2364. flex-direction: column;
  2365. flex-shrink: 0;
  2366. min-width: 240px;
  2367. max-width: 500px;
  2368. overflow: hidden;
  2369. // Tab 导航 - V2 风格(圆角填充)
  2370. .panel-tabs {
  2371. display: flex;
  2372. gap: 6px;
  2373. padding: 10px 12px;
  2374. border-bottom: 1px solid var(--border);
  2375. background: var(--white);
  2376. overflow: hidden;
  2377. .panel-tab {
  2378. padding: 6px 10px;
  2379. font-size: 12px;
  2380. font-weight: 600;
  2381. text-align: center;
  2382. cursor: pointer;
  2383. color: var(--text-2);
  2384. border-radius: 10px;
  2385. border: 1px solid transparent;
  2386. transition: all 0.2s;
  2387. display: flex;
  2388. align-items: center;
  2389. justify-content: center;
  2390. gap: 4px;
  2391. white-space: nowrap;
  2392. flex-shrink: 0;
  2393. &:hover {
  2394. color: var(--primary);
  2395. background: var(--primary-light);
  2396. }
  2397. &.active {
  2398. background: var(--primary);
  2399. color: #fff;
  2400. border-color: rgba(0, 0, 0, 0.04);
  2401. box-shadow: var(--shadow-md);
  2402. }
  2403. .tab-count {
  2404. font-size: 10px;
  2405. font-weight: 500;
  2406. background: rgba(255, 255, 255, 0.2);
  2407. padding: 1px 6px;
  2408. border-radius: 10px;
  2409. color: inherit;
  2410. }
  2411. &:not(.active) .tab-count {
  2412. background: var(--bg);
  2413. color: var(--text-3);
  2414. }
  2415. }
  2416. }
  2417. // Tab 滑入动画 - 简化为淡入淡出
  2418. .tab-slide-enter-active {
  2419. transition: opacity 0.25s ease-out;
  2420. }
  2421. .tab-slide-leave-active {
  2422. transition: opacity 0.15s ease-in;
  2423. }
  2424. .tab-slide-enter-from,
  2425. .tab-slide-leave-to {
  2426. opacity: 0;
  2427. }
  2428. .panel-header {
  2429. padding: 14px 16px;
  2430. border-bottom: 1px solid var(--border);
  2431. font-size: 13px;
  2432. font-weight: 600;
  2433. display: flex;
  2434. justify-content: space-between;
  2435. align-items: center;
  2436. .file-count {
  2437. font-size: 12px;
  2438. color: var(--text-3);
  2439. font-weight: normal;
  2440. }
  2441. }
  2442. .panel-body {
  2443. flex: 1;
  2444. overflow-y: auto;
  2445. padding: 12px;
  2446. min-height: 0;
  2447. &.toc-panel {
  2448. padding: 8px;
  2449. }
  2450. &.reports-panel {
  2451. display: flex;
  2452. flex-direction: column;
  2453. gap: 12px;
  2454. }
  2455. }
  2456. // ==========================================
  2457. // 我的报告面板 - V2 风格
  2458. // ==========================================
  2459. .new-report-btn {
  2460. width: 100%;
  2461. border-radius: var(--radius-md);
  2462. height: 40px;
  2463. font-size: 14px;
  2464. font-weight: 600;
  2465. background: var(--primary-gradient);
  2466. border: none;
  2467. &:hover {
  2468. box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
  2469. }
  2470. }
  2471. // 新建报告下拉菜单样式
  2472. .new-report-menu {
  2473. padding: 4px 0;
  2474. .menu-item {
  2475. display: flex;
  2476. align-items: flex-start;
  2477. gap: 12px;
  2478. padding: 12px 14px;
  2479. cursor: pointer;
  2480. border-radius: var(--radius-sm);
  2481. transition: all 0.2s;
  2482. &:hover {
  2483. background: var(--bg);
  2484. }
  2485. .menu-icon {
  2486. font-size: 24px;
  2487. flex-shrink: 0;
  2488. line-height: 1;
  2489. }
  2490. .menu-content {
  2491. flex: 1;
  2492. min-width: 0;
  2493. }
  2494. .menu-title {
  2495. font-size: 14px;
  2496. font-weight: 500;
  2497. color: var(--text-1);
  2498. margin-bottom: 4px;
  2499. }
  2500. .menu-desc {
  2501. font-size: 12px;
  2502. color: var(--text-3);
  2503. line-height: 1.4;
  2504. }
  2505. }
  2506. }
  2507. .report-search {
  2508. :deep(.el-input__wrapper) {
  2509. border-radius: 18px;
  2510. background: var(--bg);
  2511. box-shadow: none;
  2512. border: 1px solid var(--border);
  2513. &:hover, &.is-focus {
  2514. border-color: var(--primary);
  2515. background: var(--white);
  2516. }
  2517. }
  2518. }
  2519. .report-list {
  2520. display: flex;
  2521. flex-direction: column;
  2522. gap: 10px;
  2523. flex: 1;
  2524. overflow-y: auto;
  2525. padding: 4px 0;
  2526. .report-item {
  2527. display: flex;
  2528. align-items: center;
  2529. gap: 10px;
  2530. padding: 12px;
  2531. background: var(--white);
  2532. border: 1px solid var(--border);
  2533. border-radius: var(--radius-md);
  2534. cursor: pointer;
  2535. transition: all 0.2s;
  2536. &:hover {
  2537. border-color: var(--primary);
  2538. background: var(--primary-light);
  2539. transform: translateY(-1px);
  2540. box-shadow: var(--shadow-sm);
  2541. }
  2542. &.active {
  2543. border-color: var(--primary);
  2544. background: var(--primary-light);
  2545. box-shadow: var(--shadow-sm);
  2546. &::before {
  2547. content: '';
  2548. position: absolute;
  2549. left: 0;
  2550. top: 50%;
  2551. transform: translateY(-50%);
  2552. width: 3px;
  2553. height: 18px;
  2554. background: var(--primary);
  2555. border-radius: 0 2px 2px 0;
  2556. }
  2557. .report-name {
  2558. color: var(--primary);
  2559. }
  2560. }
  2561. .report-icon {
  2562. width: 40px;
  2563. height: 40px;
  2564. border-radius: var(--radius-sm);
  2565. background: var(--bg);
  2566. display: flex;
  2567. align-items: center;
  2568. justify-content: center;
  2569. font-size: 18px;
  2570. flex-shrink: 0;
  2571. }
  2572. .report-info {
  2573. flex: 1;
  2574. min-width: 0;
  2575. .report-name {
  2576. font-weight: 600;
  2577. font-size: 13px;
  2578. color: var(--text-1);
  2579. white-space: nowrap;
  2580. overflow: hidden;
  2581. text-overflow: ellipsis;
  2582. margin-bottom: 4px;
  2583. }
  2584. .report-meta {
  2585. display: flex;
  2586. align-items: center;
  2587. gap: 8px;
  2588. font-size: 11px;
  2589. color: var(--text-3);
  2590. .report-status {
  2591. padding: 2px 8px;
  2592. border-radius: 4px;
  2593. background: var(--bg);
  2594. font-weight: 500;
  2595. &.draft { color: var(--text-3); }
  2596. &.editing { color: var(--primary); background: var(--primary-light); }
  2597. &.published { color: var(--success); background: #f6ffed; }
  2598. &.archived { color: var(--text-3); }
  2599. }
  2600. }
  2601. }
  2602. }
  2603. }
  2604. .report-empty, .report-loading {
  2605. display: flex;
  2606. flex-direction: column;
  2607. align-items: center;
  2608. justify-content: center;
  2609. padding: 40px 20px;
  2610. color: var(--text-3);
  2611. .empty-icon {
  2612. font-size: 48px;
  2613. margin-bottom: 16px;
  2614. opacity: 0.4;
  2615. }
  2616. .empty-text {
  2617. font-size: 14px;
  2618. font-weight: 500;
  2619. color: var(--text-2);
  2620. margin-bottom: 6px;
  2621. }
  2622. .empty-hint {
  2623. font-size: 12px;
  2624. }
  2625. }
  2626. .report-loading {
  2627. flex-direction: row;
  2628. gap: 10px;
  2629. padding: 24px;
  2630. }
  2631. // ==========================================
  2632. // 目录列表 - V2 风格
  2633. // ==========================================
  2634. .toc-list {
  2635. .toc-item {
  2636. display: flex;
  2637. align-items: flex-start;
  2638. padding: 10px 12px;
  2639. border-radius: var(--radius-sm);
  2640. cursor: pointer;
  2641. transition: all 0.2s;
  2642. font-size: 13px;
  2643. line-height: 1.5;
  2644. &:hover {
  2645. background: var(--primary-light);
  2646. }
  2647. .toc-bullet {
  2648. flex-shrink: 0;
  2649. width: 14px;
  2650. color: var(--primary);
  2651. font-size: 8px;
  2652. margin-top: 5px;
  2653. }
  2654. .toc-text {
  2655. flex: 1;
  2656. color: var(--text-1);
  2657. word-break: break-word;
  2658. }
  2659. .toc-page {
  2660. flex-shrink: 0;
  2661. margin-left: 8px;
  2662. color: var(--text-3);
  2663. font-size: 11px;
  2664. }
  2665. // 层级缩进
  2666. &.toc-level-1 {
  2667. padding-left: 12px;
  2668. font-weight: 600;
  2669. .toc-bullet { font-size: 10px; }
  2670. }
  2671. &.toc-level-2 {
  2672. padding-left: 28px;
  2673. font-weight: 500;
  2674. }
  2675. &.toc-level-3 {
  2676. padding-left: 44px;
  2677. font-size: 12px;
  2678. color: var(--text-2);
  2679. }
  2680. }
  2681. }
  2682. .toc-empty {
  2683. display: flex;
  2684. flex-direction: column;
  2685. align-items: center;
  2686. justify-content: center;
  2687. padding: 40px 20px;
  2688. color: var(--text-3);
  2689. .empty-icon {
  2690. font-size: 48px;
  2691. margin-bottom: 16px;
  2692. opacity: 0.4;
  2693. }
  2694. .empty-text {
  2695. font-size: 14px;
  2696. color: var(--text-2);
  2697. margin-bottom: 6px;
  2698. }
  2699. .empty-hint {
  2700. font-size: 12px;
  2701. }
  2702. }
  2703. }
  2704. // ==========================================
  2705. // 上传区 - V2 风格
  2706. // ==========================================
  2707. .upload-zone {
  2708. border: 2px dashed var(--border);
  2709. border-radius: var(--radius-lg);
  2710. margin-bottom: 16px;
  2711. height: 40px;
  2712. display: flex;
  2713. align-items: center;
  2714. justify-content: center;
  2715. background: var(--white);
  2716. transition: all 0.2s;
  2717. &:hover {
  2718. border-color: var(--primary);
  2719. background: var(--primary-light);
  2720. }
  2721. :deep(.el-upload-dragger) {
  2722. padding: 0 12px;
  2723. border: none;
  2724. background: transparent;
  2725. width: 100%;
  2726. height: 100%;
  2727. display: flex;
  2728. align-items: center;
  2729. justify-content: center;
  2730. }
  2731. .upload-content {
  2732. display: flex;
  2733. align-items: center;
  2734. gap: 8px;
  2735. }
  2736. .upload-icon {
  2737. font-size: 18px;
  2738. }
  2739. .upload-text {
  2740. font-size: 14px;
  2741. font-weight: 600;
  2742. color: var(--text-1);
  2743. }
  2744. .upload-hint {
  2745. display: block;
  2746. font-size: 11px;
  2747. color: var(--text-3);
  2748. margin-top: 8px;
  2749. text-align: center;
  2750. }
  2751. }
  2752. .file-list {
  2753. margin-bottom: 16px;
  2754. display: flex;
  2755. flex-direction: column;
  2756. gap: 10px;
  2757. }
  2758. // ==========================================
  2759. // 文件项 - V2 风格
  2760. // ==========================================
  2761. .file-item {
  2762. display: flex;
  2763. align-items: center;
  2764. gap: 10px;
  2765. padding: 12px;
  2766. background: var(--white);
  2767. border: 1px solid var(--border);
  2768. border-radius: var(--radius-md);
  2769. cursor: pointer;
  2770. transition: all 0.2s;
  2771. position: relative;
  2772. &:hover {
  2773. border-color: var(--primary);
  2774. background: var(--primary-light);
  2775. }
  2776. &.active {
  2777. border-color: var(--primary);
  2778. background: var(--primary-light);
  2779. }
  2780. .file-icon {
  2781. width: 40px;
  2782. height: 40px;
  2783. border-radius: var(--radius-sm);
  2784. display: flex;
  2785. align-items: center;
  2786. justify-content: center;
  2787. color: #fff;
  2788. font-weight: 700;
  2789. font-size: 13px;
  2790. flex-shrink: 0;
  2791. &.pdf { background: #ff6b6b; }
  2792. &.docx, &.doc { background: #4dabf7; }
  2793. &.xlsx, &.xls { background: #73d13d; }
  2794. &.md { background: #9254de; }
  2795. &.default { background: var(--text-3); }
  2796. }
  2797. .file-info {
  2798. flex: 1;
  2799. min-width: 0;
  2800. display: flex;
  2801. flex-direction: column;
  2802. .file-name {
  2803. font-size: 13px;
  2804. font-weight: 600;
  2805. color: var(--text-1);
  2806. white-space: nowrap;
  2807. overflow: hidden;
  2808. text-overflow: ellipsis;
  2809. }
  2810. .file-meta {
  2811. font-size: 11px;
  2812. color: var(--text-3);
  2813. margin-top: 4px;
  2814. .required {
  2815. color: var(--danger);
  2816. }
  2817. }
  2818. }
  2819. .file-status {
  2820. font-size: 11px;
  2821. white-space: nowrap;
  2822. &.parsing { color: var(--primary); }
  2823. &.done { color: var(--success); }
  2824. }
  2825. }
  2826. .add-source-btn {
  2827. width: 100%;
  2828. border-radius: var(--radius-md);
  2829. }
  2830. // ==========================================
  2831. // 中间面板 - V2 风格
  2832. // ==========================================
  2833. .center-panel {
  2834. flex: 1;
  2835. display: flex;
  2836. flex-direction: column;
  2837. background: var(--white);
  2838. overflow: hidden;
  2839. border-radius: var(--radius-md);
  2840. margin: 0 8px;
  2841. box-shadow: var(--shadow-sm);
  2842. // ==========================================
  2843. // 欢迎页 - V2 风格
  2844. // ==========================================
  2845. .welcome-page {
  2846. flex: 1;
  2847. display: flex;
  2848. align-items: center;
  2849. justify-content: center;
  2850. background: var(--white);
  2851. .welcome-content {
  2852. text-align: center;
  2853. max-width: 600px;
  2854. padding: 48px;
  2855. }
  2856. .welcome-logo {
  2857. width: 80px;
  2858. height: 80px;
  2859. margin: 0 auto 32px;
  2860. background: linear-gradient(135deg, var(--primary) 0%, #69c0ff 100%);
  2861. border-radius: 16px;
  2862. display: flex;
  2863. align-items: center;
  2864. justify-content: center;
  2865. font-size: 40px;
  2866. font-weight: 700;
  2867. color: white;
  2868. box-shadow: 0 12px 32px rgba(24, 144, 255, 0.3);
  2869. }
  2870. .welcome {
  2871. h1 {
  2872. font-size: 28px;
  2873. font-weight: 700;
  2874. color: var(--text-1);
  2875. margin-bottom: 12px;
  2876. line-height: 1.4;
  2877. span {
  2878. display: block;
  2879. font-size: 20px;
  2880. font-weight: 500;
  2881. background: var(--ai-gradient);
  2882. background-clip: text;
  2883. -webkit-background-clip: text;
  2884. -webkit-text-fill-color: transparent;
  2885. margin-top: 8px;
  2886. }
  2887. }
  2888. p {
  2889. font-size: 15px;
  2890. color: var(--text-3);
  2891. line-height: 1.6;
  2892. }
  2893. }
  2894. }
  2895. // ==========================================
  2896. // 编辑器标题栏 - V2 风格(集成工具栏)
  2897. // ==========================================
  2898. .editor-title-bar {
  2899. padding: 12px 20px;
  2900. border-bottom: 1px solid var(--border);
  2901. display: flex;
  2902. align-items: center;
  2903. gap: 16px;
  2904. background: var(--white);
  2905. flex-shrink: 0;
  2906. // 左侧:标题和保存状态
  2907. .title-section {
  2908. display: flex;
  2909. align-items: center;
  2910. gap: 10px;
  2911. flex: 1;
  2912. min-width: 0;
  2913. }
  2914. .title-input-wrapper {
  2915. position: relative;
  2916. display: inline-block;
  2917. min-width: 150px;
  2918. max-width: 300px;
  2919. .title-input {
  2920. width: 100%;
  2921. :deep(.el-input__wrapper) {
  2922. box-shadow: none;
  2923. background: transparent;
  2924. border-radius: var(--radius-sm);
  2925. padding: 0 8px;
  2926. &:hover {
  2927. background: var(--bg);
  2928. }
  2929. &.is-focus {
  2930. background: var(--white);
  2931. box-shadow: 0 0 0 2px var(--primary-light);
  2932. }
  2933. }
  2934. :deep(.el-input__inner) {
  2935. font-size: 15px;
  2936. font-weight: 600;
  2937. color: var(--text-1);
  2938. }
  2939. }
  2940. .title-measure {
  2941. position: absolute;
  2942. visibility: hidden;
  2943. white-space: nowrap;
  2944. font-size: 15px;
  2945. font-weight: 600;
  2946. padding: 0 8px;
  2947. pointer-events: none;
  2948. }
  2949. }
  2950. .save-status {
  2951. display: flex;
  2952. align-items: center;
  2953. gap: 4px;
  2954. color: var(--success);
  2955. font-size: 12px;
  2956. white-space: nowrap;
  2957. }
  2958. // 中间:视图切换
  2959. .view-toggle {
  2960. display: flex;
  2961. align-items: center;
  2962. :deep(.el-radio-group) {
  2963. .el-radio-button__inner {
  2964. padding: 6px 12px;
  2965. font-size: 12px;
  2966. }
  2967. }
  2968. }
  2969. // 右侧:操作按钮
  2970. .toolbar-actions {
  2971. display: flex;
  2972. gap: 8px;
  2973. align-items: center;
  2974. flex-shrink: 0;
  2975. :deep(.el-button) {
  2976. border-radius: var(--radius-sm);
  2977. &:not(.el-button--primary) {
  2978. border-color: var(--border);
  2979. &:hover {
  2980. border-color: var(--primary);
  2981. color: var(--primary);
  2982. background: var(--primary-light);
  2983. }
  2984. }
  2985. &.el-button--primary {
  2986. background: var(--primary-gradient);
  2987. border: none;
  2988. &:hover {
  2989. box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
  2990. }
  2991. }
  2992. }
  2993. :deep(.el-divider--vertical) {
  2994. height: 20px;
  2995. margin: 0 4px;
  2996. }
  2997. }
  2998. }
  2999. // ==========================================
  3000. // 编辑器滚动区 - V2 风格
  3001. // ==========================================
  3002. .editor-scroll {
  3003. flex: 1;
  3004. overflow-y: auto;
  3005. padding: 40px 48px;
  3006. background: var(--white);
  3007. }
  3008. .editor-content {
  3009. max-width: 1000px;
  3010. margin: 0 auto;
  3011. outline: none;
  3012. // 文档块样式
  3013. :deep(.doc-block) {
  3014. position: relative;
  3015. transition: background-color 0.2s;
  3016. &:hover {
  3017. background-color: rgba(24, 144, 255, 0.02);
  3018. }
  3019. // 被选中时的样式
  3020. &.selected {
  3021. background-color: rgba(24, 144, 255, 0.08);
  3022. outline: 1px dashed var(--primary);
  3023. }
  3024. }
  3025. :deep(h1) {
  3026. font-size: 24px;
  3027. font-weight: 700;
  3028. margin-bottom: 24px;
  3029. }
  3030. :deep(h2) {
  3031. font-size: 18px;
  3032. font-weight: 600;
  3033. margin: 28px 0 16px;
  3034. }
  3035. :deep(p) {
  3036. margin-bottom: 12px;
  3037. line-height: 1.6;
  3038. }
  3039. :deep(ul) {
  3040. margin-bottom: 16px;
  3041. padding-left: 24px;
  3042. li {
  3043. margin-bottom: 8px;
  3044. }
  3045. }
  3046. // 目录样式
  3047. :deep(.doc-toc-title) {
  3048. font-size: 18pt;
  3049. font-weight: bold;
  3050. text-align: center;
  3051. margin: 20px 0 16px;
  3052. }
  3053. :deep(.doc-toc-item) {
  3054. display: flex;
  3055. align-items: baseline;
  3056. padding: 6px 0;
  3057. line-height: 1.6;
  3058. cursor: pointer;
  3059. transition: background-color 0.2s;
  3060. &:hover {
  3061. background-color: #f5f5f5;
  3062. }
  3063. .toc-title {
  3064. flex-shrink: 0;
  3065. white-space: nowrap;
  3066. }
  3067. .toc-dots {
  3068. flex: 1;
  3069. border-bottom: 1px dotted #999;
  3070. margin: 0 8px;
  3071. min-width: 20px;
  3072. height: 0.6em;
  3073. }
  3074. .toc-page {
  3075. flex-shrink: 0;
  3076. color: #666;
  3077. min-width: 20px;
  3078. text-align: right;
  3079. }
  3080. }
  3081. // 表格样式
  3082. :deep(.doc-table-container) {
  3083. margin: 16px 0;
  3084. overflow-x: auto;
  3085. }
  3086. :deep(.doc-table) {
  3087. width: 100%;
  3088. border-collapse: collapse;
  3089. font-size: 14px;
  3090. th, td {
  3091. border: 1px solid #ddd;
  3092. padding: 8px 12px;
  3093. text-align: left;
  3094. vertical-align: top;
  3095. line-height: 1.5;
  3096. }
  3097. th {
  3098. background-color: #f5f5f5;
  3099. font-weight: bold;
  3100. }
  3101. tr:nth-child(even) td {
  3102. background-color: #fafafa;
  3103. }
  3104. tr:hover td {
  3105. background-color: #f0f7ff;
  3106. }
  3107. }
  3108. :deep(.doc-table-empty) {
  3109. padding: 20px;
  3110. text-align: center;
  3111. color: #999;
  3112. border: 1px dashed #ddd;
  3113. margin: 16px 0;
  3114. }
  3115. // 列表项样式
  3116. :deep(.doc-list-item) {
  3117. position: relative;
  3118. margin-bottom: 8px;
  3119. line-height: 1.6;
  3120. &.bullet {
  3121. padding-left: 1.5em;
  3122. &::before {
  3123. content: '•';
  3124. position: absolute;
  3125. left: 0;
  3126. }
  3127. }
  3128. &.ordered {
  3129. padding-left: 2em;
  3130. counter-increment: doc-list;
  3131. &::before {
  3132. content: counter(doc-list) '.';
  3133. position: absolute;
  3134. left: 0;
  3135. }
  3136. }
  3137. }
  3138. // 重置列表计数器
  3139. :deep(p + .doc-list-item.ordered:first-of-type),
  3140. :deep(.doc-list-item.bullet + .doc-list-item.ordered) {
  3141. counter-reset: doc-list;
  3142. }
  3143. // 块引用样式
  3144. :deep(blockquote) {
  3145. margin: 16px 0;
  3146. padding: 12px 20px;
  3147. border-left: 4px solid #ddd;
  3148. background: #f9f9f9;
  3149. color: #666;
  3150. }
  3151. // 代码块样式
  3152. :deep(pre) {
  3153. margin: 16px 0;
  3154. padding: 16px;
  3155. background: #f5f5f5;
  3156. border-radius: 4px;
  3157. overflow-x: auto;
  3158. code {
  3159. font-family: 'Consolas', 'Monaco', monospace;
  3160. font-size: 13px;
  3161. }
  3162. }
  3163. // 实体高亮样式 - 匹配原型 UI(边框 + 背景)
  3164. :deep(.entity-highlight) {
  3165. display: inline;
  3166. padding: 2px 8px;
  3167. border-radius: 4px;
  3168. cursor: pointer;
  3169. transition: all 0.2s;
  3170. font-weight: 500;
  3171. border: 1px solid #1890ff;
  3172. color: #1890ff;
  3173. background: rgba(24, 144, 255, 0.1);
  3174. &:hover {
  3175. background: #1890ff;
  3176. color: white;
  3177. }
  3178. // 实体类型颜色
  3179. &.entity {
  3180. border-color: #1890ff;
  3181. color: #1890ff;
  3182. background: rgba(24, 144, 255, 0.1);
  3183. &:hover { background: #1890ff; color: white; }
  3184. }
  3185. &.concept {
  3186. border-color: #722ed1;
  3187. color: #722ed1;
  3188. background: rgba(114, 46, 209, 0.1);
  3189. &:hover { background: #722ed1; color: white; }
  3190. }
  3191. &.data {
  3192. border-color: #52c41a;
  3193. color: #52c41a;
  3194. background: rgba(82, 196, 26, 0.1);
  3195. &:hover { background: #52c41a; color: white; }
  3196. }
  3197. &.location {
  3198. border-color: #faad14;
  3199. color: #d48806;
  3200. background: rgba(250, 173, 20, 0.1);
  3201. &:hover { background: #faad14; color: white; }
  3202. }
  3203. &.asset {
  3204. border-color: #eb2f96;
  3205. color: #eb2f96;
  3206. background: rgba(235, 47, 150, 0.1);
  3207. &:hover { background: #eb2f96; color: white; }
  3208. }
  3209. &.person {
  3210. border-color: #1890ff;
  3211. color: #1890ff;
  3212. background: rgba(24, 144, 255, 0.1);
  3213. &:hover { background: #1890ff; color: white; }
  3214. }
  3215. &.org {
  3216. border-color: #722ed1;
  3217. color: #722ed1;
  3218. background: rgba(114, 46, 209, 0.1);
  3219. &:hover { background: #722ed1; color: white; }
  3220. }
  3221. &.date {
  3222. border-color: #13c2c2;
  3223. color: #13c2c2;
  3224. background: rgba(19, 194, 194, 0.1);
  3225. &:hover { background: #13c2c2; color: white; }
  3226. }
  3227. &.product {
  3228. border-color: #eb2f96;
  3229. color: #eb2f96;
  3230. background: rgba(235, 47, 150, 0.1);
  3231. &:hover { background: #eb2f96; color: white; }
  3232. }
  3233. &.event {
  3234. border-color: #fa8c16;
  3235. color: #fa8c16;
  3236. background: rgba(250, 140, 22, 0.1);
  3237. &:hover { background: #fa8c16; color: white; }
  3238. }
  3239. &.law {
  3240. border-color: #2f54eb;
  3241. color: #2f54eb;
  3242. background: rgba(47, 84, 235, 0.1);
  3243. &:hover { background: #2f54eb; color: white; }
  3244. }
  3245. }
  3246. }
  3247. }
  3248. // ==========================================
  3249. // 右侧面板 - V2 风格
  3250. // ==========================================
  3251. .right-panel {
  3252. background: var(--white);
  3253. border-left: 1px solid var(--border);
  3254. display: flex;
  3255. flex-direction: column;
  3256. flex-shrink: 0;
  3257. min-width: 280px;
  3258. max-width: 500px;
  3259. overflow: hidden;
  3260. // 右侧面板分为上下两部分
  3261. .element-section {
  3262. flex: 4;
  3263. overflow-y: auto;
  3264. min-height: 0;
  3265. }
  3266. .ai-assistant {
  3267. flex: 6;
  3268. overflow-y: auto;
  3269. min-height: 0;
  3270. display: flex;
  3271. flex-direction: column;
  3272. }
  3273. }
  3274. // ==========================================
  3275. // 要素管理区 - V2 风格
  3276. // ==========================================
  3277. .element-section {
  3278. padding: 16px;
  3279. border-bottom: 1px dashed var(--border);
  3280. // 模块标题样式 - V2 风格
  3281. .module-title {
  3282. display: flex;
  3283. align-items: center;
  3284. gap: 10px;
  3285. font-size: 15px;
  3286. font-weight: 700;
  3287. color: var(--text-1);
  3288. margin-bottom: 14px;
  3289. .module-icon {
  3290. width: 36px;
  3291. height: 36px;
  3292. border-radius: 8px;
  3293. background: var(--primary-gradient);
  3294. display: flex;
  3295. align-items: center;
  3296. justify-content: center;
  3297. font-size: 18px;
  3298. color: white;
  3299. box-shadow: var(--shadow-md);
  3300. }
  3301. }
  3302. .element-header {
  3303. display: flex;
  3304. align-items: center;
  3305. justify-content: space-between;
  3306. margin-bottom: 12px;
  3307. .element-title {
  3308. font-size: 13px;
  3309. font-weight: 600;
  3310. display: flex;
  3311. align-items: center;
  3312. gap: 6px;
  3313. .element-count {
  3314. font-size: 11px;
  3315. color: var(--text-3);
  3316. font-weight: normal;
  3317. }
  3318. }
  3319. // 要素 Tab 切换 - V2 风格
  3320. .element-tabs {
  3321. display: flex;
  3322. gap: 8px;
  3323. .element-tab {
  3324. padding: 6px 12px;
  3325. border-radius: 12px;
  3326. background: transparent;
  3327. border: 1px solid transparent;
  3328. font-size: 13px;
  3329. cursor: pointer;
  3330. color: var(--text-2);
  3331. transition: all 0.2s;
  3332. &:hover {
  3333. background: var(--bg);
  3334. }
  3335. &.active {
  3336. background: var(--primary);
  3337. color: #fff;
  3338. border-color: rgba(0, 0, 0, 0.04);
  3339. box-shadow: var(--shadow-md);
  3340. }
  3341. }
  3342. }
  3343. }
  3344. .element-filter {
  3345. padding: 0 0 12px;
  3346. .entity-search {
  3347. margin-bottom: 12px;
  3348. :deep(.el-input__wrapper) {
  3349. border-radius: 18px;
  3350. background: var(--bg);
  3351. box-shadow: none;
  3352. border: 1px solid var(--border);
  3353. &:hover, &.is-focus {
  3354. border-color: var(--primary);
  3355. background: var(--white);
  3356. }
  3357. }
  3358. }
  3359. .entity-type-filter {
  3360. display: flex;
  3361. flex-wrap: wrap;
  3362. gap: 6px;
  3363. .filter-tag {
  3364. cursor: pointer;
  3365. transition: all 0.2s;
  3366. border-radius: 12px;
  3367. font-size: 11px;
  3368. &:hover {
  3369. border-color: var(--primary);
  3370. color: var(--primary);
  3371. }
  3372. &.active {
  3373. background: var(--primary);
  3374. color: white;
  3375. border-color: var(--primary);
  3376. }
  3377. &.clear {
  3378. background: transparent;
  3379. border-style: dashed;
  3380. color: var(--text-3);
  3381. &:hover {
  3382. border-color: var(--danger);
  3383. color: var(--danger);
  3384. }
  3385. }
  3386. }
  3387. }
  3388. }
  3389. .element-body {
  3390. padding: 0;
  3391. }
  3392. // 要素标签容器 - V2 风格
  3393. .element-tags-wrap {
  3394. display: flex;
  3395. flex-wrap: wrap;
  3396. gap: 8px;
  3397. max-height: 200px;
  3398. overflow-y: auto;
  3399. padding-right: 4px;
  3400. padding-bottom: 16px;
  3401. &::-webkit-scrollbar {
  3402. width: 4px;
  3403. }
  3404. &::-webkit-scrollbar-track {
  3405. background: var(--bg);
  3406. border-radius: 2px;
  3407. }
  3408. &::-webkit-scrollbar-thumb {
  3409. background: var(--border);
  3410. border-radius: 2px;
  3411. &:hover {
  3412. background: var(--text-3);
  3413. }
  3414. }
  3415. }
  3416. .load-more-wrap {
  3417. width: 100%;
  3418. text-align: center;
  3419. padding: 10px 0;
  3420. margin-top: 8px;
  3421. border-top: 1px dashed var(--border);
  3422. }
  3423. // ==========================================
  3424. // 要素标签样式 - V2 风格
  3425. // ==========================================
  3426. .var-tag {
  3427. height: 28px;
  3428. display: inline-flex;
  3429. align-items: center;
  3430. gap: 6px;
  3431. padding: 0 12px;
  3432. border-radius: 2px;
  3433. font-size: 12px;
  3434. cursor: pointer;
  3435. transition: all 0.2s;
  3436. background: var(--bg);
  3437. border: 1px solid var(--border);
  3438. user-select: none;
  3439. &:hover {
  3440. border-color: var(--primary);
  3441. background: var(--primary-light);
  3442. transform: translateY(-1px);
  3443. }
  3444. &:active {
  3445. cursor: grabbing;
  3446. }
  3447. .tag-icon {
  3448. font-size: 12px;
  3449. }
  3450. .tag-name {
  3451. max-width: 120px;
  3452. overflow: hidden;
  3453. text-overflow: ellipsis;
  3454. white-space: nowrap;
  3455. font-weight: 500;
  3456. line-height: 28px;
  3457. }
  3458. .tag-status {
  3459. color: #52c41a;
  3460. font-size: 10px;
  3461. }
  3462. // 动态要素样式(圆角)
  3463. &.dynamic {
  3464. border-radius: 14px;
  3465. }
  3466. // 静态要素样式(微圆角)
  3467. &.static {
  3468. border-radius: 2px;
  3469. }
  3470. // 已确认状态
  3471. &.confirmed {
  3472. background: rgba(82, 196, 26, 0.1);
  3473. border-color: #52c41a;
  3474. .tag-name {
  3475. color: #389e0d;
  3476. }
  3477. }
  3478. // 实体类型样式 - 左边框颜色区分
  3479. &.entity-person, &.entity {
  3480. border-left: 3px solid var(--primary);
  3481. }
  3482. &.entity-org, &.concept {
  3483. border-left: 3px solid #722ed1;
  3484. }
  3485. &.entity-location, &.location {
  3486. border-left: 3px solid var(--warning);
  3487. }
  3488. &.entity-date {
  3489. border-left: 3px solid #13c2c2;
  3490. }
  3491. &.entity-data, &.data {
  3492. border-left: 3px solid var(--success);
  3493. }
  3494. &.entity-product, &.asset {
  3495. border-left: 3px solid #eb2f96;
  3496. }
  3497. &.entity-event {
  3498. border-left: 3px solid #fa8c16;
  3499. }
  3500. &.entity-law {
  3501. border-left: 3px solid #2f54eb;
  3502. }
  3503. &.entity-default {
  3504. border-left: 3px solid #8c8c8c;
  3505. }
  3506. }
  3507. .element-hint {
  3508. font-size: 12px;
  3509. color: var(--text-3);
  3510. text-align: center;
  3511. padding: 24px;
  3512. }
  3513. }
  3514. // 实体高亮闪烁效果
  3515. @keyframes entity-flash {
  3516. 0%, 100% { background-color: inherit; }
  3517. 50% { background-color: #ffe58f; }
  3518. }
  3519. .entity-highlight-flash {
  3520. animation: entity-flash 0.5s ease-in-out 3;
  3521. }
  3522. // 实体编辑弹窗样式
  3523. .entity-edit-form {
  3524. .entity-edit-preview {
  3525. display: flex;
  3526. align-items: center;
  3527. justify-content: center;
  3528. gap: 10px;
  3529. padding: 16px;
  3530. background: var(--primary-light);
  3531. border: 1px dashed var(--primary);
  3532. border-radius: 8px;
  3533. margin-bottom: 20px;
  3534. .preview-icon {
  3535. font-size: 24px;
  3536. }
  3537. .preview-text {
  3538. font-size: 16px;
  3539. font-weight: 600;
  3540. color: var(--primary);
  3541. }
  3542. }
  3543. }
  3544. .category-section {
  3545. padding: 12px 16px;
  3546. border-bottom: 1px solid var(--border);
  3547. .category-header {
  3548. display: flex;
  3549. align-items: center;
  3550. gap: 8px;
  3551. font-size: 12px;
  3552. font-weight: 600;
  3553. margin-bottom: 10px;
  3554. .category-dot {
  3555. width: 10px;
  3556. height: 10px;
  3557. border-radius: 50%;
  3558. }
  3559. .category-count {
  3560. color: var(--text-3);
  3561. font-weight: normal;
  3562. background: var(--bg);
  3563. padding: 2px 8px;
  3564. border-radius: 10px;
  3565. }
  3566. }
  3567. .category-items {
  3568. .category-item {
  3569. display: flex;
  3570. justify-content: space-between;
  3571. padding: 8px 12px;
  3572. background: var(--bg);
  3573. border-radius: 6px;
  3574. margin-bottom: 6px;
  3575. cursor: pointer;
  3576. font-size: 12px;
  3577. transition: all 0.2s;
  3578. &:hover {
  3579. background: var(--primary-light);
  3580. }
  3581. .item-value {
  3582. color: var(--text-3);
  3583. }
  3584. }
  3585. }
  3586. }
  3587. // ==========================================
  3588. // 右键菜单 - V2 风格
  3589. // ==========================================
  3590. .context-menu {
  3591. position: fixed;
  3592. min-width: 180px;
  3593. background: var(--white);
  3594. border-radius: var(--radius-md);
  3595. box-shadow: var(--shadow-lg);
  3596. z-index: 3000;
  3597. overflow: hidden;
  3598. .context-menu-header {
  3599. padding: 12px 14px;
  3600. background: var(--bg);
  3601. border-bottom: 1px solid var(--border);
  3602. .selected-preview {
  3603. font-size: 12px;
  3604. color: var(--primary);
  3605. font-weight: 600;
  3606. max-width: 150px;
  3607. overflow: hidden;
  3608. text-overflow: ellipsis;
  3609. white-space: nowrap;
  3610. }
  3611. }
  3612. .context-menu-section {
  3613. padding: 8px 14px 4px;
  3614. font-size: 10px;
  3615. color: var(--text-3);
  3616. font-weight: 600;
  3617. text-transform: uppercase;
  3618. letter-spacing: 0.5px;
  3619. }
  3620. .context-menu-item {
  3621. display: flex;
  3622. align-items: center;
  3623. gap: 10px;
  3624. padding: 10px 14px;
  3625. font-size: 13px;
  3626. cursor: pointer;
  3627. transition: all 0.15s;
  3628. color: var(--text-1);
  3629. &:hover {
  3630. background: var(--primary-light);
  3631. color: var(--primary);
  3632. }
  3633. &[disabled="true"] {
  3634. opacity: 0.5;
  3635. pointer-events: none;
  3636. }
  3637. .icon {
  3638. font-size: 14px;
  3639. width: 20px;
  3640. text-align: center;
  3641. }
  3642. .shortcut {
  3643. margin-left: auto;
  3644. font-size: 11px;
  3645. color: var(--text-3);
  3646. }
  3647. }
  3648. .context-menu-divider {
  3649. height: 1px;
  3650. background: var(--border);
  3651. margin: 4px 0;
  3652. }
  3653. .context-menu-loading {
  3654. display: flex;
  3655. align-items: center;
  3656. justify-content: center;
  3657. gap: 8px;
  3658. padding: 12px;
  3659. color: var(--primary);
  3660. font-size: 12px;
  3661. border-top: 1px solid var(--border);
  3662. background: var(--bg);
  3663. }
  3664. }
  3665. // ==========================================
  3666. // 实体弹出框样式 - V2 风格
  3667. // ==========================================
  3668. .entity-popover {
  3669. .entity-popover-header {
  3670. display: flex;
  3671. align-items: center;
  3672. justify-content: space-between;
  3673. margin-bottom: 10px;
  3674. .entity-text {
  3675. font-weight: 600;
  3676. font-size: 14px;
  3677. max-width: 140px;
  3678. overflow: hidden;
  3679. text-overflow: ellipsis;
  3680. white-space: nowrap;
  3681. color: var(--text-1);
  3682. }
  3683. }
  3684. .entity-popover-type {
  3685. font-size: 12px;
  3686. color: var(--text-2);
  3687. margin-bottom: 14px;
  3688. padding: 4px 8px;
  3689. background: var(--bg);
  3690. border-radius: 4px;
  3691. display: inline-block;
  3692. }
  3693. .entity-popover-actions {
  3694. display: flex;
  3695. gap: 8px;
  3696. flex-wrap: wrap;
  3697. :deep(.el-button) {
  3698. border-radius: var(--radius-sm);
  3699. }
  3700. }
  3701. }
  3702. // ==========================================
  3703. // 知识图谱容器 - V2 风格
  3704. // ==========================================
  3705. .graph-container {
  3706. height: 500px;
  3707. position: relative;
  3708. background: linear-gradient(45deg, #f8f9fa 25%, transparent 25%),
  3709. linear-gradient(-45deg, #f8f9fa 25%, transparent 25%),
  3710. linear-gradient(45deg, transparent 75%, #f8f9fa 75%),
  3711. linear-gradient(-45deg, transparent 75%, #f8f8f8 75%);
  3712. background-size: 20px 20px;
  3713. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  3714. border-radius: var(--radius-md);
  3715. .graph-legend {
  3716. position: absolute;
  3717. top: 16px;
  3718. left: 16px;
  3719. background: var(--white);
  3720. border-radius: var(--radius-md);
  3721. padding: 14px 18px;
  3722. box-shadow: var(--shadow-md);
  3723. .legend-title {
  3724. font-size: 12px;
  3725. font-weight: 600;
  3726. margin-bottom: 10px;
  3727. color: var(--text-1);
  3728. }
  3729. .legend-item {
  3730. display: flex;
  3731. align-items: center;
  3732. gap: 8px;
  3733. font-size: 12px;
  3734. color: var(--text-2);
  3735. margin-bottom: 6px;
  3736. &:last-child {
  3737. margin-bottom: 0;
  3738. }
  3739. }
  3740. .legend-dot {
  3741. width: 12px;
  3742. height: 12px;
  3743. border-radius: 50%;
  3744. &.core, &.entity { background: var(--primary); }
  3745. &.concept { background: #722ed1; }
  3746. &.data { background: var(--success); }
  3747. &.location { background: var(--warning); }
  3748. }
  3749. }
  3750. .graph-body {
  3751. height: 100%;
  3752. display: flex;
  3753. align-items: center;
  3754. justify-content: center;
  3755. .graph-placeholder {
  3756. text-align: center;
  3757. color: var(--text-3);
  3758. .placeholder-icon {
  3759. font-size: 48px;
  3760. margin-bottom: 16px;
  3761. opacity: 0.5;
  3762. }
  3763. p {
  3764. margin-top: 12px;
  3765. font-size: 14px;
  3766. }
  3767. }
  3768. }
  3769. }
  3770. // ==========================================
  3771. // 空白编辑器占位提示样式 - V2 风格
  3772. // ==========================================
  3773. :deep(.empty-editor-placeholder) {
  3774. display: flex;
  3775. flex-direction: column;
  3776. align-items: center;
  3777. justify-content: center;
  3778. padding: 80px 40px;
  3779. text-align: center;
  3780. min-height: 400px;
  3781. .empty-icon {
  3782. font-size: 64px;
  3783. margin-bottom: 24px;
  3784. opacity: 0.8;
  3785. }
  3786. h2 {
  3787. font-size: 24px;
  3788. font-weight: 600;
  3789. margin-bottom: 12px;
  3790. color: var(--text-1);
  3791. }
  3792. .empty-subtitle {
  3793. font-size: 15px;
  3794. color: var(--text-3);
  3795. margin-bottom: 32px;
  3796. }
  3797. .empty-actions {
  3798. display: flex;
  3799. flex-direction: column;
  3800. gap: 12px;
  3801. margin-bottom: 32px;
  3802. width: 100%;
  3803. max-width: 400px;
  3804. }
  3805. .action-card {
  3806. display: flex;
  3807. align-items: center;
  3808. gap: 12px;
  3809. padding: 16px 20px;
  3810. background: var(--bg);
  3811. border: 1px solid var(--border);
  3812. border-radius: var(--radius-md);
  3813. cursor: pointer;
  3814. transition: all 0.2s;
  3815. text-align: left;
  3816. &:hover {
  3817. border-color: var(--primary);
  3818. background: var(--primary-light);
  3819. transform: translateX(4px);
  3820. }
  3821. .action-icon {
  3822. font-size: 24px;
  3823. flex-shrink: 0;
  3824. }
  3825. .action-text {
  3826. font-size: 14px;
  3827. color: var(--text-1);
  3828. font-weight: 500;
  3829. }
  3830. }
  3831. .empty-hint {
  3832. font-size: 13px;
  3833. color: var(--text-3);
  3834. padding: 12px 20px;
  3835. background: var(--bg);
  3836. border-radius: var(--radius-md);
  3837. border-left: 3px solid var(--primary);
  3838. }
  3839. }
  3840. // 高亮块动画
  3841. .highlight-block {
  3842. animation: highlight-pulse 2s ease-out;
  3843. }
  3844. @keyframes highlight-pulse {
  3845. 0% {
  3846. background: rgba(24, 144, 255, 0.3);
  3847. box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.2);
  3848. }
  3849. 100% {
  3850. background: transparent;
  3851. box-shadow: none;
  3852. }
  3853. }
  3854. </style>