|
|
@@ -253,9 +253,20 @@
|
|
|
<div class="rule-trace-info">
|
|
|
<div class="rule-trace-name">{{ rule.ruleName }}</div>
|
|
|
<div v-if="rule.inputs && rule.inputs.length" class="rule-trace-sources">
|
|
|
- <span v-for="inp in rule.inputs" :key="inp.inputId" class="rule-trace-att">📎 {{ inp.sourceName }}</span>
|
|
|
+ <span
|
|
|
+ v-for="inp in rule.inputs"
|
|
|
+ :key="inp.inputId"
|
|
|
+ class="rule-trace-att clickable"
|
|
|
+ @click="openSourceInViewer(inp)"
|
|
|
+ title="点击查看来源"
|
|
|
+ >📎 {{ formatInputSource(inp) }}</span>
|
|
|
</div>
|
|
|
- <div v-if="ruleSourceText(rule)" class="rule-trace-excerpt">
|
|
|
+ <!-- 显示来源摘要 -->
|
|
|
+ <div v-if="getInputSourceText(rule)" class="rule-trace-source-text">
|
|
|
+ <span class="source-text-label">来源段落:</span>
|
|
|
+ <span class="source-text-content">{{ getInputSourceText(rule) }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="ruleSourceText(rule)" class="rule-trace-excerpt">
|
|
|
<span class="rule-trace-excerpt-text">{{ ruleSourceText(rule) }}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -300,22 +311,59 @@
|
|
|
<div class="rp-elements-title">
|
|
|
<span class="rp-title-icon">📋</span>
|
|
|
<span class="rp-title-text">报告要素</span>
|
|
|
+ <span class="rp-title-count">{{ filledValues.length }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="rp-header-actions">
|
|
|
<el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><CopyDocument /></el-icon></el-button>
|
|
|
<el-button text circle size="small" title="规则" @click="showRuleDialog = true"><el-icon><MoreFilled /></el-icon></el-button>
|
|
|
+ <el-input
|
|
|
+ v-if="elementSearchVisible"
|
|
|
+ v-model="elementSearchQuery"
|
|
|
+ size="small"
|
|
|
+ placeholder="搜索要素..."
|
|
|
+ clearable
|
|
|
+ style="width: 120px"
|
|
|
+ @blur="elementSearchVisible = elementSearchQuery.length > 0"
|
|
|
+ />
|
|
|
+ <el-button v-else text circle size="small" title="搜索" @click="elementSearchVisible = true"><el-icon><Search /></el-icon></el-button>
|
|
|
</div>
|
|
|
- <el-button text circle size="small" title="搜索"><el-icon><Search /></el-icon></el-button>
|
|
|
</div>
|
|
|
<div class="rp-elements-body">
|
|
|
- <div class="rp-value-tags" v-if="filledValues.length > 0">
|
|
|
- <span
|
|
|
- v-for="val in filledValues"
|
|
|
- :key="val.valueId"
|
|
|
- class="rp-value-tag"
|
|
|
- :title="val.elementName"
|
|
|
- >{{ val.valueText }}</span>
|
|
|
+ <div class="rp-element-list" v-if="groupedElements.length > 0">
|
|
|
+ <div
|
|
|
+ v-for="group in groupedElements"
|
|
|
+ :key="group.namespace"
|
|
|
+ class="rp-element-group"
|
|
|
+ >
|
|
|
+ <div class="rp-group-header" @click="toggleElementGroup(group.namespace)">
|
|
|
+ <span class="rp-group-icon">{{ elementGroupExpanded[group.namespace] ? '▼' : '▶' }}</span>
|
|
|
+ <span class="rp-group-name">{{ group.label }}</span>
|
|
|
+ <span class="rp-group-count">{{ group.items.length }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="rp-group-items" v-show="elementGroupExpanded[group.namespace]">
|
|
|
+ <div
|
|
|
+ v-for="item in group.items"
|
|
|
+ :key="item.elementKey"
|
|
|
+ class="rp-element-item"
|
|
|
+ :class="{
|
|
|
+ 'is-text': item.elementType === 'text',
|
|
|
+ 'is-paragraph': item.elementType === 'paragraph',
|
|
|
+ 'is-table': item.elementType === 'table',
|
|
|
+ 'has-value': item.hasValue,
|
|
|
+ 'is-active': highlightPopover.elementKey === item.elementKey
|
|
|
+ }"
|
|
|
+ @click="scrollToElement(item)"
|
|
|
+ :title="item.valueText || '暂无值'"
|
|
|
+ >
|
|
|
+ <span class="rp-item-type">{{ item.elementType === 'text' ? 'T' : item.elementType === 'paragraph' ? 'P' : '▦' }}</span>
|
|
|
+ <span class="rp-item-name">{{ item.elementName }}</span>
|
|
|
+ <span v-if="item.hasValue" class="rp-item-preview">{{ truncateValue(item.valueText, 20) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<div class="rp-elements-empty" v-else>
|
|
|
- <span>暂无要素值</span>
|
|
|
+ <span>暂无要素</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -649,7 +697,7 @@
|
|
|
<el-dialog
|
|
|
v-model="showRuleDialog"
|
|
|
title="⚙️ 规则管理"
|
|
|
- width="720"
|
|
|
+ width="800"
|
|
|
:close-on-click-modal="true"
|
|
|
class="floating-panel-dialog rule-manage-dialog"
|
|
|
align-center
|
|
|
@@ -660,7 +708,7 @@
|
|
|
size="small"
|
|
|
placeholder="搜索规则名称 / 要素标识..."
|
|
|
clearable
|
|
|
- style="width: 220px"
|
|
|
+ style="width: 280px"
|
|
|
:prefix-icon="Search"
|
|
|
/>
|
|
|
<el-button size="small" :icon="Plus" @click="showNewRuleDialog = true">添加规则</el-button>
|
|
|
@@ -724,7 +772,7 @@
|
|
|
<span class="rule-name">{{ rule.ruleName }}</span>
|
|
|
<el-tag size="small" type="info" effect="plain" class="rule-elem-key">{{ rule.elementKey }}</el-tag>
|
|
|
</div>
|
|
|
- <div class="rule-desc" v-if="rule.description">{{ rule.description }}</div>
|
|
|
+ <div class="rule-desc" v-if="ruleSourceSummary(rule)">来源:{{ ruleSourceSummary(rule) }}</div>
|
|
|
</div>
|
|
|
<div class="rule-actions">
|
|
|
<el-button v-if="rule.actionType !== 'use_entity_value'" size="small" type="primary" text @click.stop="handleExecuteRule(rule)" title="执行" :loading="rule._executing">▶</el-button>
|
|
|
@@ -737,11 +785,11 @@
|
|
|
<span class="rule-detail-label">取值规则</span>
|
|
|
<span class="rule-detail-value">{{ rule.dslContent }}</span>
|
|
|
</div>
|
|
|
- <div class="rule-detail-row" v-if="rule.inputs && rule.inputs.length > 0">
|
|
|
+ <div class="rule-detail-row" v-if="ruleInputDisplayList(rule).length > 0">
|
|
|
<span class="rule-detail-label">输入来源</span>
|
|
|
<div class="rule-detail-inputs">
|
|
|
- <span v-for="inp in rule.inputs" :key="inp.inputId" class="rule-input-chip">
|
|
|
- 📎 {{ inp.inputName || inp.sourceName }}
|
|
|
+ <span v-for="(src, idx) in ruleInputDisplayList(rule)" :key="`${rule.id}-src-${idx}`" class="rule-input-chip">
|
|
|
+ 📎 {{ src }}
|
|
|
</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -916,6 +964,91 @@ const filledValues = computed(() => {
|
|
|
return result
|
|
|
})
|
|
|
|
|
|
+// 要素搜索和分组
|
|
|
+const elementSearchVisible = ref(false)
|
|
|
+const elementSearchQuery = ref('')
|
|
|
+const elementGroupExpanded = reactive({})
|
|
|
+
|
|
|
+const NAMESPACE_LABELS = {
|
|
|
+ 'project': '项目信息',
|
|
|
+ 'basicInfo': '基本信息',
|
|
|
+ 'review': '评审信息',
|
|
|
+ 'score': '评分信息',
|
|
|
+ '+': '扩展要素',
|
|
|
+ 'other': '其他'
|
|
|
+}
|
|
|
+
|
|
|
+const groupedElements = computed(() => {
|
|
|
+ const groups = {}
|
|
|
+ const query = elementSearchQuery.value.toLowerCase()
|
|
|
+
|
|
|
+ for (const elem of elements.value) {
|
|
|
+ // 搜索过滤
|
|
|
+ if (query && !elem.elementName.toLowerCase().includes(query) && !elem.elementKey.toLowerCase().includes(query)) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取命名空间
|
|
|
+ let namespace = 'other'
|
|
|
+ if (elem.elementKey.startsWith('+')) {
|
|
|
+ namespace = '+'
|
|
|
+ } else if (elem.elementKey.includes('.')) {
|
|
|
+ namespace = elem.elementKey.split('.')[0]
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!groups[namespace]) {
|
|
|
+ groups[namespace] = {
|
|
|
+ namespace,
|
|
|
+ label: NAMESPACE_LABELS[namespace] || namespace,
|
|
|
+ items: []
|
|
|
+ }
|
|
|
+ // 默认展开第一个分组
|
|
|
+ if (elementGroupExpanded[namespace] === undefined) {
|
|
|
+ elementGroupExpanded[namespace] = Object.keys(groups).length === 1
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 查找对应的值
|
|
|
+ const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
|
|
|
+
|
|
|
+ groups[namespace].items.push({
|
|
|
+ elementKey: elem.elementKey,
|
|
|
+ elementName: elem.elementName,
|
|
|
+ elementType: elem.elementType || 'text',
|
|
|
+ hasValue: !!(val?.valueText),
|
|
|
+ valueText: val?.valueText || ''
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按顺序排列
|
|
|
+ const order = ['project', 'basicInfo', 'review', 'score', '+', 'other']
|
|
|
+ return order.filter(ns => groups[ns]).map(ns => groups[ns])
|
|
|
+})
|
|
|
+
|
|
|
+function toggleElementGroup(namespace) {
|
|
|
+ elementGroupExpanded[namespace] = !elementGroupExpanded[namespace]
|
|
|
+}
|
|
|
+
|
|
|
+function truncateValue(text, maxLen = 20) {
|
|
|
+ if (!text) return ''
|
|
|
+ const s = String(text).replace(/\n/g, ' ').trim()
|
|
|
+ return s.length > maxLen ? s.slice(0, maxLen) + '...' : s
|
|
|
+}
|
|
|
+
|
|
|
+function scrollToElement(item) {
|
|
|
+ // 查找文档中对应的高亮元素并滚动到视图
|
|
|
+ const docPaper = docPaperRef.value
|
|
|
+ if (!docPaper) return
|
|
|
+
|
|
|
+ const selector = `[data-element-key="${item.elementKey}"]`
|
|
|
+ const el = docPaper.querySelector(selector)
|
|
|
+ if (el) {
|
|
|
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
+ // 触发点击以打开弹窗
|
|
|
+ el.click()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// 文档预览状态
|
|
|
const docContent = ref(null)
|
|
|
const docLoading = ref(false)
|
|
|
@@ -965,7 +1098,8 @@ const filteredRules = computed(() => {
|
|
|
list = list.filter(r =>
|
|
|
(r.ruleName || '').toLowerCase().includes(q) ||
|
|
|
(r.elementKey || '').toLowerCase().includes(q) ||
|
|
|
- (r.description || '').toLowerCase().includes(q)
|
|
|
+ (r.description || '').toLowerCase().includes(q) ||
|
|
|
+ ruleSourceSummary(r).toLowerCase().includes(q)
|
|
|
)
|
|
|
}
|
|
|
return list
|
|
|
@@ -984,12 +1118,131 @@ function toggleRuleExpand(ruleId) {
|
|
|
expandedRuleId.value = expandedRuleId.value === ruleId ? null : ruleId
|
|
|
}
|
|
|
|
|
|
-function ruleInputSummary(rule) {
|
|
|
- if (!rule.inputs || rule.inputs.length === 0) return ''
|
|
|
- return rule.inputs.map(i => i.inputName || i.sourceName || '').filter(Boolean).join('、')
|
|
|
+function normalizeRuleSourceName(name) {
|
|
|
+ const s = String(name || '').trim()
|
|
|
+ if (!s) return ''
|
|
|
+ return s.replace(/^来源[::]\s*/i, '')
|
|
|
+}
|
|
|
+
|
|
|
+function formatInputSource(inp) {
|
|
|
+ const sourceName = inp?.sourceName || inp?.inputName || ''
|
|
|
+ const entryPath = inp?.entryPath
|
|
|
+ if (entryPath) {
|
|
|
+ // 有 entryPath 时,显示 "附件名 → 文件名"
|
|
|
+ const fileName = entryPath.split('/').pop()
|
|
|
+ return `${sourceName} → ${fileName}`
|
|
|
+ }
|
|
|
+ return sourceName
|
|
|
+}
|
|
|
+
|
|
|
+function getInputSourceText(rule) {
|
|
|
+ // 从规则的 inputs 中获取 sourceText
|
|
|
+ const inputs = Array.isArray(rule?.inputs) ? rule.inputs : []
|
|
|
+ for (const inp of inputs) {
|
|
|
+ if (inp.sourceText) return inp.sourceText
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+async function openSourceInViewer(inp) {
|
|
|
+ // 关闭弹窗
|
|
|
+ highlightPopover.visible = false
|
|
|
+
|
|
|
+ const attId = inp.sourceNodeId
|
|
|
+ const entryPath = inp.entryPath
|
|
|
+ const sourceText = inp.sourceText
|
|
|
+
|
|
|
+ // 查找附件
|
|
|
+ const att = attachments.value.find(a => a.id === attId)
|
|
|
+ if (!att) {
|
|
|
+ ElMessage.warning('未找到来源附件')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置高亮文本(用于在查看器中高亮)
|
|
|
+ highlightSourceText.value = sourceText || ''
|
|
|
+
|
|
|
+ // 判断是否是 ZIP 文件
|
|
|
+ const ext = (att.fileType || '').toLowerCase()
|
|
|
+ if (ext === 'zip' && entryPath) {
|
|
|
+ // 打开 ZIP 内容查看器,并定位到指定文件
|
|
|
+ await handleZipAttachment(att)
|
|
|
+ // 等待 ZIP 内容加载完成后,自动打开指定的 entry
|
|
|
+ setTimeout(() => {
|
|
|
+ const fileName = entryPath.split('/').pop()
|
|
|
+ const zf = zipFileList.value.find(f => f.name === entryPath || f.name.endsWith('/' + fileName) || f.name.endsWith(fileName))
|
|
|
+ if (zf && zf.parsed) {
|
|
|
+ viewZipEntryResult(zf)
|
|
|
+ } else if (zf) {
|
|
|
+ // 如果还没解析,先解析
|
|
|
+ parseZipEntry(zf)
|
|
|
+ }
|
|
|
+ }, 800)
|
|
|
+ } else {
|
|
|
+ // 直接打开附件查看器
|
|
|
+ const state = parseStates[att.id]
|
|
|
+ if (state?.status === 'completed') {
|
|
|
+ viewParseResult(att)
|
|
|
+ } else {
|
|
|
+ // 先解析再查看
|
|
|
+ await triggerParse(att)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function ruleSourceFromActionConfig(rule) {
|
|
|
+ if (!rule.actionConfig) return ''
|
|
|
+ try {
|
|
|
+ const cfg = typeof rule.actionConfig === 'string' ? JSON.parse(rule.actionConfig) : rule.actionConfig
|
|
|
+ const zipName = normalizeRuleSourceName(cfg.zipName || cfg.zipFileName || cfg.archiveName)
|
|
|
+ const entryName = normalizeRuleSourceName(cfg.zipEntryName || cfg.entryName || cfg.fileName || cfg.attachmentName)
|
|
|
+ if (zipName && entryName && zipName !== entryName) return `${zipName}/${entryName}`
|
|
|
+ return entryName || zipName || ''
|
|
|
+ } catch (_) {
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function ruleInputDisplayList(rule) {
|
|
|
+ const list = []
|
|
|
+ const inputs = Array.isArray(rule?.inputs) ? rule.inputs : []
|
|
|
+ for (const inp of inputs) {
|
|
|
+ // 优先使用 entryPath(ZIP内文件路径),否则使用 inputName/sourceName
|
|
|
+ const entryPath = inp?.entryPath
|
|
|
+ const inputName = normalizeRuleSourceName(inp?.inputName || inp?.sourceName || '')
|
|
|
+
|
|
|
+ if (entryPath) {
|
|
|
+ // 有 entryPath 时,显示 "附件名 → 文件名"
|
|
|
+ const fileName = entryPath.split('/').pop()
|
|
|
+ const value = inputName ? `${inputName} → ${fileName}` : fileName
|
|
|
+ if (value && !list.includes(value)) list.push(value)
|
|
|
+ } else if (inputName) {
|
|
|
+ if (!list.includes(inputName)) list.push(inputName)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return list
|
|
|
+}
|
|
|
+
|
|
|
+function ruleSourceSummary(rule) {
|
|
|
+ const fromInputs = ruleInputDisplayList(rule)
|
|
|
+ if (fromInputs.length) return fromInputs.join('、')
|
|
|
+
|
|
|
+ const fromCfg = ruleSourceFromActionConfig(rule)
|
|
|
+ if (fromCfg) return fromCfg
|
|
|
+
|
|
|
+ const desc = normalizeRuleSourceName(rule?.description)
|
|
|
+ if (desc.startsWith('从附件「')) {
|
|
|
+ const m = desc.match(/从附件「([^」]+)」/)
|
|
|
+ if (m?.[1]) return m[1]
|
|
|
+ }
|
|
|
+ if (desc.startsWith('来源:') || desc.startsWith('来源:')) {
|
|
|
+ return desc.replace(/^来源[::]\s*/, '')
|
|
|
+ }
|
|
|
+ return desc
|
|
|
}
|
|
|
// 附件解析状态: { [attachmentId]: { status: 'idle'|'uploading'|'parsing'|'completed'|'failed', progress: '', markdown: '' } }
|
|
|
const parseStates = reactive({})
|
|
|
+const highlightSourceText = ref('') // 用于在附件查看器中高亮的来源文本
|
|
|
const showParseResultDialog = ref(false)
|
|
|
const parseResultAttName = ref('')
|
|
|
const parseResultContent = ref('')
|
|
|
@@ -997,14 +1250,25 @@ const parseResultViewMode = ref('rendered')
|
|
|
const parseResultIsHtml = ref(false)
|
|
|
const parseResultHtml = computed(() => {
|
|
|
if (!parseResultContent.value) return ''
|
|
|
+ let html = ''
|
|
|
// DOCX 解析结果已经是 HTML,直接使用
|
|
|
- if (parseResultIsHtml.value) return parseResultContent.value
|
|
|
- // PDF/图片解析结果是 markdown,用 marked 渲染
|
|
|
- try {
|
|
|
- return marked(parseResultContent.value)
|
|
|
- } catch (e) {
|
|
|
- return `<pre>${parseResultContent.value}</pre>`
|
|
|
+ if (parseResultIsHtml.value) {
|
|
|
+ html = parseResultContent.value
|
|
|
+ } else {
|
|
|
+ // PDF/图片解析结果是 markdown,用 marked 渲染
|
|
|
+ try {
|
|
|
+ html = marked(parseResultContent.value)
|
|
|
+ } catch (e) {
|
|
|
+ html = `<pre>${parseResultContent.value}</pre>`
|
|
|
+ }
|
|
|
}
|
|
|
+ // 如果有高亮文本,在 HTML 中高亮显示
|
|
|
+ if (highlightSourceText.value && highlightSourceText.value.length > 5) {
|
|
|
+ const escaped = highlightSourceText.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
|
+ const regex = new RegExp(`(${escaped})`, 'gi')
|
|
|
+ html = html.replace(regex, '<mark class="source-highlight">$1</mark>')
|
|
|
+ }
|
|
|
+ return html
|
|
|
})
|
|
|
const parseResultSource = computed(() => {
|
|
|
if (!parseResultContent.value) return ''
|
|
|
@@ -1098,19 +1362,6 @@ function unselectProject() {
|
|
|
leftPanelTab.value = 'projects'
|
|
|
}
|
|
|
|
|
|
-// 本地真实附件 mock 数据
|
|
|
-const mockAttachments = [
|
|
|
- { id: 'att-01', displayName: '附件1 成都院核心要素评审情况记录表.docx', fileType: 'docx', fileSize: 376919, fileUrl: '/attachments/att01-核心要素评审情况记录表.docx' },
|
|
|
- { id: 'att-02', displayName: '附件2 成都院现场评审分工表.zip', fileType: 'zip', fileSize: 2171136, fileUrl: '/attachments/att02-现场评审分工表.zip' },
|
|
|
- { id: 'att-03', displayName: '附件3 安全生产标准化建设通知.pdf', fileType: 'pdf', fileSize: 360940, fileUrl: '/attachments/att03-安全生产标准化通知.pdf' },
|
|
|
- { id: 'att-04', displayName: '附件4 成都院材料真实性说明.png', fileType: 'png', fileSize: 514264, fileUrl: '/attachments/att04-材料真实性说明.png' },
|
|
|
- { id: 'att-05', displayName: '附件5 成都院在建项目一览表.docx', fileType: 'docx', fileSize: 16056, fileUrl: '/attachments/att05-在建项目一览表.docx' },
|
|
|
- { id: 'att-06', displayName: '附件6 成都院安全管理制度清单.docx', fileType: 'docx', fileSize: 16942, fileUrl: '/attachments/att06-安全管理制度清单.docx' },
|
|
|
- { id: 'att-07', displayName: '附件7 成都院现场评审末次会签到表.png', fileType: 'png', fileSize: 564269, fileUrl: '/attachments/att07-现场评审末次会签到表.png' },
|
|
|
- { id: 'att-08', displayName: '附件8 工作方案.zip', fileType: 'zip', fileSize: 299279, fileUrl: '/attachments/att08-工作方案.zip' },
|
|
|
- { id: 'att-09', displayName: '附件9 复审问题建议表.docx', fileType: 'docx', fileSize: 29034, fileUrl: '/attachments/att09-复审问题建议表.docx' },
|
|
|
-]
|
|
|
-
|
|
|
async function loadProjectData(projectId) {
|
|
|
loading.value = true
|
|
|
try {
|
|
|
@@ -1121,8 +1372,7 @@ async function loadProjectData(projectId) {
|
|
|
ruleApi.list(projectId).catch(() => [])
|
|
|
])
|
|
|
elements.value = elemData || []; values.value = valData || []
|
|
|
- // 使用本地 mock 附件(含真实文件 URL,用于解析)
|
|
|
- attachments.value = [...mockAttachments]
|
|
|
+ attachments.value = attData || []
|
|
|
// 恢复已持久化的解析状态
|
|
|
restoreParseStates()
|
|
|
rules.value = ruleData || []
|
|
|
@@ -2185,6 +2435,38 @@ function getFileExt(att) {
|
|
|
const ext = name.split('.').pop()?.toLowerCase()
|
|
|
return ext || ''
|
|
|
}
|
|
|
+
|
|
|
+function getAttachmentFetchUrl(att) {
|
|
|
+ if (att?.fileUrl) return att.fileUrl
|
|
|
+ if (att?.fileKey) return `/api/v1/files/${encodeURIComponent(att.fileKey)}`
|
|
|
+ if (att?.filePath && /^https?:\/\//i.test(att.filePath)) return att.filePath
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+async function loadAttachmentFile(att, fallbackName = 'file') {
|
|
|
+ const cached = attachmentFileCache.get(att.id)
|
|
|
+ if (cached) return cached
|
|
|
+
|
|
|
+ const url = getAttachmentFetchUrl(att)
|
|
|
+ if (!url) return null
|
|
|
+
|
|
|
+ try {
|
|
|
+ const token = localStorage.getItem('accessToken')
|
|
|
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
|
+ const resp = await fetch(url, { headers })
|
|
|
+ if (!resp.ok) throw new Error(`下载失败(${resp.status})`)
|
|
|
+ const blob = await resp.blob()
|
|
|
+ const file = new File([blob], att.displayName || att.fileName || fallbackName, {
|
|
|
+ type: blob.type || 'application/octet-stream'
|
|
|
+ })
|
|
|
+ attachmentFileCache.set(att.id, file)
|
|
|
+ return file
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('获取附件文件失败:', e)
|
|
|
+ return null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
function getFileTypeClass(att) {
|
|
|
const ext = getFileExt(att)
|
|
|
if (ext === 'pdf') return 'type-pdf'
|
|
|
@@ -2284,6 +2566,18 @@ function viewParseResult(att) {
|
|
|
previewContentType.value = ''
|
|
|
parseResultViewMode.value = 'rendered'
|
|
|
showParseResultDialog.value = true
|
|
|
+
|
|
|
+ // 如果有高亮文本,延迟滚动到高亮位置
|
|
|
+ if (highlightSourceText.value) {
|
|
|
+ setTimeout(scrollToHighlight, 300)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function scrollToHighlight() {
|
|
|
+ const highlight = document.querySelector('.parse-result-rendered mark.source-highlight')
|
|
|
+ if (highlight) {
|
|
|
+ highlight.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
function copyParseResult() {
|
|
|
@@ -2344,27 +2638,10 @@ function canParse(att) {
|
|
|
async function handleZipAttachment(att) {
|
|
|
// 获取 ZIP 文件
|
|
|
let file = attachmentFileCache.get(att.id)
|
|
|
- if (!file && att.fileUrl) {
|
|
|
- try {
|
|
|
- const resp = await fetch(att.fileUrl)
|
|
|
- if (resp.ok) {
|
|
|
- const blob = await resp.blob()
|
|
|
- file = new File([blob], att.displayName || 'file.zip', { type: blob.type })
|
|
|
- attachmentFileCache.set(att.id, file)
|
|
|
- }
|
|
|
- } catch (e) { console.warn('获取 ZIP 文件失败:', e) }
|
|
|
- }
|
|
|
+ if (!file) file = await loadAttachmentFile(att, 'file.zip')
|
|
|
if (!file) {
|
|
|
- const picked = await new Promise((resolve) => {
|
|
|
- const input = document.createElement('input')
|
|
|
- input.type = 'file'
|
|
|
- input.accept = '.zip'
|
|
|
- input.onchange = (e) => resolve(e.target.files[0] || null)
|
|
|
- input.click()
|
|
|
- })
|
|
|
- if (!picked) return
|
|
|
- file = picked
|
|
|
- attachmentFileCache.set(att.id, file)
|
|
|
+ ElMessage.error('未找到后端附件文件,请先确认附件文件已在后端持久化')
|
|
|
+ return
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
@@ -2572,17 +2849,10 @@ async function loadPreviewFromAtt(att) {
|
|
|
}
|
|
|
const ext = getFileExt(att)
|
|
|
try {
|
|
|
- // 获取原始文件:优先缓存,其次 fileUrl
|
|
|
- let file = attachmentFileCache.get(att.id)
|
|
|
- if (!file && att.fileUrl) {
|
|
|
- const resp = await fetch(att.fileUrl)
|
|
|
- if (resp.ok) {
|
|
|
- const blob = await resp.blob()
|
|
|
- file = new File([blob], att.displayName || 'file', { type: blob.type })
|
|
|
- }
|
|
|
- }
|
|
|
+ // 获取原始文件:优先缓存,其次后端文件服务
|
|
|
+ const file = await loadAttachmentFile(att)
|
|
|
if (!file) {
|
|
|
- ElMessage.warning('原始文件不可用,请重新上传后再预览')
|
|
|
+ ElMessage.warning('原始文件不可用,请确认后端附件文件已持久化')
|
|
|
previewContentType.value = 'unsupported'
|
|
|
return
|
|
|
}
|
|
|
@@ -2671,6 +2941,7 @@ function cleanupPreviewContent() {
|
|
|
parseResultPreviewAvailable.value = false
|
|
|
parseResultOriginAtt.value = null
|
|
|
parseResultOriginZf.value = null
|
|
|
+ highlightSourceText.value = '' // 清除高亮文本
|
|
|
}
|
|
|
|
|
|
async function handleParseAttachment(att) {
|
|
|
@@ -2693,41 +2964,17 @@ async function handleParseAttachment(att) {
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
- // 1. 获取缓存的原始文件
|
|
|
+ // 1. 获取后端持久化的原始文件
|
|
|
state.status = 'uploading'
|
|
|
state.progress = '正在准备文件...'
|
|
|
|
|
|
- let file = attachmentFileCache.get(att.id)
|
|
|
- if (!file && att.fileUrl) {
|
|
|
- // 从静态资源目录获取真实文件(mock 附件或有 fileUrl 的附件)
|
|
|
- state.progress = '正在获取文件...'
|
|
|
- try {
|
|
|
- const resp = await fetch(att.fileUrl)
|
|
|
- if (resp.ok) {
|
|
|
- const blob = await resp.blob()
|
|
|
- file = new File([blob], att.displayName || `file.${ext}`, { type: blob.type })
|
|
|
- attachmentFileCache.set(att.id, file)
|
|
|
- }
|
|
|
- } catch (fetchErr) {
|
|
|
- console.warn('获取附件文件失败:', fetchErr)
|
|
|
- }
|
|
|
- }
|
|
|
+ state.progress = '正在获取文件...'
|
|
|
+ const file = await loadAttachmentFile(att, `file.${ext}`)
|
|
|
if (!file) {
|
|
|
- // 缓存中没有且无 fileUrl,提示用户重新选择文件
|
|
|
- const reselected = await new Promise((resolve) => {
|
|
|
- const input = document.createElement('input')
|
|
|
- input.type = 'file'
|
|
|
- input.accept = '.pdf,.png,.jpg,.jpeg,.docx,.doc'
|
|
|
- input.onchange = (e) => resolve(e.target.files[0] || null)
|
|
|
- input.click()
|
|
|
- })
|
|
|
- if (!reselected) {
|
|
|
- state.status = 'idle'
|
|
|
- state.progress = ''
|
|
|
- return
|
|
|
- }
|
|
|
- file = reselected
|
|
|
- attachmentFileCache.set(att.id, file)
|
|
|
+ state.status = 'failed'
|
|
|
+ state.progress = '未找到后端附件文件'
|
|
|
+ ElMessage.error('未找到后端附件文件,请先确认附件文件已在后端持久化')
|
|
|
+ return
|
|
|
}
|
|
|
|
|
|
// 2. DOCX 走后端 Java 解析,PDF/图片走 GPU 解析服务
|
|
|
@@ -4195,7 +4442,7 @@ onMounted(async () => {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
- padding: 14px 16px 10px;
|
|
|
+ padding: 12px 14px 8px;
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
.rp-elements-title {
|
|
|
@@ -4203,41 +4450,139 @@ onMounted(async () => {
|
|
|
align-items: center;
|
|
|
gap: 6px;
|
|
|
|
|
|
- .rp-title-icon { font-size: 16px; }
|
|
|
- .rp-title-text { font-size: 14px; font-weight: 700; color: var(--text-1); }
|
|
|
+ .rp-title-icon { font-size: 15px; }
|
|
|
+ .rp-title-text { font-size: 13px; font-weight: 600; color: var(--text-1); }
|
|
|
+ .rp-title-count {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #fff;
|
|
|
+ background: var(--el-color-primary);
|
|
|
+ padding: 1px 6px;
|
|
|
+ border-radius: 10px;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .rp-header-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.rp-elements-body {
|
|
|
flex: 1;
|
|
|
overflow-y: auto;
|
|
|
- padding: 0 16px 14px;
|
|
|
+ padding: 0 8px 12px;
|
|
|
}
|
|
|
|
|
|
- .rp-value-tags {
|
|
|
+ .rp-element-list {
|
|
|
display: flex;
|
|
|
- flex-wrap: wrap;
|
|
|
- gap: 8px;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
}
|
|
|
|
|
|
- .rp-value-tag {
|
|
|
- display: inline-block;
|
|
|
- padding: 5px 14px;
|
|
|
- background: #e8f4ff;
|
|
|
- color: #1677ff;
|
|
|
- border-radius: 16px;
|
|
|
- font-size: 13px;
|
|
|
- font-weight: 500;
|
|
|
- line-height: 1.4;
|
|
|
- cursor: default;
|
|
|
- transition: background 0.15s;
|
|
|
- max-width: 100%;
|
|
|
- overflow: hidden;
|
|
|
- text-overflow: ellipsis;
|
|
|
- white-space: nowrap;
|
|
|
+ .rp-element-group {
|
|
|
+ .rp-group-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 6px 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: background 0.15s;
|
|
|
+
|
|
|
+ &:hover { background: var(--bg-2); }
|
|
|
+
|
|
|
+ .rp-group-icon {
|
|
|
+ font-size: 10px;
|
|
|
+ color: var(--text-3);
|
|
|
+ width: 12px;
|
|
|
+ }
|
|
|
+ .rp-group-name {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--text-2);
|
|
|
+ flex: 1;
|
|
|
+ }
|
|
|
+ .rp-group-count {
|
|
|
+ font-size: 10px;
|
|
|
+ color: var(--text-3);
|
|
|
+ background: var(--bg-2);
|
|
|
+ padding: 1px 5px;
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .rp-group-items {
|
|
|
+ padding-left: 12px;
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
+ .rp-element-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 5px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.15s;
|
|
|
+ border-left: 2px solid transparent;
|
|
|
+
|
|
|
&:hover {
|
|
|
- background: #d0e8ff;
|
|
|
+ background: var(--bg-2);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-active {
|
|
|
+ background: #e6f4ff;
|
|
|
+ border-left-color: var(--el-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.has-value {
|
|
|
+ .rp-item-name { color: var(--text-1); }
|
|
|
+ }
|
|
|
+
|
|
|
+ .rp-item-type {
|
|
|
+ font-size: 9px;
|
|
|
+ font-weight: 600;
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ border-radius: 3px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-text .rp-item-type {
|
|
|
+ background: #e6f7ff;
|
|
|
+ color: #1890ff;
|
|
|
+ }
|
|
|
+ &.is-paragraph .rp-item-type {
|
|
|
+ background: #f6ffed;
|
|
|
+ color: #52c41a;
|
|
|
+ }
|
|
|
+ &.is-table .rp-item-type {
|
|
|
+ background: #fff7e6;
|
|
|
+ color: #fa8c16;
|
|
|
+ }
|
|
|
+
|
|
|
+ .rp-item-name {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--text-3);
|
|
|
+ flex: 1;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .rp-item-preview {
|
|
|
+ font-size: 11px;
|
|
|
+ color: var(--text-3);
|
|
|
+ max-width: 80px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ opacity: 0.7;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -4245,7 +4590,7 @@ onMounted(async () => {
|
|
|
padding: 20px;
|
|
|
text-align: center;
|
|
|
color: var(--text-3);
|
|
|
- font-size: 13px;
|
|
|
+ font-size: 12px;
|
|
|
}
|
|
|
|
|
|
// ---- AI 助手区 ----
|
|
|
@@ -5903,6 +6248,42 @@ onMounted(async () => {
|
|
|
font-size: 11px;
|
|
|
color: var(--text-3);
|
|
|
word-break: break-all;
|
|
|
+
|
|
|
+ &.clickable {
|
|
|
+ cursor: pointer;
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ &:hover {
|
|
|
+ text-decoration: underline;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .rule-trace-source-text {
|
|
|
+ margin-top: 4px;
|
|
|
+ padding: 6px 8px;
|
|
|
+ background: #fffbe6;
|
|
|
+ border-left: 3px solid #faad14;
|
|
|
+ border-radius: 2px;
|
|
|
+
|
|
|
+ .source-text-label {
|
|
|
+ font-size: 10px;
|
|
|
+ color: #d48806;
|
|
|
+ font-weight: 500;
|
|
|
+ display: block;
|
|
|
+ margin-bottom: 2px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .source-text-content {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #614700;
|
|
|
+ line-height: 1.5;
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-line-clamp: 4;
|
|
|
+ line-clamp: 4;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ overflow: hidden;
|
|
|
+ word-break: break-all;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -6020,24 +6401,33 @@ onMounted(async () => {
|
|
|
.fp-toolbar {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- gap: 8px;
|
|
|
- padding: 12px 20px;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 14px 20px;
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
background: #fafbfc;
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
|
.fp-count {
|
|
|
margin-left: auto;
|
|
|
font-size: 12px;
|
|
|
color: #999;
|
|
|
+ line-height: 1;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.fp-list {
|
|
|
- padding: 8px 12px;
|
|
|
+ padding: 12px 16px 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.rule-manage-dialog .el-dialog__body {
|
|
|
+ max-height: 72vh;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
}
|
|
|
|
|
|
&.rule-manage-dialog .fp-list {
|
|
|
- max-height: 480px;
|
|
|
+ max-height: none;
|
|
|
+ flex: 1;
|
|
|
overflow-y: auto;
|
|
|
}
|
|
|
|
|
|
@@ -6201,6 +6591,21 @@ onMounted(async () => {
|
|
|
color: #374151;
|
|
|
}
|
|
|
|
|
|
+ // 来源文本高亮样式
|
|
|
+ mark.source-highlight {
|
|
|
+ background: linear-gradient(180deg, #fff3cd, #ffeeba);
|
|
|
+ color: #856404;
|
|
|
+ padding: 2px 4px;
|
|
|
+ border-radius: 3px;
|
|
|
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
|
+ animation: highlight-pulse 2s ease-in-out;
|
|
|
+ }
|
|
|
+
|
|
|
+ @keyframes highlight-pulse {
|
|
|
+ 0%, 100% { background: linear-gradient(180deg, #fff3cd, #ffeeba); }
|
|
|
+ 50% { background: linear-gradient(180deg, #ffe066, #ffc107); }
|
|
|
+ }
|
|
|
+
|
|
|
code {
|
|
|
background: #f3f4f6;
|
|
|
padding: 1px 4px;
|
|
|
@@ -6298,14 +6703,14 @@ onMounted(async () => {
|
|
|
// ---- 规则筛选栏 ----
|
|
|
.rule-filter-bar {
|
|
|
display: flex;
|
|
|
- gap: 4px;
|
|
|
- padding: 0 16px 10px;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px 16px 12px;
|
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
.rule-filter-tab {
|
|
|
font-size: 12px;
|
|
|
- padding: 4px 10px;
|
|
|
+ padding: 5px 12px;
|
|
|
border-radius: 14px;
|
|
|
cursor: pointer;
|
|
|
color: #666;
|
|
|
@@ -6334,27 +6739,30 @@ onMounted(async () => {
|
|
|
|
|
|
// ---- 规则项 ----
|
|
|
.fp-rule-item {
|
|
|
- border-radius: 8px;
|
|
|
+ border-radius: 10px;
|
|
|
+ border: 1px solid #eef1f5;
|
|
|
+ background: #fff;
|
|
|
transition: background 0.15s;
|
|
|
cursor: pointer;
|
|
|
+ margin-bottom: 10px;
|
|
|
|
|
|
- &:hover { background: #f5f7fa; }
|
|
|
+ &:hover { background: #f8fafc; }
|
|
|
&.expanded { background: #fafbfc; }
|
|
|
|
|
|
.rule-item-main {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- gap: 10px;
|
|
|
- padding: 10px 12px;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 12px 14px;
|
|
|
}
|
|
|
|
|
|
.rule-action-badge {
|
|
|
flex-shrink: 0;
|
|
|
- font-size: 11px;
|
|
|
+ font-size: 12px;
|
|
|
font-weight: 600;
|
|
|
- padding: 3px 8px;
|
|
|
- border-radius: 12px;
|
|
|
- line-height: 16px;
|
|
|
+ padding: 4px 10px;
|
|
|
+ border-radius: 13px;
|
|
|
+ line-height: 18px;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
&.action-quote { background: #e6f4ff; color: #1677ff; }
|
|
|
@@ -6375,7 +6783,7 @@ onMounted(async () => {
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
.rule-name {
|
|
|
- font-size: 13px;
|
|
|
+ font-size: 14px;
|
|
|
font-weight: 500;
|
|
|
color: #1f2937;
|
|
|
}
|
|
|
@@ -6389,27 +6797,31 @@ onMounted(async () => {
|
|
|
}
|
|
|
|
|
|
.rule-desc {
|
|
|
- font-size: 11px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
color: #999;
|
|
|
- margin-top: 2px;
|
|
|
+ margin-top: 4px;
|
|
|
overflow: hidden;
|
|
|
text-overflow: ellipsis;
|
|
|
- white-space: nowrap;
|
|
|
+ display: -webkit-box;
|
|
|
+ line-clamp: 2;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.rule-actions {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- gap: 2px;
|
|
|
+ gap: 6px;
|
|
|
flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
// 展开详情区域
|
|
|
.rule-detail {
|
|
|
- padding: 0 12px 10px 12px;
|
|
|
+ padding: 2px 14px 12px;
|
|
|
border-top: 1px dashed #e8e8e8;
|
|
|
- margin: 0 12px;
|
|
|
+ margin: 0 12px 4px;
|
|
|
|
|
|
.rule-detail-row {
|
|
|
display: flex;
|