| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336 |
- <template>
- <div class="editor-page">
- <!-- 工具栏 -->
- <div class="editor-toolbar">
- <el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
- <el-divider direction="vertical" />
- <div class="title-section">
- <div class="title-input-wrapper" :style="{ width: titleInputWidth + 'px' }">
- <el-input
- v-model="reportTitle"
- class="title-input"
- placeholder="请输入报告标题"
- />
- <span class="title-measure" ref="titleMeasure">{{ reportTitle || '请输入报告标题' }}</span>
- </div>
- <span class="save-status" v-if="saved">✓ 已保存</span>
- </div>
- <div class="toolbar-right">
- <el-button
- :icon="Refresh"
- :loading="regenerating"
- @click="handleRegenerateBlocks"
- title="重新生成文档结构"
- >
- 重新生成
- </el-button>
- <el-button :icon="Clock">版本</el-button>
- <el-button :icon="Share">分享</el-button>
- <el-divider direction="vertical" />
- <el-button type="primary" :icon="Check" @click="handleSave">保存</el-button>
- </div>
- </div>
- <!-- 主体 -->
- <div class="editor-body">
- <!-- 左侧文件面板 -->
- <div class="left-panel">
- <div class="panel-header">
- <span>📁 来源文件</span>
- <span class="file-count">{{ sourceFiles.length }}个</span>
- </div>
- <div class="panel-body">
- <!-- 上传区 -->
- <el-upload
- class="upload-zone"
- drag
- action="/api/v1/parse/upload"
- :on-success="handleFileUpload"
- :show-file-list="false"
- >
- <div class="upload-content">
- <div class="upload-icon">📄</div>
- <div class="upload-text">拖拽或点击上传</div>
- <div class="upload-hint">支持 PDF / Word / Excel</div>
- </div>
- </el-upload>
- <!-- 来源文件列表 -->
- <div class="file-list">
- <div
- v-for="file in sourceFiles"
- :key="file.id"
- class="file-item"
- :class="{ active: selectedFile?.id === file.id }"
- @click="selectFile(file)"
- >
- <span class="file-icon">{{ getFileIcon(file) }}</span>
- <div class="file-info">
- <div class="file-name">{{ file.alias }}</div>
- <div class="file-meta">
- <span v-if="file.required" class="required">必需</span>
- <span v-else>可选</span>
- </div>
- </div>
- <el-button
- size="small"
- :icon="Delete"
- circle
- @click.stop="removeSourceFile(file)"
- />
- </div>
- </div>
- <!-- 添加来源文件定义 -->
- <el-button
- class="add-source-btn"
- :icon="Plus"
- @click="showAddSourceDialog = true"
- >
- 添加来源文件定义
- </el-button>
- </div>
- </div>
- <!-- 中间编辑区 -->
- <div class="center-panel">
- <div class="editor-title-bar">
- <div class="view-toggle">
- <el-radio-group v-model="viewMode" size="small">
- <el-radio-button label="edit">📝 编辑</el-radio-button>
- <el-radio-button label="preview">👁 预览</el-radio-button>
- </el-radio-group>
- </div>
- <el-button :icon="Share" circle @click="showGraphModal = true" title="知识图谱" />
- </div>
- <div class="editor-scroll" ref="editorRef">
- <div
- class="editor-content"
- contenteditable="true"
- @mouseup="handleTextSelection"
- v-html="documentContent"
- />
- </div>
- </div>
- <!-- 右侧要素面板 -->
- <div class="right-panel">
- <!-- 要素管理(展示文档中识别的实体) -->
- <div class="element-section">
- <div class="element-header">
- <span class="element-title">
- 🏷️ 要素管理
- <span class="element-count">({{ filteredEntities.length }}/{{ entities.length }})</span>
- </span>
- <el-button size="small" :icon="Plus" @click="showAddVariableDialog = true">
- 添加
- </el-button>
- </div>
- <!-- 搜索和筛选 -->
- <div class="element-filter" v-if="entities.length > 0">
- <el-input
- v-model="entitySearchKeyword"
- placeholder="搜索要素..."
- size="small"
- :prefix-icon="Search"
- clearable
- class="entity-search"
- />
- <div class="entity-type-filter">
- <el-tag
- v-for="(count, type) in entityTypeCounts"
- :key="type"
- :class="['filter-tag', { active: entityTypeFilter === type }]"
- size="small"
- @click="toggleEntityTypeFilter(type)"
- >
- {{ getEntityTypeIcon(type) }} {{ getEntityTypeName(type) }} ({{ count }})
- </el-tag>
- <el-tag
- v-if="entityTypeFilter"
- class="filter-tag clear"
- size="small"
- @click="entityTypeFilter = ''"
- >
- 清除筛选
- </el-tag>
- </div>
- </div>
- <div class="element-body">
- <div class="element-tags-wrap" v-if="filteredEntities.length > 0">
- <div
- v-for="entity in filteredEntities"
- :key="entity.id"
- class="var-tag"
- :class="getEntityTypeClass(entity.type)"
- @click="scrollToEntity(entity.id)"
- >
- <span class="tag-icon">{{ getEntityTypeIcon(entity.type) }}</span>
- <span class="tag-name">{{ entity.text }}</span>
- <span class="tag-status" v-if="entity.confirmed">✓</span>
- </div>
- </div>
- <div class="element-hint" v-if="entities.length === 0">
- 选中文本后右键标记为变量
- </div>
- <div class="element-hint" v-else-if="filteredEntities.length === 0">
- 没有匹配的要素
- </div>
- <div class="element-hint" v-else>
- 点击标签定位到文档位置
- </div>
- </div>
- </div>
- <!-- 按类别分组显示 -->
- <div class="category-section" v-for="(vars, category) in groupedVariables" :key="category">
- <div class="category-header">
- <span
- class="category-dot"
- :style="{ background: getCategoryColor(category) }"
- />
- <span>{{ getCategoryLabel(category) }}</span>
- <span class="category-count">{{ vars.length }}</span>
- </div>
- <div class="category-items">
- <div
- v-for="v in vars"
- :key="v.id"
- class="category-item"
- @click="editVariable(v)"
- >
- <span>{{ v.displayName }}</span>
- <span class="item-value">{{ v.exampleValue || '-' }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 右键菜单 -->
- <div
- v-show="contextMenuVisible"
- class="context-menu"
- :style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
- >
- <div class="context-menu-item" @click="markAsVariable('entity')">
- <span class="icon">🏢</span>
- <span>标记为核心实体</span>
- </div>
- <div class="context-menu-item" @click="markAsVariable('concept')">
- <span class="icon">💡</span>
- <span>标记为概念/技术</span>
- </div>
- <div class="context-menu-item" @click="markAsVariable('data')">
- <span class="icon">📊</span>
- <span>标记为数据/指标</span>
- </div>
- <div class="context-menu-item" @click="markAsVariable('location')">
- <span class="icon">📍</span>
- <span>标记为地点/组织</span>
- </div>
- <div class="context-menu-item" @click="markAsVariable('asset')">
- <span class="icon">📑</span>
- <span>标记为资源模板</span>
- </div>
- </div>
- <!-- 添加来源文件对话框 -->
- <el-dialog v-model="showAddSourceDialog" title="添加来源文件定义" width="400">
- <el-form :model="newSourceFile" label-width="80px">
- <el-form-item label="文件别名" required>
- <el-input v-model="newSourceFile.alias" placeholder="如:可研批复" />
- </el-form-item>
- <el-form-item label="描述">
- <el-input v-model="newSourceFile.description" placeholder="文件描述" />
- </el-form-item>
- <el-form-item label="是否必需">
- <el-switch v-model="newSourceFile.required" />
- </el-form-item>
- </el-form>
- <template #footer>
- <el-button @click="showAddSourceDialog = false">取消</el-button>
- <el-button type="primary" @click="addSourceFile">添加</el-button>
- </template>
- </el-dialog>
- <!-- 添加/编辑变量对话框 -->
- <el-dialog v-model="showVariableDialog" :title="editingVariable ? '编辑变量' : '添加变量'" width="500">
- <el-form :model="variableForm" label-width="100px">
- <el-form-item label="变量名" required>
- <el-input v-model="variableForm.name" placeholder="程序用名,如 project_name" />
- </el-form-item>
- <el-form-item label="显示名称" required>
- <el-input v-model="variableForm.displayName" placeholder="用户可见名称" />
- </el-form-item>
- <el-form-item label="类别">
- <el-select v-model="variableForm.category" style="width: 100%">
- <el-option label="核心实体" value="entity" />
- <el-option label="概念/技术" value="concept" />
- <el-option label="数据/指标" value="data" />
- <el-option label="地点/组织" value="location" />
- <el-option label="资源模板" value="asset" />
- </el-select>
- </el-form-item>
- <el-form-item label="示例值">
- <el-input v-model="variableForm.exampleValue" placeholder="文档中的原始值" />
- </el-form-item>
- <el-form-item label="来源类型">
- <el-select v-model="variableForm.sourceType" style="width: 100%">
- <el-option label="从来源文件提取" value="document" />
- <el-option label="手动输入" value="manual" />
- <el-option label="引用其他变量" value="reference" />
- <el-option label="固定值" value="fixed" />
- </el-select>
- </el-form-item>
- <el-form-item label="来源文件" v-if="variableForm.sourceType === 'document'">
- <el-select v-model="variableForm.sourceFileAlias" style="width: 100%">
- <el-option
- v-for="sf in sourceFiles"
- :key="sf.id"
- :label="sf.alias"
- :value="sf.alias"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="提取方式" v-if="variableForm.sourceType === 'document'">
- <el-select v-model="variableForm.extractType" style="width: 100%">
- <el-option label="直接提取" value="direct" />
- <el-option label="AI 字段提取" value="ai_extract" />
- <el-option label="AI 总结" value="ai_summarize" />
- </el-select>
- </el-form-item>
- </el-form>
- <template #footer>
- <el-button @click="showVariableDialog = false">取消</el-button>
- <el-button
- v-if="editingVariable"
- type="danger"
- @click="deleteVariable"
- >
- 删除
- </el-button>
- <el-button type="primary" @click="saveVariable">保存</el-button>
- </template>
- </el-dialog>
- <!-- 知识图谱弹窗 -->
- <el-dialog v-model="showGraphModal" title="🔗 知识图谱" width="900">
- <div class="graph-container">
- <div class="graph-legend">
- <div class="legend-title">图例</div>
- <div class="legend-item">
- <span class="legend-dot entity"></span>
- <span>核心实体</span>
- </div>
- <div class="legend-item">
- <span class="legend-dot concept"></span>
- <span>概念/技术</span>
- </div>
- <div class="legend-item">
- <span class="legend-dot data"></span>
- <span>数据/指标</span>
- </div>
- <div class="legend-item">
- <span class="legend-dot location"></span>
- <span>地点/组织</span>
- </div>
- </div>
- <div class="graph-body">
- <div class="graph-placeholder">
- <el-icon size="64" color="#ccc"><Connection /></el-icon>
- <p>知识图谱可视化(开发中)</p>
- </div>
- </div>
- </div>
- </el-dialog>
- </div>
- </template>
- <script setup>
- import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
- import { useRouter, useRoute } from 'vue-router'
- import {
- ArrowLeft, Clock, Share, Check, Plus, Delete, Connection, Refresh, Search
- } from '@element-plus/icons-vue'
- import { ElMessage } from 'element-plus'
- import { useTemplateStore } from '@/stores/template'
- import { documentApi } from '@/api'
- const router = useRouter()
- const route = useRoute()
- const templateStore = useTemplateStore()
- const templateId = route.params.templateId
- const reportTitle = ref('')
- const titleMeasure = ref(null)
- const titleInputWidth = ref(120)
- const viewMode = ref('edit')
- const saved = ref(true)
- const editorRef = ref(null)
- const loading = ref(false)
- const regenerating = ref(false)
- // 动态计算标题输入框宽度
- watch(reportTitle, () => {
- nextTick(() => {
- if (titleMeasure.value) {
- const measuredWidth = titleMeasure.value.offsetWidth + 30 // 额外边距
- titleInputWidth.value = Math.max(120, Math.min(400, measuredWidth))
- }
- })
- })
- // 来源文件(从 API 获取)
- const sourceFiles = ref([])
- const selectedFile = ref(null)
- const showAddSourceDialog = ref(false)
- const newSourceFile = reactive({
- alias: '',
- description: '',
- required: true
- })
- // 变量(从 API 获取)
- const variables = ref([])
- // 文档中的实体(从 blocks 的 elements 中提取)
- const entities = ref([])
- // 要素搜索和筛选
- const entitySearchKeyword = ref('')
- const entityTypeFilter = ref('')
- // 计算属性:按类型统计要素数量
- const entityTypeCounts = computed(() => {
- const counts = {}
- entities.value.forEach(entity => {
- const type = entity.type || 'default'
- counts[type] = (counts[type] || 0) + 1
- })
- return counts
- })
- // 计算属性:筛选后的要素列表
- const filteredEntities = computed(() => {
- let result = entities.value
-
- // 按类型筛选
- if (entityTypeFilter.value) {
- result = result.filter(e => e.type === entityTypeFilter.value)
- }
-
- // 按关键词搜索
- if (entitySearchKeyword.value) {
- const keyword = entitySearchKeyword.value.toLowerCase()
- result = result.filter(e =>
- e.text?.toLowerCase().includes(keyword) ||
- e.type?.toLowerCase().includes(keyword)
- )
- }
-
- return result
- })
- // 切换类型筛选
- function toggleEntityTypeFilter(type) {
- if (entityTypeFilter.value === type) {
- entityTypeFilter.value = ''
- } else {
- entityTypeFilter.value = type
- }
- }
- // 获取实体类型名称(支持后端返回的英文类型)
- function getEntityTypeName(type) {
- const typeNames = {
- // 中文类型
- 'entity': '实体',
- 'concept': '概念',
- 'data': '数据',
- 'location': '地点',
- 'asset': '资产',
- 'person': '人物',
- 'org': '组织',
- 'date': '日期',
- 'product': '产品',
- 'event': '事件',
- 'law': '法规',
- 'default': '其他',
- // 后端返回的英文类型
- 'DOC_ID': '文档编号',
- 'ORG': '组织机构',
- 'PERSON': '人物',
- 'LOCATION': '地点',
- 'LOC': '地点',
- 'DATE': '日期',
- 'TIME': '时间',
- 'MONEY': '金额',
- 'PERCENT': '百分比',
- 'PRODUCT': '产品',
- 'EVENT': '事件',
- 'LAW': '法规',
- 'WORK_OF_ART': '作品',
- 'LANGUAGE': '语言',
- 'NORP': '民族/宗教/政治团体',
- 'FAC': '设施',
- 'GPE': '地理政治实体',
- 'CARDINAL': '数量',
- 'ORDINAL': '序数',
- 'QUANTITY': '数量单位',
- 'TITLE': '职务/头衔',
- 'STANDARD': '标准规范',
- 'RATING': '评级',
- 'PERIOD': '时间段',
- 'SCORE': '评分',
- 'LEVEL': '等级'
- }
- return typeNames[type] || typeNames[type?.toUpperCase()] || type || '其他'
- }
- /**
- * 从结构化文档的 blocks 中提取所有实体
- */
- function extractEntitiesFromBlocks(blocks) {
- const entityList = []
- const entityMap = new Map() // 用于去重
-
- if (!blocks || !Array.isArray(blocks)) {
- return entityList
- }
-
- for (const block of blocks) {
- if (!block.elements || !Array.isArray(block.elements)) {
- continue
- }
-
- for (const element of block.elements) {
- if (element.type === 'entity' && element.entityId) {
- // 使用 entityId 去重
- if (!entityMap.has(element.entityId)) {
- entityMap.set(element.entityId, true)
- entityList.push({
- id: element.entityId,
- text: element.entityText || '',
- type: element.entityType || 'ENTITY',
- confirmed: element.confirmed || false
- })
- }
- }
- }
- }
-
- return entityList
- }
- // 加载模板数据
- onMounted(async () => {
- await fetchTemplateData()
- })
- async function fetchTemplateData() {
- loading.value = true
- try {
- await templateStore.fetchTemplateDetail(templateId)
-
- // 设置模板标题
- reportTitle.value = templateStore.currentTemplate?.name || '未命名模板'
-
- // 设置来源文件
- sourceFiles.value = templateStore.sourceFiles || []
-
- // 设置变量
- variables.value = templateStore.variables || []
-
- // 根据 baseDocumentId 获取文档结构化内容
- const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
- if (baseDocumentId) {
- try {
- const structuredDoc = await documentApi.getStructured(baseDocumentId)
- // 将结构化文档的 blocks 和 images 合并渲染
- if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
- documentContent.value = renderStructuredDocument(structuredDoc)
- // 提取文档中的实体
- entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
- } else {
- documentContent.value = emptyPlaceholder
- entities.value = []
- }
- } catch (docError) {
- console.warn('获取文档内容失败:', docError)
- documentContent.value = emptyPlaceholder
- entities.value = []
- }
- } else {
- documentContent.value = emptyPlaceholder
- entities.value = []
- }
- } catch (error) {
- console.error('加载模板失败:', error)
- ElMessage.error('加载模板失败')
- } finally {
- loading.value = false
- }
- }
- const showVariableDialog = ref(false)
- const showAddVariableDialog = ref(false)
- const editingVariable = ref(null)
- const variableForm = reactive({
- name: '',
- displayName: '',
- category: 'entity',
- exampleValue: '',
- sourceType: 'document',
- sourceFileAlias: '',
- extractType: 'direct'
- })
- // 右键菜单
- const contextMenuVisible = ref(false)
- const contextMenuPos = reactive({ x: 0, y: 0 })
- const selectedText = ref('')
- const selectionRange = ref(null)
- // 知识图谱
- const showGraphModal = ref(false)
- // 文档内容(从 API 获取或空白)
- const documentContent = ref('')
- // 空白模板时的占位提示
- const emptyPlaceholder = `
- <div class="empty-editor-placeholder">
- <h2>📝 开始编辑您的模板</h2>
- <p>这是一个空白模板。您可以:</p>
- <ul>
- <li>在左侧添加来源文件定义</li>
- <li>在右侧面板添加变量</li>
- <li>直接在此处编辑模板内容</li>
- <li>选中文本后右键将其标记为变量</li>
- </ul>
- </div>
- `
- /**
- * 渲染结构化文档(合并 blocks 和 images)
- * 根据 index 排序,将图片插入到正确的位置
- */
- function renderStructuredDocument(structuredDoc) {
- const blocks = structuredDoc.blocks || []
- const images = structuredDoc.images || []
- const tables = structuredDoc.tables || []
- const paragraphs = structuredDoc.paragraphs || []
-
- // 将所有元素合并
- const allElements = []
-
- // 从 blocks 中提取实体映射(按文本内容匹配)
- const entityMap = buildEntityMap(blocks)
-
- // 检查 paragraphs 是否有 runs(带格式信息)
- const hasParagraphsWithRuns = paragraphs.some(p => p.runs && p.runs.length > 0)
-
- if (hasParagraphsWithRuns) {
- // 使用 paragraphs 渲染(保留格式 + 合并实体高亮)
- paragraphs.forEach(para => {
- allElements.push({
- type: 'paragraph',
- index: para.index,
- html: renderParagraphWithRunsAndEntities(para, entityMap)
- })
- })
- } else if (blocks.length > 0) {
- // 回退到 blocks 渲染(带实体标记,但无格式)
- blocks.forEach(block => {
- if (block.type === 'page') return // 跳过根节点
- allElements.push({
- type: 'block',
- index: block.index,
- html: block.markedHtml || block.html || block.plainText || ''
- })
- })
- }
-
- // 添加图片(保持原始尺寸,不显示说明文字)
- images.forEach(img => {
- // 图片样式:保持原始尺寸,不强制居中
- const imgStyle = img.width && img.height
- ? `width:${img.width}px; height:${img.height}px;`
- : 'max-width: 100%; height: auto;'
-
- allElements.push({
- type: 'image',
- index: img.index,
- html: `<div class="doc-image" style="margin: 8px 0;">
- <img src="${img.url}" alt="${img.alt || '图片'}" style="${imgStyle}" />
- </div>`
- })
- })
-
- // 添加表格
- tables.forEach(table => {
- allElements.push({
- type: 'table',
- index: table.index,
- html: renderTable(table, entityMap)
- })
- })
-
- // 按 index 排序
- allElements.sort((a, b) => (a.index || 0) - (b.index || 0))
-
- // 合并 HTML
- return allElements.map(el => el.html).join('')
- }
- /**
- * 渲染表格
- */
- function renderTable(table, entityMap) {
- if (!table.rows || table.rows.length === 0) {
- return '<div class="doc-table-empty">空表格</div>'
- }
-
- let html = '<div class="doc-table-container"><table class="doc-table">'
-
- table.rows.forEach((row, rowIndex) => {
- html += '<tr>'
- row.forEach((cell, colIndex) => {
- const tag = rowIndex === 0 ? 'th' : 'td'
- const attrs = []
-
- if (cell.rowSpan && cell.rowSpan > 1) {
- attrs.push(`rowspan="${cell.rowSpan}"`)
- }
- if (cell.colSpan && cell.colSpan > 1) {
- attrs.push(`colspan="${cell.colSpan}"`)
- }
-
- // 单元格样式
- const styleAttrs = []
- if (cell.style) {
- if (cell.style.alignment) {
- const alignMap = { 'left': 'left', 'center': 'center', 'right': 'right', 'both': 'justify' }
- styleAttrs.push(`text-align:${alignMap[cell.style.alignment] || cell.style.alignment}`)
- }
- if (cell.style.backgroundColor) {
- styleAttrs.push(`background-color:#${cell.style.backgroundColor}`)
- }
- }
- if (styleAttrs.length > 0) {
- attrs.push(`style="${styleAttrs.join(';')}"`)
- }
-
- // 单元格内容(支持 runs 格式)
- let content = ''
- if (cell.runs && cell.runs.length > 0) {
- content = cell.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
- } else {
- content = highlightEntitiesInText(cell.text || '', entityMap)
- }
-
- html += `<${tag} ${attrs.join(' ')}>${content}</${tag}>`
- })
- html += '</tr>'
- })
-
- html += '</table></div>'
- return html
- }
- /**
- * 从 blocks 中构建实体映射
- * 返回 { entityText: { entityId, entityType, confirmed } }
- */
- function buildEntityMap(blocks) {
- const entityMap = new Map()
-
- blocks.forEach(block => {
- if (!block.elements) return
-
- block.elements.forEach(el => {
- if (el.type === 'entity' && el.entityText) {
- // 使用实体文本作为 key(可能有多个相同文本的实体)
- if (!entityMap.has(el.entityText)) {
- entityMap.set(el.entityText, [])
- }
- entityMap.get(el.entityText).push({
- entityId: el.entityId,
- entityType: el.entityType,
- confirmed: el.confirmed
- })
- }
- })
- })
-
- return entityMap
- }
- /**
- * 渲染带格式和实体高亮的段落
- */
- function renderParagraphWithRunsAndEntities(para, entityMap) {
- if (!para.runs || para.runs.length === 0) {
- // 没有 runs,使用纯文本
- const content = highlightEntitiesInText(para.content || '', entityMap)
- return wrapWithParagraphTag(content, para.type, para.style)
- }
-
- // 渲染每个 run,同时应用实体高亮
- const runsHtml = para.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
- return wrapWithParagraphTag(runsHtml, para.type, para.style)
- }
- /**
- * 渲染带格式的段落(使用 runs)- 保留兼容
- */
- function renderParagraphWithRuns(para) {
- if (!para.runs || para.runs.length === 0) {
- const content = escapeHtml(para.content || '').replace(/\n/g, '<br>')
- return wrapWithParagraphTag(content, para.type, para.style)
- }
-
- const runsHtml = para.runs.map(run => renderTextRun(run)).join('')
- return wrapWithParagraphTag(runsHtml, para.type, para.style)
- }
- /**
- * 渲染单个文本片段(Run)并高亮实体
- */
- function renderTextRunWithEntities(run, entityMap) {
- if (!run || !run.text) return ''
-
- // 先在文本中查找并高亮实体
- const highlightedText = highlightEntitiesInText(run.text, entityMap)
-
- // 如果文本被实体高亮处理过(包含 span 标签),需要特殊处理样式
- const hasEntityHighlight = highlightedText.includes('entity-highlight')
-
- // 构建样式
- const styles = buildRunStyles(run)
-
- // 如果没有样式,直接返回高亮后的文本
- if (styles.length === 0) {
- return highlightedText.replace(/\n/g, '<br>')
- }
-
- // 如果有实体高亮,需要用 span 包裹整体样式
- if (hasEntityHighlight) {
- return `<span style="${styles.join(';')}">${highlightedText.replace(/\n/g, '<br>')}</span>`
- }
-
- // 普通文本,处理换行并应用样式
- const text = escapeHtml(run.text).replace(/\n/g, '<br>')
-
- // 上下标特殊处理
- if (run.verticalAlign === 'superscript') {
- return `<sup style="${styles.join(';')}">${text}</sup>`
- } else if (run.verticalAlign === 'subscript') {
- return `<sub style="${styles.join(';')}">${text}</sub>`
- }
-
- return `<span style="${styles.join(';')}">${text}</span>`
- }
- /**
- * 在文本中查找并高亮实体
- */
- function highlightEntitiesInText(text, entityMap) {
- if (!text || !entityMap || entityMap.size === 0) {
- return escapeHtml(text || '')
- }
-
- // 按实体文本长度降序排序(优先匹配长的)
- const sortedEntities = Array.from(entityMap.keys()).sort((a, b) => b.length - a.length)
-
- let result = text
- const replacements = []
-
- // 找出所有需要替换的位置
- for (const entityText of sortedEntities) {
- const entities = entityMap.get(entityText)
- if (!entities || entities.length === 0) continue
-
- let searchStart = 0
- let entityIndex = 0
-
- while (true) {
- const pos = result.indexOf(entityText, searchStart)
- if (pos === -1) break
-
- // 获取对应的实体信息(循环使用)
- const entity = entities[entityIndex % entities.length]
-
- replacements.push({
- start: pos,
- end: pos + entityText.length,
- text: entityText,
- entity: entity
- })
-
- searchStart = pos + entityText.length
- entityIndex++
- }
- }
-
- // 按位置排序,从后往前替换(避免位置偏移)
- replacements.sort((a, b) => b.start - a.start)
-
- // 检查重叠,移除被包含的替换
- const finalReplacements = []
- for (const rep of replacements) {
- const hasOverlap = finalReplacements.some(
- existing => rep.start < existing.end && rep.end > existing.start
- )
- if (!hasOverlap) {
- finalReplacements.push(rep)
- }
- }
-
- // 执行替换
- for (const rep of finalReplacements) {
- const before = result.substring(0, rep.start)
- const after = result.substring(rep.end)
- const highlighted = renderEntityHighlight(rep.text, rep.entity)
- result = before + highlighted + after
- }
-
- // 对非实体部分进行 HTML 转义
- // 由于实体部分已经包含 HTML,需要分段处理
- return escapeNonEntityText(result)
- }
- /**
- * 转义非实体部分的文本
- */
- function escapeNonEntityText(text) {
- // 分割出实体标签和普通文本
- const parts = text.split(/(<span class="entity-highlight[^>]*>.*?<\/span>)/g)
-
- return parts.map(part => {
- if (part.startsWith('<span class="entity-highlight')) {
- return part // 保留实体标签
- }
- return escapeHtml(part) // 转义普通文本
- }).join('')
- }
- /**
- * 渲染实体高亮标签
- */
- function renderEntityHighlight(text, entity) {
- const cssClass = getEntityCssClass(entity.entityType)
- const confirmedMark = entity.confirmed ? ' ✓' : ''
-
- return `<span class="${cssClass}" ` +
- `data-entity-id="${entity.entityId || ''}" ` +
- `data-type="${entity.entityType || ''}" ` +
- `onclick="showEntityEditModal(event,'${entity.entityId || ''}')" ` +
- `contenteditable="false">${escapeHtml(text)}${confirmedMark}</span>`
- }
- /**
- * 获取实体类型对应的 CSS 类
- */
- function getEntityCssClass(entityType) {
- const typeMap = {
- 'PERSON': 'entity-highlight person',
- 'ORG': 'entity-highlight org',
- 'ORGANIZATION': 'entity-highlight org',
- 'LOC': 'entity-highlight location',
- 'LOCATION': 'entity-highlight location',
- 'GPE': 'entity-highlight location',
- 'DATE': 'entity-highlight date',
- 'TIME': 'entity-highlight date',
- 'MONEY': 'entity-highlight data',
- 'NUMBER': 'entity-highlight data',
- 'PERCENT': 'entity-highlight data',
- 'DATA': 'entity-highlight data',
- 'CONCEPT': 'entity-highlight concept',
- 'PRODUCT': 'entity-highlight product',
- 'EVENT': 'entity-highlight event'
- }
- return typeMap[entityType?.toUpperCase()] || 'entity-highlight entity'
- }
- /**
- * 构建 Run 的 CSS 样式数组
- */
- function buildRunStyles(run) {
- const styles = []
-
- if (run.fontFamily) {
- styles.push(`font-family:${run.fontFamily}`)
- }
- if (run.fontSize && run.fontSize > 0) {
- styles.push(`font-size:${run.fontSize}pt`)
- }
- if (run.color) {
- const color = run.color.startsWith('#') ? run.color : `#${run.color}`
- styles.push(`color:${color}`)
- }
- if (run.highlightColor) {
- const bgColor = getHighlightColor(run.highlightColor)
- styles.push(`background-color:${bgColor}`)
- }
- if (run.bold) {
- styles.push('font-weight:bold')
- }
- if (run.italic) {
- styles.push('font-style:italic')
- }
-
- const textDecorations = []
- if (run.underline && run.underline !== 'none') {
- const underlineStyle = run.underline === 'double' ? 'double' :
- run.underline === 'wave' || run.underline === 'wavy' ? 'wavy' :
- run.underline === 'dotted' ? 'dotted' :
- run.underline === 'dashed' ? 'dashed' : 'solid'
- textDecorations.push(`underline ${underlineStyle}`)
- }
- if (run.strikeThrough) {
- textDecorations.push('line-through')
- }
- if (textDecorations.length > 0) {
- styles.push(`text-decoration:${textDecorations.join(' ')}`)
- }
-
- return styles
- }
- /**
- * 渲染单个文本片段(Run)- 保留兼容
- */
- function renderTextRun(run) {
- if (!run || !run.text) return ''
-
- // 转义 HTML 并将换行符转换为 <br>
- let text = escapeHtml(run.text).replace(/\n/g, '<br>')
- const styles = buildRunStyles(run)
-
- // 上下标
- if (run.verticalAlign === 'superscript') {
- return styles.length > 0 ? `<sup style="${styles.join(';')}">${text}</sup>` : `<sup>${text}</sup>`
- } else if (run.verticalAlign === 'subscript') {
- return styles.length > 0 ? `<sub style="${styles.join(';')}">${text}</sub>` : `<sub>${text}</sub>`
- }
-
- // 如果没有样式,直接返回文本
- if (styles.length === 0) {
- return text
- }
-
- return `<span style="${styles.join(';')}">${text}</span>`
- }
- /**
- * 获取高亮颜色对应的 CSS 颜色
- */
- function getHighlightColor(colorName) {
- const colors = {
- 'yellow': '#ffff00',
- 'green': '#00ff00',
- 'cyan': '#00ffff',
- 'magenta': '#ff00ff',
- 'blue': '#0000ff',
- 'red': '#ff0000',
- 'darkblue': '#000080',
- 'darkcyan': '#008080',
- 'darkgreen': '#008000',
- 'darkmagenta': '#800080',
- 'darkred': '#800000',
- 'darkyellow': '#808000',
- 'darkgray': '#808080',
- 'lightgray': '#c0c0c0',
- 'black': '#000000'
- }
- return colors[colorName.toLowerCase()] || colorName
- }
- /**
- * 用段落标签包裹内容
- */
- function wrapWithParagraphTag(content, type, style) {
- // 段落样式
- const styleAttrs = []
- if (style) {
- // 对齐方式
- if (style.alignment) {
- const alignMap = {
- 'left': 'left',
- 'center': 'center',
- 'right': 'right',
- 'both': 'justify', // 两端对齐
- 'justify': 'justify'
- }
- styleAttrs.push(`text-align:${alignMap[style.alignment] || style.alignment}`)
- }
-
- // 左缩进(twips -> pt,1 twip = 1/20 pt)
- if (style.indentLeft) {
- styleAttrs.push(`padding-left:${style.indentLeft / 20}pt`)
- }
-
- // 右缩进
- if (style.indentRight) {
- styleAttrs.push(`padding-right:${style.indentRight / 20}pt`)
- }
-
- // 首行缩进
- if (style.indentFirstLine) {
- styleAttrs.push(`text-indent:${style.indentFirstLine / 20}pt`)
- }
-
- // 悬挂缩进(负的首行缩进 + 增加左边距)
- if (style.indentHanging) {
- const hangingPt = style.indentHanging / 20
- styleAttrs.push(`text-indent:-${hangingPt}pt`)
- // 如果没有左缩进,需要增加左边距来补偿
- if (!style.indentLeft) {
- styleAttrs.push(`margin-left:${hangingPt}pt`)
- }
- }
-
- // 段前间距
- if (style.spacingBefore) {
- styleAttrs.push(`margin-top:${style.spacingBefore / 20}pt`)
- }
-
- // 段后间距
- if (style.spacingAfter) {
- styleAttrs.push(`margin-bottom:${style.spacingAfter / 20}pt`)
- }
-
- // 行距处理
- if (style.lineSpacing) {
- // getSpacingBetween 返回的是倍数(如 1.0, 1.5, 2.0)
- if (style.lineSpacing >= 1 && style.lineSpacing <= 5) {
- styleAttrs.push(`line-height:${style.lineSpacing}`)
- }
- } else if (style.lineSpacingValue && style.lineSpacingRule) {
- // 精确行距值处理
- const rule = style.lineSpacingRule.toLowerCase()
- if (rule === 'exact') {
- // 固定行距(twips -> pt)
- styleAttrs.push(`line-height:${style.lineSpacingValue / 20}pt`)
- } else if (rule === 'atleast' || rule === 'at_least') {
- // 最小行距
- styleAttrs.push(`min-height:${style.lineSpacingValue / 20}pt`)
- } else if (rule === 'auto') {
- // 倍数行距(240 twips = 1 倍行距)
- styleAttrs.push(`line-height:${style.lineSpacingValue / 240}`)
- }
- }
-
- // 字体信息(段落级别的默认字体)
- if (style.fontFamily) {
- styleAttrs.push(`font-family:${style.fontFamily}`)
- }
- if (style.fontSize) {
- styleAttrs.push(`font-size:${style.fontSize}pt`)
- }
- }
-
- const styleAttr = styleAttrs.length > 0 ? ` style="${styleAttrs.join(';')}"` : ''
-
- // 目录项特殊处理
- if (type === 'toc_item') {
- const pageNum = style?.tocPageNum || ''
- // 计算缩进级别(根据章节号判断)
- let level = 0
- const levelMatch = content.match(/^(\d+(?:\.\d+)*)/)
- if (levelMatch) {
- level = (levelMatch[1].match(/\./g) || []).length
- }
- const indentStyle = level > 0 ? ` style="padding-left:${level * 20}px"` : ''
- 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>`
- }
-
- switch (type) {
- case 'heading1':
- return `<h1${styleAttr}>${content}</h1>`
- case 'heading2':
- return `<h2${styleAttr}>${content}</h2>`
- case 'heading3':
- return `<h3${styleAttr}>${content}</h3>`
- case 'heading':
- return `<h2${styleAttr}>${content}</h2>`
- case 'toc':
- return `<div class="doc-toc-title"${styleAttr}>${content}</div>`
- case 'bullet':
- case 'list_item':
- return `<div class="doc-list-item bullet"${styleAttr}>${content}</div>`
- case 'ordered':
- return `<div class="doc-list-item ordered"${styleAttr}>${content}</div>`
- case 'quote':
- return `<blockquote${styleAttr}>${content}</blockquote>`
- case 'code':
- return `<pre><code>${content}</code></pre>`
- case 'title':
- return `<h1 class="doc-title"${styleAttr}>${content}</h1>`
- default:
- return `<p${styleAttr}>${content}</p>`
- }
- }
- /**
- * HTML 转义
- */
- function escapeHtml(text) {
- if (!text) return ''
- return text
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- }
- // 计算属性
- const groupedVariables = computed(() => {
- const groups = {}
- variables.value.forEach(v => {
- const cat = v.category || 'other'
- if (!groups[cat]) groups[cat] = []
- groups[cat].push(v)
- })
- return groups
- })
- // 方法
- function goBack() {
- router.back()
- }
- function handleSave() {
- saved.value = true
- ElMessage.success('保存成功')
- }
- // 重新生成文档块结构
- async function handleRegenerateBlocks() {
- const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
- if (!baseDocumentId) {
- ElMessage.warning('没有关联的示例文档')
- return
- }
- regenerating.value = true
- try {
- const result = await documentApi.regenerateBlocks(baseDocumentId)
- ElMessage.success(`重新生成成功: ${result.blockCount} 个文档块, ${result.entityCount} 个实体`)
-
- // 重新加载文档内容
- const structuredDoc = await documentApi.getStructured(baseDocumentId)
- if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
- documentContent.value = renderStructuredDocument(structuredDoc)
- // 重新提取实体
- entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
- }
- } catch (error) {
- console.error('重新生成失败:', error)
- ElMessage.error('重新生成失败: ' + (error.message || '未知错误'))
- } finally {
- regenerating.value = false
- }
- }
- function getFileIcon(file) {
- return '📄'
- }
- function selectFile(file) {
- selectedFile.value = file
- }
- async function removeSourceFile(file) {
- try {
- await templateStore.deleteSourceFile(file.id)
- sourceFiles.value = sourceFiles.value.filter(f => f.id !== file.id)
- ElMessage.success('删除成功')
- } catch (error) {
- ElMessage.error('删除失败: ' + error.message)
- }
- }
- async function addSourceFile() {
- if (!newSourceFile.alias) {
- ElMessage.warning('请输入文件别名')
- return
- }
- try {
- const sf = await templateStore.addSourceFile(templateId, newSourceFile)
- sourceFiles.value.push(sf)
- showAddSourceDialog.value = false
- Object.assign(newSourceFile, { alias: '', description: '', required: true })
- ElMessage.success('添加成功')
- } catch (error) {
- ElMessage.error('添加失败: ' + error.message)
- }
- }
- function getCategoryIcon(category) {
- const icons = {
- entity: '🏢',
- concept: '💡',
- data: '📊',
- location: '📍',
- asset: '📑'
- }
- return icons[category] || '📌'
- }
- function getCategoryColor(category) {
- const colors = {
- entity: '#1890ff',
- concept: '#722ed1',
- data: '#52c41a',
- location: '#faad14',
- asset: '#eb2f96'
- }
- return colors[category] || '#8c8c8c'
- }
- function getCategoryLabel(category) {
- const labels = {
- entity: '核心实体',
- concept: '概念/技术',
- data: '数据/指标',
- location: '地点/组织',
- asset: '资源模板'
- }
- return labels[category] || '其他'
- }
- /**
- * 根据实体类型获取图标
- */
- function getEntityTypeIcon(type) {
- const icons = {
- 'PERSON': '👤',
- 'ORGANIZATION': '🏢',
- 'ORG': '🏢',
- 'LOCATION': '📍',
- 'LOC': '📍',
- 'DATE': '📅',
- 'TIME': '⏰',
- 'PERIOD': '📆',
- 'MONEY': '💰',
- 'PERCENT': '📊',
- 'PRODUCT': '📦',
- 'EVENT': '📋',
- 'FACILITY': '🏭',
- 'FAC': '🏭',
- 'GPE': '🌍',
- 'LAW': '⚖️',
- 'WORK_OF_ART': '🎨',
- 'LANGUAGE': '🗣️',
- 'QUANTITY': '🔢',
- 'ORDINAL': '🔢',
- 'CARDINAL': '🔢',
- 'ENTITY': '🏷️',
- 'DOC_ID': '📄',
- 'NORP': '👥',
- 'TITLE': '🎖️',
- 'STANDARD': '📋',
- 'RATING': '⭐',
- 'SCORE': '💯',
- 'LEVEL': '📊'
- }
- return icons[type?.toUpperCase()] || '🏷️'
- }
- /**
- * 根据实体类型获取样式类名
- */
- function getEntityTypeClass(type) {
- const typeMap = {
- 'PERSON': 'entity-person',
- 'ORGANIZATION': 'entity-org',
- 'LOCATION': 'entity-location',
- 'DATE': 'entity-date',
- 'TIME': 'entity-date',
- 'MONEY': 'entity-data',
- 'PERCENT': 'entity-data',
- 'PRODUCT': 'entity-product',
- 'EVENT': 'entity-event',
- 'FACILITY': 'entity-org',
- 'GPE': 'entity-location',
- 'LAW': 'entity-law'
- }
- return typeMap[type?.toUpperCase()] || 'entity-default'
- }
- /**
- * 滚动到文档中的指定实体
- */
- function scrollToEntity(entityId) {
- const editorEl = document.querySelector('.editor-content')
- if (!editorEl) return
-
- const entitySpan = editorEl.querySelector(`[data-entity-id="${entityId}"]`)
- if (entitySpan) {
- entitySpan.scrollIntoView({ behavior: 'smooth', block: 'center' })
- // 添加高亮闪烁效果
- entitySpan.classList.add('entity-highlight-flash')
- setTimeout(() => {
- entitySpan.classList.remove('entity-highlight-flash')
- }, 2000)
- }
- }
- function editVariable(variable) {
- editingVariable.value = variable
- Object.assign(variableForm, variable)
- showVariableDialog.value = true
- }
- async function saveVariable() {
- if (!variableForm.name || !variableForm.displayName) {
- ElMessage.warning('请填写必要字段')
- return
- }
- try {
- if (editingVariable.value) {
- // 更新
- const updated = await templateStore.updateVariable(editingVariable.value.id, variableForm)
- Object.assign(editingVariable.value, updated)
- ElMessage.success('更新成功')
- } else {
- // 新增
- const newVar = await templateStore.addVariable(templateId, variableForm)
- variables.value.push(newVar)
- ElMessage.success('添加成功')
- }
- showVariableDialog.value = false
- resetVariableForm()
- } catch (error) {
- ElMessage.error('保存失败: ' + error.message)
- }
- }
- async function deleteVariable() {
- if (editingVariable.value) {
- try {
- await templateStore.deleteVariable(editingVariable.value.id)
- variables.value = variables.value.filter(v => v.id !== editingVariable.value.id)
- showVariableDialog.value = false
- resetVariableForm()
- ElMessage.success('删除成功')
- } catch (error) {
- ElMessage.error('删除失败: ' + error.message)
- }
- }
- }
- function resetVariableForm() {
- editingVariable.value = null
- Object.assign(variableForm, {
- name: '',
- displayName: '',
- category: 'entity',
- exampleValue: '',
- sourceType: 'document',
- sourceFileAlias: '',
- extractType: 'direct'
- })
- }
- function handleTextSelection(event) {
- const selection = window.getSelection()
- const text = selection.toString().trim()
- if (text) {
- selectedText.value = text
- selectionRange.value = selection.getRangeAt(0)
- contextMenuPos.x = event.clientX
- contextMenuPos.y = event.clientY
- contextMenuVisible.value = true
- } else {
- contextMenuVisible.value = false
- }
- }
- function markAsVariable(category) {
- if (!selectedText.value) return
- // 生成变量名
- const varName = 'var_' + Date.now()
- // 添加变量
- variables.value.push({
- id: Date.now().toString(),
- name: varName,
- displayName: selectedText.value.slice(0, 20),
- category,
- exampleValue: selectedText.value,
- sourceType: 'document'
- })
- // 关闭菜单
- contextMenuVisible.value = false
- selectedText.value = ''
- ElMessage.success('变量标记成功')
- }
- function handleFileUpload(response) {
- if (response.code === 200) {
- ElMessage.success('文件上传成功')
- }
- }
- // 点击其他地方关闭右键菜单
- function handleClickOutside(event) {
- if (!event.target.closest('.context-menu')) {
- contextMenuVisible.value = false
- }
- }
- onMounted(() => {
- document.addEventListener('click', handleClickOutside)
- })
- onUnmounted(() => {
- document.removeEventListener('click', handleClickOutside)
- })
- </script>
- <style lang="scss" scoped>
- .editor-page {
- height: calc(100vh - 56px);
- display: flex;
- flex-direction: column;
- background: var(--bg);
- }
- .editor-toolbar {
- height: 56px;
- background: #fff;
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- padding: 0 16px;
- gap: 12px;
- flex-shrink: 0;
-
- .title-section {
- display: flex;
- align-items: center;
- gap: 8px;
- flex: 1;
- min-width: 0;
- }
- .title-input-wrapper {
- position: relative;
- display: inline-block;
- min-width: 150px;
- max-width: 500px;
-
- .title-input {
- width: 100%;
- :deep(.el-input__wrapper) {
- box-shadow: none;
- background: transparent;
- border-radius: 6px;
- &:hover, &.is-focus {
- background: var(--bg);
- }
- }
-
- :deep(.el-input__inner) {
- font-size: 16px;
- font-weight: 600;
- }
- }
-
- .title-measure {
- position: absolute;
- visibility: hidden;
- white-space: nowrap;
- font-size: 16px;
- font-weight: 600;
- padding: 0 11px;
- pointer-events: none;
- }
- }
- .save-status {
- color: var(--success);
- font-size: 12px;
- white-space: nowrap;
- opacity: 0.8;
- }
- .toolbar-right {
- display: flex;
- gap: 8px;
- align-items: center;
- flex-shrink: 0;
- }
- }
- .editor-body {
- flex: 1;
- display: flex;
- overflow: hidden;
- }
- .left-panel {
- width: 260px;
- background: #fff;
- border-right: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- flex-shrink: 0;
- .panel-header {
- padding: 14px 16px;
- border-bottom: 1px solid var(--border);
- font-size: 13px;
- font-weight: 600;
- display: flex;
- justify-content: space-between;
- .file-count {
- color: var(--text-3);
- font-weight: normal;
- }
- }
- .panel-body {
- flex: 1;
- overflow-y: auto;
- padding: 12px;
- }
- }
- .upload-zone {
- border: 2px dashed var(--border);
- border-radius: 10px;
- margin-bottom: 16px;
- :deep(.el-upload-dragger) {
- padding: 20px;
- border: none;
- background: transparent;
- }
- .upload-content {
- text-align: center;
- }
- .upload-icon {
- font-size: 32px;
- margin-bottom: 8px;
- }
- .upload-text {
- font-size: 13px;
- color: var(--text-2);
- }
- .upload-hint {
- font-size: 11px;
- color: var(--text-3);
- }
- }
- .file-list {
- margin-bottom: 16px;
- }
- .file-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 12px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 8px;
- margin-bottom: 8px;
- cursor: pointer;
- transition: all 0.2s;
- &:hover, &.active {
- border-color: var(--primary);
- background: var(--primary-light);
- }
- .file-icon {
- font-size: 24px;
- }
- .file-info {
- flex: 1;
- min-width: 0;
- .file-name {
- font-size: 12px;
- font-weight: 500;
- }
- .file-meta {
- font-size: 11px;
- color: var(--text-3);
- .required {
- color: var(--danger);
- }
- }
- }
- }
- .add-source-btn {
- width: 100%;
- }
- .center-panel {
- flex: 1;
- display: flex;
- flex-direction: column;
- background: #fff;
- overflow: hidden;
- .editor-title-bar {
- padding: 12px 24px;
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- background: var(--bg);
- }
- .editor-scroll {
- flex: 1;
- overflow-y: auto;
- padding: 24px 32px;
- }
- .editor-content {
- max-width: 800px;
- margin: 0 auto;
- outline: none;
- :deep(h1) {
- font-size: 24px;
- font-weight: 700;
- margin-bottom: 24px;
- }
- :deep(h2) {
- font-size: 18px;
- font-weight: 600;
- margin: 28px 0 16px;
- }
- :deep(p) {
- margin-bottom: 12px;
- line-height: 1.6;
- }
- :deep(ul) {
- margin-bottom: 16px;
- padding-left: 24px;
- li {
- margin-bottom: 8px;
- }
- }
-
- // 目录样式
- :deep(.doc-toc-title) {
- font-size: 18pt;
- font-weight: bold;
- text-align: center;
- margin: 20px 0 16px;
- }
-
- :deep(.doc-toc-item) {
- display: flex;
- align-items: baseline;
- padding: 6px 0;
- line-height: 1.6;
- cursor: pointer;
- transition: background-color 0.2s;
-
- &:hover {
- background-color: #f5f5f5;
- }
-
- .toc-title {
- flex-shrink: 0;
- white-space: nowrap;
- }
-
- .toc-dots {
- flex: 1;
- border-bottom: 1px dotted #999;
- margin: 0 8px;
- min-width: 20px;
- height: 0.6em;
- }
-
- .toc-page {
- flex-shrink: 0;
- color: #666;
- min-width: 20px;
- text-align: right;
- }
- }
-
- // 表格样式
- :deep(.doc-table-container) {
- margin: 16px 0;
- overflow-x: auto;
- }
-
- :deep(.doc-table) {
- width: 100%;
- border-collapse: collapse;
- font-size: 14px;
-
- th, td {
- border: 1px solid #ddd;
- padding: 8px 12px;
- text-align: left;
- vertical-align: top;
- line-height: 1.5;
- }
-
- th {
- background-color: #f5f5f5;
- font-weight: bold;
- }
-
- tr:nth-child(even) td {
- background-color: #fafafa;
- }
-
- tr:hover td {
- background-color: #f0f7ff;
- }
- }
-
- :deep(.doc-table-empty) {
- padding: 20px;
- text-align: center;
- color: #999;
- border: 1px dashed #ddd;
- margin: 16px 0;
- }
-
- // 列表项样式
- :deep(.doc-list-item) {
- position: relative;
- margin-bottom: 8px;
- line-height: 1.6;
-
- &.bullet {
- padding-left: 1.5em;
- &::before {
- content: '•';
- position: absolute;
- left: 0;
- }
- }
-
- &.ordered {
- padding-left: 2em;
- counter-increment: doc-list;
- &::before {
- content: counter(doc-list) '.';
- position: absolute;
- left: 0;
- }
- }
- }
-
- // 重置列表计数器
- :deep(p + .doc-list-item.ordered:first-of-type),
- :deep(.doc-list-item.bullet + .doc-list-item.ordered) {
- counter-reset: doc-list;
- }
-
- // 块引用样式
- :deep(blockquote) {
- margin: 16px 0;
- padding: 12px 20px;
- border-left: 4px solid #ddd;
- background: #f9f9f9;
- color: #666;
- }
-
- // 代码块样式
- :deep(pre) {
- margin: 16px 0;
- padding: 16px;
- background: #f5f5f5;
- border-radius: 4px;
- overflow-x: auto;
-
- code {
- font-family: 'Consolas', 'Monaco', monospace;
- font-size: 13px;
- }
- }
-
- // 实体高亮样式 - 匹配原型 UI(边框 + 背景)
- :deep(.entity-highlight) {
- display: inline;
- padding: 2px 8px;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.2s;
- font-weight: 500;
- border: 1px solid #1890ff;
- color: #1890ff;
- background: rgba(24, 144, 255, 0.1);
-
- &:hover {
- background: #1890ff;
- color: white;
- }
-
- // 实体类型颜色
- &.entity {
- border-color: #1890ff;
- color: #1890ff;
- background: rgba(24, 144, 255, 0.1);
- &:hover { background: #1890ff; color: white; }
- }
-
- &.concept {
- border-color: #722ed1;
- color: #722ed1;
- background: rgba(114, 46, 209, 0.1);
- &:hover { background: #722ed1; color: white; }
- }
-
- &.data {
- border-color: #52c41a;
- color: #52c41a;
- background: rgba(82, 196, 26, 0.1);
- &:hover { background: #52c41a; color: white; }
- }
-
- &.location {
- border-color: #faad14;
- color: #d48806;
- background: rgba(250, 173, 20, 0.1);
- &:hover { background: #faad14; color: white; }
- }
-
- &.asset {
- border-color: #eb2f96;
- color: #eb2f96;
- background: rgba(235, 47, 150, 0.1);
- &:hover { background: #eb2f96; color: white; }
- }
-
- &.person {
- border-color: #1890ff;
- color: #1890ff;
- background: rgba(24, 144, 255, 0.1);
- &:hover { background: #1890ff; color: white; }
- }
-
- &.org {
- border-color: #722ed1;
- color: #722ed1;
- background: rgba(114, 46, 209, 0.1);
- &:hover { background: #722ed1; color: white; }
- }
-
- &.date {
- border-color: #13c2c2;
- color: #13c2c2;
- background: rgba(19, 194, 194, 0.1);
- &:hover { background: #13c2c2; color: white; }
- }
-
- &.product {
- border-color: #eb2f96;
- color: #eb2f96;
- background: rgba(235, 47, 150, 0.1);
- &:hover { background: #eb2f96; color: white; }
- }
-
- &.event {
- border-color: #fa8c16;
- color: #fa8c16;
- background: rgba(250, 140, 22, 0.1);
- &:hover { background: #fa8c16; color: white; }
- }
-
- &.law {
- border-color: #2f54eb;
- color: #2f54eb;
- background: rgba(47, 84, 235, 0.1);
- &:hover { background: #2f54eb; color: white; }
- }
- }
- }
- }
- .right-panel {
- width: 380px;
- background: #fff;
- border-left: 1px solid var(--border);
- overflow-y: auto;
- flex-shrink: 0;
- }
- .element-section {
- border-bottom: 1px solid var(--border);
- .element-header {
- padding: 14px 16px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- .element-title {
- font-size: 13px;
- font-weight: 600;
- .element-count {
- color: var(--text-3);
- font-weight: normal;
- }
- }
- }
- .element-filter {
- padding: 0 16px 12px;
-
- .entity-search {
- margin-bottom: 10px;
-
- :deep(.el-input__wrapper) {
- border-radius: 18px;
- }
- }
-
- .entity-type-filter {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
-
- .filter-tag {
- cursor: pointer;
- transition: all 0.2s;
- border-radius: 12px;
- font-size: 11px;
-
- &:hover {
- border-color: var(--primary);
- color: var(--primary);
- }
-
- &.active {
- background: var(--primary);
- color: white;
- border-color: var(--primary);
- }
-
- &.clear {
- background: transparent;
- border-style: dashed;
- color: var(--text-3);
-
- &:hover {
- border-color: var(--danger);
- color: var(--danger);
- }
- }
- }
- }
- }
- .element-body {
- padding: 0 16px 16px;
- }
- .element-tags-wrap {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- max-height: 280px;
- overflow-y: auto;
- }
-
- // 要素标签样式 - 匹配原型 UI
- .var-tag {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 6px 12px;
- border-radius: 16px;
- font-size: 12px;
- cursor: grab;
- transition: all 0.2s;
- background: var(--bg);
- border: 1px solid var(--border);
- user-select: none;
-
- &:hover {
- border-color: var(--primary);
- background: var(--primary-light);
- transform: translateY(-1px);
- }
-
- &:active {
- cursor: grabbing;
- }
-
- .tag-icon {
- font-size: 12px;
- }
-
- .tag-name {
- max-width: 120px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-weight: 500;
- }
-
- .tag-status {
- color: #52c41a;
- font-size: 10px;
- }
-
- // 实体类型样式 - 左边框颜色区分
- &.entity-person, &.entity {
- border-left: 3px solid #1890ff;
- }
- &.entity-org, &.concept {
- border-left: 3px solid #722ed1;
- }
- &.entity-location, &.location {
- border-left: 3px solid #faad14;
- }
- &.entity-date {
- border-left: 3px solid #13c2c2;
- }
- &.entity-data, &.data {
- border-left: 3px solid #52c41a;
- }
- &.entity-product, &.asset {
- border-left: 3px solid #eb2f96;
- }
- &.entity-event {
- border-left: 3px solid #fa8c16;
- }
- &.entity-law {
- border-left: 3px solid #2f54eb;
- }
- &.entity-default {
- border-left: 3px solid #8c8c8c;
- }
- }
- .element-hint {
- font-size: 12px;
- color: var(--text-3);
- text-align: center;
- padding: 20px;
- }
- }
- // 实体高亮闪烁效果
- @keyframes entity-flash {
- 0%, 100% { background-color: inherit; }
- 50% { background-color: #ffe58f; }
- }
- .entity-highlight-flash {
- animation: entity-flash 0.5s ease-in-out 3;
- }
- .category-section {
- padding: 12px 16px;
- border-bottom: 1px solid var(--border);
- .category-header {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 12px;
- font-weight: 600;
- margin-bottom: 10px;
- .category-dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- }
- .category-count {
- color: var(--text-3);
- font-weight: normal;
- background: var(--bg);
- padding: 2px 8px;
- border-radius: 10px;
- }
- }
- .category-items {
- .category-item {
- display: flex;
- justify-content: space-between;
- padding: 8px 12px;
- background: var(--bg);
- border-radius: 6px;
- margin-bottom: 6px;
- cursor: pointer;
- font-size: 12px;
- transition: all 0.2s;
- &:hover {
- background: var(--primary-light);
- }
- .item-value {
- color: var(--text-3);
- }
- }
- }
- }
- .context-menu {
- position: fixed;
- min-width: 180px;
- background: #fff;
- border-radius: 10px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
- z-index: 3000;
- overflow: hidden;
- .context-menu-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 14px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.15s;
- &:hover {
- background: var(--primary-light);
- color: var(--primary);
- }
- .icon {
- font-size: 14px;
- }
- }
- }
- .graph-container {
- height: 500px;
- position: relative;
- background: linear-gradient(135deg, #f8fafc 0%, #f0f4f8 100%);
- border-radius: 8px;
- .graph-legend {
- position: absolute;
- top: 16px;
- left: 16px;
- background: #fff;
- border-radius: 8px;
- padding: 12px 16px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- .legend-title {
- font-size: 12px;
- font-weight: 600;
- margin-bottom: 8px;
- color: var(--text-2);
- }
- .legend-item {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- color: var(--text-2);
- margin-bottom: 4px;
- }
- .legend-dot {
- width: 12px;
- height: 12px;
- border-radius: 50%;
- &.entity { background: var(--primary); }
- &.concept { background: #722ed1; }
- &.data { background: var(--success); }
- &.location { background: var(--warning); }
- }
- }
- .graph-body {
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- .graph-placeholder {
- text-align: center;
- color: var(--text-3);
- p {
- margin-top: 12px;
- }
- }
- }
- }
- // 空白编辑器占位提示样式
- :deep(.empty-editor-placeholder) {
- padding: 60px 40px;
- text-align: center;
- color: var(--text-2);
- h2 {
- font-size: 24px;
- margin-bottom: 20px;
- color: var(--text-1);
- }
- p {
- font-size: 15px;
- margin-bottom: 16px;
- }
- ul {
- list-style: none;
- padding: 0;
- text-align: left;
- max-width: 300px;
- margin: 0 auto;
- li {
- padding: 8px 0;
- padding-left: 24px;
- position: relative;
- font-size: 14px;
- &::before {
- content: '✓';
- position: absolute;
- left: 0;
- color: var(--primary);
- }
- }
- }
- }
- </style>
|