Compare commits

...

24 Commits

Author SHA1 Message Date
3743b5ddcd 增加创建和更新计划基本信息的界面 2025-09-10 20:04:38 +08:00
6fe73d8ffe 限制用户不能创建一个既有子计划又有步骤的计划 2025-09-10 19:36:34 +08:00
91f160b07e 优化计划展示界面 2025-09-10 19:27:42 +08:00
a1950872fc 1.修复参数解析bug
2. 增加查看计划详情的界面
2025-09-10 19:03:41 +08:00
c499571c11 修复参数解析bug 2025-09-10 18:16:07 +08:00
cc7ea94e41 1. 实现前端删除饲喂计划
2. 修复后端delete接口bug
2025-09-10 16:25:52 +08:00
40a19b831a 增加饲喂计划列表展示界面 2025-09-10 16:14:05 +08:00
9944340d17 1.注册饲喂计划相关路由
2. 实现create接口
2025-09-10 15:13:31 +08:00
4a70c1e839 修复更新计划时会用原计划ID创建新计划的问题 2025-09-10 15:00:29 +08:00
e75b3ee148 实现Delete接口 2025-09-10 14:46:47 +08:00
cbcba09d40 实现Update接口 2025-09-10 14:42:54 +08:00
4805e422f7 实现Detail接口 2025-09-10 14:35:54 +08:00
64c86de71e 实现UpdateFeedingPlan 2025-09-10 14:32:56 +08:00
3ecbf3b5af 实现DeleteFeedingPlan 2025-09-10 14:29:44 +08:00
1a14aec19b 1. 调整之前的Feed数据库查询方法
2. 实现CreateFeedingPlan
2025-09-10 14:15:03 +08:00
008677467b 1. 定义Detail接口
2. 实现ListPlans接口
2025-09-10 13:41:24 +08:00
2b4dd3e74d task增加任务完成后通知 2025-09-10 13:04:25 +08:00
8468a96398 model修改:
1. 增加子计划支持
2. 增加步骤和计划执行完后等待一段时间再执行下一个, 增加延迟执行和多次执行
2025-09-09 20:52:38 +08:00
e27aec0ca2 定义喂料计划model 2025-09-09 20:42:19 +08:00
52cf8c58ed 前端默认展开设备列表 2025-09-09 19:16:13 +08:00
d7c2ffb108 Merge pull request '单测' (#2) from 单测 into main
Reviewed-on: #2
2025-09-09 19:11:14 +08:00
43befdb71c 增加关闭hub 2025-09-09 19:11:08 +08:00
8d639d3b09 MockDeviceRepo 2025-09-09 18:45:53 +08:00
4f928dff9f MockDeviceRepo 2025-09-09 18:45:44 +08:00
26 changed files with 2691 additions and 32 deletions

View File

@@ -32,6 +32,6 @@ websocket:
# 心跳配置 # 心跳配置
heartbeat: heartbeat:
# 心跳间隔(秒) # 心跳间隔(秒)
interval: 5 interval: 30
# 请求并发数 # 请求并发数
concurrency: 5 concurrency: 5

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

21
frontend/dist/assets/index.cb9d3828.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>猪场管理系统</title> <title>猪场管理系统</title>
<script type="module" crossorigin src="/assets/index.2015effd.js"></script> <script type="module" crossorigin src="/assets/index.cb9d3828.js"></script>
<link rel="stylesheet" href="/assets/index.7f062720.css"> <link rel="stylesheet" href="/assets/index.bcc76856.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View 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>

View File

@@ -12,6 +12,7 @@
<ul> <ul>
<li><router-link to="/dashboard" class="active">控制台</router-link></li> <li><router-link to="/dashboard" class="active">控制台</router-link></li>
<li><router-link to="/device">设备管理</router-link></li> <li><router-link to="/device">设备管理</router-link></li>
<li><router-link to="/feed/plan">饲喂计划</router-link></li>
</ul> </ul>
</div> </div>

View File

@@ -8,6 +8,14 @@
</div> </div>
</header> </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"> <main class="main-content">
<div class="toolbar"> <div class="toolbar">
<button class="btn btn-primary" @click="openAddDeviceModal">添加设备</button> <button class="btn btn-primary" @click="openAddDeviceModal">添加设备</button>
@@ -289,6 +297,8 @@ export default {
if (response.ok && data.code === 0) { if (response.ok && data.code === 0) {
this.devices = data.data.devices this.devices = data.data.devices
// 默认展开所有节点
this.expandAllNodes()
} else { } else {
console.error('获取设备列表失败:', data.message) 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() { openAddDeviceModal() {
this.editingDevice = null this.editingDevice = null
@@ -462,6 +489,39 @@ export default {
background-color: #f5f7fa; 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 { .header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;

View 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>

View 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>

View File

@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
import Login from '../pages/Login.vue' import Login from '../pages/Login.vue'
import Dashboard from '../pages/Dashboard.vue' import Dashboard from '../pages/Dashboard.vue'
import Device from '../pages/Device.vue' import Device from '../pages/Device.vue'
import FeedPlan from '../pages/FeedPlan.vue'
import FeedPlanDetail from '../pages/FeedPlanDetail.vue'
const routes = [ const routes = [
{ {
@@ -20,6 +22,19 @@ const routes = [
name: 'Device', name: 'Device',
component: Device, component: Device,
meta: { requiresAuth: true } 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
} }
] ]

View File

@@ -1,9 +1,15 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {

4
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.0.0 github.com/golang-jwt/jwt/v5 v5.0.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/panjf2000/ants/v2 v2.11.3 github.com/panjf2000/ants/v2 v2.11.3
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.17.0 golang.org/x/crypto v0.17.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/postgres v1.5.9 gorm.io/driver/postgres v1.5.9
@@ -16,6 +17,7 @@ require (
require ( require (
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // 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/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@@ -36,7 +38,9 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // 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/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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect

2
go.sum
View File

@@ -73,6 +73,8 @@ 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.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.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.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.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View File

@@ -13,6 +13,7 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/api/middleware" "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/config"
"git.huangwc.com/pig/pig-farm-controller/internal/controller/device" "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/operation"
"git.huangwc.com/pig/pig-farm-controller/internal/controller/remote" "git.huangwc.com/pig/pig-farm-controller/internal/controller/remote"
"git.huangwc.com/pig/pig-farm-controller/internal/controller/user" "git.huangwc.com/pig/pig-farm-controller/internal/controller/user"
@@ -44,6 +45,9 @@ type API struct {
// deviceController 设备控制控制器 // deviceController 设备控制控制器
deviceController *device.Controller deviceController *device.Controller
// feedController 饲喂管理控制器
feedController *feed.Controller
// remoteController 远程控制控制器 // remoteController 远程控制控制器
remoteController *remote.Controller remoteController *remote.Controller
@@ -65,7 +69,16 @@ type API struct {
// NewAPI 创建并返回一个新的API实例 // NewAPI 创建并返回一个新的API实例
// 初始化Gin引擎和相关配置 // 初始化Gin引擎和相关配置
func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRepo repository.OperationHistoryRepo, deviceControlRepo repository.DeviceControlRepo, deviceRepo repository.DeviceRepo, websocketManager *websocket.Manager, heartbeatService *service.HeartbeatService, 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为发布模式
gin.SetMode(gin.DebugMode) gin.SetMode(gin.DebugMode)
@@ -98,6 +111,9 @@ func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRe
// 创建设备控制控制器 // 创建设备控制控制器
deviceController := device.NewController(deviceControlRepo, deviceRepo, websocketManager, heartbeatService, deviceStatusPool) deviceController := device.NewController(deviceControlRepo, deviceRepo, websocketManager, heartbeatService, deviceStatusPool)
// 创建饲喂管理控制器
feedController := feed.NewController(feedRepo)
// 创建远程控制控制器 // 创建远程控制控制器
remoteController := remote.NewController(websocketManager) remoteController := remote.NewController(websocketManager)
@@ -110,6 +126,7 @@ func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRe
userController: userController, userController: userController,
operationController: operationController, operationController: operationController,
deviceController: deviceController, deviceController: deviceController,
feedController: feedController,
remoteController: remoteController, remoteController: remoteController,
authMiddleware: authMiddleware, authMiddleware: authMiddleware,
websocketManager: websocketManager, websocketManager: websocketManager,
@@ -222,6 +239,16 @@ func (a *API) setupRoutes() {
deviceGroup.GET("/status", a.deviceController.GetDeviceStatus) 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") remoteGroup := protectedGroup.Group("/remote")
{ {

View File

@@ -3,14 +3,471 @@
// 通过任务执行器执行具体控制任务 // 通过任务执行器执行具体控制任务
package feed 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 { type Controller struct {
// TODO: 定义饲料控制器结构 feedPlanRepo repository.FeedPlanRepo
logger *logs.Logger
} }
// NewFeedController 创建并返回一个新的饲料控制器实例 // NewController 创建并返回一个新的饲料控制器实例
func NewFeedController() *FeedController { func NewController(feedPlanRepo repository.FeedPlanRepo) *Controller {
// TODO: 实现饲料控制器初始化 // 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 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
}

View File

@@ -40,6 +40,9 @@ type Application struct {
// DeviceRepo 设备仓库实例 // DeviceRepo 设备仓库实例
DeviceRepo repository.DeviceRepo DeviceRepo repository.DeviceRepo
// FeedPlanRepo 投喂计划仓库实例
FeedPlanRepo repository.FeedPlanRepo
// WebsocketManager WebSocket管理器 // WebsocketManager WebSocket管理器
WebsocketManager *websocket.Manager WebsocketManager *websocket.Manager
@@ -97,6 +100,8 @@ func (app *Application) Start() error {
// 初始化设备仓库 // 初始化设备仓库
app.DeviceRepo = repository.NewDeviceRepo(app.Storage.GetDB()) app.DeviceRepo = repository.NewDeviceRepo(app.Storage.GetDB())
app.FeedPlanRepo = repository.NewFeedPlanRepo(app.Storage.GetDB())
// 初始化设备状态池 // 初始化设备状态池
app.DeviceStatusPool = service.NewDeviceStatusPool() app.DeviceStatusPool = service.NewDeviceStatusPool()
@@ -109,7 +114,16 @@ func (app *Application) Start() error {
app.HeartbeatService = service.NewHeartbeatService(app.WebsocketManager, app.DeviceStatusPool, app.DeviceRepo, app.Config) app.HeartbeatService = service.NewHeartbeatService(app.WebsocketManager, app.DeviceStatusPool, app.DeviceRepo, app.Config)
// 初始化API组件 // 初始化API组件
app.API = api.NewAPI(app.Config, app.UserRepo, app.OperationHistoryRepo, app.DeviceControlRepo, app.DeviceRepo, app.WebsocketManager, app.HeartbeatService, 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个工作协程) // 初始化任务执行器组件(使用5个工作协程)
app.TaskExecutor = task.NewExecutor(5) app.TaskExecutor = task.NewExecutor(5)

193
internal/model/feed.go Normal file
View 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"
}

View File

@@ -19,6 +19,10 @@ var migrateModels = []interface{}{
&model.OperationHistory{}, &model.OperationHistory{},
&model.Device{}, &model.Device{},
&model.DeviceControl{}, &model.DeviceControl{},
&model.FeedingPlan{},
&model.FeedingPlanStep{},
&model.FeedingExecution{},
&model.FeedingExecutionStep{},
} }
// PostgresStorage 代表基于PostgreSQL的存储实现 // PostgresStorage 代表基于PostgreSQL的存储实现

View 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])
}
}

View File

@@ -23,6 +23,12 @@ type Task interface {
// GetPriority 获取任务优先级 // GetPriority 获取任务优先级
GetPriority() int GetPriority() int
// Done 返回一个channel当任务执行完毕时该channel会被关闭
Done() <-chan struct{}
// IsDone 检查任务是否已完成
IsDone() bool
} }
// taskItem 任务队列中的元素 // taskItem 任务队列中的元素

View 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)
}

View File

@@ -46,6 +46,9 @@ type Hub struct {
// deviceRepo 设备仓库 // deviceRepo 设备仓库
deviceRepo repository.DeviceRepo deviceRepo repository.DeviceRepo
// 关闭消息
close chan struct{}
} }
// Client WebSocket客户端结构 // Client WebSocket客户端结构
@@ -78,6 +81,7 @@ func NewHub(deviceRepo repository.DeviceRepo) *Hub {
deviceClients: make(map[string]*Client), deviceClients: make(map[string]*Client),
logger: logs.NewLogger(), logger: logs.NewLogger(),
deviceRepo: deviceRepo, deviceRepo: deviceRepo,
close: make(chan struct{}),
} }
} }
@@ -101,10 +105,20 @@ func (h *Hub) Run() {
h.unregisterClient(client) h.unregisterClient(client)
case message := <-h.broadcast: case message := <-h.broadcast:
h.broadcastMessage(message) h.broadcastMessage(message)
case <-h.close:
return
} }
} }
} }
func (h *Hub) Close() {
// 关闭时清理所有资源
for client := range h.clients {
h.unregisterClient(client)
}
close(h.close)
}
// registerClient 注册客户端 // registerClient 注册客户端
func (h *Hub) registerClient(client *Client) { func (h *Hub) registerClient(client *Client) {
h.mutex.Lock() h.mutex.Lock()

View File

@@ -75,6 +75,10 @@ func (s *Server) Start() {
go s.hub.Run() go s.hub.Run()
} }
func (s *Server) Stop() {
s.hub.Close()
}
// readPump 从WebSocket连接读取消息 // readPump 从WebSocket连接读取消息
func (c *Client) readPump() { func (c *Client) readPump() {
defer func() { defer func() {

14
vendor/modules.txt vendored
View File

@@ -24,6 +24,9 @@ github.com/bytedance/sonic/utf8
# github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 # github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311
## explicit; go 1.15 ## explicit; go 1.15
github.com/chenzhuoyu/base64x 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 # github.com/gabriel-vasile/mimetype v1.4.2
## explicit; go 1.20 ## explicit; go 1.20
github.com/gabriel-vasile/mimetype github.com/gabriel-vasile/mimetype
@@ -129,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/danger
github.com/pelletier/go-toml/v2/internal/tracker github.com/pelletier/go-toml/v2/internal/tracker
github.com/pelletier/go-toml/v2/unstable 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 # github.com/rogpeppe/go-internal v1.14.1
## explicit; go 1.23 ## 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 # github.com/twitchyliquid64/golang-asm v0.15.1
## explicit; go 1.13 ## explicit; go 1.13
github.com/twitchyliquid64/golang-asm/asm/arch github.com/twitchyliquid64/golang-asm/asm/arch