瀏覽代碼

设备管理页面恢复到hash=25ef04ccccf0e57df6ab61db7c9f68462f9d7bca

zengy 4 天之前
父節點
當前提交
a28e5a3cac
共有 1 個文件被更改,包括 604 次插入 和 133 次删除
  1. 604 133
      src/views/subSystem/pipeNetwork/assetInfo/equipmentBase/index.vue

+ 604 - 133
src/views/subSystem/pipeNetwork/assetInfo/equipmentBase/index.vue

@@ -1,7 +1,15 @@
 <!-- 物联感知设备管理 -->
 <template>
-  <div class="app-container">
-    <!-- 设备统计 -->
+  <div class="app-container equipment-page">
+    <!-- 页头 -->
+    <div class="page-head">
+      <div class="page-head-titles">
+        <div class="page-title">设备统计</div>
+      </div>
+      <el-button type="primary" icon="Plus" class="btn-primary" @click="handleAdd">新增设备</el-button>
+    </div>
+
+    <!-- KPI -->
     <div class="kpi-row">
       <el-card shadow="never" class="kpi">
         <div class="kpi-icon total"><el-icon><monitor /></el-icon></div>
@@ -33,85 +41,123 @@
       </el-card>
     </div>
 
-    <!-- 查询条件 -->
-    <el-form ref="queryRef" :model="queryParams" :inline="true" v-show="showSearch" label-width="80px">
-      <el-form-item label="设备编码" prop="equipmentCode">
-        <el-input v-model="queryParams.equipmentCode" placeholder="请输入设备编码" clearable style="width: 200px" @keyup.enter="handleQuery" />
-      </el-form-item>
-      <el-form-item label="设备名称" prop="equipmentName">
-        <el-input v-model="queryParams.equipmentName" placeholder="请输入设备名称" clearable style="width: 200px" @keyup.enter="handleQuery" />
-      </el-form-item>
-      <el-form-item label="设备类型" prop="equipmentTypeId">
-        <el-select v-model="queryParams.equipmentTypeId" placeholder="请选择设备类型" clearable style="width: 200px">
-          <el-option v-for="item in equipmentSubtypes" :key="item.id" :label="item.typeName" :value="item.id" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="运行状态">
-        <el-select v-model="runStatus" placeholder="全部" clearable style="width: 160px">
-          <el-option label="在线" value="online" />
-          <el-option label="离线" value="offline" />
-          <el-option label="报警" value="alarm" />
-        </el-select>
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
-        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
-      </el-form-item>
-    </el-form>
-
-    <!-- 工具栏 -->
-    <el-row :gutter="10" class="mb8">
-      <el-col :span="1.5">
-        <el-button type="primary" plain icon="Plus" @click="handleAdd">新增设备</el-button>
-      </el-col>
-      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
-    </el-row>
-
     <!-- 设备台账 -->
