5 Commits
master ... main

Author SHA1 Message Date
xiaozhiyong
14906a83d2 更新 2026-06-08 09:19:44 +08:00
xiaozhiyong
f182a7d736 更新 2026-06-08 09:19:34 +08:00
xiaozhiyong
b8d29b5006 更新 2026-06-08 09:06:45 +08:00
xiaozhiyong
d3e9fcce4c 更新 2026-06-05 09:52:51 +08:00
xiaozhiyong
1890cca75f 更新 2026-06-04 16:54:46 +08:00
13 changed files with 1193 additions and 262 deletions

View File

@@ -1,8 +1,8 @@
import service from '@/utils/request'
export const getDeviceWarnList = (data) => {
export const getAlarmRecordListByPage = (data) => {
return service({
url: '/device/getDeviceWarnList',
url: '/device/getAlarmRecordListByPage',
method: 'post',
data: data
})

View File

@@ -16,9 +16,9 @@ export const deviceOperation = (data) => {
})
}
export const getDeviceDetailsInfoByRemote = (data) => {
export const getDeviceDetailsListByPage = (data) => {
return service({
url: '/device/getDeviceDetailsInfoByRemote',
url: '/device/getDeviceDetailsListByPage',
method: 'post',
data: data
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -13,6 +13,11 @@
"/src/view/dashboard/index.vue": "Dashboard",
"/src/view/equipment/alarmRecord/index.vue": "Index",
"/src/view/equipment/gateway/index.vue": "Index",
"/src/view/equipment/list/components/detail/components/alarm/index.vue": "Index",
"/src/view/equipment/list/components/detail/components/info/index.vue": "Index",
"/src/view/equipment/list/components/detail/components/trend/index.vue": "Index",
"/src/view/equipment/list/components/detail/index.vue": "Index",
"/src/view/equipment/list/components/list/index.vue": "Index",
"/src/view/equipment/list/index.vue": "Index",
"/src/view/equipment/particulars/index.vue": "Index",
"/src/view/error/index.vue": "Error",

View File

@@ -1,5 +1,5 @@
@use '@/style/iconfont.css';
@use "./transition.scss";
@use './transition.scss';
.html-grey {
filter: grayscale(100%);
@@ -18,11 +18,11 @@
.gva-btn-list {
@apply mb-3 flex items-center flex-wrap gap-2;
.el-button+.el-button{
.el-button + .el-button {
@apply ml-0 !important;
}
.el-upload{
.el-button{
.el-upload {
.el-button {
@apply ml-0 !important;
}
}
@@ -41,6 +41,7 @@
.gva-search-box {
@apply p-4 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded my-2;
padding-bottom: 0;
}
.gva-form-box {

View File

@@ -3,13 +3,9 @@
<!-- <warning-bar title="注:右上角头像下拉可切换角色" /> -->
<div class="gva-search-box">
<el-form ref="searchForm" :inline="true" :model="searchInfo">
<el-form-item label="用户名">
<el-input v-model="searchInfo.username" placeholder="设备ID" />
<el-form-item label="设备ID">
<el-input v-model="searchInfo.deviceId" placeholder="设备ID" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="searchInfo.nickname" placeholder="设备状态" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit"> 查询 </el-button>
<el-button icon="refresh" @click="onReset"> 重置 </el-button>
@@ -18,7 +14,7 @@
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" icon="plus" @click="addUser">新增用户</el-button>
<!-- <el-button type="primary" icon="plus" @click="addUser">新增用户</el-button> -->
</div>
<el-table :data="tableData">
<el-table-column align="left" label="网关ID" prop="gatewayId" width="200" />
@@ -85,7 +81,7 @@
}
// 查询
const getTableData = async () => {
const table = await serve.getDeviceWarnList({
const table = await serve.getAlarmRecordListByPage({
page: page.value,
pageSize: pageSize.value,
...searchInfo.value
@@ -105,10 +101,7 @@
const onReset = () => {
searchInfo.value = {
username: '',
nickname: '',
phone: '',
email: ''
deviceId: ''
}
getTableData()
}

View File

@@ -3,11 +3,8 @@
<!-- <warning-bar title="注:右上角头像下拉可切换角色" /> -->
<div class="gva-search-box">
<el-form ref="searchForm" :inline="true" :model="searchInfo">
<el-form-item label="用户名">
<el-input v-model="searchInfo.username" placeholder="设备ID" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="searchInfo.nickname" placeholder="设备状态" />
<el-form-item label="网关ID">
<el-input v-model="searchInfo.gatewayId" placeholder="网关ID" />
</el-form-item>
<el-form-item>
@@ -18,7 +15,7 @@
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" icon="plus" @click="addUser">新增用户</el-button>
<!-- <el-button type="primary" icon="plus" @click="addUser">新增用户</el-button> -->
</div>
<el-table :data="tableData">
<el-table-column align="left" label="网关ID" prop="gatewayId" width="200" />
@@ -105,10 +102,7 @@
const onReset = () => {
searchInfo.value = {
username: '',
nickname: '',
phone: '',
email: ''
gatewayId: ''
}
getTableData()
}

View File

@@ -0,0 +1,173 @@
<template>
<div class="detail-content">
<div class="detail-card">
<h3>报警记录</h3>
<el-table :data="tableData" style="width: 100%" stripe>
<el-table-column prop="CreatedAt" label="报警时间" width="180" />
<el-table-column prop="type" label="报警类型" width="120">
<template #default="{ row }">
<el-tag :type="typeMap.find((item) => item.value == row.warnType)?.type || 'info'">{{
typeMap.find((item) => item.value == row.warnType)?.label || row.warnType
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="level" label="报警等级" width="100">
<template #default="{ row }">
<el-tag :type="levelMap.find((item) => item.value == row.alarmLevel)?.type || 'info'" effect="dark">{{
levelMap.find((item) => item.value == row.alarmLevel)?.label || row.alarmLevel
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="originalContent" label="报警描述">
<template #default="{ row }">
{{ row.remark || '-' }}
</template>
</el-table-column>
<!-- <el-table-column prop="status" label="处理状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '已处理' ? 'success' : 'danger'">{{ row.status }}</el-tag>
</template>
</el-table-column> -->
</el-table>
<div class="gva-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import * as serve from '@/api/equipment/alarmRecord'
const props = defineProps({
device: {
type: Object,
default: () => null
}
})
// 报警数据
const tableData = ref([])
// 分页相关
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
// 当前页变化
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 每页大小变化
const handleSizeChange = (val) => {
pageSize.value = val
page.value = 1
getTableData()
}
// 查询报警记录
const getTableData = async () => {
const deviceId = props.device?.ID
if (deviceId) {
try {
const table = await serve.getAlarmRecordListByPage({
page: page.value,
pageSize: pageSize.value,
deviceId,
sortBy: 'CreatedAt',
desc: true
})
if (table.code === 0) {
tableData.value = table.data.list || []
total.value = table.data.total || 0
return
}
} catch (e) {
console.error('获取报警记录失败', e)
}
} else {
console.warn('[Alarm] device 为空或没有 ID 字段')
}
}
// 监听 device 变化device 更新时重新请求
watch(
() => props.device,
(newDevice) => {
if (newDevice) {
getTableData()
}
},
{ deep: true, immediate: false }
)
const typeMap = [
{
value: '45',
type: 'danger',
label: '设备事件'
},
{
value: '4f',
type: 'danger',
label: '操作事件'
}
]
const levelMap = [
{
value: '0',
type: 'primary',
label: '记录'
},
{
value: '1',
type: 'warning',
label: '提醒'
},
{
value: '2',
type: 'danger',
label: '警告'
}
]
</script>
<style lang="scss" scoped>
.detail-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.detail-card {
background-color: var(--el-bg-color-overlay, #ffffff);
border-radius: 8px;
padding: 24px;
border: 1px solid var(--el-border-color, #e4e7ed);
h3 {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary, #303133);
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter, #ebeef5);
}
}
.empty-alarm {
padding: 40px 0;
display: flex;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="detail-content">
<div class="detail-card">
<h3>基本信息</h3>
<div class="detail-row">
<span class="detail-label">设备ID</span>
<span class="detail-value">{{ device?.ID || '未设置' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">网关ID</span>
<span class="detail-value">{{ device?.gatewayId || '未设置' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">设备类型名称</span>
<span class="detail-value">{{ device?.cbTypeName || '未设置' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">网关mac</span>
<span class="detail-value">{{ device?.gatewayMac || '未设置' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">协议</span>
<span class="detail-value">{{ device?.protocol || '未设置' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">品牌</span>
<span class="detail-value">{{ device?.brand || '未设置' }}</span>
</div>
</div>
<div class="detail-card">
<h3>状态信息</h3>
<div class="detail-row">
<span class="detail-label">设备状态</span>
<span class="detail-value">
<el-tag v-if="device?.cbTypeName !== 'T30'" :type="device?.deviceStatus == 1 ? 'success' : 'danger'">
{{ device?.deviceStatus == 1 ? '合闸' : '分闸' }}
</el-tag>
<span v-else>-</span>
</span>
</div>
<div class="detail-row">
<span class="detail-label">锁定状态</span>
<span class="detail-value">
<el-tag :type="['success', 'info', 'danger'][device?.lockStatus]">
{{ ['正常', '远程合闸禁止', '锁定'][device?.lockStatus] }}
</el-tag>
</span>
</div>
<div class="detail-row">
<span class="detail-label">网络状态</span>
<span class="detail-value">
<el-tag :type="device?.netStatus == 1 ? 'primary' : 'info'">
{{ device?.netStatus == 1 ? '在线' : '离线' }}
</el-tag>
</span>
</div>
</div>
<div class="detail-card">
<h3>其他信息</h3>
<div class="detail-row">
<span class="detail-label">安装时间</span>
<span class="detail-value">{{ device?.createTime || '未设置' }}</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
device: {
type: Object,
default: () => null
}
})
</script>
<style lang="scss" scoped>
.detail-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.detail-card {
background-color: var(--el-bg-color-overlay, #ffffff);
border-radius: 8px;
padding: 24px;
border: 1px solid var(--el-border-color, #e4e7ed);
h3 {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary, #303133);
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter, #ebeef5);
}
}
.detail-row {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px dashed var(--el-border-color-lighter, #ebeef5);
&:last-child {
border-bottom: none;
}
}
.detail-label {
width: 120px;
color: var(--el-text-color-regular, #606266);
font-size: 14px;
flex-shrink: 0;
}
.detail-value {
flex: 1;
color: var(--el-text-color-primary, #303133);
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,413 @@
<template>
<div ref="chartContainerRef" class="trend-content">
<div class="trend-toolbar">
<span class="toolbar-label">时间范围</span>
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="handleDateChange"
/>
<!-- <el-button type="primary" icon="Refresh" @click="resetDateRange">重置</el-button> -->
</div>
<el-row v-if="isReady" :gutter="16">
<el-col v-for="(item, index) in chartList" :key="item.key" :xs="24" :sm="12" :md="12" :lg="8" :xl="8">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">{{ item.title }}</span>
<span class="chart-unit">{{ item.unit }}</span>
</div>
<v-chart class="chart" :option="item.option" autoresize />
</div>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import * as serve from '@/api/equipment/list.js'
use([CanvasRenderer, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const props = defineProps({
device: {
type: Object,
default: () => null
}
})
// 容器 ref
const chartContainerRef = ref(null)
// 容器就绪状态:父级用 v-show 切换时,初始为 display:None宽度为 0需要等容器有尺寸后再渲染图表
const isReady = ref(false)
let resizeObserver = null
// 检测容器是否已具备尺寸
const checkContainerReady = () => {
if (isReady.value || !chartContainerRef.value) return
const { clientWidth, clientHeight } = chartContainerRef.value
if (clientWidth > 0 && clientHeight > 0) {
isReady.value = true
}
}
// 触发图表 resize
const triggerResize = () => {
// 触发 window resize 事件让 vue-echarts autoresize 生效
window.dispatchEvent(new Event('resize'))
}
// 格式化时间 - 转为 yyyy-MM-dd HH:mm:ss
const formatDateTime = (date) => {
if (!date) return ''
// 如果已经是字符串YYYY-MM-DD HH:mm:ss 格式),直接返回
if (typeof date === 'string') {
// 验证格式
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(date)) {
return date
}
// 如果是 ISO 格式,转换一下
const d = new Date(date)
if (isNaN(d.getTime())) return date
return formatDateTime(d)
}
// 如果是 Date 对象
if (date instanceof Date) {
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
return ''
}
const getTableData = async () => {
// 如果没有 device 或 ID不请求
if (!props.device) {
console.warn('[Trend] device 为空,跳过请求')
return
}
const deviceId = props.device.ID || props.device.id
if (!deviceId) {
console.warn('[Trend] device 没有 ID 字段')
return
}
if (!dateRange.value || dateRange.value.length !== 2) {
console.warn('[Trend] 日期范围无效')
return
}
// 格式化时间
// const startTime = formatDateTime(dateRange.value[0])
// const endTime = formatDateTime(dateRange.value[1])
try {
const result = await serve.getDeviceDetailsListByPage({
page: 1,
pageSize: 999,
deviceId,
startCreatedAt: dateRange.value[0],
endCreatedAt: dateRange.value[1],
orderKey: 'voltage',
sortBy: 'CreatedAt',
order: 'desc',
desc: true
})
if (result.code === 0) {
// 提取数据
const list = result.data.list || []
// 调试:打印第一条数据的所有字段
const timeData = []
const chartData = {
voltage: [], // 电压V
leakageCurrent: [], // 漏电流值mA
cumulativeElectricity: [], // 累计用电量kWh
current: [], // 电流值A
internalTemperature: [], // 内部温度(℃)
powerFactor: [] // 功率因数
}
list.forEach((item) => {
timeData.push(item.CreatedAt)
// 兼容多种命名风格PascalCase / camelCase
chartData.voltage.push(item.voltage)
chartData.leakageCurrent.push(item.leakageCurrent)
chartData.cumulativeElectricity.push(item.cumulativeElectricity)
chartData.current.push(item.current)
chartData.internalTemperature.push(item.internalTemperature)
chartData.powerFactor.push(item.powerFactor)
})
console.log('[Trend] X轴时间数据:', timeData)
console.log('[Trend] 处理后的图表数据:', chartData)
// 更新图表
updateChartData(timeData, chartData)
}
} catch (e) {}
}
// 监听 device 变化,重新请求数据
watch(
() => props.device,
(newDevice, oldDevice) => {
if (newDevice && (!oldDevice || newDevice.ID !== oldDevice.ID)) {
getTableData()
}
nextTick(() => {
triggerResize()
})
},
{ deep: true, immediate: false }
)
onMounted(() => {
// 父级用 v-show 切换 Tab组件挂载时可能是 display:None尺寸为 0
// 需要等容器有实际尺寸后再渲染 v-chart否则 ECharts 初始化时会报
// "Can't get DOM width or height"
if (chartContainerRef.value) {
// 先检查一次(处理初始就是可见的情况)
nextTick(checkContainerReady)
// 监听容器尺寸变化:从 0 变为有尺寸时(例如切到当前 Tab触发 isReady
resizeObserver = new ResizeObserver(() => {
checkContainerReady()
})
resizeObserver.observe(chartContainerRef.value)
}
})
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
})
// 日期范围 - 默认当前时间前一小时
const getTodayRange = () => {
const now = new Date()
const start = new Date(now.getTime() - 3600 * 1000)
return [formatDateTime(start), formatDateTime(now)]
}
const dateRange = ref(getTodayRange())
// 日期变化处理
const handleDateChange = (val) => {
if (val && val.length === 2) {
// console.log('[Trend] 日期范围:', val)
// 触发图表 resize
nextTick(() => {
// triggerResize()
getTableData()
})
}
}
// 重置日期
const resetDateRange = () => {
dateRange.value = []
nextTick(() => {
triggerResize()
})
}
// 生成模拟数据
const generateMockData = () => {
const hours = []
for (let i = 0; i < 24; i++) {
hours.push(`${i}:00`)
}
return hours
}
// 基础折线图配置
// data: 数据数组xAxis: X 轴数据(可选)
const createLineOption = (name, color, data = [], xAxis = []) => {
return {
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '8%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: xAxis.length ? xAxis : generateMockData(),
boundaryGap: false,
axisLine: {
lineStyle: {
color: '#909399'
}
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#909399'
}
},
splitLine: {
lineStyle: {
color: '#ebeef5',
type: 'dashed'
}
}
},
series: [
{
name,
type: 'line',
data,
smooth: true,
// 去掉数据点标点,全一条线
symbol: 'none',
itemStyle: {
color
},
lineStyle: {
color,
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: color + '40' },
{ offset: 1, color: color + '00' }
]
}
}
}
]
}
}
const chartList = ref([
{
key: 'voltage',
title: '电压',
unit: 'V',
color: '#409EFF',
option: createLineOption('电压', '#409EFF', [], [])
},
{
key: 'leakageCurrent',
title: '漏电流值',
unit: 'mA',
color: '#67C23A',
option: createLineOption('漏电流', '#67C23A', [], [])
},
{
key: 'cumulativeElectricity',
title: '累计用电量',
unit: 'kWh',
color: '#E6A23C',
option: createLineOption('用电量', '#E6A23C', [], [])
},
{
key: 'current',
title: '电流值',
unit: 'A',
color: '#F56C6C',
option: createLineOption('电流', '#F56C6C', [], [])
},
{
key: 'internalTemperature',
title: '内部温度',
unit: '℃',
color: '#909399',
option: createLineOption('内部温度', '#909399', [], [])
},
{
key: 'powerFactor',
title: '功率因数',
unit: '',
color: '#1ABC9C',
option: createLineOption('功率因数', '#1ABC9C', [], [])
}
])
// 更新图表数据
const updateChartData = (timeData, chartData) => {
chartList.value.forEach((item) => {
const seriesData = chartData[item.key] || []
item.option = createLineOption(item.title, item.color, seriesData, timeData)
})
}
</script>
<style lang="scss" scoped>
.trend-content {
padding: 0;
}
.trend-toolbar {
width: 40%;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 16px;
background-color: var(--el-bg-color-overlay, #ffffff);
border-radius: 8px;
border: 1px solid var(--el-border-color, #e4e7ed);
}
.toolbar-label {
font-size: 14px;
color: var(--el-text-color-regular, #606266);
white-space: nowrap;
}
.chart-card {
background-color: var(--el-bg-color-overlay, #ffffff);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--el-border-color, #e4e7ed);
margin-bottom: 16px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.chart-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary, #303133);
}
.chart-unit {
font-size: 12px;
color: var(--el-text-color-regular, #606266);
}
.chart {
height: 220px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div class="device-detail-page">
<div class="detail-header">
<el-button icon="ArrowLeft" circle @click="handleBack" />
<span class="detail-title">设备信息</span>
</div>
<el-menu :default-active="activeMenu" mode="horizontal" class="detail-menu" @select="handleMenuSelect">
<el-menu-item index="info">设备详情</el-menu-item>
<el-menu-item index="alarm">报警记录</el-menu-item>
<el-menu-item index="trend">历史曲线</el-menu-item>
</el-menu>
<DeviceInfo v-show="activeMenu === 'info'" :device="device" />
<DeviceTrend v-show="activeMenu === 'trend'" :device="device" />
<DeviceAlarm v-show="activeMenu === 'alarm'" :device="device" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import DeviceInfo from './components/info/index.vue'
import DeviceTrend from './components/trend/index.vue'
import DeviceAlarm from './components/alarm/index.vue'
defineProps({
device: {
type: Object,
default: () => null
}
})
const emit = defineEmits(['back'])
const activeMenu = ref('info')
const handleMenuSelect = (index) => {
activeMenu.value = index
}
const handleBack = () => {
activeMenu.value = 'info'
emit('back')
}
</script>
<style lang="scss" scoped>
.device-detail-page {
padding: 20px;
}
.detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.detail-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary, #303133);
}
.detail-menu {
margin-bottom: 20px;
// border-radius: 8px;
// background-color: var(--el-bg-color-overlay, #ffffff);
// border: 1px solid var(--el-border-color, #e4e7ed);
}
</style>

View File

@@ -0,0 +1,375 @@
<template>
<div>
<div class="gva-search-box">
<el-form ref="searchForm" :inline="true" :model="searchInfo">
<el-form-item label="设备ID">
<el-input v-model="searchInfo.ID" placeholder="请输入" clearable />
</el-form-item>
<el-form-item label="设备状态">
<el-select v-model="searchInfo.deviceStatus" clearable placeholder="请选择">
<el-option label="合闸" :value="1" />
<el-option label="分闸" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="协议">
<el-select v-model="searchInfo.protocol" clearable placeholder="请选择">
<el-option label="HTTP" value="HTTP" />
<el-option label="UDP" value="UDP" />
<el-option label="HEX" value="HEX" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit"> 查询 </el-button>
<el-button icon="refresh" @click="onReset"> 重置 </el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<!-- <el-button type="primary" icon="plus" @click="addUser">新增用户</el-button> -->
</div>
<div class="device-grid">
<div v-for="item in tableData" :key="item.ID" class="device-card" @click="handleCardClick(item, $event)">
<div class="device-image">
<img src="@/assets/img/equipment/breaker.png" alt="设备" />
<div class="signal-icon" :class="{ online: item.netStatus == 1 }">
<i class="el-icon-connection"></i>
</div>
</div>
<div class="device-info">
<div class="card-menu">
<el-dropdown trigger="click" @command="(cmd) => handleDeviceAction(cmd, item)">
<span class="menu-icon">
<el-icon><MoreFilled /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="item.deviceStatus == 1" command="changeStatus">分闸</el-dropdown-item>
<el-dropdown-item v-else command="changeStatus">分闸</el-dropdown-item>
<el-dropdown-item v-if="[1].includes(item.lockStatus)" command="unlock">解锁</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="status-tags">
<el-tag :type="item.netStatus == 1 ? 'primary' : 'info'">
{{ item.netStatus == 1 ? '在线' : '离线' }}
</el-tag>
<el-tag
v-if="item.cbTypeName !== 'T30' && item.deviceStatus != 1"
:type="item.deviceStatus == 1 ? 'success' : 'danger'"
>
{{ item.deviceStatus == 1 ? '合闸' : '分闸' }}
</el-tag>
<el-tag v-if="item.lockStatus != 0" :type="['success', 'info', 'danger'][item.lockStatus]">
{{ ['正常', '远程合闸禁止', '锁定'][item.lockStatus] }}
</el-tag>
</div>
<div class="device-name">{{ item.cbTypeName }}</div>
<div class="device-id-row">
<span class="device-id">ID:{{ item.ID }}</span>
<el-button size="small" type="primary" link icon="CopyDocument" @click.stop="copyDeviceId(item.ID)">
</el-button>
</div>
<div class="device-location">
<el-icon><LocationInformation /></el-icon>
<span>{{ item.gatewayMac }}</span>
</div>
<div class="device-channels">
<el-icon><Postcard /></el-icon>
<span>{{ item.brand }}</span>
</div>
</div>
</div>
</div>
<div class="gva-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import * as serve from '@/api/equipment/list'
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MoreFilled } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['select-device'])
const searchInfo = ref({
deviceStatus: '',
protocol: '',
ID: ''
})
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
onMounted(() => {
getTableData()
})
// 点击设备卡片
const handleCardClick = (device, event) => {
// 如果点击的是 el-dropdown 相关元素,不跳转
if (event) {
const target = event.target
// 检查点击目标是否是 el-dropdown 内部元素
if (target.closest('.el-dropdown') || target.closest('.el-popper') || target.closest('.card-menu')) {
return
}
}
emit('select-device', device)
}
// 解锁
const unlock = async (row) => {
ElMessageBox.confirm('确认解锁吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await serve.deviceOperation({
id: row.ID,
gatewayId: row.gatewayId,
para: '0xAD'
})
if (res.code === 0) {
ElMessage.success('操作成功')
getTableData()
}
})
}
// 合分闸
const changeStatus = (row) => {
ElMessageBox.confirm(row.deviceStatus == 1 ? '确认分闸吗?' : '确认合闸吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await serve.deviceOperation({
id: row.ID,
gatewayId: row.gatewayId,
para: row.deviceStatus == 1 ? '0xA2' : '0xA1'
})
if (res.code === 0) {
ElMessage.success('操作成功')
getTableData()
}
})
}
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 查询
const getTableData = async () => {
const table = await serve.getDeviceListByPage({
page: page.value,
pageSize: pageSize.value,
...searchInfo.value
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
const onSubmit = () => {
page.value = 1
getTableData()
}
const onReset = () => {
searchInfo.value = {
deviceStatus: '',
protocol: '',
ID: ''
}
getTableData()
}
// 复制设备ID
const copyDeviceId = async (id) => {
if (!id) return
try {
await navigator.clipboard.writeText(id)
ElMessage.success('复制成功')
} catch {
ElMessage.error('复制失败')
}
}
// 处理设备操作
const handleDeviceAction = (command, item) => {
switch (command) {
case 'changeStatus':
changeStatus(item)
break
case 'unlock':
unlock(item)
break
}
}
</script>
<style lang="scss" scoped>
// 设备卡片容器
.container-device {
padding: 24px;
background-color: var(--el-bg-color, #f5f7fa);
}
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
width: 100%;
background-color: var(--el-bg-color, #f5f7fa);
}
.device-card {
background-color: var(--el-bg-color-overlay, #ffffff);
border-radius: 8px;
padding: 16px;
display: flex;
gap: 12px;
color: var(--el-text-color-regular, #303133);
position: relative;
transition: all 0.3s ease;
border: 1px solid var(--el-border-color, #e4e7ed);
align-items: center;
cursor: pointer;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: var(--el-color-primary, #409eff);
}
}
.device-image {
position: relative;
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
.signal-icon {
position: absolute;
top: 0;
right: 0;
font-size: 14px;
color: var(--el-text-color-placeholder, #9ca3af);
&.online {
color: #10b981;
}
}
}
.device-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
padding-right: 20px;
}
.card-menu {
position: absolute;
top: -4px;
right: 0;
cursor: pointer;
color: var(--el-text-color-placeholder, #94a3b8);
font-size: 18px;
&:hover {
color: var(--el-text-color-primary, #303133);
}
}
.status-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.device-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary, #303133);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-id-row {
display: flex;
align-items: center;
gap: 4px;
.device-id {
flex: 1;
font-size: 12px;
color: var(--el-color-primary, #409eff);
word-break: break-all;
}
}
.device-location,
.device-channels {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-regular, #606266);
.el-icon {
font-size: 14px;
flex-shrink: 0;
}
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>

View File

@@ -1,244 +1,25 @@
<template>
<div>
<!-- <warning-bar title="注:右上角头像下拉可切换角色" /> -->
<div class="gva-search-box">
<el-form ref="searchForm" :inline="true" :model="searchInfo">
<el-form-item label="设备状态">
<el-select v-model="searchInfo.deviceStatus" placeholder="请选择">
<el-option label="合闸" :value="1" />
<el-option label="分闸" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit"> 查询 </el-button>
<el-button icon="refresh" @click="onReset"> 重置 </el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" icon="plus" @click="addUser">新增用户</el-button>
</div>
<el-table :data="tableData" row-key="ID">
<!-- <el-table-column align="left" label="设备图片" min-width="75">
<template #default="scope">
<CustomPic style="margin-top: 8px" :pic-src="scope.row.headerImg" />
</template>
</el-table-column> -->
<el-table-column align="left" label="设备ID" prop="cbId">
<template #default="{ row }">
{{ row.cbId || '未设置' }}
</template>
</el-table-column>
<!-- <el-table-column align="left" label="项目ID" prop="projectId">
<template #default="{ row }">
{{ row.projectId || '未设置' }}
</template>
</el-table-column> -->
<el-table-column align="left" label="网关ID" prop="gatewayId">
<template #default="{ row }">
{{ row.gatewayId || '未设置' }}
</template>
</el-table-column>
<el-table-column align="left" label="设备状态">
<template #default="{ row }">
<el-tag v-if="row.cbTypeName !== 'T30'" :type="row.deviceStatus == 1 ? 'success' : 'danger'">{{
row.deviceStatus == 1 ? '合闸' : '分闸'
}}</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="锁定状态">
<template #default="{ row }">
<el-tag :type="['success', 'info', 'danger'][row.lockStatus]">{{
['正常', '远程合闸禁止', '锁定'][row.lockStatus]
}}</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="网络">
<template #default="{ row }">
<el-tag :type="row.netStatus == 1 ? 'primary' : 'info'">{{ row.netStatus == 1 ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="设备类型名称" prop="cbTypeName" />
<el-table-column align="left" label="网关mac" prop="gatewayMac" />
<el-table-column align="left" label="协议" prop="protocol" />
<el-table-column align="left" label="品牌" prop="brand" />
<el-table-column align="left" label="安装时间" prop="createTime">
<template #default="{ row }">
{{ row.createTime || '未设置' }}
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="240">
<template #default="{ row }">
<span class="action-buttons">
<el-button v-if="row.deviceStatus == 1" type="primary" link icon="SortUp" @click="changeStatus(row)">
分闸
</el-button>
<el-button v-else type="primary" link icon="SortDown" @click="changeStatus(row)">合闸</el-button>
<el-button v-if="[1].includes(row.lockStatus)" type="primary" link icon="Unlock" @click="unlock(row)">
解锁
</el-button>
<el-dropdown>
<span class="el-dropdown-link">
更多
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<el-button type="primary" link icon="Odometer" @click="updateParticulars(row)">
实时数据
</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<DeviceList v-show="!showDetail" @select-device="handleSelectDevice" />
<DeviceDetail v-show="showDetail" :device="currentDevice" @back="handleBack" />
</div>
</template>
<script setup>
import * as serve from '@/api/equipment/list'
import { ref } from 'vue'
import DeviceList from './components/list/index.vue'
import DeviceDetail from './components/detail/index.vue'
import { nextTick, ref, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// import { id } from 'element-plus/es/locale'
import { useAppStore } from '@/pinia'
const showDetail = ref(false)
const currentDevice = ref(null)
const appStore = useAppStore()
const searchInfo = ref({
deviceStatus: ''
})
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
onMounted(() => {
getTableData()
})
// 解锁
const unlock = async (row) => {
ElMessageBox.confirm('确认解锁吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await serve.deviceOperation({
id: row.ID,
gatewayId: row.gatewayId,
para: '0xAD'
})
if (res.code === 0) {
ElMessage.success('操作成功')
getTableData()
}
})
}
// 更新
const updateParticulars = async (row) => {
const res = await serve.getDeviceDetailsInfoByRemote({
deviceId: row.ID
})
if (res.code === 0) {
ElMessage.success('更新成功')
} else {
// ElMessage.error(res.msg || '更新失败')
}
}
// 合分闸
const changeStatus = (row) => {
ElMessageBox.confirm(row.deviceStatus == 1 ? '确认分闸吗?' : '确认合闸吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await serve.deviceOperation({
id: row.ID,
gatewayId: row.gatewayId,
para: row.deviceStatus == 1 ? '0xA2' : '0xA1'
})
if (res.code === 0) {
ElMessage.success('操作成功')
getTableData()
}
})
const handleSelectDevice = (device) => {
currentDevice.value = device
showDetail.value = true
}
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 查询
const getTableData = async () => {
const table = await serve.getDeviceListByPage({
page: page.value,
pageSize: pageSize.value,
...searchInfo.value
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
const onSubmit = () => {
page.value = 1
getTableData()
}
const onReset = () => {
searchInfo.value = {
deviceStatus: ''
}
getTableData()
const handleBack = () => {
showDetail.value = false
currentDevice.value = null
}
</script>
<style lang="scss" scoped>
.header-img-box {
@apply w-52 h-52 border border-solid border-gray-300 rounded-xl flex justify-center items-center cursor-pointer;
}
.action-buttons {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.el-dropdown-link {
cursor: pointer;
color: var(--el-color-primary);
// display: flex;
// align-items: center;
}
</style>