kkk-ops 1 month ago
commit 7bbdc791b8

@ -51,6 +51,18 @@ export interface LineDevicePageParams {
deviceName?: string
status?: string | number
collectionTime?: string | number
collectionTimeStart?: string
collectionTimeEnd?: string
}
export interface SingleDeviceParams {
deviceId: string | number
}
export interface HistoryRecordParams {
deviceId: string | number
collectionStartTime?: string
collectionEndTime?: string
}
// 物联设备 API
@ -101,6 +113,14 @@ export const DeviceApi = {
return await request.get({ url: `/iot/device/lineDevicePage`, params })
},
getSingleDevice: async (params: SingleDeviceParams) => {
return await request.get({ url: `/iot/device/singleDevice`, params })
},
getHistoryRecord: async (params: HistoryRecordParams) => {
return await request.get({ url: `/iot/device/historyRecord`, params })
},
// ==================== 子表(设备属性) ====================
// 获得设备属性分页

@ -56,16 +56,20 @@ ref="tableRef" v-loading="loading" :data="list" :stripe="true" :show-overflow-to
</template>
</el-table-column>
<el-table-column label="寄存器地址" align="center" prop="address" min-width="140px" />
<el-table-column label="最新值" align="center" prop="latestValue" min-width="120px">
<el-table-column label="最新值" align="center" prop="addressValue" min-width="120px">
<template #default="scope">
{{ scope.row.latestValue ?? '-' }}
{{ formatAddressValue(scope.row.addressValue) }}
</template>
</el-table-column>
<el-table-column label="单位" align="center" prop="dataUnit" width="80px" />
<el-table-column label="倍率" align="center" prop="ratio" width="80px" />
<el-table-column
label="最新采集时间" align="center" prop="latestCollectTime" :formatter="dateFormatter"
width="170px" />
label="最新采集时间"
align="center"
prop="latestCollectionTime"
:formatter="dateFormatter"
width="170px"
/>
<el-table-column label="顺序" align="center" prop="sort" width="80px">
<template #default="scope">
{{ scope.row.sort ?? '-' }}
@ -126,6 +130,16 @@ const queryFormRef = ref()
const exportLoading = ref(false)
const formatAddressValue = (value: unknown) => {
if (value === null || value === undefined) {
return '-'
}
if (typeof value === 'string' || typeof value === 'number') {
return value
}
return ''
}
const selectedIds = ref<number[]>([])
const handleSelectionChange = (rows: any[]) => {
selectedIds.value = rows?.map((row) => row.id).filter((id) => id !== undefined) ?? []

@ -3,11 +3,13 @@
<!-- 搜索工作栏 -->
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="68px">
<el-form-item label="设备编号" prop="deviceCode">
<el-input v-model="queryParams.deviceCode" placeholder="请输入设备编号" clearable @keyup.enter="handleQuery"
<el-input
v-model="queryParams.deviceCode" placeholder="请输入设备编号" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable @keyup.enter="handleQuery"
<el-input
v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
@ -48,7 +50,8 @@
<el-button type="primary" plain @click="openForm('create')" v-hasPermi="['iot:device:create']">
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button type="success" plain @click="handleExport" :loading="exportLoading"
<el-button
type="success" plain @click="handleExport" :loading="exportLoading"
v-hasPermi="['iot:device:export']">
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
@ -61,7 +64,8 @@
<!-- 列表 -->
<ContentWrap>
<el-table ref="tableRef" v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" row-key="id"
<el-table
ref="tableRef" v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" row-key="id"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" reserve-selection />
<el-table-column label="设备编号" align="left" prop="deviceCode" />
@ -71,7 +75,7 @@
<dict-tag :type="DICT_TYPE.IOT_DEVICE_TYPE" :value="scope.row.deviceType" />
</template>
</el-table-column> -->
<el-table-column label="采集协议" align="left" prop="protocol" width="250px">
<el-table-column label="采集协议" align="left" prop="protocol" width="200px">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_PROTOCOL" :value="scope.row.protocol" />
</template>
@ -81,7 +85,7 @@
<dict-tag :type="DICT_TYPE.IOT_GATEWAY_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="采集周期(s)" align="left" prop="sampleCycle" width="200px" />
<el-table-column label="采集周期(s)" align="center" prop="sampleCycle" width="100px" />
<!-- <el-table-column label="读主题" align="center" prop="readTopic" />
<el-table-column label="写主题" align="center" prop="writeTopic" />
@ -103,7 +107,7 @@
:formatter="dateFormatter"
width="170px"
/> -->
<el-table-column label="是否启用" align="center" prop="isEnable" fixed="right" width="200px">
<el-table-column label="是否启用" align="center" prop="isEnable" width="100px">
<template #default="scope">
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.isEnable" />
</template>
@ -120,7 +124,8 @@
<el-button link type="primary" @click.stop="handleEdit(scope.row)" v-hasPermi="['iot:device:update']">
编辑
</el-button>
<el-button link :type="isRowConnected(scope.row) ? 'warning' : 'success'"
<el-button
link :type="isRowConnected(scope.row) ? 'warning' : 'success'"
:loading="!!connectLoadingMap[scope.row.id]" @click.stop="handleToggleConnect(scope.row)">
{{ isRowConnected(scope.row) ? '断开连接' : '连接' }}
</el-button>
@ -131,7 +136,8 @@
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
<Pagination
:total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
</ContentWrap>
@ -339,20 +345,9 @@ const handleToggleConnect = async (row: DeviceVO) => {
}
}
let timer: any = null;
/** 初始化 **/
onMounted(() => {
getList()
timer = setInterval(async () => {
const data = await DeviceApi.getDevicePage(queryParams)
list.value = data.list
total.value = data.total
}, 5000);
})
//
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
})
</script>

