Compare commits
33 Commits
8b22514aad
...
main-old
| Author | SHA1 | Date | |
|---|---|---|---|
| 3743b5ddcd | |||
| 6fe73d8ffe | |||
| 91f160b07e | |||
| a1950872fc | |||
| c499571c11 | |||
| cc7ea94e41 | |||
| 40a19b831a | |||
| 9944340d17 | |||
| 4a70c1e839 | |||
| e75b3ee148 | |||
| cbcba09d40 | |||
| 4805e422f7 | |||
| 64c86de71e | |||
| 3ecbf3b5af | |||
| 1a14aec19b | |||
| 008677467b | |||
| 2b4dd3e74d | |||
| 8468a96398 | |||
| e27aec0ca2 | |||
| 52cf8c58ed | |||
| d7c2ffb108 | |||
| 43befdb71c | |||
| 8d639d3b09 | |||
| 4f928dff9f | |||
| 946c02516c | |||
| 0159626e1b | |||
| 4c9f059af2 | |||
| adb9a12a9d | |||
| 3cc02d3a98 | |||
| d90698401d | |||
| 5e8a57d7e8 | |||
| 00822427ca | |||
| fc657d7448 |
69
RELAY_API.md
69
RELAY_API.md
@@ -103,6 +103,29 @@ ws://[server_address]:[port]/ws/device?device_id=[device_id]
|
||||
- `command`: 指令名称,固定为"query_all_device_status"
|
||||
- `timestamp`: 指令发送时间
|
||||
|
||||
### 3.4 心跳包指令
|
||||
|
||||
平台向中继设备发送心跳包指令,用于检测设备连接状态并获取下级设备状态信息。
|
||||
|
||||
**请求格式**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "heartbeat",
|
||||
"data": {
|
||||
"timestamp": 1672545600
|
||||
},
|
||||
"timestamp": "2023-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
- `type`: 消息类型,固定为"command"
|
||||
- `command`: 指令名称,固定为"heartbeat"
|
||||
- `data`: 指令数据
|
||||
- `timestamp`: 时间戳(Unix时间戳格式)
|
||||
- `timestamp`: 指令发送时间
|
||||
|
||||
## 4. 响应接口
|
||||
|
||||
### 4.1 设备控制响应
|
||||
@@ -167,6 +190,48 @@ ws://[server_address]:[port]/ws/device?device_id=[device_id]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 心跳包响应
|
||||
|
||||
中继设备响应心跳包指令,返回自身及下级设备的状态信息。
|
||||
|
||||
**响应格式**
|
||||
```json
|
||||
{
|
||||
"type": "response",
|
||||
"command": "heartbeat",
|
||||
"data": {
|
||||
"devices": [
|
||||
{
|
||||
"device_id": "relay-001",
|
||||
"device_type": "relay",
|
||||
"status": "running"
|
||||
},
|
||||
{
|
||||
"device_id": "fan-001",
|
||||
"device_type": "fan",
|
||||
"status": "running"
|
||||
},
|
||||
{
|
||||
"device_id": "curtain-001",
|
||||
"device_type": "water_curtain",
|
||||
"status": "stopped"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timestamp": "2023-01-01T12:00:05Z"
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
- `type`: 消息类型,固定为"response"
|
||||
- `command`: 指令名称,固定为"heartbeat"
|
||||
- `data`: 响应数据
|
||||
- `devices`: 设备列表
|
||||
- `device_id`: 设备唯一标识符
|
||||
- `device_type`: 设备类型
|
||||
- `status`: 设备状态(如: running, stopped, online, offline等)
|
||||
- `timestamp`: 平台发送的时间戳, 需要原封不动的返回
|
||||
|
||||
## 5. 请求-响应机制
|
||||
|
||||
平台在发送指令后会等待中继设备的响应,超时时间由配置文件决定,默认为5秒。
|
||||
@@ -277,6 +342,7 @@ if err := response.ParseData(&status); err != nil {
|
||||
|------|------|
|
||||
| fan | 风机设备 |
|
||||
| water_curtain | 水帘设备 |
|
||||
| relay | 中继设备 |
|
||||
|
||||
## 10. 动作说明
|
||||
|
||||
@@ -293,3 +359,6 @@ if err := response.ParseData(&status); err != nil {
|
||||
| failed | 操作失败 |
|
||||
| running | 设备运行中 |
|
||||
| stopped | 设备已停止 |
|
||||
| online | 设备在线 |
|
||||
| offline | 设备离线 |
|
||||
| active | 设备激活 |
|
||||
@@ -3,4 +3,4 @@
|
||||
1. websocket不是安全的wss
|
||||
2. 添加设备时应该激活一下设备状态采集
|
||||
3. 设备Model缺少硬件地址
|
||||
|
||||
4. 如果同时有两条请求发给同一个设备, 会不会导致接收到错误的回复
|
||||
|
||||
@@ -26,3 +26,12 @@ database:
|
||||
websocket:
|
||||
# WebSocket请求超时时间(秒)
|
||||
timeout: 5
|
||||
# 心跳检测间隔(秒), 如果超过这个时间没有消息往来系统会自动发送一个心跳包维持长链接
|
||||
heartbeat_interval: 54
|
||||
|
||||
# 心跳配置
|
||||
heartbeat:
|
||||
# 心跳间隔(秒)
|
||||
interval: 30
|
||||
# 请求并发数
|
||||
concurrency: 5
|
||||
21
frontend/dist/assets/index.2015effd.js
vendored
21
frontend/dist/assets/index.2015effd.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index.7f062720.css
vendored
1
frontend/dist/assets/index.7f062720.css
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index.bcc76856.css
vendored
Normal file
1
frontend/dist/assets/index.bcc76856.css
vendored
Normal file
File diff suppressed because one or more lines are too long
21
frontend/dist/assets/index.cb9d3828.js
vendored
Normal file
21
frontend/dist/assets/index.cb9d3828.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>猪场管理系统</title>
|
||||
<script type="module" crossorigin src="/assets/index.2015effd.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index.7f062720.css">
|
||||
<script type="module" crossorigin src="/assets/index.cb9d3828.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index.bcc76856.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
303
frontend/src/components/FeedPlanForm.vue
Normal file
303
frontend/src/components/FeedPlanForm.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="feed-plan-form">
|
||||
<div class="form-header">
|
||||
<h2>{{ isEditMode ? '编辑饲喂计划' : '新建饲喂计划' }}</h2>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label for="name">计划名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
required
|
||||
:disabled="loading"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">计划描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
:disabled="loading"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>计划类型 *</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-item">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.type"
|
||||
value="manual"
|
||||
:disabled="loading"
|
||||
>
|
||||
手动触发
|
||||
</label>
|
||||
<label class="radio-item">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="form.type"
|
||||
value="auto"
|
||||
:disabled="loading"
|
||||
>
|
||||
自动触发
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.enabled"
|
||||
:disabled="loading"
|
||||
>
|
||||
启用计划
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="form.type === 'auto'" class="form-group">
|
||||
<label for="schedule_cron">定时表达式</label>
|
||||
<input
|
||||
type="text"
|
||||
id="schedule_cron"
|
||||
v-model="form.schedule_cron"
|
||||
placeholder="例如: 0 0 7 * * *"
|
||||
:disabled="loading"
|
||||
>
|
||||
<div class="help-text">Cron表达式,用于设置自动执行时间</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="execution_limit">执行次数限制</label>
|
||||
<input
|
||||
type="number"
|
||||
id="execution_limit"
|
||||
v-model.number="form.execution_limit"
|
||||
min="0"
|
||||
:disabled="loading"
|
||||
>
|
||||
<div class="help-text">0表示无限制</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="loading"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loading || isSubmitting"
|
||||
>
|
||||
{{ loading || isSubmitting ? '处理中...' : (isEditMode ? '更新计划' : '创建计划') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FeedPlanForm',
|
||||
props: {
|
||||
// 编辑模式下的初始数据
|
||||
initialData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'manual',
|
||||
enabled: true,
|
||||
schedule_cron: '',
|
||||
execution_limit: 0
|
||||
})
|
||||
},
|
||||
// 是否为编辑模式
|
||||
isEditMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 提交时的加载状态
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: { ...this.initialData },
|
||||
isSubmitting: false // 防止重复提交
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
// 监听初始数据变化,更新表单
|
||||
initialData: {
|
||||
handler(newVal) {
|
||||
this.form = { ...newVal }
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async handleSubmit() {
|
||||
// 防止重复提交
|
||||
if (this.isSubmitting || this.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isSubmitting = true
|
||||
|
||||
try {
|
||||
// 表单验证
|
||||
if (!this.form.name.trim()) {
|
||||
alert('请输入计划名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.form.type === 'auto' && this.form.schedule_cron && !this.isValidCron(this.form.schedule_cron)) {
|
||||
alert('请输入有效的Cron表达式')
|
||||
return
|
||||
}
|
||||
|
||||
// 触发提交事件
|
||||
this.$emit('submit', { ...this.form })
|
||||
} finally {
|
||||
// 在下一个tick重置提交状态,确保事件已经触发
|
||||
this.$nextTick(() => {
|
||||
this.isSubmitting = false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// 简单的Cron表达式验证
|
||||
isValidCron(cron) {
|
||||
// 这里可以添加更复杂的验证逻辑
|
||||
// 现在只是简单检查格式
|
||||
return typeof cron === 'string' && cron.trim().length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feed-plan-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="number"]:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.form-group input:disabled,
|
||||
.form-group textarea:disabled {
|
||||
background-color: #f8f9fa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #0069d9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@
|
||||
<ul>
|
||||
<li><router-link to="/dashboard" class="active">控制台</router-link></li>
|
||||
<li><router-link to="/device">设备管理</router-link></li>
|
||||
<li><router-link to="/feed/plan">饲喂计划</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
</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>
|
||||
@@ -289,6 +297,8 @@ export default {
|
||||
|
||||
if (response.ok && data.code === 0) {
|
||||
this.devices = data.data.devices
|
||||
// 默认展开所有节点
|
||||
this.expandAllNodes()
|
||||
} else {
|
||||
console.error('获取设备列表失败:', data.message)
|
||||
}
|
||||
@@ -297,6 +307,23 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 展开所有节点
|
||||
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
|
||||
@@ -462,6 +489,39 @@ export default {
|
||||
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;
|
||||
|
||||
514
frontend/src/pages/FeedPlan.vue
Normal file
514
frontend/src/pages/FeedPlan.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="feed-plan-management">
|
||||
<div class="header">
|
||||
<h1>饲喂计划管理</h1>
|
||||
<div class="user-info">
|
||||
<span>欢迎, {{ username }}</span>
|
||||
<button class="logout-btn" @click="logout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<ul>
|
||||
<li><router-link to="/dashboard">控制台</router-link></li>
|
||||
<li><router-link to="/device">设备管理</router-link></li>
|
||||
<li><router-link to="/feed/plan" class="active">饲喂计划</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" @click="createPlan">创建计划</button>
|
||||
</div>
|
||||
|
||||
<div class="plan-list">
|
||||
<div v-if="loading" class="loading">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="plans.length === 0" class="no-plans">
|
||||
暂无饲喂计划
|
||||
</div>
|
||||
|
||||
<div v-else class="plans-container">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
class="plan-card"
|
||||
>
|
||||
<div class="plan-header">
|
||||
<h3>{{ plan.name }}</h3>
|
||||
<span :class="['plan-status', { 'enabled': plan.enabled, 'disabled': !plan.enabled }]">
|
||||
{{ plan.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="plan-details">
|
||||
<p class="plan-description">{{ plan.description || '暂无描述' }}</p>
|
||||
<div class="plan-meta">
|
||||
<span class="plan-type">{{ plan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
|
||||
<span v-if="plan.schedule_cron" class="plan-cron">定时: {{ plan.schedule_cron }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plan-actions">
|
||||
<button class="action-btn detail-btn" @click="viewDetail(plan.id)">详情</button>
|
||||
<button class="action-btn edit-btn" @click="editPlan(plan)">编辑</button>
|
||||
<button class="action-btn delete-btn" @click="deletePlan(plan.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 计划表单模态框 -->
|
||||
<div v-if="showModal" class="modal-overlay" @click="closeModal">
|
||||
<div class="modal-content" @click.stop>
|
||||
<FeedPlanForm
|
||||
:initial-data="currentPlan"
|
||||
:is-edit-mode="modalType === 'edit'"
|
||||
:loading="submitting"
|
||||
@submit="submitForm"
|
||||
@cancel="closeModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FeedPlanForm from '../components/FeedPlanForm.vue'
|
||||
|
||||
export default {
|
||||
name: 'FeedPlan',
|
||||
components: {
|
||||
FeedPlanForm
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
plans: [],
|
||||
loading: true,
|
||||
// 控制模态框显示
|
||||
showModal: false,
|
||||
// 当前操作类型: 'create' 或 'edit'
|
||||
modalType: 'create',
|
||||
// 当前编辑的计划
|
||||
currentPlan: null,
|
||||
// 提交时的加载状态
|
||||
submitting: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.username = localStorage.getItem('username') || '管理员'
|
||||
this.loadPlans()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载饲喂计划列表
|
||||
async loadPlans() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await fetch('/api/v1/feed/plan/list', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.code === 0) {
|
||||
this.plans = data.data.plans || []
|
||||
} else {
|
||||
console.error('获取饲喂计划列表失败:', data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取饲喂计划列表失败:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 查看详情
|
||||
viewDetail(planId) {
|
||||
this.$router.push(`/feed/plan/detail/${planId}`)
|
||||
},
|
||||
|
||||
// 创建计划
|
||||
createPlan() {
|
||||
this.modalType = 'create'
|
||||
this.currentPlan = {
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'manual',
|
||||
enabled: true,
|
||||
schedule_cron: '',
|
||||
execution_limit: 0
|
||||
}
|
||||
this.showModal = true
|
||||
},
|
||||
|
||||
// 编辑计划
|
||||
editPlan(plan) {
|
||||
this.modalType = 'edit'
|
||||
// 深拷贝计划数据,避免直接修改原数据
|
||||
this.currentPlan = JSON.parse(JSON.stringify(plan))
|
||||
this.showModal = true
|
||||
},
|
||||
|
||||
// 关闭模态框
|
||||
closeModal() {
|
||||
this.showModal = false
|
||||
this.currentPlan = null
|
||||
},
|
||||
|
||||
// 提交表单
|
||||
async submitForm(formData) {
|
||||
// 防止重复提交
|
||||
if (this.submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting = true
|
||||
try {
|
||||
let url, method
|
||||
if (this.modalType === 'create') {
|
||||
url = '/api/v1/feed/plan/create'
|
||||
method = 'POST'
|
||||
} else {
|
||||
url = '/api/v1/feed/plan/update'
|
||||
method = 'POST'
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.code === 0) {
|
||||
// 提交成功,关闭模态框并重新加载列表
|
||||
this.closeModal()
|
||||
await this.loadPlans()
|
||||
alert(this.modalType === 'create' ? '创建计划成功' : '更新计划成功')
|
||||
} else {
|
||||
alert((this.modalType === 'create' ? '创建计划失败: ' : '更新计划失败: ') + (data.message || '未知错误'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(this.modalType === 'create' ? '创建计划失败:' : '更新计划失败:', error)
|
||||
alert((this.modalType === 'create' ? '创建计划失败: ' : '更新计划失败: ') + error.message)
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
|
||||
// 删除计划
|
||||
async deletePlan(planId) {
|
||||
if (!confirm('确定要删除这个饲喂计划吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/feed/plan/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
|
||||
},
|
||||
body: JSON.stringify({ id: planId })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.code === 0) {
|
||||
// 删除成功,重新加载列表
|
||||
await this.loadPlans()
|
||||
this.$message?.success('删除成功') || alert('删除成功')
|
||||
} else {
|
||||
this.$message?.error('删除失败: ' + data.message) || alert('删除失败: ' + data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除饲喂计划失败:', error)
|
||||
this.$message?.error('删除饲喂计划失败: ' + error.message) || alert('删除饲喂计划失败: ' + error.message)
|
||||
}
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
logout() {
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('username')
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feed-plan-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0069d9;
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.loading, .no-plans {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.plans-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
background-color: white;
|
||||
transition: box-shadow 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.plan-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.plan-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.plan-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.plan-status.enabled {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.plan-status.disabled {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.plan-details {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-description {
|
||||
margin: 0 0 15px 0;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.plan-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plan-type, .plan-cron {
|
||||
padding: 4px 8px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.detail-btn:hover {
|
||||
background-color: #138496;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background-color: #e0a800;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.plans-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.nav ul {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
</style>
|
||||
652
frontend/src/pages/FeedPlanDetail.vue
Normal file
652
frontend/src/pages/FeedPlanDetail.vue
Normal file
@@ -0,0 +1,652 @@
|
||||
<template>
|
||||
<div class="feed-plan-detail">
|
||||
<div class="header">
|
||||
<h1>饲喂计划详情</h1>
|
||||
<div class="user-info">
|
||||
<span>欢迎, {{ username }}</span>
|
||||
<button class="logout-btn" @click="logout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<ul>
|
||||
<li><router-link to="/dashboard">控制台</router-link></li>
|
||||
<li><router-link to="/device">设备管理</router-link></li>
|
||||
<li><router-link to="/feed/plan">饲喂计划</router-link></li>
|
||||
<li><router-link to="/feed/plan/detail" class="active">计划详情</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div v-if="loading" class="loading">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="plan" class="plan-detail-container">
|
||||
<div class="plan-header">
|
||||
<h2>{{ plan.name }}</h2>
|
||||
<span :class="['plan-status', { 'enabled': plan.enabled, 'disabled': !plan.enabled }]">
|
||||
{{ plan.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="plan-info">
|
||||
<div class="info-item">
|
||||
<label>计划描述:</label>
|
||||
<span>{{ plan.description || '无描述' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>计划类型:</label>
|
||||
<span>{{ plan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="plan.schedule_cron" class="info-item">
|
||||
<label>定时表达式:</label>
|
||||
<span>{{ plan.schedule_cron }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>执行次数限制:</label>
|
||||
<span>{{ plan.execution_limit > 0 ? plan.execution_limit : '无限制' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 移除主计划中的父计划ID和顺序显示 -->
|
||||
</div>
|
||||
|
||||
<div class="plan-steps">
|
||||
<h3>计划步骤</h3>
|
||||
<div v-if="plan.steps && plan.steps.length > 0" class="steps-list">
|
||||
<div
|
||||
v-for="(step, index) in plan.steps"
|
||||
:key="step.id"
|
||||
class="step-item"
|
||||
>
|
||||
<div class="step-header">
|
||||
<span class="step-number">步骤 {{ index + 1 }}</span>
|
||||
<span v-if="step.schedule_cron" class="step-cron">定时: {{ step.schedule_cron }}</span>
|
||||
</div>
|
||||
|
||||
<div class="step-details">
|
||||
<div class="detail-item">
|
||||
<label>设备ID:</label>
|
||||
<span>{{ step.device_id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<label>目标值:</label>
|
||||
<span>{{ step.target_value }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<label>动作:</label>
|
||||
<span>{{ step.action }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<label>执行次数限制:</label>
|
||||
<span>{{ step.execution_limit > 0 ? step.execution_limit : '无限制' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-steps">
|
||||
该计划暂无步骤
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="plan.sub_plans && plan.sub_plans.length > 0" class="sub-plans">
|
||||
<h3>子计划</h3>
|
||||
<div class="sub-plans-list">
|
||||
<div
|
||||
v-for="subPlan in plan.sub_plans"
|
||||
:key="subPlan.id"
|
||||
class="sub-plan-item"
|
||||
>
|
||||
<div class="sub-plan-header">
|
||||
<h4>{{ subPlan.name }}</h4>
|
||||
<span :class="['plan-status', { 'enabled': subPlan.enabled, 'disabled': !subPlan.enabled }]">
|
||||
{{ subPlan.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="sub-plan-info">
|
||||
<div class="info-item">
|
||||
<label>描述:</label>
|
||||
<span>{{ subPlan.description || '无描述' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>类型:</label>
|
||||
<span>{{ subPlan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subPlan.schedule_cron" class="info-item">
|
||||
<label>定时表达式:</label>
|
||||
<span>{{ subPlan.schedule_cron }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<label>顺序:</label>
|
||||
<span>{{ (subPlan.order_in_parent || 0) + 1 }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subPlan.parent_id" class="info-item">
|
||||
<label>父计划:</label>
|
||||
<span>{{ getParentPlanName(subPlan.parent_id) }}(id:{{ subPlan.parent_id }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sub-plan-steps">
|
||||
<h5>子计划步骤</h5>
|
||||
<div v-if="subPlan.steps && subPlan.steps.length > 0" class="steps-list">
|
||||
<div
|
||||
v-for="(step, index) in subPlan.steps"
|
||||
:key="step.id"
|
||||
class="step-item"
|
||||
>
|
||||
<div class="step-header">
|
||||
<span class="step-number">步骤 {{ index + 1 }}</span>
|
||||
<span v-if="step.schedule_cron" class="step-cron">定时: {{ step.schedule_cron }}</span>
|
||||
</div>
|
||||
|
||||
<div class="step-details">
|
||||
<div class="detail-item">
|
||||
<label>设备ID:</label>
|
||||
<span>{{ step.device_id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<label>目标值:</label>
|
||||
<span>{{ step.target_value }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<label>动作:</label>
|
||||
<span>{{ step.action }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<label>执行次数限制:</label>
|
||||
<span>{{ step.execution_limit > 0 ? step.execution_limit : '无限制' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-steps">
|
||||
该子计划暂无步骤
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" @click="goBack">返回列表</button>
|
||||
<button class="btn btn-primary" @click="editPlan">编辑计划</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FeedPlanDetail',
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
plan: null,
|
||||
loading: true,
|
||||
error: null
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.username = localStorage.getItem('username') || '管理员'
|
||||
this.loadPlanDetail()
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载计划详情
|
||||
async loadPlanDetail() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const planId = this.$route.query.id || this.$route.params.id
|
||||
if (!planId) {
|
||||
this.error = '无效的计划ID'
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/feed/plan/detail?id=${planId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.code === 0) {
|
||||
this.plan = data.data
|
||||
// 加载子计划的详细信息(包括步骤)
|
||||
await this.loadSubPlanDetails()
|
||||
} else {
|
||||
this.error = data.message || '获取计划详情失败'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取计划详情失败:', error)
|
||||
this.error = '获取计划详情失败: ' + error.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 加载子计划详情
|
||||
async loadSubPlanDetails() {
|
||||
if (!this.plan || !this.plan.sub_plans || this.plan.sub_plans.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 遍历所有子计划
|
||||
for (let i = 0; i < this.plan.sub_plans.length; i++) {
|
||||
const subPlan = this.plan.sub_plans[i]
|
||||
// 如果子计划没有步骤或步骤为空,则加载详细信息
|
||||
if (!subPlan.steps || subPlan.steps.length === 0) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/feed/plan/detail?id=${subPlan.id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('authToken')
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.code === 0) {
|
||||
// 用详细信息替换原来的简略信息
|
||||
this.plan.sub_plans[i] = data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`获取子计划 ${subPlan.id} 详情失败:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 返回列表
|
||||
goBack() {
|
||||
this.$router.push('/feed/plan')
|
||||
},
|
||||
|
||||
// 获取父计划名称
|
||||
getParentPlanName(parentId) {
|
||||
// 如果父计划就是当前主计划
|
||||
if (this.plan && this.plan.id === parentId) {
|
||||
return this.plan.name
|
||||
}
|
||||
|
||||
// 检查是否在子计划中
|
||||
if (this.plan && this.plan.sub_plans) {
|
||||
const parentPlan = this.plan.sub_plans.find(plan => plan.id === parentId)
|
||||
if (parentPlan) {
|
||||
return parentPlan.name
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回"未知父计划"
|
||||
return '未知父计划'
|
||||
},
|
||||
|
||||
// 编辑计划
|
||||
editPlan() {
|
||||
// TODO: 实现编辑计划逻辑
|
||||
alert('编辑计划功能待实现')
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
logout() {
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('username')
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feed-plan-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.plan-detail-container {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.plan-header h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.plan-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.plan-status.enabled {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.plan-status.disabled {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.plan-info {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
width: 150px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
flex: 1;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.plan-steps, .sub-plans {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.plan-steps h3, .sub-plans h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.step-cron {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background-color: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.step-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.no-steps {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sub-plans-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.sub-plan-item {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.sub-plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.sub-plan-header h4 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sub-plan-info {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sub-plan-info .info-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sub-plan-steps h5 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0069d9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.nav ul {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.step-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Login from '../pages/Login.vue'
|
||||
import Dashboard from '../pages/Dashboard.vue'
|
||||
import Device from '../pages/Device.vue'
|
||||
import FeedPlan from '../pages/FeedPlan.vue'
|
||||
import FeedPlanDetail from '../pages/FeedPlanDetail.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -20,6 +22,19 @@ const routes = [
|
||||
name: 'Device',
|
||||
component: Device,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/feed/plan',
|
||||
name: 'FeedPlan',
|
||||
component: FeedPlan,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/feed/plan/detail/:id',
|
||||
name: 'FeedPlanDetail',
|
||||
component: FeedPlanDetail,
|
||||
meta: { requiresAuth: true },
|
||||
props: true
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
|
||||
7
go.mod
7
go.mod
@@ -6,6 +6,8 @@ require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/panjf2000/ants/v2 v2.11.3
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.17.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
@@ -15,6 +17,7 @@ require (
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
@@ -35,12 +38,14 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
|
||||
11
go.sum
11
go.sum
@@ -62,6 +62,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
|
||||
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -71,14 +73,17 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
@@ -90,8 +95,8 @@ golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/api/middleware"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/config"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/controller/device"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/controller/feed"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/controller/operation"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/controller/remote"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/controller/user"
|
||||
@@ -44,6 +45,9 @@ type API struct {
|
||||
// deviceController 设备控制控制器
|
||||
deviceController *device.Controller
|
||||
|
||||
// feedController 饲喂管理控制器
|
||||
feedController *feed.Controller
|
||||
|
||||
// remoteController 远程控制控制器
|
||||
remoteController *remote.Controller
|
||||
|
||||
@@ -53,8 +57,8 @@ type API struct {
|
||||
// websocketManager WebSocket管理器
|
||||
websocketManager *websocket.Manager
|
||||
|
||||
// websocketService WebSocket服务
|
||||
websocketService *service.WebSocketService
|
||||
// heartbeatService 心跳服务
|
||||
heartbeatService *service.HeartbeatService
|
||||
|
||||
// deviceStatusPool 设备状态池
|
||||
deviceStatusPool *service.DeviceStatusPool
|
||||
@@ -65,7 +69,16 @@ type API struct {
|
||||
|
||||
// NewAPI 创建并返回一个新的API实例
|
||||
// 初始化Gin引擎和相关配置
|
||||
func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRepo repository.OperationHistoryRepo, deviceControlRepo repository.DeviceControlRepo, deviceRepo repository.DeviceRepo, websocketService *service.WebSocketService, deviceStatusPool *service.DeviceStatusPool) *API {
|
||||
func NewAPI(cfg *config.Config,
|
||||
userRepo repository.UserRepo,
|
||||
operationHistoryRepo repository.OperationHistoryRepo,
|
||||
deviceControlRepo repository.DeviceControlRepo,
|
||||
deviceRepo repository.DeviceRepo,
|
||||
feedRepo repository.FeedPlanRepo,
|
||||
websocketManager *websocket.Manager,
|
||||
heartbeatService *service.HeartbeatService,
|
||||
deviceStatusPool *service.DeviceStatusPool,
|
||||
) *API {
|
||||
// 设置Gin为发布模式
|
||||
gin.SetMode(gin.DebugMode)
|
||||
|
||||
@@ -96,13 +109,13 @@ func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRe
|
||||
operationController := operation.NewController(operationHistoryRepo)
|
||||
|
||||
// 创建设备控制控制器
|
||||
deviceController := device.NewController(deviceControlRepo, deviceRepo, websocketService, deviceStatusPool)
|
||||
deviceController := device.NewController(deviceControlRepo, deviceRepo, websocketManager, heartbeatService, deviceStatusPool)
|
||||
|
||||
// 创建WebSocket管理器
|
||||
websocketManager := websocket.NewManager(websocketService, deviceRepo)
|
||||
// 创建饲喂管理控制器
|
||||
feedController := feed.NewController(feedRepo)
|
||||
|
||||
// 创建远程控制控制器
|
||||
remoteController := remote.NewController(websocketService)
|
||||
remoteController := remote.NewController(websocketManager)
|
||||
|
||||
// 创建鉴权中间件
|
||||
authMiddleware := middleware.NewAuthMiddleware(userRepo)
|
||||
@@ -113,10 +126,11 @@ func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRe
|
||||
userController: userController,
|
||||
operationController: operationController,
|
||||
deviceController: deviceController,
|
||||
feedController: feedController,
|
||||
remoteController: remoteController,
|
||||
authMiddleware: authMiddleware,
|
||||
websocketManager: websocketManager,
|
||||
websocketService: websocketService,
|
||||
heartbeatService: heartbeatService,
|
||||
deviceStatusPool: deviceStatusPool,
|
||||
logger: logs.NewLogger(),
|
||||
}
|
||||
@@ -225,6 +239,16 @@ func (a *API) setupRoutes() {
|
||||
deviceGroup.GET("/status", a.deviceController.GetDeviceStatus)
|
||||
}
|
||||
|
||||
// 饲喂相关路由
|
||||
feedGroup := protectedGroup.Group("/feed")
|
||||
{
|
||||
feedGroup.GET("/plan/list", a.feedController.ListPlans)
|
||||
feedGroup.GET("/plan/detail", a.feedController.Detail)
|
||||
feedGroup.POST("/plan/create", a.feedController.Create)
|
||||
feedGroup.POST("/plan/update", a.feedController.Update)
|
||||
feedGroup.POST("/plan/delete", a.feedController.Delete)
|
||||
}
|
||||
|
||||
// 远程控制相关路由
|
||||
remoteGroup := protectedGroup.Group("/remote")
|
||||
{
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
// Package api 提供统一的API接口层
|
||||
// 负责处理所有外部请求,包括HTTP和WebSocket接口
|
||||
// 将请求路由到相应的服务层进行处理
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebSocket消息类型常量
|
||||
const (
|
||||
// MessageTypeCommand 平台向设备发送的指令
|
||||
MessageTypeCommand = "command"
|
||||
|
||||
// MessageTypeResponse 设备向平台发送的响应
|
||||
MessageTypeResponse = "response"
|
||||
|
||||
// MessageTypeHeartbeat 心跳消息
|
||||
MessageTypeHeartbeat = "heartbeat"
|
||||
)
|
||||
|
||||
// WebSocketMessage WebSocket消息结构
|
||||
type WebSocketMessage struct {
|
||||
// Type 消息类型
|
||||
Type string `json:"type"`
|
||||
|
||||
// DeviceID 设备ID
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
|
||||
// Command 指令内容
|
||||
Command string `json:"command,omitempty"`
|
||||
|
||||
// Data 消息数据
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
|
||||
// Timestamp 时间戳
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// WebSocketManager WebSocket管理器
|
||||
type WebSocketManager struct {
|
||||
// websocketService WebSocket服务
|
||||
websocketService *service.WebSocketService
|
||||
|
||||
// logger 日志记录器
|
||||
logger *logs.Logger
|
||||
|
||||
// upgrader WebSocket升级器
|
||||
upgrader websocket.Upgrader
|
||||
|
||||
// mutex 互斥锁
|
||||
mutex sync.RWMutex
|
||||
|
||||
// connections 设备连接映射
|
||||
connections map[string]*websocket.Conn
|
||||
}
|
||||
|
||||
// NewWebSocketManager 创建WebSocket管理器实例
|
||||
func NewWebSocketManager(websocketService *service.WebSocketService) *WebSocketManager {
|
||||
return &WebSocketManager{
|
||||
websocketService: websocketService,
|
||||
logger: logs.NewLogger(),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// 允许所有跨域请求
|
||||
return true
|
||||
},
|
||||
},
|
||||
connections: make(map[string]*websocket.Conn),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleConnection 处理WebSocket连接
|
||||
func (wm *WebSocketManager) HandleConnection(c *gin.Context) {
|
||||
// 升级HTTP连接到WebSocket
|
||||
conn, err := wm.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
wm.logger.Error("WebSocket连接升级失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取设备ID
|
||||
deviceID := c.Query("device_id")
|
||||
if deviceID == "" {
|
||||
wm.logger.Error("缺少设备ID参数")
|
||||
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "缺少设备ID参数"))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// 添加连接到映射
|
||||
wm.mutex.Lock()
|
||||
wm.connections[deviceID] = conn
|
||||
wm.mutex.Unlock()
|
||||
|
||||
wm.logger.Info("设备 " + deviceID + " 已连接")
|
||||
|
||||
// 发送连接成功消息
|
||||
successMsg := service.WebSocketMessage{
|
||||
Type: "system",
|
||||
Command: "connected",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
conn.WriteJSON(successMsg)
|
||||
|
||||
// 处理消息循环
|
||||
for {
|
||||
// 读取消息
|
||||
messageType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
wm.logger.Error("读取设备 " + deviceID + " 消息失败: " + err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
// 只处理文本消息
|
||||
if messageType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理设备消息
|
||||
if err := wm.websocketService.HandleMessage(deviceID, message); err != nil {
|
||||
wm.logger.Error("处理设备 " + deviceID + " 消息失败: " + err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 连接断开时清理
|
||||
wm.mutex.Lock()
|
||||
delete(wm.connections, deviceID)
|
||||
wm.mutex.Unlock()
|
||||
|
||||
conn.Close()
|
||||
wm.logger.Info("设备 " + deviceID + " 已断开连接")
|
||||
}
|
||||
|
||||
// SendCommand 向指定设备发送指令
|
||||
func (wm *WebSocketManager) SendCommand(deviceID, command string, data interface{}) error {
|
||||
wm.mutex.RLock()
|
||||
conn, exists := wm.connections[deviceID]
|
||||
wm.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return wm.websocketService.SendCommand(deviceID, command, data)
|
||||
}
|
||||
|
||||
// 构造消息
|
||||
msg := service.WebSocketMessage{
|
||||
Type: service.MessageTypeCommand,
|
||||
Command: command,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConnectedDevices 获取已连接的设备列表
|
||||
func (wm *WebSocketManager) GetConnectedDevices() []string {
|
||||
wm.mutex.RLock()
|
||||
defer wm.mutex.RUnlock()
|
||||
|
||||
devices := make([]string, 0, len(wm.connections))
|
||||
for deviceID := range wm.connections {
|
||||
devices = append(devices, deviceID)
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
@@ -20,6 +20,9 @@ type Config struct {
|
||||
|
||||
// WebSocket WebSocket配置
|
||||
WebSocket WebSocketConfig `yaml:"websocket"`
|
||||
|
||||
// Heartbeat 心跳配置
|
||||
Heartbeat HeartbeatConfig `yaml:"heartbeat"`
|
||||
}
|
||||
|
||||
// ServerConfig 代表服务器配置
|
||||
@@ -74,6 +77,18 @@ type DatabaseConfig struct {
|
||||
type WebSocketConfig struct {
|
||||
// Timeout WebSocket请求超时时间(秒)
|
||||
Timeout int `yaml:"timeout"`
|
||||
|
||||
// HeartbeatInterval 心跳检测间隔(秒), 如果超过这个时间没有消息往来系统会自动发送一个心跳包维持长链接
|
||||
HeartbeatInterval int `yaml:"heartbeat_interval"`
|
||||
}
|
||||
|
||||
// HeartbeatConfig 代表心跳配置
|
||||
type HeartbeatConfig struct {
|
||||
// Interval 心跳间隔(秒)
|
||||
Interval int `yaml:"interval"`
|
||||
|
||||
// Concurrency 请求并发数
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
}
|
||||
|
||||
// NewConfig 创建并返回一个新的配置实例
|
||||
@@ -82,6 +97,9 @@ func NewConfig() *Config {
|
||||
WebSocket: WebSocketConfig{
|
||||
Timeout: 5, // 默认5秒超时
|
||||
},
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Interval: 30, // 默认30秒心跳间隔
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +133,12 @@ func (c *Config) GetDatabaseConnectionString() string {
|
||||
)
|
||||
}
|
||||
|
||||
// GetWebSocketTimeout 获取WebSocket超时时间(秒)
|
||||
func (c *Config) GetWebSocketTimeout() int {
|
||||
if c.WebSocket.Timeout <= 0 {
|
||||
return 5 // 默认5秒超时
|
||||
}
|
||||
return c.WebSocket.Timeout
|
||||
// GetWebSocketConfig 获取WebSocket配置
|
||||
func (c *Config) GetWebSocketConfig() WebSocketConfig {
|
||||
return c.WebSocket
|
||||
}
|
||||
|
||||
// GetHeartbeatConfig 获取心跳配置
|
||||
func (c *Config) GetHeartbeatConfig() HeartbeatConfig {
|
||||
return c.Heartbeat
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/model"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/service"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -121,17 +122,19 @@ func (req *DeviceRequest) BindAndValidate(data []byte) error {
|
||||
type Controller struct {
|
||||
deviceControlRepo repository.DeviceControlRepo
|
||||
deviceRepo repository.DeviceRepo
|
||||
websocketService *service.WebSocketService
|
||||
websocketManager *websocket.Manager
|
||||
heartbeatService *service.HeartbeatService
|
||||
deviceStatusPool *service.DeviceStatusPool
|
||||
logger *logs.Logger
|
||||
}
|
||||
|
||||
// NewController 创建设备控制控制器实例
|
||||
func NewController(deviceControlRepo repository.DeviceControlRepo, deviceRepo repository.DeviceRepo, websocketService *service.WebSocketService, deviceStatusPool *service.DeviceStatusPool) *Controller {
|
||||
func NewController(deviceControlRepo repository.DeviceControlRepo, deviceRepo repository.DeviceRepo, websocketManager *websocket.Manager, heartbeatService *service.HeartbeatService, deviceStatusPool *service.DeviceStatusPool) *Controller {
|
||||
return &Controller{
|
||||
deviceControlRepo: deviceControlRepo,
|
||||
deviceRepo: deviceRepo,
|
||||
websocketService: websocketService,
|
||||
websocketManager: websocketManager,
|
||||
heartbeatService: heartbeatService,
|
||||
deviceStatusPool: deviceStatusPool,
|
||||
logger: logs.NewLogger(),
|
||||
}
|
||||
@@ -199,6 +202,9 @@ func (c *Controller) Create(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 刷新设备状态
|
||||
c.heartbeatService.TriggerManualHeartbeatAsync()
|
||||
|
||||
controller.SendSuccessResponse(ctx, "创建设备成功", device)
|
||||
}
|
||||
|
||||
@@ -253,9 +259,6 @@ func (c *Controller) Update(ctx *gin.Context) {
|
||||
req.BusNumber != nil && req.DeviceAddress != nil {
|
||||
device.Set485Address(*req.BusNumber, *req.DeviceAddress)
|
||||
}
|
||||
// TODO: 设备状态应该由系统自动获取,而不是由用户指定
|
||||
// 这里保持设备原有状态,后续需要实现自动状态检测
|
||||
// 设备状态现在只在内存中维护,不持久化到数据库
|
||||
|
||||
if err := c.deviceRepo.Update(device); err != nil {
|
||||
c.logger.Error("更新设备失败: " + err.Error())
|
||||
@@ -263,6 +266,9 @@ func (c *Controller) Update(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 刷新设备状态
|
||||
c.heartbeatService.TriggerManualHeartbeatAsync()
|
||||
|
||||
controller.SendSuccessResponse(ctx, "更新设备成功", device)
|
||||
}
|
||||
|
||||
@@ -291,6 +297,9 @@ func (c *Controller) Delete(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 刷新设备状态
|
||||
c.heartbeatService.TriggerManualHeartbeatAsync()
|
||||
|
||||
controller.SendSuccessResponse(ctx, "删除设备成功", nil)
|
||||
}
|
||||
|
||||
@@ -359,7 +368,7 @@ func (c *Controller) Switch(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
// 发送指令并等待响应
|
||||
response, err := c.websocketService.SendCommandAndWait("relay-001", "control_device", controlData, 0)
|
||||
response, err := c.websocketManager.SendCommandAndWait("relay-001", "control_device", controlData, 0)
|
||||
if err != nil {
|
||||
c.logger.Error("通过WebSocket发送设备控制指令失败: " + err.Error())
|
||||
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "设备控制失败: "+err.Error())
|
||||
@@ -397,6 +406,9 @@ func (c *Controller) Switch(ctx *gin.Context) {
|
||||
Message: message,
|
||||
}
|
||||
|
||||
// 刷新设备状态
|
||||
c.heartbeatService.TriggerManualHeartbeatAsync()
|
||||
|
||||
controller.SendSuccessResponse(ctx, "设备控制成功", data)
|
||||
}
|
||||
|
||||
@@ -474,6 +486,8 @@ func (c *Controller) GetDeviceStatus(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO 需要刷新设备状态吗? 刷新的话这个接口可能会很慢
|
||||
|
||||
// 从设备状态池中获取设备状态
|
||||
status, exists := c.deviceStatusPool.GetStatus(deviceID)
|
||||
if !exists {
|
||||
|
||||
@@ -3,14 +3,471 @@
|
||||
// 通过任务执行器执行具体控制任务
|
||||
package feed
|
||||
|
||||
// FeedController 饲料控制器
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/controller"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/model"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Controller 饲料控制器
|
||||
// 管理饲料制备和分配设备的控制逻辑
|
||||
type FeedController struct {
|
||||
// TODO: 定义饲料控制器结构
|
||||
type Controller struct {
|
||||
feedPlanRepo repository.FeedPlanRepo
|
||||
logger *logs.Logger
|
||||
}
|
||||
|
||||
// NewFeedController 创建并返回一个新的饲料控制器实例
|
||||
func NewFeedController() *FeedController {
|
||||
// NewController 创建并返回一个新的饲料控制器实例
|
||||
func NewController(feedPlanRepo repository.FeedPlanRepo) *Controller {
|
||||
// TODO: 实现饲料控制器初始化
|
||||
return &Controller{
|
||||
feedPlanRepo: feedPlanRepo,
|
||||
logger: logs.NewLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRequest 创建计划请求结构体
|
||||
type CreateRequest struct {
|
||||
// Name 计划名称
|
||||
Name string `json:"name"`
|
||||
|
||||
// Description 计划描述
|
||||
Description string `json:"description"`
|
||||
|
||||
// Type 计划类型(手动触发/自动触发)
|
||||
Type model.FeedingPlanType `json:"type"`
|
||||
|
||||
// Enabled 是否启用
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
|
||||
ScheduleCron *string `json:"schedule_cron,omitempty"`
|
||||
|
||||
// ExecutionLimit 执行次数限制(0表示无限制,仅当Type为auto时有效)
|
||||
ExecutionLimit int `json:"execution_limit"`
|
||||
|
||||
// ParentID 父计划ID(用于支持子计划结构)
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
|
||||
// OrderInParent 在父计划中的执行顺序
|
||||
OrderInParent *int `json:"order_in_parent,omitempty"`
|
||||
|
||||
// IsMaster 是否为主计划(主计划可以包含子计划)
|
||||
IsMaster bool `json:"is_master"`
|
||||
|
||||
// Steps 计划步骤列表
|
||||
Steps []FeedingPlanStep `json:"steps"`
|
||||
|
||||
// SubPlans 子计划列表
|
||||
SubPlans []CreateRequest `json:"sub_plans"`
|
||||
}
|
||||
|
||||
// Create 创建饲料计划
|
||||
func (c *Controller) Create(ctx *gin.Context) {
|
||||
var req CreateRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 校验计划结构
|
||||
if err := c.validatePlanStructure(&req); err != nil {
|
||||
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "计划结构错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换请求结构体为模型
|
||||
plan := c.convertToCreateModel(&req)
|
||||
|
||||
// 调用仓库创建计划
|
||||
if err := c.feedPlanRepo.CreateFeedingPlan(plan); err != nil {
|
||||
c.logger.Error("创建计划失败: " + err.Error())
|
||||
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "创建计划失败")
|
||||
return
|
||||
}
|
||||
|
||||
controller.SendSuccessResponse(ctx, "创建计划成功", nil)
|
||||
}
|
||||
|
||||
// validatePlanStructure 校验计划结构,不允许计划同时包含步骤和子计划
|
||||
func (c *Controller) validatePlanStructure(req *CreateRequest) error {
|
||||
// 检查当前计划是否同时包含步骤和子计划
|
||||
if len(req.Steps) > 0 && len(req.SubPlans) > 0 {
|
||||
return fmt.Errorf("计划不能同时包含步骤和子计划")
|
||||
}
|
||||
|
||||
// 递归检查子计划
|
||||
for _, subPlan := range req.SubPlans {
|
||||
if err := c.validatePlanStructure(&subPlan); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertToCreateModel 将创建请求结构体转换为数据库模型
|
||||
func (c *Controller) convertToCreateModel(req *CreateRequest) *model.FeedingPlan {
|
||||
plan := &model.FeedingPlan{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Type: req.Type,
|
||||
Enabled: req.Enabled,
|
||||
ScheduleCron: req.ScheduleCron,
|
||||
ExecutionLimit: req.ExecutionLimit,
|
||||
ParentID: req.ParentID,
|
||||
OrderInParent: req.OrderInParent,
|
||||
// 不需要显式设置ID字段,仓库层会处理
|
||||
}
|
||||
|
||||
// 转换步骤
|
||||
plan.Steps = make([]model.FeedingPlanStep, len(req.Steps))
|
||||
for i, step := range req.Steps {
|
||||
plan.Steps[i] = model.FeedingPlanStep{
|
||||
// ID在创建时不需要设置
|
||||
// PlanID会在创建过程中自动设置
|
||||
StepOrder: step.StepOrder,
|
||||
DeviceID: step.DeviceID,
|
||||
TargetValue: step.TargetValue,
|
||||
Action: step.Action,
|
||||
ScheduleCron: step.ScheduleCron,
|
||||
ExecutionLimit: step.ExecutionLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// 转换子计划
|
||||
plan.SubPlans = make([]model.FeedingPlan, len(req.SubPlans))
|
||||
for i, subReq := range req.SubPlans {
|
||||
plan.SubPlans[i] = *c.convertToCreateModel(&subReq)
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
// Delete 删除饲料计划
|
||||
func (c *Controller) Delete(ctx *gin.Context) {
|
||||
// 获取路径参数中的计划ID
|
||||
var req struct {
|
||||
ID uint `json:"id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 调用仓库删除计划
|
||||
if err := c.feedPlanRepo.DeleteFeedingPlan(uint(req.ID)); err != nil {
|
||||
c.logger.Error("删除计划失败: " + err.Error())
|
||||
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "删除计划失败")
|
||||
return
|
||||
}
|
||||
|
||||
controller.SendSuccessResponse(ctx, "删除计划成功", nil)
|
||||
}
|
||||
|
||||
type ListPlansResponse struct {
|
||||
Plans []ListPlanResponseItem `json:"plans"`
|
||||
}
|
||||
|
||||
type ListPlanResponseItem struct {
|
||||
// ID 计划ID
|
||||
ID uint `json:"id"`
|
||||
|
||||
// Name 计划名称
|
||||
Name string `json:"name"`
|
||||
|
||||
// Description 计划描述
|
||||
Description string `json:"description"`
|
||||
|
||||
// Type 计划类型
|
||||
Type model.FeedingPlanType `json:"type"`
|
||||
|
||||
// Enabled 是否启用
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// ScheduleCron 定时任务表达式
|
||||
ScheduleCron *string `json:"schedule_cron,omitempty"`
|
||||
}
|
||||
|
||||
// ListPlans 获取饲料计划列表
|
||||
func (c *Controller) ListPlans(ctx *gin.Context) {
|
||||
|
||||
introductions, err := c.feedPlanRepo.ListAllPlanIntroduction()
|
||||
if err != nil {
|
||||
c.logger.Error("获取设备列表失败: " + err.Error())
|
||||
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "获取计划列表失败")
|
||||
}
|
||||
|
||||
resp := ListPlansResponse{
|
||||
Plans: []ListPlanResponseItem{},
|
||||
}
|
||||
for _, introduction := range introductions {
|
||||
resp.Plans = append(resp.Plans, ListPlanResponseItem{
|
||||
ID: introduction.ID,
|
||||
Name: introduction.Name,
|
||||
Description: introduction.Description,
|
||||
Enabled: introduction.Enabled,
|
||||
Type: introduction.Type,
|
||||
ScheduleCron: introduction.ScheduleCron,
|
||||
})
|
||||
}
|
||||
|
||||
controller.SendSuccessResponse(ctx, "success", resp)
|
||||
}
|
||||
|
||||
// UpdateRequest 更新计划请求结构体
|
||||
type UpdateRequest struct {
|
||||
// ID 计划ID
|
||||
ID uint `json:"id"`
|
||||
|
||||
// Name 计划名称
|
||||
Name string `json:"name"`
|
||||
|
||||
// Description 计划描述
|
||||
Description string `json:"description"`
|
||||
|
||||
// Type 计划类型(手动触发/自动触发)
|
||||
Type model.FeedingPlanType `json:"type"`
|
||||
|
||||
// Enabled 是否启用
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
|
||||
ScheduleCron *string `json:"schedule_cron,omitempty"`
|
||||
|
||||
// ExecutionLimit 执行次数限制(0表示无限制,仅当Type为auto时有效)
|
||||
ExecutionLimit int `json:"execution_limit"`
|
||||
|
||||
// ParentID 父计划ID(用于支持子计划结构)
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
|
||||
// OrderInParent 在父计划中的执行顺序
|
||||
OrderInParent *int `json:"order_in_parent,omitempty"`
|
||||
|
||||
// IsMaster 是否为主计划(主计划可以包含子计划)
|
||||
IsMaster bool `json:"is_master"`
|
||||
|
||||
// Steps 计划步骤列表
|
||||
Steps []FeedingPlanStep `json:"steps"`
|
||||
|
||||
// SubPlans 子计划列表
|
||||
SubPlans []UpdateRequest `json:"sub_plans"`
|
||||
}
|
||||
|
||||
// DetailResponse 喂料计划主表
|
||||
type DetailResponse struct {
|
||||
// ID 计划ID
|
||||
ID uint `json:"id"`
|
||||
|
||||
// Name 计划名称
|
||||
Name string `json:"name"`
|
||||
|
||||
// Description 计划描述
|
||||
Description string `json:"description"`
|
||||
|
||||
// Type 计划类型(手动触发/自动触发)
|
||||
Type model.FeedingPlanType `json:"type"`
|
||||
|
||||
// Enabled 是否启用
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
|
||||
ScheduleCron *string `json:"schedule_cron,omitempty"`
|
||||
|
||||
// ExecutionLimit 执行次数限制(0表示无限制,仅当Type为auto时有效)
|
||||
ExecutionLimit int `json:"execution_limit"`
|
||||
|
||||
// ParentID 父计划ID(用于支持子计划结构)
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
|
||||
// OrderInParent 在父计划中的执行顺序
|
||||
OrderInParent *int `json:"order_in_parent,omitempty"`
|
||||
|
||||
// Steps 计划步骤列表
|
||||
Steps []FeedingPlanStep `json:"steps"`
|
||||
|
||||
// SubPlans 子计划列表
|
||||
SubPlans []DetailResponse `json:"sub_plans"`
|
||||
}
|
||||
|
||||
// FeedingPlanStep 喂料计划步骤表,表示计划中的每个设备动作
|
||||
type FeedingPlanStep struct {
|
||||
// ID 步骤ID
|
||||
ID uint `json:"id"`
|
||||
|
||||
// PlanID 关联的计划ID
|
||||
PlanID uint `json:"plan_id"`
|
||||
|
||||
// StepOrder 步骤顺序
|
||||
StepOrder int `json:"step_order"`
|
||||
|
||||
// DeviceID 关联的设备ID
|
||||
DeviceID uint `json:"device_id"`
|
||||
|
||||
// TargetValue 目标值(达到该值后停止工作切换到下一个设备)
|
||||
TargetValue float64 `json:"target_value"`
|
||||
|
||||
// Action 动作(如:打开设备)
|
||||
Action string `json:"action"`
|
||||
|
||||
// ScheduleCron 步骤定时任务表达式(可选)
|
||||
ScheduleCron *string `json:"schedule_cron,omitempty"`
|
||||
|
||||
// ExecutionLimit 步骤执行次数限制(0表示无限制)
|
||||
ExecutionLimit int `json:"execution_limit"`
|
||||
}
|
||||
|
||||
// Detail 获取饲料计划列细节
|
||||
func (c *Controller) Detail(ctx *gin.Context) {
|
||||
// 获取查询参数中的计划ID
|
||||
planIDStr := ctx.Query("id")
|
||||
if planIDStr == "" {
|
||||
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "缺少计划ID参数")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := strconv.ParseUint(planIDStr, 10, 32)
|
||||
if err != nil {
|
||||
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "无效的计划ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 从仓库中获取计划详情
|
||||
plan, err := c.feedPlanRepo.FindFeedingPlanByID(uint(planID))
|
||||
if err != nil {
|
||||
c.logger.Error("获取计划详情失败: " + err.Error())
|
||||
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "获取计划详情失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构体
|
||||
resp := c.convertToDetailResponse(plan)
|
||||
|
||||
controller.SendSuccessResponse(ctx, "success", resp)
|
||||
}
|
||||
|
||||
// Update 更新饲料计划
|
||||
func (c *Controller) Update(ctx *gin.Context) {
|
||||
var req UpdateRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 校验计划结构
|
||||
if err := c.validateUpdatePlanStructure(&req); err != nil {
|
||||
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "计划结构错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换请求结构体为模型
|
||||
plan := c.convertToUpdateModel(&req)
|
||||
|
||||
// 调用仓库更新计划
|
||||
if err := c.feedPlanRepo.UpdateFeedingPlan(plan); err != nil {
|
||||
c.logger.Error("更新计划失败: " + err.Error())
|
||||
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "更新计划失败")
|
||||
return
|
||||
}
|
||||
|
||||
controller.SendSuccessResponse(ctx, "更新计划成功", nil)
|
||||
}
|
||||
|
||||
// validateUpdatePlanStructure 校验更新计划结构,不允许计划同时包含步骤和子计划
|
||||
func (c *Controller) validateUpdatePlanStructure(req *UpdateRequest) error {
|
||||
// 检查当前计划是否同时包含步骤和子计划
|
||||
if len(req.Steps) > 0 && len(req.SubPlans) > 0 {
|
||||
return fmt.Errorf("计划不能同时包含步骤和子计划")
|
||||
}
|
||||
|
||||
// 递归检查子计划
|
||||
for _, subPlan := range req.SubPlans {
|
||||
if err := c.validateUpdatePlanStructure(&subPlan); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertToUpdateModel 将更新请求结构体转换为数据库模型
|
||||
func (c *Controller) convertToUpdateModel(req *UpdateRequest) *model.FeedingPlan {
|
||||
plan := &model.FeedingPlan{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Type: req.Type,
|
||||
Enabled: req.Enabled,
|
||||
ScheduleCron: req.ScheduleCron,
|
||||
ExecutionLimit: req.ExecutionLimit,
|
||||
ParentID: req.ParentID,
|
||||
OrderInParent: req.OrderInParent,
|
||||
Steps: make([]model.FeedingPlanStep, len(req.Steps)),
|
||||
SubPlans: make([]model.FeedingPlan, len(req.SubPlans)),
|
||||
}
|
||||
|
||||
// 转换步骤
|
||||
for i, step := range req.Steps {
|
||||
plan.Steps[i] = model.FeedingPlanStep{
|
||||
ID: step.ID,
|
||||
PlanID: step.PlanID,
|
||||
StepOrder: step.StepOrder,
|
||||
DeviceID: step.DeviceID,
|
||||
TargetValue: step.TargetValue,
|
||||
Action: step.Action,
|
||||
ScheduleCron: step.ScheduleCron,
|
||||
ExecutionLimit: step.ExecutionLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// 转换子计划
|
||||
for i, subReq := range req.SubPlans {
|
||||
plan.SubPlans[i] = *c.convertToUpdateModel(&subReq)
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
// convertToDetailResponse 将数据库模型转换为响应结构体
|
||||
func (c *Controller) convertToDetailResponse(plan *model.FeedingPlan) *DetailResponse {
|
||||
resp := &DetailResponse{
|
||||
ID: plan.ID,
|
||||
Name: plan.Name,
|
||||
Description: plan.Description,
|
||||
Type: plan.Type,
|
||||
Enabled: plan.Enabled,
|
||||
ScheduleCron: plan.ScheduleCron,
|
||||
ExecutionLimit: plan.ExecutionLimit,
|
||||
ParentID: plan.ParentID,
|
||||
OrderInParent: plan.OrderInParent,
|
||||
Steps: make([]FeedingPlanStep, len(plan.Steps)),
|
||||
SubPlans: make([]DetailResponse, len(plan.SubPlans)),
|
||||
}
|
||||
|
||||
// 转换步骤
|
||||
for i, step := range plan.Steps {
|
||||
resp.Steps[i] = FeedingPlanStep{
|
||||
ID: step.ID,
|
||||
PlanID: step.PlanID,
|
||||
StepOrder: step.StepOrder,
|
||||
DeviceID: step.DeviceID,
|
||||
TargetValue: step.TargetValue,
|
||||
Action: step.Action,
|
||||
ScheduleCron: step.ScheduleCron,
|
||||
ExecutionLimit: step.ExecutionLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// 转换子计划
|
||||
for i, subPlan := range plan.SubPlans {
|
||||
// 递归转换子计划
|
||||
resp.SubPlans[i] = *c.convertToDetailResponse(&subPlan)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -5,20 +5,20 @@ package remote
|
||||
import (
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/controller"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/service"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Controller 远程控制控制器
|
||||
type Controller struct {
|
||||
websocketService *service.WebSocketService
|
||||
websocketManager *websocket.Manager
|
||||
logger *logs.Logger
|
||||
}
|
||||
|
||||
// NewController 创建远程控制控制器实例
|
||||
func NewController(websocketService *service.WebSocketService) *Controller {
|
||||
func NewController(websocketManager *websocket.Manager) *Controller {
|
||||
return &Controller{
|
||||
websocketService: websocketService,
|
||||
websocketManager: websocketManager,
|
||||
logger: logs.NewLogger(),
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (c *Controller) SendCommand(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
// 发送指令并等待响应
|
||||
response, err := c.websocketService.SendCommandAndWait(req.DeviceID, req.Command, commandData, 0)
|
||||
response, err := c.websocketManager.SendCommandAndWait(req.DeviceID, req.Command, commandData, 0)
|
||||
if err != nil {
|
||||
c.logger.Error("发送指令失败: " + err.Error())
|
||||
controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "发送指令失败: "+err.Error())
|
||||
@@ -100,7 +100,7 @@ type ListConnectedDevicesResponseData struct {
|
||||
// @Router /api/v1/remote/devices [get]
|
||||
func (c *Controller) ListConnectedDevices(ctx *gin.Context) {
|
||||
// 获取已连接的设备列表
|
||||
devices := c.websocketService.GetConnectedDevices()
|
||||
devices := c.websocketManager.GetConnectedDevices()
|
||||
|
||||
data := ListConnectedDevicesResponseData{
|
||||
Devices: devices,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/storage/db"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/task"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/websocket"
|
||||
)
|
||||
|
||||
// Application 代表核心应用结构
|
||||
@@ -39,12 +40,18 @@ type Application struct {
|
||||
// DeviceRepo 设备仓库实例
|
||||
DeviceRepo repository.DeviceRepo
|
||||
|
||||
// WebSocketService WebSocket服务实例
|
||||
WebSocketService *service.WebSocketService
|
||||
// FeedPlanRepo 投喂计划仓库实例
|
||||
FeedPlanRepo repository.FeedPlanRepo
|
||||
|
||||
// WebsocketManager WebSocket管理器
|
||||
WebsocketManager *websocket.Manager
|
||||
|
||||
// DeviceStatusPool 设备状态池实例
|
||||
DeviceStatusPool *service.DeviceStatusPool
|
||||
|
||||
// HeartbeatService 心跳服务实例
|
||||
HeartbeatService *service.HeartbeatService
|
||||
|
||||
// Config 应用配置
|
||||
Config *config.Config
|
||||
|
||||
@@ -93,18 +100,30 @@ func (app *Application) Start() error {
|
||||
// 初始化设备仓库
|
||||
app.DeviceRepo = repository.NewDeviceRepo(app.Storage.GetDB())
|
||||
|
||||
app.FeedPlanRepo = repository.NewFeedPlanRepo(app.Storage.GetDB())
|
||||
|
||||
// 初始化设备状态池
|
||||
app.DeviceStatusPool = service.NewDeviceStatusPool()
|
||||
|
||||
// 初始化WebSocket服务
|
||||
app.WebSocketService = service.NewWebSocketService(app.DeviceRepo)
|
||||
// 设置设备状态池
|
||||
app.WebSocketService.SetDeviceStatusPool(app.DeviceStatusPool)
|
||||
app.WebsocketManager = websocket.NewManager(app.DeviceRepo)
|
||||
// 设置WebSocket超时时间
|
||||
app.WebSocketService.SetDefaultTimeout(app.Config.GetWebSocketTimeout())
|
||||
app.WebsocketManager.SetDefaultTimeout(app.Config.GetWebSocketConfig().Timeout)
|
||||
|
||||
// 初始化心跳服务
|
||||
app.HeartbeatService = service.NewHeartbeatService(app.WebsocketManager, app.DeviceStatusPool, app.DeviceRepo, app.Config)
|
||||
|
||||
// 初始化API组件
|
||||
app.API = api.NewAPI(app.Config, app.UserRepo, app.OperationHistoryRepo, app.DeviceControlRepo, app.DeviceRepo, app.WebSocketService, app.DeviceStatusPool)
|
||||
app.API = api.NewAPI(app.Config,
|
||||
app.UserRepo,
|
||||
app.OperationHistoryRepo,
|
||||
app.DeviceControlRepo,
|
||||
app.DeviceRepo,
|
||||
app.FeedPlanRepo,
|
||||
app.WebsocketManager,
|
||||
app.HeartbeatService,
|
||||
app.DeviceStatusPool,
|
||||
)
|
||||
|
||||
// 初始化任务执行器组件(使用5个工作协程)
|
||||
app.TaskExecutor = task.NewExecutor(5)
|
||||
@@ -119,6 +138,9 @@ func (app *Application) Start() error {
|
||||
app.logger.Info("启动任务执行器")
|
||||
app.TaskExecutor.Start()
|
||||
|
||||
// 启动心跳服务
|
||||
app.logger.Info("启动心跳服务")
|
||||
app.HeartbeatService.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,6 +156,10 @@ func (app *Application) Stop() error {
|
||||
app.logger.Info("停止任务执行器")
|
||||
app.TaskExecutor.Stop()
|
||||
|
||||
// 停止心跳服务
|
||||
app.logger.Info("停止心跳服务")
|
||||
app.HeartbeatService.Stop()
|
||||
|
||||
// 停止存储组件
|
||||
if err := app.Storage.Disconnect(); err != nil {
|
||||
return fmt.Errorf("存储断开连接失败: %v", err)
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
// Package core 提供WebSocket服务功能
|
||||
// 实现中继设备和平台之间的双向通信
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/model"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebSocket消息类型常量
|
||||
const (
|
||||
// MessageTypeCommand 平台向设备发送的指令
|
||||
MessageTypeCommand = "command"
|
||||
|
||||
// MessageTypeResponse 设备向平台发送的响应
|
||||
MessageTypeResponse = "response"
|
||||
|
||||
// MessageTypeHeartbeat 心跳消息
|
||||
MessageTypeHeartbeat = "heartbeat"
|
||||
)
|
||||
|
||||
// WebSocketMessage WebSocket消息结构
|
||||
type WebSocketMessage struct {
|
||||
// Type 消息类型
|
||||
Type string `json:"type"`
|
||||
|
||||
// DeviceID 设备ID
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
|
||||
// Command 指令内容
|
||||
Command string `json:"command,omitempty"`
|
||||
|
||||
// Data 消息数据
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
|
||||
// Timestamp 时间戳
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// DeviceConnection 设备连接信息
|
||||
type DeviceConnection struct {
|
||||
// DeviceID 设备ID
|
||||
DeviceID string
|
||||
|
||||
// Connection WebSocket连接
|
||||
Connection *websocket.Conn
|
||||
|
||||
// LastHeartbeat 最后心跳时间
|
||||
LastHeartbeat time.Time
|
||||
|
||||
// DeviceInfo 设备信息
|
||||
DeviceInfo *model.Device
|
||||
}
|
||||
|
||||
// WebSocketService WebSocket服务
|
||||
type WebSocketService struct {
|
||||
// connections 设备连接映射
|
||||
connections map[string]*DeviceConnection
|
||||
|
||||
// mutex 互斥锁
|
||||
mutex sync.RWMutex
|
||||
|
||||
// logger 日志记录器
|
||||
logger *logs.Logger
|
||||
|
||||
// deviceRepo 设备仓库
|
||||
deviceRepo repository.DeviceRepo
|
||||
}
|
||||
|
||||
// NewWebSocketService 创建WebSocket服务实例
|
||||
func NewWebSocketService(deviceRepo repository.DeviceRepo) *WebSocketService {
|
||||
return &WebSocketService{
|
||||
connections: make(map[string]*DeviceConnection),
|
||||
logger: logs.NewLogger(),
|
||||
deviceRepo: deviceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// getDeviceDisplayName 获取设备显示名称
|
||||
func (ws *WebSocketService) getDeviceDisplayName(deviceID string) string {
|
||||
if ws.deviceRepo != nil {
|
||||
if device, err := ws.deviceRepo.FindByIDString(deviceID); err == nil && device != nil {
|
||||
return fmt.Sprintf("%s(id:%s)", device.Name, deviceID)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("未知设备(id:%s)", deviceID)
|
||||
}
|
||||
|
||||
// AddConnection 添加设备连接
|
||||
func (ws *WebSocketService) AddConnection(deviceID string, conn *websocket.Conn) {
|
||||
ws.mutex.Lock()
|
||||
defer ws.mutex.Unlock()
|
||||
|
||||
ws.connections[deviceID] = &DeviceConnection{
|
||||
DeviceID: deviceID,
|
||||
Connection: conn,
|
||||
LastHeartbeat: time.Now(),
|
||||
}
|
||||
|
||||
deviceName := ws.getDeviceDisplayName(deviceID)
|
||||
ws.logger.Info(fmt.Sprintf("设备 %s 已连接", deviceName))
|
||||
}
|
||||
|
||||
// RemoveConnection 移除设备连接
|
||||
func (ws *WebSocketService) RemoveConnection(deviceID string) {
|
||||
ws.mutex.Lock()
|
||||
defer ws.mutex.Unlock()
|
||||
|
||||
deviceName := ws.getDeviceDisplayName(deviceID)
|
||||
|
||||
delete(ws.connections, deviceID)
|
||||
|
||||
ws.logger.Info(fmt.Sprintf("设备 %s 已断开连接", deviceName))
|
||||
}
|
||||
|
||||
// SendCommand 向指定设备发送指令
|
||||
func (ws *WebSocketService) SendCommand(deviceID, command string, data interface{}) error {
|
||||
ws.mutex.RLock()
|
||||
deviceConn, exists := ws.connections[deviceID]
|
||||
ws.mutex.RUnlock()
|
||||
|
||||
deviceName := ws.getDeviceDisplayName(deviceID)
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("设备 %s 未连接", deviceName)
|
||||
}
|
||||
|
||||
// 构造消息
|
||||
msg := WebSocketMessage{
|
||||
Type: MessageTypeCommand,
|
||||
Command: command,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
if err := deviceConn.Connection.WriteJSON(msg); err != nil {
|
||||
return fmt.Errorf("向设备 %s 发送指令失败: %v", deviceName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConnectedDevices 获取已连接的设备列表
|
||||
func (ws *WebSocketService) GetConnectedDevices() []string {
|
||||
ws.mutex.RLock()
|
||||
defer ws.mutex.RUnlock()
|
||||
|
||||
devices := make([]string, 0, len(ws.connections))
|
||||
for deviceID := range ws.connections {
|
||||
devices = append(devices, deviceID)
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// HandleMessage 处理来自设备的消息
|
||||
func (ws *WebSocketService) HandleMessage(deviceID string, message []byte) error {
|
||||
// 解析消息
|
||||
var msg WebSocketMessage
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
return fmt.Errorf("解析设备 %s 消息失败: %v", ws.getDeviceDisplayName(deviceID), err)
|
||||
}
|
||||
|
||||
// 更新心跳时间
|
||||
if msg.Type == MessageTypeHeartbeat {
|
||||
ws.mutex.Lock()
|
||||
if deviceConn, exists := ws.connections[deviceID]; exists {
|
||||
deviceConn.LastHeartbeat = time.Now()
|
||||
}
|
||||
ws.mutex.Unlock()
|
||||
}
|
||||
|
||||
// 记录消息日志
|
||||
ws.logger.Info(fmt.Sprintf("收到来自设备 %s 的消息: %v", ws.getDeviceDisplayName(deviceID), msg))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeviceConnection 获取设备连接信息
|
||||
func (ws *WebSocketService) GetDeviceConnection(deviceID string) (*DeviceConnection, bool) {
|
||||
ws.mutex.RLock()
|
||||
defer ws.mutex.RUnlock()
|
||||
|
||||
deviceConn, exists := ws.connections[deviceID]
|
||||
return deviceConn, exists
|
||||
}
|
||||
193
internal/model/feed.go
Normal file
193
internal/model/feed.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FeedingPlanType 喂料计划类型枚举
|
||||
type FeedingPlanType string
|
||||
|
||||
const (
|
||||
// FeedingPlanTypeManual 手动触发
|
||||
FeedingPlanTypeManual FeedingPlanType = "manual"
|
||||
// FeedingPlanTypeAuto 自动触发
|
||||
FeedingPlanTypeAuto FeedingPlanType = "auto"
|
||||
)
|
||||
|
||||
// FeedingPlan 喂料计划主表
|
||||
type FeedingPlan struct {
|
||||
// ID 计划ID
|
||||
ID uint `gorm:"primaryKey;column:id" json:"id"`
|
||||
|
||||
// Name 计划名称
|
||||
Name string `gorm:"not null;column:name" json:"name"`
|
||||
|
||||
// Description 计划描述
|
||||
Description string `gorm:"column:description" json:"description"`
|
||||
|
||||
// Type 计划类型(手动触发/自动触发)
|
||||
Type FeedingPlanType `gorm:"not null;column:type" json:"type"`
|
||||
|
||||
// Enabled 是否启用
|
||||
Enabled bool `gorm:"not null;default:true;column:enabled" json:"enabled"`
|
||||
|
||||
// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
|
||||
ScheduleCron *string `gorm:"column:schedule_cron" json:"schedule_cron,omitempty"`
|
||||
|
||||
// ExecutionLimit 执行次数限制(0表示无限制,仅当Type为auto时有效)
|
||||
ExecutionLimit int `gorm:"not null;default:0;column:execution_limit" json:"execution_limit"`
|
||||
|
||||
// ParentID 父计划ID(用于支持子计划结构)
|
||||
ParentID *uint `gorm:"column:parent_id;index" json:"parent_id,omitempty"`
|
||||
|
||||
// OrderInParent 在父计划中的执行顺序
|
||||
OrderInParent *int `gorm:"column:order_in_parent" json:"order_in_parent,omitempty"`
|
||||
|
||||
// CreatedAt 创建时间
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
|
||||
// UpdatedAt 更新时间
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// DeletedAt 删除时间(用于软删除)
|
||||
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
|
||||
|
||||
// Steps 计划步骤列表
|
||||
Steps []FeedingPlanStep `gorm:"foreignKey:PlanID" json:"-"`
|
||||
|
||||
// SubPlans 子计划列表
|
||||
SubPlans []FeedingPlan `gorm:"foreignKey:ParentID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName 指定FeedingPlan模型对应的数据库表名
|
||||
func (FeedingPlan) TableName() string {
|
||||
return "feeding_plans"
|
||||
}
|
||||
|
||||
// FeedingPlanStep 喂料计划步骤表,表示计划中的每个设备动作
|
||||
type FeedingPlanStep struct {
|
||||
// ID 步骤ID
|
||||
ID uint `gorm:"primaryKey;column:id" json:"id"`
|
||||
|
||||
// PlanID 关联的计划ID
|
||||
PlanID uint `gorm:"not null;column:plan_id;index" json:"plan_id"`
|
||||
|
||||
// StepOrder 步骤顺序
|
||||
StepOrder int `gorm:"not null;column:step_order" json:"step_order"`
|
||||
|
||||
// DeviceID 关联的设备ID
|
||||
DeviceID uint `gorm:"not null;column:device_id;index" json:"device_id"`
|
||||
|
||||
// TargetValue 目标值(达到该值后停止工作切换到下一个设备)
|
||||
TargetValue float64 `gorm:"not null;column:target_value" json:"target_value"`
|
||||
|
||||
// Action 动作(如:打开设备)
|
||||
Action string `gorm:"not null;column:action" json:"action"`
|
||||
|
||||
// ScheduleCron 步骤定时任务表达式(可选)
|
||||
ScheduleCron *string `gorm:"column:schedule_cron" json:"schedule_cron,omitempty"`
|
||||
|
||||
// ExecutionLimit 步骤执行次数限制(0表示无限制)
|
||||
ExecutionLimit int `gorm:"not null;default:0;column:execution_limit" json:"execution_limit"`
|
||||
|
||||
// CreatedAt 创建时间
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
|
||||
// UpdatedAt 更新时间
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// DeletedAt 删除时间(用于软删除)
|
||||
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
|
||||
}
|
||||
|
||||
// TableName 指定FeedingPlanStep模型对应的数据库表名
|
||||
func (FeedingPlanStep) TableName() string {
|
||||
return "feeding_plan_steps"
|
||||
}
|
||||
|
||||
// FeedingExecution 喂料执行记录表
|
||||
type FeedingExecution struct {
|
||||
// ID 执行记录ID
|
||||
ID uint `gorm:"primaryKey;column:id" json:"id"`
|
||||
|
||||
// PlanID 关联的计划ID
|
||||
PlanID uint `gorm:"not null;column:plan_id;index" json:"plan_id"`
|
||||
|
||||
// MasterPlanID 主计划ID(如果是子计划执行,记录主计划ID)
|
||||
MasterPlanID *uint `gorm:"column:master_plan_id;index" json:"master_plan_id,omitempty"`
|
||||
|
||||
// TriggerType 触发类型(手动/自动)
|
||||
TriggerType FeedingPlanType `gorm:"not null;column:trigger_type" json:"trigger_type"`
|
||||
|
||||
// Status 执行状态(进行中/已完成/已取消/失败)
|
||||
Status string `gorm:"not null;column:status" json:"status"`
|
||||
|
||||
// StartedAt 开始执行时间
|
||||
StartedAt *time.Time `gorm:"column:started_at" json:"started_at,omitempty"`
|
||||
|
||||
// FinishedAt 完成时间
|
||||
FinishedAt *time.Time `gorm:"column:finished_at" json:"finished_at,omitempty"`
|
||||
|
||||
// CreatedAt 创建时间
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
|
||||
// UpdatedAt 更新时间
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// DeletedAt 删除时间(用于软删除)
|
||||
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
|
||||
|
||||
// Steps 执行步骤详情
|
||||
Steps []FeedingExecutionStep `gorm:"foreignKey:ExecutionID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName 指定FeedingExecution模型对应的数据库表名
|
||||
func (FeedingExecution) TableName() string {
|
||||
return "feeding_executions"
|
||||
}
|
||||
|
||||
// FeedingExecutionStep 喂料执行步骤详情表
|
||||
type FeedingExecutionStep struct {
|
||||
// ID 执行步骤ID
|
||||
ID uint `gorm:"primaryKey;column:id" json:"id"`
|
||||
|
||||
// ExecutionID 关联的执行记录ID
|
||||
ExecutionID uint `gorm:"not null;column:execution_id;index" json:"execution_id"`
|
||||
|
||||
// StepID 关联的计划步骤ID
|
||||
StepID uint `gorm:"not null;column:step_id;index" json:"step_id"`
|
||||
|
||||
// DeviceID 关联的设备ID
|
||||
DeviceID uint `gorm:"not null;column:device_id;index" json:"device_id"`
|
||||
|
||||
// TargetValue 目标值
|
||||
TargetValue float64 `gorm:"not null;column:target_value" json:"target_value"`
|
||||
|
||||
// ActualValue 实际值
|
||||
ActualValue *float64 `gorm:"column:actual_value" json:"actual_value,omitempty"`
|
||||
|
||||
// Status 步骤状态(待执行/执行中/已完成/失败)
|
||||
Status string `gorm:"not null;column:status" json:"status"`
|
||||
|
||||
// StartedAt 开始执行时间
|
||||
StartedAt *time.Time `gorm:"column:started_at" json:"started_at,omitempty"`
|
||||
|
||||
// FinishedAt 完成时间
|
||||
FinishedAt *time.Time `gorm:"column:finished_at" json:"finished_at,omitempty"`
|
||||
|
||||
// CreatedAt 创建时间
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
|
||||
// UpdatedAt 更新时间
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// DeletedAt 删除时间(用于软删除)
|
||||
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
|
||||
}
|
||||
|
||||
// TableName 指定FeedingExecutionStep模型对应的数据库表名
|
||||
func (FeedingExecutionStep) TableName() string {
|
||||
return "feeding_execution_steps"
|
||||
}
|
||||
@@ -65,3 +65,19 @@ func (dsp *DeviceStatusPool) GetAllStatuses() map[string]*DeviceStatus {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SetAllStatuses 全量更新设备状态池
|
||||
func (dsp *DeviceStatusPool) SetAllStatuses(statuses map[string]*DeviceStatus) {
|
||||
dsp.mutex.Lock()
|
||||
defer dsp.mutex.Unlock()
|
||||
|
||||
// 清空现有状态
|
||||
for id := range dsp.statuses {
|
||||
delete(dsp.statuses, id)
|
||||
}
|
||||
|
||||
// 添加新状态
|
||||
for id, status := range statuses {
|
||||
dsp.statuses[id] = status
|
||||
}
|
||||
}
|
||||
|
||||
297
internal/service/heartbeat.go
Normal file
297
internal/service/heartbeat.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Package service 提供各种业务服务功能
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/config"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/model"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/websocket"
|
||||
"github.com/panjf2000/ants/v2"
|
||||
)
|
||||
|
||||
// HeartbeatService 心跳服务,负责管理设备的心跳检测
|
||||
type HeartbeatService struct {
|
||||
// websocketManager WebSocket管理器
|
||||
websocketManager *websocket.Manager
|
||||
|
||||
// deviceStatusPool 设备状态池
|
||||
deviceStatusPool *DeviceStatusPool
|
||||
|
||||
// deviceRepo 设备仓库
|
||||
deviceRepo repository.DeviceRepo
|
||||
|
||||
// logger 日志记录器
|
||||
logger *logs.Logger
|
||||
|
||||
// 心跳间隔
|
||||
heartbeatInterval time.Duration
|
||||
|
||||
// 手动心跳触发器
|
||||
triggerChan chan struct{}
|
||||
|
||||
// ticker 心跳定时器
|
||||
ticker *time.Ticker
|
||||
|
||||
// poolSize 线程池大小
|
||||
poolSize int
|
||||
|
||||
// pool 线程池
|
||||
pool *ants.Pool
|
||||
|
||||
// ctx 上下文
|
||||
ctx context.Context
|
||||
|
||||
// cancel 取消函数
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewHeartbeatService 创建心跳服务实例
|
||||
func NewHeartbeatService(websocketManager *websocket.Manager, deviceStatusPool *DeviceStatusPool, deviceRepo repository.DeviceRepo, config *config.Config) *HeartbeatService {
|
||||
|
||||
interval := config.GetHeartbeatConfig().Interval
|
||||
if interval <= 0 {
|
||||
interval = 30 // 默认30秒心跳间隔
|
||||
}
|
||||
|
||||
concurrency := config.GetHeartbeatConfig().Concurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = 10 // 默认10个并发
|
||||
}
|
||||
|
||||
return &HeartbeatService{
|
||||
websocketManager: websocketManager,
|
||||
deviceStatusPool: deviceStatusPool,
|
||||
deviceRepo: deviceRepo,
|
||||
logger: logs.NewLogger(),
|
||||
heartbeatInterval: time.Duration(interval) * time.Second,
|
||||
poolSize: concurrency,
|
||||
triggerChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动心跳服务
|
||||
func (hs *HeartbeatService) Start() {
|
||||
// 创建上下文
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
hs.cancel = cancel
|
||||
|
||||
// 创建定时器
|
||||
hs.logger.Info(fmt.Sprintf("设置心跳间隔为 %d 秒", int(hs.heartbeatInterval.Seconds())))
|
||||
hs.ticker = time.NewTicker(hs.heartbeatInterval)
|
||||
|
||||
// 创建线程池
|
||||
hs.pool, _ = ants.NewPool(hs.poolSize)
|
||||
|
||||
// 启动心跳goroutine
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-hs.ticker.C:
|
||||
hs.handleHeartbeatAll()
|
||||
case <-hs.triggerChan:
|
||||
hs.handleHeartbeatAll()
|
||||
case <-ctx.Done():
|
||||
hs.logger.Info("心跳服务已停止")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
hs.logger.Info("心跳服务已启动")
|
||||
}
|
||||
|
||||
// Stop 停止心跳服务
|
||||
func (hs *HeartbeatService) Stop() {
|
||||
if hs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if hs.ticker != nil {
|
||||
hs.ticker.Stop()
|
||||
}
|
||||
|
||||
if hs.cancel != nil {
|
||||
hs.cancel()
|
||||
}
|
||||
|
||||
if hs.pool != nil {
|
||||
hs.pool.Release()
|
||||
}
|
||||
|
||||
hs.logger.Info("[Heartbeat] 心跳任务停止指令已发送")
|
||||
}
|
||||
|
||||
// TriggerManualHeartbeat 手动触发心跳检测
|
||||
func (hs *HeartbeatService) TriggerManualHeartbeat() {
|
||||
hs.logger.Info("收到手动触发心跳检测请求")
|
||||
hs.triggerChan <- struct{}{}
|
||||
hs.logger.Info("手动心跳检测完成")
|
||||
}
|
||||
|
||||
// TriggerManualHeartbeatAsync 手动触发心跳检测且不等待检测结果
|
||||
func (hs *HeartbeatService) TriggerManualHeartbeatAsync() {
|
||||
hs.logger.Info("收到手动触发异步心跳检测请求")
|
||||
go func() {
|
||||
hs.triggerChan <- struct{}{}
|
||||
hs.logger.Info("手动心跳检测完成")
|
||||
}()
|
||||
}
|
||||
|
||||
// sendHeartbeatAll 发送心跳包到所有中继设备
|
||||
func (hs *HeartbeatService) handleHeartbeatAll() {
|
||||
// 记录心跳开始日志
|
||||
hs.logger.Debug("开始发送心跳包")
|
||||
|
||||
// 获取所有中继设备
|
||||
relays, err := hs.deviceRepo.ListAll()
|
||||
if err != nil {
|
||||
hs.logger.Error("获取设备列表失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 创建线程安全的临时map用于保存所有设备状态
|
||||
tempStatusMap := &TempStatusMap{
|
||||
data: make(map[string]*DeviceStatus),
|
||||
mu: sync.RWMutex{},
|
||||
}
|
||||
|
||||
// 遍历所有连接的设备并发送心跳包
|
||||
wg := sync.WaitGroup{}
|
||||
for _, relay := range relays {
|
||||
// 心跳包之发送给中继设备
|
||||
if relay.Type != model.DeviceTypeRelay {
|
||||
continue
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("%v", relay.ID)
|
||||
name := relay.Name
|
||||
|
||||
wg.Add(1)
|
||||
err := hs.pool.Submit(func() {
|
||||
defer wg.Done()
|
||||
|
||||
err := hs.handleHeartbeatWithStatus(id, tempStatusMap)
|
||||
if err != nil {
|
||||
hs.logger.Error("[Heartbeat] 向设备 " + name + "(id:" + id + ") 发送心跳包失败: " + err.Error())
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
hs.logger.Error("向设备 " + name + "(id:" + id + ") 发送心跳包失败(线程池异常): " + err.Error())
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 获取所有设备列表
|
||||
allDevices, err := hs.deviceRepo.ListAll()
|
||||
if err != nil {
|
||||
hs.logger.Error("获取所有设备列表失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 补齐临时map中缺失的设备,缺失的设备全部设为离线状态
|
||||
tempStatusMap.mu.Lock()
|
||||
for _, device := range allDevices {
|
||||
id := fmt.Sprintf("%v", device.ID)
|
||||
if _, exists := tempStatusMap.data[id]; !exists {
|
||||
tempStatusMap.data[id] = &DeviceStatus{
|
||||
Active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
tempStatusMap.mu.Unlock()
|
||||
|
||||
// 将临时状态更新到全局状态池
|
||||
hs.deviceStatusPool.SetAllStatuses(tempStatusMap.data)
|
||||
|
||||
hs.logger.Debug("心跳包发送完成")
|
||||
}
|
||||
|
||||
// TempStatusMap 线程安全的临时状态映射
|
||||
type TempStatusMap struct {
|
||||
data map[string]*DeviceStatus
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// SetStatus 设置设备状态
|
||||
func (tsm *TempStatusMap) SetStatus(deviceID string, status *DeviceStatus) {
|
||||
tsm.mu.Lock()
|
||||
defer tsm.mu.Unlock()
|
||||
tsm.data[deviceID] = status
|
||||
}
|
||||
|
||||
// GetStatus 获取设备状态
|
||||
func (tsm *TempStatusMap) GetStatus(deviceID string) (*DeviceStatus, bool) {
|
||||
tsm.mu.RLock()
|
||||
defer tsm.mu.RUnlock()
|
||||
status, exists := tsm.data[deviceID]
|
||||
return status, exists
|
||||
}
|
||||
|
||||
// sendHeartbeat 发送心跳包到所有中继设备
|
||||
func (hs *HeartbeatService) handleHeartbeatWithStatus(deviceID string, tempStatusMap *TempStatusMap) error {
|
||||
// 构造带时间戳的心跳包数据
|
||||
heartbeatData := map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
// 发送心跳包到设备
|
||||
response, err := hs.websocketManager.SendCommandAndWait(deviceID, "heartbeat", heartbeatData, 0)
|
||||
if err != nil {
|
||||
hs.logger.Error(fmt.Sprintf("向设备 %s 发送心跳包失败: %v", deviceID, err))
|
||||
// 更新设备状态为离线
|
||||
tempStatusMap.SetStatus(deviceID, &DeviceStatus{
|
||||
Active: false,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录收到心跳响应
|
||||
hs.logger.Debug(fmt.Sprintf("收到来自设备 %s 的心跳响应: %+v", deviceID, response))
|
||||
|
||||
// 有响应中继设备就是在线
|
||||
tempStatusMap.SetStatus(deviceID, &DeviceStatus{
|
||||
Active: true,
|
||||
})
|
||||
|
||||
// 时间戳校验
|
||||
if response.Timestamp.Unix() != heartbeatData["timestamp"] {
|
||||
hs.logger.Error(fmt.Sprintf("心跳响应时间戳校验失败: %v , 响应时间戳应当与发送的时间戳一致", response))
|
||||
return errors.New("心跳响应时间戳校验失败")
|
||||
}
|
||||
|
||||
// 解析响应中的下级设备状态
|
||||
type DeviceStatusInfo struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceType string `json:"device_type"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type HeartbeatResponseData struct {
|
||||
Devices []DeviceStatusInfo `json:"devices"`
|
||||
}
|
||||
|
||||
var responseData HeartbeatResponseData
|
||||
if err := response.ParseData(&responseData); err != nil {
|
||||
hs.logger.Error(fmt.Sprintf("解析设备 %s 的心跳响应数据失败: %v", deviceID, err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新所有下级设备的状态
|
||||
for _, device := range responseData.Devices {
|
||||
// 根据设备状态确定Active值
|
||||
isActive := device.Status == "running" || device.Status == "online" || device.Status == "active"
|
||||
|
||||
tempStatusMap.SetStatus(device.DeviceID, &DeviceStatus{
|
||||
Active: isActive,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
// Package service 提供WebSocket服务功能
|
||||
// 实现中继设备和平台之间的双向通信
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebSocket消息类型常量
|
||||
const (
|
||||
// MessageTypeCommand 平台向设备发送的指令
|
||||
MessageTypeCommand = "command"
|
||||
|
||||
// MessageTypeResponse 设备向平台发送的响应
|
||||
MessageTypeResponse = "response"
|
||||
|
||||
// MessageTypeHeartbeat 心跳消息
|
||||
MessageTypeHeartbeat = "heartbeat"
|
||||
)
|
||||
|
||||
// WebSocketMessage WebSocket消息结构
|
||||
type WebSocketMessage struct {
|
||||
// Type 消息类型
|
||||
Type string `json:"type"`
|
||||
|
||||
// DeviceID 设备ID
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
|
||||
// Command 指令内容
|
||||
Command string `json:"command,omitempty"`
|
||||
|
||||
// Data 消息数据
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
|
||||
// Timestamp 时间戳
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// DeviceConnection 设备连接信息
|
||||
type DeviceConnection struct {
|
||||
// DeviceID 设备ID
|
||||
DeviceID string
|
||||
|
||||
// Connection WebSocket连接
|
||||
Connection *websocket.Conn
|
||||
|
||||
// LastHeartbeat 最后心跳时间
|
||||
LastHeartbeat time.Time
|
||||
|
||||
// ResponseChan 响应通道
|
||||
ResponseChan chan *WebSocketMessage
|
||||
}
|
||||
|
||||
// WebSocketService WebSocket服务
|
||||
type WebSocketService struct {
|
||||
// connections 设备连接映射
|
||||
connections map[string]*DeviceConnection
|
||||
|
||||
// mutex 互斥锁
|
||||
mutex sync.RWMutex
|
||||
|
||||
// logger 日志记录器
|
||||
logger *logs.Logger
|
||||
|
||||
// defaultTimeout 默认超时时间(秒)
|
||||
defaultTimeout int
|
||||
|
||||
// deviceRepo 设备仓库
|
||||
deviceRepo repository.DeviceRepo
|
||||
|
||||
// deviceStatusPool 设备状态池
|
||||
deviceStatusPool *DeviceStatusPool
|
||||
}
|
||||
|
||||
// SetDeviceStatusPool 设置设备状态池
|
||||
func (ws *WebSocketService) SetDeviceStatusPool(pool *DeviceStatusPool) {
|
||||
ws.deviceStatusPool = pool
|
||||
}
|
||||
|
||||
// NewWebSocketService 创建WebSocket服务实例
|
||||
func NewWebSocketService(deviceRepo repository.DeviceRepo) *WebSocketService {
|
||||
return &WebSocketService{
|
||||
connections: make(map[string]*DeviceConnection),
|
||||
logger: logs.NewLogger(),
|
||||
defaultTimeout: 5, // 默认5秒超时
|
||||
deviceRepo: deviceRepo,
|
||||
deviceStatusPool: NewDeviceStatusPool(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetDefaultTimeout 设置默认超时时间
|
||||
func (ws *WebSocketService) SetDefaultTimeout(timeout int) {
|
||||
ws.defaultTimeout = timeout
|
||||
}
|
||||
|
||||
// getDeviceDisplayName 获取设备显示名称
|
||||
func (ws *WebSocketService) getDeviceDisplayName(deviceID string) string {
|
||||
if ws.deviceRepo != nil {
|
||||
if device, err := ws.deviceRepo.FindByIDString(deviceID); err == nil && device != nil {
|
||||
return fmt.Sprintf("%s(id:%s)", device.Name, deviceID)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("未知设备(id:%s)", deviceID)
|
||||
}
|
||||
|
||||
// AddConnection 添加设备连接
|
||||
func (ws *WebSocketService) AddConnection(deviceID string, conn *websocket.Conn) {
|
||||
ws.mutex.Lock()
|
||||
defer ws.mutex.Unlock()
|
||||
|
||||
ws.connections[deviceID] = &DeviceConnection{
|
||||
DeviceID: deviceID,
|
||||
Connection: conn,
|
||||
LastHeartbeat: time.Now(),
|
||||
}
|
||||
|
||||
deviceName := ws.getDeviceDisplayName(deviceID)
|
||||
ws.logger.Info(fmt.Sprintf("设备 %s 已连接", deviceName))
|
||||
}
|
||||
|
||||
// RemoveConnection 移除设备连接
|
||||
func (ws *WebSocketService) RemoveConnection(deviceID string) {
|
||||
ws.mutex.Lock()
|
||||
defer ws.mutex.Unlock()
|
||||
|
||||
deviceName := ws.getDeviceDisplayName(deviceID)
|
||||
|
||||
delete(ws.connections, deviceID)
|
||||
|
||||
ws.logger.Info(fmt.Sprintf("设备 %s 已断开连接", deviceName))
|
||||
}
|
||||
|
||||
// SetResponseHandler 设置响应处理器
|
||||
func (ws *WebSocketService) SetResponseHandler(deviceID string, responseChan chan *WebSocketMessage) {
|
||||
ws.mutex.Lock()
|
||||
defer ws.mutex.Unlock()
|
||||
|
||||
if deviceConn, exists := ws.connections[deviceID]; exists {
|
||||
deviceConn.ResponseChan = responseChan
|
||||
}
|
||||
}
|
||||
|
||||
// SendCommand 向指定设备发送指令
|
||||
func (ws *WebSocketService) SendCommand(deviceID, command string, data interface{}) error {
|
||||
ws.mutex.RLock()
|
||||
deviceConn, exists := ws.connections[deviceID]
|
||||
ws.mutex.RUnlock()
|
||||
|
||||
deviceName := ws.getDeviceDisplayName(deviceID)
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("设备 %s 未连接", deviceName)
|
||||
}
|
||||
|
||||
// 构造消息
|
||||
msg := WebSocketMessage{
|
||||
Type: MessageTypeCommand,
|
||||
Command: command,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
if err := deviceConn.Connection.WriteJSON(msg); err != nil {
|
||||
return fmt.Errorf("向设备 %s 发送指令失败: %v", deviceName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommandResponse WebSocket命令响应结构体
|
||||
type CommandResponse struct {
|
||||
// DeviceID 设备ID
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
|
||||
// Command 命令名称
|
||||
Command string `json:"command,omitempty"`
|
||||
|
||||
// Data 响应数据
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
|
||||
// Status 响应状态
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
// Message 响应消息
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
// Timestamp 时间戳
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ParseData 将响应数据解析到目标结构体
|
||||
func (cr *CommandResponse) ParseData(target interface{}) error {
|
||||
dataBytes, err := json.Marshal(cr.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(dataBytes, target)
|
||||
}
|
||||
|
||||
// CommandResult WebSocket命令执行结果
|
||||
type CommandResult struct {
|
||||
// Response 响应消息
|
||||
Response *CommandResponse
|
||||
|
||||
// Error 错误信息
|
||||
Error error
|
||||
}
|
||||
|
||||
// SendCommandAndWait 发送指令并等待响应
|
||||
func (ws *WebSocketService) SendCommandAndWait(deviceID, command string, data interface{}, timeout int) (*CommandResponse, error) {
|
||||
deviceName := ws.getDeviceDisplayName(deviceID)
|
||||
|
||||
// 如果未指定超时时间,使用默认超时时间
|
||||
if timeout <= 0 {
|
||||
timeout = ws.defaultTimeout
|
||||
}
|
||||
|
||||
// 创建用于接收响应的通道
|
||||
responseChan := make(chan *WebSocketMessage, 1)
|
||||
ws.SetResponseHandler(deviceID, responseChan)
|
||||
|
||||
// 发送指令
|
||||
if err := ws.SendCommand(deviceID, command, data); err != nil {
|
||||
return nil, fmt.Errorf("发送指令失败: %v", err)
|
||||
}
|
||||
|
||||
// 等待设备响应,设置超时
|
||||
var response *WebSocketMessage
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case response = <-responseChan:
|
||||
// 成功接收到响应
|
||||
// 转换为CommandResponse结构体
|
||||
commandResponse := &CommandResponse{
|
||||
DeviceID: response.DeviceID,
|
||||
Command: response.Command,
|
||||
Data: response.Data,
|
||||
Timestamp: response.Timestamp,
|
||||
}
|
||||
|
||||
// 尝试提取状态和消息字段
|
||||
if responseData, ok := response.Data.(map[string]interface{}); ok {
|
||||
if status, exists := responseData["status"]; exists {
|
||||
if statusStr, ok := status.(string); ok {
|
||||
commandResponse.Status = statusStr
|
||||
}
|
||||
}
|
||||
|
||||
if message, exists := responseData["message"]; exists {
|
||||
if messageStr, ok := message.(string); ok {
|
||||
commandResponse.Message = messageStr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commandResponse, nil
|
||||
case <-ctx.Done():
|
||||
// 超时处理
|
||||
return nil, fmt.Errorf("等待设备 %s 响应超时", deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConnectedDevices 获取已连接的设备列表
|
||||
func (ws *WebSocketService) GetConnectedDevices() []string {
|
||||
ws.mutex.RLock()
|
||||
defer ws.mutex.RUnlock()
|
||||
|
||||
devices := make([]string, 0, len(ws.connections))
|
||||
for deviceID := range ws.connections {
|
||||
devices = append(devices, deviceID)
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// HandleMessage 处理来自设备的消息
|
||||
func (ws *WebSocketService) HandleMessage(deviceID string, message []byte) error {
|
||||
// 解析消息
|
||||
var msg WebSocketMessage
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
return fmt.Errorf("解析设备 %s 消息失败: %v", ws.getDeviceDisplayName(deviceID), err)
|
||||
}
|
||||
|
||||
// 更新心跳时间
|
||||
if msg.Type == MessageTypeHeartbeat {
|
||||
ws.mutex.Lock()
|
||||
if deviceConn, exists := ws.connections[deviceID]; exists {
|
||||
deviceConn.LastHeartbeat = time.Now()
|
||||
}
|
||||
ws.mutex.Unlock()
|
||||
}
|
||||
|
||||
// 处理响应消息
|
||||
if msg.Type == MessageTypeResponse {
|
||||
ws.mutex.RLock()
|
||||
if deviceConn, exists := ws.connections[deviceID]; exists && deviceConn.ResponseChan != nil {
|
||||
// 发送响应到通道
|
||||
select {
|
||||
case deviceConn.ResponseChan <- &msg:
|
||||
// 成功发送
|
||||
default:
|
||||
// 通道已满,丢弃消息
|
||||
ws.logger.Warn(fmt.Sprintf("设备 %s 的响应通道已满,丢弃响应消息", ws.getDeviceDisplayName(deviceID)))
|
||||
}
|
||||
}
|
||||
ws.mutex.RUnlock()
|
||||
}
|
||||
|
||||
// 记录消息日志
|
||||
ws.logger.Info(fmt.Sprintf("收到来自设备 %s 的消息: %v", ws.getDeviceDisplayName(deviceID), msg))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeviceConnection 获取设备连接信息
|
||||
func (ws *WebSocketService) GetDeviceConnection(deviceID string) (*DeviceConnection, bool) {
|
||||
ws.mutex.RLock()
|
||||
defer ws.mutex.RUnlock()
|
||||
|
||||
deviceConn, exists := ws.connections[deviceID]
|
||||
return deviceConn, exists
|
||||
}
|
||||
@@ -19,6 +19,10 @@ var migrateModels = []interface{}{
|
||||
&model.OperationHistory{},
|
||||
&model.Device{},
|
||||
&model.DeviceControl{},
|
||||
&model.FeedingPlan{},
|
||||
&model.FeedingPlanStep{},
|
||||
&model.FeedingExecution{},
|
||||
&model.FeedingExecutionStep{},
|
||||
}
|
||||
|
||||
// PostgresStorage 代表基于PostgreSQL的存储实现
|
||||
|
||||
@@ -34,6 +34,9 @@ type DeviceRepo interface {
|
||||
|
||||
// ListAll 获取所有设备列表
|
||||
ListAll() ([]model.Device, error)
|
||||
|
||||
// FindRelayDevices 获取所有中继设备
|
||||
FindRelayDevices() ([]*model.Device, error)
|
||||
}
|
||||
|
||||
// DeviceControlRepo 设备控制仓库接口
|
||||
@@ -135,6 +138,16 @@ func (r *deviceRepo) FindByType(deviceType model.DeviceType) ([]*model.Device, e
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// FindRelayDevices 获取所有中继设备
|
||||
func (r *deviceRepo) FindRelayDevices() ([]*model.Device, error) {
|
||||
var devices []*model.Device
|
||||
result := r.db.Where("type = ?", model.DeviceTypeRelay).Find(&devices)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// Update 更新设备信息
|
||||
func (r *deviceRepo) Update(device *model.Device) error {
|
||||
result := r.db.Save(device)
|
||||
|
||||
205
internal/storage/repository/feed.go
Normal file
205
internal/storage/repository/feed.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FeedPlanRepo 饲喂管理接口
|
||||
type FeedPlanRepo interface {
|
||||
|
||||
// ListAllPlanIntroduction 获取所有计划简介
|
||||
ListAllPlanIntroduction() ([]*model.FeedingPlan, error)
|
||||
|
||||
// FindFeedingPlanByID 根据ID获取计划详情
|
||||
FindFeedingPlanByID(id uint) (*model.FeedingPlan, error)
|
||||
|
||||
// CreateFeedingPlan 创建饲料计划
|
||||
CreateFeedingPlan(feedingPlan *model.FeedingPlan) error
|
||||
|
||||
// DeleteFeedingPlan 删除饲料计划及其所有子计划和步骤
|
||||
DeleteFeedingPlan(id uint) error
|
||||
|
||||
// UpdateFeedingPlan 更新饲料计划,采用先删除再重新创建的方式
|
||||
UpdateFeedingPlan(feedingPlan *model.FeedingPlan) error
|
||||
}
|
||||
|
||||
type feedPlanRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFeedPlanRepo(db *gorm.DB) FeedPlanRepo {
|
||||
return &feedPlanRepo{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ListAllPlanIntroduction 获取所有计划简介
|
||||
func (f *feedPlanRepo) ListAllPlanIntroduction() ([]*model.FeedingPlan, error) {
|
||||
var plans []*model.FeedingPlan
|
||||
err := f.db.Model(&model.FeedingPlan{}).
|
||||
Select("id, name, description, type, enabled, schedule_cron").
|
||||
Find(&plans).Error
|
||||
return plans, err
|
||||
}
|
||||
|
||||
// FindFeedingPlanByID 根据ID获取计划详情
|
||||
func (f *feedPlanRepo) FindFeedingPlanByID(feedingPlanID uint) (*model.FeedingPlan, error) {
|
||||
var plan model.FeedingPlan
|
||||
err := f.db.Where("id = ?", feedingPlanID).
|
||||
Preload("Steps").
|
||||
Preload("SubPlans").
|
||||
First(&plan).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// CreateFeedingPlan 创建饲料计划,包括步骤和子计划
|
||||
func (f *feedPlanRepo) CreateFeedingPlan(feedingPlan *model.FeedingPlan) error {
|
||||
// 清空所有ID,确保创建新记录
|
||||
f.clearAllIDs(feedingPlan)
|
||||
|
||||
return f.db.Transaction(func(tx *gorm.DB) error {
|
||||
return f.createFeedingPlanWithTx(tx, feedingPlan)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateFeedingPlan 更新饲料计划,采用先删除再重新创建的方式
|
||||
func (f *feedPlanRepo) UpdateFeedingPlan(feedingPlan *model.FeedingPlan) error {
|
||||
// 检查计划是否存在
|
||||
_, err := f.FindFeedingPlanByID(feedingPlan.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 先删除原有的计划
|
||||
if err := f.deleteFeedingPlanWithTx(tx, feedingPlan.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 清空所有ID,包括子计划和步骤的ID
|
||||
f.clearAllIDs(feedingPlan)
|
||||
|
||||
// 再重新创建更新后的计划
|
||||
if err := f.createFeedingPlanWithTx(tx, feedingPlan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteFeedingPlan 删除饲料计划及其所有子计划和步骤
|
||||
func (f *feedPlanRepo) DeleteFeedingPlan(id uint) error {
|
||||
return f.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 递归删除计划及其所有子计划
|
||||
if err := f.deleteFeedingPlanWithTx(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// deleteFeedingPlanWithTx 在事务中递归删除饲料计划
|
||||
func (f *feedPlanRepo) deleteFeedingPlanWithTx(tx *gorm.DB, id uint) error {
|
||||
// 先查找计划及其子计划
|
||||
var plan model.FeedingPlan
|
||||
if err := tx.Where("id = ?", id).Preload("SubPlans").First(&plan).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 递归删除所有子计划
|
||||
for _, subPlan := range plan.SubPlans {
|
||||
if err := f.deleteFeedingPlanWithTx(tx, subPlan.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 删除该计划的所有步骤
|
||||
if err := tx.Where("plan_id = ?", id).Delete(&model.FeedingPlanStep{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除计划本身
|
||||
if err := tx.Delete(&model.FeedingPlan{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createFeedingPlanWithTx 在事务中递归创建饲料计划
|
||||
func (f *feedPlanRepo) createFeedingPlanWithTx(tx *gorm.DB, feedingPlan *model.FeedingPlan) error {
|
||||
// 先创建计划主体
|
||||
if err := tx.Create(feedingPlan).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理步骤 - 先按现有顺序排序,再重新分配从0开始的连续编号
|
||||
sort.Slice(feedingPlan.Steps, func(i, j int) bool {
|
||||
return feedingPlan.Steps[i].StepOrder < feedingPlan.Steps[j].StepOrder
|
||||
})
|
||||
|
||||
// 重新填充步骤编号
|
||||
for i := range feedingPlan.Steps {
|
||||
feedingPlan.Steps[i].StepOrder = i
|
||||
feedingPlan.Steps[i].PlanID = feedingPlan.ID
|
||||
}
|
||||
|
||||
// 如果有步骤,批量创建步骤
|
||||
if len(feedingPlan.Steps) > 0 {
|
||||
if err := tx.Create(&feedingPlan.Steps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 处理子计划 - 重新填充子计划编号和父ID
|
||||
sort.Slice(feedingPlan.SubPlans, func(i, j int) bool {
|
||||
// 如果OrderInParent为nil,放在最后
|
||||
if feedingPlan.SubPlans[i].OrderInParent == nil {
|
||||
return false
|
||||
}
|
||||
if feedingPlan.SubPlans[j].OrderInParent == nil {
|
||||
return true
|
||||
}
|
||||
return *feedingPlan.SubPlans[i].OrderInParent < *feedingPlan.SubPlans[j].OrderInParent
|
||||
})
|
||||
|
||||
// 重新填充子计划编号和父ID
|
||||
for i := range feedingPlan.SubPlans {
|
||||
order := i
|
||||
feedingPlan.SubPlans[i].OrderInParent = &order
|
||||
feedingPlan.SubPlans[i].ParentID = &feedingPlan.ID
|
||||
|
||||
// 递归创建子计划
|
||||
if err := f.createFeedingPlanWithTx(tx, &feedingPlan.SubPlans[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearAllIDs 清空计划及其子计划和步骤的所有ID
|
||||
func (f *feedPlanRepo) clearAllIDs(plan *model.FeedingPlan) {
|
||||
// 清空计划ID
|
||||
plan.ID = 0
|
||||
|
||||
// 清空所有步骤的ID和关联的计划ID
|
||||
for i := range plan.Steps {
|
||||
plan.Steps[i].ID = 0
|
||||
plan.Steps[i].PlanID = 0
|
||||
}
|
||||
|
||||
// 清空所有子计划的ID和关联的父计划ID,并递归清空子计划的ID
|
||||
for i := range plan.SubPlans {
|
||||
plan.SubPlans[i].ID = 0
|
||||
plan.SubPlans[i].ParentID = nil
|
||||
f.clearAllIDs(&plan.SubPlans[i])
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@ type Task interface {
|
||||
|
||||
// GetPriority 获取任务优先级
|
||||
GetPriority() int
|
||||
|
||||
// Done 返回一个channel,当任务执行完毕时该channel会被关闭
|
||||
Done() <-chan struct{}
|
||||
|
||||
// IsDone 检查任务是否已完成
|
||||
IsDone() bool
|
||||
}
|
||||
|
||||
// taskItem 任务队列中的元素
|
||||
|
||||
164
internal/tests/mocks/mock_repository.go
Normal file
164
internal/tests/mocks/mock_repository.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package mocks
|
||||
|
||||
// Package mocks 模拟测试包
|
||||
|
||||
import (
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/model"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockDeviceRepo 模拟设备仓库,实现DeviceRepo接口
|
||||
type MockDeviceRepo struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Create 模拟创建设备方法
|
||||
func (m *MockDeviceRepo) Create(device *model.Device) error {
|
||||
args := m.Called(device)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// FindByID 模拟根据ID查找设备方法
|
||||
func (m *MockDeviceRepo) FindByID(id uint) (*model.Device, error) {
|
||||
args := m.Called(id)
|
||||
// 返回第一个参数作为设备,第二个参数作为错误
|
||||
device, ok := args.Get(0).(*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return device, args.Error(1)
|
||||
}
|
||||
|
||||
// FindByIDString 模拟根据ID字符串查找设备方法
|
||||
func (m *MockDeviceRepo) FindByIDString(id string) (*model.Device, error) {
|
||||
args := m.Called(id)
|
||||
// 返回第一个参数作为设备,第二个参数作为错误
|
||||
device, ok := args.Get(0).(*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return device, args.Error(1)
|
||||
}
|
||||
|
||||
// FindByParentID 模拟根据上级设备ID查找设备方法
|
||||
func (m *MockDeviceRepo) FindByParentID(parentID uint) ([]*model.Device, error) {
|
||||
args := m.Called(parentID)
|
||||
// 返回第一个参数作为设备列表,第二个参数作为错误
|
||||
devices, ok := args.Get(0).([]*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return devices, args.Error(1)
|
||||
}
|
||||
|
||||
// FindByType 模拟根据设备类型查找设备方法
|
||||
func (m *MockDeviceRepo) FindByType(deviceType model.DeviceType) ([]*model.Device, error) {
|
||||
args := m.Called(deviceType)
|
||||
// 返回第一个参数作为设备列表,第二个参数作为错误
|
||||
devices, ok := args.Get(0).([]*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return devices, args.Error(1)
|
||||
}
|
||||
|
||||
// Update 模拟更新设备信息方法
|
||||
func (m *MockDeviceRepo) Update(device *model.Device) error {
|
||||
args := m.Called(device)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Delete 模拟删除设备方法
|
||||
func (m *MockDeviceRepo) Delete(id uint) error {
|
||||
args := m.Called(id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// ListAll 模拟获取所有设备列表方法
|
||||
func (m *MockDeviceRepo) ListAll() ([]model.Device, error) {
|
||||
args := m.Called()
|
||||
// 返回第一个参数作为设备列表,第二个参数作为错误
|
||||
devices, ok := args.Get(0).([]model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return devices, args.Error(1)
|
||||
}
|
||||
|
||||
// FindRelayDevices 模拟获取所有中继设备方法
|
||||
func (m *MockDeviceRepo) FindRelayDevices() ([]*model.Device, error) {
|
||||
args := m.Called()
|
||||
// 返回第一个参数作为设备列表,第二个参数作为错误
|
||||
devices, ok := args.Get(0).([]*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return devices, args.Error(1)
|
||||
}
|
||||
|
||||
// FindByDeviceID 模拟根据设备ID查找设备方法(额外方法)
|
||||
func (m *MockDeviceRepo) FindByDeviceID(deviceID string) (*model.Device, error) {
|
||||
args := m.Called(deviceID)
|
||||
// 返回第一个参数作为设备,第二个参数作为错误
|
||||
device, ok := args.Get(0).(*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return device, args.Error(1)
|
||||
}
|
||||
|
||||
// FindControllers 模拟查找控制器方法(额外方法)
|
||||
func (m *MockDeviceRepo) FindControllers() ([]*model.Device, error) {
|
||||
args := m.Called()
|
||||
// 返回第一个参数作为设备列表,第二个参数作为错误
|
||||
devices, ok := args.Get(0).([]*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return devices, args.Error(1)
|
||||
}
|
||||
|
||||
// FindRelays 模拟查找中继设备方法(额外方法)
|
||||
func (m *MockDeviceRepo) FindRelays() ([]*model.Device, error) {
|
||||
args := m.Called()
|
||||
// 返回第一个参数作为设备列表,第二个参数作为错误
|
||||
devices, ok := args.Get(0).([]*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return devices, args.Error(1)
|
||||
}
|
||||
|
||||
// FindDevicesByType 模拟根据类型查找设备方法(额外方法)
|
||||
func (m *MockDeviceRepo) FindDevicesByType(deviceType string) ([]*model.Device, error) {
|
||||
args := m.Called(deviceType)
|
||||
// 返回第一个参数作为设备列表,第二个参数作为错误
|
||||
devices, ok := args.Get(0).([]*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return devices, args.Error(1)
|
||||
}
|
||||
|
||||
// FindRelayDevices 模拟根据中继ID查找设备方法(额外方法)
|
||||
func (m *MockDeviceRepo) FindRelayDevicesByID(relayID uint) ([]*model.Device, error) {
|
||||
args := m.Called(relayID)
|
||||
// 返回第一个参数作为设备列表,第二个参数作为错误
|
||||
devices, ok := args.Get(0).([]*model.Device)
|
||||
if !ok {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return devices, args.Error(1)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus 模拟更新设备状态方法(额外方法)
|
||||
func (m *MockDeviceRepo) UpdateDeviceStatus(id uint, active bool) error {
|
||||
args := m.Called(id, active)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// GetDeviceStatus 模拟获取设备状态方法(额外方法)
|
||||
func (m *MockDeviceRepo) GetDeviceStatus(id uint) (bool, error) {
|
||||
args := m.Called(id)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
@@ -46,6 +46,9 @@ type Hub struct {
|
||||
|
||||
// deviceRepo 设备仓库
|
||||
deviceRepo repository.DeviceRepo
|
||||
|
||||
// 关闭消息
|
||||
close chan struct{}
|
||||
}
|
||||
|
||||
// Client WebSocket客户端结构
|
||||
@@ -78,6 +81,7 @@ func NewHub(deviceRepo repository.DeviceRepo) *Hub {
|
||||
deviceClients: make(map[string]*Client),
|
||||
logger: logs.NewLogger(),
|
||||
deviceRepo: deviceRepo,
|
||||
close: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +105,20 @@ func (h *Hub) Run() {
|
||||
h.unregisterClient(client)
|
||||
case message := <-h.broadcast:
|
||||
h.broadcastMessage(message)
|
||||
case <-h.close:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Close() {
|
||||
// 关闭时清理所有资源
|
||||
for client := range h.clients {
|
||||
h.unregisterClient(client)
|
||||
}
|
||||
close(h.close)
|
||||
}
|
||||
|
||||
// registerClient 注册客户端
|
||||
func (h *Hub) registerClient(client *Client) {
|
||||
h.mutex.Lock()
|
||||
|
||||
@@ -3,22 +3,110 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/service"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebSocket消息类型常量
|
||||
const (
|
||||
// MessageTypeCommand 平台向设备发送的指令
|
||||
MessageTypeCommand = "command"
|
||||
|
||||
// MessageTypeResponse 设备向平台发送的响应
|
||||
MessageTypeResponse = "response"
|
||||
|
||||
// MessageTypeHeartbeat 心跳消息
|
||||
MessageTypeHeartbeat = "heartbeat"
|
||||
)
|
||||
|
||||
// WebSocketMessage WebSocket消息结构
|
||||
type WebSocketMessage struct {
|
||||
// Type 消息类型
|
||||
Type string `json:"type"`
|
||||
|
||||
// DeviceID 设备ID
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
|
||||
// Command 指令内容
|
||||
Command string `json:"command,omitempty"`
|
||||
|
||||
// Data 消息数据
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
|
||||
// Timestamp 时间戳
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// DeviceConnection 设备连接信息
|
||||
type DeviceConnection struct {
|
||||
// DeviceID 设备ID
|
||||
DeviceID string
|
||||
|
||||
// Connection WebSocket连接
|
||||
Connection *websocket.Conn
|
||||
|
||||
// LastHeartbeat 最后心跳时间
|
||||
LastHeartbeat time.Time
|
||||
|
||||
// ResponseChan 响应通道
|
||||
ResponseChan chan *WebSocketMessage
|
||||
}
|
||||
|
||||
// CommandResponse WebSocket命令响应结构体
|
||||
type CommandResponse struct {
|
||||
// DeviceID 设备ID
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
|
||||
// Command 命令名称
|
||||
Command string `json:"command,omitempty"`
|
||||
|
||||
// Data 响应数据
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
|
||||
// Status 响应状态
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
// Message 响应消息
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
// Timestamp 时间戳
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ParseData 将响应数据解析到目标结构体
|
||||
func (cr *CommandResponse) ParseData(target interface{}) error {
|
||||
dataBytes, err := json.Marshal(cr.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(dataBytes, target)
|
||||
}
|
||||
|
||||
// CommandResult WebSocket命令执行结果
|
||||
type CommandResult struct {
|
||||
// Response 响应消息
|
||||
Response *CommandResponse
|
||||
|
||||
// Error 错误信息
|
||||
Error error
|
||||
}
|
||||
|
||||
// Manager WebSocket管理器
|
||||
type Manager struct {
|
||||
// websocketService WebSocket服务
|
||||
websocketService *service.WebSocketService
|
||||
// connections 设备连接映射
|
||||
connections map[string]*DeviceConnection
|
||||
|
||||
// mutex 互斥锁
|
||||
mutex sync.RWMutex
|
||||
|
||||
// logger 日志记录器
|
||||
logger *logs.Logger
|
||||
@@ -26,32 +114,34 @@ type Manager struct {
|
||||
// upgrader WebSocket升级器
|
||||
upgrader websocket.Upgrader
|
||||
|
||||
// mutex 互斥锁
|
||||
mutex sync.RWMutex
|
||||
|
||||
// connections 设备连接映射
|
||||
connections map[string]*websocket.Conn
|
||||
// defaultTimeout 默认超时时间(秒)
|
||||
defaultTimeout int
|
||||
|
||||
// deviceRepo 设备仓库
|
||||
deviceRepo repository.DeviceRepo
|
||||
}
|
||||
|
||||
// NewManager 创建WebSocket管理器实例
|
||||
func NewManager(websocketService *service.WebSocketService, deviceRepo repository.DeviceRepo) *Manager {
|
||||
func NewManager(deviceRepo repository.DeviceRepo) *Manager {
|
||||
return &Manager{
|
||||
websocketService: websocketService,
|
||||
connections: make(map[string]*DeviceConnection),
|
||||
logger: logs.NewLogger(),
|
||||
defaultTimeout: 5, // 默认5秒超时
|
||||
deviceRepo: deviceRepo,
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// 允许所有跨域请求
|
||||
return true
|
||||
},
|
||||
},
|
||||
connections: make(map[string]*websocket.Conn),
|
||||
deviceRepo: deviceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDefaultTimeout 设置默认超时时间
|
||||
func (wm *Manager) SetDefaultTimeout(timeout int) {
|
||||
wm.defaultTimeout = timeout
|
||||
}
|
||||
|
||||
// getDeviceDisplayName 获取设备显示名称
|
||||
func (wm *Manager) getDeviceDisplayName(deviceID string) string {
|
||||
if wm.deviceRepo != nil {
|
||||
@@ -62,97 +152,127 @@ func (wm *Manager) getDeviceDisplayName(deviceID string) string {
|
||||
return fmt.Sprintf("未知设备(id:%s)", deviceID)
|
||||
}
|
||||
|
||||
// HandleConnection 处理WebSocket连接
|
||||
func (wm *Manager) HandleConnection(c *gin.Context) {
|
||||
// 升级HTTP连接到WebSocket
|
||||
conn, err := wm.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
wm.logger.Error("WebSocket连接升级失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取设备ID
|
||||
deviceID := c.Query("device_id")
|
||||
if deviceID == "" {
|
||||
wm.logger.Error("缺少设备ID参数")
|
||||
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "缺少设备ID参数"))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// 添加连接到映射
|
||||
// AddConnection 添加设备连接
|
||||
func (wm *Manager) AddConnection(deviceID string, conn *websocket.Conn) {
|
||||
wm.mutex.Lock()
|
||||
wm.connections[deviceID] = conn
|
||||
wm.mutex.Unlock()
|
||||
defer wm.mutex.Unlock()
|
||||
|
||||
wm.connections[deviceID] = &DeviceConnection{
|
||||
DeviceID: deviceID,
|
||||
Connection: conn,
|
||||
LastHeartbeat: time.Now(),
|
||||
}
|
||||
|
||||
deviceName := wm.getDeviceDisplayName(deviceID)
|
||||
wm.logger.Info("设备 " + deviceName + " 已连接")
|
||||
wm.logger.Info(fmt.Sprintf("设备 %s 已连接", deviceName))
|
||||
}
|
||||
|
||||
// 发送连接成功消息
|
||||
successMsg := service.WebSocketMessage{
|
||||
Type: "system",
|
||||
Command: "connected",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
conn.WriteJSON(successMsg)
|
||||
|
||||
// 处理消息循环
|
||||
for {
|
||||
// 读取消息
|
||||
messageType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
wm.logger.Error("读取设备 " + deviceName + " 消息失败: " + err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
// 只处理文本消息
|
||||
if messageType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理设备消息
|
||||
if err := wm.websocketService.HandleMessage(deviceID, message); err != nil {
|
||||
wm.logger.Error("处理设备 " + deviceName + " 消息失败: " + err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 连接断开时清理
|
||||
// RemoveConnection 移除设备连接
|
||||
func (wm *Manager) RemoveConnection(deviceID string) {
|
||||
wm.mutex.Lock()
|
||||
delete(wm.connections, deviceID)
|
||||
wm.mutex.Unlock()
|
||||
defer wm.mutex.Unlock()
|
||||
|
||||
conn.Close()
|
||||
wm.logger.Info("设备 " + deviceName + " 已断开连接")
|
||||
deviceName := wm.getDeviceDisplayName(deviceID)
|
||||
|
||||
delete(wm.connections, deviceID)
|
||||
|
||||
wm.logger.Info(fmt.Sprintf("设备 %s 已断开连接", deviceName))
|
||||
}
|
||||
|
||||
// SetResponseHandler 设置响应处理器
|
||||
func (wm *Manager) SetResponseHandler(deviceID string, responseChan chan *WebSocketMessage) {
|
||||
wm.mutex.Lock()
|
||||
defer wm.mutex.Unlock()
|
||||
|
||||
if deviceConn, exists := wm.connections[deviceID]; exists {
|
||||
deviceConn.ResponseChan = responseChan
|
||||
}
|
||||
}
|
||||
|
||||
// SendCommand 向指定设备发送指令
|
||||
func (wm *Manager) SendCommand(deviceID, command string, data interface{}) error {
|
||||
wm.mutex.RLock()
|
||||
conn, exists := wm.connections[deviceID]
|
||||
deviceConn, exists := wm.connections[deviceID]
|
||||
wm.mutex.RUnlock()
|
||||
|
||||
deviceName := wm.getDeviceDisplayName(deviceID)
|
||||
|
||||
if !exists {
|
||||
return wm.websocketService.SendCommand(deviceID, command, data)
|
||||
return fmt.Errorf("设备 %s 未连接", deviceName)
|
||||
}
|
||||
|
||||
// 构造消息
|
||||
msg := service.WebSocketMessage{
|
||||
Type: service.MessageTypeCommand,
|
||||
msg := WebSocketMessage{
|
||||
Type: MessageTypeCommand,
|
||||
Command: command,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
deviceName := wm.getDeviceDisplayName(deviceID)
|
||||
if err := deviceConn.Connection.WriteJSON(msg); err != nil {
|
||||
return fmt.Errorf("向设备 %s 发送指令失败: %v", deviceName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCommandAndWait 发送指令并等待响应
|
||||
func (wm *Manager) SendCommandAndWait(deviceID, command string, data interface{}, timeout int) (*CommandResponse, error) {
|
||||
deviceName := wm.getDeviceDisplayName(deviceID)
|
||||
|
||||
// 如果未指定超时时间,使用默认超时时间
|
||||
if timeout <= 0 {
|
||||
timeout = wm.defaultTimeout
|
||||
}
|
||||
|
||||
// 创建用于接收响应的通道
|
||||
responseChan := make(chan *WebSocketMessage, 1)
|
||||
wm.SetResponseHandler(deviceID, responseChan)
|
||||
|
||||
// 发送指令
|
||||
if err := wm.SendCommand(deviceID, command, data); err != nil {
|
||||
return nil, fmt.Errorf("发送指令失败: %v", err)
|
||||
}
|
||||
|
||||
// 等待设备响应,设置超时
|
||||
var response *WebSocketMessage
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case response = <-responseChan:
|
||||
// 成功接收到响应
|
||||
// 转换为CommandResponse结构体
|
||||
commandResponse := &CommandResponse{
|
||||
DeviceID: response.DeviceID,
|
||||
Command: response.Command,
|
||||
Data: response.Data,
|
||||
Timestamp: response.Timestamp,
|
||||
}
|
||||
|
||||
// 尝试提取状态和消息字段
|
||||
if responseData, ok := response.Data.(map[string]interface{}); ok {
|
||||
if status, exists := responseData["status"]; exists {
|
||||
if statusStr, ok := status.(string); ok {
|
||||
commandResponse.Status = statusStr
|
||||
}
|
||||
}
|
||||
|
||||
if message, exists := responseData["message"]; exists {
|
||||
if messageStr, ok := message.(string); ok {
|
||||
commandResponse.Message = messageStr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commandResponse, nil
|
||||
case <-ctx.Done():
|
||||
// 超时处理
|
||||
return nil, fmt.Errorf("等待设备 %s 响应超时", deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConnectedDevices 获取已连接的设备列表
|
||||
func (wm *Manager) GetConnectedDevices() []string {
|
||||
wm.mutex.RLock()
|
||||
@@ -165,3 +285,110 @@ func (wm *Manager) GetConnectedDevices() []string {
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// HandleMessage 处理来自设备的消息
|
||||
func (wm *Manager) HandleMessage(deviceID string, message []byte) error {
|
||||
// 解析消息
|
||||
var msg WebSocketMessage
|
||||
if err := json.Unmarshal(message, &msg); err != nil {
|
||||
return fmt.Errorf("解析设备 %s 消息失败: %v", wm.getDeviceDisplayName(deviceID), err)
|
||||
}
|
||||
|
||||
// 更新心跳时间
|
||||
if msg.Type == MessageTypeHeartbeat {
|
||||
wm.mutex.Lock()
|
||||
if deviceConn, exists := wm.connections[deviceID]; exists {
|
||||
deviceConn.LastHeartbeat = time.Now()
|
||||
}
|
||||
wm.mutex.Unlock()
|
||||
}
|
||||
|
||||
// 处理响应消息
|
||||
if msg.Type == MessageTypeResponse {
|
||||
wm.mutex.RLock()
|
||||
if deviceConn, exists := wm.connections[deviceID]; exists && deviceConn.ResponseChan != nil {
|
||||
// 发送响应到通道
|
||||
select {
|
||||
case deviceConn.ResponseChan <- &msg:
|
||||
// 成功发送
|
||||
default:
|
||||
// 通道已满,丢弃消息
|
||||
wm.logger.Warn(fmt.Sprintf("设备 %s 的响应通道已满,丢弃响应消息", wm.getDeviceDisplayName(deviceID)))
|
||||
}
|
||||
}
|
||||
wm.mutex.RUnlock()
|
||||
}
|
||||
|
||||
// 记录消息日志
|
||||
wm.logger.Info(fmt.Sprintf("收到来自设备 %s 的消息: %v", wm.getDeviceDisplayName(deviceID), msg))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeviceConnection 获取设备连接信息
|
||||
func (wm *Manager) GetDeviceConnection(deviceID string) (*DeviceConnection, bool) {
|
||||
wm.mutex.RLock()
|
||||
defer wm.mutex.RUnlock()
|
||||
|
||||
deviceConn, exists := wm.connections[deviceID]
|
||||
return deviceConn, exists
|
||||
}
|
||||
|
||||
// HandleConnection 处理WebSocket连接
|
||||
func (wm *Manager) HandleConnection(c *gin.Context) {
|
||||
// 升级HTTP连接到WebSocket
|
||||
conn, err := wm.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
wm.logger.Error(fmt.Sprintf("WebSocket连接升级失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取设备ID
|
||||
deviceID := c.Query("device_id")
|
||||
if deviceID == "" {
|
||||
wm.logger.Error("缺少设备ID参数")
|
||||
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "缺少设备ID参数"))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// 添加连接
|
||||
wm.AddConnection(deviceID, conn)
|
||||
|
||||
deviceName := wm.getDeviceDisplayName(deviceID)
|
||||
wm.logger.Info("设备 " + deviceName + " 已连接")
|
||||
|
||||
// 发送连接成功消息
|
||||
successMsg := WebSocketMessage{
|
||||
Type: "system",
|
||||
Command: "connected",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
conn.WriteJSON(successMsg)
|
||||
|
||||
// 处理消息循环
|
||||
for {
|
||||
// 读取消息
|
||||
messageType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
wm.logger.Error(fmt.Sprintf("读取设备 %s 消息失败: %v", deviceName, err))
|
||||
break
|
||||
}
|
||||
|
||||
// 只处理文本消息
|
||||
if messageType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理设备消息
|
||||
if err := wm.HandleMessage(deviceID, message); err != nil {
|
||||
wm.logger.Error(fmt.Sprintf("处理设备 %s 消息失败: %v", deviceName, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 连接断开时清理
|
||||
wm.RemoveConnection(deviceID)
|
||||
conn.Close()
|
||||
wm.logger.Info("设备 " + deviceName + " 已断开连接")
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ func (s *Server) Start() {
|
||||
go s.hub.Run()
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
s.hub.Close()
|
||||
}
|
||||
|
||||
// readPump 从WebSocket连接读取消息
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
|
||||
23
vendor/modules.txt
vendored
23
vendor/modules.txt
vendored
@@ -24,6 +24,9 @@ github.com/bytedance/sonic/utf8
|
||||
# github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311
|
||||
## explicit; go 1.15
|
||||
github.com/chenzhuoyu/base64x
|
||||
# github.com/davecgh/go-spew v1.1.1
|
||||
## explicit
|
||||
github.com/davecgh/go-spew/spew
|
||||
# github.com/gabriel-vasile/mimetype v1.4.2
|
||||
## explicit; go 1.20
|
||||
github.com/gabriel-vasile/mimetype
|
||||
@@ -118,6 +121,10 @@ github.com/modern-go/concurrent
|
||||
# github.com/modern-go/reflect2 v1.0.2
|
||||
## explicit; go 1.12
|
||||
github.com/modern-go/reflect2
|
||||
# github.com/panjf2000/ants/v2 v2.11.3
|
||||
## explicit; go 1.18
|
||||
github.com/panjf2000/ants/v2
|
||||
github.com/panjf2000/ants/v2/pkg/sync
|
||||
# github.com/pelletier/go-toml/v2 v2.0.8
|
||||
## explicit; go 1.16
|
||||
github.com/pelletier/go-toml/v2
|
||||
@@ -125,8 +132,19 @@ github.com/pelletier/go-toml/v2/internal/characters
|
||||
github.com/pelletier/go-toml/v2/internal/danger
|
||||
github.com/pelletier/go-toml/v2/internal/tracker
|
||||
github.com/pelletier/go-toml/v2/unstable
|
||||
# github.com/pmezard/go-difflib v1.0.0
|
||||
## explicit
|
||||
github.com/pmezard/go-difflib/difflib
|
||||
# github.com/rogpeppe/go-internal v1.14.1
|
||||
## explicit; go 1.23
|
||||
# github.com/stretchr/objx v0.5.2
|
||||
## explicit; go 1.20
|
||||
github.com/stretchr/objx
|
||||
# github.com/stretchr/testify v1.10.0
|
||||
## explicit; go 1.17
|
||||
github.com/stretchr/testify/assert
|
||||
github.com/stretchr/testify/assert/yaml
|
||||
github.com/stretchr/testify/mock
|
||||
# github.com/twitchyliquid64/golang-asm v0.15.1
|
||||
## explicit; go 1.13
|
||||
github.com/twitchyliquid64/golang-asm/asm/arch
|
||||
@@ -167,8 +185,9 @@ golang.org/x/net/http2
|
||||
golang.org/x/net/http2/h2c
|
||||
golang.org/x/net/http2/hpack
|
||||
golang.org/x/net/idna
|
||||
# golang.org/x/sync v0.1.0
|
||||
## explicit
|
||||
# golang.org/x/sync v0.11.0
|
||||
## explicit; go 1.18
|
||||
golang.org/x/sync/errgroup
|
||||
golang.org/x/sync/semaphore
|
||||
# golang.org/x/sys v0.26.0
|
||||
## explicit; go 1.18
|
||||
|
||||
Reference in New Issue
Block a user