Files
pig-farm-controller/frontend/src/pages/Device.vue

910 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="device-management">
<header class="header">
<h1>设备管理</h1>
<div class="user-info">
<span>欢迎, {{ username }}</span>
<button class="logout-btn" @click="logout">退出</button>
</div>
</header>
<nav class="nav">
<ul>
<li><router-link to="/dashboard">控制台</router-link></li>
<li><router-link to="/device" class="active">设备管理</router-link></li>
<li><router-link to="/feed/plan">饲喂计划</router-link></li>
</ul>
</nav>
<main class="main-content">
<div class="toolbar">
<button class="btn btn-primary" @click="openAddDeviceModal">添加设备</button>
</div>
<div class="device-tree">
<div v-if="devices.length === 0" class="no-devices">
暂无设备数据
</div>
<div v-else>
<!-- 中继设备 -->
<div
v-for="relay in relayDevices"
:key="relay.id"
class="tree-node relay-node"
>
<div class="node-header" @click="toggleNode(relay.id)">
<div class="node-info">
<span class="toggle-icon">{{ expandedNodes.has(relay.id) ? '▼' : '►' }}</span>
<span class="node-title">{{ relay.name }}</span>
<span class="node-type relay-type">{{ getDeviceTypeText(relay.type) }}</span>
<span v-if="relay.address" class="node-address">[{{ relay.address }}]</span>
<span class="node-status" :class="{ 'status-active': relay.active, 'status-inactive': !relay.active }">{{ relay.active ? '在线' : '离线' }}</span>
</div>
<div class="node-actions">
<button class="action-btn edit-btn" @click.stop="editDevice(relay)">编辑</button>
<button class="action-btn delete-btn" @click.stop="deleteDevice(relay.id)">删除</button>
</div>
</div>
<!-- 控制器设备 -->
<div v-show="expandedNodes.has(relay.id)" class="children-container">
<div
v-for="controller in getControllerDevices(relay.id)"
:key="controller.id"
class="tree-node controller-node"
>
<div class="node-header" @click="toggleNode(controller.id)">
<div class="node-info">
<span class="toggle-icon">{{ expandedNodes.has(controller.id) ? '▼' : '►' }}</span>
<span class="node-title">{{ controller.name }}</span>
<span class="node-type controller-type">{{ getDeviceTypeText(controller.type) }}</span>
<span v-if="controller.address" class="node-address">[{{ controller.address }}]</span>
<span class="node-status" :class="{ 'status-active': controller.active, 'status-inactive': !controller.active }">{{ controller.active ? '在线' : '离线' }}</span>
</div>
<div class="node-actions">
<button class="action-btn edit-btn" @click.stop="editDevice(controller)">编辑</button>
<button class="action-btn delete-btn" @click.stop="deleteDevice(controller.id)">删除</button>
</div>
</div>
<!-- 叶子设备 -->
<div v-show="expandedNodes.has(controller.id)" class="children-container">
<div
v-for="leaf in getLeafDevices(controller.id)"
:key="leaf.id"
class="tree-node device-node"
>
<div class="node-header">
<div class="node-info">
<span class="node-title">{{ leaf.name }}</span>
<span class="node-type device-type">{{ getDeviceTypeText(leaf.type) }}</span>
<span v-if="leaf.address" class="node-address">[{{ leaf.address }}]</span>
<span class="node-status" :class="{ 'status-active': leaf.active, 'status-inactive': !leaf.active }">{{ leaf.active ? '在线' : '离线' }}</span>
</div>
<div class="node-actions">
<button class="action-btn edit-btn" @click.stop="editDevice(leaf)">编辑</button>
<button class="action-btn delete-btn" @click.stop="deleteDevice(leaf.id)">删除</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 添加/编辑设备模态框 -->
<div class="modal" v-if="showModal">
<div class="modal-content">
<div class="modal-header">
<h3>{{ editingDevice ? '编辑设备' : '添加设备' }}</h3>
<button class="close-btn" @click="closeDeviceModal">&times;</button>
</div>
<div class="modal-body">
<form @submit.prevent="saveDevice">
<input type="hidden" v-model="deviceForm.id">
<div class="form-group">
<label for="deviceName">设备名称</label>
<input type="text" id="deviceName" v-model="deviceForm.name" required>
</div>
<div class="form-group">
<label for="deviceType">设备类型</label>
<select id="deviceType" v-model="deviceForm.type" required @change="toggleParentField">
<option value="">请选择设备类型</option>
<option value="relay">中继设备</option>
<option value="pig_pen_controller">猪舍主控</option>
<option value="feed_mill_controller">做料车间主控</option>
<option value="fan">风机</option>
<option value="water_curtain">水帘</option>
</select>
</div>
<!-- 485总线设备地址字段 -->
<div class="form-group" v-if="(deviceForm.type === 'fan' || deviceForm.type === 'water_curtain') && deviceForm.type !== ''">
<label for="busNumber">485总线号</label>
<input type="number" id="busNumber" v-model.number="deviceForm.bus_number" placeholder="请输入485总线号">
</div>
<div class="form-group" v-if="(deviceForm.type === 'fan' || deviceForm.type === 'water_curtain') && deviceForm.type !== ''">
<label for="device485Address">485设备地址</label>
<input type="text" id="device485Address" v-model="deviceForm.device_address" placeholder="请输入485设备地址">
</div>
<!-- 非485总线设备地址字段 -->
<div class="form-group" v-if="deviceForm.type !== 'relay' && deviceForm.type !== '' && deviceForm.type !== 'fan' && deviceForm.type !== 'water_curtain'">
<label for="deviceAddress">设备地址</label>
<input type="text" id="deviceAddress" v-model="deviceForm.address" placeholder="请输入设备地址">
</div>
<div class="form-group" v-if="deviceForm.type !== 'relay' && deviceForm.type !== ''">
<label for="parentId">上级设备</label>
<select id="parentId" v-model="deviceForm.parent_id">
<option value="">请选择上级设备</option>
<option
v-for="parent in getParentDevicesWithDisplayName(deviceForm.type)"
:key="parent.id"
:value="parent.id"
>
{{ parent.display_name }}
</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="closeDeviceModal">取消</button>
<button class="btn btn-primary" @click="saveDevice">保存</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Device',
data() {
return {
username: localStorage.getItem('username') || '管理员',
devices: [],
showModal: false,
editingDevice: null,
deviceForm: {
id: null,
name: '',
type: '',
parent_id: null,
address: null,
bus_number: null,
device_address: null
},
expandedNodes: new Set()
}
},
computed: {
// 获取中继设备(顶级设备)
relayDevices() {
return this.devices.filter(device => device.type === 'relay')
}
},
mounted() {
this.loadDevices()
},
methods: {
logout() {
// 清除本地存储的认证信息
localStorage.removeItem('authToken')
localStorage.removeItem('userId')
localStorage.removeItem('username')
// 跳转到登录页面
this.$router.push('/')
},
// 切换节点展开/折叠状态
toggleNode(nodeId) {
if (this.expandedNodes.has(nodeId)) {
this.expandedNodes.delete(nodeId)
} else {
this.expandedNodes.add(nodeId)
}
},
// 获取控制器设备(区域主控)
getControllerDevices(parentId) {
return this.devices.filter(device =>
device.parent_id === parentId &&
(device.type === 'pig_pen_controller' || device.type === 'feed_mill_controller')
)
},
// 获取叶子设备(具体设备)
getLeafDevices(parentId) {
return this.devices.filter(device =>
device.parent_id === parentId &&
(device.type === 'fan' || device.type === 'water_curtain')
)
},
// 获取设备类型文本
getDeviceTypeText(type) {
const typeMap = {
'relay': '中继',
'pig_pen_controller': '猪舍主控',
'feed_mill_controller': '做料车间主控',
'fan': '风机',
'water_curtain': '水帘'
}
const statusMap = {
'online': '在线',
'offline': '离线',
'error': '故障'
}
return typeMap[type] || type
},
// 获取上级设备选项
getParentDevices(currentType) {
if (currentType === 'pig_pen_controller' || currentType === 'feed_mill_controller') {
// 控制器的上级是中继设备
return this.devices.filter(device => device.type === 'relay')
} else if (currentType === 'fan' || currentType === 'water_curtain') {
// 设备的上级是控制器
return this.devices.filter(device =>
device.type === 'pig_pen_controller' || device.type === 'feed_mill_controller')
}
return []
},
// 获取带显示名称的上级设备选项
getParentDevicesWithDisplayName(currentType) {
const parents = this.getParentDevices(currentType);
if (currentType === 'pig_pen_controller' || currentType === 'feed_mill_controller') {
// 控制器的上级是中继设备,直接返回中继设备列表,显示设备名称
return parents.map(relay => ({
...relay,
display_name: relay.name
}));
} else if (currentType === 'fan' || currentType === 'water_curtain') {
// 设备的上级是控制器,需要构建"中继设备名 - 区域主控名"格式的显示名称
return parents.map(controller => {
// 查找控制器的上级中继设备
const relay = this.devices.find(device => device.id === controller.parent_id);
const relayName = relay ? relay.name : '未知中继';
return {
...controller,
display_name: `${relayName} - ${controller.name}`
};
});
}
return [];
},
// 加载设备列表
async loadDevices() {
try {
const response = await fetch('/api/v1/device/list', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
}
})
const data = await response.json()
if (response.ok && data.code === 0) {
this.devices = data.data.devices
// 默认展开所有节点
this.expandAllNodes()
} else {
console.error('获取设备列表失败:', data.message)
}
} catch (error) {
console.error('获取设备列表失败:', error)
}
},
// 展开所有节点
expandAllNodes() {
// 清空当前展开的节点
this.expandedNodes.clear()
// 展开所有中继设备节点
this.relayDevices.forEach(relay => {
this.expandedNodes.add(relay.id)
// 展开所有控制器设备节点
const controllers = this.getControllerDevices(relay.id)
controllers.forEach(controller => {
this.expandedNodes.add(controller.id)
})
})
},
// 打开添加设备模态框
openAddDeviceModal() {
this.editingDevice = null
this.deviceForm = {
id: null,
name: '',
type: '',
parent_id: null,
address: null,
bus_number: null,
device_address: null
}
this.showModal = true
},
// 编辑设备
editDevice(device) {
this.editingDevice = device
this.deviceForm = { ...device }
// 如果是485总线设备尝试解析总线号和设备地址
if ((device.type === 'fan' || device.type === 'water_curtain') && device.address) {
const parts = device.address.split(':');
if (parts.length === 2) {
this.deviceForm.bus_number = parseInt(parts[0]);
this.deviceForm.device_address = parts[1];
} else {
this.deviceForm.device_address = device.address;
}
}
this.showModal = true
},
// 初始化设备表单数据
initializeDeviceForm() {
return {
id: null,
name: '',
type: '',
parent_id: null,
address: null
}
},
// 关闭模态框
closeDeviceModal() {
this.showModal = false
},
// 保存设备
async saveDevice() {
if (!this.deviceForm.name || !this.deviceForm.type) {
alert('请填写必填字段')
return
}
if (this.deviceForm.type !== 'relay' && !this.deviceForm.parent_id) {
alert('请选择上级设备')
return
}
try {
let url, method
const deviceData = {
name: this.deviceForm.name,
type: this.deviceForm.type,
parent_id: this.deviceForm.parent_id,
address: this.deviceForm.address,
bus_number: this.deviceForm.bus_number,
device_address: this.deviceForm.device_address
}
if (this.editingDevice) {
// 更新设备
url = '/api/v1/device/update'
method = 'POST'
deviceData.id = this.deviceForm.id
} else {
// 创建设备
url = '/api/v1/device/create'
method = 'POST'
}
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
},
body: JSON.stringify(deviceData)
})
const data = await response.json()
if (response.ok && data.code === 0) {
// 保存成功,重新加载设备列表
await this.loadDevices()
this.closeDeviceModal()
} else {
alert('保存失败: ' + data.message)
}
} catch (error) {
console.error('保存设备失败:', error)
alert('保存设备失败: ' + error.message)
}
},
// 删除设备
async deleteDevice(deviceId) {
if (!confirm('确定要删除这个设备吗?')) {
return
}
try {
const response = await fetch('/api/v1/device/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
},
body: JSON.stringify({ id: deviceId })
})
const data = await response.json()
if (response.ok && data.code === 0) {
// 删除成功,重新加载设备列表
await this.loadDevices()
} else {
alert('删除失败: ' + data.message)
}
} catch (error) {
console.error('删除设备失败:', error)
alert('删除设备失败: ' + error.message)
}
},
// 根据设备类型切换上级设备字段显示
toggleParentField() {
if (this.deviceForm.type === 'relay') {
this.deviceForm.parent_id = null
}
},
// 切换节点展开/折叠
toggleNode(nodeId) {
if (this.expandedNodes.has(nodeId)) {
this.expandedNodes.delete(nodeId)
} else {
this.expandedNodes.add(nodeId)
}
}
}
}
</script>
<style scoped>
.device-management {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f5f7fa;
}
.nav {
background-color: #343a40;
padding: 0;
margin-bottom: 20px;
}
.nav ul {
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
}
.nav li {
margin: 0;
}
.nav a {
display: block;
padding: 15px 20px;
color: #fff;
text-decoration: none;
transition: background-color 0.3s;
}
.nav a:hover {
background-color: #495057;
}
.nav a.active {
background-color: #007bff;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header h1 {
margin: 0;
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 10px;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.main-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.toolbar {
margin-bottom: 1.5rem;
display: flex;
justify-content: flex-end;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.device-tree {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
overflow: hidden;
}
.tree-node {
margin-bottom: 0.75rem;
border-radius: 8px;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.tree-node:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.tree-node.relay-node {
background: #fff3e0;
border-left: 4px solid #ff9800;
}
.tree-node.controller-node {
background: #e3f2fd;
border-left: 4px solid #2196f3;
margin-left: 1.5rem;
}
.tree-node.device-node {
background: #e8f5e9;
border-left: 4px solid #4caf50;
margin-left: 3rem;
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
cursor: pointer;
}
.node-header:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.node-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toggle-icon {
width: 20px;
text-align: center;
font-size: 0.8rem;
color: #666;
}
.node-title {
font-weight: 600;
color: #333;
font-size: 1.1rem;
}
.node-address {
font-size: 0.9rem;
color: #666;
background: rgba(0, 0, 0, 0.05);
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.node-type {
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 12px;
font-weight: 500;
text-transform: uppercase;
}
.node-status {
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 12px;
font-weight: 500;
}
.status-active {
background: #4caf50;
color: white;
}
.status-inactive {
background: #9e9e9e;
color: white;
}
.relay-type {
background: #ff9800;
color: white;
}
.controller-type {
background: #2196f3;
color: white;
}
.device-type {
background: #4caf50;
color: white;
}
.node-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.4rem 0.8rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s ease;
}
.edit-btn {
background: #1976d2;
color: white;
}
.edit-btn:hover {
background: #1565c0;
transform: translateY(-1px);
}
.delete-btn {
background: #d32f2f;
color: white;
}
.delete-btn:hover {
background: #c62828;
transform: translateY(-1px);
}
.children-container {
padding: 0.5rem 0 0.5rem 1rem;
border-left: 2px dashed rgba(0, 0, 0, 0.1);
margin-left: 1rem;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: white;
border-radius: 10px;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
color: #333;
font-size: 1.3rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.8rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
transition: color 0.2s ease;
}
.close-btn:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.8rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.no-devices {
text-align: center;
color: #7f8c8d;
padding: 3rem;
font-style: italic;
font-size: 1.1rem;
}
@media (max-width: 768px) {
.main-content {
padding: 1rem;
}
.header {
padding: 1rem;
}
.node-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.node-actions {
align-self: flex-end;
}
.tree-node.controller-node,
.tree-node.device-node {
margin-left: 1rem;
}
}
</style>