-    <el-table v-loading="loading" :data="equipmentList" @row-dblclick="handleView">
-      <el-table-column prop="equipmentCode" label="设备编码" min-width="140" show-overflow-tooltip />
-      <el-table-column prop="equipmentName" label="设备名称" min-width="160" show-overflow-tooltip />
-      <el-table-column label="设备类型" min-width="140" show-overflow-tooltip>
-        <template #default="{ row }">
-          {{ (row.equipmentTypeId && typeNameMap[row.equipmentTypeId]) || "未分类" }}
-        </template>
-      </el-table-column>
-      <el-table-column label="所属片区" width="100" align="center">
-        <template #default="{ row }">{{ districtLabel(row.district) }}</template>
-      </el-table-column>
-      <el-table-column prop="equipmentModel" label="设备型号" min-width="130" show-overflow-tooltip />
-      <el-table-column prop="equipmentLocation" label="安装位置" min-width="200" show-overflow-tooltip />
-      <el-table-column label="运行状态" width="140" align="center">
-        <template #default="{ row }">
-          <el-tag :type="row.onlineStatus === 1 ? 'success' : 'info'" size="small">
-            {{ row.onlineStatus === 1 ? "在线" : "离线" }}
-          </el-tag>
-          <el-tag v-if="row.alarmStatus === 1" type="danger" size="small" style="margin-left: 4px">报警</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="资产状态" width="100" align="center">
-        <template #default="{ row }">
-          <el-tag :type="currentStatusMeta(row.currentStatus).type" size="small">
-            {{ currentStatusMeta(row.currentStatus).text }}
-          </el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="操作" width="200" align="center" fixed="right" class-name="small-padding fixed-width">
-        <template #default="{ row }">
-          <el-button link type="primary" icon="View" @click="handleView(row)">详情</el-button>
-          <el-button link type="primary" icon="Edit" @click="handleUpdate(row)">修改</el-button>
-          <el-button link type="danger" icon="Delete" @click="handleDelete(row)">删除</el-button>
+    <div class="page-title section-block-title">设备台账</div>
+
+    <!-- 查询条件 -->
+    <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>
-      </el-table-column>
-    </el-table>
+      </div>
+      <el-form ref="queryRef" :model="queryParams" :inline="true" class="filters">
+        <el-form-item label="设备编码" prop="equipmentCode">
+          <el-input
+            v-model="queryParams.equipmentCode"
+            placeholder="请输入"
+            clearable
+            class="input--code"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="设备名称" prop="equipmentName">
+          <el-input
+            v-model="queryParams.equipmentName"
+            placeholder="请输入"
+            clearable
+            class="input--name"
+            @keyup.enter="handleQuery"
+          />
+        </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" />
+            <el-option label="离线" :value="0" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="报警" prop="alarmOnly">
+          <el-checkbox v-model="alarmOnly" @change="handleQuery" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" class="btn-query" @click="handleQuery">查询</el-button>
+          <el-button class="btn-reset" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card shadow="never" class="ledger">
+      <div ref="gridRef" v-loading="loading" class="grid">
+        <el-empty v-if="!equipmentList.length" description="暂无设备数据" />
+        <div
+          v-for="row in equipmentList"
+          :key="row.equipmentId"
+          class="card"
+          :class="{ 'is-selected': selectedId === row.equipmentId, 'is-alarm': getRowAlarm(row.equipmentId) === 1 }"
+          @click="handleView(row)"
+        >
+          <div class="card-top">
+            <div class="card-name">{{ row.equipmentName || "未命名设备" }}</div>
+            <div class="status-group">
+              <span class="badge" :class="getRowOnline(row.equipmentId) === 1 ? 'online' : 'offline'">
+                <span class="badge-dot"></span>
+                <span class="badge-text">{{ getRowOnline(row.equipmentId) === 1 ? "在线" : "离线" }}</span>
+              </span>
+              <span v-if="getRowAlarm(row.equipmentId) === 1" class="badge alarm">
+                <el-icon class="badge-icon"><warning-filled /></el-icon>
+                <span class="badge-text">报警</span>
+              </span>
+            </div>
+          </div>
 
-    <pagination
-      v-show="total > 0"
-      :total="total"
-      v-model:page="queryParams.pageNum"
-      v-model:limit="queryParams.pageSize"
-      @pagination="getList"
-    />
+          <div class="card-code">{{ row.equipmentCode }}</div>
+          <div class="card-district">片区:{{ districtLabel(row.district) }}</div>
+
+          <div class="card-bottom">
+            <span class="type-tag">
+              {{ (row.equipmentTypeId && typeNameMap[row.equipmentTypeId]) || "未分类" }}
+            </span>
+            <div class="card-actions">
+              <el-button link class="link-detail" @click.stop="handleView(row)">详情</el-button>
+              <el-button link class="link-delete" @click.stop="handleDelete(row)">删除</el-button>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <pagination
+        v-show="total > 0"
+        :total="total"
+        :page-sizes="pageSizes"
+        v-model:page="queryParams.pageNum"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </el-card>
 
     <!-- 设备详情弹窗 -->
     <el-dialog title="设备详情" v-model="detailOpen" width="800px" append-to-body class="detail-dialog">
@@ -177,15 +223,15 @@
       </template>
       <el-empty v-else description="未选中设备" />
       <template #footer>
-        <div class="dialog-footer">
+        <div class="dlg-footer">
+          <el-button class="btn-ghost" @click="detailOpen = false">关 闭</el-button>
           <el-button type="primary" icon="Edit" @click="handleUpdate(selectedDetail.equipmentInfo)">编辑设备</el-button>
-          <el-button @click="detailOpen = false">关 闭</el-button>
         </div>
       </template>
     </el-dialog>
 
     <!-- 新增/修改弹窗 -->
-    <el-dialog :title="title" v-model="open" width="860px" append-to-body destroy-on-close>
+    <el-dialog :title="title" v-model="open" width="860px" append-to-body>
       <el-form ref="equipmentRef" :model="form" :rules="rules" label-width="110px">
         <div class="form-section-title">基础信息</div>
         <el-row :gutter="16">
