Procházet zdrojové kódy

Merge branch 'feature/shebei'

Zimo před 3 dny
rodič
revize
76d6711e88

+ 2 - 4
src/components/Pagination/index.vue

@@ -8,17 +8,15 @@
     :page-sizes="[10, 20, 30, 50, 100]"
     :pager-count="pagerCount"
     :total="total"
-    :small="isSmall"
+    :size="isSmall ? 'small' : 'default'"
     class="float-right mb-15px mt-15px"
     layout="total, sizes, prev, pager, next, jumper"
     @size-change="handleSizeChange"
-    @current-change="handleCurrentChange"
-  />
+    @current-change="handleCurrentChange" />
 </template>
 <script lang="ts" setup>
 import { computed, watchEffect } from 'vue'
 import { useAppStore } from '@/store/modules/app'
-
 defineOptions({ name: 'Pagination' })
 
 // 此处解决了当全局size为small的时候分页组件样式太大的问题

+ 4 - 30
src/router/modules/remaining.ts

@@ -512,41 +512,15 @@ const remainingRouter: AppRouteRecordRaw[] = [
     },
     children: [
       {
-        path: 'device/detail/add/:type/:deptId',
+        path: 'device/detail/form',
         component: () => import('@/views/pms/device/IotDeviceForm.vue'),
-        name: 'DeviceDetailAdd',
-        meta: {
-          noCache: false,
-          hidden: true,
-          canTo: true,
-          icon: 'ep:add',
-          title: t('rem.AddEquipment'),
-          activeMenu: '/device/base'
-        }
-      },
-      {
-        path: 'device/detail/add',
-        component: () => import('@/views/pms/device/IotDeviceFormAdd.vue'),
-        name: 'DeviceDetailAddd',
-        meta: {
-          noCache: false,
-          hidden: true,
-          canTo: true,
-          icon: 'ep:add',
-          title: t('rem.AddEquipment'),
-          activeMenu: '/device/base'
-        }
-      },
-      {
-        path: 'device/detail/edit/:type/:id(\\d+)',
-        component: () => import('@/views/pms/device/IotDeviceForm.vue'),
-        name: 'DeviceDetailEdit',
+        name: 'DeviceDetailForm',
         meta: {
           noCache: true,
           hidden: true,
           canTo: true,
-          icon: 'ep:edit',
-          title: t('rem.EquipmentEditing'),
+          icon: 'ep:document',
+          title: t('rem.AddEquipment'),
           activeMenu: '/device/base'
         }
       },

+ 241 - 0
src/views/pms/device/DeviceAllotRecord.vue

@@ -0,0 +1,241 @@
+<template>
+  <div class="device-allot-record">
+    <section class="library-workbench">
+      <div class="library-workbench__header">
+        <div class="library-header-main">
+          <div class="workbench-title">
+            <span class="workbench-title__icon">
+              <Icon icon="ep:switch" />
+            </span>
+            <div>
+              <h3>调拨记录</h3>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn
+                label="设备名称"
+                prop="deviceName"
+                min-width="180"
+                align="left"
+                fixed="left">
+                <template #default="{ row }">
+                  <span class="device-name">{{ row.deviceName || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="设备编码" prop="deviceCode" min-width="150">
+                <template #default="{ row }">
+                  {{ row.deviceCode || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调拨前部门" prop="oldDeptName" min-width="180">
+                <template #default="{ row }">
+                  {{ row.oldDeptName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调拨后部门" prop="newDeptName" min-width="180">
+                <template #default="{ row }">
+                  {{ row.newDeptName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调拨原因" prop="reason" min-width="220" align="left">
+                <template #default="{ row }">
+                  {{ row.reason || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整人" prop="creatorName" min-width="140">
+                <template #default="{ row }">
+                  {{ row.creatorName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整时间" prop="createTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.createTime) }}
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { IotDeviceAllotLogApi, IotDeviceAllotLogVO } from '@/api/pms/iotdeviceallotlog'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'DeviceAllotRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+type AllotRecordRow = IotDeviceAllotLogVO & {
+  createTime?: Date | string | number
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents<AllotRecordRow>()
+const loading = ref(false)
+const list = ref<AllotRecordRow[]>([])
+const total = ref(0)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceId: undefined as number | undefined
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await IotDeviceAllotLogApi.getIotDeviceAllotLogPage(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const formatRecordTime = (value?: Date | string | number) => {
+  return value ? formatDate(value as Date) : '-'
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-allot-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.device-name {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+}
+</style>

+ 314 - 0
src/views/pms/device/DeviceAssociationRecord.vue

@@ -0,0 +1,314 @@
+<template>
+  <div class="device-association-record">
+    <section class="library-workbench">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        inline
+        size="default"
+        class="library-workbench__header">
+        <div class="library-header-main">
+          <div class="workbench-title">
+            <span class="workbench-title__icon">
+              <Icon icon="ep:connection" />
+            </span>
+            <div>
+              <h3>关联设备</h3>
+            </div>
+          </div>
+
+          <el-form-item label="设备编码" prop="deviceCode">
+            <el-input
+              v-model="queryParams.deviceCode"
+              class="library-search-input"
+              clearable
+              placeholder="请输入设备编码"
+              @keyup.enter="handleQuery">
+              <template #prefix>
+                <Icon icon="ep:search" />
+              </template>
+            </el-input>
+          </el-form-item>
+        </div>
+
+        <el-form-item class="library-actions">
+          <el-button type="primary" @click="handleQuery">
+            <Icon icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn label="序号" width="70" align="center">
+                <template #default="{ $index }">
+                  {{ (queryParams.pageNo - 1) * queryParams.pageSize + $index + 1 }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                label="成套名称"
+                prop="groupName"
+                min-width="180"
+                align="left"
+                fixed="left">
+                <template #default="{ row }">
+                  <span class="group-name">{{ row.groupName || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="设备名称" prop="deviceName" min-width="180">
+                <template #default="{ row }">
+                  {{ row.deviceName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="设备编码" prop="deviceCode" min-width="150">
+                <template #default="{ row }">
+                  {{ row.deviceCode || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="是否主设备" prop="ifMaster" min-width="120">
+                <template #default="{ row }">
+                  <el-tag v-if="row.ifMaster" type="success" effect="light">是</el-tag>
+                  <el-tag v-else type="info" effect="light">否</el-tag>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="创建时间" prop="createTime" min-width="160">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.createTime) }}
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { IotDeviceApi } from '@/api/pms/device'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { formatToDate } from '@/utils/dateUtil'
+
+defineOptions({ name: 'DeviceAssociationRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+interface AssociationRecordRow {
+  id?: number
+  createTime?: Date | string | number
+  deviceCode?: string
+  deviceName?: string
+  groupName?: string
+  ifMaster?: boolean
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents<AssociationRecordRow>()
+const loading = ref(false)
+const list = ref<AssociationRecordRow[]>([])
+const total = ref(0)
+const queryFormRef = ref()
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceId: undefined as number | undefined,
+  deviceCode: ''
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await IotDeviceApi.getIotDeviceSetRelation(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.deviceCode = ''
+  handleQuery()
+}
+
+const formatRecordTime = (value?: Date | string | number) => {
+  return value ? formatToDate(value) : '-'
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-association-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+
+  :deep(.el-form-item) {
+    margin-right: 10px;
+    margin-bottom: 0;
+  }
+
+  :deep(.el-form-item:last-child) {
+    margin-right: 0;
+  }
+
+  :deep(.el-button .app-iconify) {
+    margin-right: 4px;
+  }
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-search-input {
+  width: 220px;
+}
+
+.library-actions {
+  flex: none;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.group-name {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-header-main {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-search-input {
+    width: 100%;
+  }
+}
+</style>

+ 355 - 0
src/views/pms/device/DeviceBasicInfo.vue

@@ -0,0 +1,355 @@
+<template>
+  <div class="device-basic-info">
+    <section class="basic-section basic-section--manufacture">
+      <div class="basic-section__header">
+        <span class="section-icon section-icon--blue">
+          <Icon icon="ep:office-building" />
+        </span>
+        <div>
+          <h3>制造信息</h3>
+          <p>设备生产、供应及质保信息</p>
+        </div>
+      </div>
+      <div class="basic-grid basic-grid--manufacture">
+        <div v-for="field in manufactureFields" :key="field.label" class="basic-item">
+          <span>{{ field.label }}</span>
+          <strong>{{ field.value }}</strong>
+        </div>
+      </div>
+    </section>
+
+    <section class="basic-section basic-section--finance">
+      <div class="basic-section__header">
+        <span class="section-icon section-icon--orange">
+          <Icon icon="ep:coin" />
+        </span>
+        <div>
+          <h3>资产折旧</h3>
+          <p>采购成本及折旧执行信息</p>
+        </div>
+      </div>
+      <div class="basic-grid">
+        <div v-for="field in financeFields" :key="field.label" class="basic-item">
+          <span>{{ field.label }}</span>
+          <strong>{{ field.value }}</strong>
+        </div>
+      </div>
+    </section>
+
+    <section v-if="normalizedTemplateFields.length" class="basic-section basic-section--extend">
+      <div class="basic-section__header">
+        <span class="section-icon section-icon--green">
+          <Icon icon="ep:grid" />
+        </span>
+        <div>
+          <h3>扩展属性</h3>
+          <p>设备分类模板补充信息</p>
+        </div>
+      </div>
+      <div class="basic-grid">
+        <div v-for="field in normalizedTemplateFields" :key="field.key" class="basic-item">
+          <span>{{ field.label }}</span>
+          <strong>{{ field.value }}</strong>
+        </div>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { formatDate } from '@/utils/formatTime'
+
+interface DeviceBasicInfoData {
+  zzName?: string
+  manufacturerName?: string
+  manDate?: number | string | Date
+  supplierName?: string
+  expires?: number | string | Date
+  nameplate?: string
+  plPrice?: number | string
+  plDate?: number | string | Date
+  plYear?: number | string
+  plStartDate?: number | string | Date
+  plMonthed?: number | string
+  plAmounted?: number | string
+  remainAmount?: number | string
+  monthAmount?: number | string
+  totalMonth?: number | string
+  currency?: string
+}
+
+interface TemplateField {
+  sort?: number
+  name?: string
+  value?: string | number | null
+  identifier?: string
+}
+
+interface DisplayField {
+  label: string
+  value: string
+}
+
+const props = defineProps<{
+  device: DeviceBasicInfoData
+  templateFields?: TemplateField[]
+}>()
+
+const displayValue = (value?: string | number | null) => {
+  return value || value === 0 ? String(value) : '-'
+}
+
+const displayDate = (value?: number | string | Date) => {
+  return value ? formatDate(value as Date, 'YYYY-MM-DD') : '-'
+}
+
+const manufactureFields = computed<DisplayField[]>(() => [
+  {
+    label: '制造商',
+    value: displayValue(props.device.zzName || props.device.manufacturerName)
+  },
+  {
+    label: '生产日期',
+    value: displayDate(props.device.manDate)
+  },
+  {
+    label: '供应商',
+    value: displayValue(props.device.supplierName)
+  },
+  {
+    label: '铭牌信息',
+    value: displayValue(props.device.nameplate)
+  },
+  {
+    label: '质保到期',
+    value: displayDate(props.device.expires)
+  }
+])
+
+const financeFields = computed<DisplayField[]>(() => [
+  {
+    label: '采购价格',
+    value: displayValue(props.device.plPrice)
+  },
+  {
+    label: '采购日期',
+    value: displayDate(props.device.plDate)
+  },
+  {
+    label: '折旧年限',
+    value: displayValue(props.device.plYear)
+  },
+  {
+    label: '折旧开始日期',
+    value: displayDate(props.device.plStartDate)
+  },
+  {
+    label: '已提折旧月数',
+    value: displayValue(props.device.plMonthed)
+  },
+  {
+    label: '已提折旧金额',
+    value: displayValue(props.device.plAmounted)
+  },
+  {
+    label: '剩余金额',
+    value: displayValue(props.device.remainAmount)
+  },
+  {
+    label: '每月折旧金额',
+    value: displayValue(props.device.monthAmount)
+  },
+  {
+    label: '总折旧月份',
+    value: displayValue(props.device.totalMonth)
+  },
+  {
+    label: '币种',
+    value: displayValue(props.device.currency)
+  }
+])
+
+const normalizedTemplateFields = computed(() => {
+  return (props.templateFields || []).map((field, index) => ({
+    key: field.identifier || field.name || String(field.sort ?? index),
+    label: field.name || '-',
+    value: displayValue(field.value)
+  }))
+})
+</script>
+
+<style scoped lang="scss">
+.device-basic-info {
+  display: grid;
+  gap: 16px;
+}
+
+.basic-section {
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 10px;
+}
+
+.basic-section__header {
+  display: flex;
+  gap: 12px;
+  min-height: 64px;
+  padding: 14px 18px;
+  background: linear-gradient(90deg, #f7faff 0%, var(--el-bg-color) 78%);
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 15px;
+    font-weight: 700;
+    line-height: 22px;
+    color: var(--el-text-color-primary);
+  }
+
+  p {
+    margin: 2px 0 0;
+    font-size: 12px;
+    line-height: 18px;
+    color: var(--el-text-color-secondary);
+  }
+}
+
+.section-icon {
+  display: flex;
+  width: 36px;
+  height: 36px;
+  font-size: 18px;
+  border-radius: 9px;
+  align-items: center;
+  justify-content: center;
+}
+
+.section-icon--blue {
+  color: #3478f6;
+  background: #eaf2ff;
+}
+
+.section-icon--orange {
+  color: #e78324;
+  background: #fff2e3;
+}
+
+.section-icon--green {
+  color: #20a36a;
+  background: #e7f8f0;
+}
+
+.basic-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.basic-grid--manufacture {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.basic-item {
+  display: flex;
+  min-width: 0;
+  min-height: 74px;
+  padding: 15px 18px;
+  border-right: 1px solid var(--el-border-color-lighter);
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  flex-direction: column;
+  justify-content: center;
+
+  &:nth-child(4n) {
+    border-right: 0;
+  }
+
+  span {
+    font-size: 12px;
+    line-height: 18px;
+    color: var(--el-text-color-secondary);
+  }
+
+  strong {
+    margin-top: 7px;
+    font-size: 14px;
+    font-weight: 600;
+    line-height: 22px;
+    color: var(--el-text-color-primary);
+    word-break: break-word;
+  }
+}
+
+.basic-grid--manufacture {
+  .basic-item {
+    &:nth-child(4n) {
+      border-right: 1px solid var(--el-border-color-lighter);
+    }
+
+    &:nth-child(3n) {
+      border-right: 0;
+    }
+
+    &:nth-child(4) {
+      grid-column: span 2;
+    }
+  }
+}
+
+@media (width <= 1399px) {
+  .basic-grid,
+  .basic-grid--manufacture {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+
+  .basic-item {
+    &:nth-child(4n) {
+      border-right: 1px solid var(--el-border-color-lighter);
+    }
+
+    &:nth-child(3n) {
+      border-right: 0;
+    }
+  }
+}
+
+@media (width <= 991px) {
+  .basic-grid,
+  .basic-grid--manufacture {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .basic-item {
+    &:nth-child(3n) {
+      border-right: 1px solid var(--el-border-color-lighter);
+    }
+
+    &:nth-child(2n) {
+      border-right: 0;
+    }
+  }
+
+  .basic-grid--manufacture {
+    .basic-item:nth-child(4) {
+      grid-column: span 1;
+    }
+  }
+}
+
+@media (width <= 575px) {
+  .basic-grid,
+  .basic-grid--manufacture {
+    grid-template-columns: 1fr;
+  }
+
+  .basic-item {
+    border-right: 0;
+
+    &:nth-child(3n),
+    &:nth-child(4n) {
+      border-right: 0;
+    }
+  }
+}
+</style>

+ 514 - 0
src/views/pms/device/DeviceBomLibrary.vue

@@ -0,0 +1,514 @@
+<template>
+  <div class="device-bom-library">
+    <section class="library-workbench">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        inline
+        size="default"
+        class="library-workbench__header">
+        <div class="library-header-main">
+          <div class="workbench-title">
+            <span class="workbench-title__icon">
+              <Icon icon="ep:box" />
+            </span>
+            <div>
+              <h3>设备BOM清单</h3>
+            </div>
+          </div>
+
+          <el-form-item label="BOM节点名称" prop="name">
+            <el-input
+              v-model="queryParams.name"
+              class="library-search-input"
+              clearable
+              placeholder="请输入BOM节点名称"
+              @keyup.enter="handleQuery">
+              <template #prefix>
+                <Icon icon="ep:search" />
+              </template>
+            </el-input>
+          </el-form-item>
+          <el-form-item label="BOM节点" prop="nodeId">
+            <el-tree-select
+              v-model="selectedNodeId"
+              class="library-search-input"
+              :data="nodeOptions"
+              :props="{ label: 'name', children: 'children' }"
+              check-strictly
+              clearable
+              filterable
+              node-key="id"
+              placeholder="请选择BOM节点" />
+          </el-form-item>
+        </div>
+
+        <el-form-item class="library-actions">
+          <el-button type="primary" @click="handleQuery">
+            <Icon icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" />
+            重置
+          </el-button>
+          <el-button
+            v-hasPermi="['rq:iot-bom:create']"
+            type="primary"
+            plain
+            @click="openForm('create', null)">
+            <Icon icon="ep:plus" />
+            新增
+          </el-button>
+          <el-button type="danger" plain @click="toggleExpandAll">
+            <Icon icon="ep:sort" />
+            展开/折叠
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              v-if="refreshTable"
+              :data="displayList"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :default-expand-all="isExpandAll"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn prop="name" label="BOM节点" min-width="180" align="left" fixed="left">
+                <template #default="{ row }">
+                  <el-tooltip
+                    effect="dark"
+                    :content="`设备分类:${row.deviceCategoryName || props.deviceCategoryName || '暂无'}`"
+                    placement="top-start"
+                    :disabled="!row.deviceCategoryName && !props.deviceCategoryName">
+                    <span class="bom-node-name">{{ row.name || '-' }}</span>
+                  </el-tooltip>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="deviceCategoryName" label="设备分类" min-width="140">
+                <template #default="{ row }">
+                  {{ row.deviceCategoryName || props.deviceCategoryName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="sort" label="排序" width="80" />
+              <ZmTableColumn label="维修" width="90">
+                <template #default="{ row }">
+                  <el-switch :model-value="hasBomType(row, 1)" disabled />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="保养" width="90">
+                <template #default="{ row }">
+                  <el-switch :model-value="hasBomType(row, 2)" disabled />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="materials" label="物料数量" width="100">
+                <template #default="{ row }">
+                  {{ row.materials ?? 0 }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="操作" width="320" fixed="right" action>
+                <template #default="{ row }">
+                  <div class="table-actions">
+                    <el-button
+                      v-hasPermi="['rq:iot-bom:update']"
+                      link
+                      type="primary"
+                      class="table-action-btn"
+                      @click="openForm('update', row)">
+                      <Icon icon="ep:edit" />
+                      修改
+                    </el-button>
+                    <el-button
+                      v-hasPermi="['rq:iot-bom:update']"
+                      link
+                      type="primary"
+                      class="table-action-btn"
+                      @click="openSelectMaterialForm(row)">
+                      <Icon icon="ep:plus" />
+                      添加物料
+                    </el-button>
+                    <el-button
+                      v-hasPermi="['rq:iot-bom:update']"
+                      link
+                      type="primary"
+                      class="table-action-btn"
+                      @click="handleView(row)">
+                      <Icon icon="ep:document" />
+                      物料详情
+                    </el-button>
+                    <el-button
+                      v-hasPermi="['rq:iot-bom:delete']"
+                      link
+                      type="danger"
+                      class="table-action-btn"
+                      @click="handleDelete(row.id)">
+                      <Icon icon="ep:delete" />
+                      删除
+                    </el-button>
+                  </div>
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+    </section>
+
+    <BomFormDevice ref="formRef" :device-id="props.deviceId" @success="getList" />
+    <MaterialList ref="materialListRef" @choose="chooseMaterial" />
+    <MaterialListDrawerDevice
+      ref="showDrawer"
+      :model-value="drawerVisible"
+      :node-id="currentBomNodeId"
+      :device-id="props.deviceId"
+      :row-info="currentRowInfo"
+      @update:model-value="(val) => (drawerVisible = val)"
+      @refresh="getList" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, reactive, ref, watch } from 'vue'
+import * as BomApi from '@/api/pms/devicebom'
+import * as MaterialApi from '@/api/pms/iotdevicematerial'
+import { handleTree } from '@/utils/tree'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import BomFormDevice from '@/views/pms/device/bom/BomFormDevice.vue'
+import MaterialList from '@/views/pms/bom/MaterialList.vue'
+import MaterialListDrawerDevice from '@/views/pms/device/MaterialListDrawerDevice.vue'
+
+defineOptions({ name: 'DeviceBomLibrary' })
+
+interface BomNode {
+  id?: number
+  parentId?: number
+  deviceCategoryId?: number
+  deviceCategoryName?: string
+  name?: string
+  sort?: number
+  type?: string | number[]
+  materials?: number
+  children?: BomNode[]
+}
+
+interface MaterialItem {
+  id: number
+  name?: string
+  code?: string
+  quantity?: number
+}
+
+const props = defineProps<{
+  deviceId?: number
+  deviceCategoryName?: string
+}>()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<BomNode>()
+const message = useMessage()
+const loading = ref(false)
+const isExpandAll = ref(true)
+const refreshTable = ref(true)
+const list = ref<BomNode[]>([])
+const selectedNodeId = ref<number>()
+const queryFormRef = ref()
+const formRef = ref()
+const materialListRef = ref()
+const showDrawer = ref()
+const drawerVisible = ref(false)
+const currentBomNodeId = ref<number>()
+const currentRowInfo = ref({
+  deviceCategoryName: '',
+  bomNodeName: ''
+})
+
+const commonBomMaterialData = ref({
+  deviceCategoryId: undefined as number | undefined
+})
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined as string | undefined,
+  status: undefined as number | undefined,
+  deviceId: undefined as number | undefined
+})
+
+const nodeOptions = computed(() => list.value)
+
+const findNodeById = (nodes: BomNode[], id?: number): BomNode | undefined => {
+  if (!id) return undefined
+  for (const node of nodes) {
+    if (node.id === id) return node
+    const child = findNodeById(node.children || [], id)
+    if (child) return child
+  }
+  return undefined
+}
+
+const displayList = computed(() => {
+  if (!selectedNodeId.value) return list.value
+  const node = findNodeById(list.value, selectedNodeId.value)
+  return node ? [node] : []
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await BomApi.IotDeviceBomApi.getIotDeviceBomList(queryParams)
+    list.value = data ? handleTree(data) : []
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  selectedNodeId.value = undefined
+  handleQuery()
+}
+
+const toggleExpandAll = () => {
+  refreshTable.value = false
+  isExpandAll.value = !isExpandAll.value
+  nextTick(() => {
+    refreshTable.value = true
+  })
+}
+
+const hasBomType = (row: BomNode, type: number) => {
+  if (Array.isArray(row.type)) return row.type.includes(type)
+  return String(row.type || '')
+    .split(',')
+    .includes(String(type))
+}
+
+const openForm = (type: string, row: BomNode | null) => {
+  formRef.value?.open(type, row?.id || null)
+}
+
+const openSelectMaterialForm = (row: BomNode) => {
+  const rowWithCategory = {
+    ...row,
+    deviceCategoryName: props.deviceCategoryName
+  }
+  materialListRef.value?.open(rowWithCategory)
+  currentBomNodeId.value = row.id
+  commonBomMaterialData.value.deviceCategoryId = row.deviceCategoryId
+}
+
+const handleView = async (row: BomNode) => {
+  currentBomNodeId.value = row.id
+  currentRowInfo.value = {
+    deviceCategoryName: props.deviceCategoryName || '暂无',
+    bomNodeName: row.name || '暂无'
+  }
+  drawerVisible.value = true
+  showDrawer.value?.openDrawer()
+  await showDrawer.value?.loadMaterials(row.id)
+}
+
+const chooseMaterial = async (selectedMaterials: MaterialItem[]) => {
+  if (!currentBomNodeId.value) return
+
+  try {
+    const materialsData = selectedMaterials.map((material) => ({
+      deviceCategoryId: commonBomMaterialData.value.deviceCategoryId,
+      deviceId: props.deviceId,
+      bomNodeId: currentBomNodeId.value,
+      materialId: material.id,
+      name: material.name,
+      code: material.code,
+      quantity: material.quantity
+    }))
+    const resultCount = await MaterialApi.IotDeviceMaterialApi.addMaterials(materialsData)
+    message.success(`成功添加物料数量:${resultCount}`)
+    await showDrawer.value?.loadMaterials(currentBomNodeId.value)
+    await getList()
+  } catch {
+    message.error('添加物料失败!')
+  }
+}
+
+const handleDelete = async (id?: number) => {
+  if (!id) return
+
+  const targetNode = findNodeById(list.value, id)
+  if (targetNode?.children?.length) {
+    message.error('当前BOM节点包含子节点,不可删除')
+    return
+  }
+
+  try {
+    await message.delConfirm()
+    await BomApi.IotDeviceBomApi.deleteIotDeviceBom(id)
+    message.success('删除成功')
+    await getList()
+  } catch {}
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-bom-library {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+
+  :deep(.el-form-item) {
+    margin-right: 10px;
+    margin-bottom: 0;
+  }
+
+  :deep(.el-form-item:last-child) {
+    margin-right: 0;
+  }
+
+  :deep(.el-button .app-iconify) {
+    margin-right: 4px;
+  }
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-search-input {
+  width: 220px;
+}
+
+.library-actions {
+  flex: none;
+}
+
+.library-table-wrap {
+  position: relative;
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.bom-node-name {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.table-actions {
+  display: flex;
+  gap: 4px;
+  align-items: center;
+  justify-content: center;
+}
+
+.table-action-btn {
+  gap: 4px;
+
+  :deep(.el-icon),
+  :deep(.app-iconify) {
+    margin-right: 2px;
+  }
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-header-main {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-search-input {
+    width: 100%;
+  }
+}
+</style>

+ 483 - 0
src/views/pms/device/DeviceFailureRecord.vue

@@ -0,0 +1,483 @@
+<template>
+  <div class="device-failure-record">
+    <section class="library-workbench">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        inline
+        size="default"
+        class="library-search-form">
+        <div class="library-workbench__header">
+          <div class="library-header-main">
+            <div class="workbench-title">
+              <span class="workbench-title__icon">
+                <Icon icon="ep:warning" />
+              </span>
+              <div>
+                <h3>故障记录</h3>
+              </div>
+            </div>
+
+            <el-form-item label="故障编码" prop="failureCode">
+              <el-input
+                v-model="queryParams.failureCode"
+                class="library-search-input"
+                clearable
+                placeholder="请输入故障编码"
+                @keyup.enter="handleQuery">
+                <template #prefix>
+                  <Icon icon="ep:search" />
+                </template>
+              </el-input>
+            </el-form-item>
+            <el-form-item label="故障名称" prop="failureName">
+              <el-input
+                v-model="queryParams.failureName"
+                class="library-search-input"
+                clearable
+                placeholder="请输入故障名称"
+                @keyup.enter="handleQuery">
+                <template #prefix>
+                  <Icon icon="ep:search" />
+                </template>
+              </el-input>
+            </el-form-item>
+            <el-form-item label="状态" prop="status">
+              <el-select
+                v-model="queryParams.status"
+                class="library-search-input"
+                clearable
+                placeholder="请选择状态">
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.PMS_FAILURE_STATUS)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </div>
+
+          <el-form-item class="library-actions">
+            <el-button
+              :type="showAdvancedSearch ? 'warning' : 'primary'"
+              plain
+              @click="toggleAdvancedSearch">
+              <Icon :icon="showAdvancedSearch ? 'ep:arrow-up' : 'ep:arrow-down'" />
+              {{ showAdvancedSearch ? '收起查询' : '更多查询' }}
+            </el-button>
+            <el-button type="primary" @click="handleQuery">
+              <Icon icon="ep:search" />
+              搜索
+            </el-button>
+            <el-button @click="resetQuery">
+              <Icon icon="ep:refresh" />
+              重置
+            </el-button>
+          </el-form-item>
+        </div>
+
+        <el-collapse-transition>
+          <div v-show="showAdvancedSearch" class="advanced-search-panel">
+            <el-form-item label="是否停机" prop="ifStop">
+              <el-select
+                v-model="queryParams.ifStop"
+                class="library-search-input"
+                clearable
+                placeholder="请选择">
+                <el-option
+                  v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="故障时间" prop="failureTime">
+              <el-date-picker
+                v-model="queryParams.failureTime"
+                class="library-date-range"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                type="daterange"
+                start-placeholder="开始时间"
+                end-placeholder="结束时间"
+                :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" />
+            </el-form-item>
+            <el-form-item label="是否解决" prop="ifDeal">
+              <el-select
+                v-model="queryParams.ifDeal"
+                class="library-search-input"
+                clearable
+                placeholder="请选择">
+                <el-option
+                  v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="是否协助" prop="needHelp">
+              <el-select
+                v-model="queryParams.needHelp"
+                class="library-search-input"
+                clearable
+                placeholder="请选择">
+                <el-option
+                  v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="创建时间" prop="createTime">
+              <el-date-picker
+                v-model="queryParams.createTime"
+                class="library-date-range"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                type="daterange"
+                start-placeholder="开始时间"
+                end-placeholder="结束时间"
+                :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" />
+            </el-form-item>
+            <el-form-item label="故障系统" prop="failureSystem">
+              <el-input
+                v-model="queryParams.failureSystem"
+                class="library-search-input"
+                clearable
+                placeholder="请输入故障系统"
+                @keyup.enter="handleQuery" />
+            </el-form-item>
+            <el-form-item label="故障影响" prop="failureInfluence">
+              <el-input
+                v-model="queryParams.failureInfluence"
+                class="library-search-input"
+                clearable
+                placeholder="请输入故障影响"
+                @keyup.enter="handleQuery" />
+            </el-form-item>
+            <el-form-item label="描述" prop="description">
+              <el-input
+                v-model="queryParams.description"
+                class="library-search-input"
+                clearable
+                placeholder="请输入描述"
+                @keyup.enter="handleQuery" />
+            </el-form-item>
+          </div>
+        </el-collapse-transition>
+      </el-form>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn
+                label="故障编码"
+                prop="failureCode"
+                min-width="150"
+                align="left"
+                fixed="left">
+                <template #default="{ row }">
+                  <span class="failure-code">{{ row.failureCode || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="故障名称" prop="failureName" min-width="180" align="left">
+                <template #default="{ row }">
+                  {{ row.failureName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="设备" prop="deviceName" min-width="160">
+                <template #default="{ row }">
+                  {{ row.deviceName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="状态" prop="status" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_FAILURE_STATUS" :value="row.status" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="审核状态" prop="auditStatus" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="row.auditStatus" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="是否解决" prop="ifDeal" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.ifDeal" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="是否停机" prop="ifStop" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.ifStop" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="故障时间" prop="failureTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.failureTime) }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="故障系统" prop="failureSystem" min-width="160">
+                <template #default="{ row }">
+                  {{ row.failureSystem || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="创建时间" prop="createTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.createTime) }}
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { IotFailureReportApi, IotFailureReportVO } from '@/api/pms/failure'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DICT_TYPE, getBoolDictOptions, getStrDictOptions } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'DeviceFailureRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+type FailureRecordRow = IotFailureReportVO & {
+  auditStatus?: string | number
+  createTime?: Date | string | number
+  deviceName?: string
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents<FailureRecordRow>()
+const loading = ref(false)
+const list = ref<FailureRecordRow[]>([])
+const total = ref(0)
+const queryFormRef = ref()
+const showAdvancedSearch = ref(false)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  failureCode: undefined as string | undefined,
+  failureName: undefined as string | undefined,
+  deviceId: undefined as number | undefined,
+  status: undefined as string | undefined,
+  ifStop: undefined as string | boolean | undefined,
+  failureTime: [] as string[],
+  failureInfluence: undefined as string | undefined,
+  failureSystem: undefined as string | undefined,
+  description: undefined as string | undefined,
+  ifDeal: undefined as string | boolean | undefined,
+  needHelp: undefined as string | boolean | undefined,
+  createTime: [] as string[]
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await IotFailureReportApi.getIotFailureReportPage(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+const toggleAdvancedSearch = () => {
+  showAdvancedSearch.value = !showAdvancedSearch.value
+}
+
+const formatRecordTime = (value?: Date | string | number) => {
+  return value ? formatDate(value as Date) : '-'
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-failure-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-search-form {
+  :deep(.el-form-item) {
+    margin-right: 10px;
+    margin-bottom: 0;
+  }
+
+  :deep(.el-form-item:last-child) {
+    margin-right: 0;
+  }
+
+  :deep(.el-button .app-iconify) {
+    margin-right: 4px;
+  }
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-search-input {
+  width: 220px;
+}
+
+.library-date-range {
+  width: 300px;
+}
+
+.library-actions {
+  flex: none;
+}
+
+.advanced-search-panel {
+  display: flex;
+  padding: 12px 14px;
+  margin-bottom: 14px;
+  background: rgb(255 255 255 / 68%);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  gap: 12px 4px;
+  flex-wrap: wrap;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.failure-code {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-header-main {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-search-input {
+    width: 100%;
+  }
+}
+</style>

+ 509 - 0
src/views/pms/device/DeviceFileLibrary.vue

@@ -0,0 +1,509 @@
+<template>
+  <div class="device-file-library">
+    <section class="library-workbench">
+      <div class="library-workbench__header">
+        <div class="workbench-title">
+          <span class="workbench-title__icon">
+            <Icon icon="ep:files" />
+          </span>
+          <div>
+            <h3>资料列表</h3>
+            <p>{{ currentFolderName }} · {{ fileList.length }} 项</p>
+          </div>
+        </div>
+        <el-form ref="queryFormRef" :model="queryParams" inline class="library-search-form">
+          <el-form-item prop="filename">
+            <el-input
+              v-model="queryParams.filename"
+              class="library-search-input"
+              clearable
+              size="default"
+              placeholder="请输入文件名称"
+              @keyup.enter="handleQuery">
+              <template #prefix>
+                <Icon icon="ep:search" />
+              </template>
+            </el-input>
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" size="default" @click="handleQuery">
+              <Icon icon="ep:search" />
+              搜索
+            </el-button>
+            <el-button size="default" @click="resetQuery">
+              <Icon icon="ep:refresh" />
+              重置
+            </el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+
+      <ZmTable
+        :data="fileList"
+        class="library-table"
+        :loading="loading"
+        :show-border="true"
+        :column-max-width="420"
+        row-key="id"
+        empty-text="暂无数据"
+        @row-dblclick="handleRowDblClick">
+        <ZmTableColumn label="文件名称" prop="filename" min-width="320" align="left">
+          <template #default="{ row }">
+            <div class="file-name-cell">
+              <span :class="['file-icon', `file-icon--${getFileIconType(row)}`]">
+                <Icon :icon="getFileIcon(row)" />
+              </span>
+              <div class="file-name-cell__text">
+                <strong>{{ row.filename || '-' }}</strong>
+                <span v-if="row.fileType === 'content'">双击进入文件夹</span>
+                <span v-else>{{ row.fileClassify || 'file' }}</span>
+              </div>
+            </div>
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="文件类型" prop="fileType" min-width="120">
+          <template #default="{ row }">
+            <el-tag v-if="row.fileType === 'content'" type="warning" effect="light">文件夹</el-tag>
+            <el-tag v-else type="info" effect="light">{{ row.fileClassify || '文件' }}</el-tag>
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="文件大小" prop="fileSize" min-width="120">
+          <template #default="{ row }">
+            {{ row.fileSize || '-' }}
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="所在部门" prop="deptName" min-width="160">
+          <template #default="{ row }">
+            {{ row.deptName || '-' }}
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="所属设备" prop="deviceCode" min-width="180">
+          <template #default="{ row }">
+            {{ row.deviceCode || '-' }}
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn label="操作" width="150" fixed="right" align="center" action>
+          <template #default="{ row }">
+            <div v-if="row.fileType !== 'content'" class="table-actions">
+              <el-button
+                link
+                type="primary"
+                size="default"
+                class="table-action-btn"
+                @click.stop="previewFile(row)">
+                <Icon icon="ep:view" />
+                预览
+              </el-button>
+              <el-button
+                link
+                type="primary"
+                size="default"
+                class="table-action-btn"
+                @click.stop="downloadFile(row)">
+                <Icon icon="ep:download" />
+                下载
+              </el-button>
+            </div>
+            <span v-else class="folder-action-tip">双击进入</span>
+          </template>
+        </ZmTableColumn>
+      </ZmTable>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from 'vue'
+import { Base64 } from 'js-base64'
+import { IotInfoApi } from '@/api/pms/iotinfo'
+import { IotTreeApi } from '@/api/system/tree'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+
+defineOptions({ name: 'DeviceFileLibrary' })
+
+interface FileRecord {
+  id?: number | string
+  filename?: string
+  name?: string
+  fileType?: string
+  fileClassify?: string
+  filePath?: string
+  fileSize?: string
+  deptName?: string
+  deviceCode?: string
+}
+
+interface BreadcrumbItem {
+  id: number | string | null
+  name: string
+  key: string
+}
+
+const props = defineProps<{
+  deviceId?: number
+  deviceName?: string
+}>()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<FileRecord>()
+const queryFormRef = ref()
+const loading = ref(false)
+const fileList = ref<FileRecord[]>([])
+const rootClassId = ref<number | string | null>(null)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  filename: '',
+  fileType: null as string | null,
+  createTime: [] as string[],
+  deviceId: null as number | null,
+  classId: null as number | string | null,
+  deptId: undefined as number | undefined,
+  allName: null as string | null
+})
+
+const rootName = computed(() => props.deviceName || '设备资料')
+const breadcrumbs = ref<BreadcrumbItem[]>([{ id: null, name: '设备资料', key: 'root' }])
+const currentFolderName = computed(() => {
+  return breadcrumbs.value[breadcrumbs.value.length - 1]?.name || rootName.value
+})
+
+const resetBreadcrumbRoot = () => {
+  breadcrumbs.value = [
+    {
+      id: rootClassId.value,
+      name: rootName.value,
+      key: String(rootClassId.value || 'root')
+    }
+  ]
+}
+
+const loadFiles = async () => {
+  if (!queryParams.classId) return
+
+  loading.value = true
+  try {
+    const data = await IotInfoApi.getChildContentFile(queryParams)
+    fileList.value = Array.isArray(data) ? data : []
+  } finally {
+    loading.value = false
+  }
+}
+
+const initLibrary = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  try {
+    const rootId = await IotTreeApi.getDeviceTree(props.deviceId)
+    rootClassId.value = rootId
+    queryParams.deviceId = props.deviceId
+    queryParams.classId = rootId
+    resetBreadcrumbRoot()
+    await loadFiles()
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  loadFiles()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.filename = ''
+  queryParams.pageNo = 1
+  loadFiles()
+}
+
+const enterFolder = async (row: FileRecord) => {
+  if (!row.id) return
+
+  queryParams.filename = ''
+  queryParams.classId = row.id
+  breadcrumbs.value.push({
+    id: row.id,
+    name: row.filename || row.name || '未命名目录',
+    key: String(row.id)
+  })
+  await loadFiles()
+}
+
+const handleRowDblClick = (row: FileRecord) => {
+  if (row.fileType === 'content') {
+    enterFolder(row)
+    return
+  }
+  previewFile(row)
+}
+
+const previewFile = (row: FileRecord) => {
+  if (!row.filePath) return
+
+  const previewUrl =
+    'http://doc.deepoil.cc:8012/onlinePreview?url=' +
+    encodeURIComponent(Base64.encode(row.filePath))
+  window.open(previewUrl)
+}
+
+const downloadFile = async (row: FileRecord) => {
+  if (!row.filePath) return
+
+  try {
+    const response = await fetch(row.filePath)
+    const blob = await response.blob()
+    const downloadUrl = window.URL.createObjectURL(blob)
+    const link = document.createElement('a')
+    link.href = downloadUrl
+    link.download = row.filename || row.filePath.split('/').pop() || 'download'
+    link.click()
+    URL.revokeObjectURL(downloadUrl)
+  } catch {
+    window.open(row.filePath)
+  }
+}
+
+const getFileIconType = (row: FileRecord) => {
+  if (row.fileType === 'content') return 'folder'
+  const type = row.fileClassify?.toLowerCase()
+  if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(type || '')) return 'image'
+  if (type === 'pdf') return 'pdf'
+  if (['doc', 'docx'].includes(type || '')) return 'word'
+  if (['xls', 'xlsx'].includes(type || '')) return 'excel'
+  if (['ppt', 'pptx'].includes(type || '')) return 'ppt'
+  return 'file'
+}
+
+const getFileIcon = (row: FileRecord) => {
+  const iconType = getFileIconType(row)
+  const iconMap: Record<string, string> = {
+    folder: 'fa:folder-open',
+    image: 'ep:picture-filled',
+    pdf: 'fa-solid:file-pdf',
+    word: 'fa:file-word-o',
+    excel: 'fa-solid:file-excel',
+    ppt: 'fa-solid:file-powerpoint',
+    file: 'fa-solid:file-alt'
+  }
+  return iconMap[iconType]
+}
+
+watch(
+  () => [props.deviceId, props.deviceName],
+  () => {
+    initLibrary()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-file-library {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+
+  p {
+    margin: 2px 0 0;
+    font-size: 12px;
+    line-height: 18px;
+    color: var(--el-text-color-secondary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-search-form {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+
+  :deep(.el-form-item) {
+    margin-right: 10px;
+    margin-bottom: 0;
+  }
+
+  :deep(.el-form-item:last-child) {
+    margin-right: 0;
+  }
+}
+
+.library-search-input {
+  width: 260px;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+
+  :deep(.el-table__body .el-table__row) {
+    height: 56px;
+  }
+}
+
+.file-name-cell {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+}
+
+.file-icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 16px;
+  border-radius: 8px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.file-icon--folder {
+  color: #d08a00;
+  background: #fff5df;
+}
+
+.file-icon--image {
+  color: #2183d1;
+  background: #e9f4ff;
+}
+
+.file-icon--pdf {
+  color: #d93025;
+  background: #fff0ef;
+}
+
+.file-icon--word {
+  color: #2b65d9;
+  background: #eef4ff;
+}
+
+.file-icon--excel {
+  color: #107c41;
+  background: #eaf8ef;
+}
+
+.file-icon--ppt {
+  color: #c43e1c;
+  background: #fff1eb;
+}
+
+.file-icon--file {
+  color: var(--el-text-color-secondary);
+  background: var(--el-fill-color-light);
+}
+
+.file-name-cell__text {
+  min-width: 0;
+
+  strong,
+  span {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  strong {
+    font-size: 14px;
+    font-weight: 600;
+    line-height: 22px;
+    color: var(--el-text-color-primary);
+  }
+
+  span {
+    font-size: 12px;
+    line-height: 18px;
+    color: var(--el-text-color-secondary);
+  }
+}
+
+.table-actions {
+  display: flex;
+  gap: 4px;
+  align-items: center;
+  justify-content: center;
+}
+
+.table-action-btn {
+  :deep(.iconify) {
+    margin-right: 4px;
+  }
+}
+
+.folder-action-tip {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-search-form {
+    justify-content: flex-start;
+  }
+
+  .library-search-input {
+    width: 100%;
+  }
+}
+</style>

+ 529 - 300
src/views/pms/device/DeviceInfo.vue

@@ -1,337 +1,308 @@
 <template>
-  <ContentWrap>
-    <div style="display: flex; flex-direction: row; height: 12em; margin-top: 2px">
-      <div style="flex: 1; height: 12em; margin-left: 20px">
-        <el-image
-          :key="index"
-          :src="defaultPicUrl"
-          style="width: 35em; height: 12em"
-          @click="imagePreview(defaultPicUrl)"
-          fit="contain" />
+  <ContentWrap v-loading="formLoading" class="device-main-wrap" :body-style="{ padding: '0' }">
+    <div class="device-main">
+      <div class="device-media">
+        <button class="device-image-frame" type="button" @click="imagePreview(defaultPicUrl)">
+          <el-image :src="defaultPicUrl" class="device-image" fit="cover">
+            <template #error>
+              <div class="device-image__empty">
+                <Icon icon="ep:picture" />
+              </div>
+            </template>
+          </el-image>
+          <span class="image-preview-mask">
+            <Icon icon="ep:zoom-in" />
+            <span>{{ t('action.preview') }}</span>
+          </span>
+        </button>
       </div>
-      <div style="flex: 2; height: 12em; margin-top: 23px">
-        <el-form ref="formRef" :disabled="false" :model="formData" label-width="120px">
-          <el-row>
-            <el-col :span="8">
-              <el-form-item :label="t('iotDevice.yfCode')" prop="yfDeviceCode">
-                {{ formData.yfDeviceCode }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('iotDevice.code')" prop="deviceCode">
-                {{ formData.deviceCode }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('chooseMaintain.deviceName')" prop="deviceName">
-                {{ formData.deviceName }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('iotDevice.brand')" prop="brand">
-                {{ formData.brandName }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('iotDevice.dept')" prop="deptId">
-                {{ formData.deptName }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('info.deviceClass')" prop="assetClass">
-                {{ formData.assetClassName }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('monitor.status')" prop="deviceStatus">
-                {{ getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, formData.deviceStatus) }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('devicePerson.assets')" prop="assetProperty">
-                {{ getDictLabel(DICT_TYPE.PMS_ASSET_PROPERTY, formData.assetProperty) }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('deviceForm.model')" prop="model">
-                {{ formData.model ? formData.model : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="8">
-              <el-form-item :label="t('devicePerson.rp')" prop="responsibleNames">
-                {{ formData.responsibleNames ? formData.responsibleNames : '-' }}
-              </el-form-item>
-            </el-col>
-          </el-row>
-        </el-form>
+
+      <div class="device-content">
+        <div class="device-head">
+          <div class="device-head__main">
+            <div class="device-title-row">
+              <h1 class="device-title">{{ formatValue(formData.deviceName) }}</h1>
+              <el-tag class="device-status" effect="light" round>
+                {{ statusLabel }}
+              </el-tag>
+            </div>
+            <div class="device-subtitle">
+              <Icon icon="ep:collection-tag" />
+              <span>{{ assetPropertyLabel }}</span>
+            </div>
+          </div>
+        </div>
+
+        <div class="device-info-grid">
+          <div v-for="field in mainInfoFields" :key="field.prop" class="device-info-item">
+            <span class="device-info-item__label">
+              <Icon :icon="field.icon" />
+              {{ field.label }}
+            </span>
+            <strong>{{ field.value }}</strong>
+          </div>
+        </div>
       </div>
     </div>
   </ContentWrap>
-  <ContentWrap v-loading="formLoading">
-    <el-tabs v-model="activeName" @tab-click="handleTabClick">
+
+  <ContentWrap
+    v-loading="formLoading"
+    class="device-detail-wrap"
+    :body-style="{ padding: '0 18px 18px' }">
+    <el-tabs v-model="activeName" class="device-tabs">
       <el-tab-pane :label="t('deviceInfo.basicInformation')" name="info">
-        <el-form style="margin-top: 5px; margin-left: 35px; margin-right: 35px">
-          <el-row style="border-bottom: 1px solid #dcdfe6">
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.mfg')" prop="manufacturerId">
-                {{ formData.zzName }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.pd')" prop="manDate">
-                {{ formatDate(formData.manDate, 'YYYY-MM-DD') }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.supplier')" prop="supplierId">
-                {{ formData.supplierName ? formData.supplierName : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.ni')" prop="nameplate">
-                {{ formData.nameplate ? formData.nameplate : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.warranty')" prop="expires">
-                {{ formData.expires ? formatDate(formData.expires, 'YYYY-MM-DD') : '-' }}
-              </el-form-item>
-            </el-col>
-          </el-row>
-          <el-row style="margin-top: 20px; border-bottom: 1px solid #dcdfe6">
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.pp')" prop="plPrice">
-                {{ formData.plPrice ? formData.plPrice : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.pdate')" prop="plDate">
-                {{ formData.plDate ? formatDate(formData.plDate, 'YYYY-MM-DD') : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.dp')" prop="plYear">
-                {{ formData.plYear ? formData.plYear : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.ds')" prop="plStartDate">
-                {{ formData.plStartDate ? formatDate(formData.plStartDate, 'YYYY-MM-DD') : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.yd')" prop="plMonthed">
-                {{ formData.plMonthed ? formData.plMonthed : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.yy')" prop="plAmounted">
-                {{ formData.plAmounted ? formData.plAmounted : '-' }}
-              </el-form-item>
-            </el-col>
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.sy')" prop="remainAmount">
-                {{ formData.remainAmount ? formData.remainAmount : '-' }}
-              </el-form-item>
-            </el-col>
-
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.my')" prop="monthAmount">
-                {{ formData.monthAmount ? formData.monthAmount : '-' }}
-              </el-form-item>
-            </el-col>
-
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.mmy')" prop="totalMonth">
-                {{ formData.totalMonth ? formData.totalMonth : '-' }}
-              </el-form-item>
-            </el-col>
-
-            <el-col :span="6">
-              <el-form-item :label="t('deviceInfo.currency')" prop="currency">
-                {{ formData.currency ? formData.currency : '-' }}
-              </el-form-item>
-            </el-col>
-          </el-row>
-          <el-row style="margin-top: 20px">
-            <el-col v-for="field in list" :key="field.sort" :span="6">
-              <el-form-item :label="field.name" :prop="field.identifier">
-                {{ field.value }}
-              </el-form-item>
-            </el-col>
-          </el-row>
-        </el-form>
+        <DeviceBasicInfo :device="formData" :template-fields="templateFields" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.fileLibrary')" name="file">
-        <!--        <DeviceUpload ref="fileRef" v-if="loadedTabs.includes('1')" />-->
-        <DeviceFile
-          ref="fileRef"
-          :deviceId="id"
-          :deviceName="formData.deviceName"
-          v-if="loadedTabs.includes('1')" />
+        <DeviceFileLibrary
+          v-if="activeName === 'file'"
+          :device-id="deviceId"
+          :device-name="formData.deviceName" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.deviceBOM')" name="bom">
-        <BomList
-          ref="bomRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          :deviceCategoryName="formData.assetClassName"
-          v-if="loadedTabs.includes('2')" />
+        <DeviceBomLibrary
+          v-if="activeName === 'bom'"
+          :device-id="deviceId"
+          :device-category-name="formData.assetClassName" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.operationRecords')" name="record">
-        <RecordList
-          ref="recordRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('3')" />
+        <DeviceOperationRecord v-if="activeName === 'record'" :device-id="deviceId" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.faultRecords')" name="failure">
-        <FailureList
-          ref="failureRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('4')" />
+        <DeviceFailureRecord v-if="activeName === 'failure'" :device-id="deviceId" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.repairRecords')" name="maintain">
-        <MaintainList
-          ref="maintainRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('5')" />
+        <DeviceMaintainRecord v-if="activeName === 'maintain'" :device-id="deviceId" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.maintenanceRecords')" name="maintenance">
-        <MaintenanceList
-          ref="maintenanceRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('6')" />
+        <DeviceMaintenanceRecord v-if="activeName === 'maintenance'" :device-id="deviceId" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.inspectionRecords')" name="inspect">
-        <InspectList
-          ref="inspectRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('7')" />
+        <DeviceInspectRecord v-if="activeName === 'inspect'" :device-id="deviceId" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.transferRecords')" name="allot">
-        <AllotLogList
-          ref="allotRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('8')" />
+        <DeviceAllotRecord v-if="activeName === 'allot'" :device-id="deviceId" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.statusChangeRecords')" name="status">
-        <DeviceStatusLogList
-          ref="statusRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('9')" />
+        <DeviceStatusRecord v-if="activeName === 'status'" :device-id="deviceId" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.RPAdjustmentRecords')" name="person">
-        <PersonList
-          ref="personRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('10')" />
+        <DevicePersonRecord v-if="activeName === 'person'" :device-id="deviceId" />
       </el-tab-pane>
-
-      <!-- 关联设备 -->
       <el-tab-pane :label="t('deviceInfo.associationDevice')" name="association">
-        <AssociationDevices
-          ref="personRef"
-          v-model:activeName="activeName"
-          :deviceId="id"
-          v-if="loadedTabs.includes('11')" />
+        <DeviceAssociationRecord v-if="activeName === 'association'" :device-id="deviceId" />
       </el-tab-pane>
     </el-tabs>
   </ContentWrap>
 </template>
-<script lang="ts" setup>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
 import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
-import { DICT_TYPE, getDictLabel } from '@/utils/dict'
-import { formatDate } from '../../../utils/formatTime'
-import DeviceUpload from '@/views/pms/device/DeviceUpload.vue'
-import BomInfo from '@/views/pms/device/bom/BomInfo.vue'
-import DeviceFile from '@/views/pms/device/DeviceFile.vue'
-import BomList from '@/views/pms/device/bom/BomList.vue'
-import FailureList from '@/views/pms/device/FailureList.vue'
-import MaintainList from '@/views/pms/device/MaintainList.vue'
-import InspectList from '@/views/pms/device/InspectList.vue'
-import MaintenanceList from '@/views/pms/device/maintenance/MaintenanceList.vue'
-import AllotLogList from '@/views/pms/device/allotlog/AllotLogList.vue'
-import DeviceStatusLogList from '@/views/pms/device/statuslog/DeviceStatusLogList.vue'
-import PersonList from '@/views/pms/device/personlog/PersonList.vue'
-import RecordList from '@/views/pms/device/record/RecordList.vue'
-import AssociationDevices from '@/views/pms/device/completeSet/AssociationDevices.vue'
 import { createImageViewer } from '@/components/ImageViewer'
-import { ref, onMounted } from 'vue'
 import { getAccessToken } from '@/utils/auth'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import DeviceBasicInfo from './DeviceBasicInfo.vue'
+import DeviceAssociationRecord from './DeviceAssociationRecord.vue'
+import DeviceAllotRecord from './DeviceAllotRecord.vue'
+import DeviceBomLibrary from './DeviceBomLibrary.vue'
+import DeviceFailureRecord from './DeviceFailureRecord.vue'
+import DeviceFileLibrary from './DeviceFileLibrary.vue'
+import DeviceInspectRecord from './DeviceInspectRecord.vue'
+import DeviceMaintenanceRecord from './DeviceMaintenanceRecord.vue'
+import DeviceMaintainRecord from './DeviceMaintainRecord.vue'
+import DeviceOperationRecord from './DeviceOperationRecord.vue'
+import DevicePersonRecord from './DevicePersonRecord.vue'
+import DeviceStatusRecord from './DeviceStatusRecord.vue'
+
+defineOptions({ name: 'DeviceInfo' })
+
+type DeviceDetail = Partial<
+  Omit<
+    IotDeviceVO,
+    | 'model'
+    | 'manDate'
+    | 'expires'
+    | 'plPrice'
+    | 'plDate'
+    | 'plYear'
+    | 'plStartDate'
+    | 'plMonthed'
+    | 'plAmounted'
+    | 'remainAmount'
+    | 'monthAmount'
+    | 'totalMonth'
+  >
+> & {
+  yfDeviceCode?: string
+  brandName?: string
+  assetClass?: number
+  assetClassName?: string
+  model?: string | number
+  responsibleNames?: string
+  devicePersons?: string
+  zzName?: string
+  supplierName?: string
+  manufacturerName?: string
+  manDate?: number | string | Date
+  expires?: number | string | Date
+  plDate?: number | string | Date
+  plStartDate?: number | string | Date
+  enableDate?: number | string | Date
+  nameplate?: string
+  plPrice?: number | string
+  plYear?: number | string
+  plMonthed?: number | string
+  plAmounted?: number | string
+  remainAmount?: number | string
+  monthAmount?: number | string
+  totalMonth?: number | string
+  currency?: string
+  templateJson?: string
+}
+
+interface TemplateField {
+  sort?: number
+  name?: string
+  value?: string | number | null
+  identifier?: string
+}
+
+interface MainInfoField {
+  prop: keyof DeviceDetail | 'assetPropertyLabel' | 'responsibleNames'
+  label: string
+  value: string
+  icon: string
+}
 
-const defaultPicUrl = ref(
+const defaultDevicePic =
   import.meta.env.VITE_BASE_URL + '/admin-api/infra/file/29/get/IntegratedSolution.png'
-) // 默认设备图片
-
-defineOptions({ name: 'DeviceDetailInfo' })
-
-const { t } = useI18n() // 国际化
-const message = useMessage() // 消息弹窗
-const { params } = useRoute() // 查询参数
-const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
-const activeName = ref('info') // Tag 激活的窗口
-const list = ref([])
-const id = params.id
-const fileRef = ref() // 搜索的表单
-// SPU 表单数据
-const formData = ref({
-  id: undefined,
-  code: undefined,
-  name: undefined,
-  classification: undefined,
-  type: undefined,
-  nature: undefined,
-  creditCode: undefined,
-  tin: undefined,
-  corporation: undefined,
-  incorporationDate: undefined,
-  address: undefined,
-  bizScope: undefined,
-  registeredCapital: undefined,
-  annualTurnove: undefined,
-  size: undefined,
-  status: undefined,
-  remark: undefined,
-  deptName: undefined,
-  monthAmount: undefined,
-  totalMonth: undefined,
-  currency: undefined
+
+const { t } = useI18n()
+const { params, query } = useRoute()
+
+const formLoading = ref(false)
+const activeName = ref('info')
+const defaultPicUrl = ref(defaultDevicePic)
+const formData = ref<DeviceDetail>({})
+
+const deviceId = computed(() => {
+  const id = params.id ?? query.id
+  const normalizedId = Array.isArray(id) ? id[0] : id
+  return normalizedId ? Number(normalizedId) : undefined
+})
+
+const formatValue = (value?: string | number | null) => {
+  return value || value === 0 ? String(value) : '-'
+}
+
+const statusLabel = computed(() => {
+  return (
+    getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, formData.value.deviceStatus) ||
+    formData.value.deviceStatusName ||
+    '-'
+  )
+})
+
+const assetPropertyLabel = computed(() => {
+  return getDictLabel(DICT_TYPE.PMS_ASSET_PROPERTY, formData.value.assetProperty) || '-'
+})
+
+const responsibleNames = computed(() => {
+  return formData.value.responsibleNames || formData.value.devicePersons || '-'
+})
+
+const templateFields = computed<TemplateField[]>(() => {
+  if (!formData.value.templateJson) return []
+
+  try {
+    const fields = JSON.parse(formData.value.templateJson)
+    return Array.isArray(fields) ? fields : []
+  } catch {
+    return []
+  }
 })
-const pics = ref([])
-const imgSrc = ref('')
-const loadedTabs = ref(['info']) // 记录已加载的标签
 
-/** 获得详情 */
+const mainInfoFields = computed<MainInfoField[]>(() => [
+  {
+    prop: 'yfDeviceCode',
+    label: t('iotDevice.yfCode'),
+    value: formatValue(formData.value.yfDeviceCode),
+    icon: 'ep:postcard'
+  },
+  {
+    prop: 'deviceCode',
+    label: t('iotDevice.code'),
+    value: formatValue(formData.value.deviceCode),
+    icon: 'ep:ticket'
+  },
+  {
+    prop: 'deviceName',
+    label: t('iotDevice.name'),
+    value: formatValue(formData.value.deviceName),
+    icon: 'ep:cpu'
+  },
+  {
+    prop: 'brandName',
+    label: t('iotDevice.brand'),
+    value: formatValue(formData.value.brandName),
+    icon: 'ep:medal'
+  },
+  {
+    prop: 'deptName',
+    label: t('iotDevice.dept'),
+    value: formatValue(formData.value.deptName),
+    icon: 'ep:office-building'
+  },
+  {
+    prop: 'assetClassName',
+    label: t('info.deviceClass'),
+    value: formatValue(formData.value.assetClassName),
+    icon: 'ep:grid'
+  },
+  {
+    prop: 'deviceStatus',
+    label: '施工状态',
+    value: statusLabel.value,
+    icon: 'ep:operation'
+  },
+  {
+    prop: 'assetPropertyLabel',
+    label: t('iotDevice.assets'),
+    value: assetPropertyLabel.value,
+    icon: 'ep:collection-tag'
+  },
+  {
+    prop: 'model',
+    label: t('deviceForm.model'),
+    value: formatValue(formData.value.model),
+    icon: 'ep:set-up'
+  },
+  {
+    prop: 'responsibleNames',
+    label: t('devicePerson.rp'),
+    value: responsibleNames.value,
+    icon: 'ep:user'
+  }
+])
+
 const getDetail = async () => {
-  if (id) {
-    formLoading.value = true
-    try {
-      const res = (await IotDeviceApi.getIotDevice(id)) as IotDeviceVO
-      formData.value = res
-      pics.value.push(res.picUrl)
-      if (res) {
-        if (res.templateJson) {
-          list.value = JSON.parse(res.templateJson)
-        }
-      }
-      if (formData.value.picUrl) {
-        defaultPicUrl.value = formData.value.picUrl
-      }
-    } finally {
-      formLoading.value = false
-    }
+  if (!deviceId.value) return
+
+  formLoading.value = true
+  try {
+    const detail = (await IotDeviceApi.getIotDevice(deviceId.value)) as DeviceDetail
+    formData.value = detail || {}
+    defaultPicUrl.value = detail?.picUrl || defaultDevicePic
+  } finally {
+    formLoading.value = false
   }
 }
 
 const imagePreview = (imgUrl: string) => {
+  if (!imgUrl) return
+
   const token = getAccessToken()
   createImageViewer({
     urlList: [imgUrl],
@@ -341,16 +312,274 @@ const imagePreview = (imgUrl: string) => {
   })
 }
 
-const handleTabClick = (tab) => {
-  if (!loadedTabs.value.includes(tab.index)) {
-    // 这里可以添加每个标签对应的加载逻辑,如果有的话
-    loadedTabs.value.push(tab.index)
+onMounted(() => {
+  getDetail()
+})
+</script>
+
+<style scoped lang="scss">
+.device-main-wrap {
+  overflow: hidden;
+  border: 0;
+  border-radius: 12px;
+  box-shadow: 0 8px 24px rgb(31 45 61 / 7%);
+}
+
+.device-detail-wrap {
+  margin-top: 14px;
+  overflow: hidden;
+  border: 0;
+  border-radius: 12px;
+  box-shadow: 0 8px 24px rgb(31 45 61 / 6%);
+}
+
+.device-main {
+  display: grid;
+  grid-template-columns: 292px minmax(0, 1fr);
+  min-height: 286px;
+  padding: 24px;
+  background: radial-gradient(circle at 18px 22px, rgb(44 123 229 / 12%), transparent 30%),
+    linear-gradient(135deg, #f7fbff 0%, var(--el-bg-color) 42%, #fbfcfe 100%);
+}
+
+.device-media {
+  display: flex;
+  align-items: stretch;
+  justify-content: center;
+}
+
+.device-image-frame {
+  position: relative;
+  width: 260px;
+  height: 226px;
+  padding: 8px;
+  overflow: hidden;
+  font: inherit;
+  cursor: zoom-in;
+  background: var(--el-bg-color);
+  border: 1px solid rgb(44 123 229 / 14%);
+  border-radius: 12px;
+  box-shadow: 0 14px 32px rgb(39 83 135 / 15%);
+  appearance: none;
+
+  &:hover {
+    .device-image {
+      transform: scale(1.035);
+    }
+
+    .image-preview-mask {
+      opacity: 1;
+    }
   }
 }
 
-/** 初始化 */
-onMounted(async () => {
-  await getDetail()
-})
-</script>
-<style scoped></style>
+.device-image {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  border-radius: 8px;
+  transition: transform 0.3s ease;
+}
+
+.device-image__empty {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  font-size: 34px;
+  color: var(--el-text-color-placeholder);
+  background: var(--el-fill-color-light);
+  align-items: center;
+  justify-content: center;
+}
+
+.image-preview-mask {
+  position: absolute;
+  right: 18px;
+  bottom: 18px;
+  display: flex;
+  gap: 6px;
+  padding: 7px 12px;
+  font-size: 12px;
+  line-height: 16px;
+  color: #fff;
+  pointer-events: none;
+  background: rgb(20 29 43 / 74%);
+  border: 1px solid rgb(255 255 255 / 22%);
+  border-radius: 16px;
+  opacity: 0.88;
+  backdrop-filter: blur(6px);
+  align-items: center;
+  transition: opacity 0.2s ease;
+}
+
+.device-content {
+  min-width: 0;
+  padding: 2px 4px 0 30px;
+}
+
+.device-head {
+  display: flex;
+  gap: 24px;
+  min-height: 78px;
+  padding-bottom: 18px;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  align-items: flex-start;
+  justify-content: space-between;
+}
+
+.device-head__main {
+  min-width: 0;
+}
+
+.device-title-row {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+}
+
+.device-title {
+  max-width: 760px;
+  margin: 0;
+  overflow: hidden;
+  font-size: 24px;
+  font-weight: 700;
+  line-height: 34px;
+  color: var(--el-text-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-status {
+  flex: none;
+  padding: 0 12px;
+  font-weight: 600;
+}
+
+.device-subtitle {
+  display: flex;
+  gap: 6px;
+  margin-top: 9px;
+  font-size: 13px;
+  line-height: 20px;
+  color: #7a5a13;
+  align-items: center;
+}
+
+.device-info-grid {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(132px, 1fr));
+  gap: 0 22px;
+  padding-top: 14px;
+}
+
+.device-info-item {
+  display: flex;
+  min-width: 0;
+  min-height: 68px;
+  padding: 11px 0;
+  border-bottom: 1px dashed var(--el-border-color-lighter);
+  flex-direction: column;
+  justify-content: center;
+
+  strong {
+    margin-top: 7px;
+    overflow: hidden;
+    font-size: 14px;
+    font-weight: 600;
+    line-height: 21px;
+    color: var(--el-text-color-primary);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.device-info-item__label {
+  display: flex;
+  gap: 6px;
+  min-width: 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--el-text-color-secondary);
+  align-items: center;
+}
+
+.device-tabs {
+  :deep(.el-tabs__header) {
+    padding: 0 20px;
+    margin: 0 -18px 18px;
+    background: var(--el-bg-color);
+  }
+
+  :deep(.el-tabs__nav-wrap::after) {
+    height: 1px;
+    background-color: var(--el-border-color-lighter);
+  }
+
+  :deep(.el-tabs__item) {
+    height: 54px;
+    padding: 0 17px;
+    font-weight: 500;
+    color: var(--el-text-color-regular);
+  }
+
+  :deep(.el-tabs__item.is-active) {
+    font-weight: 600;
+    color: var(--el-color-primary);
+  }
+
+  :deep(.el-tabs__active-bar) {
+    height: 3px;
+    border-radius: 3px 3px 0 0;
+  }
+}
+
+@media (width <= 1399px) {
+  .device-main {
+    grid-template-columns: 260px minmax(0, 1fr);
+  }
+
+  .device-image-frame {
+    width: 232px;
+    height: 208px;
+  }
+
+  .device-info-grid {
+    grid-template-columns: repeat(4, minmax(132px, 1fr));
+  }
+}
+
+@media (width <= 991px) {
+  .device-main {
+    grid-template-columns: 1fr;
+  }
+
+  .device-content {
+    padding: 24px 0 0;
+  }
+
+  .device-info-grid {
+    grid-template-columns: repeat(2, minmax(132px, 1fr));
+  }
+}
+
+@media (width <= 575px) {
+  .device-main {
+    padding: 18px;
+  }
+
+  .device-head {
+    gap: 12px;
+    flex-direction: column;
+  }
+
+  .device-title {
+    font-size: 20px;
+    line-height: 30px;
+  }
+
+  .device-info-grid {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 786 - 0
src/views/pms/device/DeviceInfo1.vue

@@ -0,0 +1,786 @@
+<template>
+  <ContentWrap class="device-overview-wrap" :body-style="{ padding: '0' }">
+    <div class="device-overview">
+      <div class="device-image-panel">
+        <div class="device-image-frame" @click="imagePreview(defaultPicUrl)">
+          <el-image :src="defaultPicUrl" class="device-image" fit="cover" />
+          <div class="image-preview-mask">
+            <Icon icon="ep:zoom-in" />
+            <span>{{ t('action.preview') }}</span>
+          </div>
+        </div>
+      </div>
+      <div class="device-summary">
+        <div class="device-heading">
+          <div class="device-heading__main">
+            <div class="device-title-row">
+              <h1 class="device-title">{{ formData.deviceName || '-' }}</h1>
+              <el-tag effect="light" round>
+                {{ getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, formData.deviceStatus) || '-' }}
+              </el-tag>
+            </div>
+            <div class="device-code">
+              <Icon icon="ep:postcard" />
+              <span>{{ t('iotDevice.code') }}:{{ formData.deviceCode || '-' }}</span>
+            </div>
+          </div>
+          <div class="asset-badge">
+            <Icon icon="ep:collection-tag" />
+            <span>
+              {{ getDictLabel(DICT_TYPE.PMS_ASSET_PROPERTY, formData.assetProperty) || '-' }}
+            </span>
+          </div>
+        </div>
+
+        <div class="summary-grid">
+          <div class="summary-item">
+            <span class="summary-item__label">{{ t('iotDevice.yfCode') }}</span>
+            <strong>{{ formData.yfDeviceCode || '-' }}</strong>
+          </div>
+          <div class="summary-item">
+            <span class="summary-item__label">{{ t('iotDevice.brand') }}</span>
+            <strong>{{ formData.brandName || '-' }}</strong>
+          </div>
+          <div class="summary-item">
+            <span class="summary-item__label">{{ t('iotDevice.dept') }}</span>
+            <strong>{{ formData.deptName || '-' }}</strong>
+          </div>
+          <div class="summary-item">
+            <span class="summary-item__label">{{ t('info.deviceClass') }}</span>
+            <strong>{{ formData.assetClassName || '-' }}</strong>
+          </div>
+          <div class="summary-item">
+            <span class="summary-item__label">{{ t('deviceForm.model') }}</span>
+            <strong>{{ formData.model || '-' }}</strong>
+          </div>
+          <div class="summary-item">
+            <span class="summary-item__label">{{ t('devicePerson.rp') }}</span>
+            <strong>{{ formData.responsibleNames || '-' }}</strong>
+          </div>
+        </div>
+      </div>
+    </div>
+  </ContentWrap>
+  <ContentWrap
+    v-loading="formLoading"
+    class="device-detail-wrap"
+    :body-style="{ padding: '0 18px 18px' }">
+    <el-tabs v-model="activeName" class="device-tabs" @tab-click="handleTabClick">
+      <el-tab-pane :label="t('deviceInfo.basicInformation')" name="info">
+        <div class="device-record">
+          <section class="record-section">
+            <div class="record-section__header">
+              <span class="section-icon section-icon--blue">
+                <Icon icon="ep:office-building" />
+              </span>
+              <div>
+                <h3>制造档案</h3>
+                <p>设备生产、供应及质保信息</p>
+              </div>
+            </div>
+            <div class="record-grid">
+              <div class="record-item">
+                <span>{{ t('deviceInfo.mfg') }}</span>
+                <strong>{{ formData.zzName || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.pd') }}</span>
+                <strong>
+                  {{ formData.manDate ? formatDate(formData.manDate, 'YYYY-MM-DD') : '-' }}
+                </strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.supplier') }}</span>
+                <strong>{{ formData.supplierName || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.warranty') }}</span>
+                <strong>
+                  {{ formData.expires ? formatDate(formData.expires, 'YYYY-MM-DD') : '-' }}
+                </strong>
+              </div>
+              <div class="record-item record-item--wide">
+                <span>{{ t('deviceInfo.ni') }}</span>
+                <strong>{{ formData.nameplate || '-' }}</strong>
+              </div>
+            </div>
+          </section>
+
+          <section class="record-section">
+            <div class="record-section__header">
+              <span class="section-icon section-icon--orange">
+                <Icon icon="ep:coin" />
+              </span>
+              <div>
+                <h3>资产折旧</h3>
+                <p>采购成本及折旧执行信息</p>
+              </div>
+            </div>
+            <div class="record-grid">
+              <div class="record-item">
+                <span>{{ t('deviceInfo.pp') }}</span>
+                <strong>{{ formData.plPrice || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.pdate') }}</span>
+                <strong>
+                  {{ formData.plDate ? formatDate(formData.plDate, 'YYYY-MM-DD') : '-' }}
+                </strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.dp') }}</span>
+                <strong>{{ formData.plYear || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.ds') }}</span>
+                <strong>
+                  {{ formData.plStartDate ? formatDate(formData.plStartDate, 'YYYY-MM-DD') : '-' }}
+                </strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.yd') }}</span>
+                <strong>{{ formData.plMonthed || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.yy') }}</span>
+                <strong>{{ formData.plAmounted || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.sy') }}</span>
+                <strong>{{ formData.remainAmount || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.my') }}</span>
+                <strong>{{ formData.monthAmount || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.mmy') }}</span>
+                <strong>{{ formData.totalMonth || '-' }}</strong>
+              </div>
+              <div class="record-item">
+                <span>{{ t('deviceInfo.currency') }}</span>
+                <strong>{{ formData.currency || '-' }}</strong>
+              </div>
+            </div>
+          </section>
+
+          <section v-if="list.length" class="record-section">
+            <div class="record-section__header">
+              <span class="section-icon section-icon--green">
+                <Icon icon="ep:grid" />
+              </span>
+              <div>
+                <h3>扩展属性</h3>
+                <p>设备分类模板补充信息</p>
+              </div>
+            </div>
+            <div class="record-grid">
+              <div v-for="field in list" :key="field.sort" class="record-item">
+                <span>{{ field.name }}</span>
+                <strong>{{ field.value || '-' }}</strong>
+              </div>
+            </div>
+          </section>
+        </div>
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.fileLibrary')" name="file">
+        <!--        <DeviceUpload ref="fileRef" v-if="loadedTabs.includes('1')" />-->
+        <DeviceFile
+          ref="fileRef"
+          :deviceId="id"
+          :deviceName="formData.deviceName"
+          v-if="loadedTabs.includes('1')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.deviceBOM')" name="bom">
+        <BomList
+          ref="bomRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          :deviceCategoryName="formData.assetClassName"
+          v-if="loadedTabs.includes('2')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.operationRecords')" name="record">
+        <RecordList
+          ref="recordRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('3')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.faultRecords')" name="failure">
+        <FailureList
+          ref="failureRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('4')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.repairRecords')" name="maintain">
+        <MaintainList
+          ref="maintainRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('5')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.maintenanceRecords')" name="maintenance">
+        <MaintenanceList
+          ref="maintenanceRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('6')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.inspectionRecords')" name="inspect">
+        <InspectList
+          ref="inspectRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('7')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.transferRecords')" name="allot">
+        <AllotLogList
+          ref="allotRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('8')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.statusChangeRecords')" name="status">
+        <DeviceStatusLogList
+          ref="statusRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('9')" />
+      </el-tab-pane>
+      <el-tab-pane :label="t('deviceInfo.RPAdjustmentRecords')" name="person">
+        <PersonList
+          ref="personRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('10')" />
+      </el-tab-pane>
+
+      <!-- 关联设备 -->
+      <el-tab-pane :label="t('deviceInfo.associationDevice')" name="association">
+        <AssociationDevices
+          ref="personRef"
+          v-model:activeName="activeName"
+          :deviceId="id"
+          v-if="loadedTabs.includes('11')" />
+      </el-tab-pane>
+    </el-tabs>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import { formatDate } from '../../../utils/formatTime'
+import DeviceUpload from '@/views/pms/device/DeviceUpload.vue'
+import BomInfo from '@/views/pms/device/bom/BomInfo.vue'
+import DeviceFile from '@/views/pms/device/DeviceFile.vue'
+import BomList from '@/views/pms/device/bom/BomList.vue'
+import FailureList from '@/views/pms/device/FailureList.vue'
+import MaintainList from '@/views/pms/device/MaintainList.vue'
+import InspectList from '@/views/pms/device/InspectList.vue'
+import MaintenanceList from '@/views/pms/device/maintenance/MaintenanceList.vue'
+import AllotLogList from '@/views/pms/device/allotlog/AllotLogList.vue'
+import DeviceStatusLogList from '@/views/pms/device/statuslog/DeviceStatusLogList.vue'
+import PersonList from '@/views/pms/device/personlog/PersonList.vue'
+import RecordList from '@/views/pms/device/record/RecordList.vue'
+import AssociationDevices from '@/views/pms/device/completeSet/AssociationDevices.vue'
+import { createImageViewer } from '@/components/ImageViewer'
+import { ref, onMounted } from 'vue'
+import { getAccessToken } from '@/utils/auth'
+
+const defaultPicUrl = ref(
+  import.meta.env.VITE_BASE_URL + '/admin-api/infra/file/29/get/IntegratedSolution.png'
+) // 默认设备图片
+
+defineOptions({ name: 'DeviceDetailInfo' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const { params } = useRoute() // 查询参数
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const activeName = ref('info') // Tag 激活的窗口
+const list = ref([])
+const id = params.id
+const fileRef = ref() // 搜索的表单
+// SPU 表单数据
+const formData = ref({
+  id: undefined,
+  code: undefined,
+  name: undefined,
+  classification: undefined,
+  type: undefined,
+  nature: undefined,
+  creditCode: undefined,
+  tin: undefined,
+  corporation: undefined,
+  incorporationDate: undefined,
+  address: undefined,
+  bizScope: undefined,
+  registeredCapital: undefined,
+  annualTurnove: undefined,
+  size: undefined,
+  status: undefined,
+  remark: undefined,
+  deptName: undefined,
+  monthAmount: undefined,
+  totalMonth: undefined,
+  currency: undefined
+})
+const pics = ref([])
+const imgSrc = ref('')
+const loadedTabs = ref(['info']) // 记录已加载的标签
+
+/** 获得详情 */
+const getDetail = async () => {
+  if (id) {
+    formLoading.value = true
+    try {
+      const res = (await IotDeviceApi.getIotDevice(id)) as IotDeviceVO
+      formData.value = res
+      pics.value.push(res.picUrl)
+      if (res) {
+        if (res.templateJson) {
+          list.value = JSON.parse(res.templateJson)
+        }
+      }
+      if (formData.value.picUrl) {
+        defaultPicUrl.value = formData.value.picUrl
+      }
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+
+const imagePreview = (imgUrl: string) => {
+  const token = getAccessToken()
+  createImageViewer({
+    urlList: [imgUrl],
+    headers: {
+      Authorization: `Bearer ${token}`
+    }
+  })
+}
+
+const handleTabClick = (tab) => {
+  if (!loadedTabs.value.includes(tab.index)) {
+    // 这里可以添加每个标签对应的加载逻辑,如果有的话
+    loadedTabs.value.push(tab.index)
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await getDetail()
+})
+</script>
+<style scoped lang="scss">
+.device-overview-wrap,
+.device-detail-wrap {
+  overflow: hidden;
+  border: 0;
+  border-radius: 12px;
+  box-shadow: 0 6px 20px rgb(31 45 61 / 6%);
+}
+
+.device-overview {
+  display: flex;
+  min-height: 248px;
+  padding: 24px;
+  background: radial-gradient(circle at 0 0, rgb(64 158 255 / 13%), transparent 32%),
+    linear-gradient(135deg, #f5f9ff 0%, var(--el-bg-color) 46%, var(--el-bg-color) 100%);
+}
+
+.device-image-panel {
+  display: flex;
+  width: 286px;
+  min-width: 286px;
+  align-items: center;
+  justify-content: center;
+}
+
+.device-image-frame {
+  position: relative;
+  width: 252px;
+  height: 194px;
+  padding: 8px;
+  overflow: hidden;
+  cursor: zoom-in;
+  background: var(--el-bg-color);
+  border: 1px solid rgb(64 158 255 / 14%);
+  border-radius: 14px;
+  box-shadow: 0 12px 30px rgb(39 83 135 / 15%);
+
+  &:hover {
+    .device-image {
+      transform: scale(1.035);
+    }
+
+    .image-preview-mask {
+      opacity: 1;
+    }
+  }
+}
+
+.device-image {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  border-radius: 9px;
+  transition: transform 0.3s ease;
+}
+
+.image-preview-mask {
+  position: absolute;
+  right: 16px;
+  bottom: 16px;
+  display: flex;
+  gap: 6px;
+  padding: 6px 11px;
+  font-size: 12px;
+  color: #fff;
+  pointer-events: none;
+  background: rgb(20 29 43 / 72%);
+  border: 1px solid rgb(255 255 255 / 22%);
+  border-radius: 14px;
+  opacity: 0.88;
+  backdrop-filter: blur(6px);
+  align-items: center;
+  transition: opacity 0.2s ease;
+}
+
+.device-summary {
+  flex: 1;
+  min-width: 0;
+  padding: 4px 10px 0 30px;
+}
+
+.device-heading {
+  display: flex;
+  min-height: 70px;
+  padding-bottom: 18px;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  align-items: flex-start;
+  justify-content: space-between;
+}
+
+.device-title-row {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  :deep(.el-tag) {
+    flex: none;
+    padding: 0 12px;
+    font-weight: 600;
+  }
+}
+
+.device-title {
+  max-width: 760px;
+  margin: 0;
+  overflow: hidden;
+  font-size: 24px;
+  font-weight: 700;
+  line-height: 34px;
+  color: var(--el-text-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.device-code {
+  display: flex;
+  gap: 6px;
+  margin-top: 8px;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+  align-items: center;
+}
+
+.asset-badge {
+  display: flex;
+  gap: 7px;
+  padding: 8px 12px;
+  margin-top: 2px;
+  font-size: 13px;
+  color: #80631b;
+  background: #fff8e7;
+  border: 1px solid #f7e6b4;
+  border-radius: 8px;
+  align-items: center;
+}
+
+.summary-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(150px, 1fr));
+  gap: 0 26px;
+  padding-top: 13px;
+}
+
+.summary-item {
+  display: flex;
+  min-width: 0;
+  min-height: 58px;
+  padding: 10px 0;
+  border-bottom: 1px dashed var(--el-border-color-lighter);
+  flex-direction: column;
+  justify-content: center;
+
+  strong {
+    margin-top: 5px;
+    overflow: hidden;
+    font-size: 14px;
+    font-weight: 600;
+    line-height: 20px;
+    color: var(--el-text-color-primary);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.summary-item__label {
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--el-text-color-secondary);
+}
+
+.device-tabs {
+  :deep(.el-tabs__header) {
+    padding: 0 20px;
+    margin: 0 -18px 18px;
+    background: var(--el-bg-color);
+  }
+
+  :deep(.el-tabs__nav-wrap::after) {
+    height: 1px;
+    background-color: var(--el-border-color-lighter);
+  }
+
+  :deep(.el-tabs__item) {
+    height: 54px;
+    padding: 0 17px;
+    font-weight: 500;
+    color: var(--el-text-color-regular);
+  }
+
+  :deep(.el-tabs__item.is-active) {
+    font-weight: 600;
+    color: var(--el-color-primary);
+  }
+
+  :deep(.el-tabs__active-bar) {
+    height: 3px;
+    border-radius: 3px 3px 0 0;
+  }
+}
+
+.device-record {
+  display: grid;
+  gap: 18px;
+}
+
+.record-section {
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 10px;
+}
+
+.record-section__header {
+  display: flex;
+  gap: 12px;
+  min-height: 68px;
+  padding: 14px 20px;
+  background: linear-gradient(90deg, #f7faff 0%, var(--el-bg-color) 70%);
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 15px;
+    font-weight: 700;
+    line-height: 22px;
+    color: var(--el-text-color-primary);
+  }
+
+  p {
+    margin: 2px 0 0;
+    font-size: 12px;
+    line-height: 18px;
+    color: var(--el-text-color-secondary);
+  }
+}
+
+.section-icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  border-radius: 9px;
+  align-items: center;
+  justify-content: center;
+}
+
+.section-icon--blue {
+  color: #3478f6;
+  background: #eaf2ff;
+}
+
+.section-icon--orange {
+  color: #e78324;
+  background: #fff2e3;
+}
+
+.section-icon--green {
+  color: #20a36a;
+  background: #e7f8f0;
+}
+
+.record-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.record-item {
+  display: flex;
+  min-width: 0;
+  min-height: 76px;
+  padding: 15px 20px;
+  border-right: 1px solid var(--el-border-color-lighter);
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  flex-direction: column;
+  justify-content: center;
+
+  &:nth-child(4n) {
+    border-right: 0;
+  }
+
+  span {
+    font-size: 12px;
+    line-height: 18px;
+    color: var(--el-text-color-secondary);
+  }
+
+  strong {
+    margin-top: 6px;
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 22px;
+    color: var(--el-text-color-primary);
+    word-break: break-word;
+  }
+}
+
+.record-item--wide {
+  grid-column: span 2;
+}
+
+@media (width <= 1399px) {
+  .device-image-panel {
+    width: 250px;
+    min-width: 250px;
+  }
+
+  .device-image-frame {
+    width: 224px;
+    height: 180px;
+  }
+
+  .device-summary {
+    padding-left: 22px;
+  }
+
+  .record-grid {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+
+  .record-item {
+    &:nth-child(4n) {
+      border-right: 1px solid var(--el-border-color-lighter);
+    }
+
+    &:nth-child(3n) {
+      border-right: 0;
+    }
+  }
+}
+
+@media (width <= 991px) {
+  .device-overview {
+    flex-direction: column;
+  }
+
+  .device-image-panel {
+    width: 100%;
+    min-width: 0;
+  }
+
+  .device-summary {
+    padding: 24px 0 0;
+  }
+
+  .summary-grid {
+    grid-template-columns: repeat(2, minmax(140px, 1fr));
+  }
+
+  .record-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .record-item {
+    &:nth-child(3n) {
+      border-right: 1px solid var(--el-border-color-lighter);
+    }
+
+    &:nth-child(2n) {
+      border-right: 0;
+    }
+  }
+}
+
+@media (width <= 575px) {
+  .device-overview {
+    padding: 18px;
+  }
+
+  .device-heading {
+    gap: 12px;
+    flex-direction: column;
+  }
+
+  .device-title {
+    font-size: 20px;
+  }
+
+  .summary-grid,
+  .record-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .summary-item {
+    min-height: 54px;
+  }
+
+  .record-item,
+  .record-item--wide {
+    grid-column: span 1;
+    border-right: 0;
+
+    &:nth-child(2n),
+    &:nth-child(3n),
+    &:nth-child(4n) {
+      border-right: 0;
+    }
+  }
+
+  .record-section__header {
+    padding: 13px 16px;
+  }
+
+  .record-item {
+    min-height: 68px;
+    padding: 12px 16px;
+  }
+}
+</style>

+ 388 - 0
src/views/pms/device/DeviceInspectRecord.vue

@@ -0,0 +1,388 @@
+<template>
+  <div class="device-inspect-record">
+    <section class="library-workbench">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        inline
+        size="default"
+        class="library-workbench__header">
+        <div class="library-header-main">
+          <div class="workbench-title">
+            <span class="workbench-title__icon">
+              <Icon icon="ep:checked" />
+            </span>
+            <div>
+              <h3>巡检记录</h3>
+            </div>
+          </div>
+
+          <el-form-item label="工单名称" prop="inspectOrderTitle">
+            <el-input
+              v-model="queryParams.inspectOrderTitle"
+              class="library-search-input"
+              clearable
+              placeholder="请输入工单名称"
+              @keyup.enter="handleQuery">
+              <template #prefix>
+                <Icon icon="ep:search" />
+              </template>
+            </el-input>
+          </el-form-item>
+          <el-form-item label="工单编码" prop="inspectOrderCode">
+            <el-input
+              v-model="queryParams.inspectOrderCode"
+              class="library-search-input"
+              clearable
+              placeholder="请输入工单编码"
+              @keyup.enter="handleQuery">
+              <template #prefix>
+                <Icon icon="ep:search" />
+              </template>
+            </el-input>
+          </el-form-item>
+          <el-form-item label="状态" prop="status">
+            <el-select
+              v-model="queryParams.status"
+              class="library-search-input"
+              clearable
+              placeholder="请选择状态">
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.PMS_INSPECT_ORDER_STATUS)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="创建时间" prop="createTime">
+            <el-date-picker
+              v-model="queryParams.createTime"
+              class="library-date-range"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              type="daterange"
+              start-placeholder="开始时间"
+              end-placeholder="结束时间"
+              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" />
+          </el-form-item>
+        </div>
+
+        <el-form-item class="library-actions">
+          <el-button type="primary" @click="handleQuery">
+            <Icon icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn label="序号" width="70" align="center">
+                <template #default="{ $index }">
+                  {{ (queryParams.pageNo - 1) * queryParams.pageSize + $index + 1 }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                label="工单名称"
+                prop="inspectOrderTitle"
+                min-width="220"
+                align="left"
+                fixed="left">
+                <template #default="{ row }">
+                  <span class="inspect-title">{{ row.inspectOrderTitle || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="工单编码" prop="inspectOrderCode" min-width="160">
+                <template #default="{ row }">
+                  {{ row.inspectOrderCode || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="类型" prop="type" min-width="120">
+                <template #default="{ row }">
+                  {{ row.type || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="状态" prop="status" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_INSPECT_ORDER_STATUS" :value="row.status" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="负责人" prop="chargeName" min-width="150">
+                <template #default="{ row }">
+                  {{ row.chargeName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="备注" prop="remark" min-width="180">
+                <template #default="{ row }">
+                  {{ row.remark || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="创建时间" prop="createTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.createTime) }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="操作" width="120" fixed="right" action>
+                <template #default="{ row }">
+                  <el-button
+                    link
+                    type="primary"
+                    class="table-action-btn"
+                    @click="openDetail(row.id)">
+                    <Icon icon="ep:view" />
+                    查看
+                  </el-button>
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { IotInspectOrderApi, IotInspectOrderVO } from '@/api/pms/inspect/order'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'DeviceInspectRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+type InspectRecordRow = IotInspectOrderVO & {
+  chargeName?: string
+  createTime?: Date | string | number
+  type?: string | number
+}
+
+const { push } = useRouter()
+const { ZmTable, ZmTableColumn } = useTableComponents<InspectRecordRow>()
+const loading = ref(false)
+const list = ref<InspectRecordRow[]>([])
+const total = ref(0)
+const queryFormRef = ref()
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  inspectOrderTitle: undefined as string | undefined,
+  inspectOrderCode: undefined as string | undefined,
+  status: undefined as string | undefined,
+  createTime: [] as string[],
+  deviceIds: undefined as number | undefined
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceIds = props.deviceId
+  try {
+    const data = await IotInspectOrderApi.getDeviceIotInspectOrderPage(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+const openDetail = (id?: number) => {
+  push({ name: 'InspectOrderDetail', params: { id } })
+}
+
+const formatRecordTime = (value?: Date | string | number) => {
+  return value ? formatDate(value as Date) : '-'
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-inspect-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+
+  :deep(.el-form-item) {
+    margin-right: 10px;
+    margin-bottom: 0;
+  }
+
+  :deep(.el-form-item:last-child) {
+    margin-right: 0;
+  }
+
+  :deep(.el-button .app-iconify) {
+    margin-right: 4px;
+  }
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-search-input {
+  width: 220px;
+}
+
+.library-date-range {
+  width: 300px;
+}
+
+.library-actions {
+  flex: none;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.inspect-title {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.table-action-btn {
+  gap: 4px;
+
+  :deep(.el-icon),
+  :deep(.app-iconify) {
+    margin-right: 2px;
+  }
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-header-main {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-search-input {
+    width: 100%;
+  }
+}
+</style>

+ 471 - 0
src/views/pms/device/DeviceMaintainRecord.vue

@@ -0,0 +1,471 @@
+<template>
+  <div class="device-maintain-record">
+    <section class="library-workbench">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        inline
+        size="default"
+        class="library-search-form">
+        <div class="library-workbench__header">
+          <div class="library-header-main">
+            <div class="workbench-title">
+              <span class="workbench-title__icon">
+                <Icon icon="ep:tools" />
+              </span>
+              <div>
+                <h3>维修记录</h3>
+              </div>
+            </div>
+
+            <el-form-item label="故障编码" prop="failureCode">
+              <el-input
+                v-model="queryParams.failureCode"
+                class="library-search-input"
+                clearable
+                placeholder="请输入故障编码"
+                @keyup.enter="handleQuery">
+                <template #prefix>
+                  <Icon icon="ep:search" />
+                </template>
+              </el-input>
+            </el-form-item>
+            <el-form-item label="故障名称" prop="failureName">
+              <el-input
+                v-model="queryParams.failureName"
+                class="library-search-input"
+                clearable
+                placeholder="请输入故障名称"
+                @keyup.enter="handleQuery">
+                <template #prefix>
+                  <Icon icon="ep:search" />
+                </template>
+              </el-input>
+            </el-form-item>
+            <el-form-item label="状态" prop="status">
+              <el-select
+                v-model="queryParams.status"
+                class="library-search-input"
+                clearable
+                placeholder="请选择状态">
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.PMS_MAIN_STATUS)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </div>
+
+          <el-form-item class="library-actions">
+            <el-button
+              :type="showAdvancedSearch ? 'warning' : 'primary'"
+              plain
+              @click="toggleAdvancedSearch">
+              <Icon :icon="showAdvancedSearch ? 'ep:arrow-up' : 'ep:arrow-down'" />
+              {{ showAdvancedSearch ? '收起查询' : '更多查询' }}
+            </el-button>
+            <el-button type="primary" @click="handleQuery">
+              <Icon icon="ep:search" />
+              搜索
+            </el-button>
+            <el-button @click="resetQuery">
+              <Icon icon="ep:refresh" />
+              重置
+            </el-button>
+          </el-form-item>
+        </div>
+
+        <el-collapse-transition>
+          <div v-show="showAdvancedSearch" class="advanced-search-panel">
+            <el-form-item label="是否停机" prop="ifStop">
+              <el-select
+                v-model="queryParams.ifStop"
+                class="library-search-input"
+                clearable
+                placeholder="请选择">
+                <el-option
+                  v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="故障时间" prop="failureTime">
+              <el-date-picker
+                v-model="queryParams.failureTime"
+                class="library-date-range"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                type="daterange"
+                start-placeholder="开始时间"
+                end-placeholder="结束时间"
+                :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" />
+            </el-form-item>
+            <el-form-item label="维修开始" prop="maintainStartTime">
+              <el-date-picker
+                v-model="queryParams.maintainStartTime"
+                class="library-date-range"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                type="daterange"
+                start-placeholder="开始时间"
+                end-placeholder="结束时间"
+                :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" />
+            </el-form-item>
+            <el-form-item label="维修结束" prop="maintainEndTime">
+              <el-date-picker
+                v-model="queryParams.maintainEndTime"
+                class="library-date-range"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                type="daterange"
+                start-placeholder="开始时间"
+                end-placeholder="结束时间"
+                :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" />
+            </el-form-item>
+            <el-form-item label="创建时间" prop="createTime">
+              <el-date-picker
+                v-model="queryParams.createTime"
+                class="library-date-range"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                type="daterange"
+                start-placeholder="开始时间"
+                end-placeholder="结束时间"
+                :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" />
+            </el-form-item>
+            <el-form-item label="故障系统" prop="failureSystem">
+              <el-input
+                v-model="queryParams.failureSystem"
+                class="library-search-input"
+                clearable
+                placeholder="请输入故障系统"
+                @keyup.enter="handleQuery" />
+            </el-form-item>
+            <el-form-item label="故障影响" prop="failureInfluence">
+              <el-input
+                v-model="queryParams.failureInfluence"
+                class="library-search-input"
+                clearable
+                placeholder="请输入故障影响"
+                @keyup.enter="handleQuery" />
+            </el-form-item>
+            <el-form-item label="描述" prop="description">
+              <el-input
+                v-model="queryParams.description"
+                class="library-search-input"
+                clearable
+                placeholder="请输入描述"
+                @keyup.enter="handleQuery" />
+            </el-form-item>
+          </div>
+        </el-collapse-transition>
+      </el-form>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn
+                label="故障编码"
+                prop="failureCode"
+                min-width="150"
+                align="left"
+                fixed="left">
+                <template #default="{ row }">
+                  <span class="maintain-code">{{ row.failureCode || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="故障名称" prop="failureName" min-width="180" align="left">
+                <template #default="{ row }">
+                  {{ row.failureName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="设备" prop="deviceName" min-width="160">
+                <template #default="{ row }">
+                  {{ row.deviceName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="状态" prop="status" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_MAIN_STATUS" :value="row.status" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="审核状态" prop="auditStatus" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="row.auditStatus" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="是否停机" prop="ifStop" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.ifStop" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="维修开始时间" prop="maintainStartTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.maintainStartTime) }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="故障时间" prop="failureTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.failureTime) }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="创建时间" prop="createTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.createTime) }}
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { IotMaintainApi, IotMaintainVO } from '@/api/pms/maintain'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DICT_TYPE, getBoolDictOptions, getStrDictOptions } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'DeviceMaintainRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+type MaintainRecordRow = IotMaintainVO & {
+  createTime?: Date | string | number
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents<MaintainRecordRow>()
+const loading = ref(false)
+const list = ref<MaintainRecordRow[]>([])
+const total = ref(0)
+const queryFormRef = ref()
+const showAdvancedSearch = ref(false)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  failureCode: undefined as string | undefined,
+  failureName: undefined as string | undefined,
+  deviceId: undefined as number | undefined,
+  status: undefined as string | undefined,
+  ifStop: undefined as string | boolean | undefined,
+  failureTime: [] as string[],
+  failureInfluence: undefined as string | undefined,
+  failureSystem: undefined as string | undefined,
+  description: undefined as string | undefined,
+  maintainStartTime: [] as string[],
+  maintainEndTime: [] as string[],
+  createTime: [] as string[]
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await IotMaintainApi.getIotMaintainPage(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+const toggleAdvancedSearch = () => {
+  showAdvancedSearch.value = !showAdvancedSearch.value
+}
+
+const formatRecordTime = (value?: Date | string | number) => {
+  return value ? formatDate(value as Date) : '-'
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-maintain-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-search-form {
+  :deep(.el-form-item) {
+    margin-right: 10px;
+    margin-bottom: 0;
+  }
+
+  :deep(.el-form-item:last-child) {
+    margin-right: 0;
+  }
+
+  :deep(.el-button .app-iconify) {
+    margin-right: 4px;
+  }
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-search-input {
+  width: 220px;
+}
+
+.library-date-range {
+  width: 300px;
+}
+
+.library-actions {
+  flex: none;
+}
+
+.advanced-search-panel {
+  display: flex;
+  padding: 12px 14px;
+  margin-bottom: 14px;
+  background: rgb(255 255 255 / 68%);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  gap: 12px 4px;
+  flex-wrap: wrap;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.maintain-code {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-header-main {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-search-input {
+    width: 100%;
+  }
+}
+</style>

+ 381 - 0
src/views/pms/device/DeviceMaintenanceRecord.vue

@@ -0,0 +1,381 @@
+<template>
+  <div class="device-maintenance-record">
+    <section class="library-workbench">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        inline
+        size="default"
+        class="library-workbench__header">
+        <div class="library-header-main">
+          <div class="workbench-title">
+            <span class="workbench-title__icon">
+              <Icon icon="ep:calendar" />
+            </span>
+            <div>
+              <h3>保养记录</h3>
+            </div>
+          </div>
+
+          <el-form-item label="工单名称" prop="name">
+            <el-input
+              v-model="queryParams.name"
+              class="library-search-input"
+              clearable
+              placeholder="请输入工单名称"
+              @keyup.enter="handleQuery">
+              <template #prefix>
+                <Icon icon="ep:search" />
+              </template>
+            </el-input>
+          </el-form-item>
+          <el-form-item label="状态" prop="result">
+            <el-select
+              v-model="queryParams.result"
+              class="library-search-input"
+              clearable
+              placeholder="请选择状态">
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.PMS_MAIN_WORK_ORDER_RESULT)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="创建时间" prop="createTime">
+            <el-date-picker
+              v-model="queryParams.createTime"
+              class="library-date-range"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              type="daterange"
+              start-placeholder="开始时间"
+              end-placeholder="结束时间"
+              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" />
+          </el-form-item>
+        </div>
+
+        <el-form-item class="library-actions">
+          <el-button type="primary" @click="handleQuery">
+            <Icon icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn label="序号" width="70" align="center">
+                <template #default="{ $index }">
+                  {{ (queryParams.pageNo - 1) * queryParams.pageSize + $index + 1 }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="工单名称" prop="name" min-width="220" align="left" fixed="left">
+                <template #default="{ row }">
+                  <span class="maintenance-name">{{ row.name || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="状态" prop="result" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_MAIN_WORK_ORDER_RESULT" :value="row.result" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="保养周期" prop="maintenanceInterval" min-width="120">
+                <template #default="{ row }">
+                  {{ row.maintenanceInterval || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="工单类型" prop="type" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_MAIN_WORK_ORDER_TYPE" :value="row.type" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="负责人" prop="responsiblePersonName" min-width="150">
+                <template #default="{ row }">
+                  {{ row.responsiblePersonName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="开始时间" prop="actualStartTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.actualStartTime) }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="结束时间" prop="actualEndTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.actualEndTime) }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="创建时间" prop="createTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.createTime) }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="操作" width="120" fixed="right" action>
+                <template #default="{ row }">
+                  <el-button
+                    v-hasPermi="['pms:iot-main-work-order:query']"
+                    link
+                    type="primary"
+                    class="table-action-btn"
+                    @click="openDetail(row.id)">
+                    <Icon icon="ep:view" />
+                    查看
+                  </el-button>
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { IotMainWorkOrderApi, IotMainWorkOrderVO } from '@/api/pms/iotmainworkorder'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'DeviceMaintenanceRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+type MaintenanceRecordRow = IotMainWorkOrderVO & {
+  createTime?: Date | string | number
+  maintenanceInterval?: string | number
+}
+
+const { push } = useRouter()
+const { ZmTable, ZmTableColumn } = useTableComponents<MaintenanceRecordRow>()
+const loading = ref(false)
+const list = ref<MaintenanceRecordRow[]>([])
+const total = ref(0)
+const queryFormRef = ref()
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined as string | undefined,
+  result: undefined as string | number | undefined,
+  createTime: [] as string[],
+  deviceId: undefined as number | undefined
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await IotMainWorkOrderApi.getDeviceIotMainWorkOrderPage(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+const openDetail = (orderId?: number) => {
+  push({
+    name: 'IotDeviceMainWorkOrderDetail',
+    params: {
+      deviceId: props.deviceId,
+      orderId
+    }
+  })
+}
+
+const formatRecordTime = (value?: Date | string | number) => {
+  return value ? formatDate(value as Date) : '-'
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-maintenance-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+
+  :deep(.el-form-item) {
+    margin-right: 10px;
+    margin-bottom: 0;
+  }
+
+  :deep(.el-form-item:last-child) {
+    margin-right: 0;
+  }
+
+  :deep(.el-button .app-iconify) {
+    margin-right: 4px;
+  }
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-search-input {
+  width: 220px;
+}
+
+.library-date-range {
+  width: 300px;
+}
+
+.library-actions {
+  flex: none;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.maintenance-name {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.table-action-btn {
+  gap: 4px;
+
+  :deep(.el-icon),
+  :deep(.app-iconify) {
+    margin-right: 2px;
+  }
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-header-main {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-search-input {
+    width: 100%;
+  }
+}
+</style>

+ 349 - 0
src/views/pms/device/DeviceOperationRecord.vue

@@ -0,0 +1,349 @@
+<template>
+  <div class="device-operation-record">
+    <section class="library-workbench">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        inline
+        size="default"
+        class="library-workbench__header">
+        <div class="library-header-main">
+          <div class="workbench-title">
+            <span class="workbench-title__icon">
+              <Icon icon="ep:document-checked" />
+            </span>
+            <div>
+              <h3>运行记录</h3>
+            </div>
+          </div>
+
+          <el-form-item label="运行名称" prop="orderName">
+            <el-input
+              v-model="queryParams.orderName"
+              class="library-search-input"
+              clearable
+              placeholder="请输入运行名称"
+              @keyup.enter="handleQuery">
+              <template #prefix>
+                <Icon icon="ep:search" />
+              </template>
+            </el-input>
+          </el-form-item>
+          <el-form-item label="状态" prop="orderStatus">
+            <el-select
+              v-model="queryParams.orderStatus"
+              class="library-search-input"
+              clearable
+              placeholder="请选择状态">
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.OPERATION_FILL_ORDER_STATUS)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value" />
+            </el-select>
+          </el-form-item>
+        </div>
+
+        <el-form-item class="library-actions">
+          <el-button type="primary" @click="handleQuery">
+            <Icon icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn label="序号" width="70" align="center">
+                <template #default="{ $index }">
+                  {{ (queryParams.pageNo - 1) * queryParams.pageSize + $index + 1 }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                label="运行名称"
+                prop="orderName"
+                min-width="260"
+                align="left"
+                fixed="left">
+                <template #default="{ row }">
+                  <span class="record-name">{{ row.orderName || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="负责人" prop="userName" min-width="140">
+                <template #default="{ row }">
+                  {{ row.userName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="状态" prop="orderStatus" min-width="120">
+                <template #default="{ row }">
+                  <dict-tag
+                    :type="DICT_TYPE.OPERATION_FILL_ORDER_STATUS"
+                    :value="row.orderStatus" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="操作" width="120" fixed="right" action>
+                <template #default="{ row }">
+                  <el-button
+                    link
+                    type="primary"
+                    class="table-action-btn"
+                    @click="openWrite(row.id, row.orderStatus, row.deptId, row.createTime)">
+                    <Icon icon="ep:view" />
+                    查看
+                  </el-button>
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { IotOpeationFillApi, IotOpeationFillVO } from '@/api/pms/iotopeationfill'
+
+defineOptions({ name: 'DeviceOperationRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+type OperationRecordRow = IotOpeationFillVO & {
+  userName?: string
+}
+
+const { push } = useRouter()
+const { ZmTable, ZmTableColumn } = useTableComponents<OperationRecordRow>()
+const loading = ref(false)
+const list = ref<OperationRecordRow[]>([])
+const total = ref(0)
+const queryFormRef = ref()
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  orderName: undefined as string | undefined,
+  orderStatus: undefined as string | number | undefined,
+  deviceId: undefined as number | undefined
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await IotOpeationFillApi.getFillRecordsPage(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+const openWrite = (
+  id?: string | number,
+  status?: string | number,
+  deptid?: number,
+  createtime?: number | string
+) => {
+  push({
+    name: 'FillOrderInfoDevice',
+    params: {
+      id,
+      deviceid: props.deviceId,
+      status,
+      deptid,
+      createtime
+    }
+  })
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-operation-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+
+  :deep(.el-form-item) {
+    margin-right: 10px;
+    margin-bottom: 0;
+  }
+
+  :deep(.el-form-item:last-child) {
+    margin-right: 0;
+  }
+
+  :deep(.el-button .app-iconify) {
+    margin-right: 4px;
+  }
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-search-input {
+  width: 220px;
+}
+
+.library-actions {
+  flex: none;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.record-name {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.table-action-btn {
+  gap: 4px;
+
+  :deep(.el-icon),
+  :deep(.app-iconify) {
+    margin-right: 2px;
+  }
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-header-main {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-search-input {
+    width: 100%;
+  }
+}
+</style>

+ 246 - 0
src/views/pms/device/DevicePersonRecord.vue

@@ -0,0 +1,246 @@
+<template>
+  <div class="device-person-record">
+    <section class="library-workbench">
+      <div class="library-workbench__header">
+        <div class="library-header-main">
+          <div class="workbench-title">
+            <span class="workbench-title__icon">
+              <Icon icon="ep:user" />
+            </span>
+            <div>
+              <h3>责任人调整记录</h3>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn
+                label="设备名称"
+                prop="deviceName"
+                min-width="180"
+                align="left"
+                fixed="left">
+                <template #default="{ row }">
+                  <span class="device-name">{{ row.deviceName || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="设备编码" prop="deviceCode" min-width="150">
+                <template #default="{ row }">
+                  {{ row.deviceCode || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整前责任人" prop="oldPersonNames" min-width="180">
+                <template #default="{ row }">
+                  {{ row.oldPersonNames || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整后责任人" prop="newPersonNames" min-width="180">
+                <template #default="{ row }">
+                  {{ row.newPersonNames || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整原因" prop="reason" min-width="220" align="left">
+                <template #default="{ row }">
+                  {{ row.reason || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整人" prop="creatorName" min-width="140">
+                <template #default="{ row }">
+                  {{ row.creatorName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整时间" prop="createTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.createTime) }}
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { IotDevicePersonLogApi, IotDevicePersonLogVO } from '@/api/pms/iotdevicepersonlog'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'DevicePersonRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+type PersonRecordRow = IotDevicePersonLogVO & {
+  createTime?: Date | string | number
+  creatorName?: string
+  deviceCode?: string
+  deviceName?: string
+  newPersonNames?: string
+  oldPersonNames?: string
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents<PersonRecordRow>()
+const loading = ref(false)
+const list = ref<PersonRecordRow[]>([])
+const total = ref(0)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceId: undefined as number | undefined
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await IotDevicePersonLogApi.getIotDevicePersonLogPage(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const formatRecordTime = (value?: Date | string | number) => {
+  return value ? formatDate(value as Date) : '-'
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-person-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.device-name {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+}
+</style>

+ 245 - 0
src/views/pms/device/DeviceStatusRecord.vue

@@ -0,0 +1,245 @@
+<template>
+  <div class="device-status-record">
+    <section class="library-workbench">
+      <div class="library-workbench__header">
+        <div class="library-header-main">
+          <div class="workbench-title">
+            <span class="workbench-title__icon">
+              <Icon icon="ep:refresh" />
+            </span>
+            <div>
+              <h3>状态变更记录</h3>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="library-table-wrap">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              class="library-table"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :column-max-width="420"
+              row-key="id"
+              empty-text="暂无数据"
+              show-border>
+              <ZmTableColumn
+                label="设备名称"
+                prop="deviceName"
+                min-width="180"
+                align="left"
+                fixed="left">
+                <template #default="{ row }">
+                  <span class="device-name">{{ row.deviceName || '-' }}</span>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="设备编码" prop="deviceCode" min-width="150">
+                <template #default="{ row }">
+                  {{ row.deviceCode || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="变更前状态" prop="oldStatus" min-width="140">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="row.oldStatus" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="变更后状态" prop="newStatus" min-width="140">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="row.newStatus" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="变更原因" prop="reason" min-width="220" align="left">
+                <template #default="{ row }">
+                  {{ row.reason || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整人" prop="creatorName" min-width="140">
+                <template #default="{ row }">
+                  {{ row.creatorName || '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn label="调整时间" prop="createTime" min-width="180">
+                <template #default="{ row }">
+                  {{ formatRecordTime(row.createTime) }}
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="library-pagination">
+        <Pagination
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          :total="total"
+          @pagination="getList" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, watch } from 'vue'
+import { IotDeviceStatusLogApi, IotDeviceStatusLogVO } from '@/api/pms/iotdevicestatuslog'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DICT_TYPE } from '@/utils/dict'
+import { formatDate } from '@/utils/formatTime'
+
+defineOptions({ name: 'DeviceStatusRecord' })
+
+const props = defineProps<{
+  deviceId?: number
+}>()
+
+type StatusRecordRow = IotDeviceStatusLogVO & {
+  createTime?: Date | string | number
+  creatorName?: string
+  deviceCode?: string
+  deviceName?: string
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents<StatusRecordRow>()
+const loading = ref(false)
+const list = ref<StatusRecordRow[]>([])
+const total = ref(0)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceId: undefined as number | undefined
+})
+
+const getList = async () => {
+  if (!props.deviceId) return
+
+  loading.value = true
+  queryParams.deviceId = props.deviceId
+  try {
+    const data = await IotDeviceStatusLogApi.getIotDeviceStatusLogPage(queryParams)
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const formatRecordTime = (value?: Date | string | number) => {
+  return value ? formatDate(value as Date) : '-'
+}
+
+watch(
+  () => props.deviceId,
+  () => {
+    getList()
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped lang="scss">
+.device-status-record {
+  display: grid;
+  gap: 14px;
+}
+
+.library-workbench {
+  padding: 14px;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f8fbff 0, var(--el-bg-color) 96px), var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.library-workbench__header {
+  display: flex;
+  gap: 16px;
+  min-height: 56px;
+  margin-bottom: 14px;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.library-header-main {
+  display: flex;
+  gap: 10px;
+  min-width: 0;
+  align-items: center;
+  flex: 1;
+  flex-wrap: wrap;
+}
+
+.workbench-title {
+  display: flex;
+  gap: 12px;
+  min-width: 0;
+  align-items: center;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 700;
+    line-height: 24px;
+    color: var(--el-text-color-primary);
+  }
+}
+
+.workbench-title__icon {
+  display: flex;
+  width: 38px;
+  height: 38px;
+  font-size: 18px;
+  color: #3478f6;
+  background: #eaf2ff;
+  border-radius: 10px;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.library-table-wrap {
+  height: 560px;
+  min-width: 0;
+}
+
+.library-table {
+  --el-table-header-bg-color: var(--el-fill-color-lighter);
+  --el-table-row-hover-bg-color: #f6faff;
+
+  overflow: hidden;
+  border-radius: 8px;
+
+  :deep(.el-table__cell) {
+    padding: 9px 0;
+  }
+}
+
+.device-name {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+  font-weight: 500;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.library-pagination {
+  display: flex;
+  height: 40px;
+  margin-top: 8px;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+@media (width <= 767px) {
+  .library-workbench__header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+}
+</style>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 315 - 690
src/views/pms/device/IotDeviceForm.vue


+ 885 - 0
src/views/pms/device/IotDeviceForm1.vue

@@ -0,0 +1,885 @@
+<template>
+  <ContentWrap v-loading="formLoading">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      style="margin-right: 4em; margin-left: 0.5em"
+      label-width="130px">
+      <div class="title-div">
+        <el-button @click="baseInfoClick" class="title-button">
+          <Icon color="black" icon="ep:set-up" :size="18" class="cursor-pointer first-icon" />
+          <span class="cursor-pointer">{{ t('deviceForm.basic') }}</span>
+          <Icon
+            color="black"
+            :icon="baseIsExpanded ? 'fa-solid:angle-double-down' : 'fa-solid:angle-double-right'"
+            :size="18"
+            class="cursor-pointer" />
+        </el-button>
+      </div>
+      <div class="base-expandable-content" :class="{ 'is-expanded': baseIsExpanded }">
+        <el-row>
+          <el-col :span="8">
+            <el-form-item :label="t('iotDevice.yfClass')" prop="yfClass">
+              <el-cascader
+                :disabled="formType === 'update' && formData.yfDeviceCode"
+                style="width: 100%"
+                v-model="formData.yfClass"
+                :options="yfclasses"
+                :props="{ expandTrigger: 'hover' }"
+                clearable
+                filterable
+                @change="handleYfClassChange" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('iotDevice.yfCode')" prop="yfDeviceCode">
+              <el-input
+                v-model="formData.yfDeviceCode"
+                :disabled="formData.yfDeviceCode"
+                placeholder="请输入油服设备编码" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('iotDevice.code')" prop="deviceCode">
+              <el-input
+                v-model="formData.deviceCode"
+                :disabled="formType === 'update'"
+                placeholder="请输入设备编码" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('iotDevice.name')" prop="deviceName">
+              <lang-input v-model="formData.deviceName" placeholder="请输入设备名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('iotDevice.dept')" prop="deptId">
+              <el-tree-select
+                :disabled="formType === 'update'"
+                v-model="formData.deptId"
+                :data="deptList"
+                :props="defaultProps"
+                check-strictly
+                node-key="id"
+                filterable
+                placeholder="请选择所在部门" />
+              <!--              <el-tree-select-->
+              <!--                v-model="formData.deptId"-->
+              <!--                :data="deptList"-->
+              <!--                :props="defaultProps"-->
+              <!--                check-strictly-->
+              <!--                node-key="id"-->
+              <!--                placeholder="请选择归属部门"-->
+              <!--              />-->
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.category')" prop="assetClass">
+              <el-tree-select
+                :disabled="formType === 'update' && username !== '超级管理员'"
+                v-model="formData.assetClass"
+                :data="productClassifyList"
+                :props="defaultProps"
+                check-strictly
+                node-key="id"
+                :placeholder="t('deviceForm.categoryHolder')"
+                @change="assetclasschange"
+                filterable />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('iotDevice.status')" prop="deviceStatus">
+              <el-select
+                v-model="formData.deviceStatus"
+                :placeholder="t('deviceForm.choose')"
+                :disabled="formType === 'update'"
+                clearable>
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.PMS_DEVICE_STATUS)"
+                  :key="dict.label"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('iotDevice.assets')" prop="assetProperty">
+              <el-select v-model="formData.assetProperty" placeholder="请选择" clearable>
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.PMS_ASSET_PROPERTY)"
+                  :key="dict.id"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('iotDevice.brand')" prop="brandName">
+              <el-select
+                clearable
+                v-model="formData.brandName"
+                @clear="brandClear"
+                :placeholder="t('iotDevice.brandHolder')"
+                @click="openForm" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="车牌号" prop="carNo">
+              <el-input clearable v-model="formData.carNo" placeholder="请输入车牌号" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="设备号" prop="deviceNo">
+              <el-input clearable v-model="formData.deviceNo" placeholder="请输入设备号" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <div style="display: flex; flex-direction: row">
+              <el-form-item :label="t('deviceForm.model')" prop="model" style="width: 86%">
+                <el-input
+                  clearable
+                  v-model="formData.model"
+                  :placeholder="t('deviceForm.modelHolder')" />
+              </el-form-item>
+              <el-button type="info" @click="openModelForm">请选择</el-button>
+            </div>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.useProject')" prop="useProject">
+              <el-input v-model="formData.useProject" :disabled="isDetail" height="60px" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.assetOwner')" prop="assetOwnership">
+              <el-input v-model="formData.assetOwnership" :disabled="isDetail" height="60px" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="所在地点" prop="address">
+              <el-input v-model="formData.address" height="60px" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.picture')" prop="picUrl">
+              <UploadImg v-model="formData.picUrl" :disabled="isDetail" height="60px" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.remark')" prop="remark">
+              <el-input
+                v-model="formData.remark"
+                type="textarea"
+                :placeholder="t('deviceForm.remarkHolder')" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </div>
+      <div class="title-div">
+        <el-button @click="zzInfoClick" class="title-button">
+          <Icon color="black" icon="ep:set-up" :size="18" class="cursor-pointer first-icon" />
+          <span class="cursor-pointer">{{ t('deviceForm.make') }}</span>
+          <Icon
+            color="black"
+            :icon="zzIsExpanded ? 'fa-solid:angle-double-down' : 'fa-solid:angle-double-right'"
+            :size="18"
+            class="cursor-pointer" />
+        </el-button>
+      </div>
+      <div class="zz-expandable-content" :class="{ 'is-expanded': zzIsExpanded }">
+        <el-row>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.mfg')" prop="manufacturerId">
+              <el-select
+                clearable
+                @clear="zzClear"
+                v-model="formData.manufacturerName"
+                :placeholder="t('deviceForm.mfgHolder')"
+                @click="openCustomerZz" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.pd')" prop="manDate">
+              <el-date-picker
+                style="width: 150%"
+                v-model="formData.manDate"
+                type="date"
+                value-format="x"
+                :placeholder="t('deviceForm.pdHolder')" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.supplier')" prop="supplierId">
+              <el-select
+                clearable
+                @clear="supplierClear"
+                v-model="formData.supplierName"
+                :placeholder="t('deviceForm.suppHolder')"
+                @click="openCustomerSupplier" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.warranty')" prop="expires">
+              <el-date-picker
+                style="width: 150%"
+                v-model="formData.expires"
+                type="date"
+                value-format="x"
+                :placeholder="t('deviceForm.warrHolder')" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.enable')" prop="enableDate">
+              <el-date-picker
+                style="width: 150%"
+                v-model="formData.enableDate"
+                type="date"
+                value-format="x"
+                :placeholder="t('deviceForm.enableHolder')" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item :label="t('deviceForm.ni')" prop="nameplate">
+              <el-input
+                v-model="formData.nameplate"
+                type="textarea"
+                :placeholder="t('deviceForm.niHolder')" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </div>
+      <div class="title-div">
+        <el-button @click="cwInfoClick" class="title-button">
+          <Icon color="black" icon="ep:set-up" :size="18" class="cursor-pointer first-icon" />
+          <span class="cursor-pointer">{{ t('deviceForm.finance') }}</span>
+          <Icon
+            color="black"
+            :icon="cwIsExpanded ? 'fa-solid:angle-double-down' : 'fa-solid:angle-double-right'"
+            :size="18"
+            class="cursor-pointer" />
+        </el-button>
+      </div>
+      <div class="cw-expandable-content" :class="{ 'is-expanded': cwIsExpanded }">
+        <el-row>
+          <el-col :span="8">
+            <el-form-item
+              :label="formData.assetProperty === 'zy' ? '采购价格' : '租赁价格'"
+              prop="plPrice">
+              <el-input
+                v-model="formData.plPrice"
+                @input="handleInput(formData.plPrice, 'plPrice')"
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入采购价格' : '请输入租赁价格'
+                " />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item
+              :label="formData.assetProperty === 'zy' ? '采购日期' : '租赁日期'"
+              prop="plDate">
+              <el-date-picker
+                style="width: 150%"
+                v-model="formData.plDate"
+                type="date"
+                value-format="x"
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入采购日期' : '请输入租赁日期'
+                " />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item
+              :label="formData.assetProperty === 'zy' ? '折旧年限' : '租赁年限'"
+              prop="plYear">
+              <el-input
+                v-model="formData.plYear"
+                type="number"
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入折旧年限' : '请输入租赁年限'
+                " />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item
+              :label="formData.assetProperty === 'zy' ? '折旧开始日期' : '租赁开始日期'"
+              prop="plStartDate">
+              <el-date-picker
+                style="width: 150%"
+                v-model="formData.plStartDate"
+                type="date"
+                value-format="x"
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请选择折旧开始日期' : '请选择租赁开始日期'
+                " />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item
+              :label="formData.assetProperty === 'zy' ? '已提折旧月数' : '已租赁月数'"
+              prop="plMonthed">
+              <el-input
+                v-model="formData.plMonthed"
+                type="number"
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入已提折旧月数' : '请输入已租赁月数'
+                " />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item
+              :label="formData.assetProperty === 'zy' ? '已提折旧金额' : '已租赁金额'"
+              prop="plAmounted">
+              <el-input
+                v-model="formData.plAmounted"
+                @input="handleInput(formData.plAmounted, 'plAmounted')"
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入已提折旧金额' : '已租赁金额'
+                " />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="剩余金额" prop="remainAmount">
+              <el-input
+                v-model="formData.remainAmount"
+                @input="handleInput(formData.remainAmount, 'remainAmount')"
+                placeholder="请输入剩余金额" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="每月折旧金额" prop="monthAmount">
+              <el-input
+                v-model="formData.monthAmount"
+                @input="handleInput(formData.monthAmount, 'monthAmount')"
+                placeholder="请输入每月折旧金额" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="总折旧月份" prop="totalMonth">
+              <el-input
+                v-model="formData.totalMonth"
+                @input="handleInput(formData.totalMonth, 'totalMonth')"
+                placeholder="请输入总折旧月份" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="币种" prop="currency">
+              <el-input v-model="formData.currency" placeholder="请输入币种" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </div>
+      <div class="title-div">
+        <el-button @click="qtInfoClick" class="title-button">
+          <Icon color="black" icon="ep:set-up" :size="18" class="cursor-pointer first-icon" />
+          <span class="cursor-pointer">{{ t('deviceForm.other') }}</span>
+          <Icon
+            color="black"
+            :icon="qtIsExpanded ? 'fa-solid:angle-double-down' : 'fa-solid:angle-double-right'"
+            :size="18"
+            class="cursor-pointer" />
+        </el-button>
+      </div>
+      <div class="qt-expandable-content" :class="{ 'is-expanded': qtIsExpanded }">
+        <el-row>
+          <el-col v-for="field in list" :key="field.sort" :span="8">
+            <el-form-item
+              label-width="180px"
+              :label="field.name"
+              :prop="field.code"
+              :rules="field.rules">
+              <!-- 文本输入 -->
+              <el-input
+                v-if="field.type === 'text'"
+                v-model="formData[field.code]"
+                :placeholder="'请输入' + field.name"
+                :type="field.type || 'text'" />
+
+              <el-select
+                v-else-if="field.type === 'enum'"
+                v-model="formData[field.code]"
+                :placeholder="'请输入' + field.name"
+                clearable
+                filterable>
+                <el-option
+                  v-for="item in field.selectOptions.dataSpecsList"
+                  :key="item.name"
+                  :label="item.name"
+                  :value="item.name" />
+              </el-select>
+
+              <!-- 数字输入 -->
+              <el-input
+                v-else-if="field.type === 'int'"
+                type="number"
+                v-model="formData[field.code]"
+                style="width: 150%" />
+              <el-input
+                v-else-if="field.type === 'double'"
+                v-model="formData[field.code]"
+                @input="handleInput(formData[field.code], field.code)"
+                style="width: 150%" />
+              <!-- 日期选择 -->
+              <el-date-picker
+                v-else-if="field.type === 'date'"
+                v-model="formData[field.code]"
+                :type="field.type || 'date'"
+                :placeholder="'请输入' + field.name"
+                value-format="YYYY-MM-DD"
+                style="width: 150%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </div>
+    </el-form>
+    <el-form>
+      <el-form-item style="float: right">
+        <el-button type="success" @click="allzhankai">{{ t('deviceForm.expand') }}</el-button>
+        <el-button type="info" @click="allshouqi">{{ t('deviceForm.close') }}</el-button>
+        <el-button v-if="!isDetail" :loading="formLoading" type="warning" @click="submitForm">
+          {{ t('deviceForm.save') }}
+        </el-button>
+        <el-button @click="close" type="primary">{{ t('deviceForm.return') }}</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <BrandList ref="brandFormRef" @choose="brandChoose" />
+  <ModelList ref="modelFormRef" @choose="modelChoose" :brand="formData.brand" />
+  <CustomerList ref="customerZzFormRef" @choose="customerZzChoose" />
+  <CustomerList ref="customerSupplierFormRef" @choose="customerSupplierChoose" />
+</template>
+<script setup lang="ts">
+import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
+import BrandList from '@/views/pms/device/BrandList.vue'
+import ModelList from '@/views/pms/device/ModelList.vue'
+import CustomerList from '@/views/pms/device/CustomerList.vue'
+import { defaultProps, handleTree } from '@/utils/tree'
+import * as DeptApi from '@/api/system/dept'
+import * as ProductClassifyApi from '@/api/pms/productclassify'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { DeviceAttrModelApi } from '@/api/pms/deviceattrmodel'
+import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import { IotYfClassifyApi } from '@/api/pms/yfclass'
+import { useRefreshStore } from '@/store/modules/pms/refreshStore'
+import { watch } from 'vue'
+
+/** 设备台账 表单 */
+defineOptions({ name: 'DeviceDetailAdd' })
+const baseIsExpanded = ref(true) // 控制表单是否展开的变量
+const zzIsExpanded = ref(true) // 控制表单是否展开的变量
+const cwIsExpanded = ref(true) // 控制表单是否展开的变量
+const qtIsExpanded = ref(true) // 控制表单是否展开的变量
+
+const username = ref('')
+const deptList = ref<Tree[]>([]) // 树形结构
+const productClassifyList = ref<Tree[]>([]) // 树形结构
+const { delView } = useTagsViewStore() // 视图操作
+const { params, name, query } = useRoute() // 查询参数
+const { currentRoute, push } = useRouter()
+const { wsCache } = useCache()
+const id = params.id
+const type = params.type
+const deptId = params.deptId
+const isDetail = params.isDetail
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const brandLabel = ref('') // 表单的类型:create - 新增;update - 修改
+const zzLabel = ref('') // 表单的类型:create - 新增;update - 修改
+const supplierLabel = ref('') // 表单的类型:create - 新增;update - 修改
+const yfclasses = ref([])
+const refreshStore = useRefreshStore()
+
+const formData = ref({
+  id: undefined,
+  yfClass: undefined,
+  yfDeviceCode: undefined,
+  enableDate: undefined,
+  deviceCode: undefined,
+  deviceName: undefined,
+  brand: undefined,
+  brandName: undefined,
+  model: undefined,
+  deptId: undefined,
+  deviceStatus: undefined,
+  assetProperty: undefined,
+  picUrl: undefined,
+  assetOwnership: undefined,
+  remark: undefined,
+  useProject: undefined,
+  manufacturerId: undefined,
+  manufacturerName: undefined,
+  supplierId: undefined,
+  supplierName: undefined,
+  manDate: undefined,
+  nameplate: undefined,
+  expires: undefined,
+  plPrice: undefined,
+  plDate: undefined,
+  plYear: undefined,
+  plStartDate: undefined,
+  plMonthed: undefined,
+  plAmounted: undefined,
+  remainAmount: undefined,
+  infoId: undefined,
+  infoType: undefined,
+  infoName: undefined,
+  infoRemark: undefined,
+  infoUrl: undefined,
+  templateJson: undefined,
+  assetClass: undefined,
+  carNo: undefined,
+  deviceNo: undefined,
+  address: undefined,
+  monthAmount: undefined,
+  totalMonth: undefined,
+  currency: undefined
+})
+const formRules = reactive({
+  yfClass: [
+    {
+      validator: (rule, value, callback) => {
+        // 当资产性质为租赁('zl')时,yfClass非必填;否则必填
+        if (formData.value.assetProperty === 'zl' || value) {
+          callback() // 租赁资产或有值时通过验证
+        } else {
+          callback(new Error('编码类别不能为空')) // 非租赁资产且无值时失败
+        }
+      },
+      trigger: 'blur'
+    }
+  ],
+  yfDeviceCode: [{ required: true, message: '油服编码不能为空', trigger: 'blur' }],
+  assetClass: [{ required: true, message: '资产类别不能为空', trigger: 'blur' }],
+  deviceCode: [{ required: true, message: '设备编码不能为空', trigger: 'blur' }],
+  deviceName: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
+  brandName: [{ required: true, message: '品牌不能为空', trigger: 'blur' }],
+  deptId: [{ required: true, message: '所在部门不能为空', trigger: 'blur' }],
+  deviceStatus: [{ required: true, message: '设备状态不能为空', trigger: 'blur' }],
+  assetProperty: [{ required: true, message: '资产性质不能为空', trigger: 'blur' }],
+  manufacturerId: [{ required: true, message: '制造商id不能为空', trigger: 'blur' }],
+  manDate: [{ required: true, message: '生产日期不能为空', trigger: 'blur' }]
+})
+
+const list = ref([])
+const handleYfClassChange = async (value) => {
+  console.log(value)
+  const prefix = value.join('')
+  const last = await IotDeviceApi.getMaxCode(prefix)
+  formData.value.yfDeviceCode = prefix + last
+}
+const assetclasschange = () => {
+  const assetClass = formData.value.assetClass
+  DeviceAttrModelApi.getDeviceAttrModelListByDeviceCategoryId(assetClass).then((res) => {
+    if (res) {
+      res.forEach((item) => {
+        if (item.requiredFlag) {
+          const rule = { required: true, message: item.name + '不能为空', trigger: 'blur' }
+          item.rules = []
+          item.rules.push(rule)
+        }
+      })
+      list.value = res
+    } else {
+      list.value = []
+    }
+  })
+}
+
+watch(
+  () => formData.value.assetProperty,
+  (newVal) => {
+    nextTick(() => {
+      if (formRef.value) {
+        // 重新验证 yfClass 和 yfCode 字段
+        formRef.value.validateField('yfClass')
+      }
+    })
+  },
+  { immediate: true }
+)
+
+const brandChoose = (row) => {
+  formData.value.brand = row.id
+  // brandLabel.value = row.value
+  formData.value.brandName = row.label
+}
+const brandClear = () => {
+  formData.value.brand = undefined
+  formData.value.brandName = undefined
+}
+const modelChoose = (row) => {
+  formData.value.model = row.name
+}
+const customerSupplierChoose = (row) => {
+  formData.value.supplierId = row.id
+  formData.value.supplierName = row.name
+  // supplierLabel.value = row.name
+}
+const supplierClear = (row) => {
+  formData.value.supplierId = undefined
+  formData.value.supplierName = undefined
+}
+const customerZzChoose = (row) => {
+  formData.value.manufacturerId = row.id
+  // zzLabel.value = row.name
+  formData.value.manufacturerName = row.name
+}
+const zzClear = () => {
+  formData.value.manufacturerId = undefined
+  formData.value.manufacturerName = undefined
+}
+/** 添加/修改操作 */
+const brandFormRef = ref()
+const openForm = () => {
+  brandFormRef.value.open()
+}
+const modelFormRef = ref()
+const openModelForm = () => {
+  modelFormRef.value.open()
+}
+const customerSupplierFormRef = ref()
+const openCustomerSupplier = () => {
+  customerSupplierFormRef.value.open()
+}
+const customerZzFormRef = ref()
+const openCustomerZz = () => {
+  customerZzFormRef.value.open()
+}
+const allshouqi = () => {
+  baseIsExpanded.value = false
+  zzIsExpanded.value = false
+  cwIsExpanded.value = false
+  qtIsExpanded.value = false
+}
+const allzhankai = () => {
+  baseIsExpanded.value = true
+  zzIsExpanded.value = true
+  cwIsExpanded.value = true
+  qtIsExpanded.value = true
+}
+
+const handleInput = (value, obj) => {
+  // 1. 过滤非法字符(只允许数字和小数点)
+  let filtered = value.replace(/[^\d.]/g, '')
+
+  // 2. 处理多个小数点的情况
+  filtered = filtered.replace(/\.{2,}/g, '.')
+
+  // 3. 限制小数点后最多两位
+  let decimalParts = filtered.split('.')
+  if (decimalParts.length > 1) {
+    decimalParts = decimalParts.slice(0, 2)
+    filtered = decimalParts.join('.')
+  }
+
+  // 4. 处理以小数点开头的情况(自动补0)
+  if (filtered.startsWith('.')) {
+    filtered = '0' + filtered
+  }
+
+  // 5. 更新绑定值(同时处理连续输入多个0的情况)
+  formData.value[obj] = filtered.replace(/^0+(?=\d)/, '')
+}
+
+const close = () => {
+  delView(unref(currentRoute))
+  push({ name: 'IotDevicePms', params: {} })
+  // delView(unref(currentRoute))
+  // push({
+  //   name: 'IotDevicePms',
+  //   query: {
+  //     date: new Date().getTime()
+  //   }
+  // })
+}
+const baseInfoClick = () => {
+  baseIsExpanded.value = !baseIsExpanded.value // 切换展开状态
+}
+const zzInfoClick = () => {
+  zzIsExpanded.value = !zzIsExpanded.value // 切换展开状态
+}
+const cwInfoClick = () => {
+  cwIsExpanded.value = !cwIsExpanded.value // 切换展开状态
+}
+const qtInfoClick = () => {
+  qtIsExpanded.value = !qtIsExpanded.value // 切换展开状态
+}
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const formRef = ref()
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    if (list.value) {
+      list.value = list.value.map((item) => ({
+        ...item,
+        value: formData.value[item.code] // 自定义属性生成逻辑
+      }))
+      formData.value.templateJson = JSON.stringify(list.value)
+    }
+    formData.value.yfClass = formData.value.yfClass.join(',')
+    const data = formData.value as unknown as IotDeviceVO
+    if (formType.value === 'create') {
+      await IotDeviceApi.createIotDevice(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await IotDeviceApi.updateIotDevice(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    //emit('success')
+    const sourcePage = query.source as string
+
+    // 如果有来源页面标识,触发原页面的刷新
+    if (sourcePage) {
+      refreshStore.triggerRefresh(sourcePage)
+    }
+    close()
+  } finally {
+    formLoading.value = false
+  }
+}
+
+onMounted(async () => {
+  const userInfo = wsCache.get(CACHE_KEY.USER)
+  // NOTE: 是否需要像`setUserInfoAction`一样判断`userInfo != null`
+  username.value = userInfo.user.nickname
+  deptList.value = handleTree(await DeptApi.getSimpleDeptList())
+  productClassifyList.value = handleTree(
+    await ProductClassifyApi.IotProductClassifyApi.getSimpleProductClassifyList()
+  )
+  formData.value.assetProperty = 'zy'
+  // 修改时,设置数据
+  if (id) {
+    formType.value = 'update'
+    formLoading.value = true
+    try {
+      const iotDevice = await IotDeviceApi.getIotDevice(id)
+      formData.value = iotDevice
+      formData.value.brandName = iotDevice.brandName
+      formData.value.manufacturerName = iotDevice.zzName
+      formData.value.supplierName = iotDevice.supplierName
+      formData.value.carNo = iotDevice.carNo
+      formData.value.deviceNo = iotDevice.deviceNo
+      formData.value.address = iotDevice.address
+      if (iotDevice.yfClass) {
+        formData.value.yfClass = iotDevice.yfClass.split(',')
+      }
+      list.value = JSON.parse(iotDevice.templateJson)
+      list.value.forEach((item) => {
+        formData.value[item.code] = item.value
+      })
+    } finally {
+      formLoading.value = false
+    }
+  } else {
+    if (deptId) {
+      formData.value.deptId = Number(deptId)
+    }
+    formType.value = 'create'
+  }
+  await IotYfClassifyApi.getChildrenList().then((res) => {
+    yfclasses.value = res
+  })
+})
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    deviceCode: undefined,
+    deviceName: undefined,
+    brand: undefined,
+    model: undefined,
+    deptId: undefined,
+    deviceStatus: undefined,
+    assetProperty: undefined,
+    picUrl: undefined,
+    remark: undefined,
+    manufacturerId: undefined,
+    supplierId: undefined,
+    manDate: undefined,
+    nameplate: undefined,
+    expires: undefined,
+    plPrice: undefined,
+    plDate: undefined,
+    plYear: undefined,
+    plStartDate: undefined,
+    plMonthed: undefined,
+    plAmounted: undefined,
+    remainAmount: undefined,
+    infoId: undefined,
+    infoType: undefined,
+    infoName: undefined,
+    infoRemark: undefined,
+    infoUrl: undefined,
+    templateJson: undefined,
+    assetClass: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>
+
+<style scoped lang="scss">
+.base-expandable-content {
+  max-height: 0; /* 初始高度为0 */
+  overflow: hidden; /* 隐藏溢出的内容 */
+  transition: max-height 0.3s ease; /* 平滑过渡效果 */
+}
+
+.base-expandable-content.is-expanded {
+  min-height: 350px; /* 或者根据内容设定一个合适的最大高度 */
+}
+.zz-expandable-content {
+  max-height: 0; /* 初始高度为0 */
+  overflow: hidden; /* 隐藏溢出的内容 */
+  transition: max-height 0.3s ease; /* 平滑过渡效果 */
+}
+
+.zz-expandable-content.is-expanded {
+  min-height: 130px; /* 或者根据内容设定一个合适的最大高度 */
+}
+.cw-expandable-content {
+  max-height: 0; /* 初始高度为0 */
+  overflow: hidden; /* 隐藏溢出的内容 */
+  transition: max-height 0.3s ease; /* 平滑过渡效果 */
+}
+
+.cw-expandable-content.is-expanded {
+  max-height: 200px; /* 或者根据内容设定一个合适的最大高度 */
+}
+.qt-expandable-content {
+  max-height: 0; /* 初始高度为0 */
+  overflow: hidden; /* 隐藏溢出的内容 */
+  transition: max-height 0.3s ease; /* 平滑过渡效果 */
+}
+
+.qt-expandable-content.is-expanded {
+  max-height: 1200px; /* 或者根据内容设定一个合适的最大高度 */
+}
+.title-button {
+  font-size: 18px;
+  border: none;
+}
+.title-div {
+  margin-bottom: 20px;
+  margin-top: 10px;
+}
+.cursor-pointer {
+  vertical-align: middle;
+}
+.first-icon {
+  margin-bottom: 2px;
+}
+</style>

+ 254 - 0
src/views/pms/device/components/select-list/brand-list.vue

@@ -0,0 +1,254 @@
+<script lang="ts" setup>
+import * as DictDataApi from '@/api/system/dict/dict.data'
+import type { DictDataVO } from '@/api/system/dict/dict.data'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+
+const emit = defineEmits<{
+  choose: [row: DictDataVO]
+}>()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<DictDataVO>()
+
+const visible = ref(false)
+const loading = ref(false)
+const queryFormRef = ref()
+const list = ref<DictDataVO[]>([])
+const total = ref(0)
+const selectedRow = ref<DictDataVO | null>(null)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  label: '',
+  status: undefined as number | undefined,
+  dictType: 'pms_device_brand'
+})
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DictDataApi.getDictDataPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+const chooseRow = (row: DictDataVO) => {
+  selectedRow.value = row
+}
+
+const confirmChoose = () => {
+  if (!selectedRow.value) return
+  emit('choose', selectedRow.value)
+  visible.value = false
+}
+
+const cancelChoose = () => {
+  visible.value = false
+}
+
+const open = async () => {
+  visible.value = true
+  selectedRow.value = null
+  queryParams.pageNo = 1
+  queryParams.label = ''
+  queryParams.status = undefined
+  await getList()
+}
+
+defineExpose({ open })
+</script>
+
+<template>
+  <Dialog v-model="visible" title="选择品牌" style="width: 1100px" class="brand-select-dialog">
+    <div class="brand-select">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        class="brand-select__toolbar"
+        label-width="68px"
+        size="default">
+        <div class="brand-select__filters">
+          <el-form-item label="字典标签" prop="label">
+            <el-input
+              v-model="queryParams.label"
+              placeholder="请输入字典标签"
+              clearable
+              class="brand-select__control"
+              @keyup.enter="handleQuery" />
+          </el-form-item>
+          <el-form-item label="状态" prop="status">
+            <el-select
+              v-model="queryParams.status"
+              placeholder="数据状态"
+              clearable
+              class="brand-select__control">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value" />
+            </el-select>
+          </el-form-item>
+        </div>
+        <div class="brand-select__actions">
+          <el-button type="primary" @click="handleQuery">
+            <Icon icon="ep:search" class="mr-5px" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" class="mr-5px" />
+            重置
+          </el-button>
+        </div>
+      </el-form>
+
+      <div class="brand-select__table-panel">
+        <ZmTable
+          :data="list"
+          :loading="loading"
+          :max-height="430"
+          :show-border="true"
+          align="center"
+          @row-click="chooseRow">
+          <ZmTableColumn label="选择" width="70" hide-in-column-settings>
+            <template #default="{ row }">
+              <el-radio
+                :model-value="selectedRow?.id"
+                :value="row.id"
+                class="brand-select__radio"
+                @click.stop="chooseRow(row)" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn prop="id" label="字典编码" width="120" />
+          <ZmTableColumn prop="label" label="字典标签" min-width="180" />
+          <ZmTableColumn prop="value" label="字典键值" min-width="180" />
+          <ZmTableColumn prop="status" label="状态" width="120">
+            <template #default="{ row }">
+              <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="row.status ?? 0" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn prop="remark" label="备注" min-width="160" />
+          <ZmTableColumn
+            prop="createTime"
+            label="创建时间"
+            width="180"
+            :formatter="dateFormatter" />
+        </ZmTable>
+
+        <div class="brand-select__pagination">
+          <Pagination
+            v-model:page="queryParams.pageNo"
+            v-model:limit="queryParams.pageSize"
+            :total="total"
+            @pagination="getList" />
+        </div>
+      </div>
+
+      <div class="brand-select__footer">
+        <el-button size="default" @click="cancelChoose">取消</el-button>
+        <el-button size="default" type="primary" :disabled="!selectedRow" @click="confirmChoose">
+          确定
+        </el-button>
+      </div>
+    </div>
+  </Dialog>
+</template>
+
+<style scoped lang="scss">
+.brand-select {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.brand-select__toolbar {
+  display: flex;
+  padding: 14px 16px;
+  background: linear-gradient(135deg, rgb(64 158 255 / 6%) 0%, rgb(255 255 255 / 0%) 100%);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.brand-select__filters {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px 24px;
+}
+
+.brand-select__toolbar :deep(.el-form-item) {
+  margin-bottom: 0;
+}
+
+.brand-select__control {
+  width: 240px;
+}
+
+.brand-select__actions {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+  gap: 8px;
+}
+
+.brand-select__table-panel {
+  padding: 12px;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.brand-select__radio {
+  height: 20px;
+}
+
+.brand-select__radio :deep(.el-radio__label) {
+  display: none;
+}
+
+.brand-select__pagination {
+  display: flex;
+  padding-top: 12px;
+  justify-content: flex-end;
+}
+
+.brand-select__footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+@media (width <= 900px) {
+  .brand-select__toolbar {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .brand-select__actions {
+    justify-content: flex-end;
+  }
+
+  .brand-select__control {
+    width: 100%;
+  }
+}
+</style>

+ 256 - 0
src/views/pms/device/components/select-list/customer-list.vue

@@ -0,0 +1,256 @@
+<script lang="ts" setup>
+import { Api, type SupplierVO } from '@/api/supplier/base'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+
+interface SupplierRow extends SupplierVO {
+  createTime?: string | number | Date
+}
+
+const emit = defineEmits<{
+  choose: [row: SupplierRow]
+}>()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<SupplierRow>()
+
+const visible = ref(false)
+const loading = ref(false)
+const title = ref('选择客商')
+const queryFormRef = ref()
+const list = ref<SupplierRow[]>([])
+const total = ref(0)
+const selectedRow = ref<SupplierRow | null>(null)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined as string | undefined,
+  code: undefined as string | undefined
+})
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await Api.getPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+const chooseRow = (row: SupplierRow) => {
+  selectedRow.value = row
+}
+
+const confirmChoose = () => {
+  if (!selectedRow.value) return
+  emit('choose', selectedRow.value)
+  visible.value = false
+}
+
+const cancelChoose = () => {
+  visible.value = false
+}
+
+const open = async (dialogTitle = '选择客商') => {
+  title.value = dialogTitle
+  visible.value = true
+  selectedRow.value = null
+  queryParams.pageNo = 1
+  queryParams.name = undefined
+  queryParams.code = undefined
+  await getList()
+}
+
+defineExpose({ open })
+</script>
+
+<template>
+  <Dialog v-model="visible" :title="title" style="width: 1100px" class="customer-select-dialog">
+    <div class="customer-select">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        class="customer-select__toolbar"
+        label-width="68px"
+        size="default">
+        <div class="customer-select__filters">
+          <el-form-item label="客商名称" prop="name">
+            <el-input
+              v-model="queryParams.name"
+              placeholder="请输入客商名称"
+              clearable
+              class="customer-select__control"
+              @keyup.enter="handleQuery" />
+          </el-form-item>
+          <el-form-item label="客商编号" prop="code">
+            <el-input
+              v-model="queryParams.code"
+              placeholder="请输入客商编号"
+              clearable
+              class="customer-select__control"
+              @keyup.enter="handleQuery" />
+          </el-form-item>
+        </div>
+        <div class="customer-select__actions">
+          <el-button type="primary" @click="handleQuery">
+            <Icon icon="ep:search" class="mr-5px" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" class="mr-5px" />
+            重置
+          </el-button>
+        </div>
+      </el-form>
+
+      <div class="customer-select__table-panel">
+        <ZmTable
+          :data="list"
+          :loading="loading"
+          :max-height="430"
+          :show-border="true"
+          align="center"
+          @row-click="chooseRow">
+          <ZmTableColumn label="选择" width="70" hide-in-column-settings>
+            <template #default="{ row }">
+              <el-radio
+                :model-value="selectedRow?.id"
+                :value="row.id"
+                class="customer-select__radio"
+                @click.stop="chooseRow(row)" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn prop="name" label="客商名称" min-width="180" />
+          <ZmTableColumn prop="code" label="客商编号" min-width="150" />
+          <ZmTableColumn prop="classification" label="客商分类" width="140">
+            <template #default="{ row }">
+              <dict-tag :type="DICT_TYPE.SUPPLIER_TYPE" :value="row.classification ?? 0" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn prop="status" label="状态" width="120">
+            <template #default="{ row }">
+              <dict-tag :type="DICT_TYPE.SUPPLIER_STATUS" :value="row.status ?? 0" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn
+            prop="createTime"
+            label="创建时间"
+            width="180"
+            :formatter="dateFormatter" />
+        </ZmTable>
+
+        <div class="customer-select__pagination">
+          <Pagination
+            v-model:page="queryParams.pageNo"
+            v-model:limit="queryParams.pageSize"
+            :total="total"
+            @pagination="getList" />
+        </div>
+      </div>
+
+      <div class="customer-select__footer">
+        <el-button size="default" @click="cancelChoose">取消</el-button>
+        <el-button size="default" type="primary" :disabled="!selectedRow" @click="confirmChoose">
+          确定
+        </el-button>
+      </div>
+    </div>
+  </Dialog>
+</template>
+
+<style scoped lang="scss">
+.customer-select {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.customer-select__toolbar {
+  display: flex;
+  padding: 14px 16px;
+  background: linear-gradient(135deg, rgb(64 158 255 / 6%) 0%, rgb(255 255 255 / 0%) 100%);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.customer-select__filters {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px 24px;
+}
+
+.customer-select__toolbar :deep(.el-form-item) {
+  margin-bottom: 0;
+}
+
+.customer-select__control {
+  width: 220px;
+}
+
+.customer-select__actions {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+  gap: 8px;
+}
+
+.customer-select__table-panel {
+  padding: 12px;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.customer-select__radio {
+  height: 20px;
+}
+
+.customer-select__radio :deep(.el-radio__label) {
+  display: none;
+}
+
+.customer-select__pagination {
+  display: flex;
+  padding-top: 12px;
+  justify-content: flex-end;
+}
+
+.customer-select__footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+@media (width <= 900px) {
+  .customer-select__toolbar {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .customer-select__actions {
+    justify-content: flex-end;
+  }
+
+  .customer-select__control {
+    width: 100%;
+  }
+}
+</style>

+ 262 - 0
src/views/pms/device/components/select-list/model-list.vue

@@ -0,0 +1,262 @@
+<script lang="ts" setup>
+import { IotModelApi, type IotModelVO } from '@/api/pms/model'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+
+type BrandValue = number | string | Array<number | string>
+
+interface ModelRow extends IotModelVO {
+  createTime?: string | number | Date
+}
+
+const props = defineProps<{
+  brand?: BrandValue
+}>()
+
+const emit = defineEmits<{
+  choose: [row: ModelRow]
+}>()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<ModelRow>()
+
+const visible = ref(false)
+const loading = ref(false)
+const queryFormRef = ref()
+const list = ref<ModelRow[]>([])
+const total = ref(0)
+const selectedRow = ref<ModelRow | null>(null)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  brand: undefined as BrandValue | undefined,
+  name: '',
+  status: undefined as number | undefined
+})
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await IotModelApi.getIotModelPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+
+const chooseRow = (row: ModelRow) => {
+  selectedRow.value = row
+}
+
+const confirmChoose = () => {
+  if (!selectedRow.value) return
+  emit('choose', selectedRow.value)
+  visible.value = false
+}
+
+const cancelChoose = () => {
+  visible.value = false
+}
+
+const open = async () => {
+  visible.value = true
+  selectedRow.value = null
+  queryParams.pageNo = 1
+  queryParams.name = ''
+  queryParams.status = undefined
+  queryParams.brand = props.brand
+  await getList()
+}
+
+defineExpose({ open })
+</script>
+
+<template>
+  <Dialog v-model="visible" title="选择规格型号" style="width: 1100px" class="model-select-dialog">
+    <div class="model-select">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        class="model-select__toolbar"
+        label-width="68px"
+        size="default">
+        <div class="model-select__filters">
+          <el-form-item label="型号名称" prop="name">
+            <el-input
+              v-model="queryParams.name"
+              placeholder="请输入型号名称"
+              clearable
+              class="model-select__control"
+              @keyup.enter="handleQuery" />
+          </el-form-item>
+          <el-form-item label="状态" prop="status">
+            <el-select
+              v-model="queryParams.status"
+              placeholder="数据状态"
+              clearable
+              class="model-select__control">
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value" />
+            </el-select>
+          </el-form-item>
+        </div>
+        <div class="model-select__actions">
+          <el-button type="primary" @click="handleQuery">
+            <Icon icon="ep:search" class="mr-5px" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" class="mr-5px" />
+            重置
+          </el-button>
+        </div>
+      </el-form>
+
+      <div class="model-select__table-panel">
+        <ZmTable
+          :data="list"
+          :loading="loading"
+          :max-height="430"
+          :show-border="true"
+          align="center"
+          @row-click="chooseRow">
+          <ZmTableColumn label="选择" width="70" hide-in-column-settings>
+            <template #default="{ row }">
+              <el-radio
+                :model-value="selectedRow?.id"
+                :value="row.id"
+                class="model-select__radio"
+                @click.stop="chooseRow(row)" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn prop="name" label="型号名称" min-width="180" />
+          <ZmTableColumn prop="standard" label="符合标准" min-width="180" />
+          <ZmTableColumn prop="status" label="状态" width="120">
+            <template #default="{ row }">
+              <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="row.status ?? 0" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn
+            prop="createTime"
+            label="创建时间"
+            width="180"
+            :formatter="dateFormatter" />
+        </ZmTable>
+
+        <div class="model-select__pagination">
+          <Pagination
+            v-model:page="queryParams.pageNo"
+            v-model:limit="queryParams.pageSize"
+            :total="total"
+            @pagination="getList" />
+        </div>
+      </div>
+
+      <div class="model-select__footer">
+        <el-button size="default" @click="cancelChoose">取消</el-button>
+        <el-button size="default" type="primary" :disabled="!selectedRow" @click="confirmChoose">
+          确定
+        </el-button>
+      </div>
+    </div>
+  </Dialog>
+</template>
+
+<style scoped lang="scss">
+.model-select {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.model-select__toolbar {
+  display: flex;
+  padding: 14px 16px;
+  background: linear-gradient(135deg, rgb(64 158 255 / 6%) 0%, rgb(255 255 255 / 0%) 100%);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.model-select__filters {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px 24px;
+}
+
+.model-select__toolbar :deep(.el-form-item) {
+  margin-bottom: 0;
+}
+
+.model-select__control {
+  width: 240px;
+}
+
+.model-select__actions {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+  gap: 8px;
+}
+
+.model-select__table-panel {
+  padding: 12px;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.model-select__radio {
+  height: 20px;
+}
+
+.model-select__radio :deep(.el-radio__label) {
+  display: none;
+}
+
+.model-select__pagination {
+  display: flex;
+  padding-top: 12px;
+  justify-content: flex-end;
+}
+
+.model-select__footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+@media (width <= 900px) {
+  .model-select__toolbar {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .model-select__actions {
+    justify-content: flex-end;
+  }
+
+  .model-select__control {
+    width: 100%;
+  }
+}
+</style>

+ 9 - 3
src/views/pms/device/index.vue

@@ -970,14 +970,20 @@ const resetQuery = () => {
 const openForm = (type: string, id?: number, deptId?: number) => {
   //修改
   if (typeof id === 'number') {
-    push({ name: 'DeviceDetailEdit', params: { type, id }, query: { source: 'devicerouter' } })
+    push({
+      name: 'DeviceDetailForm',
+      query: { source: 'devicerouter', type, id, deptId }
+    })
     return
   }
   // 新增
   if (deptId) {
-    push({ name: 'DeviceDetailAdd', params: { type, deptId }, query: { source: 'devicerouter' } })
+    push({
+      name: 'DeviceDetailForm',
+      query: { source: 'devicerouter', type, deptId }
+    })
   } else {
-    push({ name: 'DeviceDetailAddd', params: {}, query: { source: 'devicerouter' } })
+    push({ name: 'DeviceDetailForm', query: { source: 'devicerouter' } })
   }
 }
 

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů