Compare commits

...

12 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
19 changed files with 1958 additions and 72 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.40048162.js"></script> <script type="module" crossorigin src="/assets/index.cb9d3828.js"></script>
<link rel="stylesheet" href="/assets/index.4965a25a.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>
@@ -481,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: {

View File

@@ -69,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)
@@ -103,7 +112,7 @@ 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() feedController := feed.NewController(feedRepo)
// 创建远程控制控制器 // 创建远程控制控制器
remoteController := remote.NewController(websocketManager) remoteController := remote.NewController(websocketManager)
@@ -235,6 +244,9 @@ func (a *API) setupRoutes() {
{ {
feedGroup.GET("/plan/list", a.feedController.ListPlans) feedGroup.GET("/plan/list", a.feedController.ListPlans)
feedGroup.GET("/plan/detail", a.feedController.Detail) 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)
} }
// 远程控制相关路由 // 远程控制相关路由

View File

@@ -4,14 +4,14 @@
package feed package feed
import ( import (
"time" "fmt"
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/controller" "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/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/model" "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/storage/repository"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// Controller 饲料控制器 // Controller 饲料控制器
@@ -30,6 +30,146 @@ func NewController(feedPlanRepo repository.FeedPlanRepo) *Controller {
} }
} }
// 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 { type ListPlansResponse struct {
Plans []ListPlanResponseItem `json:"plans"` Plans []ListPlanResponseItem `json:"plans"`
} }
@@ -68,17 +208,57 @@ func (c *Controller) ListPlans(ctx *gin.Context) {
} }
for _, introduction := range introductions { for _, introduction := range introductions {
resp.Plans = append(resp.Plans, ListPlanResponseItem{ resp.Plans = append(resp.Plans, ListPlanResponseItem{
ID: introduction.ID, ID: introduction.ID,
Name: introduction.Name, Name: introduction.Name,
Description: introduction.Description, Description: introduction.Description,
Enabled: introduction.Enabled, Enabled: introduction.Enabled,
Type: introduction.Type, Type: introduction.Type,
ScheduleCron: introduction.ScheduleCron,
}) })
} }
controller.SendSuccessResponse(ctx, "success", resp) 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 喂料计划主表 // DetailResponse 喂料计划主表
type DetailResponse struct { type DetailResponse struct {
// ID 计划ID // ID 计划ID
@@ -97,34 +277,22 @@ type DetailResponse struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
// ScheduleCron 定时任务表达式(仅当Type为auto时有效) // ScheduleCron 定时任务表达式(仅当Type为auto时有效)
ScheduleCron *string `json:"scheduleCron"` ScheduleCron *string `json:"schedule_cron,omitempty"`
// ExecutionLimit 执行次数限制(0表示无限制仅当Type为auto时有效) // ExecutionLimit 执行次数限制(0表示无限制仅当Type为auto时有效)
ExecutionLimit int `json:"executionLimit"` ExecutionLimit int `json:"execution_limit"`
// ParentID 父计划ID用于支持子计划结构 // ParentID 父计划ID用于支持子计划结构
ParentID *uint `json:"parentID"` ParentID *uint `json:"parent_id,omitempty"`
// OrderInParent 在父计划中的执行顺序 // OrderInParent 在父计划中的执行顺序
OrderInParent *int `json:"orderInParent"` OrderInParent *int `json:"order_in_parent,omitempty"`
// IsMaster 是否为主计划(主计划可以包含子计划)
IsMaster bool `json:"isMaster"`
// CreatedAt 创建时间
CreatedAt time.Time `json:"createdAt"`
// UpdatedAt 更新时间
UpdatedAt time.Time `json:"updatedAt"`
// DeletedAt 删除时间(用于软删除)
DeletedAt gorm.DeletedAt `json:"deletedAt"`
// Steps 计划步骤列表 // Steps 计划步骤列表
Steps []FeedingPlanStep `json:"steps"` Steps []FeedingPlanStep `json:"steps"`
// SubPlans 子计划列表 // SubPlans 子计划列表
SubPlans []DetailResponse `json:"subPlans"` SubPlans []DetailResponse `json:"sub_plans"`
} }
// FeedingPlanStep 喂料计划步骤表,表示计划中的每个设备动作 // FeedingPlanStep 喂料计划步骤表,表示计划中的每个设备动作
@@ -133,39 +301,173 @@ type FeedingPlanStep struct {
ID uint `json:"id"` ID uint `json:"id"`
// PlanID 关联的计划ID // PlanID 关联的计划ID
PlanID uint `json:"planID"` PlanID uint `json:"plan_id"`
// StepOrder 步骤顺序 // StepOrder 步骤顺序
StepOrder int `json:"stepOrder"` StepOrder int `json:"step_order"`
// DeviceID 关联的设备ID // DeviceID 关联的设备ID
DeviceID uint `json:"deviceID"` DeviceID uint `json:"device_id"`
// TargetValue 目标值(达到该值后停止工作切换到下一个设备) // TargetValue 目标值(达到该值后停止工作切换到下一个设备)
TargetValue float64 `json:"targetValue"` TargetValue float64 `json:"target_value"`
// Action 动作(如:打开设备) // Action 动作(如:打开设备)
Action string `json:"action"` Action string `json:"action"`
// ScheduleCron 步骤定时任务表达式(可选) // ScheduleCron 步骤定时任务表达式(可选)
ScheduleCron *string `json:"scheduleCron"` ScheduleCron *string `json:"schedule_cron,omitempty"`
// ExecutionLimit 步骤执行次数限制(0表示无限制) // ExecutionLimit 步骤执行次数限制(0表示无限制)
ExecutionLimit int `json:"executionLimit"` ExecutionLimit int `json:"execution_limit"`
// CreatedAt 创建时间
CreatedAt time.Time `json:"createdAt"`
// UpdatedAt 更新时间
UpdatedAt time.Time `json:"updatedAt"`
// DeletedAt 删除时间(用于软删除)
DeletedAt gorm.DeletedAt `json:"deletedAt"`
} }
// Detail 获取饲料计划列细节 // Detail 获取饲料计划列细节
func (c *Controller) Detail(ctx *gin.Context) { func (c *Controller) Detail(ctx *gin.Context) {
// TODO: 实现获取饲料计划列表的逻辑 // 获取查询参数中的计划ID
planIDStr := ctx.Query("id")
if planIDStr == "" {
controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "缺少计划ID参数")
return
}
controller.SendSuccessResponse(ctx, "success", &DetailResponse{}) 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)

