<script setup lang="ts">
|
import type { FormulaBindingCandidate, FormulaBindingRequest, TemplateFormulaBinding, TemplateFormulaVersion } from '../domain/formula-mapping';
|
|
import { computed, h, reactive, ref } from 'vue';
|
|
import { MoreOutlined } from '@ant-design/icons-vue';
|
import { Badge } from 'ant-design-vue';
|
|
import { formulaVersionOptions, parameterBindingCandidates, resultBindingCandidates } from '../domain/formula-mapping';
|
|
type LabeledCandidate = FormulaBindingCandidate & { label: string };
|
|
const props = withDefaults(
|
defineProps<{
|
candidates: LabeledCandidate[];
|
disabled?: boolean;
|
mappings?: TemplateFormulaBinding[];
|
usedResultBindingIds?: string[];
|
versions: TemplateFormulaVersion[];
|
}>(),
|
{ disabled: false, mappings: () => [], usedResultBindingIds: () => [] },
|
);
|
|
const emit = defineEmits<{
|
remove: [bindingId: string];
|
reserve: [request: Omit<FormulaBindingRequest, 'operationCode'>];
|
upgrade: [bindingId: string, request: Omit<FormulaBindingRequest, 'operationCode'>];
|
}>();
|
const mode = ref<'create' | 'existing'>('existing');
|
const selectedVersionId = ref('');
|
const selectedMappingId = ref('');
|
const parameterBindings = reactive<Record<string, string>>({});
|
const resultBindingId = ref('');
|
const selectableVersionIds = computed(
|
() =>
|
new Set(
|
formulaVersionOptions(props.versions)
|
.filter((option) => option.selectable)
|
.map((option) => option.id),
|
),
|
);
|
const versionSelectOptions = computed(() =>
|
props.versions
|
.filter((version) => selectableVersionIds.value.has(version.id))
|
.map((version) => ({
|
label: `${version.formulaName}${version.status === 'PRE_RELEASE' ? '(预发布)' : ''}`,
|
value: version.id,
|
})),
|
);
|
const selectedVersion = computed(() => props.versions.find((version) => version.id === selectedVersionId.value));
|
const selectedMapping = computed(() => props.mappings.find((mapping) => mapping.id === selectedMappingId.value));
|
const candidateById = computed(() => new Map(props.candidates.map((item) => [item.id, item])));
|
const versionById = computed(() => new Map(props.versions.map((item) => [item.id, item])));
|
const activeMappings = computed(() => props.mappings.filter((mapping) => mapping.status === 'ACTIVE' || mapping.status === 'PENDING'));
|
const mappingBadgeStyle = { backgroundColor: '#f0f0f0', boxShadow: 'none', color: '#595959' };
|
const modeOptions = computed(() => [
|
{
|
label: () =>
|
h('span', { class: 'formula-mode-label' }, [
|
h('span', '已有映射'),
|
activeMappings.value.length > 0
|
? h(Badge, { count: activeMappings.value.length, 'data-test': 'existing-mapping-count', numberStyle: mappingBadgeStyle })
|
: null,
|
]),
|
value: 'existing',
|
},
|
{ label: '新建映射', value: 'create' },
|
]);
|
const resultIds = computed(() =>
|
selectedVersion.value
|
? resultBindingCandidates(selectedVersion.value.resultType, props.candidates).filter(
|
(id) => !props.usedResultBindingIds.includes(id) || id === selectedMapping.value?.resultBindingId,
|
)
|
: [],
|
);
|
const canSave = computed(() =>
|
Boolean(selectedVersion.value && resultBindingId.value && selectedVersion.value.slots.every((slot) => Boolean(parameterBindings[slot.code]))),
|
);
|
|
function resetForm(): void {
|
selectedVersionId.value = '';
|
selectedMappingId.value = '';
|
resultBindingId.value = '';
|
for (const key of Object.keys(parameterBindings)) delete parameterBindings[key];
|
}
|
function chooseVersion(id: string): void {
|
if (!selectableVersionIds.value.has(id)) return;
|
selectedVersionId.value = id;
|
if (!selectedMappingId.value) {
|
resultBindingId.value = '';
|
for (const key of Object.keys(parameterBindings)) delete parameterBindings[key];
|
return;
|
}
|
const slotCodes = new Set(selectedVersion.value?.slots.map((slot) => slot.code) || []);
|
for (const key of Object.keys(parameterBindings)) {
|
if (!slotCodes.has(key)) delete parameterBindings[key];
|
}
|
}
|
function candidatesForSlot(type: TemplateFormulaVersion['resultType']): LabeledCandidate[] {
|
return parameterBindingCandidates(type, props.candidates).flatMap((id) => {
|
const candidate = candidateById.value.get(id);
|
return candidate ? [candidate] : [];
|
});
|
}
|
function resultOptions() {
|
return resultIds.value.map((id) => ({ label: candidateById.value.get(id)?.label || id, value: id }));
|
}
|
function submit(): void {
|
if (props.disabled || !selectedVersion.value || !canSave.value) return;
|
const request = {
|
formulaVersionId: selectedVersion.value.id,
|
parameterBindings: { ...parameterBindings },
|
resultBindingId: resultBindingId.value,
|
};
|
if (selectedMappingId.value) emit('upgrade', selectedMappingId.value, request);
|
else emit('reserve', request);
|
}
|
function beginUpgrade(mapping: TemplateFormulaBinding): void {
|
if (props.disabled) return;
|
resetForm();
|
selectedMappingId.value = mapping.id;
|
selectedVersionId.value = mapping.formulaVersionId;
|
Object.assign(parameterBindings, mapping.parameterBindings);
|
resultBindingId.value = mapping.resultBindingId;
|
mode.value = 'create';
|
}
|
function switchMode(value: number | string): void {
|
mode.value = value === 'existing' ? 'existing' : 'create';
|
if (mode.value === 'existing' || !selectedMappingId.value) resetForm();
|
}
|
function getPopupContainer(trigger: HTMLElement): HTMLElement {
|
return (trigger.closest('.plugin-shell') as HTMLElement) || document.body;
|
}
|
</script>
|
|
<template>
|
<section class="formula-bindings" data-test="formula-binding-panel" aria-labelledby="formula-binding-title">
|
<h2 id="formula-binding-title" class="visually-hidden">公式映射</h2>
|
<a-segmented :value="mode" block :options="modeOptions" @update:value="switchMode" />
|
|
<div v-if="mode === 'create'" class="formula-create" data-test="formula-create">
|
<a-alert
|
v-if="selectedMapping"
|
class="upgrade-banner"
|
:message="`正在升级:${versionById.get(selectedMapping.formulaVersionId)?.formulaName || '公式映射'}`"
|
show-icon
|
type="info" />
|
<a-form layout="vertical">
|
<a-form-item label="公式版本">
|
<a-select
|
:value="selectedVersionId || undefined"
|
data-test="formula-version-select"
|
:disabled="disabled"
|
:get-popup-container="getPopupContainer"
|
:options="versionSelectOptions"
|
placeholder="搜索并选择公式"
|
show-search
|
option-filter-prop="label"
|
@update:value="chooseVersion" />
|
</a-form-item>
|
|
<template v-if="selectedVersion">
|
<div class="formula-summary">
|
<div>
|
<strong>{{ selectedVersion.formulaName }}</strong
|
><a-tag :color="selectedVersion.status === 'PRE_RELEASE' ? 'warning' : 'success'">
|
{{ selectedVersion.status === 'PRE_RELEASE' ? '预发布' : '已发布' }}
|
</a-tag>
|
</div>
|
<span>{{ selectedVersion.slots.length }} 个参数 · 结果 {{ selectedVersion.resultType }}</span>
|
</div>
|
<a-alert v-if="selectedVersion.status === 'PRE_RELEASE'" class="formula-risk" message="预发布版本仅用于草稿模板验证" show-icon type="warning" />
|
<a-form-item
|
v-for="slot in selectedVersion.slots"
|
:key="slot.code"
|
:data-test="`formula-slot-${slot.code}`"
|
:label="`${slot.name}${slot.required ? ' *' : ''}`">
|
<a-select
|
v-model:value="parameterBindings[slot.code]"
|
:disabled="disabled"
|
:get-popup-container="getPopupContainer"
|
:options="candidatesForSlot(slot.type).map((candidate) => ({ label: candidate.label, value: candidate.id }))"
|
placeholder="选择已标注字段"
|
show-search
|
option-filter-prop="label" />
|
</a-form-item>
|
<a-form-item data-test="formula-result" label="计算结果 *">
|
<a-select
|
v-model:value="resultBindingId"
|
:disabled="disabled"
|
:get-popup-container="getPopupContainer"
|
:options="resultOptions()"
|
placeholder="选择结果主字段"
|
show-search
|
option-filter-prop="label" />
|
</a-form-item>
|
</template>
|
</a-form>
|
<div class="sticky-action">
|
<a-button block data-test="formula-save" :disabled="disabled || !canSave" type="primary" @click="submit">
|
{{ selectedMapping ? '升级映射' : '保存映射' }}
|
</a-button>
|
</div>
|
</div>
|
|
<div v-else class="formula-existing" data-test="formula-existing">
|
<a-empty v-if="activeMappings.length === 0" class="content-empty" description="暂无公式映射" />
|
<a-list v-else>
|
<a-list-item v-for="mapping in activeMappings" :key="mapping.id" class="formula-mapping-row">
|
<a-list-item-meta>
|
<template #title>
|
<span class="mapping-title">{{ versionById.get(mapping.formulaVersionId)?.formulaName || '未知公式' }}</span>
|
<a-tag :color="mapping.status === 'PENDING' ? 'warning' : 'success'">
|
{{ mapping.status === 'PENDING' ? '待确认' : versionById.get(mapping.formulaVersionId)?.status === 'PRE_RELEASE' ? '预发布' : '已启用' }}
|
</a-tag>
|
</template>
|
<template #description>
|
结果:{{ candidateById.get(mapping.resultBindingId)?.label || mapping.resultBindingId }} ·
|
{{ Object.keys(mapping.parameterBindings).length }} 个参数
|
</template>
|
</a-list-item-meta>
|
<a-dropdown :disabled="disabled" :get-popup-container="getPopupContainer" :trigger="['click']">
|
<a-button shape="circle" type="text" aria-label="公式映射操作"><MoreOutlined /></a-button>
|
<template #overlay>
|
<a-menu>
|
<a-menu-item data-test="formula-upgrade" @click="beginUpgrade(mapping)">升级映射</a-menu-item>
|
<a-menu-item danger data-test="formula-remove" @click="emit('remove', mapping.id)">移除映射</a-menu-item>
|
</a-menu>
|
</template>
|
</a-dropdown>
|
</a-list-item>
|
</a-list>
|
</div>
|
</section>
|
</template>
|
|
<style scoped>
|
.formula-bindings {
|
display: grid;
|
grid-template-rows: auto minmax(0, 1fr);
|
gap: 10px;
|
width: 100%;
|
max-width: 100%;
|
height: 100%;
|
min-width: 0;
|
}
|
.formula-create,
|
.formula-existing {
|
width: 100%;
|
max-width: 100%;
|
min-width: 0;
|
}
|
.formula-existing {
|
display: flex;
|
flex-direction: column;
|
min-height: 0;
|
}
|
.formula-existing .content-empty {
|
display: flex;
|
flex: 1 1 auto;
|
flex-direction: column;
|
justify-content: center;
|
margin: 0;
|
}
|
.formula-create :deep(.ant-form),
|
.formula-create :deep(.ant-select),
|
.formula-existing :deep(.ant-list) {
|
width: 100%;
|
min-width: 0;
|
}
|
.formula-mode-label {
|
display: inline-flex;
|
gap: 6px;
|
align-items: center;
|
}
|
.formula-mode-label :deep(.ant-badge-count) {
|
position: static;
|
min-width: 20px;
|
height: 20px;
|
padding: 0 6px;
|
font-size: 12px;
|
line-height: 20px;
|
transform: none;
|
}
|
.formula-summary {
|
display: grid;
|
gap: 2px;
|
padding: 8px;
|
margin-bottom: 8px;
|
background: #fafafa;
|
border: 1px solid #f0f0f0;
|
border-radius: 4px;
|
}
|
.formula-summary div {
|
display: flex;
|
gap: 6px;
|
align-items: center;
|
justify-content: space-between;
|
min-width: 0;
|
}
|
.formula-summary strong {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
.formula-summary span {
|
color: rgb(0 0 0 / 45%);
|
}
|
.formula-summary .ant-tag {
|
flex: 0 0 auto;
|
margin: 0;
|
}
|
.formula-risk,
|
.upgrade-banner {
|
margin-bottom: 8px;
|
}
|
.formula-mapping-row {
|
gap: 6px;
|
width: 100%;
|
min-width: 0;
|
padding: 8px 0 !important;
|
}
|
.formula-mapping-row :deep(.ant-list-item-meta),
|
.formula-mapping-row :deep(.ant-list-item-meta-content) {
|
min-width: 0;
|
}
|
.formula-mapping-row :deep(.ant-list-item-meta-description) {
|
overflow-wrap: anywhere;
|
}
|
.mapping-title {
|
display: inline-block;
|
max-width: 150px;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
vertical-align: bottom;
|
white-space: nowrap;
|
}
|
.formula-mapping-row .ant-tag {
|
margin-left: 6px;
|
}
|
.sticky-action {
|
position: sticky;
|
bottom: 0;
|
padding-top: 10px;
|
background: #fff;
|
}
|
.visually-hidden {
|
position: absolute;
|
width: 1px;
|
height: 1px;
|
padding: 0;
|
overflow: hidden;
|
clip: rect(0, 0, 0, 0);
|
white-space: nowrap;
|
border: 0;
|
}
|
</style>
|