Compare commits
	
		
			24 Commits
		
	
	
		
			946c02516c
			...
			main-old
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3743b5ddcd | |||
| 6fe73d8ffe | |||
| 91f160b07e | |||
| a1950872fc | |||
| c499571c11 | |||
| cc7ea94e41 | |||
| 40a19b831a | |||
| 9944340d17 | |||
| 4a70c1e839 | |||
| e75b3ee148 | |||
| cbcba09d40 | |||
| 4805e422f7 | |||
| 64c86de71e | |||
| 3ecbf3b5af | |||
| 1a14aec19b | |||
| 008677467b | |||
| 2b4dd3e74d | |||
| 8468a96398 | |||
| e27aec0ca2 | |||
| 52cf8c58ed | |||
| d7c2ffb108 | |||
| 43befdb71c | |||
| 8d639d3b09 | |||
| 4f928dff9f | 
@@ -32,6 +32,6 @@ websocket:
 | 
			
		||||
# 心跳配置
 | 
			
		||||
heartbeat:
 | 
			
		||||
  # 心跳间隔(秒)
 | 
			
		||||
  interval: 5
 | 
			
		||||
  interval: 30
 | 
			
		||||
  # 请求并发数
 | 
			
		||||
  concurrency: 5
 | 
			
		||||
							
								
								
									
										21
									
								
								frontend/dist/assets/index.2015effd.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										21
									
								
								frontend/dist/assets/index.2015effd.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								frontend/dist/assets/index.7f062720.css
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								frontend/dist/assets/index.7f062720.css
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								frontend/dist/assets/index.bcc76856.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/dist/assets/index.bcc76856.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										21
									
								
								frontend/dist/assets/index.cb9d3828.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								frontend/dist/assets/index.cb9d3828.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										4
									
								
								frontend/dist/index.html
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								frontend/dist/index.html
									
									
									
									
										vendored
									
									
								
							@@ -4,8 +4,8 @@
 | 
			
		||||
    <meta charset="UTF-8">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
    <title>猪场管理系统</title>
 | 
			
		||||
  <script type="module" crossorigin src="/assets/index.2015effd.js"></script>
 | 
			
		||||
  <link rel="stylesheet" href="/assets/index.7f062720.css">
 | 
			
		||||
  <script type="module" crossorigin src="/assets/index.cb9d3828.js"></script>
 | 
			
		||||
  <link rel="stylesheet" href="/assets/index.bcc76856.css">
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
    <div id="app"></div>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										303
									
								
								frontend/src/components/FeedPlanForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										303
									
								
								frontend/src/components/FeedPlanForm.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,303 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="feed-plan-form">
 | 
			
		||||
    <div class="form-header">
 | 
			
		||||
      <h2>{{ isEditMode ? '编辑饲喂计划' : '新建饲喂计划' }}</h2>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <form @submit.prevent="handleSubmit">
 | 
			
		||||
      <div class="form-group">
 | 
			
		||||
        <label for="name">计划名称 *</label>
 | 
			
		||||
        <input 
 | 
			
		||||
          type="text" 
 | 
			
		||||
          id="name" 
 | 
			
		||||
          v-model="form.name" 
 | 
			
		||||
          required
 | 
			
		||||
          :disabled="loading"
 | 
			
		||||
        >
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div class="form-group">
 | 
			
		||||
        <label for="description">计划描述</label>
 | 
			
		||||
        <textarea 
 | 
			
		||||
          id="description" 
 | 
			
		||||
          v-model="form.description" 
 | 
			
		||||
          rows="3"
 | 
			
		||||
          :disabled="loading"
 | 
			
		||||
        ></textarea>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div class="form-group">
 | 
			
		||||
        <label>计划类型 *</label>
 | 
			
		||||
        <div class="radio-group">
 | 
			
		||||
          <label class="radio-item">
 | 
			
		||||
            <input 
 | 
			
		||||
              type="radio" 
 | 
			
		||||
              v-model="form.type" 
 | 
			
		||||
              value="manual" 
 | 
			
		||||
              :disabled="loading"
 | 
			
		||||
            >
 | 
			
		||||
            手动触发
 | 
			
		||||
          </label>
 | 
			
		||||
          <label class="radio-item">
 | 
			
		||||
            <input 
 | 
			
		||||
              type="radio" 
 | 
			
		||||
              v-model="form.type" 
 | 
			
		||||
              value="auto" 
 | 
			
		||||
              :disabled="loading"
 | 
			
		||||
            >
 | 
			
		||||
            自动触发
 | 
			
		||||
          </label>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div class="form-group">
 | 
			
		||||
        <label>
 | 
			
		||||
          <input 
 | 
			
		||||
            type="checkbox" 
 | 
			
		||||
            v-model="form.enabled" 
 | 
			
		||||
            :disabled="loading"
 | 
			
		||||
          >
 | 
			
		||||
          启用计划
 | 
			
		||||
        </label>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div v-if="form.type === 'auto'" class="form-group">
 | 
			
		||||
        <label for="schedule_cron">定时表达式</label>
 | 
			
		||||
        <input 
 | 
			
		||||
          type="text" 
 | 
			
		||||
          id="schedule_cron" 
 | 
			
		||||
          v-model="form.schedule_cron" 
 | 
			
		||||
          placeholder="例如: 0 0 7 * * *"
 | 
			
		||||
          :disabled="loading"
 | 
			
		||||
        >
 | 
			
		||||
        <div class="help-text">Cron表达式,用于设置自动执行时间</div>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div class="form-group">
 | 
			
		||||
        <label for="execution_limit">执行次数限制</label>
 | 
			
		||||
        <input 
 | 
			
		||||
          type="number" 
 | 
			
		||||
          id="execution_limit" 
 | 
			
		||||
          v-model.number="form.execution_limit" 
 | 
			
		||||
          min="0"
 | 
			
		||||
          :disabled="loading"
 | 
			
		||||
        >
 | 
			
		||||
        <div class="help-text">0表示无限制</div>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div class="form-actions">
 | 
			
		||||
        <button 
 | 
			
		||||
          type="button" 
 | 
			
		||||
          class="btn btn-secondary" 
 | 
			
		||||
          @click="$emit('cancel')"
 | 
			
		||||
          :disabled="loading"
 | 
			
		||||
        >
 | 
			
		||||
          取消
 | 
			
		||||
        </button>
 | 
			
		||||
        <button 
 | 
			
		||||
          type="submit" 
 | 
			
		||||
          class="btn btn-primary" 
 | 
			
		||||
          :disabled="loading || isSubmitting"
 | 
			
		||||
        >
 | 
			
		||||
          {{ loading || isSubmitting ? '处理中...' : (isEditMode ? '更新计划' : '创建计划') }}
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'FeedPlanForm',
 | 
			
		||||
  props: {
 | 
			
		||||
    // 编辑模式下的初始数据
 | 
			
		||||
    initialData: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      default: () => ({
 | 
			
		||||
        name: '',
 | 
			
		||||
        description: '',
 | 
			
		||||
        type: 'manual',
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        schedule_cron: '',
 | 
			
		||||
        execution_limit: 0
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    // 是否为编辑模式
 | 
			
		||||
    isEditMode: {
 | 
			
		||||
      type: Boolean,
 | 
			
		||||
      default: false
 | 
			
		||||
    },
 | 
			
		||||
    // 提交时的加载状态
 | 
			
		||||
    loading: {
 | 
			
		||||
      type: Boolean,
 | 
			
		||||
      default: false
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      form: { ...this.initialData },
 | 
			
		||||
      isSubmitting: false // 防止重复提交
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  watch: {
 | 
			
		||||
    // 监听初始数据变化,更新表单
 | 
			
		||||
    initialData: {
 | 
			
		||||
      handler(newVal) {
 | 
			
		||||
        this.form = { ...newVal }
 | 
			
		||||
      },
 | 
			
		||||
      deep: true
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  methods: {
 | 
			
		||||
    async handleSubmit() {
 | 
			
		||||
      // 防止重复提交
 | 
			
		||||
      if (this.isSubmitting || this.loading) {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      this.isSubmitting = true
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        // 表单验证
 | 
			
		||||
        if (!this.form.name.trim()) {
 | 
			
		||||
          alert('请输入计划名称')
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if (this.form.type === 'auto' && this.form.schedule_cron && !this.isValidCron(this.form.schedule_cron)) {
 | 
			
		||||
          alert('请输入有效的Cron表达式')
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // 触发提交事件
 | 
			
		||||
        this.$emit('submit', { ...this.form })
 | 
			
		||||
      } finally {
 | 
			
		||||
        // 在下一个tick重置提交状态,确保事件已经触发
 | 
			
		||||
        this.$nextTick(() => {
 | 
			
		||||
          this.isSubmitting = false
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 简单的Cron表达式验证
 | 
			
		||||
    isValidCron(cron) {
 | 
			
		||||
      // 这里可以添加更复杂的验证逻辑
 | 
			
		||||
      // 现在只是简单检查格式
 | 
			
		||||
      return typeof cron === 'string' && cron.trim().length > 0
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.feed-plan-form {
 | 
			
		||||
  background: white;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  padding: 30px;
 | 
			
		||||
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-header h2 {
 | 
			
		||||
  margin-top: 0;
 | 
			
		||||
  color: #333;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group {
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group label {
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin-bottom: 5px;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group input[type="text"],
 | 
			
		||||
.form-group input[type="number"],
 | 
			
		||||
.form-group textarea {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  border: 1px solid #ddd;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group input[type="text"]:focus,
 | 
			
		||||
.form-group input[type="number"]:focus,
 | 
			
		||||
.form-group textarea:focus {
 | 
			
		||||
  outline: none;
 | 
			
		||||
  border-color: #007bff;
 | 
			
		||||
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group input:disabled,
 | 
			
		||||
.form-group textarea:disabled {
 | 
			
		||||
  background-color: #f8f9fa;
 | 
			
		||||
  cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.radio-group {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.radio-item {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 5px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.help-text {
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  color: #666;
 | 
			
		||||
  margin-top: 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-actions {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: flex-end;
 | 
			
		||||
  gap: 15px;
 | 
			
		||||
  margin-top: 30px;
 | 
			
		||||
  padding-top: 20px;
 | 
			
		||||
  border-top: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn {
 | 
			
		||||
  padding: 10px 20px;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn:disabled {
 | 
			
		||||
  cursor: not-allowed;
 | 
			
		||||
  opacity: 0.6;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary {
 | 
			
		||||
  background-color: #007bff;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary:hover:not(:disabled) {
 | 
			
		||||
  background-color: #0069d9;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-secondary {
 | 
			
		||||
  background-color: #6c757d;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-secondary:hover:not(:disabled) {
 | 
			
		||||
  background-color: #5a6268;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -12,6 +12,7 @@
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li><router-link to="/dashboard" class="active">控制台</router-link></li>
 | 
			
		||||
        <li><router-link to="/device">设备管理</router-link></li>
 | 
			
		||||
        <li><router-link to="/feed/plan">饲喂计划</router-link></li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,14 @@
 | 
			
		||||
      </div>
 | 
			
		||||
    </header>
 | 
			
		||||
 | 
			
		||||
    <nav class="nav">
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li><router-link to="/dashboard">控制台</router-link></li>
 | 
			
		||||
        <li><router-link to="/device" class="active">设备管理</router-link></li>
 | 
			
		||||
        <li><router-link to="/feed/plan">饲喂计划</router-link></li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </nav>
 | 
			
		||||
 | 
			
		||||
    <main class="main-content">
 | 
			
		||||
      <div class="toolbar">
 | 
			
		||||
        <button class="btn btn-primary" @click="openAddDeviceModal">添加设备</button>
 | 
			
		||||
@@ -289,6 +297,8 @@ export default {
 | 
			
		||||
        
 | 
			
		||||
        if (response.ok && data.code === 0) {
 | 
			
		||||
          this.devices = data.data.devices
 | 
			
		||||
          // 默认展开所有节点
 | 
			
		||||
          this.expandAllNodes()
 | 
			
		||||
        } else {
 | 
			
		||||
          console.error('获取设备列表失败:', data.message)
 | 
			
		||||
        }
 | 
			
		||||
@@ -297,6 +307,23 @@ export default {
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 展开所有节点
 | 
			
		||||
    expandAllNodes() {
 | 
			
		||||
      // 清空当前展开的节点
 | 
			
		||||
      this.expandedNodes.clear()
 | 
			
		||||
      
 | 
			
		||||
      // 展开所有中继设备节点
 | 
			
		||||
      this.relayDevices.forEach(relay => {
 | 
			
		||||
        this.expandedNodes.add(relay.id)
 | 
			
		||||
        
 | 
			
		||||
        // 展开所有控制器设备节点
 | 
			
		||||
        const controllers = this.getControllerDevices(relay.id)
 | 
			
		||||
        controllers.forEach(controller => {
 | 
			
		||||
          this.expandedNodes.add(controller.id)
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 打开添加设备模态框
 | 
			
		||||
    openAddDeviceModal() {
 | 
			
		||||
      this.editingDevice = null
 | 
			
		||||
@@ -462,6 +489,39 @@ export default {
 | 
			
		||||
  background-color: #f5f7fa;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav {
 | 
			
		||||
  background-color: #343a40;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav ul {
 | 
			
		||||
  list-style-type: none;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav li {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a {
 | 
			
		||||
  display: block;
 | 
			
		||||
  padding: 15px 20px;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a:hover {
 | 
			
		||||
  background-color: #495057;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a.active {
 | 
			
		||||
  background-color: #007bff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header {
 | 
			
		||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
			
		||||
  color: white;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										514
									
								
								frontend/src/pages/FeedPlan.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										514
									
								
								frontend/src/pages/FeedPlan.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,514 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="feed-plan-management">
 | 
			
		||||
    <div class="header">
 | 
			
		||||
      <h1>饲喂计划管理</h1>
 | 
			
		||||
      <div class="user-info">
 | 
			
		||||
        <span>欢迎, {{ username }}</span>
 | 
			
		||||
        <button class="logout-btn" @click="logout">退出</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <nav class="nav">
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li><router-link to="/dashboard">控制台</router-link></li>
 | 
			
		||||
        <li><router-link to="/device">设备管理</router-link></li>
 | 
			
		||||
        <li><router-link to="/feed/plan" class="active">饲喂计划</router-link></li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </nav>
 | 
			
		||||
 | 
			
		||||
    <main class="main-content">
 | 
			
		||||
      <div class="toolbar">
 | 
			
		||||
        <button class="btn btn-primary" @click="createPlan">创建计划</button>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="plan-list">
 | 
			
		||||
        <div v-if="loading" class="loading">
 | 
			
		||||
          加载中...
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div v-else-if="plans.length === 0" class="no-plans">
 | 
			
		||||
          暂无饲喂计划
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div v-else class="plans-container">
 | 
			
		||||
          <div 
 | 
			
		||||
            v-for="plan in plans" 
 | 
			
		||||
            :key="plan.id" 
 | 
			
		||||
            class="plan-card"
 | 
			
		||||
          >
 | 
			
		||||
            <div class="plan-header">
 | 
			
		||||
              <h3>{{ plan.name }}</h3>
 | 
			
		||||
              <span :class="['plan-status', { 'enabled': plan.enabled, 'disabled': !plan.enabled }]">
 | 
			
		||||
                {{ plan.enabled ? '已启用' : '已禁用' }}
 | 
			
		||||
              </span>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div class="plan-details">
 | 
			
		||||
              <p class="plan-description">{{ plan.description || '暂无描述' }}</p>
 | 
			
		||||
              <div class="plan-meta">
 | 
			
		||||
                <span class="plan-type">{{ plan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
 | 
			
		||||
                <span v-if="plan.schedule_cron" class="plan-cron">定时: {{ plan.schedule_cron }}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div class="plan-actions">
 | 
			
		||||
              <button class="action-btn detail-btn" @click="viewDetail(plan.id)">详情</button>
 | 
			
		||||
              <button class="action-btn edit-btn" @click="editPlan(plan)">编辑</button>
 | 
			
		||||
              <button class="action-btn delete-btn" @click="deletePlan(plan.id)">删除</button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </main>
 | 
			
		||||
    
 | 
			
		||||
    <!-- 计划表单模态框 -->
 | 
			
		||||
    <div v-if="showModal" class="modal-overlay" @click="closeModal">
 | 
			
		||||
      <div class="modal-content" @click.stop>
 | 
			
		||||
        <FeedPlanForm
 | 
			
		||||
          :initial-data="currentPlan"
 | 
			
		||||
          :is-edit-mode="modalType === 'edit'"
 | 
			
		||||
          :loading="submitting"
 | 
			
		||||
          @submit="submitForm"
 | 
			
		||||
          @cancel="closeModal"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import FeedPlanForm from '../components/FeedPlanForm.vue'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'FeedPlan',
 | 
			
		||||
  components: {
 | 
			
		||||
    FeedPlanForm
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      username: '',
 | 
			
		||||
      plans: [],
 | 
			
		||||
      loading: true,
 | 
			
		||||
      // 控制模态框显示
 | 
			
		||||
      showModal: false,
 | 
			
		||||
      // 当前操作类型: 'create' 或 'edit'
 | 
			
		||||
      modalType: 'create',
 | 
			
		||||
      // 当前编辑的计划
 | 
			
		||||
      currentPlan: null,
 | 
			
		||||
      // 提交时的加载状态
 | 
			
		||||
      submitting: false
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  mounted() {
 | 
			
		||||
    this.username = localStorage.getItem('username') || '管理员'
 | 
			
		||||
    this.loadPlans()
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  methods: {
 | 
			
		||||
    // 加载饲喂计划列表
 | 
			
		||||
    async loadPlans() {
 | 
			
		||||
      this.loading = true
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch('/api/v1/feed/plan/list', {
 | 
			
		||||
          method: 'GET',
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Content-Type': 'application/json',
 | 
			
		||||
            'Authorization': 'Bearer ' + localStorage.getItem('authToken')
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        
 | 
			
		||||
        const data = await response.json()
 | 
			
		||||
        
 | 
			
		||||
        if (response.ok && data.code === 0) {
 | 
			
		||||
          this.plans = data.data.plans || []
 | 
			
		||||
        } else {
 | 
			
		||||
          console.error('获取饲喂计划列表失败:', data.message)
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('获取饲喂计划列表失败:', error)
 | 
			
		||||
      } finally {
 | 
			
		||||
        this.loading = false
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 查看详情
 | 
			
		||||
    viewDetail(planId) {
 | 
			
		||||
      this.$router.push(`/feed/plan/detail/${planId}`)
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 创建计划
 | 
			
		||||
    createPlan() {
 | 
			
		||||
      this.modalType = 'create'
 | 
			
		||||
      this.currentPlan = {
 | 
			
		||||
        name: '',
 | 
			
		||||
        description: '',
 | 
			
		||||
        type: 'manual',
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        schedule_cron: '',
 | 
			
		||||
        execution_limit: 0
 | 
			
		||||
      }
 | 
			
		||||
      this.showModal = true
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 编辑计划
 | 
			
		||||
    editPlan(plan) {
 | 
			
		||||
      this.modalType = 'edit'
 | 
			
		||||
      // 深拷贝计划数据,避免直接修改原数据
 | 
			
		||||
      this.currentPlan = JSON.parse(JSON.stringify(plan))
 | 
			
		||||
      this.showModal = true
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 关闭模态框
 | 
			
		||||
    closeModal() {
 | 
			
		||||
      this.showModal = false
 | 
			
		||||
      this.currentPlan = null
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 提交表单
 | 
			
		||||
    async submitForm(formData) {
 | 
			
		||||
      // 防止重复提交
 | 
			
		||||
      if (this.submitting) {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      this.submitting = true
 | 
			
		||||
      try {
 | 
			
		||||
        let url, method
 | 
			
		||||
        if (this.modalType === 'create') {
 | 
			
		||||
          url = '/api/v1/feed/plan/create'
 | 
			
		||||
          method = 'POST'
 | 
			
		||||
        } else {
 | 
			
		||||
          url = '/api/v1/feed/plan/update'
 | 
			
		||||
          method = 'POST'
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const response = await fetch(url, {
 | 
			
		||||
          method: method,
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Content-Type': 'application/json',
 | 
			
		||||
            'Authorization': 'Bearer ' + localStorage.getItem('authToken')
 | 
			
		||||
          },
 | 
			
		||||
          body: JSON.stringify(formData)
 | 
			
		||||
        })
 | 
			
		||||
        
 | 
			
		||||
        const data = await response.json()
 | 
			
		||||
        
 | 
			
		||||
        if (response.ok && data.code === 0) {
 | 
			
		||||
          // 提交成功,关闭模态框并重新加载列表
 | 
			
		||||
          this.closeModal()
 | 
			
		||||
          await this.loadPlans()
 | 
			
		||||
          alert(this.modalType === 'create' ? '创建计划成功' : '更新计划成功')
 | 
			
		||||
        } else {
 | 
			
		||||
          alert((this.modalType === 'create' ? '创建计划失败: ' : '更新计划失败: ') + (data.message || '未知错误'))
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error(this.modalType === 'create' ? '创建计划失败:' : '更新计划失败:', error)
 | 
			
		||||
        alert((this.modalType === 'create' ? '创建计划失败: ' : '更新计划失败: ') + error.message)
 | 
			
		||||
      } finally {
 | 
			
		||||
        this.submitting = false
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 删除计划
 | 
			
		||||
    async deletePlan(planId) {
 | 
			
		||||
      if (!confirm('确定要删除这个饲喂计划吗?')) {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch('/api/v1/feed/plan/delete', {
 | 
			
		||||
          method: 'POST',
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Content-Type': 'application/json',
 | 
			
		||||
            'Authorization': 'Bearer ' + localStorage.getItem('authToken')
 | 
			
		||||
          },
 | 
			
		||||
          body: JSON.stringify({ id: planId })
 | 
			
		||||
        })
 | 
			
		||||
        
 | 
			
		||||
        const data = await response.json()
 | 
			
		||||
        
 | 
			
		||||
        if (response.ok && data.code === 0) {
 | 
			
		||||
          // 删除成功,重新加载列表
 | 
			
		||||
          await this.loadPlans()
 | 
			
		||||
          this.$message?.success('删除成功') || alert('删除成功')
 | 
			
		||||
        } else {
 | 
			
		||||
          this.$message?.error('删除失败: ' + data.message) || alert('删除失败: ' + data.message)
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('删除饲喂计划失败:', error)
 | 
			
		||||
        this.$message?.error('删除饲喂计划失败: ' + error.message) || alert('删除饲喂计划失败: ' + error.message)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 退出登录
 | 
			
		||||
    logout() {
 | 
			
		||||
      localStorage.removeItem('authToken')
 | 
			
		||||
      localStorage.removeItem('username')
 | 
			
		||||
      this.$router.push('/')
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.feed-plan-management {
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin-bottom: 30px;
 | 
			
		||||
  padding-bottom: 20px;
 | 
			
		||||
  border-bottom: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header h1 {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-info {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.logout-btn {
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
  background-color: #dc3545;
 | 
			
		||||
  color: white;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.logout-btn:hover {
 | 
			
		||||
  background-color: #c82333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav {
 | 
			
		||||
  background-color: #343a40;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav ul {
 | 
			
		||||
  list-style-type: none;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav li {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a {
 | 
			
		||||
  display: block;
 | 
			
		||||
  padding: 15px 20px;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a:hover {
 | 
			
		||||
  background-color: #495057;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a.active {
 | 
			
		||||
  background-color: #007bff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.toolbar {
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn {
 | 
			
		||||
  padding: 10px 20px;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary {
 | 
			
		||||
  background-color: #007bff;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary:hover {
 | 
			
		||||
  background-color: #0069d9;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-list {
 | 
			
		||||
  min-height: 400px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.loading, .no-plans {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  padding: 50px;
 | 
			
		||||
  color: #666;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plans-container {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
 | 
			
		||||
  gap: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-card {
 | 
			
		||||
  border: 1px solid #ddd;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  background-color: white;
 | 
			
		||||
  transition: box-shadow 0.3s, transform 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-card:hover {
 | 
			
		||||
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
 | 
			
		||||
  transform: translateY(-2px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin-bottom: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-header h3 {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  color: #333;
 | 
			
		||||
  font-size: 18px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-status {
 | 
			
		||||
  padding: 4px 8px;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-status.enabled {
 | 
			
		||||
  background-color: #d4edda;
 | 
			
		||||
  color: #155724;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-status.disabled {
 | 
			
		||||
  background-color: #f8d7da;
 | 
			
		||||
  color: #721c24;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-details {
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-description {
 | 
			
		||||
  margin: 0 0 15px 0;
 | 
			
		||||
  color: #666;
 | 
			
		||||
  line-height: 1.5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-meta {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  gap: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-type, .plan-cron {
 | 
			
		||||
  padding: 4px 8px;
 | 
			
		||||
  background-color: #e9ecef;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  color: #495057;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-actions {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 10px;
 | 
			
		||||
  justify-content: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.action-btn {
 | 
			
		||||
  padding: 6px 12px;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detail-btn {
 | 
			
		||||
  background-color: #17a2b8;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detail-btn:hover {
 | 
			
		||||
  background-color: #138496;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.edit-btn {
 | 
			
		||||
  background-color: #ffc107;
 | 
			
		||||
  color: #212529;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.edit-btn:hover {
 | 
			
		||||
  background-color: #e0a800;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.delete-btn {
 | 
			
		||||
  background-color: #dc3545;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.delete-btn:hover {
 | 
			
		||||
  background-color: #c82333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  .plans-container {
 | 
			
		||||
    grid-template-columns: 1fr;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .header {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    gap: 15px;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .nav ul {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 模态框样式 */
 | 
			
		||||
.modal-overlay {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.5);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  z-index: 1000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-content {
 | 
			
		||||
  max-width: 600px;
 | 
			
		||||
  width: 90%;
 | 
			
		||||
  max-height: 90vh;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  background: white;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										652
									
								
								frontend/src/pages/FeedPlanDetail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										652
									
								
								frontend/src/pages/FeedPlanDetail.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,652 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="feed-plan-detail">
 | 
			
		||||
    <div class="header">
 | 
			
		||||
      <h1>饲喂计划详情</h1>
 | 
			
		||||
      <div class="user-info">
 | 
			
		||||
        <span>欢迎, {{ username }}</span>
 | 
			
		||||
        <button class="logout-btn" @click="logout">退出</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <nav class="nav">
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li><router-link to="/dashboard">控制台</router-link></li>
 | 
			
		||||
        <li><router-link to="/device">设备管理</router-link></li>
 | 
			
		||||
        <li><router-link to="/feed/plan">饲喂计划</router-link></li>
 | 
			
		||||
        <li><router-link to="/feed/plan/detail" class="active">计划详情</router-link></li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </nav>
 | 
			
		||||
 | 
			
		||||
    <main class="main-content">
 | 
			
		||||
      <div v-if="loading" class="loading">
 | 
			
		||||
        加载中...
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div v-else-if="error" class="error">
 | 
			
		||||
        {{ error }}
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div v-else-if="plan" class="plan-detail-container">
 | 
			
		||||
        <div class="plan-header">
 | 
			
		||||
          <h2>{{ plan.name }}</h2>
 | 
			
		||||
          <span :class="['plan-status', { 'enabled': plan.enabled, 'disabled': !plan.enabled }]">
 | 
			
		||||
            {{ plan.enabled ? '已启用' : '已禁用' }}
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div class="plan-info">
 | 
			
		||||
          <div class="info-item">
 | 
			
		||||
            <label>计划描述:</label>
 | 
			
		||||
            <span>{{ plan.description || '无描述' }}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
          
 | 
			
		||||
          <div class="info-item">
 | 
			
		||||
            <label>计划类型:</label>
 | 
			
		||||
            <span>{{ plan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
          
 | 
			
		||||
          <div v-if="plan.schedule_cron" class="info-item">
 | 
			
		||||
            <label>定时表达式:</label>
 | 
			
		||||
            <span>{{ plan.schedule_cron }}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
          
 | 
			
		||||
          <div class="info-item">
 | 
			
		||||
            <label>执行次数限制:</label>
 | 
			
		||||
            <span>{{ plan.execution_limit > 0 ? plan.execution_limit : '无限制' }}</span>
 | 
			
		||||
          </div>
 | 
			
		||||
          
 | 
			
		||||
          <!-- 移除主计划中的父计划ID和顺序显示 -->
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div class="plan-steps">
 | 
			
		||||
          <h3>计划步骤</h3>
 | 
			
		||||
          <div v-if="plan.steps && plan.steps.length > 0" class="steps-list">
 | 
			
		||||
            <div 
 | 
			
		||||
              v-for="(step, index) in plan.steps" 
 | 
			
		||||
              :key="step.id" 
 | 
			
		||||
              class="step-item"
 | 
			
		||||
            >
 | 
			
		||||
              <div class="step-header">
 | 
			
		||||
                <span class="step-number">步骤 {{ index + 1 }}</span>
 | 
			
		||||
                <span v-if="step.schedule_cron" class="step-cron">定时: {{ step.schedule_cron }}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
              
 | 
			
		||||
              <div class="step-details">
 | 
			
		||||
                <div class="detail-item">
 | 
			
		||||
                  <label>设备ID:</label>
 | 
			
		||||
                  <span>{{ step.device_id }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                
 | 
			
		||||
                <div class="detail-item">
 | 
			
		||||
                  <label>目标值:</label>
 | 
			
		||||
                  <span>{{ step.target_value }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                
 | 
			
		||||
                <div class="detail-item">
 | 
			
		||||
                  <label>动作:</label>
 | 
			
		||||
                  <span>{{ step.action }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                
 | 
			
		||||
                <div class="detail-item">
 | 
			
		||||
                  <label>执行次数限制:</label>
 | 
			
		||||
                  <span>{{ step.execution_limit > 0 ? step.execution_limit : '无限制' }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div v-else class="no-steps">
 | 
			
		||||
            该计划暂无步骤
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div v-if="plan.sub_plans && plan.sub_plans.length > 0" class="sub-plans">
 | 
			
		||||
          <h3>子计划</h3>
 | 
			
		||||
          <div class="sub-plans-list">
 | 
			
		||||
            <div 
 | 
			
		||||
              v-for="subPlan in plan.sub_plans" 
 | 
			
		||||
              :key="subPlan.id" 
 | 
			
		||||
              class="sub-plan-item"
 | 
			
		||||
            >
 | 
			
		||||
              <div class="sub-plan-header">
 | 
			
		||||
                <h4>{{ subPlan.name }}</h4>
 | 
			
		||||
                <span :class="['plan-status', { 'enabled': subPlan.enabled, 'disabled': !subPlan.enabled }]">
 | 
			
		||||
                  {{ subPlan.enabled ? '已启用' : '已禁用' }}
 | 
			
		||||
                </span>
 | 
			
		||||
              </div>
 | 
			
		||||
              
 | 
			
		||||
              <div class="sub-plan-info">
 | 
			
		||||
                <div class="info-item">
 | 
			
		||||
                  <label>描述:</label>
 | 
			
		||||
                  <span>{{ subPlan.description || '无描述' }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                
 | 
			
		||||
                <div class="info-item">
 | 
			
		||||
                  <label>类型:</label>
 | 
			
		||||
                  <span>{{ subPlan.type === 'manual' ? '手动触发' : '自动触发' }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                
 | 
			
		||||
                <div v-if="subPlan.schedule_cron" class="info-item">
 | 
			
		||||
                  <label>定时表达式:</label>
 | 
			
		||||
                  <span>{{ subPlan.schedule_cron }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                
 | 
			
		||||
                <div class="info-item">
 | 
			
		||||
                  <label>顺序:</label>
 | 
			
		||||
                  <span>{{ (subPlan.order_in_parent || 0) + 1 }}</span>
 | 
			
		||||
                </div>
 | 
			
		||||
                
 | 
			
		||||
                <div v-if="subPlan.parent_id" class="info-item">
 | 
			
		||||
                  <label>父计划:</label>
 | 
			
		||||
                  <span>{{ getParentPlanName(subPlan.parent_id) }}(id:{{ subPlan.parent_id }})</span>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              
 | 
			
		||||
              <div class="sub-plan-steps">
 | 
			
		||||
                <h5>子计划步骤</h5>
 | 
			
		||||
                <div v-if="subPlan.steps && subPlan.steps.length > 0" class="steps-list">
 | 
			
		||||
                  <div 
 | 
			
		||||
                    v-for="(step, index) in subPlan.steps" 
 | 
			
		||||
                    :key="step.id" 
 | 
			
		||||
                    class="step-item"
 | 
			
		||||
                  >
 | 
			
		||||
                    <div class="step-header">
 | 
			
		||||
                      <span class="step-number">步骤 {{ index + 1 }}</span>
 | 
			
		||||
                      <span v-if="step.schedule_cron" class="step-cron">定时: {{ step.schedule_cron }}</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    
 | 
			
		||||
                    <div class="step-details">
 | 
			
		||||
                      <div class="detail-item">
 | 
			
		||||
                        <label>设备ID:</label>
 | 
			
		||||
                        <span>{{ step.device_id }}</span>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      
 | 
			
		||||
                      <div class="detail-item">
 | 
			
		||||
                        <label>目标值:</label>
 | 
			
		||||
                        <span>{{ step.target_value }}</span>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      
 | 
			
		||||
                      <div class="detail-item">
 | 
			
		||||
                        <label>动作:</label>
 | 
			
		||||
                        <span>{{ step.action }}</span>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      
 | 
			
		||||
                      <div class="detail-item">
 | 
			
		||||
                        <label>执行次数限制:</label>
 | 
			
		||||
                        <span>{{ step.execution_limit > 0 ? step.execution_limit : '无限制' }}</span>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div v-else class="no-steps">
 | 
			
		||||
                  该子计划暂无步骤
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div class="actions">
 | 
			
		||||
          <button class="btn btn-secondary" @click="goBack">返回列表</button>
 | 
			
		||||
          <button class="btn btn-primary" @click="editPlan">编辑计划</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </main>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'FeedPlanDetail',
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      username: '',
 | 
			
		||||
      plan: null,
 | 
			
		||||
      loading: true,
 | 
			
		||||
      error: null
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  mounted() {
 | 
			
		||||
    this.username = localStorage.getItem('username') || '管理员'
 | 
			
		||||
    this.loadPlanDetail()
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  methods: {
 | 
			
		||||
    // 加载计划详情
 | 
			
		||||
    async loadPlanDetail() {
 | 
			
		||||
      this.loading = true
 | 
			
		||||
      this.error = null
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        const planId = this.$route.query.id || this.$route.params.id
 | 
			
		||||
        if (!planId) {
 | 
			
		||||
          this.error = '无效的计划ID'
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const response = await fetch(`/api/v1/feed/plan/detail?id=${planId}`, {
 | 
			
		||||
          method: 'GET',
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Content-Type': 'application/json',
 | 
			
		||||
            'Authorization': 'Bearer ' + localStorage.getItem('authToken')
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        
 | 
			
		||||
        const data = await response.json()
 | 
			
		||||
        
 | 
			
		||||
        if (response.ok && data.code === 0) {
 | 
			
		||||
          this.plan = data.data
 | 
			
		||||
          // 加载子计划的详细信息(包括步骤)
 | 
			
		||||
          await this.loadSubPlanDetails()
 | 
			
		||||
        } else {
 | 
			
		||||
          this.error = data.message || '获取计划详情失败'
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('获取计划详情失败:', error)
 | 
			
		||||
        this.error = '获取计划详情失败: ' + error.message
 | 
			
		||||
      } finally {
 | 
			
		||||
        this.loading = false
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 加载子计划详情
 | 
			
		||||
    async loadSubPlanDetails() {
 | 
			
		||||
      if (!this.plan || !this.plan.sub_plans || this.plan.sub_plans.length === 0) {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // 遍历所有子计划
 | 
			
		||||
      for (let i = 0; i < this.plan.sub_plans.length; i++) {
 | 
			
		||||
        const subPlan = this.plan.sub_plans[i]
 | 
			
		||||
        // 如果子计划没有步骤或步骤为空,则加载详细信息
 | 
			
		||||
        if (!subPlan.steps || subPlan.steps.length === 0) {
 | 
			
		||||
          try {
 | 
			
		||||
            const response = await fetch(`/api/v1/feed/plan/detail?id=${subPlan.id}`, {
 | 
			
		||||
              method: 'GET',
 | 
			
		||||
              headers: {
 | 
			
		||||
                'Content-Type': 'application/json',
 | 
			
		||||
                'Authorization': 'Bearer ' + localStorage.getItem('authToken')
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
            
 | 
			
		||||
            const data = await response.json()
 | 
			
		||||
            
 | 
			
		||||
            if (response.ok && data.code === 0) {
 | 
			
		||||
              // 用详细信息替换原来的简略信息
 | 
			
		||||
              this.plan.sub_plans[i] = data.data
 | 
			
		||||
            }
 | 
			
		||||
          } catch (error) {
 | 
			
		||||
            console.error(`获取子计划 ${subPlan.id} 详情失败:`, error)
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 返回列表
 | 
			
		||||
    goBack() {
 | 
			
		||||
      this.$router.push('/feed/plan')
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 获取父计划名称
 | 
			
		||||
    getParentPlanName(parentId) {
 | 
			
		||||
      // 如果父计划就是当前主计划
 | 
			
		||||
      if (this.plan && this.plan.id === parentId) {
 | 
			
		||||
        return this.plan.name
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // 检查是否在子计划中
 | 
			
		||||
      if (this.plan && this.plan.sub_plans) {
 | 
			
		||||
        const parentPlan = this.plan.sub_plans.find(plan => plan.id === parentId)
 | 
			
		||||
        if (parentPlan) {
 | 
			
		||||
          return parentPlan.name
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // 默认返回"未知父计划"
 | 
			
		||||
      return '未知父计划'
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 编辑计划
 | 
			
		||||
    editPlan() {
 | 
			
		||||
      // TODO: 实现编辑计划逻辑
 | 
			
		||||
      alert('编辑计划功能待实现')
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    // 退出登录
 | 
			
		||||
    logout() {
 | 
			
		||||
      localStorage.removeItem('authToken')
 | 
			
		||||
      localStorage.removeItem('username')
 | 
			
		||||
      this.$router.push('/')
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.feed-plan-detail {
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin-bottom: 30px;
 | 
			
		||||
  padding-bottom: 20px;
 | 
			
		||||
  border-bottom: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.header h1 {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-info {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.logout-btn {
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
  background-color: #dc3545;
 | 
			
		||||
  color: white;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.logout-btn:hover {
 | 
			
		||||
  background-color: #c82333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav {
 | 
			
		||||
  background-color: #343a40;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav ul {
 | 
			
		||||
  list-style-type: none;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav li {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a {
 | 
			
		||||
  display: block;
 | 
			
		||||
  padding: 15px 20px;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a:hover {
 | 
			
		||||
  background-color: #495057;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav a.active {
 | 
			
		||||
  background-color: #007bff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.loading, .error {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  padding: 50px;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.loading {
 | 
			
		||||
  color: #666;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error {
 | 
			
		||||
  color: #dc3545;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-detail-container {
 | 
			
		||||
  background-color: white;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  padding: 30px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin-bottom: 30px;
 | 
			
		||||
  padding-bottom: 20px;
 | 
			
		||||
  border-bottom: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-header h2 {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-status {
 | 
			
		||||
  padding: 6px 12px;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-status.enabled {
 | 
			
		||||
  background-color: #d4edda;
 | 
			
		||||
  color: #155724;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-status.disabled {
 | 
			
		||||
  background-color: #f8d7da;
 | 
			
		||||
  color: #721c24;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-info {
 | 
			
		||||
  margin-bottom: 30px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.info-item {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  margin-bottom: 15px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.info-item label {
 | 
			
		||||
  width: 150px;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.info-item span {
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  color: #666;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-steps, .sub-plans {
 | 
			
		||||
  margin-bottom: 30px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.plan-steps h3, .sub-plans h3 {
 | 
			
		||||
  margin-top: 0;
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
  color: #333;
 | 
			
		||||
  padding-bottom: 10px;
 | 
			
		||||
  border-bottom: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.steps-list {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  gap: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.step-item {
 | 
			
		||||
  border: 1px solid #ddd;
 | 
			
		||||
  border-radius: 6px;
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
  background-color: #f8f9fa;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.step-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin-bottom: 15px;
 | 
			
		||||
  padding-bottom: 10px;
 | 
			
		||||
  border-bottom: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.step-number {
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  color: #007bff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.step-cron {
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  color: #666;
 | 
			
		||||
  background-color: #e9ecef;
 | 
			
		||||
  padding: 2px 6px;
 | 
			
		||||
  border-radius: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.step-details {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
 | 
			
		||||
  gap: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detail-item {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detail-item label {
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  color: #666;
 | 
			
		||||
  margin-bottom: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.detail-item span {
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.no-steps {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  padding: 30px;
 | 
			
		||||
  color: #666;
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sub-plans-list {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  gap: 25px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sub-plan-item {
 | 
			
		||||
  border: 1px solid #ddd;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
  background-color: #fff;
 | 
			
		||||
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sub-plan-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin-bottom: 15px;
 | 
			
		||||
  padding-bottom: 10px;
 | 
			
		||||
  border-bottom: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sub-plan-header h4 {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sub-plan-info {
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sub-plan-info .info-item {
 | 
			
		||||
  margin-bottom: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sub-plan-steps h5 {
 | 
			
		||||
  margin-top: 0;
 | 
			
		||||
  margin-bottom: 15px;
 | 
			
		||||
  color: #333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.actions {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: flex-end;
 | 
			
		||||
  gap: 15px;
 | 
			
		||||
  padding-top: 20px;
 | 
			
		||||
  border-top: 1px solid #eee;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn {
 | 
			
		||||
  padding: 10px 20px;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  transition: background-color 0.3s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary {
 | 
			
		||||
  background-color: #007bff;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary:hover {
 | 
			
		||||
  background-color: #0069d9;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-secondary {
 | 
			
		||||
  background-color: #6c757d;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-secondary:hover {
 | 
			
		||||
  background-color: #5a6268;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  .header {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    gap: 15px;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .nav ul {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .info-item {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
    gap: 5px;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .info-item label {
 | 
			
		||||
    width: auto;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .step-details {
 | 
			
		||||
    grid-template-columns: 1fr;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .actions {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
 | 
			
		||||
import Login from '../pages/Login.vue'
 | 
			
		||||
import Dashboard from '../pages/Dashboard.vue'
 | 
			
		||||
import Device from '../pages/Device.vue'
 | 
			
		||||
import FeedPlan from '../pages/FeedPlan.vue'
 | 
			
		||||
import FeedPlanDetail from '../pages/FeedPlanDetail.vue'
 | 
			
		||||
 | 
			
		||||
const routes = [
 | 
			
		||||
  {
 | 
			
		||||
@@ -20,6 +22,19 @@ const routes = [
 | 
			
		||||
    name: 'Device',
 | 
			
		||||
    component: Device,
 | 
			
		||||
    meta: { requiresAuth: true }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: '/feed/plan',
 | 
			
		||||
    name: 'FeedPlan',
 | 
			
		||||
    component: FeedPlan,
 | 
			
		||||
    meta: { requiresAuth: true }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: '/feed/plan/detail/:id',
 | 
			
		||||
    name: 'FeedPlanDetail',
 | 
			
		||||
    component: FeedPlanDetail,
 | 
			
		||||
    meta: { requiresAuth: true },
 | 
			
		||||
    props: true
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,15 @@
 | 
			
		||||
import { defineConfig } from 'vite'
 | 
			
		||||
import vue from '@vitejs/plugin-vue'
 | 
			
		||||
import { resolve } from 'path'
 | 
			
		||||
 | 
			
		||||
// https://vitejs.dev/config/
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  plugins: [vue()],
 | 
			
		||||
  resolve: {
 | 
			
		||||
    alias: {
 | 
			
		||||
      '@': resolve(__dirname, 'src')
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  server: {
 | 
			
		||||
    port: 3000,
 | 
			
		||||
    proxy: {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.mod
									
									
									
									
									
								
							@@ -7,6 +7,7 @@ require (
 | 
			
		||||
	github.com/golang-jwt/jwt/v5 v5.0.0
 | 
			
		||||
	github.com/gorilla/websocket v1.5.0
 | 
			
		||||
	github.com/panjf2000/ants/v2 v2.11.3
 | 
			
		||||
	github.com/stretchr/testify v1.10.0
 | 
			
		||||
	golang.org/x/crypto v0.17.0
 | 
			
		||||
	gopkg.in/yaml.v2 v2.4.0
 | 
			
		||||
	gorm.io/driver/postgres v1.5.9
 | 
			
		||||
@@ -16,6 +17,7 @@ require (
 | 
			
		||||
require (
 | 
			
		||||
	github.com/bytedance/sonic v1.9.1 // indirect
 | 
			
		||||
	github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
 | 
			
		||||
	github.com/davecgh/go-spew v1.1.1 // indirect
 | 
			
		||||
	github.com/gabriel-vasile/mimetype v1.4.2 // indirect
 | 
			
		||||
	github.com/gin-contrib/sse v0.1.0 // indirect
 | 
			
		||||
	github.com/go-playground/locales v0.14.1 // indirect
 | 
			
		||||
@@ -36,7 +38,9 @@ require (
 | 
			
		||||
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 | 
			
		||||
	github.com/modern-go/reflect2 v1.0.2 // indirect
 | 
			
		||||
	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
 | 
			
		||||
	github.com/pmezard/go-difflib v1.0.0 // indirect
 | 
			
		||||
	github.com/rogpeppe/go-internal v1.14.1 // indirect
 | 
			
		||||
	github.com/stretchr/objx v0.5.2 // indirect
 | 
			
		||||
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 | 
			
		||||
	github.com/ugorji/go/codec v1.2.11 // indirect
 | 
			
		||||
	golang.org/x/arch v0.3.0 // indirect
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.sum
									
									
									
									
									
								
							@@ -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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 | 
			
		||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 | 
			
		||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 | 
			
		||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 | 
			
		||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 | 
			
		||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ import (
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/api/middleware"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/config"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/controller/device"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/controller/feed"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/controller/operation"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/controller/remote"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/controller/user"
 | 
			
		||||
@@ -44,6 +45,9 @@ type API struct {
 | 
			
		||||
	// deviceController 设备控制控制器
 | 
			
		||||
	deviceController *device.Controller
 | 
			
		||||
 | 
			
		||||
	// feedController 饲喂管理控制器
 | 
			
		||||
	feedController *feed.Controller
 | 
			
		||||
 | 
			
		||||
	// remoteController 远程控制控制器
 | 
			
		||||
	remoteController *remote.Controller
 | 
			
		||||
 | 
			
		||||
@@ -65,7 +69,16 @@ type API struct {
 | 
			
		||||
 | 
			
		||||
// NewAPI 创建并返回一个新的API实例
 | 
			
		||||
// 初始化Gin引擎和相关配置
 | 
			
		||||
func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRepo repository.OperationHistoryRepo, deviceControlRepo repository.DeviceControlRepo, deviceRepo repository.DeviceRepo, 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.SetMode(gin.DebugMode)
 | 
			
		||||
 | 
			
		||||
@@ -98,6 +111,9 @@ func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRe
 | 
			
		||||
	// 创建设备控制控制器
 | 
			
		||||
	deviceController := device.NewController(deviceControlRepo, deviceRepo, websocketManager, heartbeatService, deviceStatusPool)
 | 
			
		||||
 | 
			
		||||
	// 创建饲喂管理控制器
 | 
			
		||||
	feedController := feed.NewController(feedRepo)
 | 
			
		||||
 | 
			
		||||
	// 创建远程控制控制器
 | 
			
		||||
	remoteController := remote.NewController(websocketManager)
 | 
			
		||||
 | 
			
		||||
@@ -110,6 +126,7 @@ func NewAPI(cfg *config.Config, userRepo repository.UserRepo, operationHistoryRe
 | 
			
		||||
		userController:      userController,
 | 
			
		||||
		operationController: operationController,
 | 
			
		||||
		deviceController:    deviceController,
 | 
			
		||||
		feedController:      feedController,
 | 
			
		||||
		remoteController:    remoteController,
 | 
			
		||||
		authMiddleware:      authMiddleware,
 | 
			
		||||
		websocketManager:    websocketManager,
 | 
			
		||||
@@ -222,6 +239,16 @@ func (a *API) setupRoutes() {
 | 
			
		||||
			deviceGroup.GET("/status", a.deviceController.GetDeviceStatus)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 饲喂相关路由
 | 
			
		||||
		feedGroup := protectedGroup.Group("/feed")
 | 
			
		||||
		{
 | 
			
		||||
			feedGroup.GET("/plan/list", a.feedController.ListPlans)
 | 
			
		||||
			feedGroup.GET("/plan/detail", a.feedController.Detail)
 | 
			
		||||
			feedGroup.POST("/plan/create", a.feedController.Create)
 | 
			
		||||
			feedGroup.POST("/plan/update", a.feedController.Update)
 | 
			
		||||
			feedGroup.POST("/plan/delete", a.feedController.Delete)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 远程控制相关路由
 | 
			
		||||
		remoteGroup := protectedGroup.Group("/remote")
 | 
			
		||||
		{
 | 
			
		||||
 
 | 
			
		||||
@@ -3,14 +3,471 @@
 | 
			
		||||
// 通过任务执行器执行具体控制任务
 | 
			
		||||
package feed
 | 
			
		||||
 | 
			
		||||
// FeedController 饲料控制器
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strconv"
 | 
			
		||||
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/controller"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/logs"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/model"
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/storage/repository"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Controller 饲料控制器
 | 
			
		||||
// 管理饲料制备和分配设备的控制逻辑
 | 
			
		||||
type FeedController struct {
 | 
			
		||||
	// TODO: 定义饲料控制器结构
 | 
			
		||||
type Controller struct {
 | 
			
		||||
	feedPlanRepo repository.FeedPlanRepo
 | 
			
		||||
	logger       *logs.Logger
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewFeedController 创建并返回一个新的饲料控制器实例
 | 
			
		||||
func NewFeedController() *FeedController {
 | 
			
		||||
// NewController 创建并返回一个新的饲料控制器实例
 | 
			
		||||
func NewController(feedPlanRepo repository.FeedPlanRepo) *Controller {
 | 
			
		||||
	// TODO: 实现饲料控制器初始化
 | 
			
		||||
	return &Controller{
 | 
			
		||||
		feedPlanRepo: feedPlanRepo,
 | 
			
		||||
		logger:       logs.NewLogger(),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CreateRequest 创建计划请求结构体
 | 
			
		||||
type CreateRequest struct {
 | 
			
		||||
	// Name 计划名称
 | 
			
		||||
	Name string `json:"name"`
 | 
			
		||||
 | 
			
		||||
	// Description 计划描述
 | 
			
		||||
	Description string `json:"description"`
 | 
			
		||||
 | 
			
		||||
	// Type 计划类型(手动触发/自动触发)
 | 
			
		||||
	Type model.FeedingPlanType `json:"type"`
 | 
			
		||||
 | 
			
		||||
	// Enabled 是否启用
 | 
			
		||||
	Enabled bool `json:"enabled"`
 | 
			
		||||
 | 
			
		||||
	// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
 | 
			
		||||
	ScheduleCron *string `json:"schedule_cron,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// ExecutionLimit 执行次数限制(0表示无限制,仅当Type为auto时有效)
 | 
			
		||||
	ExecutionLimit int `json:"execution_limit"`
 | 
			
		||||
 | 
			
		||||
	// ParentID 父计划ID(用于支持子计划结构)
 | 
			
		||||
	ParentID *uint `json:"parent_id,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// OrderInParent 在父计划中的执行顺序
 | 
			
		||||
	OrderInParent *int `json:"order_in_parent,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// IsMaster 是否为主计划(主计划可以包含子计划)
 | 
			
		||||
	IsMaster bool `json:"is_master"`
 | 
			
		||||
 | 
			
		||||
	// Steps 计划步骤列表
 | 
			
		||||
	Steps []FeedingPlanStep `json:"steps"`
 | 
			
		||||
 | 
			
		||||
	// SubPlans 子计划列表
 | 
			
		||||
	SubPlans []CreateRequest `json:"sub_plans"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create 创建饲料计划
 | 
			
		||||
func (c *Controller) Create(ctx *gin.Context) {
 | 
			
		||||
	var req CreateRequest
 | 
			
		||||
	if err := ctx.ShouldBindJSON(&req); err != nil {
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 校验计划结构
 | 
			
		||||
	if err := c.validatePlanStructure(&req); err != nil {
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "计划结构错误: "+err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换请求结构体为模型
 | 
			
		||||
	plan := c.convertToCreateModel(&req)
 | 
			
		||||
 | 
			
		||||
	// 调用仓库创建计划
 | 
			
		||||
	if err := c.feedPlanRepo.CreateFeedingPlan(plan); err != nil {
 | 
			
		||||
		c.logger.Error("创建计划失败: " + err.Error())
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "创建计划失败")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	controller.SendSuccessResponse(ctx, "创建计划成功", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validatePlanStructure 校验计划结构,不允许计划同时包含步骤和子计划
 | 
			
		||||
func (c *Controller) validatePlanStructure(req *CreateRequest) error {
 | 
			
		||||
	// 检查当前计划是否同时包含步骤和子计划
 | 
			
		||||
	if len(req.Steps) > 0 && len(req.SubPlans) > 0 {
 | 
			
		||||
		return fmt.Errorf("计划不能同时包含步骤和子计划")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 递归检查子计划
 | 
			
		||||
	for _, subPlan := range req.SubPlans {
 | 
			
		||||
		if err := c.validatePlanStructure(&subPlan); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// convertToCreateModel 将创建请求结构体转换为数据库模型
 | 
			
		||||
func (c *Controller) convertToCreateModel(req *CreateRequest) *model.FeedingPlan {
 | 
			
		||||
	plan := &model.FeedingPlan{
 | 
			
		||||
		Name:           req.Name,
 | 
			
		||||
		Description:    req.Description,
 | 
			
		||||
		Type:           req.Type,
 | 
			
		||||
		Enabled:        req.Enabled,
 | 
			
		||||
		ScheduleCron:   req.ScheduleCron,
 | 
			
		||||
		ExecutionLimit: req.ExecutionLimit,
 | 
			
		||||
		ParentID:       req.ParentID,
 | 
			
		||||
		OrderInParent:  req.OrderInParent,
 | 
			
		||||
		// 不需要显式设置ID字段,仓库层会处理
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换步骤
 | 
			
		||||
	plan.Steps = make([]model.FeedingPlanStep, len(req.Steps))
 | 
			
		||||
	for i, step := range req.Steps {
 | 
			
		||||
		plan.Steps[i] = model.FeedingPlanStep{
 | 
			
		||||
			// ID在创建时不需要设置
 | 
			
		||||
			// PlanID会在创建过程中自动设置
 | 
			
		||||
			StepOrder:      step.StepOrder,
 | 
			
		||||
			DeviceID:       step.DeviceID,
 | 
			
		||||
			TargetValue:    step.TargetValue,
 | 
			
		||||
			Action:         step.Action,
 | 
			
		||||
			ScheduleCron:   step.ScheduleCron,
 | 
			
		||||
			ExecutionLimit: step.ExecutionLimit,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换子计划
 | 
			
		||||
	plan.SubPlans = make([]model.FeedingPlan, len(req.SubPlans))
 | 
			
		||||
	for i, subReq := range req.SubPlans {
 | 
			
		||||
		plan.SubPlans[i] = *c.convertToCreateModel(&subReq)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return plan
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Delete 删除饲料计划
 | 
			
		||||
func (c *Controller) Delete(ctx *gin.Context) {
 | 
			
		||||
	// 获取路径参数中的计划ID
 | 
			
		||||
	var req struct {
 | 
			
		||||
		ID uint `json:"id" binding:"required"`
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := ctx.ShouldBindJSON(&req); err != nil {
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 调用仓库删除计划
 | 
			
		||||
	if err := c.feedPlanRepo.DeleteFeedingPlan(uint(req.ID)); err != nil {
 | 
			
		||||
		c.logger.Error("删除计划失败: " + err.Error())
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "删除计划失败")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	controller.SendSuccessResponse(ctx, "删除计划成功", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ListPlansResponse struct {
 | 
			
		||||
	Plans []ListPlanResponseItem `json:"plans"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ListPlanResponseItem struct {
 | 
			
		||||
	// ID 计划ID
 | 
			
		||||
	ID uint `json:"id"`
 | 
			
		||||
 | 
			
		||||
	// Name 计划名称
 | 
			
		||||
	Name string `json:"name"`
 | 
			
		||||
 | 
			
		||||
	// Description 计划描述
 | 
			
		||||
	Description string `json:"description"`
 | 
			
		||||
 | 
			
		||||
	// Type 计划类型
 | 
			
		||||
	Type model.FeedingPlanType `json:"type"`
 | 
			
		||||
 | 
			
		||||
	// Enabled 是否启用
 | 
			
		||||
	Enabled bool `json:"enabled"`
 | 
			
		||||
 | 
			
		||||
	// ScheduleCron 定时任务表达式
 | 
			
		||||
	ScheduleCron *string `json:"schedule_cron,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListPlans 获取饲料计划列表
 | 
			
		||||
func (c *Controller) ListPlans(ctx *gin.Context) {
 | 
			
		||||
 | 
			
		||||
	introductions, err := c.feedPlanRepo.ListAllPlanIntroduction()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.logger.Error("获取设备列表失败: " + err.Error())
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "获取计划列表失败")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	resp := ListPlansResponse{
 | 
			
		||||
		Plans: []ListPlanResponseItem{},
 | 
			
		||||
	}
 | 
			
		||||
	for _, introduction := range introductions {
 | 
			
		||||
		resp.Plans = append(resp.Plans, ListPlanResponseItem{
 | 
			
		||||
			ID:           introduction.ID,
 | 
			
		||||
			Name:         introduction.Name,
 | 
			
		||||
			Description:  introduction.Description,
 | 
			
		||||
			Enabled:      introduction.Enabled,
 | 
			
		||||
			Type:         introduction.Type,
 | 
			
		||||
			ScheduleCron: introduction.ScheduleCron,
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	controller.SendSuccessResponse(ctx, "success", resp)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdateRequest 更新计划请求结构体
 | 
			
		||||
type UpdateRequest struct {
 | 
			
		||||
	// ID 计划ID
 | 
			
		||||
	ID uint `json:"id"`
 | 
			
		||||
 | 
			
		||||
	// Name 计划名称
 | 
			
		||||
	Name string `json:"name"`
 | 
			
		||||
 | 
			
		||||
	// Description 计划描述
 | 
			
		||||
	Description string `json:"description"`
 | 
			
		||||
 | 
			
		||||
	// Type 计划类型(手动触发/自动触发)
 | 
			
		||||
	Type model.FeedingPlanType `json:"type"`
 | 
			
		||||
 | 
			
		||||
	// Enabled 是否启用
 | 
			
		||||
	Enabled bool `json:"enabled"`
 | 
			
		||||
 | 
			
		||||
	// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
 | 
			
		||||
	ScheduleCron *string `json:"schedule_cron,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// ExecutionLimit 执行次数限制(0表示无限制,仅当Type为auto时有效)
 | 
			
		||||
	ExecutionLimit int `json:"execution_limit"`
 | 
			
		||||
 | 
			
		||||
	// ParentID 父计划ID(用于支持子计划结构)
 | 
			
		||||
	ParentID *uint `json:"parent_id,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// OrderInParent 在父计划中的执行顺序
 | 
			
		||||
	OrderInParent *int `json:"order_in_parent,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// IsMaster 是否为主计划(主计划可以包含子计划)
 | 
			
		||||
	IsMaster bool `json:"is_master"`
 | 
			
		||||
 | 
			
		||||
	// Steps 计划步骤列表
 | 
			
		||||
	Steps []FeedingPlanStep `json:"steps"`
 | 
			
		||||
 | 
			
		||||
	// SubPlans 子计划列表
 | 
			
		||||
	SubPlans []UpdateRequest `json:"sub_plans"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DetailResponse 喂料计划主表
 | 
			
		||||
type DetailResponse struct {
 | 
			
		||||
	// ID 计划ID
 | 
			
		||||
	ID uint `json:"id"`
 | 
			
		||||
 | 
			
		||||
	// Name 计划名称
 | 
			
		||||
	Name string `json:"name"`
 | 
			
		||||
 | 
			
		||||
	// Description 计划描述
 | 
			
		||||
	Description string `json:"description"`
 | 
			
		||||
 | 
			
		||||
	// Type 计划类型(手动触发/自动触发)
 | 
			
		||||
	Type model.FeedingPlanType `json:"type"`
 | 
			
		||||
 | 
			
		||||
	// Enabled 是否启用
 | 
			
		||||
	Enabled bool `json:"enabled"`
 | 
			
		||||
 | 
			
		||||
	// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
 | 
			
		||||
	ScheduleCron *string `json:"schedule_cron,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// ExecutionLimit 执行次数限制(0表示无限制,仅当Type为auto时有效)
 | 
			
		||||
	ExecutionLimit int `json:"execution_limit"`
 | 
			
		||||
 | 
			
		||||
	// ParentID 父计划ID(用于支持子计划结构)
 | 
			
		||||
	ParentID *uint `json:"parent_id,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// OrderInParent 在父计划中的执行顺序
 | 
			
		||||
	OrderInParent *int `json:"order_in_parent,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// Steps 计划步骤列表
 | 
			
		||||
	Steps []FeedingPlanStep `json:"steps"`
 | 
			
		||||
 | 
			
		||||
	// SubPlans 子计划列表
 | 
			
		||||
	SubPlans []DetailResponse `json:"sub_plans"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FeedingPlanStep 喂料计划步骤表,表示计划中的每个设备动作
 | 
			
		||||
type FeedingPlanStep struct {
 | 
			
		||||
	// ID 步骤ID
 | 
			
		||||
	ID uint `json:"id"`
 | 
			
		||||
 | 
			
		||||
	// PlanID 关联的计划ID
 | 
			
		||||
	PlanID uint `json:"plan_id"`
 | 
			
		||||
 | 
			
		||||
	// StepOrder 步骤顺序
 | 
			
		||||
	StepOrder int `json:"step_order"`
 | 
			
		||||
 | 
			
		||||
	// DeviceID 关联的设备ID
 | 
			
		||||
	DeviceID uint `json:"device_id"`
 | 
			
		||||
 | 
			
		||||
	// TargetValue 目标值(达到该值后停止工作切换到下一个设备)
 | 
			
		||||
	TargetValue float64 `json:"target_value"`
 | 
			
		||||
 | 
			
		||||
	// Action 动作(如:打开设备)
 | 
			
		||||
	Action string `json:"action"`
 | 
			
		||||
 | 
			
		||||
	// ScheduleCron 步骤定时任务表达式(可选)
 | 
			
		||||
	ScheduleCron *string `json:"schedule_cron,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// ExecutionLimit 步骤执行次数限制(0表示无限制)
 | 
			
		||||
	ExecutionLimit int `json:"execution_limit"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Detail 获取饲料计划列细节
 | 
			
		||||
func (c *Controller) Detail(ctx *gin.Context) {
 | 
			
		||||
	// 获取查询参数中的计划ID
 | 
			
		||||
	planIDStr := ctx.Query("id")
 | 
			
		||||
	if planIDStr == "" {
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "缺少计划ID参数")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	planID, err := strconv.ParseUint(planIDStr, 10, 32)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "无效的计划ID")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 从仓库中获取计划详情
 | 
			
		||||
	plan, err := c.feedPlanRepo.FindFeedingPlanByID(uint(planID))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.logger.Error("获取计划详情失败: " + err.Error())
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "获取计划详情失败")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换为响应结构体
 | 
			
		||||
	resp := c.convertToDetailResponse(plan)
 | 
			
		||||
 | 
			
		||||
	controller.SendSuccessResponse(ctx, "success", resp)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Update 更新饲料计划
 | 
			
		||||
func (c *Controller) Update(ctx *gin.Context) {
 | 
			
		||||
	var req UpdateRequest
 | 
			
		||||
	if err := ctx.ShouldBindJSON(&req); err != nil {
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "请求参数错误: "+err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 校验计划结构
 | 
			
		||||
	if err := c.validateUpdatePlanStructure(&req); err != nil {
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InvalidParameterCode, "计划结构错误: "+err.Error())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换请求结构体为模型
 | 
			
		||||
	plan := c.convertToUpdateModel(&req)
 | 
			
		||||
 | 
			
		||||
	// 调用仓库更新计划
 | 
			
		||||
	if err := c.feedPlanRepo.UpdateFeedingPlan(plan); err != nil {
 | 
			
		||||
		c.logger.Error("更新计划失败: " + err.Error())
 | 
			
		||||
		controller.SendErrorResponse(ctx, controller.InternalServerErrorCode, "更新计划失败")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	controller.SendSuccessResponse(ctx, "更新计划成功", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validateUpdatePlanStructure 校验更新计划结构,不允许计划同时包含步骤和子计划
 | 
			
		||||
func (c *Controller) validateUpdatePlanStructure(req *UpdateRequest) error {
 | 
			
		||||
	// 检查当前计划是否同时包含步骤和子计划
 | 
			
		||||
	if len(req.Steps) > 0 && len(req.SubPlans) > 0 {
 | 
			
		||||
		return fmt.Errorf("计划不能同时包含步骤和子计划")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 递归检查子计划
 | 
			
		||||
	for _, subPlan := range req.SubPlans {
 | 
			
		||||
		if err := c.validateUpdatePlanStructure(&subPlan); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// convertToUpdateModel 将更新请求结构体转换为数据库模型
 | 
			
		||||
func (c *Controller) convertToUpdateModel(req *UpdateRequest) *model.FeedingPlan {
 | 
			
		||||
	plan := &model.FeedingPlan{
 | 
			
		||||
		ID:             req.ID,
 | 
			
		||||
		Name:           req.Name,
 | 
			
		||||
		Description:    req.Description,
 | 
			
		||||
		Type:           req.Type,
 | 
			
		||||
		Enabled:        req.Enabled,
 | 
			
		||||
		ScheduleCron:   req.ScheduleCron,
 | 
			
		||||
		ExecutionLimit: req.ExecutionLimit,
 | 
			
		||||
		ParentID:       req.ParentID,
 | 
			
		||||
		OrderInParent:  req.OrderInParent,
 | 
			
		||||
		Steps:          make([]model.FeedingPlanStep, len(req.Steps)),
 | 
			
		||||
		SubPlans:       make([]model.FeedingPlan, len(req.SubPlans)),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换步骤
 | 
			
		||||
	for i, step := range req.Steps {
 | 
			
		||||
		plan.Steps[i] = model.FeedingPlanStep{
 | 
			
		||||
			ID:             step.ID,
 | 
			
		||||
			PlanID:         step.PlanID,
 | 
			
		||||
			StepOrder:      step.StepOrder,
 | 
			
		||||
			DeviceID:       step.DeviceID,
 | 
			
		||||
			TargetValue:    step.TargetValue,
 | 
			
		||||
			Action:         step.Action,
 | 
			
		||||
			ScheduleCron:   step.ScheduleCron,
 | 
			
		||||
			ExecutionLimit: step.ExecutionLimit,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换子计划
 | 
			
		||||
	for i, subReq := range req.SubPlans {
 | 
			
		||||
		plan.SubPlans[i] = *c.convertToUpdateModel(&subReq)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return plan
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// convertToDetailResponse 将数据库模型转换为响应结构体
 | 
			
		||||
func (c *Controller) convertToDetailResponse(plan *model.FeedingPlan) *DetailResponse {
 | 
			
		||||
	resp := &DetailResponse{
 | 
			
		||||
		ID:             plan.ID,
 | 
			
		||||
		Name:           plan.Name,
 | 
			
		||||
		Description:    plan.Description,
 | 
			
		||||
		Type:           plan.Type,
 | 
			
		||||
		Enabled:        plan.Enabled,
 | 
			
		||||
		ScheduleCron:   plan.ScheduleCron,
 | 
			
		||||
		ExecutionLimit: plan.ExecutionLimit,
 | 
			
		||||
		ParentID:       plan.ParentID,
 | 
			
		||||
		OrderInParent:  plan.OrderInParent,
 | 
			
		||||
		Steps:          make([]FeedingPlanStep, len(plan.Steps)),
 | 
			
		||||
		SubPlans:       make([]DetailResponse, len(plan.SubPlans)),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换步骤
 | 
			
		||||
	for i, step := range plan.Steps {
 | 
			
		||||
		resp.Steps[i] = FeedingPlanStep{
 | 
			
		||||
			ID:             step.ID,
 | 
			
		||||
			PlanID:         step.PlanID,
 | 
			
		||||
			StepOrder:      step.StepOrder,
 | 
			
		||||
			DeviceID:       step.DeviceID,
 | 
			
		||||
			TargetValue:    step.TargetValue,
 | 
			
		||||
			Action:         step.Action,
 | 
			
		||||
			ScheduleCron:   step.ScheduleCron,
 | 
			
		||||
			ExecutionLimit: step.ExecutionLimit,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 转换子计划
 | 
			
		||||
	for i, subPlan := range plan.SubPlans {
 | 
			
		||||
		// 递归转换子计划
 | 
			
		||||
		resp.SubPlans[i] = *c.convertToDetailResponse(&subPlan)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return resp
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -40,6 +40,9 @@ type Application struct {
 | 
			
		||||
	// DeviceRepo 设备仓库实例
 | 
			
		||||
	DeviceRepo repository.DeviceRepo
 | 
			
		||||
 | 
			
		||||
	// FeedPlanRepo 投喂计划仓库实例
 | 
			
		||||
	FeedPlanRepo repository.FeedPlanRepo
 | 
			
		||||
 | 
			
		||||
	// WebsocketManager WebSocket管理器
 | 
			
		||||
	WebsocketManager *websocket.Manager
 | 
			
		||||
 | 
			
		||||
@@ -97,6 +100,8 @@ func (app *Application) Start() error {
 | 
			
		||||
	// 初始化设备仓库
 | 
			
		||||
	app.DeviceRepo = repository.NewDeviceRepo(app.Storage.GetDB())
 | 
			
		||||
 | 
			
		||||
	app.FeedPlanRepo = repository.NewFeedPlanRepo(app.Storage.GetDB())
 | 
			
		||||
 | 
			
		||||
	// 初始化设备状态池
 | 
			
		||||
	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)
 | 
			
		||||
 | 
			
		||||
	// 初始化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个工作协程)
 | 
			
		||||
	app.TaskExecutor = task.NewExecutor(5)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										193
									
								
								internal/model/feed.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								internal/model/feed.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,193 @@
 | 
			
		||||
package model
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"gorm.io/gorm"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// FeedingPlanType 喂料计划类型枚举
 | 
			
		||||
type FeedingPlanType string
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// FeedingPlanTypeManual 手动触发
 | 
			
		||||
	FeedingPlanTypeManual FeedingPlanType = "manual"
 | 
			
		||||
	// FeedingPlanTypeAuto 自动触发
 | 
			
		||||
	FeedingPlanTypeAuto FeedingPlanType = "auto"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// FeedingPlan 喂料计划主表
 | 
			
		||||
type FeedingPlan struct {
 | 
			
		||||
	// ID 计划ID
 | 
			
		||||
	ID uint `gorm:"primaryKey;column:id" json:"id"`
 | 
			
		||||
 | 
			
		||||
	// Name 计划名称
 | 
			
		||||
	Name string `gorm:"not null;column:name" json:"name"`
 | 
			
		||||
 | 
			
		||||
	// Description 计划描述
 | 
			
		||||
	Description string `gorm:"column:description" json:"description"`
 | 
			
		||||
 | 
			
		||||
	// Type 计划类型(手动触发/自动触发)
 | 
			
		||||
	Type FeedingPlanType `gorm:"not null;column:type" json:"type"`
 | 
			
		||||
 | 
			
		||||
	// Enabled 是否启用
 | 
			
		||||
	Enabled bool `gorm:"not null;default:true;column:enabled" json:"enabled"`
 | 
			
		||||
 | 
			
		||||
	// ScheduleCron 定时任务表达式(仅当Type为auto时有效)
 | 
			
		||||
	ScheduleCron *string `gorm:"column:schedule_cron" json:"schedule_cron,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// ExecutionLimit 执行次数限制(0表示无限制,仅当Type为auto时有效)
 | 
			
		||||
	ExecutionLimit int `gorm:"not null;default:0;column:execution_limit" json:"execution_limit"`
 | 
			
		||||
 | 
			
		||||
	// ParentID 父计划ID(用于支持子计划结构)
 | 
			
		||||
	ParentID *uint `gorm:"column:parent_id;index" json:"parent_id,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// OrderInParent 在父计划中的执行顺序
 | 
			
		||||
	OrderInParent *int `gorm:"column:order_in_parent" json:"order_in_parent,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// CreatedAt 创建时间
 | 
			
		||||
	CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
 | 
			
		||||
 | 
			
		||||
	// UpdatedAt 更新时间
 | 
			
		||||
	UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
 | 
			
		||||
 | 
			
		||||
	// DeletedAt 删除时间(用于软删除)
 | 
			
		||||
	DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
 | 
			
		||||
 | 
			
		||||
	// Steps 计划步骤列表
 | 
			
		||||
	Steps []FeedingPlanStep `gorm:"foreignKey:PlanID" json:"-"`
 | 
			
		||||
 | 
			
		||||
	// SubPlans 子计划列表
 | 
			
		||||
	SubPlans []FeedingPlan `gorm:"foreignKey:ParentID" json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TableName 指定FeedingPlan模型对应的数据库表名
 | 
			
		||||
func (FeedingPlan) TableName() string {
 | 
			
		||||
	return "feeding_plans"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FeedingPlanStep 喂料计划步骤表,表示计划中的每个设备动作
 | 
			
		||||
type FeedingPlanStep struct {
 | 
			
		||||
	// ID 步骤ID
 | 
			
		||||
	ID uint `gorm:"primaryKey;column:id" json:"id"`
 | 
			
		||||
 | 
			
		||||
	// PlanID 关联的计划ID
 | 
			
		||||
	PlanID uint `gorm:"not null;column:plan_id;index" json:"plan_id"`
 | 
			
		||||
 | 
			
		||||
	// StepOrder 步骤顺序
 | 
			
		||||
	StepOrder int `gorm:"not null;column:step_order" json:"step_order"`
 | 
			
		||||
 | 
			
		||||
	// DeviceID 关联的设备ID
 | 
			
		||||
	DeviceID uint `gorm:"not null;column:device_id;index" json:"device_id"`
 | 
			
		||||
 | 
			
		||||
	// TargetValue 目标值(达到该值后停止工作切换到下一个设备)
 | 
			
		||||
	TargetValue float64 `gorm:"not null;column:target_value" json:"target_value"`
 | 
			
		||||
 | 
			
		||||
	// Action 动作(如:打开设备)
 | 
			
		||||
	Action string `gorm:"not null;column:action" json:"action"`
 | 
			
		||||
 | 
			
		||||
	// ScheduleCron 步骤定时任务表达式(可选)
 | 
			
		||||
	ScheduleCron *string `gorm:"column:schedule_cron" json:"schedule_cron,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// ExecutionLimit 步骤执行次数限制(0表示无限制)
 | 
			
		||||
	ExecutionLimit int `gorm:"not null;default:0;column:execution_limit" json:"execution_limit"`
 | 
			
		||||
 | 
			
		||||
	// CreatedAt 创建时间
 | 
			
		||||
	CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
 | 
			
		||||
 | 
			
		||||
	// UpdatedAt 更新时间
 | 
			
		||||
	UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
 | 
			
		||||
 | 
			
		||||
	// DeletedAt 删除时间(用于软删除)
 | 
			
		||||
	DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TableName 指定FeedingPlanStep模型对应的数据库表名
 | 
			
		||||
func (FeedingPlanStep) TableName() string {
 | 
			
		||||
	return "feeding_plan_steps"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FeedingExecution 喂料执行记录表
 | 
			
		||||
type FeedingExecution struct {
 | 
			
		||||
	// ID 执行记录ID
 | 
			
		||||
	ID uint `gorm:"primaryKey;column:id" json:"id"`
 | 
			
		||||
 | 
			
		||||
	// PlanID 关联的计划ID
 | 
			
		||||
	PlanID uint `gorm:"not null;column:plan_id;index" json:"plan_id"`
 | 
			
		||||
 | 
			
		||||
	// MasterPlanID 主计划ID(如果是子计划执行,记录主计划ID)
 | 
			
		||||
	MasterPlanID *uint `gorm:"column:master_plan_id;index" json:"master_plan_id,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// TriggerType 触发类型(手动/自动)
 | 
			
		||||
	TriggerType FeedingPlanType `gorm:"not null;column:trigger_type" json:"trigger_type"`
 | 
			
		||||
 | 
			
		||||
	// Status 执行状态(进行中/已完成/已取消/失败)
 | 
			
		||||
	Status string `gorm:"not null;column:status" json:"status"`
 | 
			
		||||
 | 
			
		||||
	// StartedAt 开始执行时间
 | 
			
		||||
	StartedAt *time.Time `gorm:"column:started_at" json:"started_at,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// FinishedAt 完成时间
 | 
			
		||||
	FinishedAt *time.Time `gorm:"column:finished_at" json:"finished_at,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// CreatedAt 创建时间
 | 
			
		||||
	CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
 | 
			
		||||
 | 
			
		||||
	// UpdatedAt 更新时间
 | 
			
		||||
	UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
 | 
			
		||||
 | 
			
		||||
	// DeletedAt 删除时间(用于软删除)
 | 
			
		||||
	DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
 | 
			
		||||
 | 
			
		||||
	// Steps 执行步骤详情
 | 
			
		||||
	Steps []FeedingExecutionStep `gorm:"foreignKey:ExecutionID" json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TableName 指定FeedingExecution模型对应的数据库表名
 | 
			
		||||
func (FeedingExecution) TableName() string {
 | 
			
		||||
	return "feeding_executions"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FeedingExecutionStep 喂料执行步骤详情表
 | 
			
		||||
type FeedingExecutionStep struct {
 | 
			
		||||
	// ID 执行步骤ID
 | 
			
		||||
	ID uint `gorm:"primaryKey;column:id" json:"id"`
 | 
			
		||||
 | 
			
		||||
	// ExecutionID 关联的执行记录ID
 | 
			
		||||
	ExecutionID uint `gorm:"not null;column:execution_id;index" json:"execution_id"`
 | 
			
		||||
 | 
			
		||||
	// StepID 关联的计划步骤ID
 | 
			
		||||
	StepID uint `gorm:"not null;column:step_id;index" json:"step_id"`
 | 
			
		||||
 | 
			
		||||
	// DeviceID 关联的设备ID
 | 
			
		||||
	DeviceID uint `gorm:"not null;column:device_id;index" json:"device_id"`
 | 
			
		||||
 | 
			
		||||
	// TargetValue 目标值
 | 
			
		||||
	TargetValue float64 `gorm:"not null;column:target_value" json:"target_value"`
 | 
			
		||||
 | 
			
		||||
	// ActualValue 实际值
 | 
			
		||||
	ActualValue *float64 `gorm:"column:actual_value" json:"actual_value,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// Status 步骤状态(待执行/执行中/已完成/失败)
 | 
			
		||||
	Status string `gorm:"not null;column:status" json:"status"`
 | 
			
		||||
 | 
			
		||||
	// StartedAt 开始执行时间
 | 
			
		||||
	StartedAt *time.Time `gorm:"column:started_at" json:"started_at,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// FinishedAt 完成时间
 | 
			
		||||
	FinishedAt *time.Time `gorm:"column:finished_at" json:"finished_at,omitempty"`
 | 
			
		||||
 | 
			
		||||
	// CreatedAt 创建时间
 | 
			
		||||
	CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
 | 
			
		||||
 | 
			
		||||
	// UpdatedAt 更新时间
 | 
			
		||||
	UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
 | 
			
		||||
 | 
			
		||||
	// DeletedAt 删除时间(用于软删除)
 | 
			
		||||
	DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TableName 指定FeedingExecutionStep模型对应的数据库表名
 | 
			
		||||
func (FeedingExecutionStep) TableName() string {
 | 
			
		||||
	return "feeding_execution_steps"
 | 
			
		||||
}
 | 
			
		||||
@@ -19,6 +19,10 @@ var migrateModels = []interface{}{
 | 
			
		||||
	&model.OperationHistory{},
 | 
			
		||||
	&model.Device{},
 | 
			
		||||
	&model.DeviceControl{},
 | 
			
		||||
	&model.FeedingPlan{},
 | 
			
		||||
	&model.FeedingPlanStep{},
 | 
			
		||||
	&model.FeedingExecution{},
 | 
			
		||||
	&model.FeedingExecutionStep{},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// PostgresStorage 代表基于PostgreSQL的存储实现
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										205
									
								
								internal/storage/repository/feed.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								internal/storage/repository/feed.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,205 @@
 | 
			
		||||
package repository
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"sort"
 | 
			
		||||
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/model"
 | 
			
		||||
	"gorm.io/gorm"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// FeedPlanRepo 饲喂管理接口
 | 
			
		||||
type FeedPlanRepo interface {
 | 
			
		||||
 | 
			
		||||
	// ListAllPlanIntroduction 获取所有计划简介
 | 
			
		||||
	ListAllPlanIntroduction() ([]*model.FeedingPlan, error)
 | 
			
		||||
 | 
			
		||||
	// FindFeedingPlanByID 根据ID获取计划详情
 | 
			
		||||
	FindFeedingPlanByID(id uint) (*model.FeedingPlan, error)
 | 
			
		||||
 | 
			
		||||
	// CreateFeedingPlan 创建饲料计划
 | 
			
		||||
	CreateFeedingPlan(feedingPlan *model.FeedingPlan) error
 | 
			
		||||
 | 
			
		||||
	// DeleteFeedingPlan 删除饲料计划及其所有子计划和步骤
 | 
			
		||||
	DeleteFeedingPlan(id uint) error
 | 
			
		||||
 | 
			
		||||
	// UpdateFeedingPlan 更新饲料计划,采用先删除再重新创建的方式
 | 
			
		||||
	UpdateFeedingPlan(feedingPlan *model.FeedingPlan) error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type feedPlanRepo struct {
 | 
			
		||||
	db *gorm.DB
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewFeedPlanRepo(db *gorm.DB) FeedPlanRepo {
 | 
			
		||||
	return &feedPlanRepo{
 | 
			
		||||
		db: db,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListAllPlanIntroduction 获取所有计划简介
 | 
			
		||||
func (f *feedPlanRepo) ListAllPlanIntroduction() ([]*model.FeedingPlan, error) {
 | 
			
		||||
	var plans []*model.FeedingPlan
 | 
			
		||||
	err := f.db.Model(&model.FeedingPlan{}).
 | 
			
		||||
		Select("id, name, description, type, enabled, schedule_cron").
 | 
			
		||||
		Find(&plans).Error
 | 
			
		||||
	return plans, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindFeedingPlanByID 根据ID获取计划详情
 | 
			
		||||
func (f *feedPlanRepo) FindFeedingPlanByID(feedingPlanID uint) (*model.FeedingPlan, error) {
 | 
			
		||||
	var plan model.FeedingPlan
 | 
			
		||||
	err := f.db.Where("id = ?", feedingPlanID).
 | 
			
		||||
		Preload("Steps").
 | 
			
		||||
		Preload("SubPlans").
 | 
			
		||||
		First(&plan).Error
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return &plan, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CreateFeedingPlan 创建饲料计划,包括步骤和子计划
 | 
			
		||||
func (f *feedPlanRepo) CreateFeedingPlan(feedingPlan *model.FeedingPlan) error {
 | 
			
		||||
	// 清空所有ID,确保创建新记录
 | 
			
		||||
	f.clearAllIDs(feedingPlan)
 | 
			
		||||
 | 
			
		||||
	return f.db.Transaction(func(tx *gorm.DB) error {
 | 
			
		||||
		return f.createFeedingPlanWithTx(tx, feedingPlan)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdateFeedingPlan 更新饲料计划,采用先删除再重新创建的方式
 | 
			
		||||
func (f *feedPlanRepo) UpdateFeedingPlan(feedingPlan *model.FeedingPlan) error {
 | 
			
		||||
	// 检查计划是否存在
 | 
			
		||||
	_, err := f.FindFeedingPlanByID(feedingPlan.ID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return f.db.Transaction(func(tx *gorm.DB) error {
 | 
			
		||||
		// 先删除原有的计划
 | 
			
		||||
		if err := f.deleteFeedingPlanWithTx(tx, feedingPlan.ID); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 清空所有ID,包括子计划和步骤的ID
 | 
			
		||||
		f.clearAllIDs(feedingPlan)
 | 
			
		||||
 | 
			
		||||
		// 再重新创建更新后的计划
 | 
			
		||||
		if err := f.createFeedingPlanWithTx(tx, feedingPlan); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return nil
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DeleteFeedingPlan 删除饲料计划及其所有子计划和步骤
 | 
			
		||||
func (f *feedPlanRepo) DeleteFeedingPlan(id uint) error {
 | 
			
		||||
	return f.db.Transaction(func(tx *gorm.DB) error {
 | 
			
		||||
		// 递归删除计划及其所有子计划
 | 
			
		||||
		if err := f.deleteFeedingPlanWithTx(tx, id); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		return nil
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// deleteFeedingPlanWithTx 在事务中递归删除饲料计划
 | 
			
		||||
func (f *feedPlanRepo) deleteFeedingPlanWithTx(tx *gorm.DB, id uint) error {
 | 
			
		||||
	// 先查找计划及其子计划
 | 
			
		||||
	var plan model.FeedingPlan
 | 
			
		||||
	if err := tx.Where("id = ?", id).Preload("SubPlans").First(&plan).Error; err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 递归删除所有子计划
 | 
			
		||||
	for _, subPlan := range plan.SubPlans {
 | 
			
		||||
		if err := f.deleteFeedingPlanWithTx(tx, subPlan.ID); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 删除该计划的所有步骤
 | 
			
		||||
	if err := tx.Where("plan_id = ?", id).Delete(&model.FeedingPlanStep{}).Error; err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 删除计划本身
 | 
			
		||||
	if err := tx.Delete(&model.FeedingPlan{}, id).Error; err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// createFeedingPlanWithTx 在事务中递归创建饲料计划
 | 
			
		||||
func (f *feedPlanRepo) createFeedingPlanWithTx(tx *gorm.DB, feedingPlan *model.FeedingPlan) error {
 | 
			
		||||
	// 先创建计划主体
 | 
			
		||||
	if err := tx.Create(feedingPlan).Error; err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 处理步骤 - 先按现有顺序排序,再重新分配从0开始的连续编号
 | 
			
		||||
	sort.Slice(feedingPlan.Steps, func(i, j int) bool {
 | 
			
		||||
		return feedingPlan.Steps[i].StepOrder < feedingPlan.Steps[j].StepOrder
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// 重新填充步骤编号
 | 
			
		||||
	for i := range feedingPlan.Steps {
 | 
			
		||||
		feedingPlan.Steps[i].StepOrder = i
 | 
			
		||||
		feedingPlan.Steps[i].PlanID = feedingPlan.ID
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 如果有步骤,批量创建步骤
 | 
			
		||||
	if len(feedingPlan.Steps) > 0 {
 | 
			
		||||
		if err := tx.Create(&feedingPlan.Steps).Error; err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 处理子计划 - 重新填充子计划编号和父ID
 | 
			
		||||
	sort.Slice(feedingPlan.SubPlans, func(i, j int) bool {
 | 
			
		||||
		// 如果OrderInParent为nil,放在最后
 | 
			
		||||
		if feedingPlan.SubPlans[i].OrderInParent == nil {
 | 
			
		||||
			return false
 | 
			
		||||
		}
 | 
			
		||||
		if feedingPlan.SubPlans[j].OrderInParent == nil {
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
		return *feedingPlan.SubPlans[i].OrderInParent < *feedingPlan.SubPlans[j].OrderInParent
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// 重新填充子计划编号和父ID
 | 
			
		||||
	for i := range feedingPlan.SubPlans {
 | 
			
		||||
		order := i
 | 
			
		||||
		feedingPlan.SubPlans[i].OrderInParent = &order
 | 
			
		||||
		feedingPlan.SubPlans[i].ParentID = &feedingPlan.ID
 | 
			
		||||
 | 
			
		||||
		// 递归创建子计划
 | 
			
		||||
		if err := f.createFeedingPlanWithTx(tx, &feedingPlan.SubPlans[i]); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// clearAllIDs 清空计划及其子计划和步骤的所有ID
 | 
			
		||||
func (f *feedPlanRepo) clearAllIDs(plan *model.FeedingPlan) {
 | 
			
		||||
	// 清空计划ID
 | 
			
		||||
	plan.ID = 0
 | 
			
		||||
 | 
			
		||||
	// 清空所有步骤的ID和关联的计划ID
 | 
			
		||||
	for i := range plan.Steps {
 | 
			
		||||
		plan.Steps[i].ID = 0
 | 
			
		||||
		plan.Steps[i].PlanID = 0
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 清空所有子计划的ID和关联的父计划ID,并递归清空子计划的ID
 | 
			
		||||
	for i := range plan.SubPlans {
 | 
			
		||||
		plan.SubPlans[i].ID = 0
 | 
			
		||||
		plan.SubPlans[i].ParentID = nil
 | 
			
		||||
		f.clearAllIDs(&plan.SubPlans[i])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -23,6 +23,12 @@ type Task interface {
 | 
			
		||||
 | 
			
		||||
	// GetPriority 获取任务优先级
 | 
			
		||||
	GetPriority() int
 | 
			
		||||
 | 
			
		||||
	// Done 返回一个channel,当任务执行完毕时该channel会被关闭
 | 
			
		||||
	Done() <-chan struct{}
 | 
			
		||||
 | 
			
		||||
	// IsDone 检查任务是否已完成
 | 
			
		||||
	IsDone() bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// taskItem 任务队列中的元素
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										164
									
								
								internal/tests/mocks/mock_repository.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								internal/tests/mocks/mock_repository.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,164 @@
 | 
			
		||||
package mocks
 | 
			
		||||
 | 
			
		||||
// Package mocks 模拟测试包
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"git.huangwc.com/pig/pig-farm-controller/internal/model"
 | 
			
		||||
	"github.com/stretchr/testify/mock"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// MockDeviceRepo 模拟设备仓库,实现DeviceRepo接口
 | 
			
		||||
type MockDeviceRepo struct {
 | 
			
		||||
	mock.Mock
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create 模拟创建设备方法
 | 
			
		||||
func (m *MockDeviceRepo) Create(device *model.Device) error {
 | 
			
		||||
	args := m.Called(device)
 | 
			
		||||
	return args.Error(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindByID 模拟根据ID查找设备方法
 | 
			
		||||
func (m *MockDeviceRepo) FindByID(id uint) (*model.Device, error) {
 | 
			
		||||
	args := m.Called(id)
 | 
			
		||||
	// 返回第一个参数作为设备,第二个参数作为错误
 | 
			
		||||
	device, ok := args.Get(0).(*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return device, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindByIDString 模拟根据ID字符串查找设备方法
 | 
			
		||||
func (m *MockDeviceRepo) FindByIDString(id string) (*model.Device, error) {
 | 
			
		||||
	args := m.Called(id)
 | 
			
		||||
	// 返回第一个参数作为设备,第二个参数作为错误
 | 
			
		||||
	device, ok := args.Get(0).(*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return device, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindByParentID 模拟根据上级设备ID查找设备方法
 | 
			
		||||
func (m *MockDeviceRepo) FindByParentID(parentID uint) ([]*model.Device, error) {
 | 
			
		||||
	args := m.Called(parentID)
 | 
			
		||||
	// 返回第一个参数作为设备列表,第二个参数作为错误
 | 
			
		||||
	devices, ok := args.Get(0).([]*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return devices, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindByType 模拟根据设备类型查找设备方法
 | 
			
		||||
func (m *MockDeviceRepo) FindByType(deviceType model.DeviceType) ([]*model.Device, error) {
 | 
			
		||||
	args := m.Called(deviceType)
 | 
			
		||||
	// 返回第一个参数作为设备列表,第二个参数作为错误
 | 
			
		||||
	devices, ok := args.Get(0).([]*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return devices, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Update 模拟更新设备信息方法
 | 
			
		||||
func (m *MockDeviceRepo) Update(device *model.Device) error {
 | 
			
		||||
	args := m.Called(device)
 | 
			
		||||
	return args.Error(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Delete 模拟删除设备方法
 | 
			
		||||
func (m *MockDeviceRepo) Delete(id uint) error {
 | 
			
		||||
	args := m.Called(id)
 | 
			
		||||
	return args.Error(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListAll 模拟获取所有设备列表方法
 | 
			
		||||
func (m *MockDeviceRepo) ListAll() ([]model.Device, error) {
 | 
			
		||||
	args := m.Called()
 | 
			
		||||
	// 返回第一个参数作为设备列表,第二个参数作为错误
 | 
			
		||||
	devices, ok := args.Get(0).([]model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return devices, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindRelayDevices 模拟获取所有中继设备方法
 | 
			
		||||
func (m *MockDeviceRepo) FindRelayDevices() ([]*model.Device, error) {
 | 
			
		||||
	args := m.Called()
 | 
			
		||||
	// 返回第一个参数作为设备列表,第二个参数作为错误
 | 
			
		||||
	devices, ok := args.Get(0).([]*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return devices, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindByDeviceID 模拟根据设备ID查找设备方法(额外方法)
 | 
			
		||||
func (m *MockDeviceRepo) FindByDeviceID(deviceID string) (*model.Device, error) {
 | 
			
		||||
	args := m.Called(deviceID)
 | 
			
		||||
	// 返回第一个参数作为设备,第二个参数作为错误
 | 
			
		||||
	device, ok := args.Get(0).(*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return device, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindControllers 模拟查找控制器方法(额外方法)
 | 
			
		||||
func (m *MockDeviceRepo) FindControllers() ([]*model.Device, error) {
 | 
			
		||||
	args := m.Called()
 | 
			
		||||
	// 返回第一个参数作为设备列表,第二个参数作为错误
 | 
			
		||||
	devices, ok := args.Get(0).([]*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return devices, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindRelays 模拟查找中继设备方法(额外方法)
 | 
			
		||||
func (m *MockDeviceRepo) FindRelays() ([]*model.Device, error) {
 | 
			
		||||
	args := m.Called()
 | 
			
		||||
	// 返回第一个参数作为设备列表,第二个参数作为错误
 | 
			
		||||
	devices, ok := args.Get(0).([]*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return devices, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindDevicesByType 模拟根据类型查找设备方法(额外方法)
 | 
			
		||||
func (m *MockDeviceRepo) FindDevicesByType(deviceType string) ([]*model.Device, error) {
 | 
			
		||||
	args := m.Called(deviceType)
 | 
			
		||||
	// 返回第一个参数作为设备列表,第二个参数作为错误
 | 
			
		||||
	devices, ok := args.Get(0).([]*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return devices, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FindRelayDevices 模拟根据中继ID查找设备方法(额外方法)
 | 
			
		||||
func (m *MockDeviceRepo) FindRelayDevicesByID(relayID uint) ([]*model.Device, error) {
 | 
			
		||||
	args := m.Called(relayID)
 | 
			
		||||
	// 返回第一个参数作为设备列表,第二个参数作为错误
 | 
			
		||||
	devices, ok := args.Get(0).([]*model.Device)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, args.Error(1)
 | 
			
		||||
	}
 | 
			
		||||
	return devices, args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdateDeviceStatus 模拟更新设备状态方法(额外方法)
 | 
			
		||||
func (m *MockDeviceRepo) UpdateDeviceStatus(id uint, active bool) error {
 | 
			
		||||
	args := m.Called(id, active)
 | 
			
		||||
	return args.Error(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetDeviceStatus 模拟获取设备状态方法(额外方法)
 | 
			
		||||
func (m *MockDeviceRepo) GetDeviceStatus(id uint) (bool, error) {
 | 
			
		||||
	args := m.Called(id)
 | 
			
		||||
	return args.Bool(0), args.Error(1)
 | 
			
		||||
}
 | 
			
		||||
@@ -46,6 +46,9 @@ type Hub struct {
 | 
			
		||||
 | 
			
		||||
	// deviceRepo 设备仓库
 | 
			
		||||
	deviceRepo repository.DeviceRepo
 | 
			
		||||
 | 
			
		||||
	// 关闭消息
 | 
			
		||||
	close chan struct{}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Client WebSocket客户端结构
 | 
			
		||||
@@ -78,6 +81,7 @@ func NewHub(deviceRepo repository.DeviceRepo) *Hub {
 | 
			
		||||
		deviceClients: make(map[string]*Client),
 | 
			
		||||
		logger:        logs.NewLogger(),
 | 
			
		||||
		deviceRepo:    deviceRepo,
 | 
			
		||||
		close:         make(chan struct{}),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -101,10 +105,20 @@ func (h *Hub) Run() {
 | 
			
		||||
			h.unregisterClient(client)
 | 
			
		||||
		case message := <-h.broadcast:
 | 
			
		||||
			h.broadcastMessage(message)
 | 
			
		||||
		case <-h.close:
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *Hub) Close() {
 | 
			
		||||
	// 关闭时清理所有资源
 | 
			
		||||
	for client := range h.clients {
 | 
			
		||||
		h.unregisterClient(client)
 | 
			
		||||
	}
 | 
			
		||||
	close(h.close)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// registerClient 注册客户端
 | 
			
		||||
func (h *Hub) registerClient(client *Client) {
 | 
			
		||||
	h.mutex.Lock()
 | 
			
		||||
 
 | 
			
		||||
@@ -75,6 +75,10 @@ func (s *Server) Start() {
 | 
			
		||||
	go s.hub.Run()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s *Server) Stop() {
 | 
			
		||||
	s.hub.Close()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// readPump 从WebSocket连接读取消息
 | 
			
		||||
func (c *Client) readPump() {
 | 
			
		||||
	defer func() {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								vendor/modules.txt
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								vendor/modules.txt
									
									
									
									
										vendored
									
									
								
							@@ -24,6 +24,9 @@ github.com/bytedance/sonic/utf8
 | 
			
		||||
# github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311
 | 
			
		||||
## explicit; go 1.15
 | 
			
		||||
github.com/chenzhuoyu/base64x
 | 
			
		||||
# github.com/davecgh/go-spew v1.1.1
 | 
			
		||||
## explicit
 | 
			
		||||
github.com/davecgh/go-spew/spew
 | 
			
		||||
# github.com/gabriel-vasile/mimetype v1.4.2
 | 
			
		||||
## explicit; go 1.20
 | 
			
		||||
github.com/gabriel-vasile/mimetype
 | 
			
		||||
@@ -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/tracker
 | 
			
		||||
github.com/pelletier/go-toml/v2/unstable
 | 
			
		||||
# github.com/pmezard/go-difflib v1.0.0
 | 
			
		||||
## explicit
 | 
			
		||||
github.com/pmezard/go-difflib/difflib
 | 
			
		||||
# github.com/rogpeppe/go-internal v1.14.1
 | 
			
		||||
## explicit; go 1.23
 | 
			
		||||
# github.com/stretchr/objx v0.5.2
 | 
			
		||||
## explicit; go 1.20
 | 
			
		||||
github.com/stretchr/objx
 | 
			
		||||
# github.com/stretchr/testify v1.10.0
 | 
			
		||||
## explicit; go 1.17
 | 
			
		||||
github.com/stretchr/testify/assert
 | 
			
		||||
github.com/stretchr/testify/assert/yaml
 | 
			
		||||
github.com/stretchr/testify/mock
 | 
			
		||||
# github.com/twitchyliquid64/golang-asm v0.15.1
 | 
			
		||||
## explicit; go 1.13
 | 
			
		||||
github.com/twitchyliquid64/golang-asm/asm/arch
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user