Procházet zdrojové kódy

feat: 重构设备详情页记录页签展示
- 重构设备详情页基础信息与页签内容展示,统一设备资料库、设备BOM及各记录页签的工作台样式
- 新增运行、故障、维修、保养、巡检、调拨、状态变更、责任人调整、关联设备等独立记录组件
- 各记录组件沿用原有接口逻辑,统一使用 ZmTable、自适应表格高度和分页布局
- 保留故障、维修记录的更多查询能力,并优化高级查询区域排版

Zimo před 12 hodinami
rodič
revize
cd203e7625

+ 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>