添加设备管理界面

This commit is contained in:
2026-08-28 17:30:08 +08:00
parent bb0e34940a
commit ac9a7f31a3
10 changed files with 476 additions and 21 deletions
File diff suppressed because one or more lines are too long
+11 -11
View File
@@ -1,49 +1,49 @@
{ {
"hash": "a6f0f194", "hash": "25a37344",
"configHash": "147f2811", "configHash": "ec38f6fa",
"lockfileHash": "77198382", "lockfileHash": "74076ca5",
"browserHash": "ee71f75c", "browserHash": "8bfb7e71",
"optimized": { "optimized": {
"@element-plus/icons-vue": { "@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js", "src": "../../@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js", "file": "@element-plus_icons-vue.js",
"fileHash": "9553abbf", "fileHash": "53709748",
"needsInterop": false "needsInterop": false
}, },
"axios": { "axios": {
"src": "../../axios/index.js", "src": "../../axios/index.js",
"file": "axios.js", "file": "axios.js",
"fileHash": "3e018dd2", "fileHash": "8bc11b32",
"needsInterop": false "needsInterop": false
}, },
"echarts": { "echarts": {
"src": "../../echarts/index.js", "src": "../../echarts/index.js",
"file": "echarts.js", "file": "echarts.js",
"fileHash": "275a5510", "fileHash": "bf5b1683",
"needsInterop": false "needsInterop": false
}, },
"element-plus": { "element-plus": {
"src": "../../element-plus/es/index.mjs", "src": "../../element-plus/es/index.mjs",
"file": "element-plus.js", "file": "element-plus.js",
"fileHash": "cc57a904", "fileHash": "6f7bcfbf",
"needsInterop": false "needsInterop": false
}, },
"pinia": { "pinia": {
"src": "../../pinia/dist/pinia.js", "src": "../../pinia/dist/pinia.js",
"file": "pinia.js", "file": "pinia.js",
"fileHash": "0a5304b8", "fileHash": "c89feaca",
"needsInterop": false "needsInterop": false
}, },
"vue-router": { "vue-router": {
"src": "../../vue-router/dist/vue-router.js", "src": "../../vue-router/dist/vue-router.js",
"file": "vue-router.js", "file": "vue-router.js",
"fileHash": "0ef5b6fe", "fileHash": "a9a2c9fa",
"needsInterop": false "needsInterop": false
}, },
"vue": { "vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js", "src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js", "file": "vue.js",
"fileHash": "52e89e85", "fileHash": "53d5f754",
"needsInterop": false "needsInterop": false
} }
}, },
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+47
View File
@@ -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 })
+411 -3
View File
@@ -2,13 +2,421 @@
<div class="page-container"> <div class="page-container">
<el-card> <el-card>
<template #header> <template #header>
<span>设备管理</span> <div class="card-header">
<span>设备管理{{ total }} </span>
<el-button type="primary" icon="Plus" @click="openAdd">添加设备</el-button>
</div>
</template> </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-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-01IP:端口或串口标识)" 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> </div>
</template> </template>
<script setup> <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> </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>