Files
CN_Tool_client/frontend/src/views/tools/mmsmapping/components/MappingResultPanel.vue
yexb cd54bb676c feat(tools): 新增台账管理功能模块
- 添加 addLedger API 接口定义和实现
- 创建工程配置表单组件 (EngineeringForm)
- 创建设备配置表单组件 (EquipmentForm)
- 创建项目和测点表单组件 (ProjectForm, LineForm)
- 实现台账树形结构面板 (LedgerTreePanel)
- 添加台账数据验证契约检查脚本
- 集成字典选项和动态表单验证功能
- 实现台账节点增删改查完整流程
- 优化 Echarts 图表组件分组渲染性能
2026-05-09 07:53:32 +08:00

916 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<section class="mapping-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">映射结果</h2>
<p class="panel-description">展示和导出JSON与XML的映射结果以及JSON的映射序列配置</p>
</div>
<div class="panel-actions">
<el-button
type="primary"
:icon="Connection"
:loading="isGeneratingXml"
:disabled="!canGenerateXmlMapping"
@click="emit('generate-xml-mapping')"
>
生成XML映射
</el-button>
<div class="export-actions">
<el-button
type="primary"
plain
:icon="Download"
:disabled="!canExportActiveMapping"
@click="emit('export-mapping', activeExportType)"
>
{{ exportButtonText }}
</el-button>
<el-dropdown trigger="click" :disabled="!canExportAnyMapping" @command="handleExportCommand">
<el-button
type="primary"
plain
class="export-menu-button"
:icon="ArrowDown"
:disabled="!canExportAnyMapping"
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json" :disabled="!canExportJsonMapping">
导出JSON映射
</el-dropdown-item>
<el-dropdown-item command="xml" :disabled="!canExportXmlMapping">
导出XML映射
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-tag :type="responseStatusTagType" effect="light">{{ responseStatusText }}</el-tag>
</div>
</div>
<div class="panel-content panel-content--fixed">
<div class="panel-section result-card grow-card preview-tab-section">
<el-tabs v-model="activeTabProxy" class="preview-tabs">
<el-tab-pane label="JSON映射" name="json">
<div class="mapping-json-scroll">
<JsonMappingTree
v-if="mappingJsonPreview"
:source="mappingJsonPreview"
:meta-text="mappingMetaText"
>
<template #actions>
<el-button
type="primary"
plain
size="small"
:icon="Setting"
:disabled="!mappingJsonPreview"
@click="openSequenceDialog"
>
序列配置
</el-button>
<el-button
type="primary"
plain
size="small"
:icon="Warning"
@click="problemDialogVisible = true"
>
{{ problemButtonText }}
</el-button>
</template>
</JsonMappingTree>
<el-empty v-else description="当前返回未包含 mappingJson" />
</div>
</el-tab-pane>
<el-tab-pane v-if="showXmlMappingTab" label="XML映射" name="xml">
<div class="mapping-json-scroll">
<div class="match-result-actions">
<el-button
type="primary"
plain
:icon="Document"
:disabled="!methodDescribe"
@click="matchResultDialogVisible = true"
>
匹配结果展示
</el-button>
</div>
<div v-if="xmlMappingPreview" class="xml-file-viewer">
<div class="xml-file-header">
<span class="xml-file-name">XML 文件</span>
<span class="xml-file-meta">{{ xmlMetaText }}</span>
</div>
<pre class="xml-file-content">{{ xmlMappingPreview }}</pre>
</div>
<el-empty v-else :description="xmlEmptyText" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
<el-dialog
v-model="problemDialogVisible"
title="问题列表"
width="720px"
destroy-on-close
top="8vh"
class="mapping-problem-dialog"
>
<div v-if="problemList.length" class="problem-dialog-list">
<div v-for="(problem, index) in problemList" :key="`${index}-${problem}`" class="problem-item">
<span class="problem-index">{{ index + 1 }}</span>
<span class="problem-text">{{ problem }}</span>
</div>
</div>
<el-empty v-else :description="problemEmptyText" />
</el-dialog>
<el-dialog
v-model="matchResultDialogVisible"
title="匹配结果展示"
width="640px"
destroy-on-close
top="12vh"
>
<div class="match-result-detail">
{{ methodDescribe || '当前接口返回未包含 methodDescribe' }}
</div>
</el-dialog>
<el-dialog
v-model="sequenceDialogVisible"
title="序列配置"
width="920px"
destroy-on-close
top="8vh"
class="sequence-config-dialog"
>
<template v-if="sequenceConfigRows.length">
<div class="dialog-search-bar">
<el-input
v-model="sequenceSearchKeyword"
:prefix-icon="Search"
clearable
placeholder="按顶层、类型、上层描述、name 或路径检索"
/>
<span class="dialog-search-count">
{{ filteredSequenceRows.length }} / {{ sequenceConfigRows.length }}
</span>
</div>
<div v-if="sequenceConfigGroups.length" class="sequence-config-list">
<section v-for="group in sequenceConfigGroups" :key="group.topKey" class="sequence-top-group">
<div class="sequence-top-header">
<div>
<h3 class="sequence-top-title">{{ group.topDesc || group.topKey }}</h3>
<p class="sequence-top-key">{{ group.topKey }}</p>
</div>
<el-tag type="info" effect="light">{{ group.rowCount }} </el-tag>
</div>
<div class="sequence-type-list">
<article
v-for="typeGroup in group.typeGroups"
:key="typeGroup.typeName"
class="sequence-type-card"
>
<div class="sequence-type-header">
<div class="sequence-type-title">{{ typeGroup.typeName }}</div>
<el-tag type="primary" effect="plain" size="small">{{ typeGroup.rows.length }} </el-tag>
</div>
<div v-for="row in typeGroup.rows" :key="row.id" class="sequence-config-item">
<div class="sequence-config-info">
<div class="sequence-config-title">{{ row.parentDesc }}</div>
<div class="sequence-config-subtitle">
{{ row.name }}
<span class="sequence-config-path">{{ row.pathText }}</span>
</div>
</div>
<el-form label-position="top" size="small" class="sequence-config-form">
<el-form-item label="start">
<el-input v-model="row.start" />
</el-form-item>
<el-form-item label="end">
<el-input v-model="row.end" />
</el-form-item>
<el-form-item label="icdcout">
<el-input :model-value="row.icdcout" readonly />
</el-form-item>
</el-form>
</div>
</article>
</div>
</section>
</div>
<el-empty v-else description="当前检索条件下没有匹配的序列。" />
</template>
<el-empty v-else description="当前 JSON 映射中未分析到包含 start 和 end 的序列。" />
<template #footer>
<el-button @click="sequenceDialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="!sequenceConfigRows.length" @click="confirmSequenceConfig">
确定
</el-button>
</template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { ArrowDown, Connection, Document, Download, Search, Setting, Warning } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import JsonMappingTree from './JsonMappingTree.vue'
defineOptions({
name: 'MappingResultPanel'
})
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
type ResultTab = 'json' | 'xml' | 'problem'
type ExportMappingType = 'json' | 'xml'
type JsonObject = Record<string, unknown>
type JsonPath = Array<string | number>
interface SequenceConfigRow {
id: string
path: JsonPath
pathText: string
topKey: string
topDesc: string
parentDesc: string
desc: string
name: string
baseflag: string
start: string
end: string
icdcout: string
startValueType: string
endValueType: string
}
interface SequenceConfigTypeGroup {
typeName: string
rows: SequenceConfigRow[]
}
interface SequenceConfigGroup {
topKey: string
topDesc: string
rowCount: number
typeGroups: SequenceConfigTypeGroup[]
}
const props = defineProps<{
responseStatusText: string
responseStatusTagType: TagType
activeResultTab: ResultTab
mappingMetaText: string
mappingJsonPreview: string
xmlMetaText: string
xmlMappingPreview: string
xmlEmptyText: string
problemTabLabel: string
problemList: string[]
problemEmptyText: string
methodDescribe: string
canExportJsonMapping: boolean
canExportXmlMapping: boolean
canGenerateXmlMapping: boolean
isGeneratingXml: boolean
showXmlMappingTab: boolean
}>()
const emit = defineEmits<{
(event: 'update:activeResultTab', value: ResultTab): void
(event: 'export-mapping', value: ExportMappingType): void
(event: 'generate-xml-mapping'): void
(event: 'update-mapping-json', value: string): void
}>()
const activeTabProxy = computed({
get: () => (props.activeResultTab === 'problem' ? 'json' : props.activeResultTab),
set: value => emit('update:activeResultTab', value)
})
const canExportAnyMapping = computed(() => props.canExportJsonMapping || props.canExportXmlMapping)
const activeExportType = computed<ExportMappingType>(() => {
if (props.activeResultTab === 'xml') return 'xml'
if (props.canExportJsonMapping) return 'json'
if (props.canExportXmlMapping) return 'xml'
return 'json'
})
const canExportActiveMapping = computed(() =>
activeExportType.value === 'json' ? props.canExportJsonMapping : props.canExportXmlMapping
)
const exportButtonText = computed(() => (activeExportType.value === 'xml' ? '导出XML映射' : '导出JSON映射'))
const problemButtonText = computed(() =>
props.problemList.length ? `问题列表(${props.problemList.length}` : '问题列表'
)
const problemDialogVisible = ref(false)
const matchResultDialogVisible = ref(false)
const sequenceDialogVisible = ref(false)
const sequenceConfigRows = ref<SequenceConfigRow[]>([])
const sequenceSearchKeyword = ref('')
const normalizedSequenceSearchKeyword = computed(() => sequenceSearchKeyword.value.trim().toLowerCase())
const filteredSequenceRows = computed(() => {
const keyword = normalizedSequenceSearchKeyword.value
if (!keyword) return sequenceConfigRows.value
return sequenceConfigRows.value.filter(row =>
[row.topKey, row.topDesc, row.desc, row.parentDesc, row.name, row.baseflag, row.pathText, row.start, row.end].some(
value => value.toLowerCase().includes(keyword)
)
)
})
const sequenceConfigGroups = computed<SequenceConfigGroup[]>(() => {
const groupMap = new Map<string, SequenceConfigRow[]>()
filteredSequenceRows.value.forEach(row => {
const groupRows = groupMap.get(row.topKey) || []
groupRows.push(row)
groupMap.set(row.topKey, groupRows)
})
return Array.from(groupMap.entries()).map(([topKey, rows]) => {
const typeMap = new Map<string, SequenceConfigRow[]>()
rows.forEach(row => {
const typeRows = typeMap.get(row.desc) || []
typeRows.push(row)
typeMap.set(row.desc, typeRows)
})
return {
topKey,
topDesc: rows[0]?.topDesc || '',
rowCount: rows.length,
typeGroups: Array.from(typeMap.entries()).map(([typeName, typeRows]) => ({
typeName,
rows: typeRows
}))
}
})
})
const handleExportCommand = (command: string | number | object) => {
if (command === 'json' || command === 'xml') {
emit('export-mapping', command)
}
}
const isRecord = (value: unknown): value is JsonObject => Boolean(value && typeof value === 'object' && !Array.isArray(value))
const toDisplayText = (value: unknown, fallback: string) => {
if (typeof value === 'string' && value.trim()) return value.trim()
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return fallback
}
const getPathText = (path: JsonPath) => (path.length ? path.map(item => String(item)).join(' / ') : '$')
const getTopKey = (path: JsonPath) => (path.length ? String(path[0]) : '$')
const resolveTopDesc = (source: unknown, path: JsonPath) => {
if (!path.length || !isRecord(source)) return ''
const topValue = source[String(path[0])]
return isRecord(topValue) ? toDisplayText(topValue.desc, '') : ''
}
const isConfigurableBaseflag = (value: unknown) => {
if (typeof value === 'number') return value === 1 || value === 2
if (typeof value !== 'string') return false
return ['1', '2'].includes(value.trim())
}
const collectSequenceRows = (source: unknown, path: JsonPath = [], parentDesc = '', rootSource = source): SequenceConfigRow[] => {
if (Array.isArray(source)) {
return source.flatMap((item, index) => collectSequenceRows(item, [...path, index], parentDesc, rootSource))
}
if (!isRecord(source)) return []
const currentDesc = toDisplayText(source.desc, '')
const nextParentDesc = currentDesc || parentDesc
const childrenRows = Object.entries(source).flatMap(([key, value]) =>
collectSequenceRows(value, [...path, key], nextParentDesc, rootSource)
)
const hasStart = Object.prototype.hasOwnProperty.call(source, 'start')
const hasEnd = Object.prototype.hasOwnProperty.call(source, 'end')
if (!hasStart || !hasEnd) return childrenRows
if (!isConfigurableBaseflag(source.baseflag)) return childrenRows
return [
{
id: path.length ? path.join('/') : '$',
path,
pathText: getPathText(path),
topKey: getTopKey(path),
topDesc: resolveTopDesc(rootSource, path),
parentDesc: parentDesc || '未配置上层描述',
desc: toDisplayText(source.desc, '未命名序列'),
name: toDisplayText(source.name, '未配置 name'),
baseflag: toDisplayText(source.baseflag, ''),
start: source.start === undefined || source.start === null ? '' : String(source.start),
end: source.end === undefined || source.end === null ? '' : String(source.end),
icdcout: toDisplayText(source.icdcout, ''),
startValueType: typeof source.start,
endValueType: typeof source.end
},
...childrenRows
]
}
const getObjectByPath = (source: unknown, path: JsonPath) => {
return path.reduce<unknown>((target, key) => {
if (!target || typeof target !== 'object') return undefined
return (target as Record<string | number, unknown>)[key]
}, source)
}
const normalizeSequenceValue = (value: string, valueType: string) => {
if (valueType !== 'number') return value
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) {
throw new Error('start 和 end 需要填写有效数字')
}
return numericValue
}
const openSequenceDialog = () => {
try {
const parsed = JSON.parse(props.mappingJsonPreview) as unknown
sequenceConfigRows.value = collectSequenceRows(parsed)
sequenceSearchKeyword.value = ''
sequenceDialogVisible.value = true
} catch {
ElMessage.warning('当前 JSON 映射内容无法解析,不能配置序列')
}
}
const confirmSequenceConfig = () => {
try {
const nextJson = JSON.parse(props.mappingJsonPreview) as unknown
sequenceConfigRows.value.forEach(row => {
const target = getObjectByPath(nextJson, row.path)
if (!isRecord(target)) return
// 关键业务节点:序列配置只回写 start/end避免弹窗编辑影响映射 JSON 的其他字段结构。
target.start = normalizeSequenceValue(row.start, row.startValueType)
target.end = normalizeSequenceValue(row.end, row.endValueType)
})
emit('update-mapping-json', JSON.stringify(nextJson, null, 4))
sequenceDialogVisible.value = false
ElMessage.success('序列配置已同步到 JSON 映射')
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : '序列配置同步失败')
}
}
</script>
<style scoped lang="scss">
.mapping-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
padding: 24px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.export-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.export-menu-button {
width: 32px;
padding: 8px;
}
.panel-title {
margin: 0;
font-size: 22px;
font-weight: 600;
line-height: 1.4;
color: #1f2937;
}
.panel-description {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.7;
color: #4b5563;
}
.panel-content {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
overflow: auto;
}
.panel-content--fixed {
overflow: hidden;
}
.panel-section {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.result-card {
padding: 16px;
}
.grow-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.preview-tab-section {
overflow: hidden;
}
.preview-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.preview-tabs :deep(.el-tabs__header) {
margin-bottom: 16px;
}
.preview-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: #e5e7eb;
}
.preview-tabs :deep(.el-tabs__item) {
height: 36px;
font-size: 15px;
font-weight: 600;
color: #4b5563;
}
.preview-tabs :deep(.el-tabs__item.is-active) {
color: #2563eb;
}
.preview-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
.preview-tabs :deep(.el-tab-pane) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.mapping-json-scroll {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: auto;
}
.mapping-json-text {
margin: 0;
padding: 16px;
border: 1px solid #dbe3f0;
border-radius: 10px;
background: #ffffff;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
color: #172033;
white-space: pre-wrap;
word-break: break-word;
}
.xml-file-viewer {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
border: 1px solid #dbe3f0;
border-radius: 10px;
background: #ffffff;
overflow: hidden;
}
.xml-file-header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 40px;
padding: 0 14px;
border-bottom: 1px solid #e5e7eb;
background: #f8fafc;
white-space: nowrap;
}
.xml-file-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.xml-file-meta {
min-width: 0;
overflow: hidden;
font-size: 13px;
color: #64748b;
text-overflow: ellipsis;
}
.xml-file-content {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
overflow: auto;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
color: #172033;
white-space: pre;
}
.problem-dialog-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 62vh;
padding-right: 4px;
overflow: auto;
}
.problem-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border: 1px solid #f3d19e;
border-radius: 10px;
background: #fff7ed;
}
.problem-index {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
width: 24px;
height: 24px;
border-radius: 999px;
background: #f97316;
font-size: 12px;
font-weight: 600;
color: #ffffff;
}
.problem-text {
flex: 1;
font-size: 14px;
line-height: 1.7;
color: #7c2d12;
word-break: break-word;
}
.match-result-actions {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
margin-bottom: 12px;
}
.match-result-detail {
max-height: 58vh;
padding: 14px 16px;
overflow: auto;
border: 1px solid #dbe3f0;
border-radius: 8px;
background: #f8fafc;
font-size: 14px;
line-height: 1.7;
color: #334155;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.dialog-search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.dialog-search-count {
flex: 0 0 auto;
font-size: 13px;
line-height: 1.6;
color: #64748b;
white-space: nowrap;
}
.sequence-config-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 62vh;
padding-right: 4px;
overflow: auto;
}
.sequence-top-group {
padding: 16px;
border: 1px solid #dbe3f0;
border-radius: 10px;
background: #f8fafc;
}
.sequence-top-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.sequence-top-title {
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
color: #1f2937;
}
.sequence-top-key {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.6;
color: #64748b;
word-break: break-all;
}
.sequence-type-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sequence-type-card {
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.sequence-type-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sequence-type-title {
min-width: 0;
overflow: hidden;
font-size: 15px;
font-weight: 600;
line-height: 1.5;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.sequence-config-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 16px;
padding: 8px 0;
border: 1px solid #dbe3f0;
border-width: 1px 0 0;
background: transparent;
}
.sequence-config-item:last-child {
padding-bottom: 0;
}
.sequence-config-info {
min-width: 0;
}
.sequence-config-title {
overflow: hidden;
font-size: 15px;
font-weight: 600;
line-height: 1.5;
color: #1f2937;
text-overflow: ellipsis;
white-space: nowrap;
}
.sequence-config-subtitle {
margin-top: 4px;
overflow: hidden;
font-size: 13px;
line-height: 1.6;
color: #64748b;
text-overflow: ellipsis;
white-space: nowrap;
}
.sequence-config-path {
margin-left: 8px;
color: #94a3b8;
}
.sequence-config-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.sequence-config-form :deep(.el-form-item) {
margin-bottom: 0;
}
.sequence-config-form :deep(.el-form-item__label) {
margin-bottom: 2px;
line-height: 18px;
}
@media (max-width: 768px) {
.mapping-panel {
padding: 20px;
}
.panel-header,
.panel-actions {
flex-direction: column;
align-items: flex-start;
}
.sequence-config-item {
grid-template-columns: 1fr;
}
.sequence-config-form {
grid-template-columns: 1fr;
}
}
</style>