添加设备管理界面
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
+11
-11
@@ -1,49 +1,49 @@
|
||||
{
|
||||
"hash": "a6f0f194",
|
||||
"configHash": "147f2811",
|
||||
"lockfileHash": "77198382",
|
||||
"browserHash": "ee71f75c",
|
||||
"hash": "25a37344",
|
||||
"configHash": "ec38f6fa",
|
||||
"lockfileHash": "74076ca5",
|
||||
"browserHash": "8bfb7e71",
|
||||
"optimized": {
|
||||
"@element-plus/icons-vue": {
|
||||
"src": "../../@element-plus/icons-vue/dist/index.js",
|
||||
"file": "@element-plus_icons-vue.js",
|
||||
"fileHash": "9553abbf",
|
||||
"fileHash": "53709748",
|
||||
"needsInterop": false
|
||||
},
|
||||
"axios": {
|
||||
"src": "../../axios/index.js",
|
||||
"file": "axios.js",
|
||||
"fileHash": "3e018dd2",
|
||||
"fileHash": "8bc11b32",
|
||||
"needsInterop": false
|
||||
},
|
||||
"echarts": {
|
||||
"src": "../../echarts/index.js",
|
||||
"file": "echarts.js",
|
||||
"fileHash": "275a5510",
|
||||
"fileHash": "bf5b1683",
|
||||
"needsInterop": false
|
||||
},
|
||||
"element-plus": {
|
||||
"src": "../../element-plus/es/index.mjs",
|
||||
"file": "element-plus.js",
|
||||
"fileHash": "cc57a904",
|
||||
"fileHash": "6f7bcfbf",
|
||||
"needsInterop": false
|
||||
},
|
||||
"pinia": {
|
||||
"src": "../../pinia/dist/pinia.js",
|
||||
"file": "pinia.js",
|
||||
"fileHash": "0a5304b8",
|
||||
"fileHash": "c89feaca",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-router": {
|
||||
"src": "../../vue-router/dist/vue-router.js",
|
||||
"file": "vue-router.js",
|
||||
"fileHash": "0ef5b6fe",
|
||||
"fileHash": "a9a2c9fa",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue": {
|
||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "52e89e85",
|
||||
"fileHash": "53d5f754",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 后端路由前缀(IotDeviceController: [Route("api/config/device")])
|
||||
const base = '/config/device'
|
||||
|
||||
// ============ 枚举常量(与后端 Model.Entity.Config 枚举一一对应) ============
|
||||
|
||||
// 通讯协议(IotDeviceProtocolEnum)
|
||||
export const PROTOCOL_TYPES = {
|
||||
1: 'Modbus TCP',
|
||||
2: 'Modbus RTU',
|
||||
3: 'S7',
|
||||
4: 'TCP',
|
||||
5: '串口'
|
||||
}
|
||||
export const protocolOptions = Object.entries(PROTOCOL_TYPES).map(([value, label]) => ({ value: Number(value), label }))
|
||||
export const protocolLabel = (v) => PROTOCOL_TYPES[v] || '-'
|
||||
|
||||
// 在线状态(IotDeviceOnlineStatusEnum)
|
||||
export const ONLINE_STATUS = {
|
||||
0: { label: '离线', tag: 'info' },
|
||||
1: { label: '在线', tag: 'success' },
|
||||
2: { label: '连接中', tag: 'warning' },
|
||||
3: { label: '通讯异常', tag: 'danger' }
|
||||
}
|
||||
export const onlineStatusLabel = (s) => ONLINE_STATUS[s]?.label || '离线'
|
||||
export const onlineStatusTag = (s) => ONLINE_STATUS[s]?.tag || 'info'
|
||||
|
||||
// ============ 设备接口 ============
|
||||
|
||||
// 设备列表(服务端分页;结果数组上附带 total 总记录数,来自响应头 X-Total-Count)
|
||||
export const getDeviceList = (params) => request.get(`${base}/list`, { params })
|
||||
|
||||
// 设备详情
|
||||
export const getDeviceDetail = (id) => request.get(`${base}/${id}`)
|
||||
|
||||
// 新增设备(后端校验编号唯一性,并在数据库建记录)
|
||||
export const addDevice = (data) => request.post(base, data)
|
||||
|
||||
// 修改设备
|
||||
export const updateDevice = (data) => request.put(base, data)
|
||||
|
||||
// 删除设备(软删除)
|
||||
export const deleteDevice = (id) => request.delete(`${base}/${id}`)
|
||||
|
||||
// 设备日志(分页;结果数组上附带 total)
|
||||
export const getDeviceLogs = (id, params) => request.get(`${base}/${id}/logs`, { params })
|
||||
@@ -2,13 +2,421 @@
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>设备管理</span>
|
||||
<div class="card-header">
|
||||
<span>设备管理({{ total }} 台)</span>
|
||||
<el-button type="primary" icon="Plus" @click="openAdd">添加设备</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="设备管理 - 功能开发中" />
|
||||
|
||||
<!-- 查找设备输入框 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="请输入设备编号 / 名称 / 类型查找设备"
|
||||
clearable
|
||||
style="width: 320px"
|
||||
@keyup.enter="onSearch"
|
||||
@clear="onSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 设备表格(右键弹菜单) -->
|
||||
<el-table
|
||||
:data="list"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
row-key="Id"
|
||||
@row-contextmenu="onRowContextmenu"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="Code" label="设备编号" width="130" />
|
||||
<el-table-column prop="Name" label="设备名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="DeviceType" label="设备类型" width="110" />
|
||||
<el-table-column prop="GatewayCode" label="所属网关" width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="SlaveId" label="从站地址" width="90" align="center" />
|
||||
<el-table-column label="协议" width="110">
|
||||
<template #default="{ row }">{{ protocolLabel(row.ProtocolType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在线状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="onlineStatusTag(row.OnlineStatus)" size="small">
|
||||
{{ onlineStatusLabel(row.OnlineStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后采集" width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.LastCollectTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click.stop="openLogs(row)">日志</el-button>
|
||||
<el-button link type="primary" @click.stop="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click.stop="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<div
|
||||
v-show="ctxMenu.visible"
|
||||
class="ctx-menu"
|
||||
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="ctx-menu__item" @click="ctxEdit">
|
||||
<el-icon><Edit /></el-icon><span>修改设备</span>
|
||||
</div>
|
||||
<div class="ctx-menu__item danger" @click="ctxDelete">
|
||||
<el-icon><Delete /></el-icon><span>删除设备</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="fetchList"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑设备对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="form.Id ? '修改设备' : '添加设备'" width="560px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="设备编号" prop="Code">
|
||||
<el-input v-model="form.Code" placeholder="请输入唯一设备编号" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备名称" prop="Name">
|
||||
<el-input v-model="form.Name" placeholder="请输入设备名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="DeviceType">
|
||||
<el-select v-model="form.DeviceType" placeholder="请选择设备类型" style="width: 100%">
|
||||
<el-option v-for="d in deviceTypes" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属网关" prop="GatewayCode">
|
||||
<el-input v-model="form.GatewayCode" placeholder="如 GW-ENV-01(IP:端口或串口标识)" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="从站地址" prop="SlaveId">
|
||||
<el-input-number v-model="form.SlaveId" :min="1" :max="247" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="通讯协议" prop="ProtocolType">
|
||||
<el-select v-model="form.ProtocolType" style="width: 100%">
|
||||
<el-option v-for="p in protocolOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="制造商">
|
||||
<el-input v-model="form.Manufacturer" placeholder="请输入制造商" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用采集">
|
||||
<el-switch v-model="form.IsEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.Remark" type="textarea" :rows="2" maxlength="500" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="onSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 设备日志抽屉 -->
|
||||
<el-drawer v-model="logVisible" :title="`设备日志 - ${logDevice?.Name || ''}`" size="520px">
|
||||
<div class="log-toolbar">
|
||||
<el-button link type="primary" size="small" @click="fetchLogs(true)">刷新</el-button>
|
||||
<span class="log-count">共 {{ logTotal }} 条</span>
|
||||
</div>
|
||||
<el-timeline v-loading="logLoading" class="log-timeline">
|
||||
<el-timeline-item
|
||||
v-for="log in logList"
|
||||
:key="log.Id"
|
||||
:timestamp="fmtTime(log.LogTime)"
|
||||
:type="logTagType(log.Level)"
|
||||
>
|
||||
<el-tag :type="logTagType(log.Level)" size="small">{{ log.Level }}</el-tag>
|
||||
<span class="log-type">{{ log.LogType }}</span>
|
||||
<div class="log-message">{{ log.Message }}</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-if="!logLoading && logList.length === 0" description="暂无日志" />
|
||||
<div class="log-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="logPage"
|
||||
v-model:page-size="logPageSize"
|
||||
:total="logTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="fetchLogs"
|
||||
@size-change="onLogSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// TODO: 实现设备管理功能
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getDeviceList, addDevice, updateDevice, deleteDevice, getDeviceLogs,
|
||||
protocolOptions, protocolLabel, onlineStatusLabel, onlineStatusTag
|
||||
} from '@/api/device'
|
||||
|
||||
// ---------- 列表 ----------
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const keyword = ref('')
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = (await getDeviceList({
|
||||
pageIndex: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value || undefined
|
||||
})) || []
|
||||
total.value = list.value.total || 0
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; fetchList() }
|
||||
function onReset() { keyword.value = ''; page.value = 1; fetchList() }
|
||||
function onSizeChange() { page.value = 1; fetchList() }
|
||||
|
||||
// ---------- 新增 / 修改 ----------
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const deviceTypes = [
|
||||
{ value: 'TemperatureBox', label: '温度箱' },
|
||||
{ value: 'ChargeCabinet', label: '充放电柜' }
|
||||
]
|
||||
|
||||
const defaultForm = () => ({
|
||||
Id: '', Code: '', Name: '', DeviceType: '', GatewayCode: '',
|
||||
SlaveId: 1, ProtocolType: 1, Manufacturer: '', IsEnabled: true, Remark: ''
|
||||
})
|
||||
const form = reactive(defaultForm())
|
||||
|
||||
const formRules = {
|
||||
Code: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
|
||||
Name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||
DeviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||
GatewayCode: [{ required: true, message: '请输入所属网关', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
Object.assign(form, defaultForm())
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(form, defaultForm(), {
|
||||
Id: row.Id,
|
||||
Code: row.Code,
|
||||
Name: row.Name,
|
||||
DeviceType: row.DeviceType,
|
||||
GatewayCode: row.GatewayCode,
|
||||
SlaveId: row.SlaveId,
|
||||
ProtocolType: row.ProtocolType,
|
||||
Manufacturer: row.Manufacturer || '',
|
||||
IsEnabled: row.IsEnabled,
|
||||
Remark: row.Remark || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
if (form.Id) {
|
||||
await updateDevice({ ...form })
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
const payload = { ...form }
|
||||
delete payload.Id
|
||||
await addDevice(payload)
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ } finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
async function onDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除设备「${row.Name}」吗?`, '提示', { type: 'warning' })
|
||||
try {
|
||||
await deleteDevice(row.Id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} catch (e) { /* 拦截器已提示 */ }
|
||||
}
|
||||
|
||||
// ---------- 右键菜单 ----------
|
||||
const ctxMenu = reactive({ visible: false, x: 0, y: 0, row: null })
|
||||
|
||||
function onRowContextmenu(row, column, event) {
|
||||
event.preventDefault()
|
||||
ctxMenu.row = row
|
||||
ctxMenu.x = event.clientX
|
||||
ctxMenu.y = event.clientY
|
||||
ctxMenu.visible = true
|
||||
}
|
||||
|
||||
// 点击表格其它区域时收起右键菜单
|
||||
function onRowClick() { ctxMenu.visible = false }
|
||||
|
||||
function closeCtxMenu() { ctxMenu.visible = false }
|
||||
function ctxEdit() { closeCtxMenu(); openEdit(ctxMenu.row) }
|
||||
function ctxDelete() { closeCtxMenu(); onDelete(ctxMenu.row) }
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
window.addEventListener('click', closeCtxMenu)
|
||||
})
|
||||
onBeforeUnmount(() => window.removeEventListener('click', closeCtxMenu))
|
||||
|
||||
// ---------- 设备日志 ----------
|
||||
const logVisible = ref(false)
|
||||
const logLoading = ref(false)
|
||||
const logList = ref([])
|
||||
const logTotal = ref(0)
|
||||
const logPage = ref(1)
|
||||
const logPageSize = ref(10)
|
||||
const logDevice = ref(null)
|
||||
|
||||
async function openLogs(row) {
|
||||
logDevice.value = row
|
||||
logPage.value = 1
|
||||
logVisible.value = true
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
async function fetchLogs(reset = false) {
|
||||
if (reset) logPage.value = 1
|
||||
if (!logDevice.value) return
|
||||
logLoading.value = true
|
||||
try {
|
||||
logList.value = (await getDeviceLogs(logDevice.value.Id, {
|
||||
pageIndex: logPage.value,
|
||||
pageSize: logPageSize.value
|
||||
})) || []
|
||||
logTotal.value = logList.value.total || 0
|
||||
} catch (e) {
|
||||
logList.value = []
|
||||
logTotal.value = 0
|
||||
} finally {
|
||||
logLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onLogSizeChange() { logPage.value = 1; fetchLogs() }
|
||||
|
||||
// ---------- 格式化 ----------
|
||||
const logTagType = (level) => ({ Error: 'danger', Warn: 'warning' }[level] || 'primary')
|
||||
const fmtTime = (t) => t ? String(t).replace('T', ' ').slice(0, 19) : '-'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* 右键菜单 */
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
.ctx-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-menu__item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.ctx-menu__item.danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.ctx-menu__item.danger:hover {
|
||||
background: #fef0f0;
|
||||
}
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.log-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.log-timeline {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.log-type {
|
||||
margin-left: 6px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
.log-message {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user