Browse Source

fix(waterSupply): 统计明细英文残留统一翻译为中文 (#57)

后端统计汇总接口以 Map 返回 SQL 别名(total_count / design_capacity 等),前端此前只认驼峰键,查不到即回落英文原键;管材分组还会直接显示 PE,嵌套对象还会把 value/unit 当成分组名。改为共享标签模型:下划线键归一化、未知指标兜底中文、管材码值翻译、扁平化时区分指标值与分布分组,并补齐 6 项回归测试。
Kazerin 1 tháng trước cách đây
mục cha
commit
cd8530b995

+ 166 - 0
src/utils/waterFacilityStatisticsModel.js

@@ -42,6 +42,172 @@ export const FACILITY_STATUS_TAG_TYPES = {
 const unknownStatus = { label: '未知', tagType: 'info' }
 const FACILITY_TYPE_ORDER = ['source', 'plant', 'pumpStation', 'pipe', 'user']
 
+// 后端统计汇总接口以 Map 返回明细,键名是 SQL 别名(total_count / design_capacity ...)。
+// 这里以驼峰键为唯一口径,同时在查询前把下划线键归一化,避免英文原键漏到统计明细表格。
+export const STATISTIC_METRIC_LABELS = {
+  totalCount: '总数',
+  totalLength: '总长度',
+  averageDiameter: '平均管径',
+  designCapacity: '设计规模',
+  currentCapacity: '实际规模',
+  capacityUtilization: '产能利用率',
+  pumpCount: '水泵数量',
+  designFlow: '设计流量',
+  designHead: '设计扬程',
+  powerCapacity: '装机容量',
+  annualWaterConsumption: '年用水量',
+  materialDistribution: '管材分布',
+  diameterDistribution: '管径分布',
+  layingYearDistribution: '建设年代分布',
+  levelDistribution: '等级分布',
+  statusDistribution: '状态分布',
+  waterSourceTypeDistribution: '水源类型分布',
+  sourceTypeDistribution: '水源地类型分布',
+  qualityDistribution: '水质等级分布',
+  protectionDistribution: '保护等级分布',
+  userTypeDistribution: '用水户类型分布',
+  consumptionDistribution: '用水量分布'
+}
+
+export const STATISTIC_METRIC_DESCRIPTIONS = {
+  totalCount: '当前筛选条件下的设施总数',
+  totalLength: '当前筛选条件下的管网总长度',
+  averageDiameter: '当前筛选条件下的管径平均值',
+  designCapacity: '水厂设计供水能力',
+  currentCapacity: '水厂实际供水能力',
+  capacityUtilization: '实际供水能力与设计供水能力的比值',
+  pumpCount: '泵站内水泵总数量',
+  designFlow: '泵站设计流量',
+  designHead: '泵站设计扬程',
+  powerCapacity: '泵站装机总容量',
+  annualWaterConsumption: '用水户年度用水量',
+  materialDistribution: '按管材统计管网数量',
+  diameterDistribution: '按管径区间统计管网数量',
+  layingYearDistribution: '按建设年代统计管网数量',
+  levelDistribution: '按等级统计管网数量',
+  statusDistribution: '按业务状态统计设施数量',
+  waterSourceTypeDistribution: '按水源类型统计水厂数量',
+  sourceTypeDistribution: '按水源地类型统计数量',
+  qualityDistribution: '按水质等级统计水源地数量',
+  protectionDistribution: '按保护等级统计水源地数量',
+  userTypeDistribution: '按用水户类型统计数量',
+  consumptionDistribution: '按用水量区间统计用水户数量'
+}
+
+// 分布类统计的原始码值翻译;未登记的纯英文码值不直接展示,统一归入「其他」。
+export const DISTRIBUTION_VALUE_LABELS = {
+  materialDistribution: {
+    PE: '聚乙烯',
+    HDPE: '高密度聚乙烯',
+    PVC: '聚氯乙烯',
+    'PVC-U': '硬聚氯乙烯',
+    PVCU: '硬聚氯乙烯',
+    PP: '聚丙烯',
+    PPR: '无规共聚聚丙烯',
+    'PE-X': '交联聚乙烯',
+    PEX: '交联聚乙烯',
+    ABS: '工程塑料',
+    FRP: '玻璃钢管',
+    GRP: '玻璃钢管'
+  }
+}
+
+const UNKNOWN_GROUP_LABEL = '未填报'
+const UNKNOWN_METRIC_LABEL = '其他指标'
+const UNKNOWN_METRIC_DESCRIPTION = '按当前筛选条件统计'
+const UNKNOWN_SECTION_LABEL = '总览'
+const UNKNOWN_MATERIAL_LABEL = '其他管材'
+const STATISTIC_METRIC_UNIT = '项'
+// 码值识别:以字母开头的纯 ASCII 串视为编码(如 PE / PVC-U / HDPE);
+// 数字区间与含中文的值(100-299mm、Ⅳ类)保持原样展示。
+const ASCII_CODE_PATTERN = /^[A-Za-z][A-Za-z0-9._/-]*$/
+
+export function normalizeStatisticKey(key) {
+  return String(key ?? '')
+    .trim()
+    .replace(/_([A-Za-z0-9])/g, (match, char) => char.toUpperCase())
+}
+
+export function resolveStatisticMetricLabel(key) {
+  return STATISTIC_METRIC_LABELS[normalizeStatisticKey(key)] || UNKNOWN_METRIC_LABEL
+}
+
+export function resolveStatisticMetricDescription(key) {
+  return STATISTIC_METRIC_DESCRIPTIONS[normalizeStatisticKey(key)] || UNKNOWN_METRIC_DESCRIPTION
+}
+
+export function resolveFacilitySectionLabel(key) {
+  return FACILITY_SECTION_LABELS[key] || UNKNOWN_SECTION_LABEL
+}
+
+export function resolveDistributionValueLabel(metricKey, rawName) {
+  const value = rawName === null || rawName === undefined ? '' : String(rawName).trim()
+  if (!value) return UNKNOWN_GROUP_LABEL
+
+  const labels = DISTRIBUTION_VALUE_LABELS[metricKey]
+  if (labels && labels[value]) return labels[value]
+  if (ASCII_CODE_PATTERN.test(value)) return labels ? UNKNOWN_MATERIAL_LABEL : UNKNOWN_GROUP_LABEL
+  return value
+}
+
+export const STATISTIC_ROW_KIND = {
+  METRIC: 'metric',
+  DISTRIBUTION: 'distribution'
+}
+
+// 把后端 dashboard 的嵌套结构摊平成统计明细行。
+// 数组 => 分布分组(kind=distribution);标量 => 指标值(kind=metric),
+// 这样嵌套对象的 value/unit 这类结构键不会作为分组名进入表格。
+export function flattenStatisticMetrics(section, value) {
+  if (Array.isArray(value)) {
+    return value.map(item => ({ ...item, section, kind: STATISTIC_ROW_KIND.DISTRIBUTION }))
+  }
+  if (!value || typeof value !== 'object') {
+    return [{ name: section, value, unit: STATISTIC_METRIC_UNIT, section: '', kind: STATISTIC_ROW_KIND.METRIC }]
+  }
+  return Object.entries(value).flatMap(([name, metric]) => {
+    if (Array.isArray(metric)) {
+      return metric.map(item => ({
+        ...item,
+        section: `${section}/${name}`,
+        kind: STATISTIC_ROW_KIND.DISTRIBUTION
+      }))
+    }
+    if (metric && typeof metric === 'object') {
+      return flattenStatisticMetrics(`${section}/${name}`, metric)
+    }
+    return [{ name, value: metric, unit: STATISTIC_METRIC_UNIT, section, kind: STATISTIC_ROW_KIND.METRIC }]
+  })
+}
+
+export function formatStatisticRow(section, item, options = {}) {
+  const levelLabel = typeof options.levelLabel === 'function' ? options.levelLabel : value => value
+  const [facilityKey, metricKey] = String(section ?? '').split('/')
+  const facilityType = FACILITY_SECTION_TYPES[facilityKey] || facilityKey
+  const facilityLabel = resolveFacilitySectionLabel(facilityKey)
+  const rawMetricKey = metricKey || item.name
+  const metricLabel = resolveStatisticMetricLabel(rawMetricKey)
+  const isDistribution = item.kind === STATISTIC_ROW_KIND.DISTRIBUTION
+  const isStatusDistribution = isDistribution && metricKey === 'statusDistribution'
+  const isLevelDistribution = isDistribution && metricKey === 'levelDistribution'
+  const name = isStatusDistribution
+    ? resolveFacilityStatusLabel(facilityType, item.name)
+    : isLevelDistribution
+      ? levelLabel(item.name)
+      : isDistribution
+        ? resolveDistributionValueLabel(metricKey, item.name)
+        : metricLabel
+
+  return {
+    section: `${facilityLabel} - ${metricLabel}`,
+    name,
+    tagType: isStatusDistribution ? resolveFacilityStatusTagType(facilityType, item.name) : undefined,
+    value: item.value ?? item.totalCount ?? 0,
+    unit: item.unit || STATISTIC_METRIC_UNIT,
+    description: resolveStatisticMetricDescription(rawMetricKey)
+  }
+}
+
 export function buildPipeNetworkStatisticsQuery({ facilityTypes = [], status = '' } = {}) {
   const query = {}
   const types = Array.isArray(facilityTypes)

+ 8 - 85
src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue

@@ -105,9 +105,10 @@ import * as echarts from 'echarts'
 import { getDicts } from '@/api/system/dict/data'
 import { exportPipeNetworkStatistics, getPipeNetworkDashboard, getWaterFacilityDetail, getWaterFacilityFeatures, getWaterFacilityOverview } from '@/api/pipeNetwork/waterSupply'
 import {
-  FACILITY_SECTION_LABELS,
   FACILITY_SECTION_TYPES,
   buildPipeNetworkStatisticsQuery,
+  flattenStatisticMetrics,
+  formatStatisticRow,
   getFacilityStatusOptions,
   resolveFacilityStatusLabel,
   resolveFacilityStatusTagType
@@ -125,54 +126,6 @@ const facilityTypes = [
   { label: '管网', value: 'pipe', color: '#06b6d4' },
   { label: '用水户', value: 'user', color: '#ef4446' }
 ]
-const STATISTIC_LABELS = {
-  totalCount: '总数',
-  totalLength: '总长度',
-  averageDiameter: '平均管径',
-  designCapacity: '设计规模',
-  currentCapacity: '实际规模',
-  capacityUtilization: '产能利用率',
-  pumpCount: '水泵数量',
-  designFlow: '设计流量',
-  designHead: '设计扬程',
-  powerCapacity: '装机容量',
-  annualWaterConsumption: '年用水量',
-  materialDistribution: '管材分布',
-  diameterDistribution: '管径分布',
-  layingYearDistribution: '建设年代分布',
-  levelDistribution: '等级分布',
-  statusDistribution: '状态分布',
-  waterSourceTypeDistribution: '水源类型分布',
-  sourceTypeDistribution: '水源地类型分布',
-  qualityDistribution: '水质等级分布',
-  protectionDistribution: '保护等级分布',
-  userTypeDistribution: '用水户类型分布',
-  consumptionDistribution: '用水量分布'
-}
-const STATISTIC_DESCRIPTIONS = {
-  totalCount: '当前筛选条件下的设施总数',
-  totalLength: '当前筛选条件下的管网总长度',
-  averageDiameter: '当前筛选条件下的管径平均值',
-  designCapacity: '水厂设计供水能力',
-  currentCapacity: '水厂实际供水能力',
-  capacityUtilization: '实际供水能力与设计供水能力的比值',
-  pumpCount: '泵站内水泵总数量',
-  designFlow: '泵站设计流量',
-  designHead: '泵站设计扬程',
-  powerCapacity: '泵站装机总容量',
-  annualWaterConsumption: '用水户年度用水量',
-  materialDistribution: '按管材统计管网数量',
-  diameterDistribution: '按管径区间统计管网数量',
-  layingYearDistribution: '按建设年代统计管网数量',
-  levelDistribution: '按等级统计管网数量',
-  statusDistribution: '按业务状态统计设施数量',
-  waterSourceTypeDistribution: '按水源类型统计水厂数量',
-  sourceTypeDistribution: '按水源地类型统计数量',
-  qualityDistribution: '按水质等级统计水源地数量',
-  protectionDistribution: '按保护等级统计水源地数量',
-  userTypeDistribution: '按用水户类型统计数量',
-  consumptionDistribution: '按用水量区间统计用水户数量'
-}
 const STATUS_CATEGORY_LABELS = {
   abnormal: { source: '异常', plant: '停产', pumpStation: '故障停运', pipe: '异常', user: '停用' },
   warning: { pumpStation: '检修中', pipe: '维护中', user: '欠费' }
@@ -208,15 +161,6 @@ const handleStatusChange = value => {
 }
 
 const typeColor = type => facilityTypes.find(item => item.value === type)?.color || '#2563eb'
-const flattenMetrics = (section, value) => {
-  if (Array.isArray(value)) return value.map(item => ({ ...item, section }))
-  if (!value || typeof value !== 'object') return [{ name: section, value, unit: '项', section: '' }]
-  return Object.entries(value).flatMap(([name, metric]) => {
-    if (Array.isArray(metric)) return metric.map(item => ({ ...item, section: `${section}/${name}` }))
-    if (metric && typeof metric === 'object') return flattenMetrics(`${section}/${name}`, metric)
-    return [{ name, value: metric, unit: '项', section }]
-  })
-}
 
 const statusCategoryLabel = category => {
   const labels = new Set()
@@ -229,31 +173,10 @@ const statusCategoryLabel = category => {
 
 const resolveLevelLabel = name => {
   const item = levelDict.value.find(d => String(d.value) === String(name))
-  return item ? item.label : String(name ?? '')
-}
-
-const formatStatisticRow = (section, item) => {
-  const [facilityKey, metricKey] = section.split('/')
-  const rawMetricKey = metricKey || item.name
-  const facilityType = FACILITY_SECTION_TYPES[facilityKey] || facilityKey
-  const facilityLabel = FACILITY_SECTION_LABELS[facilityKey] || facilityKey || '总览'
-  const metricLabel = STATISTIC_LABELS[rawMetricKey] || rawMetricKey || '总览'
-  const isStatusDistribution = metricKey === 'statusDistribution'
-  const isLevelDistribution = metricKey === 'levelDistribution'
-  const name = isStatusDistribution
-    ? resolveFacilityStatusLabel(facilityType, item.name)
-    : isLevelDistribution
-      ? resolveLevelLabel(item.name)
-      : item.name ?? metricLabel
-
-  return {
-    section: `${facilityLabel} - ${metricLabel}`,
-    name,
-    tagType: isStatusDistribution ? resolveFacilityStatusTagType(facilityType, item.name) : undefined,
-    value: item.value ?? item.totalCount ?? 0,
-    unit: item.unit || '项',
-    description: STATISTIC_DESCRIPTIONS[rawMetricKey] || '按当前筛选条件统计'
-  }
+  if (item) return item.label
+  const raw = name === null || name === undefined ? '' : String(name).trim()
+  if (!raw) return '未知等级'
+  return /^[A-Za-z]/.test(raw) ? '未知等级' : raw
 }
 
 const clearStatistics = async () => {
@@ -364,8 +287,8 @@ const loadData = async () => {
       overview.value = facilityOverview.map(item => ({ label: item.facilityTypeName || item.facilityType, value: item.totalCount ?? 0, unit: '项' }))
       rows.value = Object.entries(data)
         .filter(([section]) => section !== 'facilityOverview')
-        .flatMap(([section, value]) => flattenMetrics(section, value))
-        .map(item => formatStatisticRow(item.section, item))
+        .flatMap(([section, value]) => flattenStatisticMetrics(section, value))
+        .map(item => formatStatisticRow(item.section, item, { levelLabel: resolveLevelLabel }))
       await renderCharts(facilityOverview)
     }
   } catch (error) { overview.value = []; rows.value = []; mapPoints.value = []; mapLines.value = []; console.warn(`[${props.title}] 数据加载失败`, error) } finally { loading.value = false }

+ 98 - 3
tests/waterFacilityStatistics.test.mjs

@@ -8,6 +8,13 @@ import {
   FACILITY_SECTION_TYPES,
   FACILITY_STATUS_TAG_TYPES,
   getFacilityStatusOptions,
+  flattenStatisticMetrics,
+  formatStatisticRow,
+  normalizeStatisticKey,
+  resolveDistributionValueLabel,
+  resolveFacilitySectionLabel,
+  resolveStatisticMetricDescription,
+  resolveStatisticMetricLabel,
   resolveFacilityStatusLabel
 } from '../src/utils/waterFacilityStatisticsModel.js'
 
@@ -63,7 +70,7 @@ test('statistics table uses Chinese field labels and explicit hints', () => {
     'utf8'
   )
 
-  assert.match(source, /STATISTIC_LABELS/u)
+  assert.match(source, /formatStatisticRow/u)
   assert.match(source, /el-tooltip content="按设施类型汇总的关键统计指标"/u)
   assert.match(source, /el-tooltip content="当前行的统计口径和数据来源"/u)
 })
@@ -73,8 +80,9 @@ test('statistics status rows use the agreed unknown label', () => {
     'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
     'utf8'
   )
+  const model = fs.readFileSync('src/utils/waterFacilityStatisticsModel.js', 'utf8')
 
-  assert.match(source, /resolveFacilityStatusLabel\(facilityType, item\.name\)/u)
+  assert.match(model, /resolveFacilityStatusLabel\(facilityType, item\.name\)/u)
   assert.match(source, /category === 'normal' \? '正常' : '未知'/u)
   assert.doesNotMatch(source, /未填报/u)
 })
@@ -199,8 +207,95 @@ test('statistics charts hide status series without data', () => {
     'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
     'utf8'
   )
+  const model = fs.readFileSync('src/utils/waterFacilityStatisticsModel.js', 'utf8')
 
   assert.match(source, /\.filter\(series => facilityOverview\.some\(item => Number\(item\[series\.key\] \|\| 0\) > 0\)\)/u)
-  assert.match(source, /resolveFacilityStatusLabel\(facilityType, item\.name\)/u)
+  assert.match(model, /resolveFacilityStatusLabel\(facilityType, item\.name\)/u)
   assert.doesNotMatch(source, /未填报/u)
 })
+
+test('statistics metric keys accept both snake_case and camelCase', () => {
+  assert.equal(normalizeStatisticKey('total_count'), 'totalCount')
+  assert.equal(normalizeStatisticKey('totalCount'), 'totalCount')
+  assert.equal(normalizeStatisticKey('annual_water_consumption'), 'annualWaterConsumption')
+
+  assert.equal(resolveStatisticMetricLabel('total_count'), '总数')
+  assert.equal(resolveStatisticMetricLabel('total_length'), '总长度')
+  assert.equal(resolveStatisticMetricLabel('average_diameter'), '平均管径')
+  assert.equal(resolveStatisticMetricLabel('design_capacity'), '设计规模')
+  assert.equal(resolveStatisticMetricLabel('current_capacity'), '实际规模')
+  assert.equal(resolveStatisticMetricLabel('pump_count'), '水泵数量')
+  assert.equal(resolveStatisticMetricLabel('design_flow'), '设计流量')
+  assert.equal(resolveStatisticMetricLabel('design_head'), '设计扬程')
+  assert.equal(resolveStatisticMetricLabel('power_capacity'), '装机容量')
+  assert.equal(resolveStatisticMetricLabel('annual_water_consumption'), '年用水量')
+  assert.equal(resolveStatisticMetricLabel('capacity_utilization'), '产能利用率')
+  assert.equal(resolveStatisticMetricLabel('totalCount'), '总数')
+})
+
+test('statistics detail table never falls back to english keys or codes', () => {
+  assert.equal(resolveStatisticMetricLabel('unknown_metric_xyz'), '其他指标')
+  assert.equal(resolveStatisticMetricDescription('unknown_metric_xyz'), '按当前筛选条件统计')
+  assert.equal(resolveStatisticMetricDescription('total_length'), '当前筛选条件下的管网总长度')
+  assert.equal(resolveFacilitySectionLabel('unknownSection'), '总览')
+  assert.equal(resolveFacilitySectionLabel('pipeNetwork'), '管网')
+})
+
+test('statistics distribution groups translate coded values', () => {
+  assert.equal(resolveDistributionValueLabel('materialDistribution', 'PE'), '聚乙烯')
+  assert.equal(resolveDistributionValueLabel('materialDistribution', 'PVC-U'), '硬聚氯乙烯')
+  assert.equal(resolveDistributionValueLabel('materialDistribution', '铸铁'), '铸铁')
+  assert.equal(resolveDistributionValueLabel('materialDistribution', null), '未填报')
+  assert.equal(resolveDistributionValueLabel('waterSourceTypeDistribution', '地下水'), '地下水')
+})
+
+test('statistics detail rows render backend payload without english text', () => {
+  const payload = {
+    pipeNetwork: {
+      total_count: 996,
+      total_length: 18819.43,
+      average_diameter: 334.53,
+      materialDistribution: [
+        { name: 'PE', value: 277, unit: '条' },
+        { name: '铸铁', value: 183, unit: '条' }
+      ],
+      statusDistribution: [{ name: '2', value: 4, unit: '条' }]
+    },
+    waterPlant: {
+      design_capacity: 5,
+      current_capacity: 4,
+      capacity_utilization: 80,
+      capacityUtilization: { value: 80, unit: '%' }
+    },
+    waterSource: { qualityDistribution: [{ name: 'Ⅳ类', value: 1, unit: '处' }] }
+  }
+
+  const rows = Object.entries(payload)
+    .flatMap(([section, value]) => flattenStatisticMetrics(section, value))
+    .map(item => formatStatisticRow(item.section, item, { levelLabel: name => `第${name}级` }))
+
+  const cells = rows.map(row => `${row.section}|${row.name}|${row.description}`).join('\n')
+  assert.doesNotMatch(cells, /[A-Za-z]{2,}/u)
+  assert.doesNotMatch(cells, /\bvalue\b|\bunit\b/u)
+
+  assert.equal(rows.find(row => row.section === '管网 - 总数').name, '总数')
+  assert.equal(rows.find(row => row.section === '管网 - 总长度').value, 18819.43)
+  assert.equal(rows.find(row => row.section === '管网 - 管材分布').name, '聚乙烯')
+  assert.equal(rows.find(row => row.section === '管网 - 管材分布').value, 277)
+  assert.equal(rows.find(row => row.section === '管网 - 状态分布').name, '异常')
+  assert.equal(rows.find(row => row.section === '水厂 - 产能利用率').name, '产能利用率')
+  assert.equal(rows.find(row => row.section === '水源地 - 水质等级分布').name, 'Ⅳ类')
+})
+
+test('statistics detail table uses the shared chinese label helpers', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /flattenStatisticMetrics/u)
+  assert.match(source, /formatStatisticRow\(item\.section, item, \{ levelLabel: resolveLevelLabel \}\)/u)
+  assert.match(source, /resolveLevelLabel/u)
+  assert.doesNotMatch(source, /STATISTIC_LABELS/u)
+  assert.doesNotMatch(source, /\|\| rawMetricKey/u)
+})