feat(mmsmapping): 添加 XML 映射生成功能和波形标记功能

- 新增 getXmlFromJsonApi 接口用于从 JSON 生成 XML 映射
- 添加 XML 映射相关的数据结构定义和响应处理
- 实现 XML 映射生成功能,支持 JSON 到 XML 的转换
- 添加波形图表点击事件处理和标记功能
- 实现趋势图表的标记点显示和标签功能
- 更新界面以支持 XML 映射预览和导出
- 优化图表交互体验,添加标记工具模式
- 重构部分界面组件以支持新的映射功能
This commit is contained in:
2026-05-08 09:54:52 +08:00
parent fe3ab1f679
commit a1e1fb124a
16 changed files with 1821 additions and 210 deletions

View File

@@ -0,0 +1,196 @@
<template>
<div class="json-mapping-tree-viewer">
<div class="json-tree-toolbar">
<div class="json-tree-meta">{{ metaText }}</div>
<div class="json-tree-actions">
<slot name="actions" />
<el-button type="primary" plain size="small" :disabled="!rootNode" @click="expandAll">全部展开</el-button>
<el-button plain size="small" :disabled="!rootNode" @click="collapseAll">全部收起</el-button>
</div>
</div>
<div v-if="rootNode" class="json-tree-body">
<JsonTreeNode :node="rootNode" :depth="0" :is-last="true" :expanded-keys="expandedKeys" @toggle="toggleNode" />
</div>
<pre v-else class="mapping-json-text">{{ source }}</pre>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import JsonTreeNode, { type JsonTreeNodeModel, type JsonValueType } from './JsonMappingTreeNode.vue'
defineOptions({
name: 'JsonMappingTree'
})
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
const props = defineProps<{
source: string
metaText?: string
}>()
const expandedKeys = ref<Set<string>>(new Set())
const parsedJson = computed<{ valid: true; value: JsonValue } | { valid: false }>(() => {
try {
return {
valid: true,
value: JSON.parse(props.source) as JsonValue
}
} catch {
return {
valid: false
}
}
})
const rootNode = computed(() => {
if (!parsedJson.value.valid) return null
return buildJsonNode(undefined, parsedJson.value.value, '$')
})
watch(
rootNode,
node => {
expandedKeys.value = new Set(node ? collectContainerKeys(node) : [])
},
{ immediate: true }
)
function buildJsonNode(keyName: string | undefined, source: JsonValue, path: string): JsonTreeNodeModel {
if (Array.isArray(source)) {
return {
id: path,
keyName,
openToken: '[',
closeToken: ']',
summary: ` ${source.length}`,
children: source.map((item, index) => buildJsonNode(undefined, item, `${path}/${index}`))
}
}
if (source && typeof source === 'object') {
const entries = Object.entries(source)
return {
id: path,
keyName,
openToken: '{',
closeToken: '}',
summary: ` ${entries.length}`,
children: entries.map(([key, value]) => buildJsonNode(key, value, `${path}/${escapePathKey(key)}`))
}
}
return {
id: path,
keyName,
valueText: formatPrimitiveValue(source),
valueType: getPrimitiveType(source)
}
}
function collectContainerKeys(node: JsonTreeNodeModel): string[] {
if (!node.children) return []
return [node.id, ...node.children.flatMap(collectContainerKeys)]
}
function escapePathKey(key: string) {
return key.replace(/~/g, '~0').replace(/\//g, '~1')
}
function formatPrimitiveValue(source: JsonValue) {
if (typeof source === 'string') return JSON.stringify(source)
if (source === null) return 'null'
return String(source)
}
function getPrimitiveType(source: JsonValue): JsonValueType {
if (source === null) return 'null'
if (typeof source === 'boolean') return 'boolean'
if (typeof source === 'number') return 'number'
return 'string'
}
function toggleNode(id: string) {
const nextKeys = new Set(expandedKeys.value)
if (nextKeys.has(id)) {
nextKeys.delete(id)
} else {
nextKeys.add(id)
}
expandedKeys.value = nextKeys
}
function expandAll() {
if (!rootNode.value) return
expandedKeys.value = new Set(collectContainerKeys(rootNode.value))
}
function collapseAll() {
expandedKeys.value = new Set()
}
</script>
<style scoped lang="scss">
.json-mapping-tree-viewer {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.json-tree-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 28px;
gap: 8px;
margin-bottom: 8px;
white-space: nowrap;
}
.json-tree-meta {
flex: 1;
min-width: 0;
overflow: hidden;
color: #64748b;
font-size: 13px;
text-overflow: ellipsis;
}
.json-tree-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.json-tree-body,
.mapping-json-text {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
border: 1px solid #dbe3f0;
border-radius: 10px;
background: #ffffff;
overflow: auto;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
color: #172033;
}
.mapping-json-text {
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<div v-if="!node.children" class="json-tree-line" :style="indentStyle">
<span class="json-tree-spacer" />
<template v-if="node.keyName !== undefined">
<span class="json-tree-key">{{ JSON.stringify(node.keyName) }}</span>
<span class="json-tree-colon">: </span>
</template>
<span :class="['json-tree-value', `is-${node.valueType}`]">{{ node.valueText }}</span>
<span v-if="!isLast" class="json-tree-comma">,</span>
</div>
<div v-else class="json-tree-node">
<div class="json-tree-line is-container" :style="indentStyle">
<button class="json-tree-toggle" type="button" :title="isExpanded ? '收起' : '展开'" @click="emit('toggle', node.id)">
{{ isExpanded ? '' : '' }}
</button>
<template v-if="node.keyName !== undefined">
<span class="json-tree-key">{{ JSON.stringify(node.keyName) }}</span>
<span class="json-tree-colon">: </span>
</template>
<span class="json-tree-token">{{ node.openToken }}</span>
<span v-if="!isExpanded" class="json-tree-ellipsis"> ... </span>
<span v-if="!isExpanded" class="json-tree-token">{{ node.closeToken }}</span>
<span class="json-tree-summary">{{ node.summary }}</span>
<span v-if="!isExpanded && !isLast" class="json-tree-comma">,</span>
</div>
<template v-if="isExpanded">
<JsonMappingTreeNode
v-for="(child, index) in node.children"
:key="child.id"
:node="child"
:depth="depth + 1"
:is-last="index === node.children.length - 1"
:expanded-keys="expandedKeys"
@toggle="emit('toggle', $event)"
/>
<div class="json-tree-line is-close" :style="indentStyle">
<span class="json-tree-spacer" />
<span class="json-tree-token">{{ node.closeToken }}</span>
<span v-if="!isLast" class="json-tree-comma">,</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({
name: 'JsonMappingTreeNode'
})
export type JsonValueType = 'string' | 'number' | 'boolean' | 'null'
export interface JsonTreeNodeModel {
id: string
keyName?: string
openToken?: '{' | '['
closeToken?: '}' | ']'
summary?: string
children?: JsonTreeNodeModel[]
valueText?: string
valueType?: JsonValueType
}
const props = defineProps<{
node: JsonTreeNodeModel
depth: number
isLast: boolean
expandedKeys: Set<string>
}>()
const emit = defineEmits<{
(event: 'toggle', id: string): void
}>()
const isExpanded = computed(() => props.expandedKeys.has(props.node.id))
const indentStyle = computed(() => ({
paddingLeft: `${props.depth * 18}px`
}))
</script>
<style scoped lang="scss">
.json-tree-line {
display: flex;
align-items: flex-start;
min-height: 24px;
white-space: pre;
}
.json-tree-line.is-container {
color: #172033;
}
.json-tree-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 18px;
width: 18px;
height: 22px;
margin: 0 2px 0 0;
padding: 0;
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font-family: inherit;
font-size: 15px;
line-height: 1;
}
.json-tree-toggle:hover {
color: #2563eb;
}
.json-tree-spacer {
flex: 0 0 20px;
width: 20px;
}
.json-tree-key {
color: #7c3aed;
}
.json-tree-colon,
.json-tree-comma,
.json-tree-token {
color: #334155;
}
.json-tree-ellipsis,
.json-tree-summary {
color: #94a3b8;
}
.json-tree-value.is-string {
color: #047857;
}
.json-tree-value.is-number {
color: #b45309;
}
.json-tree-value.is-boolean {
color: #2563eb;
}
.json-tree-value.is-null {
color: #64748b;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<el-dialog
:model-value="visible"
title="人工确认索引配置"
title="人工索引配置"
width="960px"
destroy-on-close
top="6vh"
@@ -10,107 +10,122 @@
>
<div class="dialog-description">
这里展示 ICD 候选索引的人工确认结果请按分组确认每个标签是否启用并为已启用标签选择合法的
lnInst确认后会自动回填到 request.indexSelection
lnInst确认后会自动回填到索引配置
</div>
<el-empty v-if="!draftGroups.length" description="当前没有可确认的索引分组。" />
<div v-else class="dialog-content">
<section v-for="group in draftGroups" :key="group.groupKey" class="group-card">
<div class="group-header">
<div>
<h3 class="group-title">{{ group.groupDesc || group.groupKey }}</h3>
<p class="group-key">{{ group.groupKey }}</p>
<template v-else>
<div class="dialog-search-bar">
<el-input
v-model="indexSearchKeyword"
:prefix-icon="Search"
clearable
placeholder="按分组、标签、目标报告、数据集或 lnInst 检索"
/>
<span class="dialog-search-count">{{ filteredLabelCount }} / {{ totalLabelCount }}</span>
</div>
<div v-if="filteredDraftGroups.length" class="dialog-content">
<section v-for="group in filteredDraftGroups" :key="group.groupKey" class="group-card">
<div class="group-header">
<div>
<h3 class="group-title">{{ group.groupDesc || group.groupKey }}</h3>
<p class="group-key">{{ group.groupKey }}</p>
</div>
<el-tag type="info" effect="light">{{ group.labelItems.length }} 个标签</el-tag>
</div>
<el-tag type="info" effect="light">{{ group.labelItems.length }} 个标签</el-tag>
</div>
<div class="label-list">
<article v-for="item in group.labelItems" :key="item.itemKey" class="label-card">
<div class="label-main">
<div class="label-meta">
<div class="label-title-row">
<span class="label-title">{{ item.label }}</span>
<el-tag v-if="item.required" type="danger" effect="light" size="small">必选</el-tag>
<el-tag v-if="item.configurableOnce" type="success" effect="light" size="small">
共享 lnInst
</el-tag>
</div>
<div class="label-options">
<span class="label-hint">共同可选值</span>
<template v-if="item.commonLnInstValues.length">
<el-tag
v-for="value in item.commonLnInstValues"
:key="`${item.label}-${value}`"
size="small"
effect="plain"
>
{{ value }}
<div class="label-list">
<article v-for="item in group.labelItems" :key="item.itemKey" class="label-card">
<div class="label-main">
<div class="label-meta">
<div class="label-title-row">
<span class="label-title">{{ item.label }}</span>
<el-tag v-if="item.required" type="danger" effect="light" size="small">必选</el-tag>
<el-tag v-if="item.configurableOnce" type="success" effect="light" size="small">
共享 lnInst
</el-tag>
</template>
<span v-else class="label-hint">当前没有共同 lnInst</span>
</div>
<div class="label-options">
<span class="label-hint">共同可选值</span>
<template v-if="item.commonLnInstValues.length">
<el-tag
v-for="value in item.commonLnInstValues"
:key="`${item.label}-${value}`"
size="small"
effect="plain"
>
{{ value }}
</el-tag>
</template>
<span v-else class="label-hint">当前没有共同 lnInst</span>
</div>
</div>
</div>
<div class="label-actions">
<el-switch
v-model="item.enabled"
:disabled="item.required"
inline-prompt
active-text="启用"
inactive-text="停用"
/>
<el-select
v-model="item.lnInst"
class="lninst-select"
placeholder="请选择 lnInst"
clearable
:disabled="!item.enabled || !item.commonLnInstValues.length"
>
<el-option
v-for="value in item.commonLnInstValues"
:key="`${item.label}-option-${value}`"
:label="value"
:value="value"
<div class="label-actions">
<el-switch
v-model="item.enabled"
:disabled="item.required"
inline-prompt
active-text="启用"
inactive-text="停用"
/>
</el-select>
</div>
</div>
<el-alert
v-if="item.enabled && !item.lnInst"
title="已启用的标签必须选择 lnInst"
type="warning"
:closable="false"
class="label-alert"
/>
<div class="target-list">
<div v-for="target in item.targets" :key="target.targetKey" class="target-item">
<div class="target-name-row">
<span class="target-name">{{ target.reportDesc || target.reportName || '--' }}</span>
<span class="target-code">{{ target.reportName || '--' }} / {{ target.dataSetName || '--' }}</span>
</div>
<div class="target-lninst-row">
<span class="label-hint">目标报告可选值</span>
<template v-if="target.availableLnInstValues.length">
<el-tag
v-for="value in target.availableLnInstValues"
:key="`${target.reportName}-${target.dataSetName}-${value}`"
size="small"
effect="plain"
>
{{ value }}
</el-tag>
</template>
<span v-else class="label-hint">当前没有可用 lnInst</span>
<el-select
v-model="item.lnInst"
class="lninst-select"
placeholder="请选择 lnInst"
clearable
:disabled="!item.enabled || !item.commonLnInstValues.length"
>
<el-option
v-for="value in item.commonLnInstValues"
:key="`${item.label}-option-${value}`"
:label="value"
:value="value"
/>
</el-select>
</div>
</div>
</div>
</article>
</div>
</section>
</div>
<el-alert
v-if="item.enabled && !item.lnInst"
title="已启用的标签必须选择 lnInst"
type="warning"
:closable="false"
class="label-alert"
/>
<div class="target-list">
<div v-for="target in item.targets" :key="target.targetKey" class="target-item">
<div class="target-name-row">
<span class="target-name">{{ target.reportDesc || target.reportName || '--' }}</span>
<span class="target-code">
{{ target.reportName || '--' }} / {{ target.dataSetName || '--' }}
</span>
</div>
<div class="target-lninst-row">
<span class="label-hint">目标报告可选值</span>
<template v-if="target.availableLnInstValues.length">
<el-tag
v-for="value in target.availableLnInstValues"
:key="`${target.reportName}-${target.dataSetName}-${value}`"
size="small"
effect="plain"
>
{{ value }}
</el-tag>
</template>
<span v-else class="label-hint">当前没有可用 lnInst</span>
</div>
</div>
</div>
</article>
</div>
</section>
</div>
<el-empty v-else description="当前检索条件下没有匹配的索引配置。" />
</template>
<template #footer>
<div class="dialog-footer">
@@ -127,6 +142,7 @@
</template>
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue'
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
@@ -300,6 +316,45 @@ const buildInitialDraftGroups = (groups: MmsMapping.IndexConfirmGroup[]): Confir
.filter(group => group.groupKey)
const draftGroups = ref<ConfirmDialogDraftGroup[]>([])
const indexSearchKeyword = ref('')
const totalLabelCount = computed(() => draftGroups.value.reduce((total, group) => total + group.labelItems.length, 0))
const normalizedIndexSearchKeyword = computed(() => indexSearchKeyword.value.trim().toLowerCase())
const matchText = (values: string[], keyword: string) => values.some(value => value.toLowerCase().includes(keyword))
const isLabelItemMatched = (item: ConfirmDialogDraftLabelItem, keyword: string) =>
matchText([item.label, item.lnInst, ...item.commonLnInstValues], keyword) ||
item.targets.some(target =>
matchText(
[target.reportDesc, target.reportName, target.dataSetName, ...target.availableLnInstValues],
keyword
)
)
const filteredDraftGroups = computed<ConfirmDialogDraftGroup[]>(() => {
const keyword = normalizedIndexSearchKeyword.value
if (!keyword) return draftGroups.value
return draftGroups.value
.map(group => {
const groupMatched = matchText([group.groupDesc, group.groupKey], keyword)
return {
...group,
labelItems: groupMatched
? group.labelItems
: group.labelItems.filter(item => isLabelItemMatched(item, keyword))
}
})
.filter(group => group.labelItems.length)
})
const filteredLabelCount = computed(() =>
filteredDraftGroups.value.reduce((total, group) => total + group.labelItems.length, 0)
)
watch(
() => [props.confirmData, props.visible] as const,
@@ -307,6 +362,7 @@ watch(
if (!visible) return
// 关键业务节点:弹窗每次打开都基于最新 confirmData 重新生成草稿,避免不同 ICD 的确认状态串用。
draftGroups.value = buildInitialDraftGroups(confirmData)
indexSearchKeyword.value = ''
},
{ immediate: true }
)
@@ -346,6 +402,21 @@ const handleConfirm = () => {
color: #4b5563;
}
.dialog-search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.dialog-search-count {
flex: 0 0 auto;
font-size: 13px;
line-height: 1.6;
color: #64748b;
white-space: nowrap;
}
.dialog-content {
display: flex;
flex-direction: column;
@@ -526,6 +597,7 @@ const handleConfirm = () => {
.group-header,
.label-main,
.dialog-search-bar,
.dialog-footer {
flex-direction: column;
align-items: flex-start;