Просмотр исходного кода

优化设备列表-设备类型查询条件样式

zengy 16 часов назад
Родитель
Сommit
25ef04cccc

+ 1 - 1
memory/index.md

@@ -71,7 +71,7 @@ npm run dev       # 默认 http://localhost:80
 
 ## 会话记录
 
-- [2026-10-21:删除事件 department 字段及核实处置单位、调整事件状态编号](sessions/2026-10-21.md)
+- [2026-10-21:事件字段与状态调整、设备台账两级类型快捷筛选](sessions/2026-10-21.md)
 - [2026-10-19:移动端故障权限及PC应急指挥与预案调整](sessions/2026-10-19.md)
 - [2026-10-16:预警统一详情、工单与预警选人及待办闭环](sessions/2026-10-16.md)
 - [2026-10-15:权限管理子系统、移动端工单权限、PC 登录页与门户高保真重构](sessions/2026-10-15.md)

+ 10 - 0
memory/sessions/2026-10-21.md

@@ -54,3 +54,13 @@
 - 修复 `DispatchAttachUrlSupport`:`toAccessibleUrl` 和 `toRelative` 均按逗号逐项处理,支持 `logs.attachUrl` 多图、混合历史完整 URL 与相对路径,避免把整串多图地址拼成一个错误 URL。
 - `api/mobile/emergency/events/{id}` 通过事件日志服务返回的 `logs.attachUrl` 将得到 `url1,url2,...`,每一项均可直接用于图片预览。
 - 验证:`DispatchAttachUrlSupportTest` 2 项通过(多图完整地址转换、混合地址相对化),后端管理模块编译通过,`git diff --check` 通过;无需数据库迁移。
+
+## 设备台账设备类型两级快捷筛选
+
+- 目标:按 `prd/设备台账-设备类型快捷筛选.html` 将设备台账的设备类型查询下拉替换为两级平铺选择。
+- `equipmentBase/index.vue` 新增一级大类按钮和二级浅底胶囊;默认一级全部、二级隐藏,选中大类才展开对应子类型。切换大类清空旧二级,点击立即查询并回到第一页;重置清空两级选择及其他查询条件。
+- 使用 `/equipmentType` 返回的真实分类,不采用原型示例分类与模拟数量;新增/编辑表单的类型树下拉保持原样。
+- 复用后端已有 `equipmentTypeIds` 集合参数过滤大类,`equipmentTypeId` 精确过滤二级;保留菜单 `rootTypeId` 范围,未分类按实际叶子 ID 查询,空大类使用无效 ID 避免退化为全部数据,不改后端或数据库。
+- 新增 `tests/equipmentTypeQuickFilter.test.mjs` 并接入 `npm test`,5 项回归覆盖默认、两级联动、后端分页参数、切换清空、重置、菜单范围、未分类与空大类。
+- 验证:新增 5 项及既有 6 项测试通过,生产构建通过(有既有 Sass 弃用与打包体积提示),`git diff --check` 通过;未进行真实登录态浏览器联调。
+- 后续:部署前端后确认真实设备分类、筛选结果与分页;无需新增接口或数据库迁移。

+ 1 - 1
package.json

