feat(mmsmapping): 添加 XML 映射生成功能和波形标记功能
- 新增 getXmlFromJsonApi 接口用于从 JSON 生成 XML 映射 - 添加 XML 映射相关的数据结构定义和响应处理 - 实现 XML 映射生成功能,支持 JSON 到 XML 的转换 - 添加波形图表点击事件处理和标记功能 - 实现趋势图表的标记点显示和标签功能 - 更新界面以支持 XML 映射预览和导出 - 优化图表交互体验,添加标记工具模式 - 重构部分界面组件以支持新的映射功能
This commit is contained in:
@@ -30,6 +30,17 @@ export const getIcdMmsJsonApi = (params: MmsMapping.GetIcdMmsJsonParams) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const getXmlFromJsonApi = (params: MmsMapping.GetXmlFromJsonParams) => {
|
||||
const formData = new FormData()
|
||||
|
||||
// 关键业务节点:XML 映射由后端根据已生成的 mappingJson 转换,前端保持 request JSON Part 的提交格式。
|
||||
formData.append('request', new Blob([JSON.stringify(params.request)], { type: 'application/json' }))
|
||||
|
||||
return http.post<MmsMapping.MappingTaskResponse>('/api/mms-mapping/get-xml-from-json', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
export const buildIndexConfirmDataApi = (params: MmsMapping.IndexCandidateGroup[]) => {
|
||||
// 关键业务节点:ICD 候选数据需要先转换成前端确认弹窗模型,后续人工确认才能继续生成正式索引配置。
|
||||
return http.post<MmsMapping.IndexConfirmGroup[]>('/api/mms-mapping/build-index-confirm-data', params)
|
||||
|
||||
@@ -63,15 +63,34 @@ export namespace MmsMapping {
|
||||
request: GetIcdMmsJsonRequestPayload
|
||||
}
|
||||
|
||||
export interface GetXmlFromJsonRequestPayload {
|
||||
mappingJson: string
|
||||
}
|
||||
|
||||
export interface GetXmlFromJsonParams {
|
||||
request: GetXmlFromJsonRequestPayload
|
||||
}
|
||||
|
||||
export interface BuildIndexSelectionRequest {
|
||||
confirmData: IndexConfirmGroup[]
|
||||
confirmedData: ConfirmedIndexGroup[]
|
||||
}
|
||||
|
||||
export interface XmlFileResponse {
|
||||
fileName?: string
|
||||
contentType?: string
|
||||
encoding?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface IcdDocument {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MappingDocument {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface IndexCandidateReport {
|
||||
reportName?: string
|
||||
dataSetName?: string
|
||||
@@ -92,8 +111,14 @@ export namespace MmsMapping {
|
||||
export interface MappingTaskResponse {
|
||||
status?: MappingTaskStatus
|
||||
message?: string
|
||||
methodDescribe?: string
|
||||
icdDocument?: IcdDocument
|
||||
mappingDocument?: MappingDocument
|
||||
mappingJson?: string
|
||||
mappingXml?: string
|
||||
xmlContent?: string
|
||||
xmlText?: string
|
||||
xmlFile?: XmlFileResponse
|
||||
savedPath?: string
|
||||
indexCandidates?: IndexCandidateGroup[]
|
||||
problems?: string[]
|
||||
|
||||
@@ -40,12 +40,68 @@ const emit = defineEmits<{
|
||||
end: number
|
||||
}
|
||||
]
|
||||
'chart-click': [
|
||||
value: {
|
||||
dataIndex: number
|
||||
axisValue: string | number
|
||||
}
|
||||
]
|
||||
}>()
|
||||
let chart: echarts.ECharts | any = null
|
||||
let isPanPointerDown = false
|
||||
|
||||
const getChartViewportRoot = () => chart?.getZr()?.painter?.getViewportRoot?.() as HTMLElement | undefined
|
||||
|
||||
const getAxisPixel = (dataIndex: number) => {
|
||||
const pixelValue = chart?.convertToPixel?.({ xAxisIndex: 0 }, dataIndex)
|
||||
|
||||
if (Array.isArray(pixelValue)) return Number(pixelValue[0])
|
||||
|
||||
return Number(pixelValue)
|
||||
}
|
||||
|
||||
const getClosestAxisDataIndex = (axisValue: unknown, offsetX: number) => {
|
||||
const xAxisData = props.options?.xAxis?.data
|
||||
|
||||
if (!Array.isArray(xAxisData) || !xAxisData.length) return -1
|
||||
|
||||
const candidateIndexes = new Set<number>()
|
||||
const axisNumber = Number(axisValue)
|
||||
const directIndex = xAxisData.findIndex((item: unknown) => item === axisValue)
|
||||
|
||||
if (Number.isFinite(axisNumber)) {
|
||||
candidateIndexes.add(Math.round(axisNumber))
|
||||
}
|
||||
|
||||
if (directIndex >= 0) {
|
||||
candidateIndexes.add(directIndex)
|
||||
}
|
||||
|
||||
xAxisData.forEach((item: unknown, index: number) => {
|
||||
if (String(item) === String(axisValue)) {
|
||||
candidateIndexes.add(index)
|
||||
}
|
||||
})
|
||||
|
||||
const validCandidates = Array.from(candidateIndexes).filter(index => index >= 0 && index < xAxisData.length)
|
||||
|
||||
if (validCandidates.length) {
|
||||
return validCandidates.reduce((closestIndex, currentIndex) => {
|
||||
const closestDistance = Math.abs(getAxisPixel(closestIndex) - offsetX)
|
||||
const currentDistance = Math.abs(getAxisPixel(currentIndex) - offsetX)
|
||||
|
||||
return currentDistance < closestDistance ? currentIndex : closestIndex
|
||||
}, validCandidates[0])
|
||||
}
|
||||
|
||||
return xAxisData.reduce((closestIndex: number, _item: unknown, currentIndex: number) => {
|
||||
const closestDistance = Math.abs(getAxisPixel(closestIndex) - offsetX)
|
||||
const currentDistance = Math.abs(getAxisPixel(currentIndex) - offsetX)
|
||||
|
||||
return currentDistance < closestDistance ? currentIndex : closestIndex
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const resetChartCursor = () => {
|
||||
const viewportRoot = getChartViewportRoot()
|
||||
if (viewportRoot) viewportRoot.style.cursor = ''
|
||||
@@ -55,14 +111,20 @@ const resetChartCursor = () => {
|
||||
const updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
|
||||
const viewportRoot = getChartViewportRoot()
|
||||
|
||||
if (!viewportRoot || props.options?.activeTool !== 'pan') {
|
||||
if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) {
|
||||
resetChartCursor()
|
||||
return
|
||||
}
|
||||
|
||||
// 平移只在图形绘图区内生效,鼠标样式同步限制到同一范围,避免坐标轴和空白区误导操作。
|
||||
const isInGrid = chart?.containPixel?.({ gridIndex: 0 }, [event.offsetX, event.offsetY])
|
||||
viewportRoot.style.cursor = isInGrid ? (isPanPointerDown ? 'grabbing' : 'grab') : ''
|
||||
viewportRoot.style.cursor = isInGrid
|
||||
? props.options?.activeTool === 'mark'
|
||||
? 'crosshair'
|
||||
: isPanPointerDown
|
||||
? 'grabbing'
|
||||
: 'grab'
|
||||
: ''
|
||||
}
|
||||
|
||||
const bindPanCursorEvents = () => {
|
||||
@@ -73,10 +135,12 @@ const bindPanCursorEvents = () => {
|
||||
zr.off('mousedown', handlePanCursorMouseDown)
|
||||
zr.off('mouseup', handlePanCursorMouseUp)
|
||||
zr.off('globalout', resetChartCursor)
|
||||
zr.off('click', handleChartClick)
|
||||
zr.on('mousemove', updatePanCursor)
|
||||
zr.on('mousedown', handlePanCursorMouseDown)
|
||||
zr.on('mouseup', handlePanCursorMouseUp)
|
||||
zr.on('globalout', resetChartCursor)
|
||||
zr.on('click', handleChartClick)
|
||||
}
|
||||
|
||||
const unbindPanCursorEvents = () => {
|
||||
@@ -87,9 +151,34 @@ const unbindPanCursorEvents = () => {
|
||||
zr.off('mousedown', handlePanCursorMouseDown)
|
||||
zr.off('mouseup', handlePanCursorMouseUp)
|
||||
zr.off('globalout', resetChartCursor)
|
||||
zr.off('click', handleChartClick)
|
||||
resetChartCursor()
|
||||
}
|
||||
|
||||
function handleChartClick(params: any) {
|
||||
if (props.options?.activeTool !== 'mark' || !chart) return
|
||||
|
||||
const event = params?.event?.event || params?.event || params
|
||||
const offsetX = Number(event?.offsetX)
|
||||
const offsetY = Number(event?.offsetY)
|
||||
|
||||
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return
|
||||
if (!chart.containPixel?.({ gridIndex: 0 }, [offsetX, offsetY])) return
|
||||
|
||||
const convertedValue = chart.convertFromPixel?.({ xAxisIndex: 0 }, [offsetX, offsetY])
|
||||
const rawAxisValue = Array.isArray(convertedValue) ? convertedValue[0] : convertedValue
|
||||
const xAxisData = props.options?.xAxis?.data
|
||||
const dataIndex = getClosestAxisDataIndex(rawAxisValue, offsetX)
|
||||
const axisValue = Array.isArray(xAxisData) ? xAxisData[dataIndex] : dataIndex
|
||||
|
||||
if (!Number.isInteger(dataIndex) || dataIndex < 0 || axisValue === undefined) return
|
||||
|
||||
emit('chart-click', {
|
||||
dataIndex,
|
||||
axisValue
|
||||
})
|
||||
}
|
||||
|
||||
function handlePanCursorMouseDown(event: { offsetX: number; offsetY: number }) {
|
||||
isPanPointerDown = true
|
||||
updatePanCursor(event)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
<MappingRequestPanel
|
||||
:selected-icd-file-name="selectedIcdFileName"
|
||||
:is-submitting="isSubmitting"
|
||||
:is-parsing="isParsing"
|
||||
:icd-file-accept="icdFileAccept"
|
||||
:request-status-text="requestStatusText"
|
||||
:request-status-tag-type="requestStatusTagType"
|
||||
:can-parse="canParseIcd"
|
||||
:can-reset="canResetPage"
|
||||
@file-change="handleIcdFileChange"
|
||||
@parse="handleParseIcd"
|
||||
@@ -18,11 +20,12 @@
|
||||
v-if="showConfigPanel"
|
||||
v-model:index-selection-json="indexSelectionJsonText"
|
||||
:is-submitting="isSubmitting"
|
||||
:is-generating="isGenerating"
|
||||
:can-generate="canGenerate"
|
||||
:json-error="indexSelectionError"
|
||||
:show-generate-button="showGenerateButton"
|
||||
:show-confirm-button="Boolean(confirmData.length)"
|
||||
:confirm-button-text="indexSelectionJsonText.trim() ? '重新确认' : '人工确认'"
|
||||
confirm-button-text="人工索引配置"
|
||||
:can-confirm="!isSubmitting"
|
||||
:has-default-json="Boolean(indexSelectionJsonText.trim())"
|
||||
:empty-description="configEmptyDescription"
|
||||
@@ -37,11 +40,21 @@
|
||||
:response-status-tag-type="responseStatusTagType"
|
||||
:mapping-meta-text="mappingMetaText"
|
||||
:mapping-json-preview="mappingJsonPreview"
|
||||
:xml-meta-text="xmlMetaText"
|
||||
:xml-mapping-preview="xmlMappingPreview"
|
||||
:xml-empty-text="xmlEmptyText"
|
||||
:problem-tab-label="problemTabLabel"
|
||||
:problem-list="problemList"
|
||||
:problem-empty-text="problemEmptyText"
|
||||
:can-export-mapping="Boolean(mappingJsonPreview)"
|
||||
:method-describe="methodDescribe"
|
||||
:can-export-json-mapping="canExportJsonMapping"
|
||||
:can-export-xml-mapping="canExportXmlMapping"
|
||||
:can-generate-xml-mapping="canGenerateXmlMapping"
|
||||
:is-generating-xml="isGeneratingXml"
|
||||
:show-xml-mapping-tab="showXmlMappingTab"
|
||||
@export-mapping="handleExportMapping"
|
||||
@generate-xml-mapping="handleGenerateXmlMapping"
|
||||
@update-mapping-json="handleUpdateMappingJson"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +76,8 @@ import {
|
||||
buildIndexConfirmDataApi,
|
||||
buildIndexSelectionApi,
|
||||
getIcdApi,
|
||||
getIcdMmsJsonApi
|
||||
getIcdMmsJsonApi,
|
||||
getXmlFromJsonApi
|
||||
} from '@/api/tools/mmsmapping'
|
||||
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
|
||||
import MappingRequestPanel from './components/MappingRequestPanel.vue'
|
||||
@@ -77,8 +91,9 @@ defineOptions({
|
||||
name: 'MmsMappingView'
|
||||
})
|
||||
|
||||
type ResultTab = 'mapping' | 'problem'
|
||||
type ResultTab = 'json' | 'xml' | 'problem'
|
||||
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
|
||||
type ExportMappingType = 'json' | 'xml'
|
||||
|
||||
const DEFAULT_REQUEST_FORM: MmsMapping.BaseRequestForm = {
|
||||
version: '1.0',
|
||||
@@ -87,7 +102,8 @@ const DEFAULT_REQUEST_FORM: MmsMapping.BaseRequestForm = {
|
||||
|
||||
const selectedIcdFile = ref<File | null>(null)
|
||||
const responsePayload = ref<MmsMapping.MappingTaskResponse | null>(null)
|
||||
const activeResultTab = ref<ResultTab>('mapping')
|
||||
const xmlResponsePayload = ref<MmsMapping.MappingTaskResponse | null>(null)
|
||||
const activeResultTab = ref<ResultTab>('json')
|
||||
const parsedCandidates = ref<MmsMapping.IndexCandidateGroup[]>([])
|
||||
const confirmData = ref<MmsMapping.IndexConfirmGroup[]>([])
|
||||
const indexSelectionJsonText = ref('')
|
||||
@@ -95,6 +111,7 @@ const confirmDialogVisible = ref(false)
|
||||
const isParsing = ref(false)
|
||||
const isConfirmingSelection = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const isGeneratingXml = ref(false)
|
||||
const icdFileAccept = '.icd,.cid,.scd,.xml'
|
||||
const problemEmptyText = '当前返回未包含 problems'
|
||||
|
||||
@@ -128,8 +145,8 @@ const logConfirmDataDiagnostics = (groups: MmsMapping.IndexConfirmGroup[]) => {
|
||||
}))
|
||||
}))
|
||||
.filter(item =>
|
||||
[item.label, ...item.commonLnInstValues, ...item.targets.map(target => target.dataSetName)].some(value =>
|
||||
String(value || '').includes('间谐波')
|
||||
[item.label, ...item.commonLnInstValues, ...item.targets.map(target => target.dataSetName)].some(
|
||||
value => String(value || '').includes('间谐波')
|
||||
)
|
||||
)
|
||||
}))
|
||||
@@ -184,15 +201,22 @@ const parsedIndexSelectionState = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isSubmitting = computed(() => isParsing.value || isConfirmingSelection.value || isGenerating.value)
|
||||
const isSubmitting = computed(
|
||||
() => isParsing.value || isConfirmingSelection.value || isGenerating.value || isGeneratingXml.value
|
||||
)
|
||||
const canResetPage = computed(() =>
|
||||
Boolean(
|
||||
selectedIcdFile.value || responsePayload.value || confirmData.value.length || indexSelectionJsonText.value.trim()
|
||||
selectedIcdFile.value ||
|
||||
responsePayload.value ||
|
||||
xmlResponsePayload.value ||
|
||||
confirmData.value.length ||
|
||||
indexSelectionJsonText.value.trim()
|
||||
)
|
||||
)
|
||||
const indexSelectionError = computed(() => parsedIndexSelectionState.value.error)
|
||||
const canGenerate = computed(
|
||||
() => Boolean(selectedIcdFile.value && indexSelectionJsonText.value.trim() && !indexSelectionError.value)
|
||||
const canParseIcd = computed(() => Boolean(selectedIcdFile.value && !isSubmitting.value))
|
||||
const canGenerate = computed(() =>
|
||||
Boolean(selectedIcdFile.value && indexSelectionJsonText.value.trim() && !indexSelectionError.value && !isSubmitting.value)
|
||||
)
|
||||
// 关键业务节点:请求配置区只在用户已经选择 ICD 后展示,避免初始态暴露无效的请求编辑区。
|
||||
const showConfigPanel = computed(() => Boolean(selectedIcdFile.value))
|
||||
@@ -201,17 +225,18 @@ const selectedIcdFileName = computed(() => selectedIcdFile.value?.name || '')
|
||||
|
||||
const configEmptyDescription = computed(() => {
|
||||
if (isParsing.value) return '正在获取 ICD 候选数据并准备人工确认,请稍候。'
|
||||
if (isConfirmingSelection.value) return '正在根据人工确认结果生成 request.indexSelection,请稍候。'
|
||||
if (isConfirmingSelection.value) return '正在根据人工索引配置生成索引配置,请稍候。'
|
||||
if (confirmDialogVisible.value || confirmData.value.length) {
|
||||
return '请先在弹窗中完成人工确认,确认后会自动回填 request.indexSelection。'
|
||||
return '请先在弹窗中完成人工索引配置,确认后会自动回填索引配置。'
|
||||
}
|
||||
if (selectedIcdFile.value) return '已选择 ICD 文件,请先点击“解析 ICD”进入人工确认流程。'
|
||||
return '当前 ICD 暂未生成可编辑的 request.indexSelection。'
|
||||
return '当前 ICD 暂未生成可编辑的索引配置。'
|
||||
})
|
||||
|
||||
const requestStatusText = computed(() => {
|
||||
if (isParsing.value) return '解析中'
|
||||
if (isConfirmingSelection.value) return '确认中'
|
||||
if (isGeneratingXml.value) return 'XML转换中'
|
||||
if (isGenerating.value) return '生成中'
|
||||
if (confirmDialogVisible.value) return '待人工确认'
|
||||
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return '已确认'
|
||||
@@ -221,7 +246,7 @@ const requestStatusText = computed(() => {
|
||||
})
|
||||
|
||||
const requestStatusTagType = computed<TagType>(() => {
|
||||
if (isParsing.value || isConfirmingSelection.value || isGenerating.value) return 'warning'
|
||||
if (isParsing.value || isConfirmingSelection.value || isGenerating.value || isGeneratingXml.value) return 'warning'
|
||||
if (confirmDialogVisible.value) return 'primary'
|
||||
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return 'success'
|
||||
if (selectedIcdFile.value) return 'primary'
|
||||
@@ -229,15 +254,24 @@ const requestStatusTagType = computed<TagType>(() => {
|
||||
})
|
||||
|
||||
const responseStatusText = computed(() => {
|
||||
if (isSubmitting.value) return '等待返回'
|
||||
return responsePayload.value?.status || '暂无结果'
|
||||
if (isGenerating.value) return 'JSON生成中'
|
||||
if (isGeneratingXml.value) return 'XML生成中'
|
||||
if (isParsing.value || isConfirmingSelection.value) return '处理中'
|
||||
if (xmlResponsePayload.value?.status === 'FAILED') return 'XML失败'
|
||||
if (responsePayload.value?.status === 'FAILED') return '失败'
|
||||
if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return '待配置'
|
||||
if (xmlResponsePayload.value) return 'XML已生成'
|
||||
if (mappingJsonPreview.value) return 'JSON已生成'
|
||||
if (responsePayload.value) return '已解析'
|
||||
return '未生成'
|
||||
})
|
||||
|
||||
const responseStatusTagType = computed<TagType>(() => {
|
||||
if (isSubmitting.value) return 'warning'
|
||||
if (xmlResponsePayload.value?.status === 'FAILED') return 'danger'
|
||||
if (responsePayload.value?.status === 'FAILED') return 'danger'
|
||||
if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return 'warning'
|
||||
if (responsePayload.value) return 'success'
|
||||
if (xmlResponsePayload.value || responsePayload.value) return 'success'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
@@ -258,7 +292,64 @@ const mappingMetaText = computed(() => {
|
||||
return `mappingJson ${mappingJsonPreview.value.length} 字符`
|
||||
})
|
||||
|
||||
const problemList = computed(() => responsePayload.value?.problems?.filter(Boolean) || [])
|
||||
// 关键业务节点:getXmlFromJson 标准响应把 XML 文本放在 xmlFile.content,旧字段仅保留为兼容兜底。
|
||||
const xmlContentForExport = computed(
|
||||
() =>
|
||||
xmlResponsePayload.value?.xmlFile?.content?.trim() ||
|
||||
xmlResponsePayload.value?.mappingXml?.trim() ||
|
||||
xmlResponsePayload.value?.xmlContent?.trim() ||
|
||||
xmlResponsePayload.value?.xmlText?.trim() ||
|
||||
''
|
||||
)
|
||||
const canExportJsonMapping = computed(() => Boolean(mappingJsonPreview.value && !isSubmitting.value))
|
||||
const canExportXmlMapping = computed(() => Boolean(xmlContentForExport.value && !isSubmitting.value))
|
||||
const canGenerateXmlMapping = computed(() => Boolean(mappingJsonPreview.value && !isSubmitting.value))
|
||||
const showXmlMappingTab = computed(() =>
|
||||
Boolean(xmlResponsePayload.value && xmlResponsePayload.value.status !== 'FAILED')
|
||||
)
|
||||
|
||||
const xmlMappingPreview = computed(() => {
|
||||
const source = xmlContentForExport.value
|
||||
|
||||
if (source) return source
|
||||
|
||||
const savedPath = xmlResponsePayload.value?.savedPath?.trim()
|
||||
if (savedPath) return `XML 文件已生成:\n${savedPath}`
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const xmlMetaText = computed(() => {
|
||||
const xmlFile = xmlResponsePayload.value?.xmlFile
|
||||
|
||||
if (xmlFile?.fileName) {
|
||||
const encoding = xmlFile.encoding?.trim()
|
||||
const contentType = xmlFile.contentType?.trim()
|
||||
const suffixParts = [encoding, contentType].filter(Boolean)
|
||||
|
||||
return suffixParts.length ? `${xmlFile.fileName}(${suffixParts.join(',')})` : xmlFile.fileName
|
||||
}
|
||||
|
||||
if (xmlResponsePayload.value?.savedPath) return `XML 文件路径:${xmlResponsePayload.value.savedPath}`
|
||||
if (xmlMappingPreview.value) return `XML映射 ${xmlMappingPreview.value.length} 字符`
|
||||
return '当前未生成 XML 映射'
|
||||
})
|
||||
|
||||
const xmlEmptyText = computed(() => {
|
||||
if (isGeneratingXml.value) return '正在根据 JSON 映射生成 XML 映射'
|
||||
if (xmlResponsePayload.value && xmlResponsePayload.value.status !== 'FAILED') {
|
||||
return '当前接口返回未包含 xmlFile.content'
|
||||
}
|
||||
if (mappingJsonPreview.value) return '当前接口返回未包含 XML 内容或文件路径'
|
||||
return '请先生成 JSON 映射'
|
||||
})
|
||||
|
||||
const problemList = computed(() => [
|
||||
...(responsePayload.value?.problems?.filter(Boolean) || []),
|
||||
...(xmlResponsePayload.value?.problems?.filter(Boolean) || [])
|
||||
])
|
||||
|
||||
const methodDescribe = computed(() => xmlResponsePayload.value?.methodDescribe?.trim() || '')
|
||||
|
||||
const problemTabLabel = computed(() => {
|
||||
if (!problemList.value.length) return '问题列表'
|
||||
@@ -266,9 +357,53 @@ const problemTabLabel = computed(() => {
|
||||
})
|
||||
|
||||
const resolveResultTab = (payload: MmsMapping.MappingTaskResponse | null): ResultTab => {
|
||||
if (payload?.mappingJson?.trim()) return 'mapping'
|
||||
if (payload?.mappingJson?.trim()) return 'json'
|
||||
if (payload?.problems?.filter(Boolean).length) return 'problem'
|
||||
return 'mapping'
|
||||
return 'json'
|
||||
}
|
||||
|
||||
const handleGenerateXmlMapping = async () => {
|
||||
const mappingJson = responsePayload.value?.mappingJson?.trim()
|
||||
|
||||
if (!mappingJson) {
|
||||
ElMessage.warning('请先生成 JSON 映射')
|
||||
return
|
||||
}
|
||||
|
||||
isGeneratingXml.value = true
|
||||
activeResultTab.value = 'json'
|
||||
xmlResponsePayload.value = null
|
||||
|
||||
try {
|
||||
// 关键业务节点:XML 映射依赖本次接口返回的完整 mappingJson,避免使用旧结果生成不一致的 XML 文件。
|
||||
const response = await getXmlFromJsonApi({
|
||||
request: {
|
||||
mappingJson
|
||||
}
|
||||
})
|
||||
const payload = unwrapApiPayload<MmsMapping.MappingTaskResponse>(response)
|
||||
|
||||
xmlResponsePayload.value = payload
|
||||
|
||||
if (payload.status === 'FAILED') {
|
||||
ElMessage.warning(payload.message || 'XML 映射生成失败')
|
||||
activeResultTab.value = payload.problems?.filter(Boolean).length ? 'problem' : 'json'
|
||||
return
|
||||
}
|
||||
|
||||
activeResultTab.value = 'xml'
|
||||
ElMessage.success(payload.message || 'XML 映射生成完成')
|
||||
} catch (error) {
|
||||
xmlResponsePayload.value = {
|
||||
status: 'FAILED',
|
||||
message: getErrorMessage(error),
|
||||
problems: [getErrorMessage(error)]
|
||||
}
|
||||
activeResultTab.value = 'problem'
|
||||
ElMessage.warning(getErrorMessage(error))
|
||||
} finally {
|
||||
isGeneratingXml.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stripProblemsFromIcdPayload = (payload: MmsMapping.MappingTaskResponse): MmsMapping.MappingTaskResponse => {
|
||||
@@ -282,11 +417,12 @@ const stripProblemsFromIcdPayload = (payload: MmsMapping.MappingTaskResponse): M
|
||||
|
||||
const resetParsedState = () => {
|
||||
responsePayload.value = null
|
||||
xmlResponsePayload.value = null
|
||||
parsedCandidates.value = []
|
||||
confirmData.value = []
|
||||
indexSelectionJsonText.value = ''
|
||||
confirmDialogVisible.value = false
|
||||
activeResultTab.value = 'mapping'
|
||||
activeResultTab.value = 'json'
|
||||
}
|
||||
|
||||
const handleIcdFileChange = (event: Event) => {
|
||||
@@ -314,6 +450,7 @@ const handleParseIcd = async () => {
|
||||
|
||||
isParsing.value = true
|
||||
responsePayload.value = null
|
||||
xmlResponsePayload.value = null
|
||||
confirmDialogVisible.value = false
|
||||
confirmData.value = []
|
||||
indexSelectionJsonText.value = ''
|
||||
@@ -379,7 +516,7 @@ const handleConfirmIndexSelection = async (confirmedData: MmsMapping.ConfirmedIn
|
||||
// 关键业务节点:只有 buildIndexSelection 返回的正式结果才能进入请求配置区,避免前端自行拼装绑定关系。
|
||||
indexSelectionJsonText.value = formatIndexSelectionJson(indexSelection)
|
||||
confirmDialogVisible.value = false
|
||||
ElMessage.success('人工确认完成,已回填 request.indexSelection')
|
||||
ElMessage.success('人工索引配置完成,已回填索引配置')
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
@@ -395,7 +532,7 @@ const handleGenerateMapping = async () => {
|
||||
|
||||
if (!indexSelectionJsonText.value.trim()) {
|
||||
if (confirmData.value.length) {
|
||||
ElMessage.warning('请先完成人工确认并生成 request.indexSelection')
|
||||
ElMessage.warning('请先完成人工索引配置并生成索引配置')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -407,7 +544,7 @@ const handleGenerateMapping = async () => {
|
||||
if (error) {
|
||||
responsePayload.value = {
|
||||
status: 'NEED_INDEX_SELECTION',
|
||||
message: 'request.indexSelection 格式有误,请继续修正',
|
||||
message: '索引配置格式有误,请继续修正',
|
||||
problems: [error]
|
||||
}
|
||||
activeResultTab.value = 'problem'
|
||||
@@ -417,9 +554,10 @@ const handleGenerateMapping = async () => {
|
||||
|
||||
isGenerating.value = true
|
||||
responsePayload.value = null
|
||||
xmlResponsePayload.value = null
|
||||
|
||||
try {
|
||||
// 关键业务节点:正式生成阶段只消费当前请求配置区里的 request.indexSelection,确保导出的映射与最终确认结果一致。
|
||||
// 关键业务节点:正式生成阶段只消费当前请求配置区里的索引配置,确保导出的映射与最终确认结果一致。
|
||||
const response = await getIcdMmsJsonApi({
|
||||
icdFile: selectedIcdFile.value,
|
||||
request: {
|
||||
@@ -446,34 +584,64 @@ const handleGenerateMapping = async () => {
|
||||
ElMessage.success(payload.message || '映射生成完成')
|
||||
} catch (error) {
|
||||
responsePayload.value = null
|
||||
xmlResponsePayload.value = null
|
||||
ElMessage.error(getErrorMessage(error))
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildExportFileName = () => {
|
||||
const baseFileName = selectedIcdFile.value?.name.replace(/\.[^.]+$/, '') || 'mapping-summary'
|
||||
return `${baseFileName}-mapping-summary.json`
|
||||
const buildExportFileName = (type: ExportMappingType) => {
|
||||
const baseFileName = selectedIcdFile.value?.name.replace(/\.[^.]+$/, '')
|
||||
const suffix = `${type}-mapping.${type}`
|
||||
|
||||
return baseFileName ? `${baseFileName}-${suffix}` : suffix
|
||||
}
|
||||
|
||||
const handleExportMapping = () => {
|
||||
if (!mappingJsonPreview.value) {
|
||||
ElMessage.warning('当前没有可导出的映射摘要')
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([mappingJsonPreview.value], { type: 'application/json;charset=utf-8' })
|
||||
const downloadTextFile = (content: string, fileName: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const objectUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = objectUrl
|
||||
link.download = buildExportFileName()
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(objectUrl)
|
||||
ElMessage.success('映射摘要已导出')
|
||||
}
|
||||
|
||||
const handleExportMapping = (type: ExportMappingType) => {
|
||||
if (type === 'json' && !mappingJsonPreview.value) {
|
||||
ElMessage.warning('当前没有可导出的 JSON 映射')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'xml' && !xmlContentForExport.value) {
|
||||
ElMessage.warning('当前没有可导出的 XML 映射')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'json') {
|
||||
downloadTextFile(mappingJsonPreview.value, buildExportFileName('json'), 'application/json;charset=utf-8')
|
||||
ElMessage.success('JSON 映射已导出')
|
||||
return
|
||||
}
|
||||
|
||||
downloadTextFile(xmlContentForExport.value, buildExportFileName('xml'), 'application/xml;charset=utf-8')
|
||||
ElMessage.success('XML 映射已导出')
|
||||
}
|
||||
|
||||
const handleUpdateMappingJson = (mappingJson: string) => {
|
||||
if (!responsePayload.value) return
|
||||
|
||||
// 关键业务节点:序列配置修改的是当前 JSON 映射结果,XML 结果需要清空,避免继续展示旧 JSON 转换出的 XML。
|
||||
responsePayload.value = {
|
||||
...responsePayload.value,
|
||||
mappingJson
|
||||
}
|
||||
xmlResponsePayload.value = null
|
||||
activeResultTab.value = 'json'
|
||||
}
|
||||
|
||||
const resetPage = () => {
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<section class="mapping-panel config-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">请求配置</h2>
|
||||
<p class="panel-description">这里展示并可继续编辑经人工确认后生成的 request.indexSelection。</p>
|
||||
<h2 class="panel-title">人工索引配置</h2>
|
||||
<p class="panel-description">展示现有的人工索引配置,并允许继续编辑。</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button
|
||||
v-if="showConfirmButton"
|
||||
type="primary"
|
||||
plain
|
||||
:icon="EditPen"
|
||||
:disabled="!canConfirm"
|
||||
@click="emit('confirm-config')"
|
||||
>
|
||||
@@ -19,11 +19,11 @@
|
||||
v-if="showGenerateButton"
|
||||
type="primary"
|
||||
:icon="Connection"
|
||||
:loading="isSubmitting"
|
||||
:loading="isGenerating"
|
||||
:disabled="!canGenerate"
|
||||
@click="emit('generate')"
|
||||
>
|
||||
生成映射
|
||||
生成JSON映射
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
:disabled="isSubmitting"
|
||||
:rows="18"
|
||||
resize="none"
|
||||
placeholder="人工确认完成后,这里会自动回填 request.indexSelection,仍可继续直接编辑。"
|
||||
placeholder="人工索引配置完成后,这里会自动回填索引配置,仍可继续直接编辑。"
|
||||
@update:model-value="value => emit('update:indexSelectionJson', String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Connection } from '@element-plus/icons-vue'
|
||||
import { Connection, EditPen } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'MappingConfigPanel'
|
||||
@@ -59,6 +59,7 @@ defineOptions({
|
||||
defineProps<{
|
||||
indexSelectionJson: string
|
||||
isSubmitting: boolean
|
||||
isGenerating: boolean
|
||||
canGenerate: boolean
|
||||
jsonError: string
|
||||
showGenerateButton: boolean
|
||||
@@ -103,8 +104,10 @@ const emit = defineEmits<{
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@@ -132,9 +135,9 @@ const emit = defineEmits<{
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
@@ -142,13 +145,17 @@ const emit = defineEmits<{
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.index-selection-textarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.json-alert + .index-selection-textarea {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@
|
||||
placeholder="请选择 `.icd`、`.cid`、`.scd` 或 `.xml` 文件"
|
||||
class="file-input"
|
||||
/>
|
||||
<el-button type="primary" :icon="FolderOpened" :loading="isSubmitting" @click="openIcdFilePicker">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="FolderOpened"
|
||||
:disabled="isSubmitting"
|
||||
@click="openIcdFilePicker"
|
||||
>
|
||||
选择 ICD
|
||||
</el-button>
|
||||
<input
|
||||
@@ -30,15 +36,16 @@
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Search"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!selectedIcdFileName"
|
||||
:loading="isParsing"
|
||||
:disabled="!canParse"
|
||||
@click="emit('parse')"
|
||||
>
|
||||
解析 ICD
|
||||
</el-button>
|
||||
<el-button :icon="Delete" :disabled="!canReset" @click="emit('reset')">清空</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :disabled="!canReset || isSubmitting" @click="emit('reset')">
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -57,9 +64,11 @@ type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
|
||||
defineProps<{
|
||||
selectedIcdFileName: string
|
||||
isSubmitting: boolean
|
||||
isParsing: boolean
|
||||
icdFileAccept: string
|
||||
requestStatusText: string
|
||||
requestStatusTagType: TagType
|
||||
canParse: boolean
|
||||
canReset: boolean
|
||||
}>()
|
||||
|
||||
|
||||
@@ -2,13 +2,49 @@
|
||||
<section class="mapping-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">调试输出</h2>
|
||||
<p class="panel-description">右侧展示最近一次接口返回的 mappingJson 和 problems,并支持导出当前映射摘要。</p>
|
||||
<h2 class="panel-title">映射结果</h2>
|
||||
<p class="panel-description">展示和导出JSON与XML的映射结果,以及JSON的映射序列配置。</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button plain :icon="Download" :disabled="!canExportMapping" @click="emit('export-mapping')">
|
||||
导出映射文件
|
||||
<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>
|
||||
@@ -16,63 +52,428 @@
|
||||
<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="映射摘要" name="mapping">
|
||||
<div class="preview-header preview-header--compact">
|
||||
<div class="preview-meta">{{ mappingMetaText }}</div>
|
||||
</div>
|
||||
<el-tab-pane label="JSON映射" name="json">
|
||||
<div class="mapping-json-scroll">
|
||||
<pre v-if="mappingJsonPreview" class="mapping-json-text">{{ mappingJsonPreview }}</pre>
|
||||
<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 :label="problemTabLabel" name="problem">
|
||||
<div class="problem-section">
|
||||
<div v-if="problemList.length" class="problem-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>
|
||||
<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>
|
||||
<el-empty v-else :description="problemEmptyText" />
|
||||
<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>
|
||||
</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 { Download } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
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
|
||||
start: string
|
||||
end: 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: 'mapping' | 'problem'
|
||||
activeResultTab: ResultTab
|
||||
mappingMetaText: string
|
||||
mappingJsonPreview: string
|
||||
xmlMetaText: string
|
||||
xmlMappingPreview: string
|
||||
xmlEmptyText: string
|
||||
problemTabLabel: string
|
||||
problemList: string[]
|
||||
problemEmptyText: string
|
||||
canExportMapping: boolean
|
||||
methodDescribe: string
|
||||
canExportJsonMapping: boolean
|
||||
canExportXmlMapping: boolean
|
||||
canGenerateXmlMapping: boolean
|
||||
isGeneratingXml: boolean
|
||||
showXmlMappingTab: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:activeResultTab', value: 'mapping' | 'problem'): void
|
||||
(event: 'export-mapping'): void
|
||||
(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,
|
||||
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.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 isZeroValue = (value: unknown) => {
|
||||
if (typeof value === 'number') return value === 0
|
||||
if (typeof value === 'string') return Number(value.trim()) === 0
|
||||
return false
|
||||
}
|
||||
|
||||
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 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 (isZeroValue(source.start) && isZeroValue(source.end)) 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'),
|
||||
start: source.start === undefined || source.start === null ? '' : String(source.start),
|
||||
end: source.end === undefined || source.end === null ? '' : String(source.end),
|
||||
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">
|
||||
@@ -98,10 +499,25 @@ const activeTabProxy = computed({
|
||||
|
||||
.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;
|
||||
@@ -189,25 +605,7 @@ const activeTabProxy = computed({
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-header--compact {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.mapping-json-scroll,
|
||||
.problem-section {
|
||||
.mapping-json-scroll {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
@@ -229,10 +627,64 @@ const activeTabProxy = computed({
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.problem-list {
|
||||
.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 {
|
||||
@@ -267,16 +719,189 @@ const activeTabProxy = computed({
|
||||
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) 300px;
|
||||
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(2, 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,
|
||||
.preview-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>
|
||||
|
||||
@@ -15,30 +15,30 @@ const normalizeOptionalString = (value: unknown) => (typeof value === 'string' ?
|
||||
|
||||
const normalizeBindings = (value: unknown, groupIndex: number): MmsMapping.IndexSelectionBinding[] => {
|
||||
if (!Array.isArray(value) || !value.length) {
|
||||
throw new Error(`request.indexSelection[${groupIndex}].bindings 必须是非空数组`)
|
||||
throw new Error(`索引配置第 ${groupIndex + 1} 组的 bindings 必须是非空数组`)
|
||||
}
|
||||
|
||||
return value.map((binding, bindingIndex) => {
|
||||
if (!isRecord(binding)) {
|
||||
throw new Error(`request.indexSelection[${groupIndex}].bindings[${bindingIndex}] 必须是对象`)
|
||||
throw new Error(`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项必须是对象`)
|
||||
}
|
||||
|
||||
return {
|
||||
reportName: normalizeRequiredString(
|
||||
binding.reportName,
|
||||
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].reportName`
|
||||
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 reportName`
|
||||
),
|
||||
dataSetName: normalizeRequiredString(
|
||||
binding.dataSetName,
|
||||
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].dataSetName`
|
||||
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 dataSetName`
|
||||
),
|
||||
label: normalizeRequiredString(
|
||||
binding.label,
|
||||
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].label`
|
||||
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 label`
|
||||
),
|
||||
lnInst: normalizeRequiredString(
|
||||
binding.lnInst,
|
||||
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].lnInst`
|
||||
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 lnInst`
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -52,22 +52,22 @@ export const parseIndexSelectionJson = (source: string): MmsMapping.IndexSelecti
|
||||
try {
|
||||
parsed = JSON.parse(source)
|
||||
} catch {
|
||||
throw new Error('request.indexSelection 不是合法 JSON')
|
||||
throw new Error('索引配置不是合法 JSON')
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('request.indexSelection 必须是数组')
|
||||
throw new Error('索引配置必须是数组')
|
||||
}
|
||||
|
||||
return parsed.map((group, groupIndex) => {
|
||||
if (!isRecord(group)) {
|
||||
throw new Error(`request.indexSelection[${groupIndex}] 必须是对象`)
|
||||
throw new Error(`索引配置第 ${groupIndex + 1} 组必须是对象`)
|
||||
}
|
||||
|
||||
const groupDesc = normalizeOptionalString(group.groupDesc)
|
||||
|
||||
return {
|
||||
groupKey: normalizeRequiredString(group.groupKey, `request.indexSelection[${groupIndex}].groupKey`),
|
||||
groupKey: normalizeRequiredString(group.groupKey, `索引配置第 ${groupIndex + 1} 组的 groupKey`),
|
||||
groupDesc,
|
||||
bindings: normalizeBindings(group.bindings, groupIndex)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="marker-data-block">
|
||||
<div class="marker-data-title">标记数据</div>
|
||||
<div v-if="markerDataItems.length" class="marker-data-list">
|
||||
<div v-for="marker in markerDataItems" :key="marker.name" class="marker-data-group">
|
||||
<div class="marker-data-name">{{ marker.name }}:{{ marker.axisValueText }}</div>
|
||||
<div class="marker-data-rows">
|
||||
<div v-for="row in marker.rows" :key="`${marker.name}-${row.label}`" class="marker-data-row">
|
||||
<span class="marker-data-label">{{ row.label }}:</span>
|
||||
<span class="marker-data-value">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="marker-data-empty">暂无标记数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="向量信息" name="vector">
|
||||
@@ -43,7 +58,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Waveform } from '@/api/tools/waveform/interface'
|
||||
import type { SummaryItem } from './types'
|
||||
import type { MarkerDataItem, SummaryItem } from './types'
|
||||
import WaveformVectorInfo from './WaveformVectorInfo.vue'
|
||||
|
||||
defineProps<{
|
||||
@@ -53,6 +68,7 @@ defineProps<{
|
||||
lastParseErrorMessage: string
|
||||
lastVectorParseErrorMessage: string
|
||||
activeVectorChannelName: string
|
||||
markerDataItems: MarkerDataItem[]
|
||||
}>()
|
||||
|
||||
// 右侧信息区仅切换展示内容,不影响波形与向量解析结果的联动状态。
|
||||
@@ -198,6 +214,75 @@ const activeInfoTab = ref('waveform')
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.marker-data-block {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.marker-data-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.marker-data-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.marker-data-group + .marker-data-group {
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.marker-data-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.marker-data-rows {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.marker-data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
min-width: fit-content;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marker-data-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.marker-data-value {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marker-data-empty {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -211,4 +296,4 @@ const activeInfoTab = ref('waveform')
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
:options="group.multiChannelOptions"
|
||||
:group="group.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
@@ -25,6 +26,7 @@
|
||||
:options="item.options"
|
||||
:group="item.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,6 +37,7 @@
|
||||
<LineChart
|
||||
:options="activeTrendOptions"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="hasWaveformData" class="single-channel-list">
|
||||
@@ -49,6 +52,7 @@
|
||||
:options="item.options"
|
||||
:group="item.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,6 +73,7 @@ import type {
|
||||
AllChannelTrendGroup,
|
||||
DisplayMode,
|
||||
SingleChannelTrendOption,
|
||||
TrendChartClickPayload,
|
||||
TrendChartZoomPayload
|
||||
} from './types'
|
||||
|
||||
@@ -84,11 +89,16 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'chart-data-zoom': [value: TrendChartZoomPayload]
|
||||
'chart-click': [value: TrendChartClickPayload]
|
||||
}>()
|
||||
|
||||
const handleChartDataZoom = (value: TrendChartZoomPayload) => {
|
||||
emit('chart-data-zoom', value)
|
||||
}
|
||||
|
||||
const handleChartClick = (value: TrendChartClickPayload) => {
|
||||
emit('chart-click', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
:is-all-channels-active="isAllChannelsActive"
|
||||
:last-parse-error-message="lastParseErrorMessage"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
@@ -55,6 +56,7 @@
|
||||
:is-all-channels-active="isAllChannelsActive"
|
||||
:last-parse-error-message="lastParseErrorMessage"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
@chart-click="handleChartClick"
|
||||
/>
|
||||
</el-dialog>
|
||||
</section>
|
||||
@@ -69,6 +71,7 @@ import {
|
||||
Crop,
|
||||
Download,
|
||||
FullScreen,
|
||||
Location,
|
||||
Picture,
|
||||
Pointer,
|
||||
RefreshLeft
|
||||
@@ -80,6 +83,7 @@ import type {
|
||||
DisplayMode,
|
||||
LabelValueOption,
|
||||
SingleChannelTrendOption,
|
||||
TrendChartClickPayload,
|
||||
TrendChartZoomPayload,
|
||||
TrendToolAction,
|
||||
TrendTabValue
|
||||
@@ -106,6 +110,7 @@ const emit = defineEmits<{
|
||||
'update:activeTrendTab': [value: TrendTabValue]
|
||||
'trend-tool-action': [value: TrendToolAction]
|
||||
'chart-data-zoom': [value: TrendChartZoomPayload]
|
||||
'chart-click': [value: TrendChartClickPayload]
|
||||
}>()
|
||||
|
||||
const fullscreenVisible = ref(false)
|
||||
@@ -126,6 +131,7 @@ const trendToolGroups: Array<{
|
||||
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
||||
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
||||
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
||||
{ action: 'mark', label: '标记', icon: Location },
|
||||
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
||||
{ action: 'pan', label: '平移', icon: Pointer }
|
||||
]
|
||||
@@ -179,6 +185,10 @@ const handleTrendToolClick = (action: TrendPanelToolAction) => {
|
||||
const handleChartDataZoom = (value: TrendChartZoomPayload) => {
|
||||
emit('chart-data-zoom', value)
|
||||
}
|
||||
|
||||
const handleChartClick = (value: TrendChartClickPayload) => {
|
||||
emit('chart-click', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -8,6 +8,7 @@ export type TrendToolAction =
|
||||
| 'y-zoom-in'
|
||||
| 'y-zoom-out'
|
||||
| 'box-zoom'
|
||||
| 'mark'
|
||||
| 'pan'
|
||||
| 'reset'
|
||||
| 'download-image'
|
||||
@@ -44,6 +45,15 @@ export interface SummaryItem {
|
||||
value: string | number
|
||||
}
|
||||
|
||||
export interface MarkerDataItem {
|
||||
name: string
|
||||
axisValueText: string
|
||||
rows: Array<{
|
||||
label: string
|
||||
value: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface FeatureCardItem {
|
||||
title: string
|
||||
rows: Array<{
|
||||
@@ -56,3 +66,8 @@ export interface TrendChartZoomPayload {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TrendChartClickPayload {
|
||||
dataIndex: number
|
||||
axisValue: string | number
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
@update:active-trend-tab="activeTrendTab = $event"
|
||||
@trend-tool-action="handleTrendToolAction"
|
||||
@chart-data-zoom="handleTrendChartDataZoom"
|
||||
@chart-click="handleTrendChartClick"
|
||||
/>
|
||||
|
||||
<WaveformInfoPanel
|
||||
@@ -41,6 +42,7 @@
|
||||
:last-parse-error-message="lastParseErrorMessage"
|
||||
:last-vector-parse-error-message="lastVectorParseErrorMessage"
|
||||
:active-vector-channel-name="activeWaveDetail?.channelName || ''"
|
||||
:marker-data-items="markerDataItems"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,8 +63,10 @@ import type {
|
||||
ChannelSelectValue,
|
||||
DisplayMode,
|
||||
LabelValueOption,
|
||||
MarkerDataItem,
|
||||
SingleChannelTrendOption,
|
||||
SummaryItem,
|
||||
TrendChartClickPayload,
|
||||
TrendChartZoomPayload,
|
||||
TrendToolAction,
|
||||
TrendTabValue,
|
||||
@@ -89,6 +93,7 @@ interface WaveformTrendPayload {
|
||||
|
||||
interface TrendChartLayoutOptions {
|
||||
showTimeAxis?: boolean
|
||||
showMarkerLabel?: boolean
|
||||
yAxisSplitCount?: number
|
||||
}
|
||||
|
||||
@@ -97,6 +102,12 @@ interface TrendZoomRange {
|
||||
end: number
|
||||
}
|
||||
|
||||
interface TrendMarker {
|
||||
name: 'T1' | 'T2'
|
||||
dataIndex: number
|
||||
axisValue: string | number
|
||||
}
|
||||
|
||||
const activeTrendTab = ref<TrendTabValue>('instant')
|
||||
const activeValueMode = ref<ValueMode>('primary')
|
||||
const activeDisplayMode = ref<DisplayMode>('single-channel')
|
||||
@@ -113,7 +124,8 @@ const lastVectorParseErrorMessage = ref('')
|
||||
const waveformFileAccept = '.cfg,.dat'
|
||||
const trendXZoomRange = ref<TrendZoomRange>({ start: 0, end: 100 })
|
||||
const trendYZoomScale = ref(1)
|
||||
const activeTrendInteractionMode = ref<'none' | 'box-zoom' | 'pan'>('none')
|
||||
const activeTrendInteractionMode = ref<'none' | 'box-zoom' | 'pan' | 'mark'>('none')
|
||||
const trendMarkers = ref<TrendMarker[]>([])
|
||||
|
||||
const trendTabs: LabelValueOption<TrendTabValue>[] = [
|
||||
{ value: 'instant', label: '瞬时波形' },
|
||||
@@ -362,6 +374,7 @@ const canResetTrendChart = computed(() => {
|
||||
|
||||
const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
|
||||
'box-zoom': activeTrendInteractionMode.value === 'box-zoom',
|
||||
mark: activeTrendInteractionMode.value === 'mark',
|
||||
pan: activeTrendInteractionMode.value === 'pan'
|
||||
}))
|
||||
|
||||
@@ -467,6 +480,7 @@ const resetTrendToolState = () => {
|
||||
trendXZoomRange.value = { start: 0, end: 100 }
|
||||
trendYZoomScale.value = 1
|
||||
activeTrendInteractionMode.value = 'none'
|
||||
trendMarkers.value = []
|
||||
}
|
||||
|
||||
const zoomTrendXAxis = (ratio: number) => {
|
||||
@@ -674,8 +688,10 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = T
|
||||
}
|
||||
}
|
||||
|
||||
const buildTrendSeries = (seriesList: WaveformSeriesItem[]) => {
|
||||
return seriesList.map(item => {
|
||||
const buildTrendSeries = (seriesList: WaveformSeriesItem[], showMarkerLabel: boolean) => {
|
||||
const markerLine = buildTrendMarkerLine(seriesList, showMarkerLabel)
|
||||
|
||||
return seriesList.map((item, index) => {
|
||||
const visiblePointCount = resolveTrendVisiblePointCount(item.data.length)
|
||||
|
||||
return {
|
||||
@@ -687,11 +703,70 @@ const buildTrendSeries = (seriesList: WaveformSeriesItem[]) => {
|
||||
lineStyle: {
|
||||
width: resolveTrendLineWidth(visiblePointCount)
|
||||
},
|
||||
markLine: index === 0 ? markerLine : undefined,
|
||||
data: item.data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const trendMarkerColors: Record<TrendMarker['name'], string> = {
|
||||
T1: '#f56c6c',
|
||||
T2: '#e6a23c'
|
||||
}
|
||||
|
||||
function buildTrendMarkerLabel(marker: TrendMarker) {
|
||||
return `${marker.name}: ${formatNumber(marker.axisValue, 2)} ms`
|
||||
}
|
||||
|
||||
function buildTrendMarkerLine(seriesList: WaveformSeriesItem[], showMarkerLabel: boolean) {
|
||||
const availableMarkers = trendMarkers.value.filter(marker =>
|
||||
seriesList.some(item => marker.dataIndex >= 0 && marker.dataIndex < item.data.length)
|
||||
)
|
||||
|
||||
if (!availableMarkers.length) return undefined
|
||||
|
||||
return {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
animation: false,
|
||||
label: {
|
||||
show: showMarkerLabel,
|
||||
position: 'end',
|
||||
distance: 8,
|
||||
rotate: 0,
|
||||
align: 'center',
|
||||
verticalAlign: 'top',
|
||||
offset: [0, 14],
|
||||
color: axisTextColor,
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
backgroundColor: 'rgba(255,255,255,0.88)',
|
||||
borderColor: axisLineColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
padding: [4, 6],
|
||||
formatter: (params: { data?: { marker?: TrendMarker } }) => {
|
||||
const marker = params.data?.marker
|
||||
return marker ? buildTrendMarkerLabel(marker) : ''
|
||||
}
|
||||
},
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
width: 1.2
|
||||
},
|
||||
data: availableMarkers.map(marker => ({
|
||||
xAxis: marker.axisValue,
|
||||
marker,
|
||||
label: {
|
||||
color: trendMarkerColors[marker.name]
|
||||
},
|
||||
lineStyle: {
|
||||
color: trendMarkerColors[marker.name]
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
|
||||
const lastIndex = timeLabels.length - 1
|
||||
|
||||
@@ -737,7 +812,7 @@ const buildTrendChartOptions = (
|
||||
chartColors = currentTrendColors.value,
|
||||
layoutOptions: TrendChartLayoutOptions = {}
|
||||
) => {
|
||||
const { showTimeAxis = true } = layoutOptions
|
||||
const { showTimeAxis = true, showMarkerLabel = showTimeAxis } = layoutOptions
|
||||
const yAxisSplitCount = resolveTrendAxisSplitCount(layoutOptions)
|
||||
const yAxisConfig = applyYAxisZoom(buildTrendAxisConfig(trendPayload, yAxisSplitCount))
|
||||
|
||||
@@ -832,7 +907,7 @@ const buildTrendChartOptions = (
|
||||
}
|
||||
},
|
||||
color: chartColors,
|
||||
series: buildTrendSeries(seriesList)
|
||||
series: buildTrendSeries(seriesList, showMarkerLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,6 +1022,48 @@ const summaryItems = computed<SummaryItem[]>(() => {
|
||||
]
|
||||
})
|
||||
|
||||
interface MarkerSeriesRow {
|
||||
label: string
|
||||
unit: string
|
||||
data: number[]
|
||||
}
|
||||
|
||||
const buildMarkerSeriesRows = (detail: Waveform.WaveDataDetail | null): MarkerSeriesRow[] => {
|
||||
const trendPayload = buildTrendPayload(detail, activeTrendTab.value, getValueScale(detail))
|
||||
|
||||
return trendPayload.series.map(item => ({
|
||||
label: item.name,
|
||||
unit: trendPayload.unit,
|
||||
data: item.data
|
||||
}))
|
||||
}
|
||||
|
||||
const visibleMarkerSeriesRows = computed<MarkerSeriesRow[]>(() => {
|
||||
if (isAllChannelsActive.value) {
|
||||
return normalizedWaveDetails.value.flatMap(detail => buildMarkerSeriesRows(detail))
|
||||
}
|
||||
|
||||
return buildMarkerSeriesRows(activeWaveDetail.value)
|
||||
})
|
||||
|
||||
const markerDataItems = computed<MarkerDataItem[]>(() => {
|
||||
// 标记数值统一移到波形信息区,图内只保留最后一张图的 T1/T2 时间标签。
|
||||
return trendMarkers.value.map(marker => ({
|
||||
name: marker.name,
|
||||
axisValueText: `${formatNumber(marker.axisValue, 2)} ms`,
|
||||
rows: visibleMarkerSeriesRows.value
|
||||
.filter(item => marker.dataIndex >= 0 && marker.dataIndex < item.data.length)
|
||||
.map(item => {
|
||||
const value = item.data[marker.dataIndex]
|
||||
|
||||
return {
|
||||
label: item.label,
|
||||
value: item.unit ? `${formatNumber(value)} ${item.unit}` : formatNumber(value)
|
||||
}
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const handleTrendToolAction = async (action: TrendToolAction) => {
|
||||
if (!hasWaveformData.value) return
|
||||
|
||||
@@ -966,6 +1083,9 @@ const handleTrendToolAction = async (action: TrendToolAction) => {
|
||||
case 'box-zoom':
|
||||
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
|
||||
break
|
||||
case 'mark':
|
||||
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'mark' ? 'none' : 'mark'
|
||||
break
|
||||
case 'pan':
|
||||
if (!canPanTrendChart.value) {
|
||||
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
|
||||
@@ -999,6 +1119,22 @@ const handleTrendChartDataZoom = (value: TrendChartZoomPayload) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTrendChartClick = (value: TrendChartClickPayload) => {
|
||||
if (activeTrendInteractionMode.value !== 'mark' || !hasWaveformData.value) return
|
||||
|
||||
const nextMarkerName: TrendMarker['name'] = trendMarkers.value.length === 1 ? 'T2' : 'T1'
|
||||
const currentMarkers = trendMarkers.value.length >= 2 ? [] : trendMarkers.value
|
||||
|
||||
trendMarkers.value = [
|
||||
...currentMarkers,
|
||||
{
|
||||
name: nextMarkerName,
|
||||
dataIndex: value.dataIndex,
|
||||
axisValue: value.axisValue
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
watch([activeTrendTab, activeValueMode, activeDisplayMode, activeChannelIndex], () => {
|
||||
resetTrendToolState()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user