View File

@@ -45,9 +45,6 @@ type FeedingPlan struct {
// OrderInParent 在父计划中的执行顺序 // OrderInParent 在父计划中的执行顺序
OrderInParent *int `gorm:"column:order_in_parent" json:"order_in_parent,omitempty"` OrderInParent *int `gorm:"column:order_in_parent" json:"order_in_parent,omitempty"`
// IsMaster 是否为主计划(主计划可以包含子计划)
IsMaster bool `gorm:"not null;default:false;column:is_master" json:"is_master"`
// CreatedAt 创建时间 // CreatedAt 创建时间
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`

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

@@ -60,6 +60,9 @@ func (f *feedPlanRepo) FindFeedingPlanByID(feedingPlanID uint) (*model.FeedingPl
// CreateFeedingPlan 创建饲料计划,包括步骤和子计划 // CreateFeedingPlan 创建饲料计划,包括步骤和子计划
func (f *feedPlanRepo) CreateFeedingPlan(feedingPlan *model.FeedingPlan) error { func (f *feedPlanRepo) CreateFeedingPlan(feedingPlan *model.FeedingPlan) error {
// 清空所有ID确保创建新记录
f.clearAllIDs(feedingPlan)
return f.db.Transaction(func(tx *gorm.DB) error { return f.db.Transaction(func(tx *gorm.DB) error {
return f.createFeedingPlanWithTx(tx, feedingPlan) return f.createFeedingPlanWithTx(tx, feedingPlan)
}) })
@@ -79,6 +82,9 @@ func (f *feedPlanRepo) UpdateFeedingPlan(feedingPlan *model.FeedingPlan) error {
return err return err
} }
// 清空所有ID包括子计划和步骤的ID
f.clearAllIDs(feedingPlan)
// 再重新创建更新后的计划 // 再重新创建更新后的计划
if err := f.createFeedingPlanWithTx(tx, feedingPlan); err != nil { if err := f.createFeedingPlanWithTx(tx, feedingPlan); err != nil {
return err return err
@@ -178,3 +184,22 @@ func (f *feedPlanRepo) createFeedingPlanWithTx(tx *gorm.DB, feedingPlan *model.F
return nil 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])
}
}