@@ -7,7 +7,7 @@
   "type": "module",
   "scripts": {
     "dev": "vite",
-    "test": "node --test tests/waterHomeRouteResolver.test.mjs tests/waterSupplyMonitoring.test.mjs tests/waterSupplyModel.test.mjs",
+    "test": "node --test tests/waterHomeRouteResolver.test.mjs tests/waterSupplyMonitoring.test.mjs tests/waterSupplyModel.test.mjs tests/equipmentTypeQuickFilter.test.mjs",
     "build:prod": "vite build",
     "build:stage": "vite build --mode staging",
     "preview": "vite preview"

+ 151 - 17
src/views/subSystem/pipeNetwork/assetInfo/equipmentBase/index.vue

@@ -46,6 +46,35 @@
 
     <!-- 查询条件 -->
     <el-card shadow="never" class="filter-card">
+      <div class="type-filter">
+        <div class="type-filter-title">设备类型</div>
+        <div class="type-filter-label">一级类型</div>
+        <div class="type-filter-options" role="group" aria-label="一级类型">
+          <button type="button" class="type-category" :aria-pressed="selectedCategoryId === undefined" @click="selectCategory(undefined)">全部</button>
+          <button
+            v-for="item in equipmentTypeTree"
+            :key="item.id"
+            type="button"
+            class="type-category"
+            :aria-pressed="selectedCategoryId === item.id"
+            @click="selectCategory(item.id)"
+          >{{ item.typeName }}</button>
+        </div>
+        <template v-if="selectedCategory">
+          <div class="type-filter-label">二级类型</div>
+          <div class="type-filter-options" role="group" aria-label="二级类型">
+            <button type="button" class="type-chip" :aria-pressed="queryParams.equipmentTypeId === undefined" @click="selectEquipmentType(undefined)">全部</button>
+            <button
+              v-for="item in equipmentSubtypes"
+              :key="item.id"
+              type="button"
+              class="type-chip"
+              :aria-pressed="queryParams.equipmentTypeId === item.id"
+              @click="selectEquipmentType(item.id)"
+            >{{ item.typeName }}</button>
+          </div>
+        </template>
+      </div>
       <el-form ref="queryRef" :model="queryParams" :inline="true" class="filters">
         <el-form-item label="设备编码" prop="equipmentCode">
           <el-input
@@ -65,19 +94,6 @@
             @keyup.enter="handleQuery"
           />
         </el-form-item>
-        <el-form-item label="设备类型" prop="equipmentTypeId">
-          <el-tree-select
-            v-model="queryParams.equipmentTypeId"
-            :data="equipmentTypeTree"
-            node-key="id"
-            :props="{ label: 'typeName', children: 'children', disabled: 'disabled' }"
-            check-strictly
-            :render-after-expand="false"
-            clearable
-            placeholder="请选择"
-            class="select--type"
-          />
-        </el-form-item>
         <el-form-item label="在线状态" prop="onlineStatus">
           <el-select v-model="queryParams.onlineStatus" clearable placeholder="请选择" class="select--online">
             <el-option label="在线" :value="1" />
@@ -400,6 +416,7 @@ const rootTypeId = ref(route.query.rootTypeId ? Number(route.query.rootTypeId) :
 
 const equipmentList = ref([]);
 const allTypeList = ref([]);
+const selectedCategoryId = ref();
 const loading = ref(false);
 const open = ref(false);
 const detailOpen = ref(false);
@@ -526,6 +543,28 @@ const equipmentTypeTree = computed(() => {
   return branch ? [branch] : tree;
 });
 
+const selectedCategory = computed(() => equipmentTypeTree.value.find((item) => item.id === selectedCategoryId.value));
+
+// 复用真实类型树;若存在更深层级,将可选叶子平铺在二级区域。
+function collectSelectableTypes(nodes) {
+  return nodes.flatMap((node) => node.children?.length
+    ? collectSelectableTypes(node.children)
+    : node.disabled ? [] : [node]);
+}
+
+const equipmentSubtypes = computed(() => collectSelectableTypes(selectedCategory.value?.children || []));
+
+function selectCategory(id) {
+  selectedCategoryId.value = id;
+  queryParams.value.equipmentTypeId = undefined;
+  handleQuery();
+}
+
+function selectEquipmentType(id) {
+  queryParams.value.equipmentTypeId = id;
+  handleQuery();
+}
+
 function currentStatusMeta(value) {
   const map = {
     0: { text: "在用", type: "success" },
@@ -594,6 +633,9 @@ function getList() {
   return listEquipment({
     ...queryParams.value,
     rootTypeId: rootTypeId.value,
+    // 保留菜单的数据范围,大类筛选通过子类型集合交由后端分页过滤。
+    // 空大类使用无效 ID 返回空结果,避免空参数被后端解释为不限类型。
+    equipmentTypeIds: selectedCategory.value ? equipmentSubtypes.value.map((item) => item.id).join(",") || "-1" : undefined,
     alarmStatus: alarmOnly.value ? 1 : undefined
   })
     .then((response) => {
@@ -699,6 +741,8 @@ function handleQuery() {
 
 function resetQuery() {
   proxy.resetForm("queryRef");
+  selectedCategoryId.value = undefined;
+  queryParams.value.equipmentTypeId = undefined;
   alarmOnly.value = false;
   handleQuery();
 }
@@ -906,6 +950,100 @@ onMounted(() => {
 }
 
 /* 筛选栏 */
+.type-filter {
+  margin-bottom: 20px;
+}
+
+.type-filter-title,
+.type-filter-label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.type-filter-title {
+  color: #4e5969;
+  font-size: 13px;
+}
+
+.type-filter-title::before,
+.type-filter-label::before {
+  content: "";
+  width: 3px;
+  height: 14px;
+  border-radius: 2px;
+  background: var(--el-color-primary);
+}
+
+.type-filter-label {
+  margin: 16px 0 8px;
+  color: #86909c;
+  font-size: 12px;
+}
+
+.type-filter-label::before {
+  background: #86909c;
+}
+
+.type-filter-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.type-category,
+.type-chip {
+  padding: 0 16px;
+  height: 34px;
+  border: 1px solid #e5e6eb;
+  border-radius: 8px;
+  background: #fff;
+  color: #4e5969;
+  font: inherit;
+  font-size: 13px;
+  white-space: nowrap;
+  cursor: pointer;
+  transition: background .15s, color .15s, border-color .15s;
+}
+
+.type-category:hover,
+.type-chip:hover {
+  color: var(--el-color-primary);
+  border-color: var(--el-color-primary-light-5);
+}
+
+.type-category[aria-pressed="true"] {
+  color: #fff;
+  border-color: var(--el-color-primary);
+  background: var(--el-color-primary);
+  font-weight: 500;
+}
+
+.type-chip {
+  height: 30px;
+  padding: 0 14px;
+  border-color: transparent;
+  border-radius: 999px;
+  background: transparent;
+}
+
+.type-chip:hover,
+.type-chip[aria-pressed="true"] {
+  background: var(--el-color-primary-light-9);
+}
+
+.type-chip[aria-pressed="true"] {
+  color: var(--el-color-primary);
+  border-color: var(--el-color-primary);
+  font-weight: 500;
+}
+
+.type-category:focus-visible,
+.type-chip:focus-visible {
+  outline: 2px solid var(--el-color-primary);
+  outline-offset: 2px;
+}
+
 .filters {
   display: flex;
   align-items: center;
@@ -930,10 +1068,6 @@ onMounted(() => {
   width: 150px;
 }
 
-.select--type {
-  width: 150px;
-}
-
 .select--online {
   width: 120px;
 }

+ 104 - 0
tests/equipmentTypeQuickFilter.test.mjs

@@ -0,0 +1,104 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import { computed, reactive, ref, toRefs } from 'vue';
+
+// 执行页面本身的筛选逻辑,避免测试副本与实际请求参数脱节。
+const source = readFileSync(new URL('../src/views/subSystem/pipeNetwork/assetInfo/equipmentBase/index.vue', import.meta.url), 'utf8');
+const script = source.match(/<script setup>([\s\S]*?)<\/script>/)[1]
+  .replace(/^import[\s\S]*?from\s+["'][^"']+["'];?/gm, '')
+  .replace(/import\.meta\.env\.VITE_APP_BASE_API/g, '"/test-api"');
+
+const types = [
+  { id: 1, parentId: 0, typeName: '供水设备' },
+  { id: 2, parentId: 0, typeName: '排水设备' },
+  { id: 3, parentId: 0, typeName: '空大类' },
+  { id: 11, parentId: 1, typeName: '流量计' },
+  { id: 12, parentId: 1, typeName: '压力计' },
+  { id: 16, parentId: 2, typeName: '液位计' },
+  { id: 99, parentId: 90, typeName: '孤立类型' }
+];
+
+function createPage(rootTypeId) {
+  const calls = [];
+  let page;
+  const bindings = {
+    computed, reactive, ref, toRefs,
+    onMounted() {},
+    getCurrentInstance: () => ({ proxy: { resetForm() {
+      Object.assign(page.queryParams.value, { equipmentCode: undefined, equipmentName: undefined, onlineStatus: undefined });
+    } } }),
+    useRoute: () => ({ query: { rootTypeId } }),
+    listEquipment: async (query) => { calls.push(query); return { records: [], total: 0 }; }
+  };
+  page = new Function(...Object.keys(bindings), `${script}
+    return { allTypeList, equipmentTypeTree, equipmentSubtypes, selectedCategoryId, queryParams, alarmOnly, selectCategory, selectEquipmentType, resetQuery, getList };`
+  )(...Object.values(bindings));
+  page.allTypeList.value = types;
+  return { page, calls };
+}
+
+test('初始全部不限制设备类型,二级为空', async () => {
+  const { page, calls } = createPage();
+  await page.getList();
+  assert.equal(calls[0].equipmentTypeIds, undefined);
+  assert.equal(calls[0].equipmentTypeId, undefined);
+  assert.deepEqual(page.equipmentSubtypes.value, []);
+  assert.match(source, /type-category/);
+  assert.match(source, /type-chip/);
+  assert.doesNotMatch(source.split('<script setup>')[0], /v-model="queryParams.equipmentTypeId"/);
+});
+
+test('选择大类和二级立即查询,后端分页前过滤,保留其他条件', () => {
+  const { page, calls } = createPage();
+  Object.assign(page.queryParams.value, { pageNum: 5, equipmentName: '测试', onlineStatus: 0 });
+  page.alarmOnly.value = true;
+  page.selectCategory(1);
+  assert.deepEqual(page.equipmentSubtypes.value.map(item => item.id), [11, 12]);
+  assert.equal(calls.at(-1).equipmentTypeIds, '11,12');
+  assert.equal(calls.at(-1).pageNum, 1);
+  page.selectEquipmentType(12);
+  assert.equal(calls.at(-1).equipmentTypeId, 12);
+  assert.equal(calls.at(-1).equipmentName, '测试');
+  assert.equal(calls.at(-1).onlineStatus, 0);
+  assert.equal(calls.at(-1).alarmStatus, 1);
+  page.selectEquipmentType(undefined);
+  assert.equal(calls.at(-1).equipmentTypeId, undefined);
+  assert.equal(calls.at(-1).equipmentTypeIds, '11,12');
+});
+
+test('切换一级清空旧二级,一级全部取消分类限制', () => {
+  const { page, calls } = createPage();
+  page.selectCategory(1);
+  page.selectEquipmentType(12);
+  page.selectCategory(2);
+  assert.equal(calls.at(-1).equipmentTypeId, undefined);
+  assert.equal(calls.at(-1).equipmentTypeIds, '16');
+  page.selectCategory(undefined);
+  assert.equal(calls.at(-1).equipmentTypeIds, undefined);
+  assert.deepEqual(page.equipmentSubtypes.value, []);
+});
+
+test('菜单范围始终保留,重置同时清空两级、常规条件和报警', () => {
+  const { page, calls } = createPage('1');
+  assert.deepEqual(page.equipmentTypeTree.value.map(item => item.id), [1]);
+  page.selectCategory(1);
+  page.selectEquipmentType(11);
+  page.queryParams.value.equipmentCode = 'TEST';
+  page.alarmOnly.value = true;
+  page.resetQuery();
+  assert.equal(page.selectedCategoryId.value, undefined);
+  assert.equal(calls.at(-1).rootTypeId, 1);
+  assert.equal(calls.at(-1).equipmentTypeIds, undefined);
+  assert.equal(calls.at(-1).equipmentTypeId, undefined);
+  assert.equal(calls.at(-1).equipmentCode, undefined);
+  assert.equal(calls.at(-1).alarmStatus, undefined);
+});
+
+test('未分类按真实类型ID查询,空大类不能退化为全部数据', () => {
+  const { page, calls } = createPage();
+  page.selectCategory('__other__');
+  assert.equal(calls.at(-1).equipmentTypeIds, '99');
+  page.selectCategory(3);
+  assert.equal(calls.at(-1).equipmentTypeIds, '-1');
+});