Explorar o código

fix(water): refine statistics dashboard acceptance (#7)

Kazerin hai 4 días
pai
achega
fb44069416

+ 18 - 0
memory/sessions/2026-09-01.md

@@ -23,3 +23,21 @@
 
 - 本次未连接真实 KingbaseES 执行集成验收;最终验收仍需以 KingbaseES 作为唯一生产真相源。
 - Issue #7 尚未在 Gogs 关闭,等待用户人工验收。
+
+## 人工验收修正轮次
+
+### 目标
+
+针对用户对 T3「管网数据统计分析」的人工验收反馈,在原边界内优化视觉表现、恢复“展示全部”筛选,并统一五类设施的状态口径。
+
+### 结论
+
+- 统计卡片拆分图标与文字区域,图表改用渐变堆叠柱状图与中心总数环形图,底部表格改为中文字段、状态标签和提示说明。
+- 状态筛选增加显式“展示全部”选项;查询构造忽略空状态,切换设施类型时先归一化状态,清除筛选只触发一次加载。
+- 建立设施专属状态契约:水源地正常/异常,水厂正常/停产,泵站正常/故障停运/检修中,管网正常/停用,用水户正常/欠费/停用;未知状态统一显示为“未知”。
+- 后端总览统计增加 `warningCount` 与 `unknownCount`,约定外状态计入未知,保证总数与状态分项闭合; Excel 导出使用设施专属状态文案。
+- 前端针对性测试 16/16 通过,后端针对性契约测试 4/4 通过,后端全量测试 42/42 通过;前端生产构建通过。前端全量 Node 测试 44/45,唯一失败为 T3 外既有 `monitor/job` 详情居中弹窗规则问题。
+
+### 风险与后续
+
+- 本轮仍未连接真实 KingbaseES 做人工验收,最终验收需以 KingbaseES 环境确认统计口径、筛选和 Excel 导出。

+ 78 - 0
src/utils/waterFacilityStatisticsModel.js

@@ -1,3 +1,46 @@
+export const FACILITY_STATUS_LABELS = {
+  source: { '0': '正常', '1': '异常' },
+  plant: { '0': '正常', '1': '停产' },
+  pumpStation: { '0': '正常', '1': '故障停运', '2': '检修中' },
+  pipe: { '0': '正常', '1': '停用' },
+  user: { '0': '正常', '1': '欠费', '2': '停用' }
+}
+
+export const FACILITY_TYPE_LABELS = {
+  source: '水源地',
+  plant: '水厂',
+  pumpStation: '泵站',
+  pipe: '管网',
+  user: '用水户'
+}
+
+export const FACILITY_SECTION_TYPES = {
+  pipeNetwork: 'pipe',
+  waterPlant: 'plant',
+  waterSource: 'source',
+  waterUser: 'user',
+  pumpStation: 'pumpStation'
+}
+
+export const FACILITY_SECTION_LABELS = {
+  pipeNetwork: FACILITY_TYPE_LABELS.pipe,
+  waterPlant: FACILITY_TYPE_LABELS.plant,
+  waterSource: FACILITY_TYPE_LABELS.source,
+  waterUser: FACILITY_TYPE_LABELS.user,
+  pumpStation: FACILITY_TYPE_LABELS.pumpStation
+}
+
+export const FACILITY_STATUS_TAG_TYPES = {
+  source: { '0': 'success', '1': 'danger' },
+  plant: { '0': 'success', '1': 'danger' },
+  pumpStation: { '0': 'success', '1': 'danger', '2': 'warning' },
+  pipe: { '0': 'success', '1': 'danger' },
+  user: { '0': 'success', '1': 'warning', '2': 'danger' }
+}
+
+const unknownStatus = { label: '未知', tagType: 'info' }
+const FACILITY_TYPE_ORDER = ['source', 'plant', 'pumpStation', 'pipe', 'user']
+
 export function buildPipeNetworkStatisticsQuery({ facilityTypes = [], status = '' } = {}) {
   const query = {}
   const types = Array.isArray(facilityTypes)
@@ -9,3 +52,38 @@ export function buildPipeNetworkStatisticsQuery({ facilityTypes = [], status = '
   if (normalizedStatus) query.status = normalizedStatus
   return query
 }
+
+export function getFacilityStatusOptions(facilityTypes = []) {
+  const types = FACILITY_TYPE_ORDER.filter(type => facilityTypes.includes(type))
+  const statusValues = new Set()
+
+  types.forEach(type => {
+    Object.keys(FACILITY_STATUS_LABELS[type] || {}).forEach(status => statusValues.add(status))
+  })
+
+  return [...statusValues].sort().map(status => {
+    const labels = types
+      .map(type => ({
+        type,
+        label: FACILITY_STATUS_LABELS[type]?.[status]
+      }))
+      .filter(item => item.label)
+
+    const distinctLabels = [...new Set(labels.map(item => item.label))]
+    const label = types.length === 1
+      ? `${FACILITY_TYPE_LABELS[types[0]]}: ${distinctLabels[0]}`
+      : distinctLabels.join(' / ')
+
+    return { value: status, label }
+  })
+}
+
+export function resolveFacilityStatusLabel(type, status) {
+  const value = status === null || status === undefined ? '' : String(status)
+  return FACILITY_STATUS_LABELS[type]?.[value] || unknownStatus.label
+}
+
+export function resolveFacilityStatusTagType(type, status) {
+  const value = status === null || status === undefined ? '' : String(status)
+  return FACILITY_STATUS_TAG_TYPES[type]?.[value] || unknownStatus.tagType
+}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 223 - 19
src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue


+ 161 - 3
tests/waterFacilityStatistics.test.mjs

@@ -2,7 +2,14 @@ import test from 'node:test'
 import assert from 'node:assert/strict'
 import fs from 'node:fs'
 
-import { buildPipeNetworkStatisticsQuery } from '../src/utils/waterFacilityStatisticsModel.js'
+import {
+  buildPipeNetworkStatisticsQuery,
+  FACILITY_SECTION_LABELS,
+  FACILITY_SECTION_TYPES,
+  FACILITY_STATUS_TAG_TYPES,
+  getFacilityStatusOptions,
+  resolveFacilityStatusLabel
+} from '../src/utils/waterFacilityStatisticsModel.js'
 
 test('statistics query mirrors dashboard and export filters', () => {
   assert.deepEqual(
@@ -14,6 +21,11 @@ test('statistics query mirrors dashboard and export filters', () => {
   )
 })
 
+test('statistics status filter returns to the all-state contract', () => {
+  assert.equal(buildPipeNetworkStatisticsQuery({ facilityTypes: ['pipe'], status: '' }).status, undefined)
+  assert.equal(buildPipeNetworkStatisticsQuery({ facilityTypes: ['pipe'], status: null }).status, undefined)
+})
+
 test('statistics query omits empty filters instead of requesting empty datasets', () => {
   assert.deepEqual(buildPipeNetworkStatisticsQuery(), {})
   assert.deepEqual(buildPipeNetworkStatisticsQuery({ facilityTypes: ['', ' '], status: '' }), {})
@@ -37,6 +49,152 @@ test('statistics status filter follows the facility status contract', () => {
     'utf8'
   )
 
-  assert.match(source, /<el-option label="正常" value="0" \/><el-option label="异常" value="1" \/>/u)
-  assert.doesNotMatch(source, /value="2"/u)
+  assert.match(source, /getFacilityStatusOptions/u)
+  assert.match(source, /resolveFacilityStatusLabel/u)
+  assert.match(source, /label="展示全部" value=""/u)
+  assert.doesNotMatch(source, /@clear="/u)
+  assert.match(source, /warningCount/u)
+  assert.match(source, /unknownCount/u)
+})
+
+test('statistics table uses Chinese field labels and explicit hints', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /STATISTIC_LABELS/u)
+  assert.match(source, /el-tooltip content="按设施类型汇总的关键统计指标"/u)
+  assert.match(source, /el-tooltip content="当前行的统计口径和数据来源"/u)
+})
+
+test('statistics status rows use the agreed unknown label', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /resolveFacilityStatusLabel\(facilityType, item\.name\)/u)
+  assert.match(source, /category === 'normal' \? '正常' : '未知'/u)
+  assert.doesNotMatch(source, /未填报/u)
+})
+
+test('statistics status filter returns to the unfiltered contract', () => {
+  assert.deepEqual(
+    buildPipeNetworkStatisticsQuery({ facilityTypes: ['plant'], status: null }),
+    { facilityTypes: 'plant' }
+  )
+  assert.doesNotMatch(
+    JSON.stringify(buildPipeNetworkStatisticsQuery({ facilityTypes: ['plant'], status: '' })),
+    /"status"/u
+  )
+})
+
+test('facility status labels follow the per-facility-type contract', () => {
+  assert.equal(resolveFacilityStatusLabel('source', 0), '正常')
+  assert.equal(resolveFacilityStatusLabel('source', 1), '异常')
+  assert.equal(resolveFacilityStatusLabel('plant', 1), '停产')
+  assert.equal(resolveFacilityStatusLabel('pumpStation', 1), '故障停运')
+  assert.equal(resolveFacilityStatusLabel('pumpStation', 2), '检修中')
+  assert.equal(resolveFacilityStatusLabel('pipe', 1), '停用')
+  assert.equal(resolveFacilityStatusLabel('user', 1), '欠费')
+  assert.equal(resolveFacilityStatusLabel('user', 2), '停用')
+  assert.equal(resolveFacilityStatusLabel('source', null), '未知')
+})
+
+test('dashboard sections map to the shared facility status contract', () => {
+  assert.deepEqual(FACILITY_SECTION_TYPES, {
+    pipeNetwork: 'pipe',
+    waterPlant: 'plant',
+    waterSource: 'source',
+    waterUser: 'user',
+    pumpStation: 'pumpStation'
+  })
+
+  assert.equal(resolveFacilityStatusLabel(FACILITY_SECTION_TYPES.waterSource, 1), '异常')
+  assert.equal(resolveFacilityStatusLabel(FACILITY_SECTION_TYPES.waterPlant, 1), '停产')
+  assert.equal(resolveFacilityStatusLabel(FACILITY_SECTION_TYPES.pipeNetwork, 1), '停用')
+  assert.equal(resolveFacilityStatusLabel(FACILITY_SECTION_TYPES.waterUser, 1), '欠费')
+  assert.deepEqual(FACILITY_SECTION_LABELS, {
+    pipeNetwork: '管网',
+    waterPlant: '水厂',
+    waterSource: '水源地',
+    waterUser: '用水户',
+    pumpStation: '泵站'
+  })
+})
+
+test('facility status colors follow the per-facility-type contract', () => {
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.source, { 0: 'success', 1: 'danger' })
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.plant, { 0: 'success', 1: 'danger' })
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.pumpStation, {
+    0: 'success',
+    1: 'danger',
+    2: 'warning'
+  })
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.pipe, { 0: 'success', 1: 'danger' })
+  assert.deepEqual(FACILITY_STATUS_TAG_TYPES.user, {
+    0: 'success',
+    1: 'warning',
+    2: 'danger'
+  })
+})
+
+test('status options only expose values valid for selected facility types', () => {
+  assert.deepEqual(getFacilityStatusOptions(['source']), [
+    { value: '0', label: '水源地: 正常' },
+    { value: '1', label: '水源地: 异常' }
+  ])
+  assert.deepEqual(getFacilityStatusOptions(['source', 'pumpStation', 'user']), [
+    { value: '0', label: '正常' },
+    { value: '1', label: '异常 / 故障停运 / 欠费' },
+    { value: '2', label: '检修中 / 停用' }
+  ])
+  assert.deepEqual(getFacilityStatusOptions([]), [])
+})
+
+test('dashboard provides an explicit path back to the unfiltered state', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /@change="handleStatusChange"/u)
+  assert.match(source, /statusFilter\.value = String\(value \?\? ''\)/u)
+})
+
+test('changing facility types normalizes a stale status filter before loading data', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /@change="handleTypeChange"/u)
+  assert.match(
+    source,
+    /const handleTypeChange = \(\) => \{\s*const options = getFacilityStatusOptions\(selectedTypes\.value\)\s*if \(!options\.some\(option => option\.value === statusFilter\.value\)\) statusFilter\.value = ''\s*loadData\(\)\s*\}/u
+  )
+})
+
+test('statistics cards and charts use a non-overlapping technical layout', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    'utf8'
+  )
+
+  assert.match(source, /\.metric-icon \{\s*[^}]*position: static/u)
+  assert.match(source, /grid-template-columns: repeat\(5, minmax\(160px, 1fr\)\)/u)
+  assert.match(source, /radius: \['52%', '72%'\]/u)
+  assert.doesNotMatch(source, /roseType:/u)
+})
+
+test('statistics charts hide status series without data', () => {
+  const source = fs.readFileSync(
+    'src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue',
+    '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.doesNotMatch(source, /未填报/u)
 })

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio