Files
CN_Tool_client/frontend/src/views/tools/mmsMapping/components/MappingConfirmDialog.vue
yexb a1e1fb124a feat(mmsmapping): 添加 XML 映射生成功能和波形标记功能
- 新增 getXmlFromJsonApi 接口用于从 JSON 生成 XML 映射
- 添加 XML 映射相关的数据结构定义和响应处理
- 实现 XML 映射生成功能,支持 JSON 到 XML 的转换
- 添加波形图表点击事件处理和标记功能
- 实现趋势图表的标记点显示和标签功能
- 更新界面以支持 XML 映射预览和导出
- 优化图表交互体验,添加标记工具模式
- 重构部分界面组件以支持新的映射功能
2026-05-08 09:54:52 +08:00

616 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<el-dialog
:model-value="visible"
title="人工索引配置"
width="960px"
destroy-on-close
top="6vh"
class="mapping-confirm-dialog"
@close="emit('update:visible', false)"
>
<div class="dialog-description">
这里展示 ICD 候选索引的人工确认结果请按分组确认每个标签是否启用并为已启用标签选择合法的
lnInst确认后会自动回填到索引配置
</div>
<el-empty v-if="!draftGroups.length" description="当前没有可确认的索引分组。" />
<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>
<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 }}
</el-tag>
</template>
<span v-else class="label-hint">当前没有共同 lnInst</span>
</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"
/>
</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>
</div>
</div>
</div>
</article>
</div>
</section>
</div>
<el-empty v-else description="当前检索条件下没有匹配的索引配置。" />
</template>
<template #footer>
<div class="dialog-footer">
<div v-if="validationMessage" class="footer-message">{{ validationMessage }}</div>
<div class="footer-actions">
<el-button :disabled="submitting" @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="Boolean(validationMessage)" @click="handleConfirm">
确认并生成索引配置
</el-button>
</div>
</div>
</template>
</el-dialog>
</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'
defineOptions({
name: 'MappingConfirmDialog'
})
interface ConfirmDialogDraftTarget {
targetKey: string
reportName: string
dataSetName: string
reportDesc: string
availableLnInstValues: string[]
}
interface ConfirmDialogDraftLabelItem {
itemKey: string
label: string
required: boolean
configurableOnce: boolean
enabled: boolean
lnInst: string
commonLnInstValues: string[]
targets: ConfirmDialogDraftTarget[]
}
interface ConfirmDialogDraftGroup {
groupKey: string
groupDesc: string
labelItems: ConfirmDialogDraftLabelItem[]
}
interface PreparedConfirmDialogDraftLabelItem {
itemKey: string
label: string
required: boolean
configurableOnce: boolean
defaultLnInst: string
commonLnInstValues: string[]
targets: ConfirmDialogDraftTarget[]
}
const props = defineProps<{
visible: boolean
submitting: boolean
confirmData: MmsMapping.IndexConfirmGroup[]
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'confirm', value: MmsMapping.ConfirmedIndexGroup[]): void
}>()
const normalizeStringArray = (values?: string[]) => (values || []).map(value => value?.trim() || '').filter(Boolean)
const sortLnInstValues = (values: string[]) =>
[...values].sort((left, right) => {
const leftNumber = Number(left)
const rightNumber = Number(right)
const bothNumeric = !Number.isNaN(leftNumber) && !Number.isNaN(rightNumber)
if (bothNumeric && leftNumber !== rightNumber) {
return leftNumber - rightNumber
}
return left.localeCompare(right, 'zh-CN', { numeric: true })
})
const buildLnInstCluster = (items: PreparedConfirmDialogDraftLabelItem[]) => {
const clusters: PreparedConfirmDialogDraftLabelItem[][] = []
items.forEach(item => {
const itemValueSet = new Set(item.commonLnInstValues)
const matchedCluster = clusters.find(cluster =>
cluster.some(clusterItem => clusterItem.commonLnInstValues.some(value => itemValueSet.has(value)))
)
if (matchedCluster) {
matchedCluster.push(item)
return
}
clusters.push([item])
})
return clusters
}
const resolveDefaultLnInst = (commonLnInstValues: string[], expectedLnInst: string) => {
if (!commonLnInstValues.length) return ''
if (!expectedLnInst) return ''
if (!commonLnInstValues.includes(expectedLnInst)) return ''
return expectedLnInst
}
const buildInitialDraftGroups = (groups: MmsMapping.IndexConfirmGroup[]): ConfirmDialogDraftGroup[] =>
groups
.map(group => {
const preparedItems = (group.labelItems || [])
.map<PreparedConfirmDialogDraftLabelItem | null>((item, itemIndex) => {
const commonLnInstValues = sortLnInstValues(normalizeStringArray(item.commonLnInstValues))
const defaultLnInst = resolveDefaultLnInst(commonLnInstValues, item.defaultLnInst?.trim() || '')
const itemKey = `${group.groupKey?.trim() || 'group'}-${itemIndex}-${item.label?.trim() || 'label'}`
return {
itemKey,
label: item.label?.trim() || '',
required: Boolean(item.required),
configurableOnce: Boolean(item.configurableOnce),
defaultLnInst,
commonLnInstValues,
targets: (item.targets || []).map((target, targetIndex) => ({
targetKey: `${itemKey}-target-${targetIndex}-${target.reportName?.trim() || ''}-${target.dataSetName?.trim() || ''}`,
reportName: target.reportName?.trim() || '',
dataSetName: target.dataSetName?.trim() || '',
reportDesc: target.reportDesc?.trim() || '',
availableLnInstValues: sortLnInstValues(normalizeStringArray(target.availableLnInstValues))
}))
}
})
.filter((item): item is PreparedConfirmDialogDraftLabelItem => Boolean(item?.label))
const clusters = buildLnInstCluster(preparedItems)
const defaultStateMap = new Map<
string,
{
enabled: boolean
lnInst: string
}
>()
clusters.forEach(cluster => {
const clusterValues = sortLnInstValues(
Array.from(new Set(cluster.flatMap(item => item.commonLnInstValues)))
)
cluster.forEach((item, index) => {
const expectedLnInst = index < clusterValues.length ? clusterValues[index] : ''
const defaultLnInst = item.defaultLnInst || resolveDefaultLnInst(item.commonLnInstValues, expectedLnInst)
const enabled = item.required || Boolean(defaultLnInst)
defaultStateMap.set(item.itemKey, {
enabled,
lnInst: defaultLnInst
})
})
})
return {
groupKey: group.groupKey?.trim() || '',
groupDesc: group.groupDesc?.trim() || '',
labelItems: preparedItems.map(item => {
const defaultState = defaultStateMap.get(item.itemKey) || {
enabled: item.required,
lnInst: ''
}
return {
itemKey: item.itemKey,
label: item.label,
required: item.required,
configurableOnce: item.configurableOnce,
enabled: defaultState.enabled,
lnInst: defaultState.lnInst,
commonLnInstValues: item.commonLnInstValues,
targets: item.targets
}
})
}
})
.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,
([confirmData, visible]) => {
if (!visible) return
// 关键业务节点:弹窗每次打开都基于最新 confirmData 重新生成草稿,避免不同 ICD 的确认状态串用。
draftGroups.value = buildInitialDraftGroups(confirmData)
indexSearchKeyword.value = ''
},
{ immediate: true }
)
const validationMessage = computed(() => {
for (const group of draftGroups.value) {
for (const item of group.labelItems) {
if (!item.enabled) continue
if (!item.lnInst) return `分组“${group.groupDesc || group.groupKey}”中的标签“${item.label}”必须选择 lnInst`
}
}
return ''
})
const buildConfirmedGroups = (): MmsMapping.ConfirmedIndexGroup[] =>
draftGroups.value.map(group => ({
groupKey: group.groupKey,
labelItems: group.labelItems.map(item => ({
label: item.label,
enabled: item.enabled,
lnInst: item.enabled ? item.lnInst : ''
}))
}))
const handleConfirm = () => {
if (validationMessage.value) return
emit('confirm', buildConfirmedGroups())
}
</script>
<style scoped lang="scss">
.dialog-description {
margin-bottom: 16px;
font-size: 14px;
line-height: 1.7;
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;
gap: 16px;
max-height: 68vh;
padding-right: 4px;
overflow: auto;
}
.group-card {
padding: 20px;
border: 1px solid #dbe3f0;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.group-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.group-title {
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 1.5;
color: #1f2937;
}
.group-key {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.6;
color: #64748b;
word-break: break-all;
}
.label-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.label-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
}
.label-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.label-meta,
.label-actions,
.target-list {
min-width: 0;
}
.label-meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
}
.label-title-row,
.label-options,
.target-lninst-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.label-title {
font-size: 16px;
font-weight: 600;
line-height: 1.5;
color: #111827;
}
.label-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.lninst-select {
width: 180px;
}
.label-hint {
font-size: 13px;
line-height: 1.6;
color: #64748b;
}
.label-alert {
margin-top: 12px;
}
.target-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
.target-item {
padding: 12px;
border-radius: 10px;
background: #f8fafc;
}
.target-name-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.target-name {
font-size: 14px;
font-weight: 600;
line-height: 1.6;
color: #1f2937;
}
.target-code {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
color: #64748b;
word-break: break-all;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.footer-message {
font-size: 13px;
line-height: 1.6;
color: #d97706;
text-align: left;
}
.footer-actions {
display: flex;
gap: 12px;
margin-left: auto;
}
@media (max-width: 768px) {
.dialog-content {
max-height: 62vh;
}
.group-card,
.label-card {
padding: 14px;
}
.group-header,
.label-main,
.dialog-search-bar,
.dialog-footer {
flex-direction: column;
align-items: flex-start;
}
.label-actions,
.footer-actions {
width: 100%;
}
.lninst-select {
width: 100%;
}
}
</style>