@@ -253,12 +299,12 @@
           </el-col>
           <el-col :span="12">
             <el-form-item label="资产价值(元)" prop="assetValue">
-              <el-input-number v-model="form.assetValue" :precision="2" :step="100" controls-position="right" style="width: 100%" />
+              <el-input-number v-model="form.assetValue" :precision="2" :step="100" style="width: 100%" />
             </el-form-item>
           </el-col>
           <el-col :span="12">
             <el-form-item label="使用年限(年)" prop="useLife">
-              <el-input-number v-model="form.useLife" :precision="0" :step="1" controls-position="right" style="width: 100%" />
+              <el-input-number v-model="form.useLife" :precision="0" :step="1" style="width: 100%" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -275,7 +321,7 @@
               <div class="coordinate-row">
                 <el-input-number v-model="form.longitude" :precision="6" :step="0.000001" controls-position="right" placeholder="经度" />
                 <el-input-number v-model="form.latitude" :precision="6" :step="0.000001" controls-position="right" placeholder="纬度" />
-                <el-button type="primary" plain icon="Location" @click="openMapPicker">地图选点</el-button>
+                <el-button type="primary" plain @click="openMapPicker">地图选点</el-button>
               </div>
               <div class="coordinate-tip">{{ locationTip || "点击地图获取经纬度,地址将自动回填到安装位置,也可以直接手工修改。" }}</div>
             </el-form-item>
@@ -308,7 +354,20 @@
           </el-col>
           <el-col :span="24">
             <el-form-item label="设备图片" prop="equipmentImage">
-              <image-upload v-model="form.equipmentImage" :limit="1" />
+              <el-upload
+                class="device-image-uploader"
+                :class="{ 'hide-uploader': imageFileList.length >= 1 }"
+                list-type="picture-card"
+                accept="image/*"
+                :http-request="uploadSingleImage"
+                :file-list="imageFileList"
+                :before-upload="beforeImageUpload"
+                :on-remove="handleRemoveImage"
+                :limit="1"
+                :on-exceed="handleImageExceed"
+              >
+                <el-icon><plus /></el-icon>
+              </el-upload>
             </el-form-item>
           </el-col>
         </el-row>
@@ -339,17 +398,15 @@
         解析地址:{{ form.equipmentLocation || "点击地图后自动解析" }}
       </div>
       <template #footer>
-        <div class="dialog-footer">
-          <el-button type="primary" @click="mapOpen = false">使用当前坐标</el-button>
-          <el-button @click="mapOpen = false">关 闭</el-button>
-        </div>
+        <el-button @click="mapOpen = false">关 闭</el-button>
+        <el-button type="primary" @click="mapOpen = false">使用当前坐标</el-button>
       </template>
     </el-dialog>
   </div>
 </template>
 
 <script setup>
-import { computed, getCurrentInstance, onMounted, reactive, ref, toRefs } from "vue";
+import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref, toRefs } from "vue";
 import GisMapCanvas from "@/components/GisMapCanvas.vue";
 import { bd09ToWgs84 } from "@/utils/coordTransform";
 import { useRoute } from "vue-router";
@@ -364,8 +421,8 @@ import {
   updateEquipment,
   updateEquipmentStatus
 } from "@/api/pipeNetwork/pdeviceInform";
-
-defineOptions({ name: "EquipmentBase" });
+import request from "@/utils/request";
+import { buildMinioAttachmentUrl } from "@/utils/minioAttachment";
 
 const { proxy } = getCurrentInstance();
 
