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

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

View File

@@ -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[]) => { export const buildIndexConfirmDataApi = (params: MmsMapping.IndexCandidateGroup[]) => {
// 关键业务节点ICD 候选数据需要先转换成前端确认弹窗模型,后续人工确认才能继续生成正式索引配置。 // 关键业务节点ICD 候选数据需要先转换成前端确认弹窗模型,后续人工确认才能继续生成正式索引配置。
return http.post<MmsMapping.IndexConfirmGroup[]>('/api/mms-mapping/build-index-confirm-data', params) return http.post<MmsMapping.IndexConfirmGroup[]>('/api/mms-mapping/build-index-confirm-data', params)

View File

@@ -63,15 +63,34 @@ export namespace MmsMapping {
request: GetIcdMmsJsonRequestPayload request: GetIcdMmsJsonRequestPayload
} }
export interface GetXmlFromJsonRequestPayload {
mappingJson: string
}
export interface GetXmlFromJsonParams {
request: GetXmlFromJsonRequestPayload
}
export interface BuildIndexSelectionRequest { export interface BuildIndexSelectionRequest {
confirmData: IndexConfirmGroup[] confirmData: IndexConfirmGroup[]
confirmedData: ConfirmedIndexGroup[] confirmedData: ConfirmedIndexGroup[]
} }
export interface XmlFileResponse {
fileName?: string
contentType?: string
encoding?: string
content?: string
}
export interface IcdDocument { export interface IcdDocument {
[key: string]: unknown [key: string]: unknown
} }
export interface MappingDocument {
[key: string]: unknown
}
export interface IndexCandidateReport { export interface IndexCandidateReport {
reportName?: string reportName?: string
dataSetName?: string dataSetName?: string
@@ -92,8 +111,14 @@ export namespace MmsMapping {
export interface MappingTaskResponse { export interface MappingTaskResponse {
status?: MappingTaskStatus status?: MappingTaskStatus
message?: string message?: string
methodDescribe?: string
icdDocument?: IcdDocument icdDocument?: IcdDocument
mappingDocument?: MappingDocument
mappingJson?: string mappingJson?: string
mappingXml?: string
xmlContent?: string
xmlText?: string
xmlFile?: XmlFileResponse
savedPath?: string savedPath?: string
indexCandidates?: IndexCandidateGroup[] indexCandidates?: IndexCandidateGroup[]
problems?: string[] problems?: string[]

View File

@@ -40,12 +40,68 @@ const emit = defineEmits<{
end: number end: number
} }
] ]
'chart-click': [
value: {
dataIndex: number
axisValue: string | number
}
]
}>() }>()
let chart: echarts.ECharts | any = null let chart: echarts.ECharts | any = null
let isPanPointerDown = false let isPanPointerDown = false
const getChartViewportRoot = () => chart?.getZr()?.painter?.getViewportRoot?.() as HTMLElement | undefined 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 resetChartCursor = () => {
const viewportRoot = getChartViewportRoot() const viewportRoot = getChartViewportRoot()
if (viewportRoot) viewportRoot.style.cursor = '' if (viewportRoot) viewportRoot.style.cursor = ''
@@ -55,14 +111,20 @@ const resetChartCursor = () => {
const updatePanCursor = (event: { offsetX: number; offsetY: number }) => { const updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
const viewportRoot = getChartViewportRoot() const viewportRoot = getChartViewportRoot()
if (!viewportRoot || props.options?.activeTool !== 'pan') { if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) {
resetChartCursor() resetChartCursor()
return return
} }
// 平移只在图形绘图区内生效,鼠标样式同步限制到同一范围,避免坐标轴和空白区误导操作。 // 平移只在图形绘图区内生效,鼠标样式同步限制到同一范围,避免坐标轴和空白区误导操作。
const isInGrid = chart?.containPixel?.({ gridIndex: 0 }, [event.offsetX, event.offsetY]) 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 = () => { const bindPanCursorEvents = () => {
@@ -73,10 +135,12 @@ const bindPanCursorEvents = () => {
zr.off('mousedown', handlePanCursorMouseDown) zr.off('mousedown', handlePanCursorMouseDown)
zr.off('mouseup', handlePanCursorMouseUp) zr.off('mouseup', handlePanCursorMouseUp)
zr.off('globalout', resetChartCursor) zr.off('globalout', resetChartCursor)
zr.off('click', handleChartClick)
zr.on('mousemove', updatePanCursor) zr.on('mousemove', updatePanCursor)
zr.on('mousedown', handlePanCursorMouseDown) zr.on('mousedown', handlePanCursorMouseDown)
zr.on('mouseup', handlePanCursorMouseUp) zr.on('mouseup', handlePanCursorMouseUp)
zr.on('globalout', resetChartCursor) zr.on('globalout', resetChartCursor)
zr.on('click', handleChartClick)
} }
const unbindPanCursorEvents = () => { const unbindPanCursorEvents = () => {
@@ -87,9 +151,34 @@ const unbindPanCursorEvents = () => {
zr.off('mousedown', handlePanCursorMouseDown) zr.off('mousedown', handlePanCursorMouseDown)
zr.off('mouseup', handlePanCursorMouseUp) zr.off('mouseup', handlePanCursorMouseUp)
zr.off('globalout', resetChartCursor) zr.off('globalout', resetChartCursor)
zr.off('click', handleChartClick)
resetChartCursor() 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 }) { function handlePanCursorMouseDown(event: { offsetX: number; offsetY: number }) {
isPanPointerDown = true isPanPointerDown = true
updatePanCursor(event) updatePanCursor(event)

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,11 @@
<MappingRequestPanel <MappingRequestPanel
:selected-icd-file-name="selectedIcdFileName" :selected-icd-file-name="selectedIcdFileName"
:is-submitting="isSubmitting" :is-submitting="isSubmitting"
:is-parsing="isParsing"
:icd-file-accept="icdFileAccept" :icd-file-accept="icdFileAccept"
:request-status-text="requestStatusText" :request-status-text="requestStatusText"
:request-status-tag-type="requestStatusTagType" :request-status-tag-type="requestStatusTagType"
:can-parse="canParseIcd"
:can-reset="canResetPage" :can-reset="canResetPage"
@file-change="handleIcdFileChange" @file-change="handleIcdFileChange"
@parse="handleParseIcd" @parse="handleParseIcd"
@@ -18,11 +20,12 @@
v-if="showConfigPanel" v-if="showConfigPanel"
v-model:index-selection-json="indexSelectionJsonText" v-model:index-selection-json="indexSelectionJsonText"
:is-submitting="isSubmitting" :is-submitting="isSubmitting"
:is-generating="isGenerating"
:can-generate="canGenerate" :can-generate="canGenerate"
:json-error="indexSelectionError" :json-error="indexSelectionError"
:show-generate-button="showGenerateButton" :show-generate-button="showGenerateButton"
:show-confirm-button="Boolean(confirmData.length)" :show-confirm-button="Boolean(confirmData.length)"
:confirm-button-text="indexSelectionJsonText.trim() ? '重新确认' : '人工确认'" confirm-button-text="人工索引配置"
:can-confirm="!isSubmitting" :can-confirm="!isSubmitting"
:has-default-json="Boolean(indexSelectionJsonText.trim())" :has-default-json="Boolean(indexSelectionJsonText.trim())"
:empty-description="configEmptyDescription" :empty-description="configEmptyDescription"
@@ -37,11 +40,21 @@
:response-status-tag-type="responseStatusTagType" :response-status-tag-type="responseStatusTagType"
:mapping-meta-text="mappingMetaText" :mapping-meta-text="mappingMetaText"
:mapping-json-preview="mappingJsonPreview" :mapping-json-preview="mappingJsonPreview"
:xml-meta-text="xmlMetaText"
:xml-mapping-preview="xmlMappingPreview"
:xml-empty-text="xmlEmptyText"
:problem-tab-label="problemTabLabel" :problem-tab-label="problemTabLabel"
:problem-list="problemList" :problem-list="problemList"
:problem-empty-text="problemEmptyText" :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" @export-mapping="handleExportMapping"
@generate-xml-mapping="handleGenerateXmlMapping"
@update-mapping-json="handleUpdateMappingJson"
/> />
</div> </div>
@@ -63,7 +76,8 @@ import {
buildIndexConfirmDataApi, buildIndexConfirmDataApi,
buildIndexSelectionApi, buildIndexSelectionApi,
getIcdApi, getIcdApi,
getIcdMmsJsonApi getIcdMmsJsonApi,
getXmlFromJsonApi
} from '@/api/tools/mmsmapping' } from '@/api/tools/mmsmapping'
import type { MmsMapping } from '@/api/tools/mmsmapping/interface' import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
import MappingRequestPanel from './components/MappingRequestPanel.vue' import MappingRequestPanel from './components/MappingRequestPanel.vue'
@@ -77,8 +91,9 @@ defineOptions({
name: 'MmsMappingView' name: 'MmsMappingView'
}) })
type ResultTab = 'mapping' | 'problem' type ResultTab = 'json' | 'xml' | 'problem'
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger' type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
type ExportMappingType = 'json' | 'xml'
const DEFAULT_REQUEST_FORM: MmsMapping.BaseRequestForm = { const DEFAULT_REQUEST_FORM: MmsMapping.BaseRequestForm = {
version: '1.0', version: '1.0',
@@ -87,7 +102,8 @@ const DEFAULT_REQUEST_FORM: MmsMapping.BaseRequestForm = {
const selectedIcdFile = ref<File | null>(null) const selectedIcdFile = ref<File | null>(null)
const responsePayload = ref<MmsMapping.MappingTaskResponse | 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 parsedCandidates = ref<MmsMapping.IndexCandidateGroup[]>([])
const confirmData = ref<MmsMapping.IndexConfirmGroup[]>([]) const confirmData = ref<MmsMapping.IndexConfirmGroup[]>([])
const indexSelectionJsonText = ref('') const indexSelectionJsonText = ref('')
@@ -95,6 +111,7 @@ const confirmDialogVisible = ref(false)
const isParsing = ref(false) const isParsing = ref(false)
const isConfirmingSelection = ref(false) const isConfirmingSelection = ref(false)
const isGenerating = ref(false) const isGenerating = ref(false)
const isGeneratingXml = ref(false)
const icdFileAccept = '.icd,.cid,.scd,.xml' const icdFileAccept = '.icd,.cid,.scd,.xml'
const problemEmptyText = '当前返回未包含 problems' const problemEmptyText = '当前返回未包含 problems'
@@ -128,8 +145,8 @@ const logConfirmDataDiagnostics = (groups: MmsMapping.IndexConfirmGroup[]) => {
})) }))
})) }))
.filter(item => .filter(item =>
[item.label, ...item.commonLnInstValues, ...item.targets.map(target => target.dataSetName)].some(value => [item.label, ...item.commonLnInstValues, ...item.targets.map(target => target.dataSetName)].some(
String(value || '').includes('间谐波') 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(() => const canResetPage = computed(() =>
Boolean( 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 indexSelectionError = computed(() => parsedIndexSelectionState.value.error)
const canGenerate = computed( const canParseIcd = computed(() => Boolean(selectedIcdFile.value && !isSubmitting.value))
() => Boolean(selectedIcdFile.value && indexSelectionJsonText.value.trim() && !indexSelectionError.value) const canGenerate = computed(() =>
Boolean(selectedIcdFile.value && indexSelectionJsonText.value.trim() && !indexSelectionError.value && !isSubmitting.value)
) )
// 关键业务节点:请求配置区只在用户已经选择 ICD 后展示,避免初始态暴露无效的请求编辑区。 // 关键业务节点:请求配置区只在用户已经选择 ICD 后展示,避免初始态暴露无效的请求编辑区。
const showConfigPanel = computed(() => Boolean(selectedIcdFile.value)) const showConfigPanel = computed(() => Boolean(selectedIcdFile.value))
@@ -201,17 +225,18 @@ const selectedIcdFileName = computed(() => selectedIcdFile.value?.name || '')
const configEmptyDescription = computed(() => { const configEmptyDescription = computed(() => {
if (isParsing.value) return '正在获取 ICD 候选数据并准备人工确认,请稍候。' if (isParsing.value) return '正在获取 ICD 候选数据并准备人工确认,请稍候。'
if (isConfirmingSelection.value) return '正在根据人工确认结果生成 request.indexSelection,请稍候。' if (isConfirmingSelection.value) return '正在根据人工索引配置生成索引配置,请稍候。'
if (confirmDialogVisible.value || confirmData.value.length) { if (confirmDialogVisible.value || confirmData.value.length) {
return '请先在弹窗中完成人工确认,确认后会自动回填 request.indexSelection。' return '请先在弹窗中完成人工索引配置,确认后会自动回填索引配置。'
} }
if (selectedIcdFile.value) return '已选择 ICD 文件,请先点击“解析 ICD”进入人工确认流程。' if (selectedIcdFile.value) return '已选择 ICD 文件,请先点击“解析 ICD”进入人工确认流程。'
return '当前 ICD 暂未生成可编辑的 request.indexSelection。' return '当前 ICD 暂未生成可编辑的索引配置。'
}) })
const requestStatusText = computed(() => { const requestStatusText = computed(() => {
if (isParsing.value) return '解析中' if (isParsing.value) return '解析中'
if (isConfirmingSelection.value) return '确认中' if (isConfirmingSelection.value) return '确认中'
if (isGeneratingXml.value) return 'XML转换中'
if (isGenerating.value) return '生成中' if (isGenerating.value) return '生成中'
if (confirmDialogVisible.value) return '待人工确认' if (confirmDialogVisible.value) return '待人工确认'
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return '已确认' if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return '已确认'
@@ -221,7 +246,7 @@ const requestStatusText = computed(() => {
}) })
const requestStatusTagType = computed<TagType>(() => { 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 (confirmDialogVisible.value) return 'primary'
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return 'success' if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return 'success'
if (selectedIcdFile.value) return 'primary' if (selectedIcdFile.value) return 'primary'
@@ -229,15 +254,24 @@ const requestStatusTagType = computed<TagType>(() => {
}) })
const responseStatusText = computed(() => { const responseStatusText = computed(() => {
if (isSubmitting.value) return '等待返回' if (isGenerating.value) return 'JSON生成中'
return responsePayload.value?.status || '暂无结果' 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>(() => { const responseStatusTagType = computed<TagType>(() => {
if (isSubmitting.value) return 'warning' if (isSubmitting.value) return 'warning'
if (xmlResponsePayload.value?.status === 'FAILED') return 'danger'
if (responsePayload.value?.status === 'FAILED') return 'danger' if (responsePayload.value?.status === 'FAILED') return 'danger'
if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return 'warning' if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return 'warning'
if (responsePayload.value) return 'success' if (xmlResponsePayload.value || responsePayload.value) return 'success'
return 'info' return 'info'
}) })
@@ -258,7 +292,64 @@ const mappingMetaText = computed(() => {
return `mappingJson ${mappingJsonPreview.value.length} 字符` 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(() => { const problemTabLabel = computed(() => {
if (!problemList.value.length) return '问题列表' if (!problemList.value.length) return '问题列表'
@@ -266,9 +357,53 @@ const problemTabLabel = computed(() => {
}) })
const resolveResultTab = (payload: MmsMapping.MappingTaskResponse | null): ResultTab => { 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' 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 => { const stripProblemsFromIcdPayload = (payload: MmsMapping.MappingTaskResponse): MmsMapping.MappingTaskResponse => {
@@ -282,11 +417,12 @@ const stripProblemsFromIcdPayload = (payload: MmsMapping.MappingTaskResponse): M
const resetParsedState = () => { const resetParsedState = () => {
responsePayload.value = null responsePayload.value = null
xmlResponsePayload.value = null
parsedCandidates.value = [] parsedCandidates.value = []
confirmData.value = [] confirmData.value = []
indexSelectionJsonText.value = '' indexSelectionJsonText.value = ''
confirmDialogVisible.value = false confirmDialogVisible.value = false
activeResultTab.value = 'mapping' activeResultTab.value = 'json'
} }
const handleIcdFileChange = (event: Event) => { const handleIcdFileChange = (event: Event) => {
@@ -314,6 +450,7 @@ const handleParseIcd = async () => {
isParsing.value = true isParsing.value = true
responsePayload.value = null responsePayload.value = null
xmlResponsePayload.value = null
confirmDialogVisible.value = false confirmDialogVisible.value = false
confirmData.value = [] confirmData.value = []
indexSelectionJsonText.value = '' indexSelectionJsonText.value = ''
@@ -379,7 +516,7 @@ const handleConfirmIndexSelection = async (confirmedData: MmsMapping.ConfirmedIn
// 关键业务节点:只有 buildIndexSelection 返回的正式结果才能进入请求配置区,避免前端自行拼装绑定关系。 // 关键业务节点:只有 buildIndexSelection 返回的正式结果才能进入请求配置区,避免前端自行拼装绑定关系。
indexSelectionJsonText.value = formatIndexSelectionJson(indexSelection) indexSelectionJsonText.value = formatIndexSelectionJson(indexSelection)
confirmDialogVisible.value = false confirmDialogVisible.value = false
ElMessage.success('人工确认完成,已回填 request.indexSelection') ElMessage.success('人工索引配置完成,已回填索引配置')
} catch (error) { } catch (error) {
ElMessage.error(getErrorMessage(error)) ElMessage.error(getErrorMessage(error))
} finally { } finally {
@@ -395,7 +532,7 @@ const handleGenerateMapping = async () => {
if (!indexSelectionJsonText.value.trim()) { if (!indexSelectionJsonText.value.trim()) {
if (confirmData.value.length) { if (confirmData.value.length) {
ElMessage.warning('请先完成人工确认并生成 request.indexSelection') ElMessage.warning('请先完成人工索引配置并生成索引配置')
return return
} }
@@ -407,7 +544,7 @@ const handleGenerateMapping = async () => {
if (error) { if (error) {
responsePayload.value = { responsePayload.value = {
status: 'NEED_INDEX_SELECTION', status: 'NEED_INDEX_SELECTION',
message: 'request.indexSelection 格式有误,请继续修正', message: '索引配置格式有误,请继续修正',
problems: [error] problems: [error]
} }
activeResultTab.value = 'problem' activeResultTab.value = 'problem'
@@ -417,9 +554,10 @@ const handleGenerateMapping = async () => {
isGenerating.value = true isGenerating.value = true
responsePayload.value = null responsePayload.value = null
xmlResponsePayload.value = null
try { try {
// 关键业务节点:正式生成阶段只消费当前请求配置区里的 request.indexSelection,确保导出的映射与最终确认结果一致。 // 关键业务节点:正式生成阶段只消费当前请求配置区里的索引配置,确保导出的映射与最终确认结果一致。
const response = await getIcdMmsJsonApi({ const response = await getIcdMmsJsonApi({
icdFile: selectedIcdFile.value, icdFile: selectedIcdFile.value,
request: { request: {
@@ -446,34 +584,64 @@ const handleGenerateMapping = async () => {
ElMessage.success(payload.message || '映射生成完成') ElMessage.success(payload.message || '映射生成完成')
} catch (error) { } catch (error) {
responsePayload.value = null responsePayload.value = null
xmlResponsePayload.value = null
ElMessage.error(getErrorMessage(error)) ElMessage.error(getErrorMessage(error))
} finally { } finally {
isGenerating.value = false isGenerating.value = false
} }
} }
const buildExportFileName = () => { const buildExportFileName = (type: ExportMappingType) => {
const baseFileName = selectedIcdFile.value?.name.replace(/\.[^.]+$/, '') || 'mapping-summary' const baseFileName = selectedIcdFile.value?.name.replace(/\.[^.]+$/, '')
return `${baseFileName}-mapping-summary.json` const suffix = `${type}-mapping.${type}`
return baseFileName ? `${baseFileName}-${suffix}` : suffix
} }
const handleExportMapping = () => { const downloadTextFile = (content: string, fileName: string, mimeType: string) => {
if (!mappingJsonPreview.value) { const blob = new Blob([content], { type: mimeType })
ElMessage.warning('当前没有可导出的映射摘要')
return
}
const blob = new Blob([mappingJsonPreview.value], { type: 'application/json;charset=utf-8' })
const objectUrl = window.URL.createObjectURL(blob) const objectUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = objectUrl link.href = objectUrl
link.download = buildExportFileName() link.download = fileName
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
window.URL.revokeObjectURL(objectUrl) 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 = () => { const resetPage = () => {

View File

@@ -2,14 +2,14 @@
<section class="mapping-panel config-panel"> <section class="mapping-panel config-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2 class="panel-title">请求配置</h2> <h2 class="panel-title">人工索引配置</h2>
<p class="panel-description">这里展示并可继续编辑经人工确认后生成的 request.indexSelection</p> <p class="panel-description">展示现有的人工索引配置并允许继续编辑</p>
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<el-button <el-button
v-if="showConfirmButton" v-if="showConfirmButton"
type="primary" type="primary"
plain :icon="EditPen"
:disabled="!canConfirm" :disabled="!canConfirm"
@click="emit('confirm-config')" @click="emit('confirm-config')"
> >
@@ -19,11 +19,11 @@
v-if="showGenerateButton" v-if="showGenerateButton"
type="primary" type="primary"
:icon="Connection" :icon="Connection"
:loading="isSubmitting" :loading="isGenerating"
:disabled="!canGenerate" :disabled="!canGenerate"
@click="emit('generate')" @click="emit('generate')"
> >
生成映射 生成JSON映射
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -39,7 +39,7 @@
:disabled="isSubmitting" :disabled="isSubmitting"
:rows="18" :rows="18"
resize="none" resize="none"
placeholder="人工确认完成后,这里会自动回填 request.indexSelection,仍可继续直接编辑。" placeholder="人工索引配置完成后,这里会自动回填索引配置,仍可继续直接编辑。"
@update:model-value="value => emit('update:indexSelectionJson', String(value || ''))" @update:model-value="value => emit('update:indexSelectionJson', String(value || ''))"
/> />
</div> </div>
@@ -50,7 +50,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Connection } from '@element-plus/icons-vue' import { Connection, EditPen } from '@element-plus/icons-vue'
defineOptions({ defineOptions({
name: 'MappingConfigPanel' name: 'MappingConfigPanel'
@@ -59,6 +59,7 @@ defineOptions({
defineProps<{ defineProps<{
indexSelectionJson: string indexSelectionJson: string
isSubmitting: boolean isSubmitting: boolean
isGenerating: boolean
canGenerate: boolean canGenerate: boolean
jsonError: string jsonError: string
showGenerateButton: boolean showGenerateButton: boolean
@@ -103,8 +104,10 @@ const emit = defineEmits<{
.panel-actions { .panel-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center;
gap: 12px; gap: 12px;
white-space: nowrap;
} }
.panel-title { .panel-title {
@@ -132,9 +135,9 @@ const emit = defineEmits<{
} }
.panel-section { .panel-section {
border: 1px solid #e5e7eb; border: none;
border-radius: 12px; border-radius: 0;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); background: transparent;
} }
.result-card { .result-card {
@@ -142,13 +145,17 @@ const emit = defineEmits<{
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
padding: 16px; padding: 0;
overflow: hidden; overflow: hidden;
} }
.index-selection-textarea { .index-selection-textarea {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
margin-top: 0;
}
.json-alert + .index-selection-textarea {
margin-top: 16px; margin-top: 16px;
} }

View File

@@ -17,7 +17,13 @@
placeholder="请选择 `.icd`、`.cid`、`.scd` 或 `.xml` 文件" placeholder="请选择 `.icd`、`.cid`、`.scd` 或 `.xml` 文件"
class="file-input" 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 选择 ICD
</el-button> </el-button>
<input <input
@@ -30,15 +36,16 @@
</div> </div>
<el-button <el-button
type="primary" type="primary"
plain
:icon="Search" :icon="Search"
:loading="isSubmitting" :loading="isParsing"
:disabled="!selectedIcdFileName" :disabled="!canParse"
@click="emit('parse')" @click="emit('parse')"
> >
解析 ICD 解析 ICD
</el-button> </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>
</div> </div>
</section> </section>
@@ -57,9 +64,11 @@ type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
defineProps<{ defineProps<{
selectedIcdFileName: string selectedIcdFileName: string
isSubmitting: boolean isSubmitting: boolean
isParsing: boolean
icdFileAccept: string icdFileAccept: string
requestStatusText: string requestStatusText: string
requestStatusTagType: TagType requestStatusTagType: TagType
canParse: boolean
canReset: boolean canReset: boolean
}>() }>()

View File

@@ -2,13 +2,49 @@
<section class="mapping-panel"> <section class="mapping-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2 class="panel-title">调试输出</h2> <h2 class="panel-title">映射结果</h2>
<p class="panel-description">右侧展示最近一次接口返回的 mappingJson problems并支持导出当前映射摘要</p> <p class="panel-description">展示和导出JSON与XML的映射结果以及JSON的映射序列配置</p>
</div> </div>
<div class="panel-actions"> <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> </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> <el-tag :type="responseStatusTagType" effect="light">{{ responseStatusText }}</el-tag>
</div> </div>
</div> </div>
@@ -16,63 +52,428 @@
<div class="panel-content panel-content--fixed"> <div class="panel-content panel-content--fixed">
<div class="panel-section result-card grow-card preview-tab-section"> <div class="panel-section result-card grow-card preview-tab-section">
<el-tabs v-model="activeTabProxy" class="preview-tabs"> <el-tabs v-model="activeTabProxy" class="preview-tabs">
<el-tab-pane label="映射摘要" name="mapping"> <el-tab-pane label="JSON映射" name="json">
<div class="preview-header preview-header--compact">
<div class="preview-meta">{{ mappingMetaText }}</div>
</div>
<div class="mapping-json-scroll"> <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" /> <el-empty v-else description="当前返回未包含 mappingJson" />
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="problemTabLabel" name="problem"> <el-tab-pane v-if="showXmlMappingTab" label="XML映射" name="xml">
<div class="problem-section"> <div class="mapping-json-scroll">
<div v-if="problemList.length" class="problem-list"> <div class="match-result-actions">
<div v-for="(problem, index) in problemList" :key="`${index}-${problem}`" class="problem-item"> <el-button
<span class="problem-index">{{ index + 1 }}</span> type="primary"
<span class="problem-text">{{ problem }}</span> plain
</div> :icon="Document"
:disabled="!methodDescribe"
@click="matchResultDialogVisible = true"
>
匹配结果展示
</el-button>
</div> </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> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
</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> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Download } from '@element-plus/icons-vue' import { ArrowDown, Connection, Document, Download, Search, Setting, Warning } from '@element-plus/icons-vue'
import { computed } from 'vue' import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import JsonMappingTree from './JsonMappingTree.vue'
defineOptions({ defineOptions({
name: 'MappingResultPanel' name: 'MappingResultPanel'
}) })
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger' 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<{ const props = defineProps<{
responseStatusText: string responseStatusText: string
responseStatusTagType: TagType responseStatusTagType: TagType
activeResultTab: 'mapping' | 'problem' activeResultTab: ResultTab
mappingMetaText: string mappingMetaText: string
mappingJsonPreview: string mappingJsonPreview: string
xmlMetaText: string
xmlMappingPreview: string
xmlEmptyText: string
problemTabLabel: string problemTabLabel: string
problemList: string[] problemList: string[]
problemEmptyText: string problemEmptyText: string
canExportMapping: boolean methodDescribe: string
canExportJsonMapping: boolean
canExportXmlMapping: boolean
canGenerateXmlMapping: boolean
isGeneratingXml: boolean
showXmlMappingTab: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:activeResultTab', value: 'mapping' | 'problem'): void (event: 'update:activeResultTab', value: ResultTab): void
(event: 'export-mapping'): void (event: 'export-mapping', value: ExportMappingType): void
(event: 'generate-xml-mapping'): void
(event: 'update-mapping-json', value: string): void
}>() }>()
const activeTabProxy = computed({ const activeTabProxy = computed({
get: () => props.activeResultTab, get: () => (props.activeResultTab === 'problem' ? 'json' : props.activeResultTab),
set: value => emit('update:activeResultTab', value) 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -98,10 +499,25 @@ const activeTabProxy = computed({
.panel-actions { .panel-actions {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 12px; 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 { .panel-title {
margin: 0; margin: 0;
font-size: 22px; font-size: 22px;
@@ -189,25 +605,7 @@ const activeTabProxy = computed({
min-height: 0; min-height: 0;
} }
.preview-header { .mapping-json-scroll {
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 {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@@ -229,10 +627,64 @@ const activeTabProxy = computed({
word-break: break-word; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
max-height: 62vh;
padding-right: 4px;
overflow: auto;
} }
.problem-item { .problem-item {
@@ -267,16 +719,189 @@ const activeTabProxy = computed({
word-break: break-word; 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) { @media (max-width: 768px) {
.mapping-panel { .mapping-panel {
padding: 20px; padding: 20px;
} }
.panel-header, .panel-header,
.panel-actions, .panel-actions {
.preview-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.sequence-config-item {
grid-template-columns: 1fr;
}
.sequence-config-form {
grid-template-columns: 1fr;
}
} }
</style> </style>

View File

@@ -15,30 +15,30 @@ const normalizeOptionalString = (value: unknown) => (typeof value === 'string' ?
const normalizeBindings = (value: unknown, groupIndex: number): MmsMapping.IndexSelectionBinding[] => { const normalizeBindings = (value: unknown, groupIndex: number): MmsMapping.IndexSelectionBinding[] => {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
throw new Error(`request.indexSelection[${groupIndex}].bindings 必须是非空数组`) throw new Error(`索引配置第 ${groupIndex + 1} 组的 bindings 必须是非空数组`)
} }
return value.map((binding, bindingIndex) => { return value.map((binding, bindingIndex) => {
if (!isRecord(binding)) { if (!isRecord(binding)) {
throw new Error(`request.indexSelection[${groupIndex}].bindings[${bindingIndex}] 必须是对象`) throw new Error(`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 必须是对象`)
} }
return { return {
reportName: normalizeRequiredString( reportName: normalizeRequiredString(
binding.reportName, binding.reportName,
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].reportName` `索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 reportName`
), ),
dataSetName: normalizeRequiredString( dataSetName: normalizeRequiredString(
binding.dataSetName, binding.dataSetName,
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].dataSetName` `索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 dataSetName`
), ),
label: normalizeRequiredString( label: normalizeRequiredString(
binding.label, binding.label,
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].label` `索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 label`
), ),
lnInst: normalizeRequiredString( lnInst: normalizeRequiredString(
binding.lnInst, 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 { try {
parsed = JSON.parse(source) parsed = JSON.parse(source)
} catch { } catch {
throw new Error('request.indexSelection 不是合法 JSON') throw new Error('索引配置不是合法 JSON')
} }
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
throw new Error('request.indexSelection 必须是数组') throw new Error('索引配置必须是数组')
} }
return parsed.map((group, groupIndex) => { return parsed.map((group, groupIndex) => {
if (!isRecord(group)) { if (!isRecord(group)) {
throw new Error(`request.indexSelection[${groupIndex}] 必须是对象`) throw new Error(`索引配置第 ${groupIndex + 1} 必须是对象`)
} }
const groupDesc = normalizeOptionalString(group.groupDesc) const groupDesc = normalizeOptionalString(group.groupDesc)
return { return {
groupKey: normalizeRequiredString(group.groupKey, `request.indexSelection[${groupIndex}].groupKey`), groupKey: normalizeRequiredString(group.groupKey, `索引配置第 ${groupIndex + 1} 组的 groupKey`),
groupDesc, groupDesc,
bindings: normalizeBindings(group.bindings, groupIndex) bindings: normalizeBindings(group.bindings, groupIndex)
} }

View File

@@ -15,6 +15,21 @@
</div> </div>
</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> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="向量信息" name="vector"> <el-tab-pane label="向量信息" name="vector">
@@ -43,7 +58,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import type { Waveform } from '@/api/tools/waveform/interface' import type { Waveform } from '@/api/tools/waveform/interface'
import type { SummaryItem } from './types' import type { MarkerDataItem, SummaryItem } from './types'
import WaveformVectorInfo from './WaveformVectorInfo.vue' import WaveformVectorInfo from './WaveformVectorInfo.vue'
defineProps<{ defineProps<{
@@ -53,6 +68,7 @@ defineProps<{
lastParseErrorMessage: string lastParseErrorMessage: string
lastVectorParseErrorMessage: string lastVectorParseErrorMessage: string
activeVectorChannelName: string activeVectorChannelName: string
markerDataItems: MarkerDataItem[]
}>() }>()
// 右侧信息区仅切换展示内容,不影响波形与向量解析结果的联动状态。 // 右侧信息区仅切换展示内容,不影响波形与向量解析结果的联动状态。
@@ -198,6 +214,75 @@ const activeInfoTab = ref('waveform')
word-break: break-all; 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) { @media (max-width: 1200px) {
@@ -211,4 +296,4 @@ const activeInfoTab = ref('waveform')
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
</style> </style>

View File

@@ -11,6 +11,7 @@
:options="group.multiChannelOptions" :options="group.multiChannelOptions"
:group="group.group" :group="group.group"
@chart-data-zoom="handleChartDataZoom" @chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/> />
</div> </div>
<template v-else> <template v-else>
@@ -25,6 +26,7 @@
:options="item.options" :options="item.options"
:group="item.group" :group="item.group"
@chart-data-zoom="handleChartDataZoom" @chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/> />
</div> </div>
</div> </div>
@@ -35,6 +37,7 @@
<LineChart <LineChart
:options="activeTrendOptions" :options="activeTrendOptions"
@chart-data-zoom="handleChartDataZoom" @chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/> />
</div> </div>
<div v-else-if="hasWaveformData" class="single-channel-list"> <div v-else-if="hasWaveformData" class="single-channel-list">
@@ -49,6 +52,7 @@
:options="item.options" :options="item.options"
:group="item.group" :group="item.group"
@chart-data-zoom="handleChartDataZoom" @chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/> />
</div> </div>
</div> </div>
@@ -69,6 +73,7 @@ import type {
AllChannelTrendGroup, AllChannelTrendGroup,
DisplayMode, DisplayMode,
SingleChannelTrendOption, SingleChannelTrendOption,
TrendChartClickPayload,
TrendChartZoomPayload TrendChartZoomPayload
} from './types' } from './types'
@@ -84,11 +89,16 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'chart-data-zoom': [value: TrendChartZoomPayload] 'chart-data-zoom': [value: TrendChartZoomPayload]
'chart-click': [value: TrendChartClickPayload]
}>() }>()
const handleChartDataZoom = (value: TrendChartZoomPayload) => { const handleChartDataZoom = (value: TrendChartZoomPayload) => {
emit('chart-data-zoom', value) emit('chart-data-zoom', value)
} }
const handleChartClick = (value: TrendChartClickPayload) => {
emit('chart-click', value)
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -35,6 +35,7 @@
:is-all-channels-active="isAllChannelsActive" :is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage" :last-parse-error-message="lastParseErrorMessage"
@chart-data-zoom="handleChartDataZoom" @chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/> />
<el-dialog <el-dialog
@@ -55,6 +56,7 @@
:is-all-channels-active="isAllChannelsActive" :is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage" :last-parse-error-message="lastParseErrorMessage"
@chart-data-zoom="handleChartDataZoom" @chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/> />
</el-dialog> </el-dialog>
</section> </section>
@@ -69,6 +71,7 @@ import {
Crop, Crop,
Download, Download,
FullScreen, FullScreen,
Location,
Picture, Picture,
Pointer, Pointer,
RefreshLeft RefreshLeft
@@ -80,6 +83,7 @@ import type {
DisplayMode, DisplayMode,
LabelValueOption, LabelValueOption,
SingleChannelTrendOption, SingleChannelTrendOption,
TrendChartClickPayload,
TrendChartZoomPayload, TrendChartZoomPayload,
TrendToolAction, TrendToolAction,
TrendTabValue TrendTabValue
@@ -106,6 +110,7 @@ const emit = defineEmits<{
'update:activeTrendTab': [value: TrendTabValue] 'update:activeTrendTab': [value: TrendTabValue]
'trend-tool-action': [value: TrendToolAction] 'trend-tool-action': [value: TrendToolAction]
'chart-data-zoom': [value: TrendChartZoomPayload] 'chart-data-zoom': [value: TrendChartZoomPayload]
'chart-click': [value: TrendChartClickPayload]
}>() }>()
const fullscreenVisible = ref(false) const fullscreenVisible = ref(false)
@@ -126,6 +131,7 @@ const trendToolGroups: Array<{
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold }, { action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold }, { action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
{ action: 'box-zoom', label: '框选放大', icon: Crop }, { action: 'box-zoom', label: '框选放大', icon: Crop },
{ action: 'mark', label: '标记', icon: Location },
{ action: 'reset', label: '恢复', icon: RefreshLeft }, { action: 'reset', label: '恢复', icon: RefreshLeft },
{ action: 'pan', label: '平移', icon: Pointer } { action: 'pan', label: '平移', icon: Pointer }
] ]
@@ -179,6 +185,10 @@ const handleTrendToolClick = (action: TrendPanelToolAction) => {
const handleChartDataZoom = (value: TrendChartZoomPayload) => { const handleChartDataZoom = (value: TrendChartZoomPayload) => {
emit('chart-data-zoom', value) emit('chart-data-zoom', value)
} }
const handleChartClick = (value: TrendChartClickPayload) => {
emit('chart-click', value)
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -8,6 +8,7 @@ export type TrendToolAction =
| 'y-zoom-in' | 'y-zoom-in'
| 'y-zoom-out' | 'y-zoom-out'
| 'box-zoom' | 'box-zoom'
| 'mark'
| 'pan' | 'pan'
| 'reset' | 'reset'
| 'download-image' | 'download-image'
@@ -44,6 +45,15 @@ export interface SummaryItem {
value: string | number value: string | number
} }
export interface MarkerDataItem {
name: string
axisValueText: string
rows: Array<{
label: string
value: string
}>
}
export interface FeatureCardItem { export interface FeatureCardItem {
title: string title: string
rows: Array<{ rows: Array<{
@@ -56,3 +66,8 @@ export interface TrendChartZoomPayload {
start: number start: number
end: number end: number
} }
export interface TrendChartClickPayload {
dataIndex: number
axisValue: string | number
}

View File

@@ -32,6 +32,7 @@
@update:active-trend-tab="activeTrendTab = $event" @update:active-trend-tab="activeTrendTab = $event"
@trend-tool-action="handleTrendToolAction" @trend-tool-action="handleTrendToolAction"
@chart-data-zoom="handleTrendChartDataZoom" @chart-data-zoom="handleTrendChartDataZoom"
@chart-click="handleTrendChartClick"
/> />
<WaveformInfoPanel <WaveformInfoPanel
@@ -41,6 +42,7 @@
:last-parse-error-message="lastParseErrorMessage" :last-parse-error-message="lastParseErrorMessage"
:last-vector-parse-error-message="lastVectorParseErrorMessage" :last-vector-parse-error-message="lastVectorParseErrorMessage"
:active-vector-channel-name="activeWaveDetail?.channelName || ''" :active-vector-channel-name="activeWaveDetail?.channelName || ''"
:marker-data-items="markerDataItems"
/> />
</div> </div>
</div> </div>
@@ -61,8 +63,10 @@ import type {
ChannelSelectValue, ChannelSelectValue,
DisplayMode, DisplayMode,
LabelValueOption, LabelValueOption,
MarkerDataItem,
SingleChannelTrendOption, SingleChannelTrendOption,
SummaryItem, SummaryItem,
TrendChartClickPayload,
TrendChartZoomPayload, TrendChartZoomPayload,
TrendToolAction, TrendToolAction,
TrendTabValue, TrendTabValue,
@@ -89,6 +93,7 @@ interface WaveformTrendPayload {
interface TrendChartLayoutOptions { interface TrendChartLayoutOptions {
showTimeAxis?: boolean showTimeAxis?: boolean
showMarkerLabel?: boolean
yAxisSplitCount?: number yAxisSplitCount?: number
} }
@@ -97,6 +102,12 @@ interface TrendZoomRange {
end: number end: number
} }
interface TrendMarker {
name: 'T1' | 'T2'
dataIndex: number
axisValue: string | number
}
const activeTrendTab = ref<TrendTabValue>('instant') const activeTrendTab = ref<TrendTabValue>('instant')
const activeValueMode = ref<ValueMode>('primary') const activeValueMode = ref<ValueMode>('primary')
const activeDisplayMode = ref<DisplayMode>('single-channel') const activeDisplayMode = ref<DisplayMode>('single-channel')
@@ -113,7 +124,8 @@ const lastVectorParseErrorMessage = ref('')
const waveformFileAccept = '.cfg,.dat' const waveformFileAccept = '.cfg,.dat'
const trendXZoomRange = ref<TrendZoomRange>({ start: 0, end: 100 }) const trendXZoomRange = ref<TrendZoomRange>({ start: 0, end: 100 })
const trendYZoomScale = ref(1) 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>[] = [ const trendTabs: LabelValueOption<TrendTabValue>[] = [
{ value: 'instant', label: '瞬时波形' }, { value: 'instant', label: '瞬时波形' },
@@ -362,6 +374,7 @@ const canResetTrendChart = computed(() => {
const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({ const activeTrendToolStates = computed<Partial<Record<TrendToolAction, boolean>>>(() => ({
'box-zoom': activeTrendInteractionMode.value === 'box-zoom', 'box-zoom': activeTrendInteractionMode.value === 'box-zoom',
mark: activeTrendInteractionMode.value === 'mark',
pan: activeTrendInteractionMode.value === 'pan' pan: activeTrendInteractionMode.value === 'pan'
})) }))
@@ -467,6 +480,7 @@ const resetTrendToolState = () => {
trendXZoomRange.value = { start: 0, end: 100 } trendXZoomRange.value = { start: 0, end: 100 }
trendYZoomScale.value = 1 trendYZoomScale.value = 1
activeTrendInteractionMode.value = 'none' activeTrendInteractionMode.value = 'none'
trendMarkers.value = []
} }
const zoomTrendXAxis = (ratio: number) => { const zoomTrendXAxis = (ratio: number) => {
@@ -674,8 +688,10 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = T
} }
} }
const buildTrendSeries = (seriesList: WaveformSeriesItem[]) => { const buildTrendSeries = (seriesList: WaveformSeriesItem[], showMarkerLabel: boolean) => {
return seriesList.map(item => { const markerLine = buildTrendMarkerLine(seriesList, showMarkerLabel)
return seriesList.map((item, index) => {
const visiblePointCount = resolveTrendVisiblePointCount(item.data.length) const visiblePointCount = resolveTrendVisiblePointCount(item.data.length)
return { return {
@@ -687,11 +703,70 @@ const buildTrendSeries = (seriesList: WaveformSeriesItem[]) => {
lineStyle: { lineStyle: {
width: resolveTrendLineWidth(visiblePointCount) width: resolveTrendLineWidth(visiblePointCount)
}, },
markLine: index === 0 ? markerLine : undefined,
data: item.data 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 buildTimeAxisLabelFormatter = (timeLabels: string[]) => {
const lastIndex = timeLabels.length - 1 const lastIndex = timeLabels.length - 1
@@ -737,7 +812,7 @@ const buildTrendChartOptions = (
chartColors = currentTrendColors.value, chartColors = currentTrendColors.value,
layoutOptions: TrendChartLayoutOptions = {} layoutOptions: TrendChartLayoutOptions = {}
) => { ) => {
const { showTimeAxis = true } = layoutOptions const { showTimeAxis = true, showMarkerLabel = showTimeAxis } = layoutOptions
const yAxisSplitCount = resolveTrendAxisSplitCount(layoutOptions) const yAxisSplitCount = resolveTrendAxisSplitCount(layoutOptions)
const yAxisConfig = applyYAxisZoom(buildTrendAxisConfig(trendPayload, yAxisSplitCount)) const yAxisConfig = applyYAxisZoom(buildTrendAxisConfig(trendPayload, yAxisSplitCount))
@@ -832,7 +907,7 @@ const buildTrendChartOptions = (
} }
}, },
color: chartColors, 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) => { const handleTrendToolAction = async (action: TrendToolAction) => {
if (!hasWaveformData.value) return if (!hasWaveformData.value) return
@@ -966,6 +1083,9 @@ const handleTrendToolAction = async (action: TrendToolAction) => {
case 'box-zoom': case 'box-zoom':
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom' activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
break break
case 'mark':
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'mark' ? 'none' : 'mark'
break
case 'pan': case 'pan':
if (!canPanTrendChart.value) { if (!canPanTrendChart.value) {
ElMessage.info('请先放大 X 轴或框选局部区域后再平移') 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], () => { watch([activeTrendTab, activeValueMode, activeDisplayMode, activeChannelIndex], () => {
resetTrendToolState() resetTrendToolState()
}) })