Editor.vue 55 KB

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