From 70d0c660afa9cb10c52056cee7c98ab94d48e70f Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期二, 08 九月 2026 17:59:19 +0800
Subject: [PATCH] 分离拼接来源详情与模板字段并补齐版本验收

---
 src/utils/spliceSavedReport.ts            |    8 +
 src/views/SpliceReportForm.vue            |   44 ++++++++---
 tests/e2e/splice-battery-segments.spec.ts |  147 ++++++++++++++++++++++++++++++++++++
 docs/pages/report-splice.md               |    2 
 src/api/spliceReports.ts                  |   23 +++++
 src/components/SpliceFieldControls.vue    |   10 -
 6 files changed, 210 insertions(+), 24 deletions(-)

diff --git a/docs/pages/report-splice.md b/docs/pages/report-splice.md
index 4b64254..e3e15b3 100644
--- a/docs/pages/report-splice.md
+++ b/docs/pages/report-splice.md
@@ -17,6 +17,8 @@
 
 测试详情按来源列带入控制台保存的实验、两类字段列表和逐电池字符串,包含空字符串。来源人工字段不凭名称推断共享身份或单位。报表人工调整以测试和测试电池身份保存,重排后不串列。拼接模板独立存储,只保存字段结构、顺序、展示名称与单位,不带来源或填写值。
 
+来源详情使用独立表格,字段按“分类 + 名称”、人工覆盖按测试电池保存;报表模板的共享字段按明确字段 ID 读取冻结值,同名也不借用来源人工内容。应用模板不取消来源详情,不覆盖其人工值。主动更新来源展示新增字段,取消未调整字段,已人工调整的字段及空字符串继续保留。没有独立详情设置的旧版本仍按原结构查看和普通编辑,仅主动更新进入新模式,并保留原报表人工值。
+
 表格指标可人工调整并显示计算原值;统计图保持冻结的原始计算结果。取消电池或移除来源时清理失效人工值,保留其他有效修改。报表修改不反写测试事实。
 
 首次保存重新读取所选来源;普通编辑基于已保存快照追加版本;只有明确执行“按最新来源生成新版本”才重读来源。来源已删除时仍可普通编辑、查看历史及打印,主动更新来源被拒绝。已有错误历史快照不被静默改写,应在来源尚存时主动生成新版本。
diff --git a/src/api/spliceReports.ts b/src/api/spliceReports.ts
index 505249d..d70fa33 100644
--- a/src/api/spliceReports.ts
+++ b/src/api/spliceReports.ts
@@ -5,7 +5,7 @@
 export interface SpliceBusinessField { fieldDefinitionId: number; fieldName: string; fieldValue: string | null }
 export interface SpliceSegment { source: SpliceCandidate; offset: number; originalCycleNos: number[]; statistics: Record<string, Record<string, number | null>>; businessFields?: SpliceBusinessField[]; consoleDetail?: { experimentName?: string; batteryFields: string[]; specialFields: string[]; fieldValues: Record<string, string> } }
 export interface SpliceMatrix { segments: SpliceSegment[]; cycleNos: number[] }
-export interface SpliceSettings { sourceExperimentNames?: Record<string,string>; hiddenSeries?: boolean; fieldStructure?: SpliceField[]; statisticValues?: Record<string, string>; batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] }
+export interface SpliceSettings { sourceDetailOverrides?: Record<string, { experimentName?: string; fieldValues?: Record<string, string> }>; sourceExperimentNames?: Record<string,string>; hiddenSeries?: boolean; fieldStructure?: SpliceField[]; statisticValues?: Record<string, string>; batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] }
 export interface SpliceSummary { id: number; name: string; creator: string; createdAt: number }
 export interface SavedSpliceReport extends SpliceSummary { spliceMode?: 'battery-segments'; description?: string; selections: SpliceSelection[]; segments: SpliceSegment[]; cycleNos: number[]; settings: SpliceSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string }
 const root='/system/splice-report'
