yeg
2026-09-28 be3757f2433803cb3bfc4c47b54cd8cdff8575e5
循环页与导出标注循环统计的来源类别

设备没有循环汇总行时循环统计由工步层聚合得出,界面与导出必须能区分推导值与设备事实:
循环子页新增「数据来源」列(设备提供 / 工步层聚合 / 文件导入),有聚合行时页面显式说明
聚合口径,下载的 CSV 带上同一列。该列只加在循环子页,不改动与甲方文件对齐的业务列。
2个文件已修改
23 ■■■■■ 已修改文件
src/api/tests.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleDataListPanel.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts
@@ -397,6 +397,8 @@
  efficiency: number | string | null
  chargeEnergy: number | string | null
  dischargeEnergy: number | string | null
  /** 来源类别:DEVICE 设备提供、AGGREGATED 本系统按工步层聚合、IMPORTED 授权文件导入。 */
  sourceKind?: string | null
}
// 三子页和图表查询持久化当前有效数据,不回放实时监控或触发预警判定。
src/components/ConsoleDataListPanel.vue
@@ -60,6 +60,7 @@
  efficiency: string
  chargeEnergy: string
  dischargeEnergy: string
  sourceKind: string
}
const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; canImport?: boolean; receptionStatus?: string; bindings?: Array<{ batteryNo: string; bindingId?: number | null; channelId?: number | null; devid?: number | null; chlid?: number | null }>}>()
const route = useRoute()
@@ -76,6 +77,11 @@
  { key: 'step', label: '工步' },
  { key: 'cycle', label: '循环' },
] as const
// 循环统计的来源类别:设备与文件给的值原样展示,推导值必须说明它是聚合出来的。
const cycleSourceLabels: Record<string, string> = { DEVICE: '设备提供', AGGREGATED: '工步层聚合', IMPORTED: '文件导入' }
const CYCLE_SOURCE_UNKNOWN = '未标注'
const cycleSourceTitle = '「工步层聚合」表示设备没有提供循环汇总行,该循环的容量、能量与效率由本系统按工步层同方向工步求和得出;推导结果可能带有设备工步行里的偏差。'
const hasAggregatedCycles = computed(() => cycleRows.value.some(row => row.sourceKind === cycleSourceLabels.AGGREGATED))
const filters = reactive<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
const appliedFilters = ref<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
const currentPage = ref(1)
@@ -117,7 +123,7 @@
        ],
)
function emptyRow(base: Partial<DataRow>): DataRow {
  return { sourceIdentity: '-', collectorDeviceTime: '-', id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, relativeTimeSeconds: '-', recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargeMedianVoltage: '-', dischargeMedianVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...base }
  return { sourceIdentity: '-', collectorDeviceTime: '-', id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, relativeTimeSeconds: '-', recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargeMedianVoltage: '-', dischargeMedianVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', sourceKind: '-', ...base }
}
function mapRecord(record: PersistedTestRecord): DataRow {
  const deviceTime = formatTime(record.deviceTime ?? record.recordedAt)
@@ -144,7 +150,7 @@
function mapCycle(record: PersistedTestCycle): DataRow {
  const startedAt = formatTime(record.recordedAt)
  const identity = record.stableIdentity || `cycle:${[record.adapterId, record.devtype, record.deviceId, record.subdevid, record.channelId, record.cycleNo].join(':')}`
  return emptyRow({ id: identity, recordNo: identity, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), chargeCapacity: formatTestDisplayValue('chargeCapacity', record.chargeCapacity), dischargeCapacity: formatTestDisplayValue('dischargeCapacity', record.dischargeCapacity), efficiency: record.efficiency == null ? '-' : Number(record.efficiency).toFixed(2), chargeEnergy: formatTestDisplayValue('chargeEnergy', record.chargeEnergy), dischargeEnergy: formatTestDisplayValue('dischargeEnergy', record.dischargeEnergy) })
  return emptyRow({ id: identity, recordNo: identity, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), chargeCapacity: formatTestDisplayValue('chargeCapacity', record.chargeCapacity), dischargeCapacity: formatTestDisplayValue('dischargeCapacity', record.dischargeCapacity), efficiency: record.efficiency == null ? '-' : Number(record.efficiency).toFixed(2), chargeEnergy: formatTestDisplayValue('chargeEnergy', record.chargeEnergy), dischargeEnergy: formatTestDisplayValue('dischargeEnergy', record.dischargeEnergy), sourceKind: cycleSourceLabels[String(record.sourceKind || 'DEVICE')] || CYCLE_SOURCE_UNKNOWN })
}
async function loadRecords() {
  loading.value = true
@@ -352,8 +358,10 @@
    return
  }
  const columns = consoleImportColumns[dataType.value]
  const headers = Object.values(columns)
  const records = filteredRows.value.map(row => Object.keys(columns).map(field => row[field as keyof DataRow]))
  // 导出与界面一致地带上来源列:循环的推导值不能在被导出后失去"聚合来源"这个事实。
  const extraColumn = activeTab.value === 'cycle' ? { field: 'sourceKind' as const, label: '数据来源' } : null
  const headers = [...Object.values(columns), ...(extraColumn ? [extraColumn.label] : [])]
  const records = filteredRows.value.map(row => [...Object.keys(columns).map(field => row[field as keyof DataRow]), ...(extraColumn ? [row[extraColumn.field]] : [])])
  const blob = new Blob([`\uFEFF${[headers, ...records].map(item => item.map(escapeCsv).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const anchor = document.createElement('a')
@@ -383,6 +391,7 @@
      <small>{{ loading ? '加载中...' : `共 ${filteredRows.length} 条` }}</small>
    </header>
    <p v-if="activeTab === 'realtime'" class="realtime-hint">最近数据时间:{{ latestRealtimeDataAt }} · 设备约 60 秒产生一行新数据,展示刷新比它快是正常的</p>
    <p v-if="activeTab === 'cycle' && hasAggregatedCycles" class="realtime-hint">来源标注「工步层聚合」表示设备没有提供循环汇总行,该循环由本系统按工步层同方向工步求和得出,不是设备事实;界面与导出都带这一列。</p>
    <div class="filter-grid">
      <label
        ><span>电池</span><el-select v-model="filters.batteryNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in batteryOptions" :key="item" :label="item" :value="item" /></el-select></label
@@ -436,6 +445,7 @@
              <el-checkbox v-if="activeTab === 'realtime'" :model-value="allPageSelected" :indeterminate="somePageSelected" aria-label="选择本页" @change="(checked: string | number | boolean) => togglePageSelection(Boolean(checked))" />
            </th>
            <th v-for="(label, field) in consoleImportColumns[dataType]" :key="field">{{ label }}</th>
            <th v-if="activeTab === 'cycle'" :title="cycleSourceTitle">数据来源</th>
            <th class="action-column">操作</th>
          </tr>
        </thead>
@@ -550,13 +560,14 @@
                <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeEnergy" size="small" />
                <span v-else>{{ row.dischargeEnergy }}</span>
              </td>
              <td :title="cycleSourceTitle">{{ row.sourceKind }}</td>
              <td class="action-column">
                <div class="row-actions"><button type="button" @click="openRowEditor(row)">编辑</button><button class="delete-action" type="button" @click="deleteSelected([row.id])">删除</button></div>
              </td>
            </tr></template
          >
          <tr v-if="!loading && !pagedRows.length">
            <td :colspan="activeTab === 'realtime' ? 17 : activeTab === 'step' ? 14 : 10" class="empty-cell">
            <td :colspan="activeTab === 'realtime' ? 17 : activeTab === 'step' ? 14 : 11" class="empty-cell">
              {{ loadError || '暂无检测柜记录' }}
            </td>
          </tr>