Editor.vue 55 KB

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