@@ -46,6 +46,8 @@
 export function spliceSourceValue(segment: SpliceSegment, settings: SpliceSettings, field: SpliceField) {
   const manual = settings.detailFieldValues?.[field.name]?.[spliceSourceKey(segment.source)]
   if (manual !== undefined) return manual
+  // 独立详情版本中模板仅使用明确共享身份;来源人工详情由专用区域呈现。
+  if (settings.sourceDetailOverrides !== undefined) return field.fieldDefinitionId ? segment.businessFields?.find(value => value.fieldDefinitionId === field.fieldDefinitionId)?.fieldValue ?? '' : ''
   // 控制台详情只有名称,没有共享字段身份;模板字段一旦带 ID,必须按 ID 取组值,不能被同名详情冒认。
   if (field.fieldDefinitionId) {
     return segment.businessFields?.find(value => value.fieldDefinitionId === field.fieldDefinitionId)?.fieldValue ?? ''
@@ -55,6 +57,25 @@
   if (segment.consoleDetail?.batteryFields.includes(field.name) || segment.consoleDetail?.specialFields.includes(field.name)) return ''
   return ''
 }
+/** 来源详情按分类和名称保持原结构;更新来源取消字段时仍保留已经人工改过的格。 */
+export function spliceSourceFields(segments: SpliceSegment[], overrides: SpliceSettings['sourceDetailOverrides']) {
+  const fields = segments.flatMap(segment => (['battery', 'special'] as const).flatMap(category =>
+    (segment.consoleDetail?.[category === 'battery' ? 'batteryFields' : 'specialFields'] || [])
+      .map(name => ({ key: `${category}|${name}`, name, category }))))
+  for (const segment of segments) for (const key of Object.keys(overrides?.[spliceSourceKey(segment.source)]?.fieldValues || {})) {
+    const category = key.startsWith('battery|') ? 'battery' : 'special'
+    fields.push({ key, name: key.slice(category.length + 1), category })
+  }
+  return [...new Map(fields.map(field => [field.key, field])).values()]
+}
+/** 明确空字符串优先,缺失来源格不借用同名共享字段或其他电池值。 */
+export function spliceDetailValue(segment: SpliceSegment, overrides: SpliceSettings['sourceDetailOverrides'], field?: ReturnType<typeof spliceSourceFields>[number]) {
+  const manual = overrides?.[spliceSourceKey(segment.source)]
+  if (!field) return manual?.experimentName ?? segment.consoleDetail?.experimentName ?? ''
+  if (manual?.fieldValues?.[field.key] !== undefined) return manual.fieldValues[field.key]!
+  const names = segment.consoleDetail?.[field.category === 'battery' ? 'batteryFields' : 'specialFields'] || []
+  return names.includes(field.name) ? segment.consoleDetail?.fieldValues[field.name] ?? '' : ''
+}
 export function spliceExperiment(segment: SpliceSegment, settings: SpliceSettings) {
   return settings.sourceExperimentNames?.[spliceSourceKey(segment.source)] ?? segment.consoleDetail?.experimentName ?? ''
 }
diff --git a/src/components/SpliceFieldControls.vue b/src/components/SpliceFieldControls.vue
index 1156bd1..70637c2 100644
--- a/src/components/SpliceFieldControls.vue
+++ b/src/components/SpliceFieldControls.vue
@@ -14,8 +14,7 @@
 const loading = ref(false)
 const saving = ref(false)
 const error = ref('')
-// 共享字段以定义 ID 区分;控制台详情只有名称,不能与同名共享字段合并。
-const identity = (field: SpliceField) => field.fieldDefinitionId ? `id:${field.fieldDefinitionId}` : `name:${field.name}`
+const identity = (field: SpliceField) => field.name
 const fieldName = (field: SpliceField) => field.name
 function openSettings() {
   const available: SpliceField[] = props.segments.flatMap(segment => (segment.businessFields || []).map(field => ({
@@ -53,11 +52,8 @@
 function applyTemplate() {
   const template = templates.value.find(item => item.id === selectedTemplate.value)
   if (!template) { ElMessage.warning('请选择字段模板'); return }
-  const consoleFields: SpliceField[] = [...props.segments.flatMap(segment => (segment.consoleDetail?.batteryFields || []).map(name => ({ name, displayName: name, unit: '', category: 'battery' as const }))), ...props.segments.flatMap(segment => (segment.consoleDetail?.specialFields || []).map(name => ({ name, displayName: name, unit: '', category: 'special' as const })))]
-  const selected = template.fields.map(field => ({ ...field }))
-  // 模板只替换结构;来源控制台字段仍保留为独立详情行,除非模板明确选择了同一身份。
-  for (const field of consoleFields) if (!selected.some(item => item.name === field.name && !item.fieldDefinitionId)) selected.push(field)
-  emit('apply', selected)
+  // 来源详情独立于模板结构维护,不参与模板字段身份、单位和唯一性校验。
+  emit('apply', template.fields.map(field => ({ ...field })))
   templatesVisible.value = false; ElMessage.success('字段模板已应用')
 }
 async function saveTemplate() {
diff --git a/src/utils/spliceSavedReport.ts b/src/utils/spliceSavedReport.ts
index a12a399..21f46a6 100644
--- a/src/utils/spliceSavedReport.ts
+++ b/src/utils/spliceSavedReport.ts
@@ -2,7 +2,7 @@
 import { LineChart } from 'echarts/charts'
 import { GridComponent, MarkLineComponent } from 'echarts/components'
 import { SVGRenderer } from 'echarts/renderers'
-import { getSplicePrintVersion, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceValue, spliceExperiment, type SavedSpliceReport } from '@/api/spliceReports'
+import { getSplicePrintVersion, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceValue, spliceExperiment, spliceSourceFields, spliceDetailValue, type SavedSpliceReport } from '@/api/spliceReports'
 import { openSavedReportPrint } from './savedReportPrint'
 import css from './spliceSavedReport.css?inline'
 
@@ -117,7 +117,11 @@
   if (chartsOnly) return `<div class="splice-snapshot">${charts}</div>`
   const fields = spliceFields(record.settings)
   const headers = segments.map((_, index) => `<th>${cell(sourceName(index))}</th>`).join('')
-  const details = `<section><h2>测试详情</h2><table><thead><tr><th>字段</th>${headers}</tr></thead><tbody><tr><td>实验</td>${segments.map(segment => `<td>${escapeHtml(spliceExperiment(segment, record.settings))}</td>`).join('')}</tr>${fields.map(field => `<tr><td>${cell(field.displayName)}${field.unit ? ` (${cell(field.unit)})` : ''}</td>${segments.map(segment => `<td>${escapeHtml(spliceSourceValue(segment, record.settings, field))}</td>`).join('')}</tr>`).join('')}</tbody></table></section>`
+  const overrides = record.settings.sourceDetailOverrides
+  const sourceDetails = overrides === undefined ? '' : `<section class="source-detail-section"><h2>来源测试详情</h2><table><thead><tr><th>字段</th>${headers}</tr></thead><tbody><tr><td>实验</td>${segments.map(segment => `<td>${escapeHtml(spliceDetailValue(segment, overrides))}</td>`).join('')}</tr>${spliceSourceFields(segments, overrides).map(field => `<tr><td>${cell(field.name)}(${field.category === 'battery' ? '电池字段' : '特殊字段'})</td>${segments.map(segment => `<td>${escapeHtml(spliceDetailValue(segment, overrides, field))}</td>`).join('')}</tr>`).join('')}</tbody></table></section>`
+  // 无独立详情设置的旧版仍使用其原字段结构,不猜补来源字段或改变历史行含义。
+  const legacyExperiment = overrides === undefined ? `<tr><td>实验</td>${segments.map(segment => `<td>${escapeHtml(spliceExperiment(segment, record.settings))}</td>`).join('')}</tr>` : ''
+  const details = `${sourceDetails}<section class="report-field-section"><h2>${overrides === undefined ? '测试详情' : '报表字段'}</h2><table><thead><tr><th>字段</th>${headers}</tr></thead><tbody>${legacyExperiment}${fields.map(field => `<tr><td>${cell(field.displayName)}${field.unit ? ` (${cell(field.unit)})` : ''}</td>${segments.map(segment => `<td>${escapeHtml(spliceSourceValue(segment, record.settings, field))}</td>`).join('')}</tr>`).join('')}</tbody></table></section>`
   const sources = `<section><h2>来源顺序与循环对应</h2><table><thead><tr><th>来源</th><th>原循环号 → 拼接循环号</th></tr></thead><tbody>${segments.map((segment, index) => `<tr><td>${cell(sourceName(index))}</td><td>${segment.originalCycleNos.map(no => `${no} → ${no + segment.offset}`).join('、') || '无已保存循环'}</td></tr>`).join('')}</tbody></table></section>`
   const statistics = segments.map((segment, index) => `<section class="splice-statistics"><h2>${cell(sourceName(index))}</h2><table><thead><tr><th>原循环号</th><th>拼接循环号</th>${metrics.map(metric => `<th>${metric.title} (${metric.unit})</th>`).join('')}</tr></thead><tbody>${segment.originalCycleNos.map(no => `<tr><td>${no}</td><td>${no + segment.offset}</td>${metrics.map(metric => {
     const original = segment.statistics[String(no)]?.[metric.key]
diff --git a/src/views/SpliceReportForm.vue b/src/views/SpliceReportForm.vue
index 3721796..60d8cbd 100644
--- a/src/views/SpliceReportForm.vue
+++ b/src/views/SpliceReportForm.vue
@@ -4,8 +4,8 @@
 import { useRoute, useRouter } from 'vue-router'
 import { getUserInfo } from '@/api/auth'
 import { getSpliceSources, previewSpliceReport, createSpliceReport, editSpliceReport, refreshSpliceSource,
-  getSpliceReport, getSpliceVersion, listSpliceVersions, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceKey, spliceSourceValue, spliceExperiment,
-  type SpliceCandidate, type SpliceMatrix, type SpliceField, type SpliceSettings, type SavedSpliceReport } from '@/api/spliceReports'
+  getSpliceReport, getSpliceVersion, listSpliceVersions, spliceFields, spliceFieldValue, spliceStatisticKey, spliceSourceKey, spliceSourceValue, spliceExperiment, spliceSourceFields, spliceDetailValue,
+  type SpliceCandidate, type SpliceMatrix, type SpliceField, type SpliceSettings, type SpliceSegment, type SavedSpliceReport } from '@/api/spliceReports'
 import { openSpliceSavedReportPrint } from '@/utils/spliceSavedReport'
 import SpliceSavedReport from '@/components/SpliceSavedReport.vue'
 import SpliceFieldControls from '@/components/SpliceFieldControls.vue'
@@ -35,6 +35,7 @@
 const detailFieldValues = ref<Record<string, Record<string, string>>>({})
 const statisticValues = ref<Record<string, string>>({})
 const sourceExperimentNames = ref<Record<string,string>>({})
+const sourceDetailOverrides = ref<SpliceSettings['sourceDetailOverrides']>({})
 const hiddenSeries = ref(false)
 const statisticMetrics = [{ key: 'finalVoltage1', title: '最终电压1', unit: 'V' }, { key: 'energyEfficiency', title: '能量效率', unit: '%' }] as const
 const statisticVisible = reactive({ finalVoltage1: true, energyEfficiency: true })
@@ -57,7 +58,10 @@
 const matrix = computed(() => savedReport.value || draftMatrix.value)
 const segmentMode = computed(() => !savedReport.value || savedReport.value.spliceMode === 'battery-segments')
 const detailColumns = computed(() => matrix.value?.segments || [])
+const sourceFields = computed(() => spliceSourceFields(detailColumns.value, sourceDetailOverrides.value))
+const independentDetails = computed(() => segmentMode.value && sourceDetailOverrides.value !== undefined)
 const statisticSettings = computed<SpliceSettings>(() => ({
+  ...(sourceDetailOverrides.value === undefined ? {} : { sourceDetailOverrides: sourceDetailOverrides.value }),
   sourceExperimentNames: sourceExperimentNames.value,
   hiddenSeries: hiddenSeries.value,
   fieldStructure: fields.value,
@@ -84,6 +88,7 @@
   detailFieldValues.value = structuredClone(saved.settings.detailFieldValues || {})
   statisticValues.value = { ...saved.settings.statisticValues }
   sourceExperimentNames.value = { ...saved.settings.sourceExperimentNames }
+  sourceDetailOverrides.value = saved.settings.sourceDetailOverrides === undefined ? undefined : structuredClone(saved.settings.sourceDetailOverrides)
   hiddenSeries.value = saved.settings.hiddenSeries === true
   seriesColors.value = [...(saved.settings.seriesColors || ['#3978f6', '#109854', '#d25b45', '#7754a8'])]
   selectedTests.value = saved.segments.map(segment => segment.source)
@@ -109,6 +114,7 @@
       selectedTests.value = []; samples.value = []; reportName.value = ''; experimentName.value = ''
       fields.value = []; detailFieldValues.value = {}; statisticValues.value = {}; reordered = false
       sourceExperimentNames.value = {}; hiddenSeries.value = false
+      sourceDetailOverrides.value = {}
       sources.value = await getSpliceSources()
       savedContent.value = formContent()
     }
@@ -124,6 +130,8 @@
   // 来源移除后同步移除其人工值,仍在范围内的修改继续保留。
   statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) =>
     selected.some(selection => key.startsWith(`${selection.testId}|${selection.testBatteryId}|`))))
+  sourceDetailOverrides.value = Object.fromEntries(Object.entries(sourceDetailOverrides.value || {}).filter(([key]) =>
+    selected.some(selection => key === `${selection.testId}|${selection.testBatteryId}`)))
   if (selected.length < 2) { previewing.value = false; return }
   previewing.value = true
   try {
@@ -133,12 +141,6 @@
         statisticMetrics.map(metric => spliceStatisticKey(segment, cycle, metric.key)))))
       statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) => allowed.has(key)))
       draftMatrix.value = result
-      // 控制台只提供字段名,带入时不猜单位;已有人工结构与值保持不变。
-      for (const segment of result.segments) for (const category of ['battery', 'special'] as const) {
-        for (const name of segment.consoleDetail?.[category === 'battery' ? 'batteryFields' : 'specialFields'] || []) {
-          if (!fields.value.some(field => field.name === name)) fields.value.push({ name, category, displayName: name, unit: '' })
-        }
-      }
     }
   } catch (error) {
     if (request === previewRequest) previewError.value = error instanceof Error ? error.message : '统计预览失败'
@@ -168,6 +170,20 @@
 function setFieldValue(field: SpliceField, battery: string, value: string) {
   detailFieldValues.value[field.name] ||= {}
   detailFieldValues.value[field.name][battery] = value
+}
+function setDetailValue(segment: SpliceSegment, value: string, field?: typeof sourceFields.value[number]) {
+  sourceDetailOverrides.value ||= {}
+  const key = spliceSourceKey(segment.source)
+  const current = sourceDetailOverrides.value[key] || {}
+  sourceDetailOverrides.value[key] = field ? { ...current, fieldValues: { ...current.fieldValues, [field.key]: value } } : { ...current, experimentName: value }
+}
+function applyFields(selected: SpliceField[]) {
+  // 模板不携值;同名但身份或单位不同的字段不能继承原模板人工值。来源详情覆盖完全独立。
+  for (const field of selected) {
+    const previous = fields.value.find(item => item.name === field.name)
+    if (!previous || (previous.fieldDefinitionId ?? null) !== (field.fieldDefinitionId ?? null) || previous.category !== field.category || previous.unit !== field.unit) delete detailFieldValues.value[field.name]
+  }
+  fields.value = selected
 }
 async function addSpecialField() {
   try {
@@ -244,15 +260,19 @@
     <section class="detail-panel">
       <header class="panel-heading"><div><strong>测试详情</strong></div></header>
       <div class="detail-meta"><label><span>报表说明</span><el-input v-model="experimentName" /></label><div><span>电池组</span><strong>{{ batteryGroup }}</strong></div></div>
-      <div v-if="segmentMode" class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, detailColumns.length * 190 + 170)}px` }"><thead><tr><th>字段</th><th v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)">{{ segment.source.name }}<br>{{ segment.source.group }} · {{ segment.source.batteryNo }}</th></tr></thead><tbody>
-        <tr><td>实验</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceExperiment(segment, statisticSettings)" placeholder="缺失" @update:model-value="sourceExperimentNames[spliceSourceKey(segment.source)] = $event" /></td></tr>
-        <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceSourceValue(segment, statisticSettings, field)" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, spliceSourceKey(segment.source), $event)" /></td></tr>
+      <div v-if="independentDetails" class="detail-table-wrap"><table class="detail-table source-detail-table" :style="{ minWidth: `${Math.max(760, detailColumns.length * 190 + 170)}px` }"><thead><tr><th>来源详情字段</th><th v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)">{{ segment.source.name }}<br>{{ segment.source.group }} · {{ segment.source.batteryNo }}</th></tr></thead><tbody>
+        <tr><td>实验</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceDetailValue(segment, sourceDetailOverrides)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} 实验名称`" placeholder="缺失" @update:model-value="setDetailValue(segment, $event)" /></td></tr>
+        <tr v-for="field in sourceFields" :key="field.key"><td>{{ field.name }}({{ field.category === 'battery' ? '电池字段' : '特殊字段' }})</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceDetailValue(segment, sourceDetailOverrides, field)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} ${field.category === 'battery' ? '电池字段' : '特殊字段'} ${field.name}`" size="small" placeholder="缺失" @update:model-value="setDetailValue(segment, $event, field)" /></td></tr>
+      </tbody></table></div>
+      <div v-if="segmentMode" class="detail-table-wrap"><table class="detail-table report-field-table" :style="{ minWidth: `${Math.max(760, detailColumns.length * 190 + 170)}px` }"><thead><tr><th>{{ independentDetails ? '报表字段' : '字段' }}</th><th v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)">{{ segment.source.name }}<br>{{ segment.source.group }} · {{ segment.source.batteryNo }}</th></tr></thead><tbody>
+        <tr v-if="!independentDetails"><td>实验</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceExperiment(segment, statisticSettings)" placeholder="缺失" @update:model-value="sourceExperimentNames[spliceSourceKey(segment.source)] = $event" /></td></tr>
+        <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="segment in detailColumns" :key="spliceSourceKey(segment.source)"><el-input :model-value="spliceSourceValue(segment, statisticSettings, field)" :aria-label="`${segment.source.name} ${segment.source.batteryNo} 报表字段 ${field.displayName}`" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, spliceSourceKey(segment.source), $event)" /></td></tr>
       </tbody></table></div>
       <div v-else class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>字段</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody>
         <tr v-for="field in fields" :key="field.name"><td>{{ field.displayName }}{{ field.unit ? ` (${field.unit})` : '' }}</td><td v-for="sample in samples" :key="sample"><el-input :model-value="fieldValue(field, sample)" size="small" placeholder="缺失" @update:model-value="setFieldValue(field, sample, $event)" /></td></tr>
       </tbody></table></div>
       <div class="detail-actions"><button class="primary-button" type="button" @click="addSpecialField"><el-icon><Plus /></el-icon>添加项</button></div>
-      <SpliceFieldControls :fields="fields" :segments="matrix?.segments || []" :can-create="canCreate" :can-edit="canEdit" @apply="fields = $event" />
+      <SpliceFieldControls :fields="fields" :segments="matrix?.segments || []" :can-create="canCreate" :can-edit="canEdit" @apply="applyFields" />
     </section>
 
     <section v-if="statisticPreview" class="chart-panel">
diff --git a/tests/e2e/splice-battery-segments.spec.ts b/tests/e2e/splice-battery-segments.spec.ts
index a83208d..241353f 100644
--- a/tests/e2e/splice-battery-segments.spec.ts
+++ b/tests/e2e/splice-battery-segments.spec.ts
@@ -9,6 +9,8 @@
   await stack.start()
   stack.executeIsolatedSql(`
     INSERT INTO t_battery_group(group_id,group_name,model) VALUES(9701,'甲组','型号'),(9702,'乙组','型号');
+    INSERT INTO t_battery_group_field(field_id,field_name,field_type) VALUES(9701,'质量','number'),(9702,'另一质量','number');
+    INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) VALUES(9701,9701,'12.5'),(9702,9701,'28'),(9701,9702,'12500'),(9702,9702,'28000');
     INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(9701,'容量');
     INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status,started_at_utc)
     VALUES(9701,'测试甲',1,1,9701,'隔离验收','COMPLETED','ENDED','2026-09-01 00:00:00'),(9702,'测试乙',1,1,9701,'隔离验收','COMPLETED','ENDED','2026-09-02 00:00:00');
