Editor.vue 60 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336
  1. <template>
  2. <div class="editor-page">
  3. <!-- 工具栏 -->
  4. <div class="editor-toolbar">
  5. <el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
  6. <el-divider direction="vertical" />
  7. <div class="title-section">
  8. <div class="title-input-wrapper" :style="{ width: titleInputWidth + 'px' }">
  9. <el-input
  10. v-model="reportTitle"
  11. class="title-input"
  12. placeholder="请输入报告标题"
  13. />
  14. <span class="title-measure" ref="titleMeasure">{{ reportTitle || '请输入报告标题' }}</span>
  15. </div>
  16. <span class="save-status" v-if="saved">✓ 已保存</span>
  17. </div>
  18. <div class="toolbar-right">
  19. <el-button
  20. :icon="Refresh"
  21. :loading="regenerating"
  22. @click="handleRegenerateBlocks"
  23. title="重新生成文档结构"
  24. >
  25. 重新生成
  26. </el-button>
  27. <el-button :icon="Clock">版本</el-button>
  28. <el-button :icon="Share">分享</el-button>
  29. <el-divider direction="vertical" />
  30. <el-button type="primary" :icon="Check" @click="handleSave">保存</el-button>
  31. </div>
  32. </div>
  33. <!-- 主体 -->
  34. <div class="editor-body">
  35. <!-- 左侧文件面板 -->
  36. <div class="left-panel">
  37. <div class="panel-header">
  38. <span>📁 来源文件</span>
  39. <span class="file-count">{{ sourceFiles.length }}个</span>
  40. </div>
  41. <div class="panel-body">
  42. <!-- 上传区 -->
  43. <el-upload
  44. class="upload-zone"
  45. drag
  46. action="/api/v1/parse/upload"
  47. :on-success="handleFileUpload"
  48. :show-file-list="false"
  49. >
  50. <div class="upload-content">
  51. <div class="upload-icon">📄</div>
  52. <div class="upload-text">拖拽或点击上传</div>
  53. <div class="upload-hint">支持 PDF / Word / Excel</div>
  54. </div>
  55. </el-upload>
  56. <!-- 来源文件列表 -->
  57. <div class="file-list">
  58. <div
  59. v-for="file in sourceFiles"
  60. :key="file.id"
  61. class="file-item"
  62. :class="{ active: selectedFile?.id === file.id }"
  63. @click="selectFile(file)"
  64. >
  65. <span class="file-icon">{{ getFileIcon(file) }}</span>
  66. <div class="file-info">
  67. <div class="file-name">{{ file.alias }}</div>
  68. <div class="file-meta">
  69. <span v-if="file.required" class="required">必需</span>
  70. <span v-else>可选</span>
  71. </div>
  72. </div>
  73. <el-button
  74. size="small"
  75. :icon="Delete"
  76. circle
  77. @click.stop="removeSourceFile(file)"
  78. />
  79. </div>
  80. </div>
  81. <!-- 添加来源文件定义 -->
  82. <el-button
  83. class="add-source-btn"
  84. :icon="Plus"
  85. @click="showAddSourceDialog = true"
  86. >
  87. 添加来源文件定义
  88. </el-button>
  89. </div>
  90. </div>
  91. <!-- 中间编辑区 -->
  92. <div class="center-panel">
  93. <div class="editor-title-bar">
  94. <div class="view-toggle">
  95. <el-radio-group v-model="viewMode" size="small">
  96. <el-radio-button label="edit">📝 编辑</el-radio-button>
  97. <el-radio-button label="preview">👁 预览</el-radio-button>
  98. </el-radio-group>
  99. </div>
  100. <el-button :icon="Share" circle @click="showGraphModal = true" title="知识图谱" />
  101. </div>
  102. <div class="editor-scroll" ref="editorRef">
  103. <div
  104. class="editor-content"
  105. contenteditable="true"
  106. @mouseup="handleTextSelection"
  107. v-html="documentContent"
  108. />
  109. </div>
  110. </div>
  111. <!-- 右侧要素面板 -->
  112. <div class="right-panel">
  113. <!-- 要素管理(展示文档中识别的实体) -->
  114. <div class="element-section">
  115. <div class="element-header">
  116. <span class="element-title">
  117. 🏷️ 要素管理
  118. <span class="element-count">({{ filteredEntities.length }}/{{ entities.length }})</span>
  119. </span>
  120. <el-button size="small" :icon="Plus" @click="showAddVariableDialog = true">
  121. 添加
  122. </el-button>
  123. </div>
  124. <!-- 搜索和筛选 -->
  125. <div class="element-filter" v-if="entities.length > 0">
  126. <el-input
  127. v-model="entitySearchKeyword"
  128. placeholder="搜索要素..."
  129. size="small"
  130. :prefix-icon="Search"
  131. clearable
  132. class="entity-search"
  133. />
  134. <div class="entity-type-filter">
  135. <el-tag
  136. v-for="(count, type) in entityTypeCounts"
  137. :key="type"
  138. :class="['filter-tag', { active: entityTypeFilter === type }]"
  139. size="small"
  140. @click="toggleEntityTypeFilter(type)"
  141. >
  142. {{ getEntityTypeIcon(type) }} {{ getEntityTypeName(type) }} ({{ count }})
  143. </el-tag>
  144. <el-tag
  145. v-if="entityTypeFilter"
  146. class="filter-tag clear"
  147. size="small"
  148. @click="entityTypeFilter = ''"
  149. >
  150. 清除筛选
  151. </el-tag>
  152. </div>
  153. </div>
  154. <div class="element-body">
  155. <div class="element-tags-wrap" v-if="filteredEntities.length > 0">
  156. <div
  157. v-for="entity in filteredEntities"
  158. :key="entity.id"
  159. class="var-tag"
  160. :class="getEntityTypeClass(entity.type)"
  161. @click="scrollToEntity(entity.id)"
  162. >
  163. <span class="tag-icon">{{ getEntityTypeIcon(entity.type) }}</span>
  164. <span class="tag-name">{{ entity.text }}</span>
  165. <span class="tag-status" v-if="entity.confirmed">✓</span>
  166. </div>
  167. </div>
  168. <div class="element-hint" v-if="entities.length === 0">
  169. 选中文本后右键标记为变量
  170. </div>
  171. <div class="element-hint" v-else-if="filteredEntities.length === 0">
  172. 没有匹配的要素
  173. </div>
  174. <div class="element-hint" v-else>
  175. 点击标签定位到文档位置
  176. </div>
  177. </div>
  178. </div>
  179. <!-- 按类别分组显示 -->
  180. <div class="category-section" v-for="(vars, category) in groupedVariables" :key="category">
  181. <div class="category-header">
  182. <span
  183. class="category-dot"
  184. :style="{ background: getCategoryColor(category) }"
  185. />
  186. <span>{{ getCategoryLabel(category) }}</span>
  187. <span class="category-count">{{ vars.length }}</span>
  188. </div>
  189. <div class="category-items">
  190. <div
  191. v-for="v in vars"
  192. :key="v.id"
  193. class="category-item"
  194. @click="editVariable(v)"
  195. >
  196. <span>{{ v.displayName }}</span>
  197. <span class="item-value">{{ v.exampleValue || '-' }}</span>
  198. </div>
  199. </div>
  200. </div>
  201. </div>
  202. </div>
  203. <!-- 右键菜单 -->
  204. <div
  205. v-show="contextMenuVisible"
  206. class="context-menu"
  207. :style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
  208. >
  209. <div class="context-menu-item" @click="markAsVariable('entity')">
  210. <span class="icon">🏢</span>
  211. <span>标记为核心实体</span>
  212. </div>
  213. <div class="context-menu-item" @click="markAsVariable('concept')">
  214. <span class="icon">💡</span>
  215. <span>标记为概念/技术</span>
  216. </div>
  217. <div class="context-menu-item" @click="markAsVariable('data')">
  218. <span class="icon">📊</span>
  219. <span>标记为数据/指标</span>
  220. </div>
  221. <div class="context-menu-item" @click="markAsVariable('location')">
  222. <span class="icon">📍</span>
  223. <span>标记为地点/组织</span>
  224. </div>
  225. <div class="context-menu-item" @click="markAsVariable('asset')">
  226. <span class="icon">📑</span>
  227. <span>标记为资源模板</span>
  228. </div>
  229. </div>
  230. <!-- 添加来源文件对话框 -->
  231. <el-dialog v-model="showAddSourceDialog" title="添加来源文件定义" width="400">
  232. <el-form :model="newSourceFile" label-width="80px">
  233. <el-form-item label="文件别名" required>
  234. <el-input v-model="newSourceFile.alias" placeholder="如:可研批复" />
  235. </el-form-item>
  236. <el-form-item label="描述">
  237. <el-input v-model="newSourceFile.description" placeholder="文件描述" />
  238. </el-form-item>
  239. <el-form-item label="是否必需">
  240. <el-switch v-model="newSourceFile.required" />
  241. </el-form-item>
  242. </el-form>
  243. <template #footer>
  244. <el-button @click="showAddSourceDialog = false">取消</el-button>
  245. <el-button type="primary" @click="addSourceFile">添加</el-button>
  246. </template>
  247. </el-dialog>
  248. <!-- 添加/编辑变量对话框 -->
  249. <el-dialog v-model="showVariableDialog" :title="editingVariable ? '编辑变量' : '添加变量'" width="500">
  250. <el-form :model="variableForm" label-width="100px">
  251. <el-form-item label="变量名" required>
  252. <el-input v-model="variableForm.name" placeholder="程序用名,如 project_name" />
  253. </el-form-item>
  254. <el-form-item label="显示名称" required>
  255. <el-input v-model="variableForm.displayName" placeholder="用户可见名称" />
  256. </el-form-item>
  257. <el-form-item label="类别">
  258. <el-select v-model="variableForm.category" style="width: 100%">
  259. <el-option label="核心实体" value="entity" />
  260. <el-option label="概念/技术" value="concept" />
  261. <el-option label="数据/指标" value="data" />
  262. <el-option label="地点/组织" value="location" />
  263. <el-option label="资源模板" value="asset" />
  264. </el-select>
  265. </el-form-item>
  266. <el-form-item label="示例值">
  267. <el-input v-model="variableForm.exampleValue" placeholder="文档中的原始值" />
  268. </el-form-item>
  269. <el-form-item label="来源类型">
  270. <el-select v-model="variableForm.sourceType" style="width: 100%">
  271. <el-option label="从来源文件提取" value="document" />
  272. <el-option label="手动输入" value="manual" />
  273. <el-option label="引用其他变量" value="reference" />
  274. <el-option label="固定值" value="fixed" />
  275. </el-select>
  276. </el-form-item>
  277. <el-form-item label="来源文件" v-if="variableForm.sourceType === 'document'">
  278. <el-select v-model="variableForm.sourceFileAlias" style="width: 100%">
  279. <el-option
  280. v-for="sf in sourceFiles"
  281. :key="sf.id"
  282. :label="sf.alias"
  283. :value="sf.alias"
  284. />
  285. </el-select>
  286. </el-form-item>
  287. <el-form-item label="提取方式" v-if="variableForm.sourceType === 'document'">
  288. <el-select v-model="variableForm.extractType" style="width: 100%">
  289. <el-option label="直接提取" value="direct" />
  290. <el-option label="AI 字段提取" value="ai_extract" />
  291. <el-option label="AI 总结" value="ai_summarize" />
  292. </el-select>
  293. </el-form-item>
  294. </el-form>
  295. <template #footer>
  296. <el-button @click="showVariableDialog = false">取消</el-button>
  297. <el-button
  298. v-if="editingVariable"
  299. type="danger"
  300. @click="deleteVariable"
  301. >
  302. 删除
  303. </el-button>
  304. <el-button type="primary" @click="saveVariable">保存</el-button>
  305. </template>
  306. </el-dialog>
  307. <!-- 知识图谱弹窗 -->
  308. <el-dialog v-model="showGraphModal" title="🔗 知识图谱" width="900">
  309. <div class="graph-container">
  310. <div class="graph-legend">
  311. <div class="legend-title">图例</div>
  312. <div class="legend-item">
  313. <span class="legend-dot entity"></span>
  314. <span>核心实体</span>
  315. </div>
  316. <div class="legend-item">
  317. <span class="legend-dot concept"></span>
  318. <span>概念/技术</span>
  319. </div>
  320. <div class="legend-item">
  321. <span class="legend-dot data"></span>
  322. <span>数据/指标</span>
  323. </div>
  324. <div class="legend-item">
  325. <span class="legend-dot location"></span>
  326. <span>地点/组织</span>
  327. </div>
  328. </div>
  329. <div class="graph-body">
  330. <div class="graph-placeholder">
  331. <el-icon size="64" color="#ccc"><Connection /></el-icon>
  332. <p>知识图谱可视化(开发中)</p>
  333. </div>
  334. </div>
  335. </div>
  336. </el-dialog>
  337. </div>
  338. </template>
  339. <script setup>
  340. import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
  341. import { useRouter, useRoute } from 'vue-router'
  342. import {
  343. ArrowLeft, Clock, Share, Check, Plus, Delete, Connection, Refresh, Search
  344. } from '@element-plus/icons-vue'
  345. import { ElMessage } from 'element-plus'
  346. import { useTemplateStore } from '@/stores/template'
  347. import { documentApi } from '@/api'
  348. const router = useRouter()
  349. const route = useRoute()
  350. const templateStore = useTemplateStore()
  351. const templateId = route.params.templateId
  352. const reportTitle = ref('')
  353. const titleMeasure = ref(null)
  354. const titleInputWidth = ref(120)
  355. const viewMode = ref('edit')
  356. const saved = ref(true)
  357. const editorRef = ref(null)
  358. const loading = ref(false)
  359. const regenerating = ref(false)
  360. // 动态计算标题输入框宽度
  361. watch(reportTitle, () => {
  362. nextTick(() => {
  363. if (titleMeasure.value) {
  364. const measuredWidth = titleMeasure.value.offsetWidth + 30 // 额外边距
  365. titleInputWidth.value = Math.max(120, Math.min(400, measuredWidth))
  366. }
  367. })
  368. })
  369. // 来源文件(从 API 获取)
  370. const sourceFiles = ref([])
  371. const selectedFile = ref(null)
  372. const showAddSourceDialog = ref(false)
  373. const newSourceFile = reactive({
  374. alias: '',
  375. description: '',
  376. required: true
  377. })
  378. // 变量(从 API 获取)
  379. const variables = ref([])
  380. // 文档中的实体(从 blocks 的 elements 中提取)
  381. const entities = ref([])
  382. // 要素搜索和筛选
  383. const entitySearchKeyword = ref('')
  384. const entityTypeFilter = ref('')
  385. // 计算属性:按类型统计要素数量
  386. const entityTypeCounts = computed(() => {
  387. const counts = {}
  388. entities.value.forEach(entity => {
  389. const type = entity.type || 'default'
  390. counts[type] = (counts[type] || 0) + 1
  391. })
  392. return counts
  393. })
  394. // 计算属性:筛选后的要素列表
  395. const filteredEntities = computed(() => {
  396. let result = entities.value
  397. // 按类型筛选
  398. if (entityTypeFilter.value) {
  399. result = result.filter(e => e.type === entityTypeFilter.value)
  400. }
  401. // 按关键词搜索
  402. if (entitySearchKeyword.value) {
  403. const keyword = entitySearchKeyword.value.toLowerCase()
  404. result = result.filter(e =>
  405. e.text?.toLowerCase().includes(keyword) ||
  406. e.type?.toLowerCase().includes(keyword)
  407. )
  408. }
  409. return result
  410. })
  411. // 切换类型筛选
  412. function toggleEntityTypeFilter(type) {
  413. if (entityTypeFilter.value === type) {
  414. entityTypeFilter.value = ''
  415. } else {
  416. entityTypeFilter.value = type
  417. }
  418. }
  419. // 获取实体类型名称(支持后端返回的英文类型)
  420. function getEntityTypeName(type) {
  421. const typeNames = {
  422. // 中文类型
  423. 'entity': '实体',
  424. 'concept': '概念',
  425. 'data': '数据',
  426. 'location': '地点',
  427. 'asset': '资产',
  428. 'person': '人物',
  429. 'org': '组织',
  430. 'date': '日期',
  431. 'product': '产品',
  432. 'event': '事件',
  433. 'law': '法规',
  434. 'default': '其他',
  435. // 后端返回的英文类型
  436. 'DOC_ID': '文档编号',
  437. 'ORG': '组织机构',
  438. 'PERSON': '人物',
  439. 'LOCATION': '地点',
  440. 'LOC': '地点',
  441. 'DATE': '日期',
  442. 'TIME': '时间',
  443. 'MONEY': '金额',
  444. 'PERCENT': '百分比',
  445. 'PRODUCT': '产品',
  446. 'EVENT': '事件',
  447. 'LAW': '法规',
  448. 'WORK_OF_ART': '作品',
  449. 'LANGUAGE': '语言',
  450. 'NORP': '民族/宗教/政治团体',
  451. 'FAC': '设施',
  452. 'GPE': '地理政治实体',
  453. 'CARDINAL': '数量',
  454. 'ORDINAL': '序数',
  455. 'QUANTITY': '数量单位',
  456. 'TITLE': '职务/头衔',
  457. 'STANDARD': '标准规范',
  458. 'RATING': '评级',
  459. 'PERIOD': '时间段',
  460. 'SCORE': '评分',
  461. 'LEVEL': '等级'
  462. }
  463. return typeNames[type] || typeNames[type?.toUpperCase()] || type || '其他'
  464. }
  465. /**
  466. * 从结构化文档的 blocks 中提取所有实体
  467. */
  468. function extractEntitiesFromBlocks(blocks) {
  469. const entityList = []
  470. const entityMap = new Map() // 用于去重
  471. if (!blocks || !Array.isArray(blocks)) {
  472. return entityList
  473. }
  474. for (const block of blocks) {
  475. if (!block.elements || !Array.isArray(block.elements)) {
  476. continue
  477. }
  478. for (const element of block.elements) {
  479. if (element.type === 'entity' && element.entityId) {
  480. // 使用 entityId 去重
  481. if (!entityMap.has(element.entityId)) {
  482. entityMap.set(element.entityId, true)
  483. entityList.push({
  484. id: element.entityId,
  485. text: element.entityText || '',
  486. type: element.entityType || 'ENTITY',
  487. confirmed: element.confirmed || false
  488. })
  489. }
  490. }
  491. }
  492. }
  493. return entityList
  494. }
  495. // 加载模板数据
  496. onMounted(async () => {
  497. await fetchTemplateData()
  498. })
  499. async function fetchTemplateData() {
  500. loading.value = true
  501. try {
  502. await templateStore.fetchTemplateDetail(templateId)
  503. // 设置模板标题
  504. reportTitle.value = templateStore.currentTemplate?.name || '未命名模板'
  505. // 设置来源文件
  506. sourceFiles.value = templateStore.sourceFiles || []
  507. // 设置变量
  508. variables.value = templateStore.variables || []
  509. // 根据 baseDocumentId 获取文档结构化内容
  510. const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
  511. if (baseDocumentId) {
  512. try {
  513. const structuredDoc = await documentApi.getStructured(baseDocumentId)
  514. // 将结构化文档的 blocks 和 images 合并渲染
  515. if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
  516. documentContent.value = renderStructuredDocument(structuredDoc)
  517. // 提取文档中的实体
  518. entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
  519. } else {
  520. documentContent.value = emptyPlaceholder
  521. entities.value = []
  522. }
  523. } catch (docError) {
  524. console.warn('获取文档内容失败:', docError)
  525. documentContent.value = emptyPlaceholder
  526. entities.value = []
  527. }
  528. } else {
  529. documentContent.value = emptyPlaceholder
  530. entities.value = []
  531. }
  532. } catch (error) {
  533. console.error('加载模板失败:', error)
  534. ElMessage.error('加载模板失败')
  535. } finally {
  536. loading.value = false
  537. }
  538. }
  539. const showVariableDialog = ref(false)
  540. const showAddVariableDialog = ref(false)
  541. const editingVariable = ref(null)
  542. const variableForm = reactive({
  543. name: '',
  544. displayName: '',
  545. category: 'entity',
  546. exampleValue: '',
  547. sourceType: 'document',
  548. sourceFileAlias: '',
  549. extractType: 'direct'
  550. })
  551. // 右键菜单
  552. const contextMenuVisible = ref(false)
  553. const contextMenuPos = reactive({ x: 0, y: 0 })
  554. const selectedText = ref('')
  555. const selectionRange = ref(null)
  556. // 知识图谱
  557. const showGraphModal = ref(false)
  558. // 文档内容(从 API 获取或空白)
  559. const documentContent = ref('')
  560. // 空白模板时的占位提示
  561. const emptyPlaceholder = `
  562. <div class="empty-editor-placeholder">
  563. <h2>📝 开始编辑您的模板</h2>
  564. <p>这是一个空白模板。您可以:</p>
  565. <ul>
  566. <li>在左侧添加来源文件定义</li>
  567. <li>在右侧面板添加变量</li>
  568. <li>直接在此处编辑模板内容</li>
  569. <li>选中文本后右键将其标记为变量</li>
  570. </ul>
  571. </div>
  572. `
  573. /**
  574. * 渲染结构化文档(合并 blocks 和 images)
  575. * 根据 index 排序,将图片插入到正确的位置
  576. */
  577. function renderStructuredDocument(structuredDoc) {
  578. const blocks = structuredDoc.blocks || []
  579. const images = structuredDoc.images || []
  580. const tables = structuredDoc.tables || []
  581. const paragraphs = structuredDoc.paragraphs || []
  582. // 将所有元素合并
  583. const allElements = []
  584. // 从 blocks 中提取实体映射(按文本内容匹配)
  585. const entityMap = buildEntityMap(blocks)
  586. // 检查 paragraphs 是否有 runs(带格式信息)
  587. const hasParagraphsWithRuns = paragraphs.some(p => p.runs && p.runs.length > 0)
  588. if (hasParagraphsWithRuns) {
  589. // 使用 paragraphs 渲染(保留格式 + 合并实体高亮)
  590. paragraphs.forEach(para => {
  591. allElements.push({
  592. type: 'paragraph',
  593. index: para.index,
  594. html: renderParagraphWithRunsAndEntities(para, entityMap)
  595. })
  596. })
  597. } else if (blocks.length > 0) {
  598. // 回退到 blocks 渲染(带实体标记,但无格式)
  599. blocks.forEach(block => {
  600. if (block.type === 'page') return // 跳过根节点
  601. allElements.push({
  602. type: 'block',
  603. index: block.index,
  604. html: block.markedHtml || block.html || block.plainText || ''
  605. })
  606. })
  607. }
  608. // 添加图片(保持原始尺寸,不显示说明文字)
  609. images.forEach(img => {
  610. // 图片样式:保持原始尺寸,不强制居中
  611. const imgStyle = img.width && img.height
  612. ? `width:${img.width}px; height:${img.height}px;`
  613. : 'max-width: 100%; height: auto;'
  614. allElements.push({
  615. type: 'image',
  616. index: img.index,
  617. html: `<div class="doc-image" style="margin: 8px 0;">
  618. <img src="${img.url}" alt="${img.alt || '图片'}" style="${imgStyle}" />
  619. </div>`
  620. })
  621. })
  622. // 添加表格
  623. tables.forEach(table => {
  624. allElements.push({
  625. type: 'table',
  626. index: table.index,
  627. html: renderTable(table, entityMap)
  628. })
  629. })
  630. // 按 index 排序
  631. allElements.sort((a, b) => (a.index || 0) - (b.index || 0))
  632. // 合并 HTML
  633. return allElements.map(el => el.html).join('')
  634. }
  635. /**
  636. * 渲染表格
  637. */
  638. function renderTable(table, entityMap) {
  639. if (!table.rows || table.rows.length === 0) {
  640. return '<div class="doc-table-empty">空表格</div>'
  641. }
  642. let html = '<div class="doc-table-container"><table class="doc-table">'
  643. table.rows.forEach((row, rowIndex) => {
  644. html += '<tr>'
  645. row.forEach((cell, colIndex) => {
  646. const tag = rowIndex === 0 ? 'th' : 'td'
  647. const attrs = []
  648. if (cell.rowSpan && cell.rowSpan > 1) {
  649. attrs.push(`rowspan="${cell.rowSpan}"`)
  650. }
  651. if (cell.colSpan && cell.colSpan > 1) {
  652. attrs.push(`colspan="${cell.colSpan}"`)
  653. }
  654. // 单元格样式
  655. const styleAttrs = []
  656. if (cell.style) {
  657. if (cell.style.alignment) {
  658. const alignMap = { 'left': 'left', 'center': 'center', 'right': 'right', 'both': 'justify' }
  659. styleAttrs.push(`text-align:${alignMap[cell.style.alignment] || cell.style.alignment}`)
  660. }
  661. if (cell.style.backgroundColor) {
  662. styleAttrs.push(`background-color:#${cell.style.backgroundColor}`)
  663. }
  664. }
  665. if (styleAttrs.length > 0) {
  666. attrs.push(`style="${styleAttrs.join(';')}"`)
  667. }
  668. // 单元格内容(支持 runs 格式)
  669. let content = ''
  670. if (cell.runs && cell.runs.length > 0) {
  671. content = cell.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
  672. } else {
  673. content = highlightEntitiesInText(cell.text || '', entityMap)
  674. }
  675. html += `<${tag} ${attrs.join(' ')}>${content}</${tag}>`
  676. })
  677. html += '</tr>'
  678. })
  679. html += '</table></div>'
  680. return html
  681. }
  682. /**
  683. * 从 blocks 中构建实体映射
  684. * 返回 { entityText: { entityId, entityType, confirmed } }
  685. */
  686. function buildEntityMap(blocks) {
  687. const entityMap = new Map()
  688. blocks.forEach(block => {
  689. if (!block.elements) return
  690. block.elements.forEach(el => {
  691. if (el.type === 'entity' && el.entityText) {
  692. // 使用实体文本作为 key(可能有多个相同文本的实体)
  693. if (!entityMap.has(el.entityText)) {
  694. entityMap.set(el.entityText, [])
  695. }
  696. entityMap.get(el.entityText).push({
  697. entityId: el.entityId,
  698. entityType: el.entityType,
  699. confirmed: el.confirmed
  700. })
  701. }
  702. })
  703. })
  704. return entityMap
  705. }
  706. /**
  707. * 渲染带格式和实体高亮的段落
  708. */
  709. function renderParagraphWithRunsAndEntities(para, entityMap) {
  710. if (!para.runs || para.runs.length === 0) {
  711. // 没有 runs,使用纯文本
  712. const content = highlightEntitiesInText(para.content || '', entityMap)
  713. return wrapWithParagraphTag(content, para.type, para.style)
  714. }
  715. // 渲染每个 run,同时应用实体高亮
  716. const runsHtml = para.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
  717. return wrapWithParagraphTag(runsHtml, para.type, para.style)
  718. }
  719. /**
  720. * 渲染带格式的段落(使用 runs)- 保留兼容
  721. */
  722. function renderParagraphWithRuns(para) {
  723. if (!para.runs || para.runs.length === 0) {
  724. const content = escapeHtml(para.content || '').replace(/\n/g, '<br>')
  725. return wrapWithParagraphTag(content, para.type, para.style)
  726. }
  727. const runsHtml = para.runs.map(run => renderTextRun(run)).join('')
  728. return wrapWithParagraphTag(runsHtml, para.type, para.style)
  729. }
  730. /**
  731. * 渲染单个文本片段(Run)并高亮实体
  732. */
  733. function renderTextRunWithEntities(run, entityMap) {
  734. if (!run || !run.text) return ''
  735. // 先在文本中查找并高亮实体
  736. const highlightedText = highlightEntitiesInText(run.text, entityMap)
  737. // 如果文本被实体高亮处理过(包含 span 标签),需要特殊处理样式
  738. const hasEntityHighlight = highlightedText.includes('entity-highlight')
  739. // 构建样式
  740. const styles = buildRunStyles(run)
  741. // 如果没有样式,直接返回高亮后的文本
  742. if (styles.length === 0) {
  743. return highlightedText.replace(/\n/g, '<br>')
  744. }
  745. // 如果有实体高亮,需要用 span 包裹整体样式
  746. if (hasEntityHighlight) {
  747. return `<span style="${styles.join(';')}">${highlightedText.replace(/\n/g, '<br>')}</span>`
  748. }
  749. // 普通文本,处理换行并应用样式
  750. const text = escapeHtml(run.text).replace(/\n/g, '<br>')
  751. // 上下标特殊处理
  752. if (run.verticalAlign === 'superscript') {
  753. return `<sup style="${styles.join(';')}">${text}</sup>`
  754. } else if (run.verticalAlign === 'subscript') {
  755. return `<sub style="${styles.join(';')}">${text}</sub>`
  756. }
  757. return `<span style="${styles.join(';')}">${text}</span>`
  758. }
  759. /**
  760. * 在文本中查找并高亮实体
  761. */
  762. function highlightEntitiesInText(text, entityMap) {
  763. if (!text || !entityMap || entityMap.size === 0) {
  764. return escapeHtml(text || '')
  765. }
  766. // 按实体文本长度降序排序(优先匹配长的)
  767. const sortedEntities = Array.from(entityMap.keys()).sort((a, b) => b.length - a.length)
  768. let result = text
  769. const replacements = []
  770. // 找出所有需要替换的位置
  771. for (const entityText of sortedEntities) {
  772. const entities = entityMap.get(entityText)
  773. if (!entities || entities.length === 0) continue
  774. let searchStart = 0
  775. let entityIndex = 0
  776. while (true) {
  777. const pos = result.indexOf(entityText, searchStart)
  778. if (pos === -1) break
  779. // 获取对应的实体信息(循环使用)
  780. const entity = entities[entityIndex % entities.length]
  781. replacements.push({
  782. start: pos,
  783. end: pos + entityText.length,
  784. text: entityText,
  785. entity: entity
  786. })
  787. searchStart = pos + entityText.length
  788. entityIndex++
  789. }
  790. }
  791. // 按位置排序,从后往前替换(避免位置偏移)
  792. replacements.sort((a, b) => b.start - a.start)
  793. // 检查重叠,移除被包含的替换
  794. const finalReplacements = []
  795. for (const rep of replacements) {
  796. const hasOverlap = finalReplacements.some(
  797. existing => rep.start < existing.end && rep.end > existing.start
  798. )
  799. if (!hasOverlap) {
  800. finalReplacements.push(rep)
  801. }
  802. }
  803. // 执行替换
  804. for (const rep of finalReplacements) {
  805. const before = result.substring(0, rep.start)
  806. const after = result.substring(rep.end)
  807. const highlighted = renderEntityHighlight(rep.text, rep.entity)
  808. result = before + highlighted + after
  809. }
  810. // 对非实体部分进行 HTML 转义
  811. // 由于实体部分已经包含 HTML,需要分段处理
  812. return escapeNonEntityText(result)
  813. }
  814. /**
  815. * 转义非实体部分的文本
  816. */
  817. function escapeNonEntityText(text) {
  818. // 分割出实体标签和普通文本
  819. const parts = text.split(/(<span class="entity-highlight[^>]*>.*?<\/span>)/g)
  820. return parts.map(part => {
  821. if (part.startsWith('<span class="entity-highlight')) {
  822. return part // 保留实体标签
  823. }
  824. return escapeHtml(part) // 转义普通文本
  825. }).join('')
  826. }
  827. /**
  828. * 渲染实体高亮标签
  829. */
  830. function renderEntityHighlight(text, entity) {
  831. const cssClass = getEntityCssClass(entity.entityType)
  832. const confirmedMark = entity.confirmed ? ' ✓' : ''
  833. return `<span class="${cssClass}" ` +
  834. `data-entity-id="${entity.entityId || ''}" ` +
  835. `data-type="${entity.entityType || ''}" ` +
  836. `onclick="showEntityEditModal(event,'${entity.entityId || ''}')" ` +
  837. `contenteditable="false">${escapeHtml(text)}${confirmedMark}</span>`
  838. }
  839. /**
  840. * 获取实体类型对应的 CSS 类
  841. */
  842. function getEntityCssClass(entityType) {
  843. const typeMap = {
  844. 'PERSON': 'entity-highlight person',
  845. 'ORG': 'entity-highlight org',
  846. 'ORGANIZATION': 'entity-highlight org',
  847. 'LOC': 'entity-highlight location',
  848. 'LOCATION': 'entity-highlight location',
  849. 'GPE': 'entity-highlight location',
  850. 'DATE': 'entity-highlight date',
  851. 'TIME': 'entity-highlight date',
  852. 'MONEY': 'entity-highlight data',
  853. 'NUMBER': 'entity-highlight data',
  854. 'PERCENT': 'entity-highlight data',
  855. 'DATA': 'entity-highlight data',
  856. 'CONCEPT': 'entity-highlight concept',
  857. 'PRODUCT': 'entity-highlight product',
  858. 'EVENT': 'entity-highlight event'
  859. }
  860. return typeMap[entityType?.toUpperCase()] || 'entity-highlight entity'
  861. }
  862. /**
  863. * 构建 Run 的 CSS 样式数组
  864. */
  865. function buildRunStyles(run) {
  866. const styles = []
  867. if (run.fontFamily) {
  868. styles.push(`font-family:${run.fontFamily}`)
  869. }
  870. if (run.fontSize && run.fontSize > 0) {
  871. styles.push(`font-size:${run.fontSize}pt`)
  872. }
  873. if (run.color) {
  874. const color = run.color.startsWith('#') ? run.color : `#${run.color}`
  875. styles.push(`color:${color}`)
  876. }
  877. if (run.highlightColor) {
  878. const bgColor = getHighlightColor(run.highlightColor)
  879. styles.push(`background-color:${bgColor}`)
  880. }
  881. if (run.bold) {
  882. styles.push('font-weight:bold')
  883. }
  884. if (run.italic) {
  885. styles.push('font-style:italic')
  886. }
  887. const textDecorations = []
  888. if (run.underline && run.underline !== 'none') {
  889. const underlineStyle = run.underline === 'double' ? 'double' :
  890. run.underline === 'wave' || run.underline === 'wavy' ? 'wavy' :
  891. run.underline === 'dotted' ? 'dotted' :
  892. run.underline === 'dashed' ? 'dashed' : 'solid'
  893. textDecorations.push(`underline ${underlineStyle}`)
  894. }
  895. if (run.strikeThrough) {
  896. textDecorations.push('line-through')
  897. }
  898. if (textDecorations.length > 0) {
  899. styles.push(`text-decoration:${textDecorations.join(' ')}`)
  900. }
  901. return styles
  902. }
  903. /**
  904. * 渲染单个文本片段(Run)- 保留兼容
  905. */
  906. function renderTextRun(run) {
  907. if (!run || !run.text) return ''
  908. // 转义 HTML 并将换行符转换为 <br>
  909. let text = escapeHtml(run.text).replace(/\n/g, '<br>')
  910. const styles = buildRunStyles(run)
  911. // 上下标
  912. if (run.verticalAlign === 'superscript') {
  913. return styles.length > 0 ? `<sup style="${styles.join(';')}">${text}</sup>` : `<sup>${text}</sup>`
  914. } else if (run.verticalAlign === 'subscript') {
  915. return styles.length > 0 ? `<sub style="${styles.join(';')}">${text}</sub>` : `<sub>${text}</sub>`
  916. }
  917. // 如果没有样式,直接返回文本
  918. if (styles.length === 0) {
  919. return text
  920. }
  921. return `<span style="${styles.join(';')}">${text}</span>`
  922. }
  923. /**
  924. * 获取高亮颜色对应的 CSS 颜色
  925. */
  926. function getHighlightColor(colorName) {
  927. const colors = {
  928. 'yellow': '#ffff00',
  929. 'green': '#00ff00',
  930. 'cyan': '#00ffff',
  931. 'magenta': '#ff00ff',
  932. 'blue': '#0000ff',
  933. 'red': '#ff0000',
  934. 'darkblue': '#000080',
  935. 'darkcyan': '#008080',
  936. 'darkgreen': '#008000',
  937. 'darkmagenta': '#800080',
  938. 'darkred': '#800000',
  939. 'darkyellow': '#808000',
  940. 'darkgray': '#808080',
  941. 'lightgray': '#c0c0c0',
  942. 'black': '#000000'
  943. }
  944. return colors[colorName.toLowerCase()] || colorName
  945. }
  946. /**
  947. * 用段落标签包裹内容
  948. */
  949. function wrapWithParagraphTag(content, type, style) {
  950. // 段落样式
  951. const styleAttrs = []
  952. if (style) {
  953. // 对齐方式
  954. if (style.alignment) {
  955. const alignMap = {
  956. 'left': 'left',
  957. 'center': 'center',
  958. 'right': 'right',
  959. 'both': 'justify', // 两端对齐
  960. 'justify': 'justify'
  961. }
  962. styleAttrs.push(`text-align:${alignMap[style.alignment] || style.alignment}`)
  963. }
  964. // 左缩进(twips -> pt,1 twip = 1/20 pt)
  965. if (style.indentLeft) {
  966. styleAttrs.push(`padding-left:${style.indentLeft / 20}pt`)
  967. }
  968. // 右缩进
  969. if (style.indentRight) {
  970. styleAttrs.push(`padding-right:${style.indentRight / 20}pt`)
  971. }
  972. // 首行缩进
  973. if (style.indentFirstLine) {
  974. styleAttrs.push(`text-indent:${style.indentFirstLine / 20}pt`)
  975. }
  976. // 悬挂缩进(负的首行缩进 + 增加左边距)
  977. if (style.indentHanging) {
  978. const hangingPt = style.indentHanging / 20
  979. styleAttrs.push(`text-indent:-${hangingPt}pt`)
  980. // 如果没有左缩进,需要增加左边距来补偿
  981. if (!style.indentLeft) {
  982. styleAttrs.push(`margin-left:${hangingPt}pt`)
  983. }
  984. }
  985. // 段前间距
  986. if (style.spacingBefore) {
  987. styleAttrs.push(`margin-top:${style.spacingBefore / 20}pt`)
  988. }
  989. // 段后间距
  990. if (style.spacingAfter) {
  991. styleAttrs.push(`margin-bottom:${style.spacingAfter / 20}pt`)
  992. }
  993. // 行距处理
  994. if (style.lineSpacing) {
  995. // getSpacingBetween 返回的是倍数(如 1.0, 1.5, 2.0)
  996. if (style.lineSpacing >= 1 && style.lineSpacing <= 5) {
  997. styleAttrs.push(`line-height:${style.lineSpacing}`)
  998. }
  999. } else if (style.lineSpacingValue && style.lineSpacingRule) {
  1000. // 精确行距值处理
  1001. const rule = style.lineSpacingRule.toLowerCase()
  1002. if (rule === 'exact') {
  1003. // 固定行距(twips -> pt)
  1004. styleAttrs.push(`line-height:${style.lineSpacingValue / 20}pt`)
  1005. } else if (rule === 'atleast' || rule === 'at_least') {
  1006. // 最小行距
  1007. styleAttrs.push(`min-height:${style.lineSpacingValue / 20}pt`)
  1008. } else if (rule === 'auto') {
  1009. // 倍数行距(240 twips = 1 倍行距)
  1010. styleAttrs.push(`line-height:${style.lineSpacingValue / 240}`)
  1011. }
  1012. }
  1013. // 字体信息(段落级别的默认字体)
  1014. if (style.fontFamily) {
  1015. styleAttrs.push(`font-family:${style.fontFamily}`)
  1016. }
  1017. if (style.fontSize) {
  1018. styleAttrs.push(`font-size:${style.fontSize}pt`)
  1019. }
  1020. }
  1021. const styleAttr = styleAttrs.length > 0 ? ` style="${styleAttrs.join(';')}"` : ''
  1022. // 目录项特殊处理
  1023. if (type === 'toc_item') {
  1024. const pageNum = style?.tocPageNum || ''
  1025. // 计算缩进级别(根据章节号判断)
  1026. let level = 0
  1027. const levelMatch = content.match(/^(\d+(?:\.\d+)*)/)
  1028. if (levelMatch) {
  1029. level = (levelMatch[1].match(/\./g) || []).length
  1030. }
  1031. const indentStyle = level > 0 ? ` style="padding-left:${level * 20}px"` : ''
  1032. 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>`
  1033. }
  1034. switch (type) {
  1035. case 'heading1':
  1036. return `<h1${styleAttr}>${content}</h1>`
  1037. case 'heading2':
  1038. return `<h2${styleAttr}>${content}</h2>`
  1039. case 'heading3':
  1040. return `<h3${styleAttr}>${content}</h3>`
  1041. case 'heading':
  1042. return `<h2${styleAttr}>${content}</h2>`
  1043. case 'toc':
  1044. return `<div class="doc-toc-title"${styleAttr}>${content}</div>`
  1045. case 'bullet':
  1046. case 'list_item':
  1047. return `<div class="doc-list-item bullet"${styleAttr}>${content}</div>`
  1048. case 'ordered':
  1049. return `<div class="doc-list-item ordered"${styleAttr}>${content}</div>`
  1050. case 'quote':
  1051. return `<blockquote${styleAttr}>${content}</blockquote>`
  1052. case 'code':
  1053. return `<pre><code>${content}</code></pre>`
  1054. case 'title':
  1055. return `<h1 class="doc-title"${styleAttr}>${content}</h1>`
  1056. default:
  1057. return `<p${styleAttr}>${content}</p>`
  1058. }
  1059. }
  1060. /**
  1061. * HTML 转义
  1062. */
  1063. function escapeHtml(text) {
  1064. if (!text) return ''
  1065. return text
  1066. .replace(/&/g, '&amp;')
  1067. .replace(/</g, '&lt;')
  1068. .replace(/>/g, '&gt;')
  1069. .replace(/"/g, '&quot;')
  1070. }
  1071. // 计算属性
  1072. const groupedVariables = computed(() => {
  1073. const groups = {}
  1074. variables.value.forEach(v => {
  1075. const cat = v.category || 'other'
  1076. if (!groups[cat]) groups[cat] = []
  1077. groups[cat].push(v)
  1078. })
  1079. return groups
  1080. })
  1081. // 方法
  1082. function goBack() {
  1083. router.back()
  1084. }
  1085. function handleSave() {
  1086. saved.value = true
  1087. ElMessage.success('保存成功')
  1088. }
  1089. // 重新生成文档块结构
  1090. async function handleRegenerateBlocks() {
  1091. const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
  1092. if (!baseDocumentId) {
  1093. ElMessage.warning('没有关联的示例文档')
  1094. return
  1095. }
  1096. regenerating.value = true
  1097. try {
  1098. const result = await documentApi.regenerateBlocks(baseDocumentId)
  1099. ElMessage.success(`重新生成成功: ${result.blockCount} 个文档块, ${result.entityCount} 个实体`)
  1100. // 重新加载文档内容
  1101. const structuredDoc = await documentApi.getStructured(baseDocumentId)
  1102. if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
  1103. documentContent.value = renderStructuredDocument(structuredDoc)
  1104. // 重新提取实体
  1105. entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
  1106. }
  1107. } catch (error) {
  1108. console.error('重新生成失败:', error)
  1109. ElMessage.error('重新生成失败: ' + (error.message || '未知错误'))
  1110. } finally {
  1111. regenerating.value = false
  1112. }
  1113. }
  1114. function getFileIcon(file) {
  1115. return '📄'
  1116. }
  1117. function selectFile(file) {
  1118. selectedFile.value = file
  1119. }
  1120. async function removeSourceFile(file) {
  1121. try {
  1122. await templateStore.deleteSourceFile(file.id)
  1123. sourceFiles.value = sourceFiles.value.filter(f => f.id !== file.id)
  1124. ElMessage.success('删除成功')
  1125. } catch (error) {
  1126. ElMessage.error('删除失败: ' + error.message)
  1127. }
  1128. }
  1129. async function addSourceFile() {
  1130. if (!newSourceFile.alias) {
  1131. ElMessage.warning('请输入文件别名')
  1132. return
  1133. }
  1134. try {
  1135. const sf = await templateStore.addSourceFile(templateId, newSourceFile)
  1136. sourceFiles.value.push(sf)
  1137. showAddSourceDialog.value = false
  1138. Object.assign(newSourceFile, { alias: '', description: '', required: true })
  1139. ElMessage.success('添加成功')
  1140. } catch (error) {
  1141. ElMessage.error('添加失败: ' + error.message)
  1142. }
  1143. }
  1144. function getCategoryIcon(category) {
  1145. const icons = {
  1146. entity: '🏢',
  1147. concept: '💡',
  1148. data: '📊',
  1149. location: '📍',
  1150. asset: '📑'
  1151. }
  1152. return icons[category] || '📌'
  1153. }
  1154. function getCategoryColor(category) {
  1155. const colors = {
  1156. entity: '#1890ff',
  1157. concept: '#722ed1',
  1158. data: '#52c41a',
  1159. location: '#faad14',
  1160. asset: '#eb2f96'
  1161. }
  1162. return colors[category] || '#8c8c8c'
  1163. }
  1164. function getCategoryLabel(category) {
  1165. const labels = {
  1166. entity: '核心实体',
  1167. concept: '概念/技术',
  1168. data: '数据/指标',
  1169. location: '地点/组织',
  1170. asset: '资源模板'
  1171. }
  1172. return labels[category] || '其他'
  1173. }
  1174. /**
  1175. * 根据实体类型获取图标
  1176. */
  1177. function getEntityTypeIcon(type) {
  1178. const icons = {
  1179. 'PERSON': '👤',
  1180. 'ORGANIZATION': '🏢',
  1181. 'ORG': '🏢',
  1182. 'LOCATION': '📍',
  1183. 'LOC': '📍',
  1184. 'DATE': '📅',
  1185. 'TIME': '⏰',
  1186. 'PERIOD': '📆',
  1187. 'MONEY': '💰',
  1188. 'PERCENT': '📊',
  1189. 'PRODUCT': '📦',
  1190. 'EVENT': '📋',
  1191. 'FACILITY': '🏭',
  1192. 'FAC': '🏭',
  1193. 'GPE': '🌍',
  1194. 'LAW': '⚖️',
  1195. 'WORK_OF_ART': '🎨',
  1196. 'LANGUAGE': '🗣️',
  1197. 'QUANTITY': '🔢',
  1198. 'ORDINAL': '🔢',
  1199. 'CARDINAL': '🔢',
  1200. 'ENTITY': '🏷️',
  1201. 'DOC_ID': '📄',
  1202. 'NORP': '👥',
  1203. 'TITLE': '🎖️',
  1204. 'STANDARD': '📋',
  1205. 'RATING': '⭐',
  1206. 'SCORE': '💯',
  1207. 'LEVEL': '📊'
  1208. }
  1209. return icons[type?.toUpperCase()] || '🏷️'
  1210. }
  1211. /**
  1212. * 根据实体类型获取样式类名
  1213. */
  1214. function getEntityTypeClass(type) {
  1215. const typeMap = {
  1216. 'PERSON': 'entity-person',
  1217. 'ORGANIZATION': 'entity-org',
  1218. 'LOCATION': 'entity-location',
  1219. 'DATE': 'entity-date',
  1220. 'TIME': 'entity-date',
  1221. 'MONEY': 'entity-data',
  1222. 'PERCENT': 'entity-data',
  1223. 'PRODUCT': 'entity-product',
  1224. 'EVENT': 'entity-event',
  1225. 'FACILITY': 'entity-org',
  1226. 'GPE': 'entity-location',
  1227. 'LAW': 'entity-law'
  1228. }
  1229. return typeMap[type?.toUpperCase()] || 'entity-default'
  1230. }
  1231. /**
  1232. * 滚动到文档中的指定实体
  1233. */
  1234. function scrollToEntity(entityId) {
  1235. const editorEl = document.querySelector('.editor-content')
  1236. if (!editorEl) return
  1237. const entitySpan = editorEl.querySelector(`[data-entity-id="${entityId}"]`)
  1238. if (entitySpan) {
  1239. entitySpan.scrollIntoView({ behavior: 'smooth', block: 'center' })
  1240. // 添加高亮闪烁效果
  1241. entitySpan.classList.add('entity-highlight-flash')
  1242. setTimeout(() => {
  1243. entitySpan.classList.remove('entity-highlight-flash')
  1244. }, 2000)
  1245. }
  1246. }
  1247. function editVariable(variable) {
  1248. editingVariable.value = variable
  1249. Object.assign(variableForm, variable)
  1250. showVariableDialog.value = true
  1251. }
  1252. async function saveVariable() {
  1253. if (!variableForm.name || !variableForm.displayName) {
  1254. ElMessage.warning('请填写必要字段')
  1255. return
  1256. }
  1257. try {
  1258. if (editingVariable.value) {
  1259. // 更新
  1260. const updated = await templateStore.updateVariable(editingVariable.value.id, variableForm)
  1261. Object.assign(editingVariable.value, updated)
  1262. ElMessage.success('更新成功')
  1263. } else {
  1264. // 新增
  1265. const newVar = await templateStore.addVariable(templateId, variableForm)
  1266. variables.value.push(newVar)
  1267. ElMessage.success('添加成功')
  1268. }
  1269. showVariableDialog.value = false
  1270. resetVariableForm()
  1271. } catch (error) {
  1272. ElMessage.error('保存失败: ' + error.message)
  1273. }
  1274. }
  1275. async function deleteVariable() {
  1276. if (editingVariable.value) {
  1277. try {
  1278. await templateStore.deleteVariable(editingVariable.value.id)
  1279. variables.value = variables.value.filter(v => v.id !== editingVariable.value.id)
  1280. showVariableDialog.value = false
  1281. resetVariableForm()
  1282. ElMessage.success('删除成功')
  1283. } catch (error) {
  1284. ElMessage.error('删除失败: ' + error.message)
  1285. }
  1286. }
  1287. }
  1288. function resetVariableForm() {
  1289. editingVariable.value = null
  1290. Object.assign(variableForm, {
  1291. name: '',
  1292. displayName: '',
  1293. category: 'entity',
  1294. exampleValue: '',
  1295. sourceType: 'document',
  1296. sourceFileAlias: '',
  1297. extractType: 'direct'
  1298. })
  1299. }
  1300. function handleTextSelection(event) {
  1301. const selection = window.getSelection()
  1302. const text = selection.toString().trim()
  1303. if (text) {
  1304. selectedText.value = text
  1305. selectionRange.value = selection.getRangeAt(0)
  1306. contextMenuPos.x = event.clientX
  1307. contextMenuPos.y = event.clientY
  1308. contextMenuVisible.value = true
  1309. } else {
  1310. contextMenuVisible.value = false
  1311. }
  1312. }
  1313. function markAsVariable(category) {
  1314. if (!selectedText.value) return
  1315. // 生成变量名
  1316. const varName = 'var_' + Date.now()
  1317. // 添加变量
  1318. variables.value.push({
  1319. id: Date.now().toString(),
  1320. name: varName,
  1321. displayName: selectedText.value.slice(0, 20),
  1322. category,
  1323. exampleValue: selectedText.value,
  1324. sourceType: 'document'
  1325. })
  1326. // 关闭菜单
  1327. contextMenuVisible.value = false
  1328. selectedText.value = ''
  1329. ElMessage.success('变量标记成功')
  1330. }
  1331. function handleFileUpload(response) {
  1332. if (response.code === 200) {
  1333. ElMessage.success('文件上传成功')
  1334. }
  1335. }
  1336. // 点击其他地方关闭右键菜单
  1337. function handleClickOutside(event) {
  1338. if (!event.target.closest('.context-menu')) {
  1339. contextMenuVisible.value = false
  1340. }
  1341. }
  1342. onMounted(() => {
  1343. document.addEventListener('click', handleClickOutside)
  1344. })
  1345. onUnmounted(() => {
  1346. document.removeEventListener('click', handleClickOutside)
  1347. })
  1348. </script>
  1349. <style lang="scss" scoped>
  1350. .editor-page {
  1351. height: calc(100vh - 56px);
  1352. display: flex;
  1353. flex-direction: column;
  1354. background: var(--bg);
  1355. }
  1356. .editor-toolbar {
  1357. height: 56px;
  1358. background: #fff;
  1359. border-bottom: 1px solid var(--border);
  1360. display: flex;
  1361. align-items: center;
  1362. padding: 0 16px;
  1363. gap: 12px;
  1364. flex-shrink: 0;
  1365. .title-section {
  1366. display: flex;
  1367. align-items: center;
  1368. gap: 8px;
  1369. flex: 1;
  1370. min-width: 0;
  1371. }
  1372. .title-input-wrapper {
  1373. position: relative;
  1374. display: inline-block;
  1375. min-width: 150px;
  1376. max-width: 500px;
  1377. .title-input {
  1378. width: 100%;
  1379. :deep(.el-input__wrapper) {
  1380. box-shadow: none;
  1381. background: transparent;
  1382. border-radius: 6px;
  1383. &:hover, &.is-focus {
  1384. background: var(--bg);
  1385. }
  1386. }
  1387. :deep(.el-input__inner) {
  1388. font-size: 16px;
  1389. font-weight: 600;
  1390. }
  1391. }
  1392. .title-measure {
  1393. position: absolute;
  1394. visibility: hidden;
  1395. white-space: nowrap;
  1396. font-size: 16px;
  1397. font-weight: 600;
  1398. padding: 0 11px;
  1399. pointer-events: none;
  1400. }
  1401. }
  1402. .save-status {
  1403. color: var(--success);
  1404. font-size: 12px;
  1405. white-space: nowrap;
  1406. opacity: 0.8;
  1407. }
  1408. .toolbar-right {
  1409. display: flex;
  1410. gap: 8px;
  1411. align-items: center;
  1412. flex-shrink: 0;
  1413. }
  1414. }
  1415. .editor-body {
  1416. flex: 1;
  1417. display: flex;
  1418. overflow: hidden;
  1419. }
  1420. .left-panel {
  1421. width: 260px;
  1422. background: #fff;
  1423. border-right: 1px solid var(--border);
  1424. display: flex;
  1425. flex-direction: column;
  1426. flex-shrink: 0;
  1427. .panel-header {
  1428. padding: 14px 16px;
  1429. border-bottom: 1px solid var(--border);
  1430. font-size: 13px;
  1431. font-weight: 600;
  1432. display: flex;
  1433. justify-content: space-between;
  1434. .file-count {
  1435. color: var(--text-3);
  1436. font-weight: normal;
  1437. }
  1438. }
  1439. .panel-body {
  1440. flex: 1;
  1441. overflow-y: auto;
  1442. padding: 12px;
  1443. }
  1444. }
  1445. .upload-zone {
  1446. border: 2px dashed var(--border);
  1447. border-radius: 10px;
  1448. margin-bottom: 16px;
  1449. :deep(.el-upload-dragger) {
  1450. padding: 20px;
  1451. border: none;
  1452. background: transparent;
  1453. }
  1454. .upload-content {
  1455. text-align: center;
  1456. }
  1457. .upload-icon {
  1458. font-size: 32px;
  1459. margin-bottom: 8px;
  1460. }
  1461. .upload-text {
  1462. font-size: 13px;
  1463. color: var(--text-2);
  1464. }
  1465. .upload-hint {
  1466. font-size: 11px;
  1467. color: var(--text-3);
  1468. }
  1469. }
  1470. .file-list {
  1471. margin-bottom: 16px;
  1472. }
  1473. .file-item {
  1474. display: flex;
  1475. align-items: center;
  1476. gap: 10px;
  1477. padding: 10px 12px;
  1478. background: #fff;
  1479. border: 1px solid var(--border);
  1480. border-radius: 8px;
  1481. margin-bottom: 8px;
  1482. cursor: pointer;
  1483. transition: all 0.2s;
  1484. &:hover, &.active {
  1485. border-color: var(--primary);
  1486. background: var(--primary-light);
  1487. }
  1488. .file-icon {
  1489. font-size: 24px;
  1490. }
  1491. .file-info {
  1492. flex: 1;
  1493. min-width: 0;
  1494. .file-name {
  1495. font-size: 12px;
  1496. font-weight: 500;
  1497. }
  1498. .file-meta {
  1499. font-size: 11px;
  1500. color: var(--text-3);
  1501. .required {
  1502. color: var(--danger);
  1503. }
  1504. }
  1505. }
  1506. }
  1507. .add-source-btn {
  1508. width: 100%;
  1509. }
  1510. .center-panel {
  1511. flex: 1;
  1512. display: flex;
  1513. flex-direction: column;
  1514. background: #fff;
  1515. overflow: hidden;
  1516. .editor-title-bar {
  1517. padding: 12px 24px;
  1518. border-bottom: 1px solid var(--border);
  1519. display: flex;
  1520. align-items: center;
  1521. justify-content: space-between;
  1522. gap: 12px;
  1523. background: var(--bg);
  1524. }
  1525. .editor-scroll {
  1526. flex: 1;
  1527. overflow-y: auto;
  1528. padding: 24px 32px;
  1529. }
  1530. .editor-content {
  1531. max-width: 800px;
  1532. margin: 0 auto;
  1533. outline: none;
  1534. :deep(h1) {
  1535. font-size: 24px;
  1536. font-weight: 700;
  1537. margin-bottom: 24px;
  1538. }
  1539. :deep(h2) {
  1540. font-size: 18px;
  1541. font-weight: 600;
  1542. margin: 28px 0 16px;
  1543. }
  1544. :deep(p) {
  1545. margin-bottom: 12px;
  1546. line-height: 1.6;
  1547. }
  1548. :deep(ul) {
  1549. margin-bottom: 16px;
  1550. padding-left: 24px;
  1551. li {
  1552. margin-bottom: 8px;
  1553. }
  1554. }
  1555. // 目录样式
  1556. :deep(.doc-toc-title) {
  1557. font-size: 18pt;
  1558. font-weight: bold;
  1559. text-align: center;
  1560. margin: 20px 0 16px;
  1561. }
  1562. :deep(.doc-toc-item) {
  1563. display: flex;
  1564. align-items: baseline;
  1565. padding: 6px 0;
  1566. line-height: 1.6;
  1567. cursor: pointer;
  1568. transition: background-color 0.2s;
  1569. &:hover {
  1570. background-color: #f5f5f5;
  1571. }
  1572. .toc-title {
  1573. flex-shrink: 0;
  1574. white-space: nowrap;
  1575. }
  1576. .toc-dots {
  1577. flex: 1;
  1578. border-bottom: 1px dotted #999;
  1579. margin: 0 8px;
  1580. min-width: 20px;
  1581. height: 0.6em;
  1582. }
  1583. .toc-page {
  1584. flex-shrink: 0;
  1585. color: #666;
  1586. min-width: 20px;
  1587. text-align: right;
  1588. }
  1589. }
  1590. // 表格样式
  1591. :deep(.doc-table-container) {
  1592. margin: 16px 0;
  1593. overflow-x: auto;
  1594. }
  1595. :deep(.doc-table) {
  1596. width: 100%;
  1597. border-collapse: collapse;
  1598. font-size: 14px;
  1599. th, td {
  1600. border: 1px solid #ddd;
  1601. padding: 8px 12px;
  1602. text-align: left;
  1603. vertical-align: top;
  1604. line-height: 1.5;
  1605. }
  1606. th {
  1607. background-color: #f5f5f5;
  1608. font-weight: bold;
  1609. }
  1610. tr:nth-child(even) td {
  1611. background-color: #fafafa;
  1612. }
  1613. tr:hover td {
  1614. background-color: #f0f7ff;
  1615. }
  1616. }
  1617. :deep(.doc-table-empty) {
  1618. padding: 20px;
  1619. text-align: center;
  1620. color: #999;
  1621. border: 1px dashed #ddd;
  1622. margin: 16px 0;
  1623. }
  1624. // 列表项样式
  1625. :deep(.doc-list-item) {
  1626. position: relative;
  1627. margin-bottom: 8px;
  1628. line-height: 1.6;
  1629. &.bullet {
  1630. padding-left: 1.5em;
  1631. &::before {
  1632. content: '•';
  1633. position: absolute;
  1634. left: 0;
  1635. }
  1636. }
  1637. &.ordered {
  1638. padding-left: 2em;
  1639. counter-increment: doc-list;
  1640. &::before {
  1641. content: counter(doc-list) '.';
  1642. position: absolute;
  1643. left: 0;
  1644. }
  1645. }
  1646. }
  1647. // 重置列表计数器
  1648. :deep(p + .doc-list-item.ordered:first-of-type),
  1649. :deep(.doc-list-item.bullet + .doc-list-item.ordered) {
  1650. counter-reset: doc-list;
  1651. }
  1652. // 块引用样式
  1653. :deep(blockquote) {
  1654. margin: 16px 0;
  1655. padding: 12px 20px;
  1656. border-left: 4px solid #ddd;
  1657. background: #f9f9f9;
  1658. color: #666;
  1659. }
  1660. // 代码块样式
  1661. :deep(pre) {
  1662. margin: 16px 0;
  1663. padding: 16px;
  1664. background: #f5f5f5;
  1665. border-radius: 4px;
  1666. overflow-x: auto;
  1667. code {
  1668. font-family: 'Consolas', 'Monaco', monospace;
  1669. font-size: 13px;
  1670. }
  1671. }
  1672. // 实体高亮样式 - 匹配原型 UI(边框 + 背景)
  1673. :deep(.entity-highlight) {
  1674. display: inline;
  1675. padding: 2px 8px;
  1676. border-radius: 4px;
  1677. cursor: pointer;
  1678. transition: all 0.2s;
  1679. font-weight: 500;
  1680. border: 1px solid #1890ff;
  1681. color: #1890ff;
  1682. background: rgba(24, 144, 255, 0.1);
  1683. &:hover {
  1684. background: #1890ff;
  1685. color: white;
  1686. }
  1687. // 实体类型颜色
  1688. &.entity {
  1689. border-color: #1890ff;
  1690. color: #1890ff;
  1691. background: rgba(24, 144, 255, 0.1);
  1692. &:hover { background: #1890ff; color: white; }
  1693. }
  1694. &.concept {
  1695. border-color: #722ed1;
  1696. color: #722ed1;
  1697. background: rgba(114, 46, 209, 0.1);
  1698. &:hover { background: #722ed1; color: white; }
  1699. }
  1700. &.data {
  1701. border-color: #52c41a;
  1702. color: #52c41a;
  1703. background: rgba(82, 196, 26, 0.1);
  1704. &:hover { background: #52c41a; color: white; }
  1705. }
  1706. &.location {
  1707. border-color: #faad14;
  1708. color: #d48806;
  1709. background: rgba(250, 173, 20, 0.1);
  1710. &:hover { background: #faad14; color: white; }
  1711. }
  1712. &.asset {
  1713. border-color: #eb2f96;
  1714. color: #eb2f96;
  1715. background: rgba(235, 47, 150, 0.1);
  1716. &:hover { background: #eb2f96; color: white; }
  1717. }
  1718. &.person {
  1719. border-color: #1890ff;
  1720. color: #1890ff;
  1721. background: rgba(24, 144, 255, 0.1);
  1722. &:hover { background: #1890ff; color: white; }
  1723. }
  1724. &.org {
  1725. border-color: #722ed1;
  1726. color: #722ed1;
  1727. background: rgba(114, 46, 209, 0.1);
  1728. &:hover { background: #722ed1; color: white; }
  1729. }
  1730. &.date {
  1731. border-color: #13c2c2;
  1732. color: #13c2c2;
  1733. background: rgba(19, 194, 194, 0.1);
  1734. &:hover { background: #13c2c2; color: white; }
  1735. }
  1736. &.product {
  1737. border-color: #eb2f96;
  1738. color: #eb2f96;
  1739. background: rgba(235, 47, 150, 0.1);
  1740. &:hover { background: #eb2f96; color: white; }
  1741. }
  1742. &.event {
  1743. border-color: #fa8c16;
  1744. color: #fa8c16;
  1745. background: rgba(250, 140, 22, 0.1);
  1746. &:hover { background: #fa8c16; color: white; }
  1747. }
  1748. &.law {
  1749. border-color: #2f54eb;
  1750. color: #2f54eb;
  1751. background: rgba(47, 84, 235, 0.1);
  1752. &:hover { background: #2f54eb; color: white; }
  1753. }
  1754. }
  1755. }
  1756. }
  1757. .right-panel {
  1758. width: 380px;
  1759. background: #fff;
  1760. border-left: 1px solid var(--border);
  1761. overflow-y: auto;
  1762. flex-shrink: 0;
  1763. }
  1764. .element-section {
  1765. border-bottom: 1px solid var(--border);
  1766. .element-header {
  1767. padding: 14px 16px;
  1768. display: flex;
  1769. align-items: center;
  1770. justify-content: space-between;
  1771. .element-title {
  1772. font-size: 13px;
  1773. font-weight: 600;
  1774. .element-count {
  1775. color: var(--text-3);
  1776. font-weight: normal;
  1777. }
  1778. }
  1779. }
  1780. .element-filter {
  1781. padding: 0 16px 12px;
  1782. .entity-search {
  1783. margin-bottom: 10px;
  1784. :deep(.el-input__wrapper) {
  1785. border-radius: 18px;
  1786. }
  1787. }
  1788. .entity-type-filter {
  1789. display: flex;
  1790. flex-wrap: wrap;
  1791. gap: 6px;
  1792. .filter-tag {
  1793. cursor: pointer;
  1794. transition: all 0.2s;
  1795. border-radius: 12px;
  1796. font-size: 11px;
  1797. &:hover {
  1798. border-color: var(--primary);
  1799. color: var(--primary);
  1800. }
  1801. &.active {
  1802. background: var(--primary);
  1803. color: white;
  1804. border-color: var(--primary);
  1805. }
  1806. &.clear {
  1807. background: transparent;
  1808. border-style: dashed;
  1809. color: var(--text-3);
  1810. &:hover {
  1811. border-color: var(--danger);
  1812. color: var(--danger);
  1813. }
  1814. }
  1815. }
  1816. }
  1817. }
  1818. .element-body {
  1819. padding: 0 16px 16px;
  1820. }
  1821. .element-tags-wrap {
  1822. display: flex;
  1823. flex-wrap: wrap;
  1824. gap: 8px;
  1825. max-height: 280px;
  1826. overflow-y: auto;
  1827. }
  1828. // 要素标签样式 - 匹配原型 UI
  1829. .var-tag {
  1830. display: inline-flex;
  1831. align-items: center;
  1832. gap: 6px;
  1833. padding: 6px 12px;
  1834. border-radius: 16px;
  1835. font-size: 12px;
  1836. cursor: grab;
  1837. transition: all 0.2s;
  1838. background: var(--bg);
  1839. border: 1px solid var(--border);
  1840. user-select: none;
  1841. &:hover {
  1842. border-color: var(--primary);
  1843. background: var(--primary-light);
  1844. transform: translateY(-1px);
  1845. }
  1846. &:active {
  1847. cursor: grabbing;
  1848. }
  1849. .tag-icon {
  1850. font-size: 12px;
  1851. }
  1852. .tag-name {
  1853. max-width: 120px;
  1854. overflow: hidden;
  1855. text-overflow: ellipsis;
  1856. white-space: nowrap;
  1857. font-weight: 500;
  1858. }
  1859. .tag-status {
  1860. color: #52c41a;
  1861. font-size: 10px;
  1862. }
  1863. // 实体类型样式 - 左边框颜色区分
  1864. &.entity-person, &.entity {
  1865. border-left: 3px solid #1890ff;
  1866. }
  1867. &.entity-org, &.concept {
  1868. border-left: 3px solid #722ed1;
  1869. }
  1870. &.entity-location, &.location {
  1871. border-left: 3px solid #faad14;
  1872. }
  1873. &.entity-date {
  1874. border-left: 3px solid #13c2c2;
  1875. }
  1876. &.entity-data, &.data {
  1877. border-left: 3px solid #52c41a;
  1878. }
  1879. &.entity-product, &.asset {
  1880. border-left: 3px solid #eb2f96;
  1881. }
  1882. &.entity-event {
  1883. border-left: 3px solid #fa8c16;
  1884. }
  1885. &.entity-law {
  1886. border-left: 3px solid #2f54eb;
  1887. }
  1888. &.entity-default {
  1889. border-left: 3px solid #8c8c8c;
  1890. }
  1891. }
  1892. .element-hint {
  1893. font-size: 12px;
  1894. color: var(--text-3);
  1895. text-align: center;
  1896. padding: 20px;
  1897. }
  1898. }
  1899. // 实体高亮闪烁效果
  1900. @keyframes entity-flash {
  1901. 0%, 100% { background-color: inherit; }
  1902. 50% { background-color: #ffe58f; }
  1903. }
  1904. .entity-highlight-flash {
  1905. animation: entity-flash 0.5s ease-in-out 3;
  1906. }
  1907. .category-section {
  1908. padding: 12px 16px;
  1909. border-bottom: 1px solid var(--border);
  1910. .category-header {
  1911. display: flex;
  1912. align-items: center;
  1913. gap: 8px;
  1914. font-size: 12px;
  1915. font-weight: 600;
  1916. margin-bottom: 10px;
  1917. .category-dot {
  1918. width: 10px;
  1919. height: 10px;
  1920. border-radius: 50%;
  1921. }
  1922. .category-count {
  1923. color: var(--text-3);
  1924. font-weight: normal;
  1925. background: var(--bg);
  1926. padding: 2px 8px;
  1927. border-radius: 10px;
  1928. }
  1929. }
  1930. .category-items {
  1931. .category-item {
  1932. display: flex;
  1933. justify-content: space-between;
  1934. padding: 8px 12px;
  1935. background: var(--bg);
  1936. border-radius: 6px;
  1937. margin-bottom: 6px;
  1938. cursor: pointer;
  1939. font-size: 12px;
  1940. transition: all 0.2s;
  1941. &:hover {
  1942. background: var(--primary-light);
  1943. }
  1944. .item-value {
  1945. color: var(--text-3);
  1946. }
  1947. }
  1948. }
  1949. }
  1950. .context-menu {
  1951. position: fixed;
  1952. min-width: 180px;
  1953. background: #fff;
  1954. border-radius: 10px;
  1955. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  1956. z-index: 3000;
  1957. overflow: hidden;
  1958. .context-menu-item {
  1959. display: flex;
  1960. align-items: center;
  1961. gap: 10px;
  1962. padding: 10px 14px;
  1963. font-size: 13px;
  1964. cursor: pointer;
  1965. transition: all 0.15s;
  1966. &:hover {
  1967. background: var(--primary-light);
  1968. color: var(--primary);
  1969. }
  1970. .icon {
  1971. font-size: 14px;
  1972. }
  1973. }
  1974. }
  1975. .graph-container {
  1976. height: 500px;
  1977. position: relative;
  1978. background: linear-gradient(135deg, #f8fafc 0%, #f0f4f8 100%);
  1979. border-radius: 8px;
  1980. .graph-legend {
  1981. position: absolute;
  1982. top: 16px;
  1983. left: 16px;
  1984. background: #fff;
  1985. border-radius: 8px;
  1986. padding: 12px 16px;
  1987. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  1988. .legend-title {
  1989. font-size: 12px;
  1990. font-weight: 600;
  1991. margin-bottom: 8px;
  1992. color: var(--text-2);
  1993. }
  1994. .legend-item {
  1995. display: flex;
  1996. align-items: center;
  1997. gap: 8px;
  1998. font-size: 11px;
  1999. color: var(--text-2);
  2000. margin-bottom: 4px;
  2001. }
  2002. .legend-dot {
  2003. width: 12px;
  2004. height: 12px;
  2005. border-radius: 50%;
  2006. &.entity { background: var(--primary); }
  2007. &.concept { background: #722ed1; }
  2008. &.data { background: var(--success); }
  2009. &.location { background: var(--warning); }
  2010. }
  2011. }
  2012. .graph-body {
  2013. height: 100%;
  2014. display: flex;
  2015. align-items: center;
  2016. justify-content: center;
  2017. .graph-placeholder {
  2018. text-align: center;
  2019. color: var(--text-3);
  2020. p {
  2021. margin-top: 12px;
  2022. }
  2023. }
  2024. }
  2025. }
  2026. // 空白编辑器占位提示样式
  2027. :deep(.empty-editor-placeholder) {
  2028. padding: 60px 40px;
  2029. text-align: center;
  2030. color: var(--text-2);
  2031. h2 {
  2032. font-size: 24px;
  2033. margin-bottom: 20px;
  2034. color: var(--text-1);
  2035. }
  2036. p {
  2037. font-size: 15px;
  2038. margin-bottom: 16px;
  2039. }
  2040. ul {
  2041. list-style: none;
  2042. padding: 0;
  2043. text-align: left;
  2044. max-width: 300px;
  2045. margin: 0 auto;
  2046. li {
  2047. padding: 8px 0;
  2048. padding-left: 24px;
  2049. position: relative;
  2050. font-size: 14px;
  2051. &::before {
  2052. content: '✓';
  2053. position: absolute;
  2054. left: 0;
  2055. color: var(--primary);
  2056. }
  2057. }
  2058. }
  2059. }
  2060. </style>