@ -0,0 +1,315 @@
<template>
<el-row :gutter="20">
<el-col :span="6" :xs="24">
<ContentWrap class="h-1/1">
<el-input
v-model="keyword"
clearable
placeholder="搜索设备或参数"
class="!w-1/1"
@input="handleKeywordChange"
/>
<div class="mt-12px">
<el-tree
ref="treeRef"
v-loading="treeLoading"
:data="treeData"
:props="treeProps"
node-key="id"
highlight-current
:expand-on-click-node="false"
:filter-node-method="filterTreeNode"
@node-click="handleTreeNodeClick"
/>
</div>
</ContentWrap>
</el-col>
<el-col :span="18" :xs="24">
<ContentWrap>
<el-form class="-mb-15px" :inline="true">
<el-form-item label="时间">
<el-date-picker
v-model="dateRange"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:shortcuts="dateShortcuts"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-360px"
/>
</el-form-item>
<el-form-item>
<el-button :disabled="!selectedParam" @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-button :disabled="!selectedParam" @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-alert
v-if="selectedParam"
:title="selectedParamTitle"
type="info"
:closable="false"
class="mb-12px"
/>
<div v-loading="chartLoading">
<el-empty v-if="!selectedParam" description="请选择左侧参数" />
<EChart v-else :options="chartOption" height="520px" />
</div>
</ContentWrap>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import dayjs from 'dayjs'
import { Echart as EChart } from '@/components/Echart'
defineOptions({ name: 'DeviceParamAnalysis' })
type TreeNodeType = 'device' | 'param'
type DeviceTreeNode = {
id: string
label: string
type: TreeNodeType
children?: DeviceTreeNode[]
deviceId?: string
paramKey?: string
unit?: string
}
const message = useMessage()
const treeRef = ref()
const treeLoading = ref(false)
const keyword = ref('')
const treeProps = { children: 'children', label: 'label' }
const treeData = ref<DeviceTreeNode[]>([])
const selectedParam = ref<DeviceTreeNode | null>(null)
const chartLoading = ref(false)
const chartXAxis = ref<string[]>([])
const chartSeries = ref<number[]>([])
const buildDefaultDateRange = (): [string, string] => {
const end = dayjs().endOf('day')
const start = end.subtract(6, 'day').startOf('day')
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]
}
const dateRange = ref<[string, string]>(buildDefaultDateRange())
const dateShortcuts = [
{
text: '最近 7 天',
value: () => {
const end = dayjs().endOf('day').toDate()
const start = dayjs().subtract(6, 'day').startOf('day').toDate()
return [start, end]
}
},
{
text: '上周',
value: () => {
const start = dayjs().subtract(1, 'week').startOf('week').toDate()
const end = dayjs().subtract(1, 'week').endOf('week').toDate()
return [start, end]
}
},
{
text: '上个月',
value: () => {
const start = dayjs().subtract(1, 'month').startOf('month').toDate()
const end = dayjs().subtract(1, 'month').endOf('month').toDate()
return [start, end]
}
},
{
text: '三个月内',
value: () => {
const end = dayjs().endOf('day').toDate()
const start = dayjs().subtract(3, 'month').startOf('day').toDate()
return [start, end]
}
}
]
const selectedParamTitle = computed(() => {
const param = selectedParam.value
if (!param) return ''
const unitText = param.unit ? `${param.unit}` : ''
return `参数:${param.label}${unitText}`
})
const chartOption = computed<EChartsOption>(() => {
const unit = selectedParam.value?.unit
return {
tooltip: { trigger: 'axis' },
grid: { left: 30, right: 20, top: 20, bottom: 40, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: chartXAxis.value
},
yAxis: {
type: 'value',
name: unit || ''
},
series: [
{
type: 'line',
name: selectedParam.value?.label || '参数',
smooth: true,
showSymbol: false,
data: chartSeries.value
}
]
}
})
const handleKeywordChange = () => {
treeRef.value?.filter(keyword.value)
}
const filterTreeNode = (value: string, data: DeviceTreeNode) => {
if (!value) return true
return data.label?.toLowerCase().includes(value.toLowerCase())
}
const mockFetchTree = async (): Promise<DeviceTreeNode[]> => {
const devices = [
{ id: 'D-1001', label: '压合机-01' },
{ id: 'D-1002', label: '烘干线-02' },
{ id: 'D-1003', label: '制浆机-03' },
{ id: 'D-1004', label: '包装机-04' }
]
const params = [
{ key: 'temp', label: '温度', unit: '℃' },
{ key: 'pressure', label: '压力', unit: 'kPa' },
{ key: 'speed', label: '速度', unit: 'm/s' },
{ key: 'power', label: '功率', unit: 'kW' },
{ key: 'current', label: '电流', unit: 'A' }
]
await new Promise((resolve) => setTimeout(resolve, 200))
return devices.map((d) => ({
id: d.id,
label: d.label,
type: 'device',
deviceId: d.id,
children: params.map((p) => ({
id: `${d.id}::${p.key}`,
label: p.label,
type: 'param',
deviceId: d.id,
paramKey: p.key,
unit: p.unit
}))
}))
}
const buildSeriesSeed = (key: string) => {
let hash = 0
for (let i = 0; i < key.length; i++) {
hash = (hash << 5) - hash + key.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
const mockFetchSeries = async (args: { deviceId: string; paramKey: string; start: string; end: string }) => {
await new Promise((resolve) => setTimeout(resolve, 250))
const start = dayjs(args.start)
const end = dayjs(args.end)
const diff = Math.max(end.startOf('day').diff(start.startOf('day'), 'day'), 0)
const count = Math.min(diff + 1, 366)
const x: string[] = []
const y: number[] = []
const seed = buildSeriesSeed(`${args.deviceId}::${args.paramKey}`)
const base = 20 + (seed % 30)
const amp = 5 + (seed % 10)
for (let i = 0; i < count; i++) {
const d = start.add(i, 'day')
x.push(d.format('YYYY-MM-DD'))
const v = base + Math.sin(i / 2) * amp + ((seed % 7) - 3) * 0.6
y.push(Number(v.toFixed(2)))
}
return { x, y }
}
const loadTree = async () => {
treeLoading.value = true
try {
treeData.value = await mockFetchTree()
} finally {
treeLoading.value = false
}
}
const ensureDateRange = () => {
if (!dateRange.value || dateRange.value.length !== 2) {
dateRange.value = buildDefaultDateRange()
}
}
const fetchChart = async () => {
const param = selectedParam.value
if (!param?.deviceId || !param.paramKey) return
ensureDateRange()
const [start, end] = dateRange.value
if (!start || !end) return
chartLoading.value = true
try {
const res = await mockFetchSeries({ deviceId: param.deviceId, paramKey: param.paramKey, start, end })
chartXAxis.value = res.x
chartSeries.value = res.y
} catch {
message.error('获取图表数据失败')
} finally {
chartLoading.value = false
}
}
const handleTreeNodeClick = async (data: DeviceTreeNode) => {
if (data.type !== 'param') return
selectedParam.value = data
dateRange.value = buildDefaultDateRange()
await fetchChart()
}
const handleQuery = async () => {
if (!selectedParam.value) return
await fetchChart()
}
const resetQuery = async () => {
dateRange.value = buildDefaultDateRange()
await fetchChart()
}
onMounted(async () => {
await loadTree()
})
</script>
<style scoped lang="scss">
:deep(.el-tree) {
max-height: calc(100vh - 280px);
overflow: auto;
}
</style>

@ -3,11 +3,13 @@
<!-- 搜索工作栏 -->
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="100px">
<el-form-item label="点位编码" prop="attributeCode">
<el-input v-model="queryParams.attributeCode" placeholder="请输入点位编码" clearable @keyup.enter="handleQuery"
<el-input
v-model="queryParams.attributeCode" placeholder="请输入点位编码" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="点位名称" prop="attributeName">
<el-input v-model="queryParams.attributeName" placeholder="请输入点位名称" clearable @keyup.enter="handleQuery"
<el-input
v-model="queryParams.attributeName" placeholder="请输入点位名称" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="点位类型" prop="attributeType">
@ -45,7 +47,8 @@
<el-button type="primary" plain @click="openForm('create')" v-hasPermi="['iot:device-model:create']">
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button type="success" plain @click="handleExport" :loading="exportLoading"
<el-button
type="success" plain @click="handleExport" :loading="exportLoading"
v-hasPermi="['iot:device-model:export']">
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
@ -59,7 +62,8 @@
<!-- 列表 -->
<ContentWrap>
<el-table ref="tableRef" v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" row-key="id"
<el-table
ref="tableRef" v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" row-key="id"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" reserve-selection />
<!-- <el-table-column label="ID" align="center" prop="id" /> -->
@ -75,7 +79,8 @@
<el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180px" />
<el-table-column label="操作" align="center" width="150px" fixed="right">
<template #default="scope">
<el-button link type="primary" @click="openForm('update', scope.row.id)"
<el-button
link type="primary" @click="openForm('update', scope.row.id)"
v-hasPermi="['iot:device-model:update']">
编辑
</el-button>
@ -86,7 +91,8 @@
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
<Pagination
:total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
</ContentWrap>

@ -3,17 +3,20 @@
<!-- 搜索工作栏 -->
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="68px">
<el-form-item label="模型编码" prop="code">
<el-input v-model="queryParams.code" placeholder="请输入模型编码" clearable @keyup.enter="handleQuery"
<el-input
v-model="queryParams.code" placeholder="请输入模型编码" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="模型名称" prop="name">
<el-input v-model="queryParams.name" placeholder="请输入模型名称" clearable @keyup.enter="handleQuery"
<el-input
v-model="queryParams.name" placeholder="请输入模型名称" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="通讯协议" prop="protocol">
<el-select v-model="queryParams.protocol" placeholder="请选择通讯协议" clearable class="!w-240px">
<el-option v-for="dict in getStrDictOptions(DICT_TYPE.IOT_PROTOCOL)" :key="dict.value" :label="dict.label"
<el-option
v-for="dict in getStrDictOptions(DICT_TYPE.IOT_PROTOCOL)" :key="dict.value" :label="dict.label"
:value="dict.value" />
</el-select>
</el-form-item>
@ -51,7 +54,8 @@
<el-button type="danger" plain @click="handleBatchDelete" v-hasPermi="['iot:device-model:delete']">
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
<el-button type="success" plain @click="handleExport" :loading="exportLoading"
<el-button
type="success" plain @click="handleExport" :loading="exportLoading"
v-hasPermi="['iot:device-model:export']">
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
@ -61,7 +65,8 @@
<!-- 列表 -->
<ContentWrap>
<el-table ref="tableRef" v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"
<el-table
ref="tableRef" v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"
highlight-current-row row-key="id" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" reserve-selection />
<el-table-column label="模型编码" align="center" prop="code" />
@ -79,7 +84,8 @@
<el-button link type="primary" @click="handleCopy(scope.row.id)" v-hasPermi="['iot:device-model:create']">
复制
</el-button>
<el-button link type="primary" @click="openForm('update', scope.row.id)"
<el-button
link type="primary" @click="openForm('update', scope.row.id)"
v-hasPermi="['iot:device-model:update']">
编辑
</el-button>
@ -90,7 +96,8 @@
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
<Pagination
:total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
</ContentWrap>

@ -0,0 +1,266 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="900" :scroll="true" max-height="80vh" align-center>
<div class="single-device-dialog">
<el-form class="-mb-15px" :inline="true" label-width="80px">
<el-form-item label="采集时间">
<el-date-picker v-model="collectionTimeRange" value-format="YYYY-MM-DD HH:mm:ss" type="datetimerange"
start-placeholder="开始时间" end-placeholder="结束时间"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-360px" />
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"></el-button>
<el-button @click="resetQuery"></el-button>
</el-form-item>
</el-form>
<ContentWrap v-loading="loading">
<div v-if="recordGroups.length" class="single-device-dialog__record-list">
<div v-for="group in recordGroups" :key="group.key" class="single-device-dialog__record">
<div class="single-device-dialog__record-title">
采集时间{{ group.collectTime || '-' }}
</div>
<div v-if="group.sections.length" class="single-device-dialog__table-grid">
<div v-for="section in group.sections" :key="`${group.key}-${section.key}`"
class="single-device-dialog__section">
<div class="single-device-dialog__section-title">
{{ section.title }}
</div>
<el-empty v-if="!section.columns.length" description="暂无数据" />
<el-table v-else :data="section.rows" :border="true" :header-cell-style="headerCellStyle"
:cell-style="bodyCellStyle" size="small">
<el-table-column v-for="col in section.columns" :key="col.prop" :prop="col.prop" :label="col.label"
align="center">
<template #default="scope">
<span>{{ formatCell(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-empty v-else description="暂无数据" />
</div>
</div>
<el-empty v-else description="暂无数据" />
</ContentWrap>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceApi } from '@/api/iot/device'
type SectionColumn = {
prop: string
label: string
}
type SectionRow = Record<string, string | number | null>
type Section = {
key: string
title: string
columns: SectionColumn[]
rows: SectionRow[]
}
type RecordGroup = {
key: string
collectTime?: string
sections: Section[]
}
const props = defineProps<{
modelValue: boolean
deviceId?: string | number
deviceName?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const dialogVisible = computed({
get() {
return props.modelValue
},
set(value: boolean) {
emit('update:modelValue', value)
}
})
const dialogTitle = computed(() => {
const name = props.deviceName ? props.deviceName : '-'
return `历史记录:${name}`
})
const loading = ref(false)
const recordGroups = ref<RecordGroup[]>([])
const collectionTimeRange = ref<string[]>([])
const toGroupMap = (value: any): Record<string, any[]> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const entries = Object.entries(value).filter(([, v]) => Array.isArray(v))
if (!entries.length) {
return {}
}
const map: Record<string, any[]> = {}
for (const [k, v] of entries) {
map[k] = v as any[]
}
return map
}
const formatCell = (value: string | number | null | undefined) => {
if (value === null || value === undefined) {
return ''
}
return value
}
const headerCellStyle = () => {
return {
fontWeight: 500,
padding: '6px 4px',
backgroundColor: 'var(--el-fill-color-light)',
color: 'var(--el-text-color-primary)'
}
}
const bodyCellStyle = () => {
return {
padding: '4px 4px'
}
}
const buildSectionsFromGroups = (groups: Record<string, any[]>): Section[] => {
const result: Section[] = []
for (const [key, list] of Object.entries(groups)) {
const columns: SectionColumn[] = []
const row: SectionRow = {}
if (Array.isArray(list) && list.length) {
list.forEach((item: any, index: number) => {
const prop = `col${index}`
const label = item?.attributeName ?? `字段${index + 1}`
columns.push({ prop, label })
row[prop] = item?.addressValue ?? ''
})
}
result.push({
key,
title: key,
columns,
rows: columns.length ? [row] : []
})
}
return result
}
const fetchHistory = async () => {
if (props.deviceId === undefined || props.deviceId === null || props.deviceId === '') {
recordGroups.value = []
return
}
loading.value = true
try {
const params: Parameters<typeof DeviceApi.getHistoryRecord>[0] = { deviceId: props.deviceId }
if (Array.isArray(collectionTimeRange.value) && collectionTimeRange.value.length === 2) {
params.collectionStartTime = collectionTimeRange.value[0]
params.collectionEndTime = collectionTimeRange.value[1]
}
const res: any = await DeviceApi.getHistoryRecord(params)
const list = res?.data?.data ?? res?.data ?? res
const records = Array.isArray(list) ? list : []
recordGroups.value = records.map((item: any, index: number) => {
const groups = toGroupMap(item)
return {
key: `${item?.collectTime ?? index}-${index}`,
collectTime: item?.collectTime,
sections: buildSectionsFromGroups(groups)
}
})
} finally {
loading.value = false
}
}
const handleQuery = () => {
fetchHistory()
}
const resetQuery = () => {
collectionTimeRange.value = []
fetchHistory()
}
watch(
() => [props.modelValue, props.deviceId],
([visible]) => {
if (!visible) {
return
}
fetchHistory()
}
)
</script>
<style scoped>
.single-device-dialog {
display: flex;
flex-direction: column;
gap: 12px;
}
.single-device-dialog__table-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 12px;
}
.single-device-dialog__record-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.single-device-dialog__record {
padding: 12px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color-overlay);
}
.single-device-dialog__section {
padding: 10px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-fill-color-lighter);
}
.single-device-dialog__section :deep(.el-table) {
--el-table-row-hover-bg-color: transparent;
}
.single-device-dialog__record-title {
font-size: 13px;
margin-bottom: 6px;
color: var(--el-text-color-secondary);
}
.single-device-dialog__section-title {
font-size: 13px;
margin-bottom: 4px;
color: var(--el-text-color-primary);
}
.single-device-dialog__section :deep(.el-table__inner-wrapper) {
border-radius: 0;
}
.single-device-dialog__section :deep(.el-table__header-wrapper th) {
border-bottom-color: var(--el-border-color);
}
</style>

@ -0,0 +1,191 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="80px"
>
<el-form-item label="产线编码" prop="lineNode">
<el-input
v-model="queryParams.lineNode"
placeholder="请输入产线编码"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="产线名称" prop="lineName">
<el-input
v-model="queryParams.lineName"
placeholder="请输入产线名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="设备编码" prop="deviceCode">
<el-input
v-model="queryParams.deviceCode"
placeholder="请输入设备编码"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="queryParams.deviceName"
placeholder="请输入设备名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
row-key="id"
>
<el-table-column label="产线编码" align="left" prop="lineNode" min-width="140px" />
<el-table-column label="产线名称" align="left" prop="lineName" min-width="160px" />
<el-table-column label="设备编码" align="left" prop="deviceCode" min-width="140px" />
<el-table-column label="设备名称" align="left" prop="deviceName" min-width="160px" />
<el-table-column
label="采集时间"
align="center"
prop="collectionTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" fixed="right" width="150px">
<template #default="scope">
<el-button link type="primary" @click="handleSingleView(scope.row)">
历史记录
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<HistorySingleDeviceDialog
v-model="singleDialogVisible"
:device-id="singleDeviceId"
:device-name="singleDeviceName"
/>
</template>
<script setup lang="ts">
import { dateFormatter } from '@/utils/formatTime'
import { DeviceApi, LineDeviceVO, LineDevicePageParams } from '@/api/iot/device'
import HistorySingleDeviceDialog from './HistorySingleDeviceDialog.vue'
defineOptions({ name: 'HistoryData' })
const loading = ref(true)
const list = ref<LineDeviceVO[]>([])
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
id: undefined as string | number | undefined,
lineNode: undefined as string | undefined,
lineName: undefined as string | undefined,
deviceCode: undefined as string | undefined,
deviceName: undefined as string | undefined,
status: undefined as string | number | undefined
})
const queryFormRef = ref()
const singleDialogVisible = ref(false)
const singleDeviceId = ref<string | number>()
const singleDeviceName = ref<string>('')
const buildQueryParams = (): LineDevicePageParams => {
const params: LineDevicePageParams = {
pageNo: queryParams.pageNo,
pageSize: queryParams.pageSize
}
if (queryParams.lineNode) {
params.lineNode = queryParams.lineNode
}
if (queryParams.lineName) {
params.lineName = queryParams.lineName
}
if (queryParams.deviceCode) {
params.deviceCode = queryParams.deviceCode
}
if (queryParams.deviceName) {
params.deviceName = queryParams.deviceName
}
return params
}
const getList = async () => {
loading.value = true
try {
const data = await DeviceApi.getLineDevicePage(buildQueryParams())
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
}
const handleSingleView = (row: LineDeviceVO) => {
const deviceId = (row as any)?.deviceId ?? row?.id
if (deviceId === undefined || deviceId === null || deviceId === '') {
return
}
singleDeviceId.value = deviceId
singleDeviceName.value = row.deviceName || ''
singleDialogVisible.value = true
}
onMounted(() => {
getList()
})
const activatedOnce = ref(false)
onActivated(() => {
if (!activatedOnce.value) {
activatedOnce.value = true
return
}
getList()
})
</script>

@ -0,0 +1,215 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="900">
<div class="single-device-dialog">
<div class="single-device-dialog__header">
<div>设备名称{{ deviceName || '-' }}</div>
<div>采集时间{{ displayTime }}</div>
</div>
<ContentWrap v-loading="loading">
<div v-if="sections.length" class="single-device-dialog__table-grid">
<div v-for="section in sections" :key="section.key" class="single-device-dialog__section">
<div class="single-device-dialog__section-title">
{{ section.title }}
</div>
<el-empty v-if="!section.columns.length" description="暂无数据" />
<el-table
v-else :data="section.rows" :border="true" :header-cell-style="headerCellStyle"
:cell-style="bodyCellStyle" size="small">
<el-table-column
v-for="col in section.columns" :key="col.prop" :prop="col.prop" :label="col.label"
align="center">
<template #default="scope">
<span>{{ formatCell(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-empty v-else description="暂无数据" />
</ContentWrap>
</div>
</Dialog>
</template>
<script setup lang="ts">
import { formatDate } from '@/utils/formatTime'
import { DeviceApi } from '@/api/iot/device'
type SectionColumn = {
prop: string
label: string
}
type SectionRow = Record<string, string | number | null>
type Section = {
key: string
title: string
columns: SectionColumn[]
rows: SectionRow[]
}
const props = defineProps<{
modelValue: boolean
deviceId?: string | number
deviceName?: string
collectionTime?: string | number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const dialogVisible = computed({
get() {
return props.modelValue
},
set(value: boolean) {
emit('update:modelValue', value)
}
})
const dialogTitle = computed(() => {
return '单设备监控'
})
const loading = ref(false)
const deviceName = computed(() => props.deviceName)
const sections = ref<Section[]>([])
const toGroupMap = (value: any): Record<string, any[]> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const entries = Object.entries(value).filter(([, v]) => Array.isArray(v))
if (!entries.length) {
return {}
}
const map: Record<string, any[]> = {}
for (const [k, v] of entries) {
map[k] = v as any[]
}
return map
}
const displayTime = computed(() => {
if (!props.collectionTime) {
return '-'
}
const value = props.collectionTime
if (typeof value === 'number') {
return formatDate(new Date(value))
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return formatDate(date)
})
const headerCellStyle = () => {
return {
fontWeight: 500,
padding: '6px 4px'
}
}
const bodyCellStyle = () => {
return {
padding: '4px 4px'
}
}
const formatCell = (value: string | number | null | undefined) => {
if (value === null || value === undefined) {
return ''
}
return value
}
const buildSectionsFromGroups = (groups: Record<string, any[]>): Section[] => {
const result: Section[] = []
for (const [key, list] of Object.entries(groups)) {
const columns: SectionColumn[] = []
const row: SectionRow = {}
if (Array.isArray(list) && list.length) {
list.forEach((item: any, index: number) => {
const prop = `col${index}`
const label = item?.attributeName ?? `字段${index + 1}`
columns.push({ prop, label })
row[prop] = item?.addressValue ?? ''
})
}
result.push({
key,
title: key,
columns,
rows: columns.length ? [row] : []
})
}
return result
}
const fetchList = async () => {
if (props.deviceId === undefined || props.deviceId === null || props.deviceId === '') {
sections.value = []
return
}
loading.value = true
try {
const res: any = await DeviceApi.getSingleDevice({ deviceId: props.deviceId })
const groups = Array.isArray(res)
? { 默认: res }
: {
...toGroupMap(res?.data),
...toGroupMap(res?.data?.data),
...toGroupMap(res)
}
sections.value = buildSectionsFromGroups(groups)
} finally {
loading.value = false
}
}
watch(
() => [props.modelValue, props.deviceId],
([visible]) => {
if (!visible) {
return
}
fetchList()
}
)
</script>
<style scoped>
.single-device-dialog {
display: flex;
flex-direction: column;
gap: 12px;
}
.single-device-dialog__header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
color: var(--el-text-color-primary);
}
.single-device-dialog__table-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 12px;
}
.single-device-dialog__section-title {
font-size: 13px;
margin-bottom: 4px;
color: var(--el-text-color-primary);
}
.single-device-dialog__section :deep(.el-table__inner-wrapper) {
border-radius: 0;
}
</style>

@ -3,39 +3,23 @@
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="68px">
<el-form-item label="产线编码" prop="lineNode">
<el-input
v-model="queryParams.lineNode"
placeholder="请输入产线编码"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
v-model="queryParams.lineNode" placeholder="请输入产线编码" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="产线名称" prop="lineName">
<el-input
v-model="queryParams.lineName"
placeholder="请输入产线名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
v-model="queryParams.lineName" placeholder="请输入产线名称" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="设备编码" prop="deviceCode">
<el-input
v-model="queryParams.deviceCode"
placeholder="请输入设备编码"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
v-model="queryParams.deviceCode" placeholder="请输入设备编码" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="queryParams.deviceName"
placeholder="请输入设备名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
@ -49,13 +33,7 @@
</ContentWrap>
<ContentWrap>
<el-table
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
row-key="id"
>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" row-key="id">
<el-table-column label="产线编码" align="left" prop="lineNode" min-width="140px" />
<el-table-column label="产线名称" align="left" prop="lineName" min-width="160px" />
<el-table-column label="设备编码" align="left" prop="deviceCode" min-width="140px" />
@ -65,13 +43,7 @@
<dict-tag :type="DICT_TYPE.IOT_GATEWAY_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column
label="最新采集时间"
align="center"
prop="collectionTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="最新采集时间" align="center" prop="collectionTime" :formatter="dateFormatter" width="180px" />
<el-table-column label="操作" align="center" fixed="right" width="150px">
<template #default="scope">
<el-button link type="primary" @click="handleSingleMonitor(scope.row)"></el-button>
@ -79,50 +51,28 @@
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
:total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
</ContentWrap>
<Dialog v-model="monitorDialogVisible" :title="monitorDialogTitle" width="900">
<ContentWrap>
<el-table
v-loading="monitorLoading"
:data="monitorList"
:stripe="true"
:show-overflow-tooltip="true"
row-key="pointCode"
>
<el-table-column label="点位编码" align="left" prop="pointCode" min-width="140px" />
<el-table-column label="点位名称" align="left" prop="pointName" min-width="160px" />
<el-table-column label="数据类型" align="center" prop="dataType" width="120px" />
<el-table-column label="最新值" align="center" prop="latestValue" width="120px">
<template #default="scope">
{{ scope.row.latestValue === null ? 'null' : scope.row.latestValue }}
</template>
</el-table-column>
<el-table-column label="单位" align="center" prop="unit" width="100px" />
<el-table-column
label="最新采集时间"
align="center"
prop="latestCollectTime"
:formatter="dateFormatter"
width="180px"
/>
</el-table>
</ContentWrap>
</Dialog>
<SingleDeviceMonitorDialog
v-model="monitorDialogVisible"
:device-id="monitorDeviceId"
:device-name="monitorDeviceName"
:collection-time="monitorCollectionTime"
/>
</template>
<script setup lang="ts">
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { DeviceApi, LineDeviceVO } from '@/api/iot/device'
import SingleDeviceMonitorDialog from './SingleDeviceMonitorDialog.vue'
defineOptions({ name: 'RealTimeMonitoring' })
const message = useMessage()
const loading = ref(true)
const list = ref<LineDeviceVO[]>([])
const total = ref(0)
@ -139,100 +89,23 @@ const queryParams = reactive({
})
const queryFormRef = ref() //
type MonitorRow = {
pointCode: string
pointName: string
dataType: string
latestValue: any
unit: string
latestCollectTime: string
}
const monitorDialogVisible = ref(false)
const monitorDialogTitle = ref('单设备监控')
const monitorLoading = ref(false)
const monitorList = ref<MonitorRow[]>([])
const buildMockCollectTime = (hour: number, minute: number, second: number) => {
const hh = String(hour).padStart(2, '0')
const mm = String(minute).padStart(2, '0')
const ss = String(second).padStart(2, '0')
return `2025-11-25 ${hh}:${mm}:${ss}`
}
const buildMockMonitorList = () => {
return [
{
pointCode: 'state',
pointName: '运行状态',
dataType: 'bool',
latestValue: 100,
unit: '',
latestCollectTime: buildMockCollectTime(9, 16, 12)
},
{
pointCode: 'faultCode',
pointName: '故障代码',
dataType: 'int',
latestValue: null,
unit: '',
latestCollectTime: buildMockCollectTime(9, 16, 18)
},
{
pointCode: 'machineOutp',
pointName: '产量',
dataType: 'long',
latestValue: 100,
unit: '',
latestCollectTime: buildMockCollectTime(9, 16, 25)
},
{
pointCode: 'cycleTime',
pointName: '节拍(秒)',
dataType: 'int',
latestValue: 45,
unit: '',
latestCollectTime: buildMockCollectTime(9, 16, 31)
},
{
pointCode: 'goodCount',
pointName: '良品数',
dataType: 'long',
latestValue: 980,
unit: '',
latestCollectTime: buildMockCollectTime(9, 16, 36)
},
{
pointCode: 'rejectCount',
pointName: '不良数',
dataType: 'long',
latestValue: 12,
unit: '',
latestCollectTime: buildMockCollectTime(9, 16, 42)
},
{
pointCode: 'alarmFlag',
pointName: '报警标识',
dataType: 'bool',
latestValue: 0,
unit: '',
latestCollectTime: buildMockCollectTime(9, 16, 49)
}
] as MonitorRow[]
}
const monitorDeviceId = ref<string | number>()
const monitorDeviceName = ref<string>()
const monitorCollectionTime = ref<string | number>()
const buildQueryParams = () => {
const params: Record<string, any> = {
const buildQueryParams = (): Parameters<typeof DeviceApi.getLineDevicePage>[0] => {
const params: Parameters<typeof DeviceApi.getLineDevicePage>[0] = {
pageNo: queryParams.pageNo,
pageSize: queryParams.pageSize
}
const keys = ['id', 'lineNode', 'lineName', 'deviceCode', 'deviceName', 'status', 'collectionTime']
const keys = ['id', 'lineNode', 'lineName', 'deviceCode', 'deviceName', 'status', 'collectionTime'] as const
for (const key of keys) {
const value = (queryParams as any)[key]
const value = queryParams[key]
if (value === undefined || value === null || value === '') {
continue
}
params[key] = value
(params as any)[key] = value
}
return params
}
@ -259,32 +132,18 @@ const resetQuery = () => {
}
const handleSingleMonitor = (row: LineDeviceVO) => {
monitorDialogTitle.value = row?.deviceName ? `单设备监控:${row.deviceName}` : '单设备监控'
monitorLoading.value = true
monitorDialogVisible.value = true
try {
monitorList.value = buildMockMonitorList()
} finally {
monitorLoading.value = false
const deviceId = (row as any)?.deviceId ?? row?.id
if (deviceId === undefined || deviceId === null || deviceId === '') {
message.error('设备信息不完整')
return
}
monitorDeviceId.value = deviceId
monitorDeviceName.value = row?.deviceName ?? ''
monitorCollectionTime.value = row?.collectionTime
monitorDialogVisible.value = true
}
let timer: any = null
onMounted(() => {
getList()
timer = setInterval(async () => {
try {
const data = await DeviceApi.getLineDevicePage(buildQueryParams())
list.value = data.list
total.value = data.total
} catch {}
}, 5000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>

Loading…
Cancel
Save