@@ -375,19 +432,18 @@ const rootTypeId = ref(route.query.rootTypeId ? Number(route.query.rootTypeId) :
 
 const equipmentList = ref([]);
 const allTypeList = ref([]);
+const selectedCategoryId = ref();
 const loading = ref(false);
-const showSearch = ref(true);
 const open = ref(false);
 const detailOpen = ref(false);
 const mapOpen = ref(false);
-// 运行状态筛选:'' 不限 / online 在线 / offline 离线 / alarm 报警
-// 后端把在线状态与报警状态分成两个参数,这里收在一个下拉里,避免并排放两个控件互相冲突
-const runStatus = ref("");
+const alarmOnly = ref(false);
 const mapCenter = ref({ lng: 110.3965, lat: 28.4638 });
 const mapPoints = ref([]);
 const total = ref(0);
 const title = ref("");
 const selectedDetail = ref({});
+const selectedId = ref("");
 const locationTip = ref("");
 
 const statistics = reactive({
@@ -428,6 +484,14 @@ function getRowStatus(equipmentId) {
   return equipmentList.value.find(item => item.equipmentId === equipmentId)?.currentStatus;
 }
 
+function getRowOnline(equipmentId) {
+  return equipmentList.value.find(item => item.equipmentId === equipmentId)?.onlineStatus;
+}
+
+function getRowAlarm(equipmentId) {
+  return equipmentList.value.find(item => item.equipmentId === equipmentId)?.alarmStatus;
+}
+
 // parentId 为 0(或空)表示父类
 function isTopLevelType(item) {
   const parentId = item?.parentId;
@@ -495,20 +559,27 @@ 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]);
 }
 
-/**
- * 可选的设备类型
- *
- * equipmentTypeTree 已按菜单注入的 rootTypeId 收窄到本子系统(排水只有 6 个叶子类型),
- * 数量不多,直接平铺成下拉选项。
- */
-const equipmentSubtypes = computed(() => collectSelectableTypes(equipmentTypeTree.value));
+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 = {
@@ -519,13 +590,64 @@ function currentStatusMeta(value) {
   return map[value] || { text: "未知", type: "info" };
 }
 
-const baseUrl = import.meta.env.VITE_APP_BASE_API;
-
+// 详情弹窗图片回显统一走 MinIO 前缀(与上传组件一致),不能用 baseUrl(/dev-api)
 function resolveImageUrl(url) {
-  if (!url) return "";
-  const value = String(url).trim();
-  if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) return value;
-  return baseUrl + (value.startsWith("/") ? value : "/" + value);
+  return buildMinioAttachmentUrl(url);
+}
+
+// 设备图片:单文件上传,调用 /minio/upload?dir=other
+const imageFileList = ref([]);
+
+function beforeImageUpload(file) {
+  const isImg = file.type.startsWith("image/");
+  if (!isImg) {
+    proxy.$modal.msgError("请上传图片格式文件");
+    return false;
+  }
+  const isLt = file.size / 1024 / 1024 < 5;
+  if (!isLt) {
+    proxy.$modal.msgError("上传图片大小不能超过 5MB");
+    return false;
+  }
+  return true;
+}
+
+function handleImageExceed() {
+  proxy.$modal.msgError("只能上传一张设备图片");
+}
+
+function uploadSingleImage(options) {
+  const formData = new FormData();
+  formData.append("file", options.file);
+  return request({
+    url: "/minio/upload?dir=other",
+    method: "post",
+    data: formData,
+    headers: { repeatSubmit: false },
+    timeout: 60000
+  })
+    .then((res) => {
+      if (res.code === 200) {
+        const relativePath = res.relativePath || res.url;
+        form.value.equipmentImage = relativePath;
+        imageFileList.value = [
+          { name: res.originalFilename || "设备图片", url: buildMinioAttachmentUrl(relativePath), status: "success" }
+        ];
+        options.onSuccess(res);
+      } else {
+        proxy.$modal.msgError(res.msg || "上传失败");
+        options.onError();
+      }
+    })
+    .catch((err) => {
+      proxy.$modal.msgError("上传失败");
+      options.onError(err);
+    });
+}
+
+function handleRemoveImage() {
+  form.value.equipmentImage = undefined;
+  imageFileList.value = [];
 }
 
 const data = reactive({
@@ -534,7 +656,8 @@ const data = reactive({
     pageSize: 10,
     equipmentCode: undefined,
     equipmentName: undefined,
-    equipmentTypeId: undefined
+    equipmentTypeId: undefined,
+    onlineStatus: undefined
   },
   form: {},
   rules: {
@@ -572,15 +695,41 @@ function createEmptyForm() {
   };
 }
 
+// 卡片网格自适应分页:每页条数 = 每行列数 × 固定行数,随屏幕宽度变化,
+// 保证任意宽度下卡片都能铺满整行
+const gridRef = ref(null);
+const PAGE_ROWS = 3;
+const CARD_MIN_WIDTH = 320;
+const GRID_GAP = 10;
+let lastCols = null;
+
+function gridColumns() {
+  const width = gridRef.value?.clientWidth || 0;
+  if (!width) return null;
+  return Math.max(1, Math.floor((width + GRID_GAP) / (CARD_MIN_WIDTH + GRID_GAP)));
+}
+
+const pageSizes = computed(() => {
+  const options = [3, 6, 9, 12, 15, 18];
+  const current = queryParams.value.pageSize;
+  return options.includes(current) ? options : [...options, current].sort((a, b) => a - b);
+});
+
 function getList() {
+  const cols = gridColumns();
+  if (cols && cols !== lastCols) {
+    lastCols = cols;
+    queryParams.value.pageSize = cols * PAGE_ROWS;
+    queryParams.value.pageNum = 1;
+  }
   loading.value = true;
-  const status = runStatus.value;
   return listEquipment({
     ...queryParams.value,
     rootTypeId: rootTypeId.value,
-    // 在线 / 离线走 onlineStatus,报警走 alarmStatus,两者互斥,不会同时传
-    onlineStatus: status === "online" ? 1 : status === "offline" ? 0 : undefined,
-    alarmStatus: status === "alarm" ? 1 : undefined
+    // 保留菜单的数据范围,大类筛选通过子类型集合交由后端分页过滤。
+    // 空大类使用无效 ID 返回空结果,避免空参数被后端解释为不限类型。
+    equipmentTypeIds: selectedCategory.value ? equipmentSubtypes.value.map((item) => item.id).join(",") || "-1" : undefined,
+    alarmStatus: alarmOnly.value ? 1 : undefined
   })
     .then((response) => {
       equipmentList.value = response.records || [];
@@ -609,6 +758,7 @@ function loadStatistics() {
 
 function reset() {
   form.value = createEmptyForm();
+  imageFileList.value = [];
   locationTip.value = "";
   proxy.resetForm("equipmentRef");
 }
@@ -685,8 +835,9 @@ function handleQuery() {
 
 function resetQuery() {
   proxy.resetForm("queryRef");
+  selectedCategoryId.value = undefined;
   queryParams.value.equipmentTypeId = undefined;
-  runStatus.value = "";
+  alarmOnly.value = false;
   handleQuery();
 }
 
@@ -712,6 +863,9 @@ function handleUpdate(row) {
         ? Number(form.value.equipmentTypeId)
         : undefined;
     form.value.currentStatus = getRowStatus(equipmentId) ?? 0;
+    imageFileList.value = form.value.equipmentImage
+      ? [{ name: "设备图片", url: buildMinioAttachmentUrl(form.value.equipmentImage), status: "success" }]
+      : [];
     open.value = true;
     title.value = "修改设备";
   });
@@ -721,6 +875,7 @@ function handleView(row) {
   if (!row?.equipmentId) {
     return;
   }
+  selectedId.value = row.equipmentId;
   return getEquipmentDetail(row.equipmentId).then((response) => {
     selectedDetail.value = response.data || {};
     detailOpen.value = true;
@@ -738,6 +893,7 @@ function handleDelete(row) {
       proxy.$modal.msgSuccess("删除成功");
       if (selectedDetail.value?.equipmentInfo?.equipmentId === row.equipmentId) {
         selectedDetail.value = {};
+        selectedId.value = "";
         detailOpen.value = false;
       }
       return Promise.all([getList(), loadAssistData(), loadStatistics()]);
@@ -774,18 +930,56 @@ function submitForm() {
   });
 }
 
+let gridResizeObserver = null;
+
 onMounted(() => {
   reset();
   Promise.all([getList(), loadAssistData(), loadStatistics()]);
+  if (typeof ResizeObserver !== "undefined" && gridRef.value) {
+    gridResizeObserver = new ResizeObserver(() => {
+      if (gridColumns() !== lastCols) {
+        getList();
+      }
+    });
+    gridResizeObserver.observe(gridRef.value);
+  }
+});
+
+onBeforeUnmount(() => {
+  gridResizeObserver?.disconnect();
+  gridResizeObserver = null;
 });
 </script>
 
 <style scoped>
+.equipment-page {
+  background: #f9f9f9;
+}
+
+/* 页头 */
+.page-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20px;
+}
+
+.page-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #0a1628;
+  line-height: 1.4;
+}
+
+.btn-primary :deep(.el-icon) {
+  font-size: 14px;
+}
+
 /* KPI */
 .kpi-row {
   display: flex;
   gap: 16px;
-  margin-bottom: 16px;
+  margin-bottom: 20px;
 }
 
 .kpi {
@@ -846,38 +1040,217 @@ onMounted(() => {
   color: #0a1628;
 }
 
-/* 详情弹窗 */
-.dlg-summary {
+/* 台账卡片 */
+.filter-card {
+  margin-bottom: 20px;
+  border-radius: 12px;
+}
+
+.section-block-title {
+  margin: 20px 0 12px;
+}
+
+.ledger {
+  border-radius: 12px;
+}
+
+.ledger :deep(.el-card__body) {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  padding: 20px;
+}
+
+/* 筛选栏 */
+.type-filter {
+  margin-bottom: 20px;
+}
+
+.type-filter-title,
+.type-filter-label {
   display: flex;
   align-items: center;
-  gap: 12px;
+  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;
-  padding: 14px;
+  gap: 8px;
+}
+
+.type-category,
+.type-chip {
+  padding: 0 16px;
+  height: 34px;
+  border: 1px solid #e5e6eb;
   border-radius: 8px;
-  background: #f5f9ff;
+  background: #fff;
+  color: #4e5969;
+  font: inherit;
+  font-size: 13px;
+  white-space: nowrap;
+  cursor: pointer;
+  transition: background .15s, color .15s, border-color .15s;
 }
 
-.summary-name {
-  font-size: 15px;
+.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;
-  color: #014db2;
 }
 
-.summary-code {
+.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;
+  flex-wrap: wrap;
+  gap: 4px 16px;
+}
+
+.filters :deep(.el-form-item) {
+  margin-right: 0;
+  margin-bottom: 0;
+}
+
+.filters :deep(.el-form-item__label) {
   font-size: 12px;
   font-weight: 400;
   color: #6b7280;
+  padding-right: 8px;
 }
 
-.summary-tag {
-  display: inline-flex;
-  align-items: center;
-  padding: 3px 8px;
-  border-radius: 4px;
+.input--code,
+.input--name {
+  width: 150px;
+}
+
+.select--online {
+  width: 120px;
+}
+
+.btn-query {
+  height: 34px;
+  padding: 0 16px;
+  border-radius: 8px;
+  font-size: 13px;
+  font-weight: 500;
+}
+
+.btn-reset {
+  height: 34px;
+  padding: 0 16px;
+  border-radius: 8px;
+  font-size: 13px;
+  font-weight: 400;
+}
+
+/* 设备卡片 */
+.grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+  align-items: start;
+  gap: 10px;
+  min-height: 160px;
+}
+
+.card {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  align-self: start;
+  min-height: 0;
+  padding: 10px 12px;
+  border-radius: 10px;
   background: #fff;
-  font-size: 11px;
+  border: 1px solid #ebebed;
+  cursor: pointer;
+  transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
+}
+
+.card:hover {
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
+}
+
+.card.is-selected {
+  background: #f5f9ff;
+  border: 1.5px solid #014db2;
+}
+
+.card.is-alarm {
+  border: 1.5px solid #fca5a5;
+}
+
+.card-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.card-name {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 14px;
   font-weight: 500;
-  color: #014db2;
+  color: #0a1628;
 }
 
 .status-group {
@@ -930,6 +1303,93 @@ onMounted(() => {
   color: #ef4444;
 }
 
+.card-code {
+  font-size: 12px;
+  font-weight: 400;
+  color: #9ca3af;
+}
+
+.card-district {
+  overflow: hidden;
+  color: #6b7280;
+  font-size: 12px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-bottom {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.type-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 8px;
+  border-radius: 4px;
+  background: #edf3fc;
+  font-size: 11px;
+  font-weight: 500;
+  color: #014db2;
+}
+
+.card-actions {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.card-actions :deep(.el-button) {
+  height: auto;
+  padding: 0;
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.link-detail {
+  color: #014db2;
+}
+
+.link-delete {
+  color: #ef4444;
+}
+
+/* 详情弹窗 */
+.dlg-summary {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+  padding: 14px;
+  border-radius: 8px;
+  background: #f5f9ff;
+}
+
+.summary-name {
+  font-size: 15px;
+  font-weight: 500;
+  color: #014db2;
+}
+
+.summary-code {
+  font-size: 12px;
+  font-weight: 400;
+  color: #6b7280;
+}
+
+.summary-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 8px;
+  border-radius: 4px;
+  background: #fff;
+  font-size: 11px;
+  font-weight: 500;
+  color: #014db2;
+}
+
 .dlg-grid {
   margin-top: 16px;
 }
@@ -996,6 +1456,7 @@ onMounted(() => {
   font-size: 13px;
 }
 
+.dlg-footer,
 .dialog-footer {
   display: flex;
   align-items: center;
@@ -1017,5 +1478,15 @@ onMounted(() => {
   .kpi {
     flex: 1 1 100%;
   }
+
+  .page-head {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 12px;
+  }
+}
+
+.device-image-uploader.hide-uploader :deep(.el-upload--picture-card) {
+  display: none;
 }
 </style>