@@ -39,6 +41,147 @@
   expect(result.code, `${path}: ${result.msg}`).toBe(code)
   return result.data
 }
+test('来源详情与同名模板独立编辑冻结,主动更新字段并保留历史打印', async ({ page }) => {
+  const errors: string[] = []
+  page.on('pageerror', error => errors.push(error.message))
+  await login(page)
+  const detailA = { experimentName: '原实验', batteryFields: ['质量', '取消字段'], specialFields: ['备注'], fieldValues: { 质量: { '1号': '控制台未知单位', '2号': '' }, 取消字段: { '1号': '取消原值' }, 备注: { '1号': '来源备注', '2号': '' } } }
+  await api(page, 'test/9701/console-detail', 'PUT', detailA)
+  await api(page, 'test/9702/console-detail', 'PUT', { experimentName: '另一实验', batteryFields: ['质量'], specialFields: [], fieldValues: { 质量: { '1号': '跨组人工质量' } } })
+  const templateFields = [
+    { name: '质量', displayName: '质量', category: 'battery', unit: 'g', fieldDefinitionId: 9701 },
+    { name: '另一质量', displayName: '质量', category: 'battery', unit: 'mg', fieldDefinitionId: 9702 },
+    { name: '备注', displayName: '模板备注', category: 'special', unit: '', fieldDefinitionId: null },
+  ]
+  const templateId = await api(page, 'splice-report/templates', 'POST', { name: '同名不同身份纯结构', fields: templateFields })
+  await page.goto('/report-splice/new')
+  await page.getByPlaceholder('请输入拼接报表名称').fill('来源模板边界')
+  for (const label of ['测试甲(甲组,1号)', '测试甲(甲组,2号)', '测试乙(乙组,1号)']) {
+    await page.locator('.panel-actions').getByRole('button', { name: '添加拼接', exact: true }).click()
+    await page.getByRole('dialog').locator('.el-select').last().click()
+    await page.getByRole('option', { name: label, exact: true }).click()
+    await page.getByRole('dialog').getByRole('button', { name: '添加拼接', exact: true }).click()
+  }
+  const sourceQuality = page.getByRole('textbox', { name: '测试甲 1号 电池字段 质量', exact: true })
+  await expect(sourceQuality).toHaveValue('控制台未知单位')
+  await expect(page.getByRole('textbox', { name: '测试甲 2号 电池字段 质量', exact: true })).toHaveValue('')
+  await sourceQuality.fill('报表来源覆盖')
+  await page.getByRole('textbox', { name: '测试甲 1号 特殊字段 备注', exact: true }).fill('')
+  await page.getByRole('textbox', { name: '测试甲 1号 实验名称', exact: true }).fill('')
+  const applyTemplate = async () => {
+    await page.getByRole('button', { name: '选择字段模板', exact: true }).click()
+    await page.getByRole('dialog').getByText('同名不同身份纯结构', { exact: true }).click()
+    await page.getByRole('dialog').getByRole('button', { name: '应用模板', exact: true }).click()
+  }
+  await applyTemplate()
+  const rowG = page.locator('.report-field-table tr').filter({ has: page.locator('td').filter({ hasText: /^质量 \(g\)$/ }) })
+  const rowMg = page.locator('.report-field-table tr').filter({ has: page.locator('td').filter({ hasText: /^质量 \(mg\)$/ }) })
+  await expect(rowG.locator('input').first()).toHaveValue('12.5')
+  await expect(rowMg.locator('input').first()).toHaveValue('12500')
+  await expect(sourceQuality).toHaveValue('报表来源覆盖')
+  await expect(page.getByRole('textbox', { name: '测试甲 1号 报表字段 模板备注', exact: true })).toHaveValue('')
+  await rowG.locator('input').first().fill('13.7')
+  await page.getByRole('textbox', { name: '测试甲 1号 报表字段 模板备注', exact: true }).fill('模板人工值')
+  await applyTemplate()
+  await expect(rowG.locator('input').first()).toHaveValue('13.7')
+  await expect(rowMg.locator('input').first()).toHaveValue('12500')
+  await expect(sourceQuality).toHaveValue('报表来源覆盖')
+  // 模板保存只取报表结构,来源实验、控制台字段及两类人工值都不能夹带。
+  await page.getByRole('button', { name: '选择字段模板', exact: true }).click()
+  await page.getByRole('dialog').getByRole('button', { name: '保存为模板', exact: true }).click()
+  const templateDialog = page.getByRole('dialog', { name: '保存拼接字段模板', exact: true })
+  await templateDialog.getByRole('textbox').fill('边界另存结构')
+  await templateDialog.getByRole('button', { name: '保存', exact: true }).click()
+  await expect(templateDialog).toBeHidden()
+  await page.getByRole('dialog', { name: '选择拼接字段模板', exact: true }).getByRole('button', { name: '取消', exact: true }).click()
+  const template = (await api(page, 'splice-report/templates')).find((item: any) => item.name === '边界另存结构')
+  expect(template.fields).toEqual(templateFields)
+  expect(JSON.stringify(template)).not.toContain('报表来源覆盖')
+  expect(JSON.stringify(template)).not.toContain('模板人工值')
+  expect(JSON.stringify(template)).not.toContain('取消字段')
+  await page.getByRole('button', { name: '保存报表', exact: true }).click()
+  await expect(page).toHaveURL(/report-splice$/)
+  const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
+  const list = await (await page.request.get(`${stack.backendUrl}/system/splice-report/list`, { headers: { Authorization: `Bearer ${token}` } })).json()
+  const id = list.rows.find((item: any) => item.name === '来源模板边界').id
+  const first = await api(page, `splice-report/${id}`)
+  expect(first.settings.fieldStructure).toHaveLength(3)
+  expect(first.settings.sourceDetailOverrides['9701|9701']).toEqual({ experimentName: '', fieldValues: { 'battery|质量': '报表来源覆盖', 'special|备注': '' } })
+  expect(first.settings.detailFieldValues.质量['9701|9701']).toBe('13.7')
+  expect(first.segments[0].consoleDetail.fieldValues.质量).toBe('控制台未知单位')
+  expect((await api(page, 'test/9701')).consoleDetail).toMatchObject(detailA)
+  await page.goto(`/report-splice/${id}/edit`)
+  await page.reload()
+  await expect(sourceQuality).toHaveValue('报表来源覆盖')
+  await expect(rowG.locator('input').first()).toHaveValue('13.7')
+  await expect(rowMg.locator('input').first()).toHaveValue('12500')
+  await page.getByRole('button', { name: '保存修改', exact: true }).click()
+  await expect(page).toHaveURL(/report-splice$/)
+  const second = await api(page, `splice-report/${id}`)
+  expect(second.segments).toEqual(first.segments)
+  // 更新新增/取消字段不能依赖模板列表;消失但人工改过的质量、备注及空实验继续保留。
+  await api(page, 'test/9701/console-detail', 'PUT', { experimentName: '新实验', batteryFields: ['新增来源字段'], specialFields: [], fieldValues: { 新增来源字段: { '1号': '新增内容', '2号': '' } } })
+  const refreshed = await api(page, `splice-report/${id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '来源字段增减' })
+  expect(refreshed.settings.fieldStructure).toEqual(first.settings.fieldStructure)
+  expect(refreshed.settings.sourceDetailOverrides).toEqual(first.settings.sourceDetailOverrides)
+  await page.goto(`/report-splice/${id}/edit`)
+  await expect(page.getByRole('textbox', { name: '测试甲 1号 电池字段 新增来源字段', exact: true })).toHaveValue('新增内容')
+  await expect(page.getByRole('textbox', { name: '测试甲 1号 电池字段 取消字段', exact: true })).toHaveCount(0)
+  await expect(sourceQuality).toHaveValue('报表来源覆盖')
+  await expect(page.getByRole('textbox', { name: '测试甲 1号 特殊字段 备注', exact: true })).toHaveValue('')
+  await expect(page.getByRole('textbox', { name: '测试甲 1号 实验名称', exact: true })).toHaveValue('')
+  await page.goto(`/report-splice/${id}/edit?mode=view`)
+  const sourceSection = page.locator('.source-detail-section')
+  await expect(sourceSection).toContainText('新增内容')
+  await expect(sourceSection).toContainText('报表来源覆盖')
+  await expect(sourceSection).not.toContainText('取消字段')
+  await expect(sourceSection).not.toContainText('(g)')
+  await expect(page.locator('.report-field-section')).toContainText('13.7')
+  await expect(page.locator('.report-field-section')).toContainText('12500')
+  await page.screenshot({ path: 'tests/e2e/.artifacts/splice-source-template-boundary.png', fullPage: true })
+  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
+  const pending = page.waitForEvent('popup')
+  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
+  const popup = await pending
+  await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true')
+  expect(await popup.locator('.splice-snapshot').innerText()).toEqual(await page.locator('.splice-snapshot').innerText())
+  await popup.pdf({ path: 'tests/e2e/.artifacts/splice-source-template-boundary.pdf', preferCSSPageSize: true, printBackground: true })
+  await popup.close()
+  await page.goto(`/report-splice/${id}/edit?mode=view&version=1`)
+  await expect(page.locator('.source-detail-section')).toContainText('取消原值')
+  await expect(page.locator('.source-detail-section')).not.toContainText('新增内容')
+  expect(await api(page, `splice-report/${id}/versions/1`)).toEqual(first)
+  for (const sourceDetailOverrides of [{ '9701|9999': { experimentName: '错归属' } }, { '9701|9701': { fieldValues: { 'voltage': '不能冒充设备值' } } }])
+    await api(page, `splice-report/${id}/versions`, 'POST', { name: first.name, baseVersionNo: 3, changeSummary: '拒绝无效详情', settings: { ...refreshed.settings, sourceDetailOverrides } }, 500)
+  expect((await api(page, `splice-report/${id}`)).versionNo).toBe(3)
+  // 隔离库复原旧07快照形状:普通读写不补来源行,明确更新才启用新详情,同时保留旧人工值。
+  const legacy = await api(page, 'splice-report', 'POST', { name: '旧07快照', selections: [selection(9701), selection(9702)], settings: {
+    fieldStructure: [{ name: '旧人工字段', displayName: '旧人工字段', category: 'special', unit: '' }],
+    detailFieldValues: { 旧人工字段: { '9701|9701': '旧人工保留' } }, sourceExperimentNames: { '9701|9701': '旧实验保留' },
+  } })
+  stack.executeIsolatedSql(`UPDATE t_splice_report_version SET snapshot_json=JSON_REMOVE(snapshot_json,'$.settings.sourceDetailOverrides') WHERE report_id=${legacy.id}`)
+  const old = await api(page, `splice-report/${legacy.id}`)
+  expect(old.settings.sourceDetailOverrides).toBeUndefined()
+  await page.goto(`/report-splice/${legacy.id}/edit`)
+  await expect(page.locator('.source-detail-table')).toHaveCount(0)
+  await expect(page.getByRole('textbox', { name: '测试甲 1号 报表字段 旧人工字段', exact: true })).toHaveValue('旧人工保留')
+  await page.getByRole('button', { name: '保存修改', exact: true }).click()
+  await expect(page).toHaveURL(/report-splice$/)
+  const legacyEdited = await api(page, `splice-report/${legacy.id}`)
+  expect(legacyEdited.settings.sourceDetailOverrides).toBeUndefined()
+  expect(legacyEdited.segments).toEqual(old.segments)
+  const legacyUpdated = await api(page, `splice-report/${legacy.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '旧版明确更新' })
+  expect(legacyUpdated.settings.detailFieldValues).toEqual(old.settings.detailFieldValues)
+  expect(legacyUpdated.settings.sourceDetailOverrides['9701|9701'].experimentName).toBe('旧实验保留')
+  await page.goto(`/report-splice/${legacy.id}/edit?mode=view`)
+  await expect(page.locator('.source-detail-section')).toContainText('旧实验保留')
+  await expect(page.locator('.source-detail-section')).toContainText('新增内容')
+  await expect(page.locator('.report-field-section')).toContainText('旧人工保留')
+  expect(await api(page, `splice-report/${legacy.id}/versions/1`)).toEqual(old)
+  expect(templateId).toBeGreaterThan(0)
+  expect(errors).toEqual([])
+})
+
 test('逐电池身份、接续、来源详情、界面重排及保存打印闭环',async ({page}) => {
   await login(page)
   await api(page,'test/9701/console-detail','PUT',{experimentName:'实验甲',batteryFields:['人工字段无共享身份'],specialFields:['备注'],fieldValues:{备注:{'1号':'甲一','2号':''},人工字段无共享身份:{'1号':'原样单位未知','2号':'另一值'}}})
@@ -74,7 +217,7 @@
   }
   await expect(page.locator('.splice-chart')).toHaveCount(2)
   await expect(page.locator('.splice-snapshot h1')).toHaveCount(0)
-  const detailRow = page.locator('.detail-table tr').filter({has:page.locator('td').filter({hasText:/^备注$/})})
+  const detailRow = page.locator('.source-detail-table tr').filter({has:page.locator('td').filter({hasText:/^备注(特殊字段)$/})})
   await expect(detailRow.locator('input').nth(0)).toHaveValue('甲一')
   await expect(detailRow.locator('input').nth(1)).toHaveValue('')
   await detailRow.locator('input').nth(2).fill('乙一报表')
@@ -98,7 +241,7 @@
   const saved = await api(page,`splice-report/${list.rows.find((r:any) => r.name === '逐电池拼接界面').id}`)
   expect(saved.selections).toEqual([selection(9701),selection(9703),selection(9702)])
   expect(saved.settings.hiddenSeries).toBe(true)
-  expect(saved.settings.detailFieldValues.备注['9702|9703']).toBe('乙一报表')
+  expect(saved.settings.sourceDetailOverrides['9702|9703'].fieldValues['special|备注']).toBe('乙一报表')
   expect(saved.settings.statisticValues['9702|9703|1|finalVoltage1']).toBe('8')
   expect(saved.segments[1].statistics['1'].finalVoltage1).toBe(0)
   expect((await api(page,'test/9702')).consoleDetail.fieldValues.备注['1号']).toBe('乙一')

--
Gitblit v1.7.1