| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145 |
- <template>
- <div class="editor-page">
- <!-- 工具栏 -->
- <div class="editor-toolbar">
- <el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
- <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 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">
- <h2>{{ reportTitle }}</h2>
- <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" />
- </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">({{ entities.length }})</span>
- </span>
- <el-button size="small" :icon="Plus" @click="showAddVariableDialog = true">
- 添加
- </el-button>
- </div>
- <div class="element-body">
- <div class="element-tags-wrap">
- <div
- v-for="entity in entities"
- :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>
- 点击标签定位到文档位置
- </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
- } 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([])
- /**
- * 从结构化文档的 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': '🏢',
- 'LOCATION': '📍',
- 'DATE': '📅',
- 'TIME': '⏰',
- 'MONEY': '💰',
- 'PERCENT': '📊',
- 'PRODUCT': '📦',
- 'EVENT': '📋',
- 'FACILITY': '🏭',
- 'GPE': '🌍',
- 'LAW': '⚖️',
- 'WORK_OF_ART': '🎨',
- 'LANGUAGE': '🗣️',
- 'QUANTITY': '🔢',
- 'ORDINAL': '🔢',
- 'CARDINAL': '🔢',
- 'ENTITY': '🏷️'
- }
- 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: 16px;
- flex-shrink: 0;
- .title-input-wrapper {
- position: relative;
- display: inline-block;
- min-width: 120px;
- max-width: 400px;
-
- .title-input {
- width: 100%;
- :deep(.el-input__wrapper) {
- box-shadow: none;
- background: transparent;
- &:hover, &.is-focus {
- background: var(--bg);
- }
- }
-
- :deep(.el-input__inner) {
- font-size: 15px;
- font-weight: 500;
- }
- }
-
- .title-measure {
- position: absolute;
- visibility: hidden;
- white-space: nowrap;
- font-size: 15px;
- font-weight: 500;
- padding: 0 11px; // 与 el-input 内边距一致
- pointer-events: none;
- }
- }
- .save-status {
- color: var(--success);
- font-size: 13px;
- }
- .toolbar-right {
- margin-left: auto;
- display: flex;
- gap: 8px;
- align-items: center;
- }
- }
- .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: 16px 24px;
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- gap: 12px;
- h2 {
- flex: 1;
- font-size: 18px;
- font-weight: 600;
- }
- }
- .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-body {
- padding: 0 16px 16px;
- }
- .element-tags-wrap {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- max-height: 300px;
- 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>
|