Sfoglia il codice sorgente

Backup: Pre-Vue3 migration snapshot

jiuling 4 mesi fa
parent
commit
182b795b94

+ 907 - 0
.kiro/specs/vue2-to-vue3-migration/design.md

@@ -0,0 +1,907 @@
+# Design Document
+
+## Overview
+
+本设计文档描述了将「农小禹智慧农业系统」从 uni-app Vue2 (Options API) 迁移到 uni-app Vue3 (Composition API) 的技术方案。迁移的核心目标是支持 HarmonyOS 打包,同时确保在 Android、iOS 和 H5 平台上的功能和行为完全一致。
+
+### 迁移原则
+
+1. **零业务逻辑改动** - 所有业务逻辑、API 调用、数据处理保持不变
+2. **零 UI 结构改动** - 所有模板结构、样式、布局保持不变
+3. **语法层面迁移** - 仅进行 Vue2 到 Vue3 的语法和 API 转换
+4. **跨平台一致性** - 确保所有平台行为一致
+5. **HarmonyOS 兼容** - 移除浏览器特定 API,使用 uni-app 跨平台 API
+6. **可维护性优先** - 遵循 Vue3 最佳实践,添加清晰的注释
+
+### 项目现状分析
+
+**当前技术栈:**
+- Vue 2.6.14 + Options API
+- Vuex 3.6.2
+- uni-app 2.x
+- uview-ui 2.0.38
+- 第三方插件: Jessibuca (视频播放)、高德地图 SDK
+
+**项目规模:**
+- 约 40+ 页面组件
+- 4 个公共组件
+- 15+ API 服务模块
+- 1 个 Vuex store
+- 多个工具函数模块
+
+## Architecture
+
+### 迁移架构设计
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                     迁移执行层                                │
+│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
+│  │ 组件迁移引擎  │  │ Store迁移引擎 │  │ 配置迁移引擎  │      │
+│  └──────────────┘  └──────────────┘  └──────────────┘      │
+└─────────────────────────────────────────────────────────────┘
+                            ↓
+┌─────────────────────────────────────────────────────────────┐
+│                     转换规则层                                │
+│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
+│  │ 语法转换规则  │  │ API映射规则   │  │ 生命周期映射  │      │
+│  └──────────────┘  └──────────────┘  └──────────────┘      │
+└─────────────────────────────────────────────────────────────┘
+                            ↓
+┌─────────────────────────────────────────────────────────────┐
+│                     验证层                                    │
+│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
+│  │ 语法验证器    │  │ 功能测试器    │  │ 性能测试器    │      │
+│  └──────────────┘  └──────────────┘  └──────────────┘      │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 分层职责
+
+**迁移执行层:**
+- 组件迁移引擎: 负责 .vue 文件的转换
+- Store 迁移引擎: 负责 Vuex store 的升级
+- 配置迁移引擎: 负责 package.json、manifest.json 等配置文件的更新
+
+**转换规则层:**
+- 语法转换规则: Options API → Composition API 的转换规则
+- API 映射规则: Vue2 API → Vue3 API 的映射关系
+- 生命周期映射: Vue2 生命周期钩子 → Vue3 生命周期钩子的映射
+
+**验证层:**
+- 语法验证器: 验证转换后的代码符合 Vue3 规范
+- 功能测试器: 验证业务逻辑和功能一致性
+- 性能测试器: 验证性能指标不低于原版本
+
+## Components and Interfaces
+
+### 核心组件
+
+#### 1. 组件迁移引擎 (ComponentMigrationEngine)
+
+**职责:** 将 Vue2 单文件组件转换为 Vue3 Composition API 格式
+
+**接口:**
+```typescript
+interface ComponentMigrationEngine {
+  // 迁移单个组件文件
+  migrateComponent(filePath: string): MigrationResult
+  
+  // 批量迁移组件
+  migrateComponents(filePaths: string[]): MigrationResult[]
+  
+  // 验证迁移结果
+  validateMigration(result: MigrationResult): ValidationResult
+}
+```
+
+**转换流程:**
+1. 解析 Vue2 组件的 script、template、style 部分
+2. 转换 script 部分: Options API → Composition API
+3. 更新 template 部分: 移除 Vue2 特有语法
+4. 保持 style 部分不变
+5. 生成 Vue3 组件文件
+
+#### 2. Store 迁移引擎 (StoreMigrationEngine)
+
+**职责:** 将 Vuex 3.x store 升级到 Vuex 4.x 或迁移到 Pinia
+
+**接口:**
+```typescript
+interface StoreMigrationEngine {
+  // 迁移 store 文件
+  migrateStore(storePath: string, target: 'vuex4' | 'pinia'): MigrationResult
+  
+  // 更新组件中的 store 使用
+  updateStoreUsage(componentPath: string): MigrationResult
+}
+```
+
+
+**转换策略:**
+- 选项 A: 升级到 Vuex 4.x (保持 API 相似性,迁移成本低)
+- 选项 B: 迁移到 Pinia (更现代,更好的 TypeScript 支持)
+- 推荐: Vuex 4.x (项目已有 Vuex 使用经验,迁移风险低)
+
+#### 3. 配置迁移引擎 (ConfigMigrationEngine)
+
+**职责:** 更新项目配置文件以支持 Vue3
+
+**接口:**
+```typescript
+interface ConfigMigrationEngine {
+  // 更新 package.json 依赖
+  updateDependencies(): void
+  
+  // 更新 manifest.json 配置
+  updateManifest(): void
+  
+  // 更新 main.js 入口文件
+  updateMainEntry(): void
+}
+```
+
+**需要更新的配置:**
+- package.json: 升级 Vue、Vuex、uni-app 等依赖
+- manifest.json: 更新 vueVersion 为 "3"
+- main.js: 使用 createApp 替代 new Vue
+
+### 转换规则定义
+
+#### Options API → Composition API 转换规则
+
+| Vue2 Options API | Vue3 Composition API | 说明 |
+|-----------------|---------------------|------|
+| `data()` | `ref()` / `reactive()` | 基本类型用 ref,对象用 reactive |
+| `methods` | 函数定义 | 在 setup 中定义普通函数 |
+| `computed` | `computed()` | 使用 computed 函数包装 |
+| `watch` | `watch()` / `watchEffect()` | 使用 watch 函数 |
+| `props` | `defineProps()` | 使用 defineProps 声明 |
+| `$emit` | `defineEmits()` | 使用 defineEmits 声明 |
+| `this.$refs` | `ref()` | 使用 ref 创建模板引用 |
+| `this.$store` | `useStore()` | 使用 useStore 获取 store |
+| `this.$route` | `useRoute()` | 使用 useRoute 获取路由 |
+| `this.$router` | `useRouter()` | 使用 useRouter 获取路由器 |
+
+#### 生命周期钩子映射
+
+| Vue2 生命周期 | Vue3 生命周期 | 说明 |
+|--------------|--------------|------|
+| `beforeCreate` | `setup()` 顶层 | 逻辑移至 setup 顶层 |
+| `created` | `setup()` 顶层 | 逻辑移至 setup 顶层 |
+| `beforeMount` | `onBeforeMount()` | 导入并使用 |
+| `mounted` | `onMounted()` | 导入并使用 |
+| `beforeUpdate` | `onBeforeUpdate()` | 导入并使用 |
+| `updated` | `onUpdated()` | 导入并使用 |
+| `beforeDestroy` | `onBeforeUnmount()` | 名称变更 |
+| `destroyed` | `onUnmounted()` | 名称变更 |
+| `activated` | `onActivated()` | 导入并使用 |
+| `deactivated` | `onDeactivated()` | 导入并使用 |
+| `errorCaptured` | `onErrorCaptured()` | 导入并使用 |
+| uni-app 生命周期 | 保持不变 | onLoad, onShow, onHide 等 |
+
+#### 模板语法转换规则
+
+| Vue2 语法 | Vue3 语法 | 说明 |
+|----------|----------|------|
+| `v-model` | `v-model` | 自定义组件需调整 |
+| `.sync` | `v-model:propName` | .sync 修饰符已移除 |
+| `$listeners` | 移除 | 已合并到 $attrs |
+| `v-bind="$attrs"` | `v-bind="$attrs"` | 保持不变 |
+| 自定义指令钩子 | 更新钩子名称 | bind→beforeMount 等 |
+
+## Data Models
+
+### 迁移结果数据模型
+
+```typescript
+interface MigrationResult {
+  // 文件路径
+  filePath: string
+  
+  // 迁移状态
+  status: 'success' | 'partial' | 'failed'
+  
+  // 转换后的代码
+  code: string
+  
+  // 警告信息
+  warnings: Warning[]
+  
+  // 错误信息
+  errors: Error[]
+  
+  // TODO 项
+  todos: TodoItem[]
+}
+
+interface Warning {
+  line: number
+  column: number
+  message: string
+  rule: string
+}
+
+interface TodoItem {
+  line: number
+  message: string
+  reason: string
+  recommendation: string
+}
+
+interface ValidationResult {
+  isValid: boolean
+  errors: ValidationError[]
+  warnings: ValidationWarning[]
+}
+```
+
+### 组件转换示例
+
+**Vue2 组件 (Before):**
+```vue
+<script>
+export default {
+  data() {
+    return {
+      count: 0,
+      user: {
+        name: 'John',
+        age: 30
+      }
+    }
+  },
+  computed: {
+    doubleCount() {
+      return this.count * 2
+    }
+  },
+  methods: {
+    increment() {
+      this.count++
+    }
+  },
+  mounted() {
+    console.log('Component mounted')
+  }
+}
+</script>
+```
+
+**Vue3 组件 (After):**
+```vue
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+
+const count = ref(0)
+const user = reactive({
+  name: 'John',
+  age: 30
+})
+
+const doubleCount = computed(() => count.value * 2)
+
+const increment = () => {
+  count.value++
+}
+
+onMounted(() => {
+  console.log('Component mounted')
+})
+</script>
+```
+
+
+### Store 转换示例
+
+**Vuex 3.x (Before):**
+```javascript
+// store/index.js
+import Vue from 'vue'
+import Vuex from 'vuex'
+
+Vue.use(Vuex)
+
+const store = new Vuex.Store({
+  state: {
+    count: 0
+  },
+  mutations: {
+    increment(state) {
+      state.count++
+    }
+  }
+})
+
+export default store
+```
+
+**Vuex 4.x (After):**
+```javascript
+// store/index.js
+import { createStore } from 'vuex'
+
+const store = createStore({
+  state: {
+    count: 0
+  },
+  mutations: {
+    increment(state) {
+      state.count++
+    }
+  }
+})
+
+export default store
+```
+
+**组件中使用 Store (Before):**
+```vue
+<script>
+export default {
+  computed: {
+    count() {
+      return this.$store.state.count
+    }
+  },
+  methods: {
+    increment() {
+      this.$store.commit('increment')
+    }
+  }
+}
+</script>
+```
+
+**组件中使用 Store (After):**
+```vue
+<script setup>
+import { computed } from 'vue'
+import { useStore } from 'vuex'
+
+const store = useStore()
+
+const count = computed(() => store.state.count)
+
+const increment = () => {
+  store.commit('increment')
+}
+</script>
+```
+
+### 全局配置转换示例
+
+**main.js (Before):**
+```javascript
+import Vue from 'vue'
+import App from './App'
+import store from './store'
+import uView from 'uview-ui'
+
+// 全局过滤器
+Vue.filter('formatDate', (value) => {
+  // ...
+})
+
+// 全局属性
+Vue.prototype.$api = api
+
+Vue.use(store)
+Vue.use(uView)
+
+const app = new Vue({
+  store,
+  ...App
+})
+app.$mount()
+```
+
+**main.js (After):**
+```javascript
+import { createSSRApp } from 'vue'
+import App from './App.vue'
+import store from './store'
+import uView from 'uview-ui' // 需要 Vue3 兼容版本
+
+// 全局方法 (替代过滤器)
+import { formatDate } from './utils/filters'
+
+export function createApp() {
+  const app = createSSRApp(App)
+  
+  app.use(store)
+  app.use(uView)
+  
+  // 全局属性
+  app.config.globalProperties.$api = api
+  app.config.globalProperties.$formatDate = formatDate
+  
+  return {
+    app
+  }
+}
+```
+
+## Correctness Properties
+
+*属性 (Property) 是关于系统应该如何行为的形式化陈述,它应该在所有有效执行中保持为真。属性是人类可读规范和机器可验证正确性保证之间的桥梁。通过属性测试,我们可以验证代码在各种输入下的正确性。*
+
+### Property 1: Options API 完整转换
+
+*对于任意* Vue2 组件,如果它包含 data、methods、computed、watch 中的任意选项,转换后的 Vue3 组件应该使用对应的 Composition API (ref/reactive、函数定义、computed()、watch()),并且不包含 Options API 的选项对象。
+
+**Validates: Requirements 1.1, 1.3**
+
+### Property 2: 生命周期钩子正确映射
+
+*对于任意* Vue2 组件,如果它包含生命周期钩子 (mounted、created、beforeDestroy、destroyed 等),转换后的 Vue3 组件应该使用对应的 Composition API 钩子 (onMounted、setup 顶层、onBeforeUnmount、onUnmounted 等),并且钩子内的业务逻辑保持不变。
+
+**Validates: Requirements 1.2, 2.1, 2.2, 2.3, 2.4**
+
+### Property 3: uni-app 生命周期保持不变
+
+*对于任意* 包含 uni-app 特有生命周期 (onLoad、onShow、onHide、onPullDownRefresh 等) 的组件,转换后这些生命周期钩子的名称和用法应该保持完全不变。
+
+**Validates: Requirements 2.5**
+
+### Property 4: this 关键字完全移除
+
+*对于任意* 转换后的 Vue3 组件,代码中不应该包含 `this.` 的引用 (除了在注释中),所有数据和方法访问应该使用 Composition API 的直接引用。
+
+**Validates: Requirements 1.4**
+
+### Property 5: script setup 语法使用
+
+*对于任意* 转换后的 Vue3 组件,应该使用 `<script setup>` 语法糖,而不是传统的 `<script>` + `export default` 方式。
+
+**Validates: Requirements 1.5**
+
+### Property 6: Store 使用方式转换
+
+*对于任意* 在组件中使用 Vuex store 的代码,转换后应该使用 `useStore()` 获取 store 实例,而不是 `this.$store`,并且所有 store 状态访问应该使用 `computed()` 包装以保持响应性。
+
+**Validates: Requirements 3.2, 3.3**
+
+### Property 7: Props 和 Emits 声明
+
+*对于任意* 接收 props 或发出事件的组件,转换后应该使用 `defineProps()` 和 `defineEmits()` 进行声明,并且保持原有的类型定义、默认值和验证规则。
+
+**Validates: Requirements 7.1, 7.2, 7.3**
+
+### Property 8: 模板引用转换
+
+*对于任意* 使用 `this.$refs` 的代码,转换后应该使用 `ref()` 创建模板引用,并且在模板中使用 `ref` 属性绑定。
+
+**Validates: Requirements 6.3**
+
+### Property 9: 计算属性和侦听器转换
+
+*对于任意* 包含 computed 或 watch 的组件,转换后应该使用 `computed()` 和 `watch()` 函数,并且保持原有的选项 (如 deep、immediate)。
+
+**Validates: Requirements 8.1, 8.2, 8.4**
+
+### Property 10: 浏览器 API 移除
+
+*对于任意* 转换后的代码,不应该包含 `window.` 或 `document.` 的直接引用,所有浏览器特定功能应该使用 uni-app 提供的跨平台 API 替代。
+
+**Validates: Requirements 10.1, 10.2**
+
+### Property 11: 条件编译正确使用
+
+*对于任意* 平台特定的代码,应该使用 uni-app 的条件编译标记 (`#ifdef`、`#ifndef` 等) 进行隔离,并且包含对 HarmonyOS 平台的支持。
+
+**Validates: Requirements 10.5, 13.5**
+
+### Property 12: 模板结构保持不变
+
+*对于任意* Vue 组件,转换前后的 `<template>` 部分的 DOM 结构、元素层级、class 和 style 绑定应该保持完全一致 (除了必要的语法更新如 .sync → v-model:propName)。
+
+**Validates: Requirements 12.1, 12.3**
+
+### Property 13: 样式保持不变
+
+*对于任意* Vue 组件,转换前后的 `<style>` 部分的内容应该保持完全一致,包括所有 CSS/SCSS 规则、选择器和属性。
+
+**Validates: Requirements 12.2**
+
+### Property 14: v-model 语法更新
+
+*对于任意* 在模板中使用 v-model 的自定义组件,转换后应该符合 Vue3 的 v-model 语法规范,并且 .sync 修饰符应该转换为 v-model:propName 语法。
+
+**Validates: Requirements 5.1, 5.4**
+
+### Property 15: $listeners 移除
+
+*对于任意* 使用 `$listeners` 的代码,转换后应该移除 `$listeners` 的引用,因为 Vue3 已将其合并到 `$attrs` 中。
+
+**Validates: Requirements 5.3**
+
+### Property 16: 自定义指令钩子更新
+
+*对于任意* 自定义指令,转换后应该更新指令钩子名称 (bind → beforeMount, inserted → mounted, update → updated, componentUpdated → updated, unbind → unmounted)。
+
+**Validates: Requirements 5.2**
+
+### Property 17: TODO 注释添加
+
+*对于任意* 无法自动迁移的代码,转换后应该添加明确的 TODO 注释,包含问题原因和推荐的解决方案。
+
+**Validates: Requirements 14.2, 17.1, 17.2**
+
+
+## Error Handling
+
+### 错误分类
+
+#### 1. 语法错误 (Syntax Errors)
+- **描述:** 转换后的代码不符合 Vue3 语法规范
+- **处理策略:** 
+  - 添加 TODO 注释标记问题位置
+  - 提供详细的错误信息和修复建议
+  - 在迁移报告中记录错误详情
+
+#### 2. 不兼容的第三方库 (Incompatible Dependencies)
+- **描述:** 第三方库不支持 Vue3
+- **处理策略:**
+  - uview-ui: 升级到 uview-plus (Vue3 版本)
+  - Jessibuca: 保持现有实现,使用条件编译隔离 H5 平台
+  - 其他库: 寻找 Vue3 兼容的替代方案或自行实现
+
+#### 3. 复杂的 this 引用 (Complex this References)
+- **描述:** 某些复杂的 this 引用难以自动转换
+- **处理策略:**
+  - 添加 TODO 注释
+  - 提供手动转换指南
+  - 示例:
+    ```javascript
+    // TODO: 手动转换复杂的 this 引用
+    // 原因: 动态属性访问难以自动推断
+    // 推荐: 使用明确的变量引用替代 this[dynamicKey]
+    ```
+
+#### 4. 全局混入 (Global Mixins)
+- **描述:** 全局混入在 Vue3 中不推荐使用
+- **处理策略:**
+  - 评估是否可以转换为组合式函数
+  - 如果必须保留,添加 TODO 注释说明
+  - 提供组合式函数的重构建议
+
+#### 5. 过滤器 (Filters)
+- **描述:** Vue3 移除了过滤器功能
+- **处理策略:**
+  - 转换为全局方法: `app.config.globalProperties.$filterName`
+  - 或转换为组合式函数
+  - 在模板中使用方法调用替代管道语法
+
+### 错误恢复机制
+
+```typescript
+interface ErrorRecovery {
+  // 尝试自动修复
+  autoFix(error: MigrationError): FixResult
+  
+  // 回退到安全状态
+  rollback(filePath: string): void
+  
+  // 生成修复建议
+  generateSuggestion(error: MigrationError): Suggestion
+}
+```
+
+### 日志和报告
+
+**迁移日志格式:**
+```
+[INFO] 开始迁移: pages/dashboard/index.vue
+[SUCCESS] 转换 data → ref/reactive
+[SUCCESS] 转换 methods → 函数定义
+[SUCCESS] 转换 computed → computed()
+[WARNING] 发现复杂的 this 引用,已添加 TODO 注释
+[SUCCESS] 转换 mounted → onMounted
+[INFO] 迁移完成: pages/dashboard/index.vue (1 warning, 0 errors)
+```
+
+**迁移报告结构:**
+```markdown
+# 迁移报告
+
+## 概览
+- 总文件数: 45
+- 成功: 40
+- 部分成功: 4
+- 失败: 1
+
+## 详细信息
+
+### 成功迁移 (40)
+- pages/dashboard/index.vue
+- pages/login/index.vue
+- ...
+
+### 部分成功 (4)
+- pages/device/index.vue (1 warning)
+  - 警告: 复杂的 this 引用需要手动处理
+- ...
+
+### 失败 (1)
+- components/complex-component.vue
+  - 错误: 使用了不支持的第三方库
+
+## TODO 清单
+1. pages/device/index.vue:123 - 手动转换复杂的 this 引用
+2. components/complex-component.vue:45 - 替换不兼容的第三方库
+```
+
+## Testing Strategy
+
+### 测试方法论
+
+本项目采用**双重测试策略**:
+1. **单元测试** - 验证具体示例、边缘情况和错误条件
+2. **属性测试** - 验证通用属性在所有输入下的正确性
+
+两种测试方法互补:
+- 单元测试捕获具体的 bug 和边缘情况
+- 属性测试验证通用的正确性保证
+
+### 属性测试配置
+
+**测试框架:** 
+- JavaScript/TypeScript: fast-check
+- 最小迭代次数: 100 次
+- 每个正确性属性对应一个属性测试
+
+**测试标记格式:**
+```javascript
+// Feature: vue2-to-vue3-migration, Property 1: Options API 完整转换
+test('Options API should be fully converted to Composition API', () => {
+  fc.assert(
+    fc.property(
+      generateVue2Component(), // 生成器
+      (component) => {
+        const result = migrateComponent(component)
+        // 验证转换后不包含 Options API
+        expect(result.code).not.toMatch(/export default\s*{/)
+        expect(result.code).toMatch(/<script setup>/)
+      }
+    ),
+    { numRuns: 100 }
+  )
+})
+```
+
+### 单元测试策略
+
+#### 1. 语法转换测试
+- 测试 data → ref/reactive 转换
+- 测试 methods → 函数定义转换
+- 测试 computed → computed() 转换
+- 测试 watch → watch() 转换
+- 测试生命周期钩子映射
+
+#### 2. 边缘情况测试
+- 空组件
+- 只有 template 的组件
+- 复杂嵌套的数据结构
+- 动态属性访问
+- 异步操作
+
+#### 3. 集成测试
+- 完整页面的迁移
+- Store 与组件的集成
+- 路由与组件的集成
+- 第三方库的集成
+
+#### 4. 功能测试
+- 用户登录流程
+- 数据列表加载和展示
+- 表单提交和验证
+- 页面跳转和参数传递
+- 地图定位功能
+- 视频播放功能
+
+#### 5. 跨平台测试
+- Android 平台功能测试
+- iOS 平台功能测试
+- H5 平台功能测试
+- HarmonyOS 平台功能测试
+
+### 性能测试
+
+**关键指标:**
+- 应用启动时间
+- 页面首次渲染时间
+- 页面切换时间
+- 列表滚动流畅度
+- 内存占用
+
+**测试方法:**
+- 使用 uni-app 性能分析工具
+- 在真机上进行测试
+- 对比迁移前后的性能数据
+
+### 视觉回归测试
+
+**工具:** Percy 或 BackstopJS
+
+**测试范围:**
+- 所有主要页面的截图对比
+- 不同屏幕尺寸的适配
+- 不同主题的显示
+
+### 测试覆盖率目标
+
+- 单元测试覆盖率: > 80%
+- 集成测试覆盖率: > 60%
+- 关键业务流程: 100%
+
+## Implementation Notes
+
+### 迁移顺序
+
+**阶段 1: 基础设施迁移**
+1. 更新 package.json 依赖
+2. 更新 manifest.json 配置
+3. 迁移 main.js 入口文件
+4. 迁移 Vuex store
+
+**阶段 2: 工具函数和服务迁移**
+1. 迁移 utils 工具函数
+2. 迁移 API 服务模块
+3. 更新全局配置和常量
+
+**阶段 3: 组件迁移**
+1. 迁移公共组件 (components/common)
+2. 迁移页面组件 (pages)
+   - 优先级: 登录页 → 首页 → 其他页面
+3. 迁移 App.vue
+
+**阶段 4: 测试和验证**
+1. 运行单元测试
+2. 运行属性测试
+3. 进行功能测试
+4. 进行跨平台测试
+5. 进行性能测试
+
+### 关键技术决策
+
+#### 1. 状态管理: Vuex 4.x vs Pinia
+
+**决策: 使用 Vuex 4.x**
+
+**理由:**
+- 项目已有 Vuex 使用经验
+- Vuex 4.x API 与 3.x 高度相似,迁移成本低
+- 不需要重新培训团队
+- 风险较低
+
+#### 2. UI 组件库: uview-ui 升级方案
+
+**决策: 升级到 uview-plus**
+
+**理由:**
+- uview-plus 是 uview-ui 的 Vue3 版本
+- API 基本保持一致
+- 官方维护,稳定性有保障
+
+**迁移步骤:**
+1. 安装 uview-plus: `npm install uview-plus`
+2. 更新导入语句
+3. 测试所有使用的组件
+4. 处理 API 差异 (如有)
+
+#### 3. 视频播放: Jessibuca 处理方案
+
+**决策: 保持现有实现,使用条件编译**
+
+**理由:**
+- Jessibuca 主要用于 H5 平台
+- 不影响其他平台
+- 使用条件编译隔离
+
+**实现:**
+```javascript
+// #ifdef H5
+import JessibucaPlugin from './utils/jessibuca-plugin'
+app.use(JessibucaPlugin)
+// #endif
+```
+
+### HarmonyOS 特殊处理
+
+#### 1. 条件编译标记
+
+```javascript
+// #ifdef H5 || MP-WEIXIN || APP-PLUS
+// 通用代码
+// #endif
+
+// #ifdef APP-PLUS-NVUE
+// nvue 特定代码
+// #endif
+
+// 添加 HarmonyOS 支持
+// #ifdef H5 || MP-WEIXIN || APP-PLUS || MP-HARMONY
+// 跨平台代码
+// #endif
+```
+
+#### 2. API 兼容性处理
+
+**地图功能:**
+```javascript
+// 使用 uni-app 统一 API
+uni.getLocation({
+  type: 'gcj02',
+  success: (res) => {
+    // 处理定位结果
+  }
+})
+```
+
+**存储功能:**
+```javascript
+// 使用 uni-app 统一 API
+uni.setStorageSync('key', 'value')
+const value = uni.getStorageSync('key')
+```
+
+### 不可自动迁移的场景
+
+以下场景需要手动处理,迁移工具会添加 TODO 注释:
+
+1. **动态组件名称**
+```javascript
+// TODO: 手动转换动态组件引用
+// 原因: 动态 import 需要根据具体情况调整
+// 推荐: 使用 defineAsyncComponent 包装
+const component = () => import(`@/components/${dynamicName}.vue`)
+```
+
+2. **复杂的 this 上下文**
+```javascript
+// TODO: 手动转换复杂的 this 引用
+// 原因: 动态属性访问难以自动推断
+// 推荐: 重构为明确的变量引用
+const value = this[computedKey]
+```
+
+3. **render 函数**
+```javascript
+// TODO: 手动转换 render 函数
+// 原因: render 函数在 Vue3 中有 API 变更
+// 推荐: 参考 Vue3 render 函数文档
+render(h) {
+  return h('div', 'content')
+}
+```
+
+4. **全局混入**
+```javascript
+// TODO: 评估是否可以转换为组合式函数
+// 原因: 全局混入在 Vue3 中不推荐使用
+// 推荐: 使用组合式函数替代
+Vue.mixin({
+  // ...
+})
+```
+
+### 迁移检查清单
+
+- [ ] package.json 依赖已更新
+- [ ] manifest.json vueVersion 已更新为 "3"
+- [ ] main.js 使用 createSSRApp
+- [ ] Vuex store 已升级到 4.x
+- [ ] 所有组件使用 `<script setup>`
+- [ ] 所有 this 引用已移除
+- [ ] 所有生命周期钩子已更新
+- [ ] 所有 store 使用已更新为 useStore()
+- [ ] 所有过滤器已转换为方法
+- [ ] 所有全局属性已迁移到 globalProperties
+- [ ] 所有 window/document 引用已移除
+- [ ] 所有条件编译已包含 HarmonyOS 支持
+- [ ] uview-ui 已升级到 uview-plus
+- [ ] 所有单元测试通过
+- [ ] 所有属性测试通过
+- [ ] 所有平台功能测试通过
+- [ ] 性能指标不低于原版本

+ 256 - 0
.kiro/specs/vue2-to-vue3-migration/requirements.md

@@ -0,0 +1,256 @@
+# Requirements Document
+
+## Introduction
+
+本文档定义了将「农小禹智慧农业系统」从 uni-app Vue2 (Options API) 迁移到 uni-app Vue3 (Composition API) 的需求规范。该迁移的核心目标是支持 HarmonyOS 打包,同时保持 Android、iOS 和 H5 平台的完整功能和行为一致性。
+
+## Glossary
+
+- **System**: 农小禹智慧农业系统 uni-app 应用
+- **Migration_Engine**: 负责执行 Vue2 到 Vue3 代码转换的迁移引擎
+- **Composition_API**: Vue3 的组合式 API 编程范式
+- **Options_API**: Vue2 的选项式 API 编程范式
+- **HarmonyOS**: 华为鸿蒙操作系统
+- **ArkUI**: HarmonyOS 的 UI 编译环境
+- **Platform**: 指 Android、iOS、H5、HarmonyOS 等运行平台
+- **Business_Logic**: 应用的业务逻辑,包括接口调用、数据处理、状态管理等
+- **Lifecycle_Hook**: Vue 组件的生命周期钩子函数
+- **Reactive_Data**: Vue 的响应式数据系统
+- **Component**: Vue 组件,包括页面组件和公共组件
+- **Store**: Vuex 状态管理存储
+- **API_Service**: 封装的 API 服务模块
+- **Utils**: 工具函数模块
+- **Third_Party_Library**: 第三方依赖库,如 uview-ui
+
+## Requirements
+
+### Requirement 1: Vue3 语法迁移
+
+**User Story:** 作为开发者,我希望所有 Vue2 Options API 代码迁移为 Vue3 Composition API,以便支持 HarmonyOS 编译。
+
+#### Acceptance Criteria
+
+1. WHEN 迁移 Vue 组件时,THE Migration_Engine SHALL 将 Options API 的 data、methods、computed、watch 转换为 Composition API 的 ref、reactive、computed、watch
+2. WHEN 处理组件生命周期时,THE Migration_Engine SHALL 将 Vue2 生命周期钩子(mounted、created 等)转换为 Vue3 对应钩子(onMounted、setup 等)
+3. WHEN 迁移响应式数据时,THE Migration_Engine SHALL 使用 ref() 处理基本类型,使用 reactive() 处理对象类型
+4. WHEN 处理 this 引用时,THE Migration_Engine SHALL 移除所有 this 关键字,改用 Composition API 的直接引用
+5. THE System SHALL 在所有组件中使用 `<script setup>` 语法糖
+
+### Requirement 2: 生命周期钩子转换
+
+**User Story:** 作为开发者,我希望所有 Vue2 生命周期钩子正确转换为 Vue3 钩子,以确保组件行为一致。
+
+#### Acceptance Criteria
+
+1. WHEN 遇到 beforeCreate 或 created 钩子时,THE Migration_Engine SHALL 将逻辑移至 setup() 函数顶层
+2. WHEN 遇到 mounted 钩子时,THE Migration_Engine SHALL 转换为 onMounted()
+3. WHEN 遇到 beforeDestroy 钩子时,THE Migration_Engine SHALL 转换为 onBeforeUnmount()
+4. WHEN 遇到 destroyed 钩子时,THE Migration_Engine SHALL 转换为 onUnmounted()
+5. WHEN 遇到 uni-app 特有生命周期(onShow、onLoad 等)时,THE Migration_Engine SHALL 保持原有命名和用法
+
+### Requirement 3: 状态管理迁移
+
+**User Story:** 作为开发者,我希望 Vuex 3.x 迁移到 Vuex 4.x 或 Pinia,以兼容 Vue3 生态。
+
+#### Acceptance Criteria
+
+1. WHEN 迁移 Vuex store 时,THE Migration_Engine SHALL 升级到 Vuex 4.x 或迁移到 Pinia
+2. WHEN 在组件中使用 store 时,THE Migration_Engine SHALL 使用 useStore() 替代 this.$store
+3. WHEN 访问 store 状态时,THE Migration_Engine SHALL 使用 computed() 包装以保持响应性
+4. THE Store SHALL 保持所有现有的 state、getters、mutations、actions 的业务逻辑不变
+
+### Requirement 4: 依赖库升级
+
+**User Story:** 作为开发者,我希望所有第三方依赖升级到 Vue3 兼容版本,以确保系统正常运行。
+
+#### Acceptance Criteria
+
+1. THE System SHALL 将 Vue 从 2.6.14 升级到 3.x 最新稳定版
+2. THE System SHALL 将 @dcloudio/uni-app 升级到支持 Vue3 的版本
+3. THE System SHALL 将 uview-ui 升级到 Vue3 兼容版本或替换为兼容方案
+4. THE System SHALL 将 Vuex 从 3.6.2 升级到 4.x 或迁移到 Pinia
+5. WHEN 第三方库不支持 Vue3 时,THE System SHALL 寻找替代方案或自行实现
+
+### Requirement 5: 组件模板兼容性
+
+**User Story:** 作为开发者,我希望所有组件模板语法兼容 Vue3,以避免运行时错误。
+
+#### Acceptance Criteria
+
+1. WHEN 模板中使用 v-model 时,THE Migration_Engine SHALL 确保符合 Vue3 的 v-model 语法规范
+2. WHEN 使用自定义指令时,THE Migration_Engine SHALL 更新指令钩子名称(bind → beforeMount,update → updated 等)
+3. WHEN 使用 $listeners 时,THE Migration_Engine SHALL 移除 $listeners(Vue3 已合并到 $attrs)
+4. WHEN 使用 .sync 修饰符时,THE Migration_Engine SHALL 转换为 v-model:propName 语法
+5. THE System SHALL 确保所有模板语法符合 Vue3 规范
+
+### Requirement 6: 事件处理迁移
+
+**User Story:** 作为开发者,我希望所有事件处理逻辑正确迁移,以保持交互功能完整。
+
+#### Acceptance Criteria
+
+1. WHEN 定义事件处理函数时,THE Migration_Engine SHALL 在 setup() 中定义函数并返回
+2. WHEN 使用 $emit 时,THE Migration_Engine SHALL 使用 defineEmits() 声明事件
+3. WHEN 使用 $refs 时,THE Migration_Engine SHALL 使用 ref() 创建模板引用
+4. THE System SHALL 保持所有事件处理的业务逻辑不变
+
+### Requirement 7: Props 和 Emits 声明
+
+**User Story:** 作为开发者,我希望组件的 props 和 emits 使用 Vue3 的声明方式,以获得更好的类型推断。
+
+#### Acceptance Criteria
+
+1. WHEN 组件接收 props 时,THE Migration_Engine SHALL 使用 defineProps() 声明
+2. WHEN 组件发出事件时,THE Migration_Engine SHALL 使用 defineEmits() 声明
+3. THE System SHALL 为 props 提供类型定义和默认值
+4. THE System SHALL 保持所有 props 验证规则不变
+
+### Requirement 8: 计算属性和侦听器迁移
+
+**User Story:** 作为开发者,我希望计算属性和侦听器正确迁移到 Composition API,以保持响应式逻辑。
+
+#### Acceptance Criteria
+
+1. WHEN 迁移 computed 属性时,THE Migration_Engine SHALL 使用 computed() 函数包装
+2. WHEN 迁移 watch 侦听器时,THE Migration_Engine SHALL 使用 watch() 或 watchEffect() 函数
+3. THE System SHALL 保持计算属性的缓存特性
+4. THE System SHALL 保持侦听器的 deep、immediate 等选项
+
+### Requirement 9: 全局配置迁移
+
+**User Story:** 作为开发者,我希望全局配置(过滤器、混入、原型方法等)正确迁移到 Vue3,以保持全局功能。
+
+#### Acceptance Criteria
+
+1. WHEN 迁移全局过滤器时,THE Migration_Engine SHALL 转换为全局方法或组合式函数
+2. WHEN 迁移 Vue.prototype 属性时,THE Migration_Engine SHALL 使用 app.config.globalProperties
+3. WHEN 迁移全局混入时,THE Migration_Engine SHALL 评估是否可转换为组合式函数
+4. THE System SHALL 保持所有全局功能的业务逻辑不变
+
+### Requirement 10: HarmonyOS 兼容性
+
+**User Story:** 作为开发者,我希望迁移后的代码完全兼容 HarmonyOS ArkUI 编译环境,以成功打包鸿蒙应用。
+
+#### Acceptance Criteria
+
+1. THE System SHALL 移除所有 window 和 document 对象的直接引用
+2. THE System SHALL 使用 uni-app 提供的跨平台 API 替代浏览器特定 API
+3. THE System SHALL 确保所有条件编译标记正确处理 HarmonyOS 平台
+4. THE System SHALL 测试并验证在 HarmonyOS 环境下的功能完整性
+5. WHEN 使用平台特定功能时,THE System SHALL 使用条件编译隔离平台代码
+
+### Requirement 11: 业务逻辑保持不变
+
+**User Story:** 作为产品负责人,我希望迁移过程不改变任何业务逻辑,以确保功能一致性。
+
+#### Acceptance Criteria
+
+1. THE Migration_Engine SHALL 保持所有 API 接口调用的参数和返回值处理逻辑不变
+2. THE Migration_Engine SHALL 保持所有数据处理和计算逻辑不变
+3. THE Migration_Engine SHALL 保持所有页面跳转和路由逻辑不变
+4. THE Migration_Engine SHALL 保持所有条件判断和业务规则不变
+5. THE System SHALL 通过功能测试验证业务逻辑的一致性
+
+### Requirement 12: 页面结构保持不变
+
+**User Story:** 作为 UI/UX 设计师,我希望迁移不改变页面结构和样式,以保持用户体验一致。
+
+#### Acceptance Criteria
+
+1. THE Migration_Engine SHALL 保持所有模板结构不变
+2. THE Migration_Engine SHALL 保持所有 CSS/SCSS 样式不变
+3. THE Migration_Engine SHALL 保持所有布局和组件层级不变
+4. THE System SHALL 通过视觉回归测试验证 UI 一致性
+
+### Requirement 13: 跨平台行为一致性
+
+**User Story:** 作为测试工程师,我希望迁移后的应用在所有平台上行为一致,以确保用户体验统一。
+
+#### Acceptance Criteria
+
+1. THE System SHALL 在 Android 平台上保持原有功能和性能
+2. THE System SHALL 在 iOS 平台上保持原有功能和性能
+3. THE System SHALL 在 H5 平台上保持原有功能和性能
+4. THE System SHALL 在 HarmonyOS 平台上实现与其他平台一致的功能
+5. WHEN 平台差异无法避免时,THE System SHALL 使用条件编译提供平台特定实现
+
+### Requirement 14: 代码质量和可维护性
+
+**User Story:** 作为技术负责人,我希望迁移后的代码质量高、可维护性强,以降低未来维护成本。
+
+#### Acceptance Criteria
+
+1. THE Migration_Engine SHALL 遵循 Vue3 官方最佳实践
+2. THE Migration_Engine SHALL 为无法自动迁移的代码添加 TODO 注释和说明
+3. THE Migration_Engine SHALL 保持代码格式和命名规范一致
+4. THE System SHALL 提供迁移文档说明关键变更点
+5. THE System SHALL 确保迁移后的代码通过 ESLint 检查
+
+### Requirement 15: 第三方插件兼容性
+
+**User Story:** 作为开发者,我希望所有第三方插件和工具正确迁移或替换,以保持功能完整。
+
+#### Acceptance Criteria
+
+1. WHEN 迁移 Jessibuca 视频播放插件时,THE System SHALL 确保在 H5 平台正常工作
+2. WHEN 迁移高德地图 SDK 时,THE System SHALL 确保定位和地图功能正常
+3. WHEN 迁移 uview-ui 组件库时,THE System SHALL 升级到 Vue3 兼容版本或寻找替代方案
+4. THE System SHALL 测试所有第三方功能在各平台的兼容性
+
+### Requirement 16: 性能优化
+
+**User Story:** 作为用户,我希望迁移后的应用性能不低于原版本,最好有所提升。
+
+#### Acceptance Criteria
+
+1. THE System SHALL 利用 Vue3 的性能优势优化渲染性能
+2. THE System SHALL 使用 Composition API 优化代码复用和逻辑组织
+3. THE System SHALL 保持或改善应用启动时间
+4. THE System SHALL 保持或改善页面切换流畅度
+5. THE System SHALL 通过性能测试验证关键指标不低于原版本
+
+### Requirement 17: 错误处理和调试
+
+**User Story:** 作为开发者,我希望迁移过程中的错误易于发现和调试,以快速解决问题。
+
+#### Acceptance Criteria
+
+1. THE Migration_Engine SHALL 为每个无法自动迁移的代码添加明确的 TODO 注释
+2. THE Migration_Engine SHALL 在 TODO 注释中说明问题原因和推荐解决方案
+3. THE System SHALL 提供详细的错误日志和堆栈信息
+4. THE System SHALL 在开发环境启用 Vue3 的开发者工具支持
+
+### Requirement 18: 渐进式迁移支持
+
+**User Story:** 作为项目经理,我希望支持渐进式迁移,以降低风险和工作量。
+
+#### Acceptance Criteria
+
+1. THE System SHALL 支持按页面或模块逐步迁移
+2. THE System SHALL 允许 Vue2 和 Vue3 代码在迁移期间共存
+3. THE System SHALL 提供迁移进度跟踪机制
+4. THE System SHALL 支持回滚到迁移前的状态
+
+### Requirement 19: 测试覆盖
+
+**User Story:** 作为质量保证工程师,我希望有完整的测试覆盖,以确保迁移质量。
+
+#### Acceptance Criteria
+
+1. THE System SHALL 为关键业务逻辑提供单元测试
+2. THE System SHALL 为主要用户流程提供集成测试
+3. THE System SHALL 在所有目标平台上进行端到端测试
+4. THE System SHALL 进行视觉回归测试验证 UI 一致性
+5. THE System SHALL 进行性能测试验证性能指标
+
+### Requirement 20: 文档和培训
+
+**User Story:** 作为团队成员,我希望有完整的迁移文档和培训材料,以快速掌握 Vue3 开发。
+
+#### Acceptance Criteria
+
+1. THE System SHALL 提供 Vue2 到 Vue3 的迁移指南文档
+2. THE System SHALL 提供关键 API 变更对照表
+3. THE System SHALL 提供常见问题和解决方案文档
+4. THE System SHALL 提供 Vue3 Composition API 最佳实践文档
+5. THE System SHALL 提供代码示例和模板

+ 496 - 0
.kiro/specs/vue2-to-vue3-migration/tasks.md

@@ -0,0 +1,496 @@
+# Implementation Plan: Vue2 to Vue3 Migration
+
+## Overview
+
+本实施计划将「农小禹智慧农业系统」从 uni-app Vue2 迁移到 uni-app Vue3,以支持 HarmonyOS 打包。迁移将按照"基础设施 → 工具函数 → 公共组件 → 页面组件"的顺序进行,确保每个阶段都经过充分测试后再进入下一阶段。
+
+实现语言: **JavaScript** (保持当前项目语言,降低迁移成本)
+
+## Tasks
+
+- [-] 1. 环境准备和依赖升级
+  - 创建新分支 `feature/vue3-migration`
+  - 备份当前项目代码
+  - 更新 package.json 中的依赖版本
+  - 更新 manifest.json 配置
+  - _Requirements: 4.1, 4.2, 4.4_
+
+- [ ]* 1.1 编写依赖升级验证测试
+  - 验证 package.json 中 Vue 版本为 3.x
+  - 验证 Vuex 版本为 4.x
+  - 验证 uni-app 版本支持 Vue3
+  - _Requirements: 4.1, 4.2, 4.4_
+
+- [ ] 2. 迁移入口文件 main.js
+  - 将 `new Vue()` 改为 `createSSRApp()`
+  - 更新 Vuex store 的注册方式
+  - 迁移全局过滤器为全局方法
+  - 迁移 Vue.prototype 属性到 app.config.globalProperties
+  - 更新 uview-ui 导入 (升级到 uview-plus)
+  - 处理 Jessibuca 插件的条件编译
+  - _Requirements: 9.1, 9.2, 4.3_
+
+- [ ]* 2.1 编写 main.js 迁移验证测试
+  - 验证使用 createSSRApp 创建应用
+  - 验证全局属性正确注册
+  - 验证插件正确加载
+  - _Requirements: 9.1, 9.2_
+
+- [ ] 3. 迁移 Vuex Store
+  - 将 `new Vuex.Store()` 改为 `createStore()`
+  - 移除 `Vue.use(Vuex)`
+  - 保持所有 state、getters、mutations、actions 的业务逻辑不变
+  - _Requirements: 3.1, 3.4_
+
+- [ ]* 3.1 编写 Store 功能测试
+  - 测试 state 读取
+  - 测试 mutations 提交
+  - 测试 actions 调度
+  - 验证业务逻辑一致性
+  - _Requirements: 3.4_
+
+- [ ] 4. 迁移工具函数模块
+  - 检查 utils 目录下的所有工具函数
+  - 移除 window/document 直接引用,替换为 uni-app API
+  - 更新导出方式 (如需要)
+  - _Requirements: 10.1, 10.2_
+
+- [ ]* 4.1 编写工具函数单元测试
+  - 测试日期格式化函数
+  - 测试坐标转换函数
+  - 测试存储工具函数
+  - _Requirements: 10.1, 10.2_
+
+- [ ] 5. 迁移 API 服务模块
+  - 检查 api/services 目录下的所有服务文件
+  - 确保请求拦截器兼容 Vue3
+  - 更新 store 引用方式 (如有)
+  - _Requirements: 11.1_
+
+- [ ] 6. Checkpoint - 基础设施验证
+  - 确保所有基础设施迁移完成
+  - 运行所有单元测试
+  - 询问用户是否有问题
+
+- [ ] 7. 迁移公共组件 - dict-tag.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换 props 为 defineProps()
+  - 转换 data 为 ref/reactive
+  - 转换 computed 为 computed()
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 7.1, 12.1, 12.2_
+
+- [ ]* 7.1 编写 dict-tag 组件属性测试
+  - **Property 1: Options API 完整转换**
+  - **Validates: Requirements 1.1, 1.3**
+
+- [ ]* 7.2 编写 dict-tag 组件单元测试
+  - 测试组件渲染
+  - 测试 props 传递
+  - 测试样式应用
+  - _Requirements: 7.1_
+
+- [ ] 8. 迁移公共组件 - LocationPicker.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换 props 和 emits
+  - 转换响应式数据
+  - 转换事件处理函数
+  - 转换生命周期钩子
+  - 确保地图 API 使用 uni-app 统一接口
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 6.2, 7.1, 7.2, 10.2_
+
+- [ ]* 8.1 编写 LocationPicker 组件属性测试
+  - **Property 7: Props 和 Emits 声明**
+  - **Validates: Requirements 7.1, 7.2, 7.3**
+
+- [ ]* 8.2 编写 LocationPicker 组件功能测试
+  - 测试地图初始化
+  - 测试位置选择
+  - 测试事件发出
+  - _Requirements: 10.2_
+
+- [ ] 9. 迁移公共组件 - video-player.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换 $refs 为 ref() 模板引用
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 6.3_
+
+- [ ]* 9.1 编写 video-player 组件属性测试
+  - **Property 8: 模板引用转换**
+  - **Validates: Requirements 6.3**
+
+- [ ] 10. 迁移公共组件 - jessibuca.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 添加 H5 平台条件编译
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 10.5, 15.1_
+
+- [ ]* 10.1 编写 jessibuca 组件功能测试
+  - 测试 H5 平台视频播放
+  - 验证条件编译正确性
+  - _Requirements: 15.1_
+
+- [ ] 11. Checkpoint - 公共组件验证
+  - 确保所有公共组件迁移完成
+  - 运行所有组件测试
+  - 询问用户是否有问题
+
+- [ ] 12. 迁移 App.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换 onLaunch、onShow、onHide 生命周期 (保持 uni-app 命名)
+  - 转换 methods 为函数定义
+  - 移除所有 this 引用
+  - 保持 style 不变
+  - _Requirements: 1.1, 1.4, 1.5, 2.5_
+
+- [ ]* 12.1 编写 App.vue 属性测试
+  - **Property 3: uni-app 生命周期保持不变**
+  - **Validates: Requirements 2.5**
+
+- [ ] 13. 迁移登录页面 - pages/login/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换 data 为 ref/reactive
+  - 转换 methods 为函数定义
+  - 转换 computed 为 computed()
+  - 转换生命周期钩子
+  - 更新 store 使用方式为 useStore()
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 3.2, 3.3, 12.1, 12.2_
+
+- [ ]* 13.1 编写登录页面属性测试
+  - **Property 6: Store 使用方式转换**
+  - **Validates: Requirements 3.2, 3.3**
+
+- [ ]* 13.2 编写登录页面功能测试
+  - 测试登录表单提交
+  - 测试表单验证
+  - 测试登录成功跳转
+  - _Requirements: 11.1, 11.3_
+
+- [ ] 14. 迁移登录页面 - pages/login/register.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 更新 store 使用方式
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 3.2, 12.1, 12.2_
+
+- [ ] 15. 迁移登录页面 - pages/login/forget-password.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 12.1, 12.2_
+
+- [ ] 16. Checkpoint - 登录模块验证
+  - 确保所有登录相关页面迁移完成
+  - 运行登录流程功能测试
+  - 询问用户是否有问题
+
+
+- [ ] 17. 迁移首页 - pages/dashboard/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换 data 为 ref/reactive (注意复杂对象使用 reactive)
+  - 转换 computed 为 computed()
+  - 转换 methods 为函数定义
+  - 转换 watch 为 watch() (如有)
+  - 转换生命周期钩子 (onShow、onLoad、mounted 等)
+  - 更新 store 使用方式为 useStore()
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 3.2, 3.3, 8.1, 12.1, 12.2_
+
+- [ ]* 17.1 编写首页属性测试
+  - **Property 9: 计算属性和侦听器转换**
+  - **Validates: Requirements 8.1, 8.2, 8.4**
+
+- [ ]* 17.2 编写首页功能测试
+  - 测试数据加载
+  - 测试地块切换
+  - 测试图表渲染
+  - 测试页面跳转
+  - _Requirements: 11.1, 11.2, 11.3_
+
+- [ ] 18. 迁移农事活动页面 - pages/activity/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 更新 store 使用方式
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 3.2, 12.1, 12.2_
+
+- [ ] 19. 迁移农事活动详情页 - pages/activity/activity-detail.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 20. 迁移设备监测页面 - pages/device/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 更新 store 使用方式
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 3.2, 12.1, 12.2_
+
+- [ ] 21. 迁移设备列表页面 - pages/device/device-list/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 22. 迁移设备详情页面 (3个)
+  - 迁移 pages/device/device-list/detail-camera.vue
+  - 迁移 pages/device/device-list/detail-collector.vue
+  - 迁移 pages/device/device-list/detail-machine.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 23. 迁移农机设备页面 - pages/device/device-list/agricultural/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 24. 迁移作业相关页面 (2个)
+  - 迁移 pages/device/job-create/index.vue
+  - 迁移 pages/device/job-detail/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 25. Checkpoint - 设备模块验证
+  - 确保所有设备相关页面迁移完成
+  - 运行设备模块功能测试
+  - 询问用户是否有问题
+
+- [ ] 26. 迁移农业知识页面 - pages/knowledge/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 27. 迁移知识详情页面 - pages/knowledge/detail.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 28. 迁移 AI 聊天页面 - pages/knowledge/ai-chat/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 29. 迁移用户中心页面 - pages/user/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 更新 store 使用方式
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 3.2, 12.1, 12.2_
+
+- [ ] 30. 迁移用户信息页面 - pages/userInfo/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 更新 store 使用方式
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 3.2, 12.1, 12.2_
+
+- [ ] 31. 迁移其他页面 (10个)
+  - 迁移 pages/machine/index.vue
+  - 迁移 pages/field/index.vue
+  - 迁移 pages/plots/list.vue
+  - 迁移 pages/chart/index.vue
+  - 迁移 pages/more/index.vue
+  - 迁移 pages/settings/index.vue
+  - 迁移 pages/about/index.vue
+  - 迁移 pages/privacy/index.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 32. 迁移服务页面 (10个)
+  - 迁移 pages/service/mall.vue
+  - 迁移 pages/service/mall-detail.vue
+  - 迁移 pages/service/sales.vue
+  - 迁移 pages/service/sales-detail.vue
+  - 迁移 pages/service/sales-publish.vue
+  - 迁移 pages/service/purchase-publish.vue
+  - 迁移 pages/service/my-publish.vue
+  - 迁移 pages/service/expert.vue
+  - 迁移 pages/service/expert-detail.vue
+  - 迁移 pages/service/expert-chat.vue
+  - 迁移 pages/service/certification.vue
+  - 迁移 pages/service/insurance.vue
+  - 将 Options API 转换为 Composition API
+  - 使用 `<script setup>` 语法
+  - 转换响应式数据和方法
+  - 转换生命周期钩子
+  - 移除所有 this 引用
+  - 保持 template 和 style 不变
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.5, 12.1, 12.2_
+
+- [ ] 33. Checkpoint - 所有页面迁移完成
+  - 确保所有页面组件迁移完成
+  - 运行所有页面测试
+  - 询问用户是否有问题
+
+- [ ] 34. 添加 HarmonyOS 平台支持
+  - 在所有条件编译中添加 HarmonyOS 支持
+  - 检查并更新平台特定代码
+  - 确保使用 uni-app 跨平台 API
+  - _Requirements: 10.3, 10.5, 13.5_
+
+- [ ]* 34.1 编写 HarmonyOS 兼容性属性测试
+  - **Property 10: 浏览器 API 移除**
+  - **Validates: Requirements 10.1, 10.2**
+
+- [ ]* 34.2 编写 HarmonyOS 兼容性属性测试
+  - **Property 11: 条件编译正确使用**
+  - **Validates: Requirements 10.5, 13.5**
+
+- [ ] 35. 代码质量检查和优化
+  - 运行 ESLint 检查所有迁移后的代码
+  - 修复所有 ESLint 错误和警告
+  - 检查所有 TODO 注释,确保有明确说明
+  - 优化代码结构和命名
+  - _Requirements: 14.1, 14.2, 14.3, 14.5, 17.2_
+
+- [ ]* 35.1 编写代码质量属性测试
+  - **Property 17: TODO 注释添加**
+  - **Validates: Requirements 14.2, 17.1, 17.2**
+
+- [ ] 36. 编写综合属性测试
+  - 编写 Property 1: Options API 完整转换测试
+  - 编写 Property 2: 生命周期钩子正确映射测试
+  - 编写 Property 4: this 关键字完全移除测试
+  - 编写 Property 5: script setup 语法使用测试
+  - 编写 Property 12: 模板结构保持不变测试
+  - 编写 Property 13: 样式保持不变测试
+  - 编写 Property 14: v-model 语法更新测试
+  - 编写 Property 15: $listeners 移除测试
+  - 编写 Property 16: 自定义指令钩子更新测试
+  - 配置每个测试运行 100 次迭代
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 5.1, 5.2, 5.3, 5.4, 12.1, 12.2_
+
+- [ ] 37. 跨平台功能测试
+  - 在 Android 平台测试核心功能
+  - 在 iOS 平台测试核心功能
+  - 在 H5 平台测试核心功能
+  - 在 HarmonyOS 平台测试核心功能
+  - 验证所有平台行为一致
+  - _Requirements: 13.1, 13.2, 13.3, 13.4_
+
+- [ ]* 37.1 编写跨平台功能测试用例
+  - 测试用户登录流程
+  - 测试数据列表加载
+  - 测试表单提交
+  - 测试页面跳转
+  - 测试地图定位 (如适用)
+  - _Requirements: 13.1, 13.2, 13.3, 13.4_
+
+- [ ] 38. 性能测试和优化
+  - 测量应用启动时间
+  - 测量页面首次渲染时间
+  - 测量页面切换时间
+  - 对比迁移前后的性能数据
+  - 优化性能瓶颈 (如有)
+  - _Requirements: 16.1, 16.3, 16.4_
+
+- [ ]* 38.1 编写性能测试用例
+  - 测试应用启动性能
+  - 测试页面渲染性能
+  - 测试列表滚动性能
+  - 验证性能不低于原版本
+  - _Requirements: 16.1, 16.3, 16.4_
+
+- [ ] 39. 视觉回归测试
+  - 对所有主要页面进行截图对比
+  - 验证 UI 布局一致性
+  - 验证样式渲染一致性
+  - 修复视觉差异 (如有)
+  - _Requirements: 12.1, 12.2_
+
+- [ ] 40. 最终验证和文档
+  - 运行所有单元测试,确保通过
+  - 运行所有属性测试,确保通过
+  - 运行所有功能测试,确保通过
+  - 完成迁移检查清单
+  - 编写迁移总结文档
+  - 记录已知问题和 TODO 项
+  - _Requirements: 14.4, 20.1, 20.2, 20.3_
+
+- [ ] 41. 最终 Checkpoint - 迁移完成
+  - 确保所有任务完成
+  - 确保所有测试通过
+  - 询问用户是否准备合并代码
+
+## Notes
+
+- 任务标记 `*` 的为可选测试任务,可根据项目进度和资源情况决定是否执行
+- 每个 Checkpoint 任务都是重要的验证点,确保在继续之前所有功能正常
+- 属性测试使用 fast-check 库,每个测试至少运行 100 次迭代
+- 所有迁移任务都必须保持业务逻辑、UI 结构和样式不变
+- 遇到无法自动迁移的代码,必须添加 TODO 注释并说明原因和解决方案
+- HarmonyOS 平台支持是本次迁移的核心目标,必须在所有条件编译中包含
+- 建议按顺序执行任务,确保每个阶段稳定后再进入下一阶段

+ 3 - 0
androidPrivacy.json

@@ -0,0 +1,3 @@
+{
+    "prompt": "template"
+}

+ 22 - 0
api/services/agriculturalMachines.js

@@ -0,0 +1,22 @@
+/* 农机管理相关API */
+import {
+  http,
+  Method
+} from '@/utils/request.js';
+import storage from "@/utils/storage.js";
+import api from "@/config/api.js";
+import config from "@/config/config.js";
+const userInfo = storage.getUserInfo()
+/**
+ * 获取所有设备列表
+ * @returns {Promise} 设备列表
+ */
+export function machinesDeviceList(params = {}) {
+  if (userInfo.sysUser.deptId) params.deptIdList = userInfo.sysUser.deptId;
+  return http.request({
+    url: 'base/machines/list',
+    method: Method.GET,
+	  needToken: true,
+    params: params
+  });
+}

+ 121 - 0
api/services/job.js

@@ -0,0 +1,121 @@
+import { http, Method } from '@/utils/request.js'
+import storage from "@/utils/storage.js";
+const userInfo = storage.getUserInfo()
+/**
+ * 创建作业配置
+ * 仅作为前端预留,后端实现由服务端负责
+ * @param {Object} data JobCreateState
+ */
+export function createJob(data) {
+  data.userId = userInfo.userid
+  return http.request({
+    url: '/uniapp/deviceTasks/add',
+    method: Method.POST,
+    needToken: true,
+    data
+  })
+}
+
+/**
+ * 获取作业任务列表
+ * 接口:GET /uniapp/deviceTasks/list
+ * @param {Object} params
+ * @param {number} [params.pageNum] 页码
+ * @param {number} [params.pageSize] 每页条数
+ * @param {string} [params.taskName] 作业名称(模糊)
+ * @param {number} [params.taskStatus] 作业状态:0未开始/1进行中/2已完成/3已取消
+ * @param {number} [params.areaType] 工作区域类型
+ * @param {string} [params.startTime] 创建时间起
+ * @param {string} [params.endTime] 创建时间止
+ * @param {number|string} [params.deviceId] 设备ID(如果后端支持筛选,可传)
+ */
+export function deviceTasksList(params = {}) {
+  return http.request({
+    url: '/uniapp/deviceTasks/list',
+    method: Method.GET,
+    needToken: true,
+    data: params
+  })
+}
+/**
+  * 获取车辆作业详细信息
+*/
+export function getInfo(id){
+  return http.request({
+    url: `/uniapp/deviceTasks/${id}`,
+    method: Method.GET,
+    needToken: true
+  })
+}
+
+/**
+  * 删除当前作业 
+*/
+export function deleteTask(taskId){
+  return http.request({
+    url: `/uniapp/deviceTasks/delete/${taskId}`,
+    method: Method.DELETE,
+    needToken: true
+  })
+}
+
+/**
+ * 开始作业
+ * 接口:PUT /uniapp/deviceTasks/start/{taskId}
+ */
+export function startTask(taskId) {
+  return http.request({
+    url: `/uniapp/deviceTasks/start/${parseInt(taskId)}`,
+    method: Method.PUT,
+    needToken: true
+  })
+}
+
+/**
+ * 暂停作业
+ * 接口:PUT /uniapp/deviceTasks/pause/{taskId}
+ */
+export function pauseTask(taskId) {
+  return http.request({
+    url: `/uniapp/deviceTasks/pause/${taskId}`,
+    method: Method.PUT,
+    needToken: true
+  })
+}
+
+/**
+ * 停止作业
+ * 接口:PUT /uniapp/deviceTasks/stop/{taskId}
+ */
+export function stopTask(taskId) {
+  return http.request({
+    url: `/uniapp/deviceTasks/stop/${taskId}`,
+    method: Method.PUT,
+    needToken: true
+  })
+}
+
+/**
+ * 召回设备
+ * 接口:PUT /uniapp/deviceTasks/recall/{taskId}
+ */
+export function recallTask(taskId) {
+  return http.request({
+    url: `/uniapp/deviceTasks/recall/${taskId}`,
+    method: Method.PUT,
+    needToken: true
+  })
+}
+
+/**
+ * 获取设备实时数据
+ * 接口:GET /uniapp/deviceTasks/realtime/{deviceId}
+ * @param {string|number} deviceId 设备编号/id
+ */
+export function getRealtimeData(deviceId) {
+  return http.request({
+    url: `/uniapp/deviceTasks/realtime/${deviceId}`,
+    method: Method.GET,
+    needToken: true
+  })
+}

+ 21 - 0
api/services/machineAlarmRecords.js

@@ -0,0 +1,21 @@
+/* 农机告警相关API */
+import {
+  http,
+  Method
+} from '@/utils/request.js';
+import storage from "@/utils/storage.js";
+import api from "@/config/api.js";
+import config from "@/config/config.js";
+const userInfo = storage.getUserInfo()
+/**
+ * 获取所有设备列表
+ * @returns {Promise} 设备列表
+ */
+export function machineAlarmRecordsList(params = {}) {
+  return http.request({
+    url: 'base/machineAlarmRecords/list',
+    method: Method.GET,
+	  needToken: true,
+    params: params
+  });
+}

+ 3 - 3
config/api.js

@@ -5,8 +5,8 @@ const dev = {
 };
 // 生产环境
 const prod = {
-  serve: "http://nxy.gbdfarm.com:9000/pro-uniapp",
-  upload: "http://nxy.gbdfarm.com"
+  serve: "https://nxy.gbdfarm.com:9000/pro-uniapp",
+  upload: "https://nxy.gbdfarm.com"
 };
 
 //默认生产环境
@@ -19,7 +19,7 @@ if (process.env.NODE_ENV == "development") {
 }
 //微信小程序,app的打包方式建议为生产环境,所以这块直接条件编译赋值
 // #ifdef MP-WEIXIN || APP-PLUS
-// api = prod;
+api = prod;
 // #endif
 export default {
   ...api,

+ 98 - 3
manifest.json

@@ -1,5 +1,5 @@
 {
-    "name" : "nongxiaoyu",
+    "name" : "农小禹",
     "appid" : "__UNI__D07390E",
     "description" : "农小禹小程序",
     "versionName" : "1.0.0",
@@ -8,24 +8,119 @@
     "app-plus" : {
         "distribute" : {
             "android" : {
-                "permissions" : [ "INTERNET" ]
+                "permissions" : [
+                    "<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />",
+                    "<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />",
+                    "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />",
+                    "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\" />",
+                    "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\" />",
+                    "<uses-permission android:name=\"android.permission.INTERNET\" />",
+                    "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\" />",
+                    "<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\" />",
+                    "<uses-permission android:name=\"android.permission.ACCESS_LOCATION_EXTRA_COMMANDS\" />",
+                    "<uses-permission android:name=\"android.permission.BLUETOOTH\" />",
+                    "<uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\" />"
+                ]
+            },
+            "ios" : {
+                "dtds" : [ "geolocation" ],
+                "dSYMs" : false
+            },
+            "sdkConfigs" : {
+                "geolocation" : {
+                    "amap" : {
+                        "name" : "amap4Lm85iA9",
+                        "__platform__" : [ "ios", "android" ],
+                        "appkey_ios" : "9f2cac7ea18905dd3830cf7360a43a35",
+                        "appkey_android" : "9f2cac7ea18905dd3830cf7360a43a35"
+                    }
+                },
+                "maps" : {
+                    "amap" : {
+                        "name" : "amap4Lm85iA9",
+                        "appkey_ios" : "9f2cac7ea18905dd3830cf7360a43a35",
+                        "appkey_android" : "9f2cac7ea18905dd3830cf7360a43a35"
+                    }
+                },
+                "ad" : {}
+            },
+            "icons" : {
+                "android" : {
+                    "hdpi" : "unpackage/res/icons/72x72.png",
+                    "xhdpi" : "unpackage/res/icons/96x96.png",
+                    "xxhdpi" : "unpackage/res/icons/144x144.png",
+                    "xxxhdpi" : "unpackage/res/icons/192x192.png"
+                },
+                "ios" : {
+                    "appstore" : "unpackage/res/icons/1024x1024.png",
+                    "ipad" : {
+                        "app" : "unpackage/res/icons/76x76.png",
+                        "app@2x" : "unpackage/res/icons/152x152.png",
+                        "notification" : "unpackage/res/icons/20x20.png",
+                        "notification@2x" : "unpackage/res/icons/40x40.png",
+                        "proapp@2x" : "unpackage/res/icons/167x167.png",
+                        "settings" : "unpackage/res/icons/29x29.png",
+                        "settings@2x" : "unpackage/res/icons/58x58.png",
+                        "spotlight" : "unpackage/res/icons/40x40.png",
+                        "spotlight@2x" : "unpackage/res/icons/80x80.png"
+                    },
+                    "iphone" : {
+                        "app@2x" : "unpackage/res/icons/120x120.png",
+                        "app@3x" : "unpackage/res/icons/180x180.png",
+                        "notification@2x" : "unpackage/res/icons/40x40.png",
+                        "notification@3x" : "unpackage/res/icons/60x60.png",
+                        "settings@2x" : "unpackage/res/icons/58x58.png",
+                        "settings@3x" : "unpackage/res/icons/87x87.png",
+                        "spotlight@2x" : "unpackage/res/icons/80x80.png",
+                        "spotlight@3x" : "unpackage/res/icons/120x120.png"
+                    }
+                }
+            },
+            "splashscreen" : {
+                "useOriginalMsgbox" : true
             }
+        },
+        "modules" : {
+            "Geolocation" : {},
+            "Maps" : {}
         }
     },
     "mp-weixin" : {
         "appid" : "wxc738cddfb96a9176",
         "setting" : {
             "urlCheck" : false
+        },
+        "permission" : {
+            "scope.userLocation" : {
+                "desc" : "你的位置信息将用于获取附近的农田信息"
+            }
         }
     },
     "vueVersion" : "2",
     "h5" : {
         "devServer" : {
             "port" : 9000,
-			"host": "0.0.0.0"
+            "host" : "0.0.0.0"
         },
         "router" : {
             "base" : "./"
+        },
+        "optimization" : {
+            "prefetchBackgroundManifest" : false
+        },
+        "sdkConfigs" : {
+            "maps" : {
+                "amap" : {
+                    "key" : "9f2cac7ea18905dd3830cf7360a43a35",
+                    "securityJsCode" : "41af52e416d1fd1b15020dac066cec86",
+                    "serviceHost" : ""
+                }
+            }
+        }
+    },
+    "mp-jd" : {
+        "unipush" : {
+            "enable" : false
         }
     }
 }

+ 27 - 4
pages.json

@@ -57,6 +57,15 @@
 				"navigationBarTitleText": "设备监测"
 			}
 		},
+		{
+			"path": "pages/device/job-create/index",
+			"style": {
+				"navigationBarTitleText": "新增作业",
+				"navigationBarBackgroundColor": "#ffffff",
+				"navigationBarTextStyle": "black",
+				"enablePullDownRefresh": false
+			}
+		},
 		{
 			"path": "pages/device/device-list/detail-camera",
 			"style": {
@@ -85,6 +94,15 @@
 				"disableScroll": true
 			}
 		},
+		{
+			"path": "pages/device/device-list/agricultural/index",
+			"style": {
+				"navigationBarTitleText": "农机设备",
+				"navigationBarBackgroundColor": "#ffffff",
+				"navigationBarTextStyle": "black",
+				"enablePullDownRefresh": true
+			}
+		},
 		{
 			"path": "pages/device/device-list/detail-machine",
 			"style": {
@@ -94,6 +112,15 @@
 				"enablePullDownRefresh": false
 			}
 		},
+		{
+			"path": "pages/device/job-detail/index",
+			"style": {
+				"navigationBarTitleText": "农机设备作业详情",
+				"navigationBarBackgroundColor": "#ffffff",
+				"navigationBarTextStyle": "black",
+				"enablePullDownRefresh": false
+			}
+		},
 		{
 			"path": "pages/knowledge/index",
 			"style": {
@@ -110,10 +137,6 @@
 				},
 				"app-plus": {
 					"titleNView": {
-						// "buttons": [{
-						// 	"type": "back",
-						// 	"background": "transparent"
-						// }]
 					}
 				},
 				"mp-weixin": {

+ 371 - 0
pages/device/device-list/agricultural/index.vue

@@ -0,0 +1,371 @@
+<template>
+  <view class="container">
+    <!-- 顶部搜索与筛选 -->
+    <view class="search-bar">
+      <input v-model="filters.search" placeholder="搜索:名称 / 编号 / 所属农场" class="search-input" />
+      <picker mode="selector" :range="machineTypeDisplayOptions" @change="onTypeChange" class="filter-picker">
+        <view class="filter-label">类型:{{ selectedTypeLabel }}</view>
+      </picker>
+      <picker mode="selector" :range="onlineStatusDisplayOptions" @change="onStatusChange" class="filter-picker">
+        <view class="filter-label">状态:{{ selectedStatusLabel }}</view>
+      </picker>
+      <button @click="onSearch" class="search-btn">搜索</button>
+    </view>
+
+    <!-- 列表 -->
+    <scroll-view scroll-y class="list-wrapper" :style="{height: listHeight+'px'}">
+      <view v-if="machines.length === 0 && !loading" class="empty-tip">暂无农机数据</view>
+
+      <view v-for="machine in machines" :key="machine.id" class="machine-card" @click="navigateToDetail(machine)">
+        <image :src="machine.imageUrl || '/static/icons/machine_default.png'" class="machine-image" mode="aspectFill" />
+
+        <view class="machine-info">
+          <view class="row">
+            <text class="machine-name">{{ machine.machineName }}</text>
+            <text class="machine-code">({{ machine.machineCode }})</text>
+          </view>
+
+          <view class="row meta-row">
+            <text class="meta-item">类型:{{ getMachineTypeLabel(machine.machineType) }}</text>
+            <text class="meta-item">农场:{{ machine.deptName || '-' }}</text>
+            <text class="meta-item">地块:{{ machine.currentField || '-' }}</text>
+          </view>
+
+          <view class="row status-row">
+            <view :class="['status-badge', onlineClass(machine.onlineStatus)]">{{ onlineStatusLabel(machine.onlineStatus) }}</view>
+            <text class="meta-item">保养:{{ maintenanceLabel(machine.maintenanceStatus) }}</text>
+            <text class="meta-item">定位:{{ locationLabel(machine.locationStatus) }}</text>
+            <text class="alarm-count">告警:{{ machine.alarmCount || 0 }}</text>
+          </view>
+
+          <view class="row footer-row">
+            <text class="manager">负责人:{{ machine.managerName || '-' }}</text>
+            <text class="date">启用:{{ formatDate(machine.purchaseDate) }}</text>
+          </view>
+        </view>
+      </view>
+
+      <view v-if="loading" class="loading-tip">加载中...</view>
+
+      <!-- 分页/加载更多 -->
+      <button v-if="!allLoaded && !loading" @click="loadMore" class="load-more-btn">加载更多</button>
+      <text v-if="allLoaded" class="end-tip">已加载全部</text>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { machinesDeviceList } from "@/api/services/agriculturalMachines.js";
+import storage from "@/utils/storage.js";
+
+export default {
+  data() {
+    return {
+      machines: [],
+      loading: false,
+      pageNum: 1,
+      pageSize: 10,
+      total: 0,
+      allLoaded: false,
+      listHeight: 0,
+      filters: {
+        search: '',
+        machineType: '', // numeric or string code from backend
+        onlineStatus: '' // filter by onlineStatus
+      },
+      machineTypeDisplayOptions: ['全部','其他','拖拉机','收割机','播种机','喷雾机','农业智能体'],
+      machineTypeCodeOptions: ['', '0','1','2','3','4','5'],
+      onlineStatusDisplayOptions: ['全部','离线','在线','待命','作业中','维护中','故障'],
+      onlineStatusCodeOptions: ['', '0','1','2','3','4','5'],
+      selectedTypeLabel: '全部',
+      selectedStatusLabel: '全部'
+    }
+  },
+
+  methods: {
+    formatDate(dateStr) {
+      if (!dateStr) return '-';
+      const d = new Date(dateStr);
+      if (isNaN(d.getTime())) return '-';
+      const y = d.getFullYear();
+      const m = (`0${d.getMonth() + 1}`).slice(-2);
+      const day = (`0${d.getDate()}`).slice(-2);
+      return `${y}-${m}-${day}`;
+    },
+
+    getMachineTypeLabel(type) {
+      // backend uses numbers or strings; coerce to number if possible
+      const map = {
+        '0': '其他',
+        '1': '拖拉机',
+        '2': '收割机',
+        '3': '播种机',
+        '4': '喷雾机',
+        '5': '农业智能体'
+      };
+      return map[String(type)] || (type ? String(type) : '其他');
+    },
+
+    onlineStatusLabel(code) {
+      const map = {
+        '0': '离线',
+        '1': '在线',
+        '2': '待命',
+        '3': '作业中',
+        '4': '维护中',
+        '5': '故障'
+      };
+      return map[String(code)] || '-';
+    },
+
+    onlineClass(code) {
+      const c = Number(code);
+      if (c === 1) return 'status-online';
+      if (c === 0) return 'status-offline';
+      if (c === 5) return 'status-error';
+      return 'status-idle';
+    },
+
+    maintenanceLabel(code) {
+      const map = { '1': '正常', '2': '需保养', '3': '待修复' };
+      return map[code] || (code ? code : '-');
+    },
+
+    locationLabel(code) {
+      const map = { '1': '良好', '2': '异常', '3': '无信号' };
+      return map[code] || (code ? code : '-');
+    },
+
+    onTypeChange(e) {
+      const idx = Number(e.detail.value);
+      this.selectedTypeLabel = this.machineTypeDisplayOptions[idx];
+      this.filters.machineType = this.machineTypeCodeOptions[idx] || '';
+    },
+
+    onStatusChange(e) {
+      const idx = Number(e.detail.value);
+      this.selectedStatusLabel = this.onlineStatusDisplayOptions[idx];
+      this.filters.onlineStatus = this.onlineStatusCodeOptions[idx] || '';
+    },
+
+    onSearch() {
+      this.pageNum = 1;
+      this.machines = [];
+      this.allLoaded = false;
+      this.fetchMachines();
+    },
+
+    fetchMachines() {
+      if (this.loading || this.allLoaded) return;
+      this.loading = true;
+      uni.showLoading({ title: '加载中...' });
+
+      const params = {
+        pageNum: this.pageNum,
+        pageSize: this.pageSize,
+        machineName: this.filters.search || undefined,
+        machineCode: this.filters.search || undefined,
+        deptName: this.filters.search || undefined,
+        machineType: this.filters.machineType || undefined,
+        onlineStatus: this.filters.onlineStatus || undefined
+      };
+
+      machinesDeviceList(params)
+        .then(res => {
+          if (res && res.data.code === 200 && res.data.rows) {
+            const data = res.data.rows;
+            if (this.pageNum === 1) {
+              this.machines = data;
+            } else {
+              this.machines = this.machines.concat(data);
+            }
+            this.total = res.data.total
+            if (this.machines.length >= this.total) {
+              this.allLoaded = true;
+            } else {
+              this.allLoaded = false;
+            }
+          } else {
+            uni.showToast({ title: (res && res.data.rows && res.data.msg) || '获取数据失败', icon: 'none' });
+          }
+        })
+        .catch(err => {
+          console.error('fetchMachines error', err);
+          uni.showToast({ title: '请求失败', icon: 'none' });
+        })
+        .finally(() => {
+          this.loading = false;
+          uni.hideLoading();
+        });
+    },
+
+    loadMore() {
+      if (this.loading || this.allLoaded) return;
+      this.pageNum += 1;
+      this.fetchMachines();
+    },
+
+    navigateToDetail(machine) {
+      // Placeholder navigation - create detail page separately if needed
+      uni.navigateTo({
+        url: '/pages/device/device-list/detail-machine?id=' + machine.id,
+        success: (res) => {
+          setTimeout(() => {
+            uni.$emit('agriculturalMachinesData', {
+              machineId: machine.id,
+              machineCode: machine.machineCode,
+              machineName: machine.machineName,
+              onlineStatus: machine.onlineStatus,
+              updateTime: machine.updateTime
+            });
+          }, 100);
+        }
+      });
+    }
+  },
+
+  onLoad() {
+    // compute list height to make scroll-view fill screen (simple heuristic)
+    const systemInfo = uni.getSystemInfoSync();
+    // subtract header/search height approx 160px
+    this.listHeight = systemInfo.windowHeight - 160;
+    // initial load
+    this.fetchMachines();
+  },
+
+  onPullDownRefresh() {
+    this.pageNum = 1;
+    this.machines = [];
+    this.allLoaded = false;
+    this.fetchMachines();
+    setTimeout(() => {
+      uni.stopPullDownRefresh();
+    }, 800);
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  padding: 24rpx;
+  background-color: #F9FCFA;
+  min-height: 100vh;
+  box-sizing: border-box;
+}
+.search-bar {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-bottom: 18rpx;
+}
+.search-input {
+  flex: 1;
+  height: 68rpx;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  background: #fff;
+  border: 1rpx solid #EFEFEF;
+}
+.filter-picker {
+  width: 180rpx;
+  height: 68rpx;
+  background: #fff;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 1rpx solid #EFEFEF;
+}
+.filter-label {
+  color: #666;
+}
+.search-btn {
+  background: #3BB44A;
+  color: #fff;
+  padding: 14rpx 20rpx;
+  border-radius: 12rpx;
+}
+.list-wrapper {
+  width: 100%;
+}
+.machine-card {
+  display: flex;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 20rpx;
+  margin-bottom: 18rpx;
+  box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.04);
+  align-items: center;
+}
+.machine-image {
+  width: 140rpx;
+  height: 100rpx;
+  border-radius: 12rpx;
+  margin-right: 18rpx;
+  background: #f6f6f6;
+}
+.machine-info {
+  flex: 1;
+}
+.row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+.machine-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-right: 8rpx;
+}
+.machine-code {
+  color: #999;
+  font-size: 24rpx;
+}
+.meta-row .meta-item {
+  margin-right: 18rpx;
+  color: #666;
+  font-size: 24rpx;
+}
+.status-row .status-badge {
+  padding: 8rpx 12rpx;
+  border-radius: 8rpx;
+  color: #fff;
+  font-size: 22rpx;
+  margin-right: 12rpx;
+}
+.status-online { background: #4CAF50; }
+.status-offline { background: #F56C6C; }
+.status-error { background: #FF9800; }
+.status-idle { background: #9E9E9E; }
+.alarm-count {
+  margin-left: 12rpx;
+  color: #F56C6C;
+  font-weight: 600;
+}
+.footer-row {
+  justify-content: space-between;
+  color: #999;
+  font-size: 22rpx;
+}
+.loading-tip, .end-tip {
+  text-align: center;
+  color: #999;
+  margin: 18rpx 0;
+}
+.empty-tip {
+  text-align: center;
+  color: #999;
+  margin-top: 60rpx;
+}
+.load-more-btn {
+  width: 80%;
+  margin: 18rpx auto;
+  background: #fff;
+  border: 1rpx solid #EFEFEF;
+  padding: 14rpx 0;
+  border-radius: 12rpx;
+}
+</style>
+
+

+ 576 - 78
pages/device/device-list/detail-machine.vue

@@ -4,13 +4,13 @@
     <view class="device-header">
       <view class="device-info-row">
         <view class="device-name-container">
-          <text class="device-name">{{ deviceInfo.name }}</text>
+          <text class="device-name">{{ deviceInfo.machineName }}</text>
           <view 
             class="status-tag" 
-            :class="deviceInfo.status === 'online' ? 'status-online' : 'status-offline'"
+            :class="deviceInfo.onlineStatus == 1 ? 'status-online' : 'status-offline'"
           >
-            <view class="status-dot" :class="{'offline-dot': deviceInfo.status === 'offline'}"></view>
-            {{ deviceInfo.status === 'online' ? '在线' : '离线' }}
+            <view class="status-dot" :class="{'offline-dot': deviceInfo.onlineStatus == 0}"></view>
+            {{ deviceInfo.onlineStatus == 1 ? '在线' : '离线' }}  
           </view>
         </view>
         
@@ -25,7 +25,7 @@
             <image src="/static/icons/device_icon.png" mode="aspectFit" style="width: 36rpx; height: 36rpx;"></image>
           </view>
           <text class="meta-label">设备编号:</text>
-          <text class="meta-value">{{ deviceInfo.deviceId }}</text>
+          <text class="meta-value">{{ deviceInfo.machineCode }}</text>
         </view>
         
         <view class="device-meta-item">
@@ -41,19 +41,69 @@
             <image src="/static/icons/clock_icon.png" mode="aspectFit" style="width: 36rpx; height: 36rpx;"></image>
           </view>
           <text class="meta-label">最近更新:</text>
-          <text class="meta-value">{{ deviceInfo.lastUpdate }}</text>
+          <text class="meta-value">{{ deviceInfo.updateTime }}</text>
         </view>
       </view>
     </view>
 
+    <!-- 作业任务列表(竖向排列) -->
+    <view class="task-section">
+      <view class="section-title task-title-row">
+        <view class="task-title-left">
+          <text class="task-title-text">作业任务列表</text>
+          <text class="task-title-sub">配置并选择当前设备的作业</text>
+        </view>
+        <view class="task-add-btn" @click.stop="goToCreateJob">
+          <text class="task-add-plus">+</text>
+        </view>
+      </view>
+
+      <view v-if="taskList.length" class="task-list">
+        <view
+          v-for="task in taskList"
+          :key="task.id"
+          class="task-card"
+          :class="{ active: task.id === selectedTaskId }"
+          @click="selectTask(task)"
+        >
+          <view class="task-header">
+            <view class="task-icon">
+              <image src="/static/icons/task_icon.png" mode="aspectFit" class="task-icon-img"></image>
+            </view>
+            <view class="task-title-wrap">
+              <text class="task-title">{{ task.name }}</text>
+              <text class="task-sub">{{ task.fieldArea }}</text>
+            </view>
+            <view class="task-area-type">{{ task.areaTypeText }}</view>
+            <view class="task-radio">
+              <view class="radio-outer" :class="{ checked: task.id === selectedTaskId }">
+                <view class="radio-inner"></view>
+              </view>
+            </view>
+          </view>
+          <view class="task-meta">
+            <text class="task-time">计划时间:{{ task.planTime }}</text>
+            <view class="task-meta-right">
+              <text class="task-status">{{ task.statusText }}</text>
+              <button class="task-delete-btn" @click.stop="confirmDeleteTask(task)">删除</button>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view v-else class="task-empty">
+        <text class="task-empty-text">当前设备暂无作业任务</text>
+      </view>
+    </view>
+
     <!-- 设备状态信息区域 -->
-    <view class="status-section">
+    <!-- <view class="status-section">
       <view class="section-title">
         <text>设备状态</text>
       </view>
       
       <view class="status-grid">
-        <!-- 作业状态 -->
+        <!-- 作业状态
         <view class="status-item" hover-class="status-item-hover">
           <text class="status-label">作业状态</text>
           <view class="status-value-container">
@@ -66,7 +116,7 @@
           <text class="status-time">{{ deviceInfo.statusUpdateTime || '1分钟前' }}</text>
         </view>
         
-        <!-- 电量状态 -->
+        <!-- 电量状态
         <view class="status-item" hover-class="status-item-hover">
           <text class="status-label">电量</text>
           <view class="status-value-container">
@@ -79,7 +129,7 @@
           <text class="status-time">{{ deviceInfo.batteryUpdateTime || '30秒前' }}</text>
         </view>
         
-        <!-- GPS信号 -->
+        <!-- GPS信号
         <view class="status-item" hover-class="status-item-hover">
           <text class="status-label">GPS信号</text>
           <view class="status-value-container">
@@ -92,7 +142,7 @@
           <text class="status-time">{{ deviceInfo.gpsUpdateTime || '15秒前' }}</text>
         </view>
         
-        <!-- 工作时长 -->
+        <!-- 工作时长
         <view class="status-item" hover-class="status-item-hover">
           <text class="status-label">今日工作</text>
           <view class="status-value-container">
@@ -102,7 +152,7 @@
           <text class="status-time">累计时长</text>
         </view>
         
-        <!-- 作业面积 -->
+        <!-- 作业面积
         <view class="status-item" hover-class="status-item-hover">
           <text class="status-label">今日面积</text>
           <view class="status-value-container">
@@ -112,7 +162,7 @@
           <text class="status-time">已完成</text>
         </view>
         
-        <!-- 当前速度 -->
+        <!-- 当前速度
         <view class="status-item" hover-class="status-item-hover">
           <text class="status-label">当前速度</text>
           <view class="status-value-container">
@@ -122,9 +172,9 @@
           <text class="status-time">{{ deviceInfo.speedUpdateTime || '实时' }}</text>
         </view>
       </view>
-    </view>
+    </view> -->
 
-    <!-- 设备控制区域 -->
+    <!-- 设备控制区域 
     <view class="control-section">
       <view class="section-title">
         <text>设备控制</text>
@@ -134,7 +184,7 @@
       </view>
       
       <view class="control-container">
-        <!-- 启动/停止按钮 -->
+        <!-- 启动/停止按钮
         <view class="engine-control">
           <view 
             class="engine-button" 
@@ -152,9 +202,9 @@
           </view>
         </view>
         
-        <!-- 方向控制 -->
+        <!-- 方向控制
         <view class="direction-control">
-          <!-- 前进按钮 -->
+          <!-- 前进按钮 
           <view class="control-btn control-forward" 
                 :class="{'disabled': !isEngineOn || deviceInfo.status === 'offline', 'active': activeControl === 'forward'}"
                 @touchstart="startControl('forward')" 
@@ -163,9 +213,9 @@
             <text>前进</text>
           </view>
           
-          <!-- 左右控制行 -->
+          <!-- 左右控制行 
           <view class="control-row">
-            <!-- 左转按钮 -->
+            <!-- 左转按钮 
             <view class="control-btn control-left" 
                   :class="{'disabled': !isEngineOn || deviceInfo.status === 'offline', 'active': activeControl === 'left'}"
                   @touchstart="startControl('left')" 
@@ -174,7 +224,7 @@
               <text>左转</text>
             </view>
             
-            <!-- 中心状态显示 -->
+            <!-- 中心状态显示
             <view class="control-center">
               <view class="speed-display">
                 <text class="speed-value">{{ currentSpeed }}</text>
@@ -182,7 +232,7 @@
               </view>
             </view>
             
-            <!-- 右转按钮 -->
+            <!-- 右转按钮
             <view class="control-btn control-right" 
                   :class="{'disabled': !isEngineOn || deviceInfo.status === 'offline', 'active': activeControl === 'right'}"
                   @touchstart="startControl('right')" 
@@ -192,7 +242,7 @@
             </view>
           </view>
           
-          <!-- 后退按钮 -->
+          <!-- 后退按钮
           <view class="control-btn control-backward" 
                 :class="{'disabled': !isEngineOn || deviceInfo.status === 'offline', 'active': activeControl === 'backward'}"
                 @touchstart="startControl('backward')" 
@@ -202,7 +252,7 @@
           </view>
         </view>
         
-        <!-- 快捷操作按钮 -->
+        <!-- 快捷操作按钮
         <view class="quick-controls">
           <view class="quick-control-btn" 
                 :class="{'disabled': deviceInfo.status === 'offline'}"
@@ -232,6 +282,17 @@
           </view>
         </view>
       </view>
+    </view>-->
+
+    <!-- 底部“开始作业”按钮 -->
+    <view class="task-footer">
+      <button
+        class="start-btn"
+        :class="{ disabled: !taskList.length || !selectedTaskId || deviceInfo.onlineStatus !== 1 }"
+        @click="startWork"
+      >
+        开始作业
+      </button>
     </view>
 
     <!-- 告警信息列表 -->
@@ -278,28 +339,34 @@
 </template>
 
 <script>
+import { machineAlarmRecordsList } from '@/api/services/machineAlarmRecords';
+import { deviceTasksList, startTask, deleteTask } from '@/api/services/job';
 export default {
   data() {
     return {
       // 设备信息
       deviceInfo: {
-        id: '',
-        name: '智能播种机-001',
-        deviceId: 'AGM-001-2024',
-        location: '东区水稻田A块',
-        status: 'online', // online, offline
-        workStatus: 'idle', // working, idle, error
-        battery: 85,
-        gpsSignal: 92,
-        workDuration: '2.5',
-        workArea: '15.6',
-        currentSpeed: '0',
-        lastUpdate: '1分钟前',
-        statusUpdateTime: '1分钟前',
-        batteryUpdateTime: '30秒前',
-        gpsUpdateTime: '15秒前',
-        speedUpdateTime: '实时'
+        // id: '',
+        // name: '智能播种机-001',
+        // deviceId: 'AGM-001-2024',
+        // location: '东区水稻田A块',
+        // status: 'online', // online, offline
+        // workStatus: 'idle', // working, idle, error
+        // battery: 85,
+        // gpsSignal: 92,
+        // workDuration: '2.5',
+        // workArea: '15.6',
+        // currentSpeed: '0',
+        // lastUpdate: '1分钟前',
+        // statusUpdateTime: '1分钟前',
+        // batteryUpdateTime: '30秒前',
+        // gpsUpdateTime: '15秒前',
+        // speedUpdateTime: '实时'
       },
+
+      // 作业任务列表
+      taskList: [],
+      selectedTaskId: null,
       
       // 控制状态
       isEngineOn: false,
@@ -308,33 +375,40 @@ export default {
       
       // 页面状态
       isRefreshing: false,
-      
+      alarmTypeMap: {
+        0: '其他',
+        1: '发动机',
+        2: '燃油',
+        3: '温度',
+        4: '压力',
+        5: '定位',
+      },
       // 告警数据
       alerts: [
-        {
-          id: 1,
-          title: '电量不足警告',
-          description: '设备电量低于30%,建议及时充电',
-          level: 'medium',
-          time: '5分钟前',
-          handled: false
-        },
-        {
-          id: 2,
-          title: 'GPS信号弱',
-          description: '当前GPS信号较弱,可能影响导航精度',
-          level: 'low',
-          time: '10分钟前',
-          handled: false
-        },
-        {
-          id: 3,
-          title: '作业异常',
-          description: '检测到播种深度异常,请检查设备状态',
-          level: 'high',
-          time: '15分钟前',
-          handled: false
-        }
+        // {
+        //   id: 1,
+        //   title: '电量不足警告',
+        //   description: '设备电量低于30%,建议及时充电',
+        //   level: 'medium',
+        //   time: '5分钟前',
+        //   handled: false
+        // },
+        // {
+        //   id: 2,
+        //   title: 'GPS信号弱',
+        //   description: '当前GPS信号较弱,可能影响导航精度',
+        //   level: 'low',
+        //   time: '10分钟前',
+        //   handled: false
+        // },
+        // {
+        //   id: 3,
+        //   title: '作业异常',
+        //   description: '检测到播种深度异常,请检查设备状态',
+        //   level: 'high',
+        //   time: '15分钟前',
+        //   handled: false
+        // }
       ]
     }
   },
@@ -347,15 +421,16 @@ export default {
   },
   
   onLoad(options) {
+    this.deviceInfo.id = options.id;
     // 获取设备ID参数
-    if (options.deviceId) {
-      this.deviceInfo.deviceId = options.deviceId;
-    }
-    
-    // 获取设备ID参数(兼容不同参数名)
-    if (options.id) {
-      this.deviceInfo.id = options.id;
-    }
+    uni.$once('agriculturalMachinesData', (data) => {
+      if (data ) {
+        this.deviceInfo.machineCode = data.machineCode;
+        this.deviceInfo.machineName = data.machineName;
+        this.deviceInfo.onlineStatus = data.onlineStatus;
+        this.deviceInfo.updateTime = data.updateTime;
+      }
+    });
     
     // 设置导航栏标题
     uni.setNavigationBarTitle({
@@ -364,6 +439,10 @@ export default {
     
     // 加载设备数据
     this.loadDeviceData();
+    // 默认加载当前设备的作业任务
+    this.loadTaskList();
+    // 加载设备告警数据
+    this.loadDeviceAlarmData();
   },
   
   onShow() {
@@ -377,28 +456,115 @@ export default {
   },
   
   methods: {
+    // 加载设备告警数据
+    loadDeviceAlarmData() {
+      return machineAlarmRecordsList({
+        machineId: this.deviceInfo.id
+      }).then(res => {
+        console.log('设备告警数据:', res);
+        if (res.data.code === 200 && res.data.rows) {
+          this.alerts = res.data.rows.map(item => ({
+            id: item.id,
+            // title: this.formatAlarmType(item.alarmType),
+            title: item.alarmDesc,
+            level: item.alarmLevel === 3 ? 'high' : item.alarmLevel === 2 ? 'medium' : 'low',
+            time: item.alarmTime
+          }));
+        }
+      });
+    },
+    formatAlarmType(level) {
+      return this.alarmTypeMap[level] || '其他';
+    },
     // 加载设备数据
     loadDeviceData() {
       // 这里可以调用API获取设备详细信息
       console.log('加载设备数据:', this.deviceInfo.deviceId);
     },
+
+    // 加载当前设备的作业任务列表
+    loadTaskList() {
+      const params = {
+        pageNum: 1,
+        pageSize: 10,
+        // 文档未声明 deviceId 过滤,但为了兼容后端如支持按设备筛选,这里传入
+        deviceId: this.deviceInfo.id
+      };
+
+      return deviceTasksList(params)
+        .then((res) => {
+          console.log("res任务作业列表",res);
+          
+          const { data } = res || {};
+          if (data && data.code === 200) {
+            const list = (data.rows) ? data.rows : [];
+
+            // 适配页面字段
+            const areaTypeTextMap = {
+              1: '回字形',
+              2: '弓字形',
+              3: '自定义',
+              4: '垄沟'
+            }
+
+            this.taskList = list.map((item) => ({
+              id: item.id,
+              name: item.taskName,
+              fieldArea: item.workArea ? item.workArea.areaName : '',
+              planTime: item.createTime,
+              status: item.taskStatus,
+              statusText: item.taskStatusDesc,
+              areaType: item.workAreas && item.workAreas.areaType,
+              areaTypeText: areaTypeTextMap[item.workAreas && item.workAreas.areaType || 0] || '未知'
+            }));
+            console.log('taskList',this.taskList);
+
+            // 默认选中第一条
+            if (this.taskList.length) {
+              // 若当前已选中且仍存在,则保持;否则选第一条
+              const exists = this.selectedTaskId && this.taskList.some(t => t.id === this.selectedTaskId);
+              this.selectedTaskId = exists ? this.selectedTaskId : this.taskList[0].id;
+            } else {
+              this.selectedTaskId = null;
+            }
+          } else {
+            this.taskList = [];
+            this.selectedTaskId = null;
+            uni.showToast({
+              title: (data && data.msg) ? data.msg : '获取作业任务失败',
+              icon: 'none'
+            });
+          }
+        })
+        .catch((err) => {
+          console.error('获取作业任务列表失败:', err);
+          this.taskList = [];
+          this.selectedTaskId = null;
+          uni.showToast({
+            title: '获取作业任务失败',
+            icon: 'none'
+          });
+        });
+    },
     
     // 刷新数据
     refreshData() {
       this.isRefreshing = true;
-      
-      // 模拟刷新延迟
-      setTimeout(() => {
+
+      Promise.all([
+        this.loadTaskList(),
+        this.loadDeviceAlarmData()
+      ]).finally(() => {
         this.isRefreshing = false;
         // 更新最近更新时间
         this.deviceInfo.lastUpdate = '刚刚';
-        
+
         uni.showToast({
           title: '刷新成功',
           icon: 'success',
           duration: 1500
         });
-      }, 1000);
+      });
     },
     
     // 获取工作状态文本
@@ -559,7 +725,129 @@ export default {
         icon: 'success'
       });
     },
+
+    // 跳转到新增作业页面
+    goToCreateJob() {
+      uni.navigateTo({
+        url: `/pages/device/job-create/index?machineCode=${this.deviceInfo.machineCode}&id=${this.deviceInfo.id}`
+      });
+    },
+
+    // 选择作业任务
+    selectTask(task) {
+      this.selectedTaskId = task.id;
+    },
+
+    // 开始作业
+    startWork() {
+      if (!this.taskList.length) {
+        uni.showToast({
+          title: '暂无可执行的作业任务',
+          icon: 'none'
+        });
+        return;
+      }
+      if (!this.selectedTaskId) {
+        uni.showToast({
+          title: '请先选择一个作业任务',
+          icon: 'none'
+        });
+        return;
+      }
+      if (this.deviceInfo.onlineStatus !== 1) {
+        uni.showToast({
+          title: '设备未在线,无法开始作业',
+          icon: 'none'
+        });
+        return;
+      }
+
+      const task = this.taskList.find(t => t.id === this.selectedTaskId);
+      uni.showModal({
+        title: '开始作业',
+        content: `确定开始执行「${task.name}」吗?`,
+        success: async (res) => {
+          if (res.confirm) {
+            try {
+              uni.showLoading({ title: '启动中...' });
+              const resp = await startTask(this.selectedTaskId);
+              const { data } = resp || {};
+              if (data && data.code === 200) {
+                uni.showToast({
+                  title: '作业已启动',
+                  icon: 'success'
+                });
+                this.isEngineOn = true;
+                this.deviceInfo.workStatus = 'working';
+
+                // 跳转到作业详情页
+                setTimeout(() => {
+                  uni.navigateTo({
+                    url: `/pages/device/job-detail/index?id=${this.selectedTaskId}&deviceId=${this.deviceInfo.machineCode}&deviceName=${this.deviceInfo.machineName}`
+                  });
+                }, 300);
+              } else {
+                uni.showToast({
+                  title: (data && data.msg) ? data.msg : '启动失败',
+                  icon: 'none'
+                });
+              }
+            } catch (e) {
+              console.error('开始作业失败:', e);
+              uni.showToast({
+                title: '网络异常,启动失败',
+                icon: 'none'
+              });
+            } finally {
+              uni.hideLoading();
+            }
+          }
+        }
+      });
+    },
     
+    // 删除作业
+    confirmDeleteTask(task) {
+      if (!task || !task.id) return
+
+      uni.showModal({
+        title: '确认删除',
+        content: `确定删除作业「${task.name}」吗?删除后不可恢复。`,
+        confirmText: '删除',
+        confirmColor: '#F56C6C',
+        success: async (res) => {
+          if (!res.confirm) return
+
+          try {
+            uni.showLoading({ title: '删除中...' })
+            const resp = await deleteTask(task.id)
+            const { data } = resp || {}
+            if (data && data.code === 200) {
+              uni.showToast({ title: '删除成功', icon: 'success' })
+
+              // 若删除的是当前选中任务,清理选中态
+              if (this.selectedTaskId === task.id) {
+                this.selectedTaskId = null
+              }
+
+              // 重新拉取列表
+              await this.loadTaskList()
+            } else {
+              uni.showToast({
+                title: (data && data.msg) ? data.msg : '删除失败',
+                icon: 'none'
+              })
+            }
+          } catch (e) {
+            console.error('删除作业失败:', e)
+            uni.showToast({ title: '网络异常,删除失败', icon: 'none' })
+          } finally {
+            uni.hideLoading()
+          }
+        }
+      })
+    },
+
     // 处理告警
     handleAlert(alert) {
       uni.showModal({
@@ -1160,4 +1448,214 @@ export default {
   font-size: 28rpx;
   color: #999999;
 }
+
+/* 作业任务列表 */
+.task-section {
+  background: #ffffff;
+  margin: 0 24rpx 20rpx;
+  padding: 28rpx 24rpx 20rpx;
+  border-radius: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
+}
+
+.task-list {
+  margin-top: 8rpx;
+}
+
+.task-card {
+  background: #f9fbfa;
+  border-radius: 18rpx;
+  padding: 20rpx 18rpx;
+  margin-bottom: 16rpx;
+  border: 1rpx solid rgba(0, 0, 0, 0.03);
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
+}
+
+.task-card.active {
+  border-color: #3bb44a;
+  background: #f2fff4;
+  box-shadow: 0 4rpx 14rpx rgba(59, 180, 74, 0.12);
+}
+
+.task-header {
+  display: flex;
+  align-items: center;
+}
+
+.task-icon {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 16rpx;
+  background: linear-gradient(135deg, #66cc6a, #3bb44a);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 16rpx;
+}
+
+.task-icon-img {
+  width: 34rpx;
+  height: 34rpx;
+}
+
+.task-area-type {
+  font-size: 22rpx;
+  color: #888;
+  margin-left: 8rpx;
+  flex-shrink: 0;
+}
+
+.task-title-wrap {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.task-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.task-sub {
+  margin-top: 6rpx;
+  font-size: 24rpx;
+  color: #8c9396;
+}
+
+.task-radio {
+  margin-left: 12rpx;
+}
+
+.radio-outer {
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 16rpx;
+  border: 2rpx solid #c0c4cc;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.radio-outer.checked {
+  border-color: #3bb44a;
+  background: rgba(59, 180, 74, 0.1);
+}
+
+.radio-inner {
+  width: 18rpx;
+  height: 18rpx;
+  border-radius: 9rpx;
+  background: #3bb44a;
+  opacity: 0;
+}
+
+.radio-outer.checked .radio-inner {
+  opacity: 1;
+}
+
+.task-meta {
+  margin-top: 12rpx;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 24rpx;
+}
+
+.task-time {
+  color: #909399;
+}
+
+.task-meta-right {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+
+.task-status {
+  color: #3bb44a;
+}
+
+.task-delete-btn {
+  height: 52rpx;
+  line-height: 52rpx;
+  padding: 0 16rpx;
+  border-radius: 26rpx;
+  background-color: rgba(245, 108, 108, 0.12);
+  color: #F56C6C;
+  font-size: 22rpx;
+}
+
+.task-empty {
+  padding: 40rpx 0 10rpx;
+  display: flex;
+  justify-content: center;
+}
+
+.task-empty-text {
+  font-size: 26rpx;
+  color: #999999;
+}
+
+.task-footer {
+  padding: 12rpx 30rpx 24rpx;
+  background: #f8fcf9;
+}
+
+.start-btn {
+  width: 100%;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 44rpx;
+  background: linear-gradient(135deg, #3bb44a, #66cc6a);
+  color: #ffffff;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+
+.start-btn.disabled {
+  background: #dcdfe6;
+  color: #ffffff;
+}
+
+/* 作业标题行加号按钮(与新增作业页保持一致) */
+.task-title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.task-title-left {
+  display: flex;
+  flex-direction: column;
+}
+
+.task-title-text {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.task-title-sub {
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #8c9396;
+}
+
+.task-add-btn {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 30rpx;
+  background: linear-gradient(135deg, #3bb44a, #66cc6a);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.3);
+}
+
+.task-add-plus {
+  font-size: 40rpx;
+  color: #ffffff;
+  line-height: 1;
+}
 </style> 

+ 46 - 2
pages/device/device-list/index.vue

@@ -173,6 +173,7 @@
 
 <script>
 import { fetchDevicesByType } from "@/api/services/device.js";
+import { machinesDeviceList } from "@/api/services/agriculturalMachines.js";
 import storage from "@/utils/storage.js";
 
 export default {
@@ -226,10 +227,31 @@ export default {
     this.initFieldInfo();
     
     // 加载设备列表
-    this.loadDeviceList();
+    this.deviceType === 'tractor' ? this.loadMachinesList() : this.loadDeviceList();
+    // this.loadDeviceList();
   },
   
   methods: {
+    loadMachinesList() {
+      machinesDeviceList({
+        pageNum: this.pageNum,
+        pageSize: this.pageSize,
+
+      }).then(res => {
+        console.log("res收到发斯蒂芬斯蒂",res);
+        
+        res.data.code === 200 && res.data.rows && (this.deviceList = res.data.rows);
+        res.data.code !== 200 && this.handleApiError(res);
+      }).catch(error => {
+        console.error('获取农机设备列表失败', error);
+        uni.showToast({
+          title: '获取农机设备列表失败',
+          icon: 'none'
+        });
+      }).finally(() => {
+        this.loading = false;
+      });
+    },
     // 初始化地块信息
     initFieldInfo() {
       const currentPlots = JSON.parse(storage.getPlots() || '{}');
@@ -393,7 +415,7 @@ export default {
                 deviceTypeId: device.deviceTypeId,
                 fieldName: device.fieldName,
                 deviceName: device.deviceName,
-				status:device.status
+				        status:device.status
               });
             }, 100); // 100ms 通常足够,必要时可加到 200
           },
@@ -1072,4 +1094,26 @@ export default {
   0% { transform: rotate(0deg); }
   100% { transform: rotate(360deg); }
 }
+
+/* 悬浮新增按钮 */
+.floating-add-btn {
+  position: fixed;
+  right: 40rpx;
+  bottom: 160rpx;
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 60rpx;
+  background: linear-gradient(135deg, #3BB44A, #66CC6A);
+  box-shadow: 0 12rpx 30rpx rgba(59, 180, 74, 0.35);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 99;
+}
+
+.floating-add-btn .plus {
+  font-size: 60rpx;
+  color: #ffffff;
+  line-height: 1;
+}
 </style> 

+ 10 - 1
pages/device/index.vue

@@ -173,6 +173,8 @@ export default {
     
     // 跳转到对应设备列表页面
     navigateToDeviceList(type) {
+    console.log("type",type);
+    
 		// 传递指定设备类型的在线、离线数量
 		this.deviceList.forEach((item, index) => {
 			if(item.type === type){
@@ -180,9 +182,15 @@ export default {
 				this.typeOffline =  item.offline
 			}
 		});
+    if(type === 'tractor'){
+      uni.navigateTo({
+        url: `/pages/device/device-list/agricultural/index?type=${type}&typeOnline=${this.typeOnline}&typeOffline=${this.typeOffline}`
+      });
+    }else{
       uni.navigateTo({
         url: `/pages/device/device-list/index?type=${type}&typeOnline=${this.typeOnline}&typeOffline=${this.typeOffline}`
       });
+    }
     },
     
     // 切换地块
@@ -190,7 +198,8 @@ export default {
       uni.navigateTo({
         url: '/pages/field-selector/index?callback=deviceCenter'
       });
-    }
+    },
+
   },
   
   // 页面导航配置

+ 2305 - 0
pages/device/job-create/index.vue

@@ -0,0 +1,2305 @@
+<template>
+  <view class="page">
+    <!-- 步骤头部 -->
+    <view class="header">
+      <view class="title-row">
+        <text class="title">新增作业</text>
+        <text class="device-id">设备:{{ jobState.machineCode || '未知设备' }}</text>
+      </view>
+      <view class="step-row">
+        <view
+          v-for="step in steps"
+          :key="step.id"
+          class="step-item"
+        >
+          <view
+            class="step-index"
+            :class="{
+              active: currentStep === step.id,
+              done: currentStep > step.id
+            }"
+          >
+            <text v-if="currentStep > step.id">✓</text>
+            <text v-else>{{ step.id }}</text>
+          </view>
+          <view class="step-texts">
+            <text class="step-title">{{ step.title }}</text>
+            <text class="step-sub">{{ step.sub }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 主体内容 -->
+    <view class="content">
+      <!-- Step 1: 选择作业区域类型 & 路线类型 -->
+      <view v-if="currentStep === 1" class="step-block">
+        <view class="card select-card">
+          <view class="select-title">选择作业区域类型</view>
+          <view class="select-sub">
+            不同区域形状将影响后续路线生成方式,请根据实际地块选择。
+          </view>
+          <view class="select-tabs">
+            <view
+              v-for="item in areaTypes"
+              :key="item.value"
+              class="select-tab"
+              :class="{ active: selectedAreaType === item.value }"
+              @click="selectAreaType(item.value)"
+            >
+              <text class="select-tab-label">{{ item.label }}</text>
+              <text class="select-tab-sub" v-if="item.desc">
+                {{ item.desc }}
+              </text>
+            </view>
+          </view>
+        </view>
+
+        <view class="card select-card">
+          <view class="select-title">选择路线类型</view>
+          <view class="select-sub">
+            依据区域形状推荐的路线类型,后端将按所选类型生成具体作业路线。
+          </view>
+          <view class="select-tabs">
+            <view
+              v-for="route in availableRouteTypes"
+              :key="route.value"
+              class="select-tab small"
+              :class="{ active: selectedRouteType === route.value }"
+              @click="selectRouteType(route.value)"
+            >
+              <text class="select-tab-label">{{ route.label }}</text>
+              <text class="select-tab-sub" v-if="route.desc">
+                {{ route.desc }}
+              </text>
+            </view>
+          </view>
+        </view>
+
+        <view class="card tips-card">
+          <view class="tips-title">说明</view>
+          <view class="tips-content">
+            <text>
+              - 本步骤仅选择区域类型与路线类型,下一步将进入地图打点新增作业区域。
+            </text>
+            <text>
+              - 当前选择会随作业一起提交至后端,用于指导路线生成策略。
+            </text>
+          </view>
+        </view>
+      </view>
+
+      <!-- Step 2: 地图打点 -->
+      <view v-else-if="currentStep === 2" class="step-block">
+        <!-- 地图占位 -->
+        <view class="map-card">
+          <view class="map-header">
+            <text class="map-title">地图预览</text>
+            <view class="map-header-actions">
+              <button class="btn-location" @click="manualLocation" :loading="locating">
+                <text class="btn-location-text">{{ locating ? '定位中...' : '📍 定位' }}</text>
+              </button>
+            </view>
+            <text class="map-sub">通过遥控器移动设备,在地图上逐点记录</text>
+          </view>
+          <view class="map-body">
+            <!-- 始终渲染地图容器以避免渲染时序问题;未加载脚本时容器为空白 -->
+            <view id="mapContainer"></view>
+            <text v-if="!amapLoaded" class="map-placeholder">
+              地图占位(H5 平台会加载高德地图 SDK){{
+                '\n'
+              }}当前模式:{{ modeLabel }}
+            </text>
+          </view>
+          <view class="map-footer">
+            <text class="map-hint">
+              提示:请使用遥控器移动设备到拐点位置,再点击“新增点”记录坐标。
+            </text>
+          </view>
+        </view>
+
+        <!-- 模式切换 -->
+        <view class="mode-card">
+          <view class="mode-title-row">
+            <text class="mode-title">点位类型</text>
+            <text class="mode-sub">在不同模式下分别记录作业区域、障碍物和返航点</text>
+          </view>
+          <view class="mode-tabs">
+            <view
+              v-for="item in modes"
+              :key="item.value"
+              class="mode-tab"
+              :class="{ active: mode === item.value }"
+              @click="switchMode(item.value)"
+            >
+              <text class="mode-tab-label">{{ item.label }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 控制面板 -->
+        <view class="panel-card">
+          <view class="panel-row">
+            <button class="btn primary" @click="addPoint">新增点</button>
+            <button class="btn ghost" @click="undoPoint" :disabled="!canUndo">
+              撤销
+            </button>
+            <button
+              class="btn ghost"
+              v-if="mode === 'obstacle'"
+              @click="finishCurrentObstacle"
+              :disabled="!currentObstacle.length"
+            >
+              完成当前障碍物
+            </button>
+          </view>
+          <view class="panel-desc">
+            <text v-if="mode === 'area'">
+              作业区域至少需要 3 个点,建议为近似矩形。
+            </text>
+            <text v-else-if="mode === 'obstacle'">
+              每个障碍物可记录多个点,点击“完成当前障碍物”结束本组记录。
+            </text>
+            <text v-else>
+              仅允许 1 个返航点,重复新增会覆盖已有点。
+            </text>
+          </view>
+        </view>
+
+        <!-- 已记录点列表 -->
+        <view class="list-card">
+          <view class="list-title-row">
+            <text class="list-title">已记录点位</text>
+          </view>
+
+          <scroll-view scroll-y class="list-scroll">
+            <!-- 作业区域点 -->
+            <view class="list-group">
+              <view class="list-group-header">
+                <text class="list-group-title">作业区域点({{ jobState.areaPoints.length }})</text>
+              </view>
+              <view
+                v-if="jobState.areaPoints.length"
+                class="point-list"
+              >
+                <view
+                  v-for="(p, index) in jobState.areaPoints"
+                  :key="'area-' + index"
+                  class="point-item"
+                >
+                  <text class="point-label">P{{ index + 1 }}</text>
+                  <text class="point-coord">
+                    {{ formatPoint(p) }}
+                  </text>
+                  <text class="point-time">{{ formatPointTime(p.timestamp) }}</text>
+                </view>
+              </view>
+              <view v-else class="empty-row">
+                <text>暂未记录作业区域点</text>
+              </view>
+            </view>
+
+            <!-- 障碍物点 -->
+            <view class="list-group">
+              <view class="list-group-header">
+                <text class="list-group-title">
+                  障碍物({{ jobState.obstacles.length + (currentObstacle.length ? 1 : 0) }} 组)
+                </text>
+              </view>
+              <view
+                v-if="jobState.obstacles.length || currentObstacle.length"
+                class="obstacle-list"
+              >
+                <!-- 已完成的障碍物组 -->
+                <view
+                  v-for="(obs, oIdx) in jobState.obstacles"
+                  :key="'obs-' + oIdx"
+                  class="obstacle-item"
+                >
+                  <text class="obstacle-title">障碍物 {{ oIdx + 1 }}({{ obs.length }} 点)</text>
+                  <view
+                    v-for="(p, pIdx) in obs"
+                    :key="'obs-' + oIdx + '-' + pIdx"
+                    class="point-item small"
+                  >
+                    <text class="point-label">O{{ oIdx + 1 }}-{{ pIdx + 1 }}</text>
+                    <text class="point-coord">
+                      {{ formatPoint(p) }}
+                    </text>
+                  </view>
+                </view>
+
+                <!-- 正在录入中的障碍物(未点击“完成当前障碍物”之前也要实时回显) -->
+                <view
+                  v-if="currentObstacle.length"
+                  class="obstacle-item"
+                >
+                  <text class="obstacle-title">障碍物 {{ jobState.obstacles.length + 1 }}(录入中,{{ currentObstacle.length }} 点)</text>
+                  <view
+                    v-for="(p, pIdx) in currentObstacle"
+                    :key="'obs-current-' + pIdx"
+                    class="point-item small"
+                  >
+                    <text class="point-label">O{{ jobState.obstacles.length + 1 }}-{{ pIdx + 1 }}</text>
+                    <text class="point-coord">
+                      {{ formatPoint(p) }}
+                    </text>
+                  </view>
+                </view>
+              </view>
+              <view v-else class="empty-row">
+                <text>暂未记录障碍物点</text>
+              </view>
+            </view>
+
+            <!-- 返航点 -->
+            <view class="list-group">
+              <view class="list-group-header">
+                <text class="list-group-title">返航点</text>
+              </view>
+              <view v-if="jobState.returnPoint" class="point-item">
+                <text class="point-label">R</text>
+                <text class="point-coord">
+                  {{ formatPoint(jobState.returnPoint) }}
+                </text>
+                <text class="point-time">
+                  {{ formatPointTime(jobState.returnPoint.timestamp) }}
+                </text>
+              </view>
+              <view v-else class="empty-row">
+                <text>暂未设置返航点</text>
+              </view>
+            </view>
+          </scroll-view>
+        </view>
+      </view>
+
+      <!-- Step 3: 起点选择 -->
+      <view v-else-if="currentStep === 3" class="step-block">
+        <view class="map-card small">
+          <view class="map-header">
+            <text class="map-title">选择起点</text>
+            <text class="map-sub">
+              从已记录的作业区域点中选择作业起点,可通过左右切换预览。
+            </text>
+          </view>
+          <view class="map-body">
+            <text class="map-placeholder">
+              这里显示作业区域示意图(占位){{
+                '\n'
+              }}当前起点:P{{ currentStartDisplay }}
+            </text>
+          </view>
+        </view>
+
+        <view class="panel-card">
+          <view class="panel-row center">
+            <button class="btn ghost" @click="prevStart" :disabled="!canChangeStart">
+              上一个
+            </button>
+            <view class="start-index">
+              <text class="start-index-text">
+                起点:P{{ currentStartDisplay }}
+              </text>
+            </view>
+            <button class="btn ghost" @click="nextStart" :disabled="!canChangeStart">
+              下一个
+            </button>
+          </view>
+          <view class="panel-desc">
+            <text>
+              提示:起点将决定设备的初始行进方向与作业顺序,后端会基于该起点生成具体路线。
+            </text>
+          </view>
+        </view>
+
+        <view class="list-card">
+          <view class="list-title-row">
+            <text class="list-title">作业区域点列表</text>
+          </view>
+          <scroll-view scroll-y class="list-scroll">
+            <view
+              v-for="(p, index) in jobState.areaPoints"
+              :key="'start-' + index"
+              class="point-item"
+              :class="{ active: index === jobState.startPointIndex }"
+              @click="setStartIndex(index)"
+            >
+              <text class="point-label">P{{ index + 1 }}</text>
+              <text class="point-coord">{{ formatPoint(p) }}</text>
+            </view>
+          </scroll-view>
+        </view>
+      </view>
+
+      <!-- Step 4: 作业信息确认 -->
+      <view v-else-if="currentStep === 4" class="step-block">
+        <view class="card confirm-card">
+          <view class="confirm-title">
+            <text>作业基本信息</text>
+          </view>
+
+          <view class="form-item required">
+            <text class="label">作业名称</text>
+            <input
+              class="input"
+              v-model="jobState.jobName"
+              placeholder="请输入作业名称,如“Test device - 北区作业”"
+            />
+          </view>
+
+          <view class="form-item required">
+            <text class="label">地块ID</text>
+            <input
+              class="input"
+              v-model="jobState.fieldId"
+              placeholder="请输入地块ID"
+              type="text"
+            />
+          </view>
+
+          <view class="form-item required">
+            <text class="label">路径宽度(厘米)</text>
+            <input
+              class="input"
+              v-model="jobState.pathWidth"
+              placeholder="请输入路径宽度,单位:厘米"
+              type="number"
+            />
+          </view>
+
+          <view class="summary-row">
+            <text class="summary-label">作业区域类型</text>
+            <text class="summary-value">{{ areaTypeLabel }}</text>
+          </view>
+          <view class="summary-row">
+            <text class="summary-label">路线类型</text>
+            <text class="summary-value">{{ routeTypeLabel }}</text>
+          </view>
+          <view class="summary-row">
+            <text class="summary-label">作业区域点</text>
+            <text class="summary-value">{{ jobState.areaPoints.length }} 个</text>
+          </view>
+          <view class="summary-row">
+            <text class="summary-label">障碍物</text>
+            <text class="summary-value">
+              {{ jobState.obstacles.length }} 组
+            </text>
+          </view>
+          <view class="summary-row">
+            <text class="summary-label">返航点</text>
+            <text class="summary-value">
+              {{ jobState.returnPoint ? '已设置' : '未设置' }}
+            </text>
+          </view>
+          <view class="summary-row">
+            <text class="summary-label">起点索引</text>
+            <text class="summary-value">
+              P{{ currentStartDisplay }}
+            </text>
+          </view>
+        </view>
+
+        <view class="card tips-card">
+          <view class="tips-title">说明</view>
+          <view class="tips-content">
+            <text>
+              - 前端仅负责记录点位与基本配置,并在本页面完成数据完整性校验。
+            </text>
+            <text>
+              - 路线生成、几何合法性校验以及调度逻辑由后端 `/api/job/create` 负责处理。
+            </text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 步骤导航 -->
+    <view class="footer">
+      <button class="btn ghost" @click="prevStep" :disabled="currentStep === 1">
+        上一步
+      </button>
+      <button
+        class="btn primary"
+        v-if="currentStep < 4"
+        @click="nextStep"
+      >
+        下一步
+      </button>
+      <button
+        class="btn primary"
+        v-else
+        :loading="submitting"
+        @click="submitJob"
+      >
+        完成并提交
+      </button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { createJob, getRealtimeData } from '@/api/services/job.js'
+import coordinateUtils from '@/utils/coordinateUtils.js'
+
+    // 地图实例将保存在组件 data 的 `map` 字段中(见 data() 中的 map: null)
+    // 我们在组件方法里设置一个绑定到全局回调的适配器,以便 AMap 的 script callback 能够调用组件内的初始化方法。
+// 简单模拟一个“当前设备坐标”,后续可替换为真实位置上报
+function mockCurrentPoint(baseIndex = 0) {
+  const now = Date.now()
+  const lng = 120.0 + (baseIndex % 10) * 0.0001
+  const lat = 30.0 + (baseIndex % 10) * 0.0001
+  return {
+    lng,
+    lat,
+    timestamp: now
+  }
+}
+
+export default {
+  data() {
+    return {
+      currentStep: 1,
+      steps: [
+        { id: 1, title: '区域与路线', sub: '选择作业区域类型与路线类型' },
+        { id: 2, title: '打点建模', sub: '作业区域 / 障碍物 / 返航点' },
+        { id: 3, title: '选择起点', sub: '确定作业起点位置' },
+        { id: 4, title: '信息确认', sub: '填写作业名称并提交' }
+      ],
+      // 区域类型与路线类型
+      areaTypes: [
+        {
+          value: 'loopArea',
+          label: '回字形区域',
+          desc: '规则四边形地块,适合标准往返或回字形路线',
+          routes: [{ value: 'loop', label: '回字形路线(loop)' }]
+        },
+        {
+          value: 'bowArea',
+          label: '弓子形区域',
+          desc: '一侧为弧形或不规则,适合弓字形或自适应路线',
+          routes: [{ value: 'bow', label: '弓子形路线(bow)' }]
+        },
+        {
+          value: 'customArea',
+          label: '自定义区域',
+          desc: '任意多边形地块,路线由后端自适应规划',
+          routes: [{ value: 'custom', label: '自定义路线(custom)' }]
+        },
+        {
+          value: 'ridgeArea',
+          label: '垄沟区域',
+          desc: '存在大量垄沟或等距行的地块',
+          routes: [{ value: 'ridge', label: '垄沟路线(ridge)' }]
+        }
+      ],
+      selectedAreaType: 'loopArea',
+      selectedRouteType: 'loop',
+      mode: 'area', // area | obstacle | return
+      modes: [
+        { value: 'area', label: '作业区域点' },
+        { value: 'obstacle', label: '障碍物点' },
+        { value: 'return', label: '返航点' }
+      ],
+      currentObstacle: [],
+      submitting: false,
+      locating: false,
+      // 高德地图相关
+      map: null,
+      amapLoaded: false,
+      markers: [],
+      geolocation: null, // 高德定位实例
+      obstacleMarkers: [], // 障碍物标记数组
+      returnMarker: null, // 返航点标记
+
+      // 设备实时位置轮询(用于“遥控车 + 实时打点”)
+      realtimeMarker: null,
+      realtimeTimer: null,
+      lastReportTime: null,
+      latestRealtimeLngLat: null,
+      polling: false,
+      // 回字形(loopArea) 编辑相关
+      loopMarkers: [],
+      loopPolygon: null,
+      loopReplaceIndex: null,
+      // TODO: 将下面的占位 KEY 替换为真实的高德地图 Key(仅 H5 生效)
+      amapKey: '9f2cac7ea18905dd3830cf7360a43a35',
+      jscode: '41af52e416d1fd1b15020dac066cec86',
+      jobState: {
+        machineCode: '',
+        areaType: 'loopArea',
+        routeType: 'loop',
+        areaPoints: [],
+        obstacles: [],
+        returnPoint: null,
+        startPointIndex: 0,
+        jobName: '',
+        fieldId: '',
+        pathWidth: 100
+      }
+      ,
+      // 回字形编辑相关图形对象(外圈逻辑已移除)
+      areaPolygon: null
+    }
+  },
+
+  onLoad(options) {
+    const { machineCode, id } = options || {}
+    if (machineCode) {
+      this.jobState.machineCode = machineCode 
+      this.jobState.machineId = id
+    }
+  },
+  // UniApp 页面就绪生命周期(H5 平台可在此初始化地图)
+    onReady() {
+      // 在页面就绪时再加载 AMap 脚本(确保 DOM 容器存在)
+      if (typeof window !== 'undefined' && typeof document !== 'undefined') {
+        // eslint-disable-next-line no-console
+        console.log('[job-create] onReady - loading AMap script')
+        this.loadScript()
+      }
+    },
+
+  computed: {
+    modeLabel() {
+      const m = this.modes.find(m => m.value === this.mode)
+      return m ? m.label : ''
+    },
+    canUndo() {
+      if (this.mode === 'area') {
+        return this.jobState.areaPoints.length > 0
+      }
+      if (this.mode === 'obstacle') {
+        return this.currentObstacle.length > 0
+      }
+      if (this.mode === 'return') {
+        return !!this.jobState.returnPoint
+      }
+      return false
+    },
+    canChangeStart() {
+      return this.jobState.areaPoints.length > 0
+    },
+    currentStartDisplay() {
+      if (!this.jobState.areaPoints.length) return '-'
+      return this.jobState.startPointIndex + 1
+    },
+    // 展示当前区域类型与路线类型名称
+    areaTypeLabel() {
+      const a = this.areaTypes.find(a => a.value === this.jobState.areaType)
+      return a ? a.label : ''
+    },
+    availableRouteTypes() {
+      const a = this.areaTypes.find(a => a.value === this.selectedAreaType)
+      return a ? a.routes : []
+    },
+    routeTypeLabel() {
+      const list = this.areaTypes.reduce((acc, cur) => {
+        if (cur.routes && cur.routes.length) {
+          acc.push(...cur.routes)
+        }
+        return acc
+      }, [])
+      const r = list.find(r => r.value === this.jobState.routeType)
+      return r ? r.label : this.jobState.routeType
+    }
+  },
+
+  onUnload() {
+    // 页面销毁时停止轮询
+    this.clearRealtimePolling()
+    // 清理所有地图标记
+    this.clearAllMarkers()
+  },
+
+  methods: {
+    loadScript() { // 挂载动态js
+				// 绑定全局回调,AMap 脚本会调用 window.mapInit
+				window.mapInit = () => {
+					this._createMapWhenReady()
+				}
+
+				var script = document.createElement('script');
+				script.src = `https://webapi.amap.com/maps?v=2.0&key=${this.amapKey}&callback=mapInit`;
+				document.body.appendChild(script);
+        this.amapLoaded = true
+			},
+
+    _createMapWhenReady() {
+      const createWhenReady = () => {
+        const container = document.getElementById('mapContainer')
+        if (!container) {
+          // 容器尚未渲染,延迟重试
+          setTimeout(createWhenReady, 200)
+          return
+        }
+
+        // 创建基础地图实例并保存在组件 data.map 中
+        const defaultCenter = [113.382, 22.5211]
+
+        const createMapWithCenter = centerArr => {
+          try {
+            this.map = new AMap.Map('mapContainer', {
+              center: centerArr || defaultCenter,
+              zoom: 16
+            })
+          } catch (err) {
+            console.error('[job-create] create map failed', err)
+            return
+          }
+
+          // 添加卫星图层
+          try {
+            if (AMap.TileLayer && typeof AMap.TileLayer.Satellite === 'function') {
+              const sat = new AMap.TileLayer.Satellite()
+              this.map.add(sat)
+            }
+          } catch (layerErr) {
+            console.warn('[job-create] satellite layer failed', layerErr)
+          }
+
+          // 添加工具栏
+          try {
+            AMap.plugin('AMap.ToolBar', () => {
+              const toolbar = new AMap.ToolBar()
+              if (this.map && typeof this.map.addControl === 'function') {
+                this.map.addControl(toolbar)
+              }
+            })
+          } catch (pluginErr) {
+            console.warn('[job-create] toolbar plugin failed', pluginErr)
+          }
+
+          // 加载高德定位插件(异步加载)
+          AMap.plugin('AMap.Geolocation', () => {
+            this.geolocation = new AMap.Geolocation({
+              enableHighAccuracy: true,
+              timeout: 10000,
+              maximumAge: 0, // 不使用缓存,每次都获取最新位置
+              convert: true, // 自动偏移坐标,偏移后的坐标为高德坐标
+              showButton: false,
+              showMarker: false,
+              showCircle: false,
+              panToLocation: false,
+              zoomToAccuracy: false,
+              // 优先使用浏览器定位,失败后使用IP定位
+              noIpLocate: 0,
+              // 使用精确定位
+              GeoLocationFirst: true
+            })
+            console.log('[job-create] 高德定位插件加载完成')
+            
+            // 插件加载完成后立即尝试定位
+            this.tryAutoLocation()
+          })
+
+          // 绑定地图点击事件
+          if (this.map && typeof this.map.on === 'function') {
+            this.map.on('click', e => {
+              const lng = e.lnglat && (e.lnglat.lng || (e.lnglat.getLng && e.lnglat.getLng()))
+              const lat = e.lnglat && (e.lnglat.lat || (e.lnglat.getLat && e.lnglat.getLat()))
+              if (lng == null || lat == null) return
+              this.onMapClick({ lng, lat })
+            })
+          }
+
+          // 地图初始化完成后,如果已选择设备,则开始轮询实时位置
+          this.setupRealtimePolling()
+        }
+
+        // 创建地图(使用默认中心点)
+        console.log('[job-create] 创建地图使用默认中心点:', defaultCenter)
+        createMapWithCenter(defaultCenter)
+      }
+
+      createWhenReady()
+    },
+
+    // 自动定位(插件加载完成后调用)
+    tryAutoLocation() {
+      console.log('[job-create] 开始自动定位...')
+      
+      this._getCurrentLocation(
+        res => {
+          console.log('[job-create] 自动定位成功:', res)
+          const { longitude, latitude } = res || {}
+          if (longitude != null && latitude != null) {
+            console.log('[job-create] 使用定位坐标:', [longitude, latitude])
+            this._centerMapToLngLat(longitude, latitude, 16)
+            uni.showToast({
+              title: '已定位到您的位置',
+              icon: 'success',
+              duration: 2000
+            })
+          } else {
+            console.warn('[job-create] 定位返回坐标无效')
+          }
+        },
+        err => {
+          console.warn('[job-create] 自动定位失败:', err)
+          // 不显示错误提示,避免干扰用户
+        }
+      )
+    },
+    // 尝试获取设备定位并居中地图(优先使用 uni.getLocation,回退到 navigator 或模拟点)
+    _getDeviceLocationAndCenter() {
+      const doCenter = (lng, lat) => {
+        if (!lng && lng !== 0) return
+        if (!lat && lat !== 0) return
+        this._centerMapToLngLat(lng, lat, 18)
+        // 在当前位置添加一个简单标记(不影响现有图层)
+        try {
+          if (this.map && typeof AMap !== 'undefined' && AMap.Marker) {
+            new AMap.Marker({
+              position: [lng, lat],
+              map: this.map
+            })
+          }
+        } catch (err) {
+          // eslint-disable-next-line no-console
+          console.warn('[job-create] add marker failed', err)
+        }
+      }
+
+      // 使用高德定位
+      this._getCurrentLocation(
+        res => {
+          console.log("当前定位", res);
+          const { longitude, latitude } = res || {}
+          if (longitude != null && latitude != null) {
+            doCenter(longitude, latitude)
+          } else {
+            const p = this.getMapCenterPoint() || mockCurrentPoint(0)
+            doCenter(p.lng, p.lat)
+          }
+        },
+        () => {
+          const p = this.getMapCenterPoint() || mockCurrentPoint(0)
+          doCenter(p.lng, p.lat)
+        }
+      )
+
+      // 最后回退到地图中心或模拟点
+      const p = this.getMapCenterPoint() || mockCurrentPoint(0)
+      doCenter(p.lng, p.lat)
+    },
+
+    // 将地图居中到指定经纬度并设置缩放(安全调用)
+    _centerMapToLngLat(lng, lat, zoom = 18) {
+      if (!this.map) return
+      try {
+        if (typeof this.map.setCenter === 'function') {
+          this.map.setCenter([lng, lat])
+        }
+        if (typeof this.map.setZoom === 'function' && typeof zoom === 'number') {
+          this.map.setZoom(zoom)
+        }
+      } catch (err) {
+        // eslint-disable-next-line no-console
+        console.warn('[job-create] center map failed', err)
+      }
+    },
+    // AMap 加载由页面顶部的全局 callback `mapInit` 与 `loadScript()` 管理
+
+    // 使用高德定位获取当前位置
+    _getCurrentLocation(successCallback, failCallback, retryCount = 0) {
+      if (!this.geolocation) {
+        // 如果还未初始化,等待一段时间后重试,最多重试5次
+        if (retryCount < 5) {
+          console.log(`[job-create] 高德定位未初始化,${retryCount + 1}秒后重试...`)
+          setTimeout(() => {
+            this._getCurrentLocation(successCallback, failCallback, retryCount + 1)
+          }, 1000)
+          return
+        } else {
+          console.warn('[job-create] 高德定位未初始化,重试失败')
+          if (failCallback) failCallback(new Error('高德定位未初始化'))
+          return
+        }
+      }
+
+      console.log('[job-create] 开始调用高德定位 getCurrentPosition...')
+      
+      this.geolocation.getCurrentPosition((status, result) => {
+        console.log('[job-create] 定位回调 status:', status, 'result:', result)
+        
+        if (status === 'complete') {
+          const { lng, lat } = result.position
+          console.log('[job-create] 高德定位成功:', { lng, lat })
+          if (successCallback) {
+            successCallback({
+              longitude: lng,
+              latitude: lat
+            })
+          }
+        } else {
+          console.error('[job-create] 高德定位失败:', result)
+          let errorMsg = '定位失败'
+          let errorDetail = ''
+          
+          switch(result.info) {
+            case 'FAILED':
+              errorMsg = '定位失败,请检查网络连接'
+              errorDetail = '可能原因:网络问题或GPS信号弱'
+              break
+            case 'NOT_SUPPORTED':
+              errorMsg = '浏览器不支持定位功能'
+              errorDetail = '请使用支持地理定位的现代浏览器'
+              break
+            case 'PERMISSION_DENIED':
+              errorMsg = '定位权限被拒绝'
+              errorDetail = 'HTTPS环境下需要用户授权定位权限,HTTP环境下浏览器会直接拒绝'
+              break
+            case 'PERMISSION_GRANTED':
+              errorMsg = '定位权限已获取但定位失败'
+              errorDetail = '可能是GPS信号问题'
+              break
+            case 'TIMEOUT':
+              errorMsg = '定位请求超时'
+              errorDetail = '请检查网络连接或GPS信号'
+              break
+            default:
+              errorMsg = `定位失败: ${result.info}`
+              errorDetail = result.message || ''
+          }
+          
+          console.warn('[job-create] 定位失败详情:', errorMsg, errorDetail)
+          
+          if (failCallback) {
+            failCallback({ 
+              code: result.info, 
+              message: errorMsg,
+              detail: errorDetail
+            })
+          }
+        }
+      })
+    },
+
+    // 从地图获取中心点(回退到模拟坐标)
+    getMapCenterPoint() {
+      if (this.map && typeof this.map.getCenter === 'function') {
+        const c = this.map.getCenter()
+        // AMap 返回对象通常包含 lng/lat
+        return {
+          lng: c.lng || (c.lng === 0 ? 0 : c.getLng && c.getLng()),
+          lat: c.lat || (c.lat === 0 ? 0 : c.getLat && c.getLat()),
+          timestamp: Date.now()
+        }
+      }
+      return null
+    },
+    // ---------- end AMap ----------
+
+    // 区域类型与路线类型选择
+    selectAreaType(val) {
+      this.selectedAreaType = val
+      const a = this.areaTypes.find(item => item.value === val)
+      if (a && a.routes && a.routes.length) {
+        this.selectedRouteType = a.routes[0].value
+      }
+      this.jobState.areaType = this.selectedAreaType
+      this.jobState.routeType = this.selectedRouteType
+    },
+    selectRouteType(val) {
+      this.selectedRouteType = val
+      this.jobState.routeType = val
+    },
+    // 模式切换
+    switchMode(val) {
+      this.mode = val
+    },
+
+    // ===================== 设备实时位置轮询(参考 job-detail) =====================
+    setupRealtimePolling() {
+      // 仅 Step2 打点建模时需要实时位置
+      if (this.currentStep !== 2) return
+
+      const deviceId = this.jobState.machineId || this.jobState.machineCode
+      if (!deviceId) return
+      if (!this.amapLoaded || !this.map) return
+
+      // 避免重复开启
+      if (this.realtimeTimer) return
+
+      // 立刻拉一次
+      this.fetchRealtimeAndUpdate()
+
+      this.realtimeTimer = setInterval(() => {
+        this.fetchRealtimeAndUpdate()
+      }, 3000)
+    },
+
+    clearRealtimePolling() {
+      if (this.realtimeTimer) {
+        clearInterval(this.realtimeTimer)
+        this.realtimeTimer = null
+      }
+      this.polling = false
+    },
+
+    async fetchRealtimeAndUpdate() {
+      try {
+        const deviceId = this.jobState.machineCode
+        if (!deviceId) return
+
+        this.polling = true
+        const res = await getRealtimeData(deviceId)
+        const payload = res && res.data && (res.data.data || res.data)
+        if (!payload) return
+
+        const reportTime = payload.reportTime
+        if (reportTime && this.lastReportTime && reportTime < this.lastReportTime) {
+          return
+        }
+        if (reportTime) this.lastReportTime = reportTime
+
+        const pt = payload.currentPoint
+        if (!pt || pt.x == null || pt.y == null) return
+
+        const lngLat = [pt.x, pt.y]
+        this.latestRealtimeLngLat = lngLat
+
+        this.updateRealtimeMarker(lngLat)
+
+        // 轻量跟随:视野中心跟随车,不频繁 fitView
+        try {
+          if (this.map && typeof this.map.setCenter === 'function') {
+            this.map.setCenter(lngLat)
+          }
+        } catch (e) {}
+      } catch (e) {
+        // eslint-disable-next-line no-console
+        console.warn('[job-create] fetchRealtimeAndUpdate failed', e)
+      } finally {
+        this.polling = false
+      }
+    },
+
+    updateRealtimeMarker(lngLat) {
+      if (!this.map || !this.amapLoaded) return
+      if (!lngLat || lngLat.length !== 2) return
+
+      if (!this.realtimeMarker) {
+        this.realtimeMarker = new AMap.Marker({
+          map: this.map,
+          position: lngLat
+        })
+      } else {
+        this.realtimeMarker.setPosition(lngLat)
+      }
+    },
+
+    // 新增点(使用模拟坐标/地图中心点)
+    addPoint() {
+      // Step2 的“新增点”需要使用设备实时位置:
+      // - 轮询拿到的 latestRealtimeLngLat 优先
+      // - 回退到地图中心点(手动拖拽视野时)
+      // - 再回退到 mock
+      const realtimeLngLat = this.latestRealtimeLngLat
+      const realtimePoint = realtimeLngLat && realtimeLngLat.length === 2
+        ? { lng: realtimeLngLat[0], lat: realtimeLngLat[1], timestamp: Date.now() }
+        : null
+
+      // 回退:地图中心点
+      let centerPoint = null
+      if (this.amapLoaded && this.map) {
+        centerPoint = this.getMapCenterPoint()
+      }
+
+      const fallbackPoint = centerPoint || mockCurrentPoint(0)
+      const point = realtimePoint || fallbackPoint
+
+      if (this.mode === 'area') {
+        this.onMapClick({ lng: point.lng, lat: point.lat })
+      } else if (this.mode === 'obstacle') {
+        this.currentObstacle.push(point)
+        // 添加障碍物标记
+        this.addObstacleMarker(point, this.jobState.obstacles.length, this.currentObstacle.length - 1)
+      } else if (this.mode === 'return') {
+        if (this.jobState.returnPoint) {
+          uni.showModal({
+            title: '覆盖返航点',
+            content: '已存在返航点,是否覆盖为当前设备位置?',
+            success: res => {
+              if (res.confirm) {
+                this.jobState.returnPoint = point
+                // 更新返航点标记
+                this.updateReturnMarker(point)
+              }
+            }
+          })
+        } else {
+          this.jobState.returnPoint = point
+          // 添加返航点标记
+          this.updateReturnMarker(point)
+        }
+      }
+    },
+
+    // 撤销点
+    undoPoint() {
+      if (!this.canUndo) return
+      if (this.mode === 'area') {
+        // if (this.selectedAreaType === 'loopArea') {
+          // 删除最后一个点,同时删除对应 draggable marker
+          const lastIdx = this.jobState.areaPoints.length - 1
+          if (lastIdx >= 0) {
+            this.jobState.areaPoints.pop()
+            const m = this.loopMarkers.pop()
+            if (m && typeof m.setMap === 'function') {
+              try { m.setMap(null) } catch (e) {}
+            }
+            // 更新 polygon 或清除
+            if (this.jobState.areaPoints.length >= 3) {
+              this.recomputeLoopPolygon()
+            } else {
+              if (this.loopPolygon && typeof this.loopPolygon.setMap === 'function') {
+                try { this.loopPolygon.setMap(null) } catch (e) {}
+              }
+              this.loopPolygon = null
+            }
+          }
+        // } 
+        // else {
+        //   // 非回字形:删除最后一个点,并同步删除最后一个 marker + 重绘 polygon
+        //   const lastIdx = this.jobState.areaPoints.length - 1
+        //   if (lastIdx >= 0) {
+        //     this.jobState.areaPoints.pop()
+        //     const m = this.markers && this.markers.length ? this.markers.pop() : null
+        //     if (m && typeof m.setMap === 'function') {
+        //       try { m.setMap(null) } catch (e) {}
+        //     }
+        //   }
+
+        //   if (this.jobState.areaPoints.length >= 3) {
+        //     this.drawAreaPolygon()
+        //   } else if (this.areaPolygon) {
+        //     try { this.areaPolygon.setMap(null) } catch (e) {}
+        //     this.areaPolygon = null
+        //   }
+        // }
+      } else if (this.mode === 'obstacle') {
+        this.currentObstacle.pop()
+        // 删除最后一个障碍物标记
+        this.removeLastObstacleMarker()
+      } else if (this.mode === 'return') {
+        this.jobState.returnPoint = null
+        // 删除返航点标记
+        this.clearReturnMarker()
+      }
+    },
+
+    // 完成当前障碍物
+    finishCurrentObstacle() {
+      if (!this.currentObstacle.length) return
+      this.jobState.obstacles.push([...this.currentObstacle])
+      this.currentObstacle = []
+      uni.showToast({
+        title: '已保存一组障碍物',
+        icon: 'success'
+      })
+    },
+
+    // 将“录入中”的障碍物也并入 jobState.obstacles(用于步骤切换/提交前兜底)
+    flushCurrentObstacle() {
+      if (this.currentObstacle && this.currentObstacle.length) {
+        this.jobState.obstacles.push([...this.currentObstacle])
+        this.currentObstacle = []
+      }
+    },
+
+    // 步骤导航
+    prevStep() {
+      if (this.currentStep === 1) return
+      this.currentStep -= 1
+
+      // 离开 Step2 时停止轮询,避免后台持续请求
+      if (this.currentStep !== 2) {
+        this.clearRealtimePolling()
+      }
+    },
+
+    nextStep() {
+      // Step1 校验:区域类型与路线类型必选
+      if (this.currentStep === 1) {
+        if (!this.selectedAreaType || !this.selectedRouteType) {
+          uni.showToast({
+            title: '请选择作业区域类型和路线类型',
+            icon: 'none'
+          })
+          return
+        }
+        this.jobState.areaType = this.selectedAreaType
+        this.jobState.routeType = this.selectedRouteType
+      }
+      // Step2 校验:作业区域点 / 返航点
+      if (this.currentStep === 2) {
+        if (this.jobState.areaPoints.length < 3) {
+          uni.showToast({
+            title: '作业区域点至少需要 3 个',
+            icon: 'none'
+          })
+          return
+        }
+
+        // 离开打点页前,把“录入中”的障碍物也保存下来,避免忘记点“完成当前障碍物”
+        this.flushCurrentObstacle()
+
+        if (!this.jobState.returnPoint) {
+          uni.showToast({
+            title: '请先设置返航点',
+            icon: 'none'
+          })
+          return
+        }
+      }
+      // Step3 校验:需存在作业区域点
+      if (this.currentStep === 3) {
+        if (!this.jobState.areaPoints.length) {
+          uni.showToast({
+            title: '请先在上一步记录作业区域点',
+            icon: 'none'
+          })
+          return
+        }
+      }
+      if (this.currentStep < 4) {
+        this.currentStep += 1
+
+        // 进入 Step2 时开启轮询(地图可能已初始化)
+        if (this.currentStep === 2) {
+          this.setupRealtimePolling()
+        } else {
+          // 离开 Step2 时关闭轮询
+          this.clearRealtimePolling()
+        }
+      }
+    },
+
+    // 起点选择
+    setStartIndex(index) {
+      if (!this.jobState.areaPoints.length) return
+      this.jobState.startPointIndex = index
+    },
+    prevStart() {
+      if (!this.canChangeStart) return
+      const total = this.jobState.areaPoints.length
+      this.jobState.startPointIndex =
+        (this.jobState.startPointIndex - 1 + total) % total
+    },
+    nextStart() {
+      if (!this.canChangeStart) return
+      const total = this.jobState.areaPoints.length
+      this.jobState.startPointIndex =
+        (this.jobState.startPointIndex + 1) % total
+    },
+
+    // 手动定位
+    async manualLocation() {
+      if (this.locating) return
+      this.locating = true
+
+      const doCenter = (lng, lat) => {
+        if (!lng && lng !== 0) return
+        if (!lat && lat !== 0) return
+        this._centerMapToLngLat(lng, lat, 18)
+        uni.showToast({
+          title: '已定位到您的位置',
+          icon: 'success',
+          duration: 2000
+        })
+      }
+
+      try {
+        // 检查是否为HTTPS环境
+        const isSecureContext = window.isSecureContext || window.location.protocol === 'https:'
+        if (!isSecureContext) {
+          console.warn('[job-create] 非HTTPS环境,浏览器可能拒绝定位请求')
+          uni.showModal({
+            title: '定位提示',
+            content: '当前为HTTP环境,浏览器可能拒绝定位请求。建议使用HTTPS访问或使用IP定位。',
+            showCancel: false
+          })
+        }
+
+        // 使用高德定位
+        await new Promise((resolve, reject) => {
+          this._getCurrentLocation(
+            res => {
+              const { longitude, latitude } = res || {}
+              if (longitude != null && latitude != null) {
+                doCenter(longitude, latitude)
+                resolve()
+              } else {
+                reject(new Error('无效坐标'))
+              }
+            },
+            reject
+          )
+        })
+      } catch (err) {
+        console.error('[job-create] 手动定位失败:', err)
+        let errorMsg = '定位失败'
+        let showDetail = false
+        
+        if (err.message) {
+          errorMsg = err.message
+        }
+        if (err.detail) {
+          showDetail = true
+        }
+        
+        if (showDetail) {
+          uni.showModal({
+            title: errorMsg,
+            content: err.detail || '请检查浏览器定位权限设置,或确保使用HTTPS访问',
+            showCancel: false
+          })
+        } else {
+          uni.showToast({
+            title: errorMsg,
+            icon: 'none',
+            duration: 3000
+          })
+        }
+      } finally {
+        this.locating = false
+      }
+    },
+
+    // 提交作业
+    async submitJob() {
+      if (!this.jobState.jobName) {
+        uni.showToast({
+          title: '请填写作业名称',
+          icon: 'none'
+        })
+        return
+      }
+      if (!this.jobState.fieldId) {
+        uni.showToast({
+          title: '请填写地块ID',
+          icon: 'none'
+        })
+        return
+      }
+      if (!this.jobState.pathWidth) {
+        uni.showToast({
+          title: '请填写路径宽度',
+          icon: 'none'
+        })
+        return
+      }
+      if (this.jobState.areaPoints.length < 3) {
+        uni.showToast({
+          title: '作业区域点至少需要 3 个',
+          icon: 'none'
+        })
+        return
+      }
+
+      // 提交前兜底:把“录入中”的障碍物也并入 obstacles
+      this.flushCurrentObstacle()
+
+      // 将高德坐标转换为WGS84坐标
+      const convertedAreaPoints = coordinateUtils.convertPointsToWgs84(this.jobState.areaPoints)
+      const convertedObstacles = this.jobState.obstacles.map(obstacleGroup =>
+        coordinateUtils.convertPointsToWgs84(obstacleGroup)
+      )
+      const convertedReturnPoint = this.jobState.returnPoint ?
+        coordinateUtils.convertPointToWgs84(this.jobState.returnPoint) : undefined
+
+      // 映射 areaType 到数字
+      const areaTypeMap = {
+        'loopArea': 1,   // 回字形
+        'bowArea': 2,    // 弓字形
+        'customArea': 3, // 自定义
+        'ridgeArea': 4   // 垄沟
+      }
+      console.log("this.jobState",this.jobState);
+      const payload = {
+        // deviceId: this.jobState.machineCode,
+        deviceId: this.jobState.machineId,
+        fieldId: parseInt(this.jobState.fieldId),
+        taskName: this.jobState.jobName,
+        areaType: areaTypeMap[this.jobState.areaType] || 1,
+        waypoints: convertedAreaPoints.map(p => ({ lng: p.lng, lat: p.lat })),
+        obstacles: convertedObstacles.flat().map(p => ({ lng: p.lng, lat: p.lat })),
+        returnPoint: convertedReturnPoint ? { lng: convertedReturnPoint.lng, lat: convertedReturnPoint.lat } : undefined,
+        pathWidth: parseInt(this.jobState.pathWidth)
+      }
+      console.log("payload",payload);
+      
+
+      this.submitting = true
+      uni.showLoading({ title: '提交中...' })
+      try {
+        const res = await createJob(payload)
+        console.log("res收到尽快发货",res);
+        
+        const { data } = res || {}
+        if (data && data.code === 200) {
+          uni.showToast({
+            title: '作业创建成功',
+            icon: 'success'
+          })
+          setTimeout(() => {
+            uni.navigateBack()
+          }, 800)
+        } else {
+          uni.showToast({
+            title: (data && data.msg) || '提交失败',
+            icon: 'none'
+          })
+        }
+      } catch (err) {
+        console.error('创建作业失败', err)
+        uni.showToast({
+          title: '网络异常或接口未就绪',
+          icon: 'none'
+        })
+      } finally {
+        this.submitting = false
+        uni.hideLoading()
+      }
+    },
+
+    // 区域编辑逻辑(支持所有区域类型)
+    // - 点击地图依次记录点(或替换已选点)
+    // - 回字形区域使用可拖拽 Marker,其他区域使用普通 Marker
+    // - 点击某个点可以进入“替换模式”,下一次地图点击会替换该点
+    onMapClick({ lng, lat } = {}) {
+      if (!lng && lng !== 0) return
+      if (!lat && lat !== 0) return
+      
+      // 确保在区域编辑模式下
+      if (this.mode !== 'area') return
+
+      const newPoint = { lng, lat, timestamp: Date.now() }
+
+      // 回字形区域特殊处理(支持拖拽和替换)
+    
+        // 替换模式下替换指定点
+        if (this.loopReplaceIndex != null && this.loopReplaceIndex >= 0 && this.loopReplaceIndex < this.jobState.areaPoints.length) {
+          this.jobState.areaPoints.splice(this.loopReplaceIndex, 1, newPoint)
+          const m = this.loopMarkers[this.loopReplaceIndex]
+          if (m && typeof m.setPosition === 'function') {
+            try { m.setPosition([lng, lat]) } catch (e) {}
+          }
+          this.loopReplaceIndex = null
+          if (this.jobState.areaPoints.length >= 3) this.recomputeLoopPolygon()
+          return
+        }
+        
+        // 正常添加点
+        this.jobState.areaPoints.push(newPoint)
+        const idx = this.jobState.areaPoints.length - 1
+        this.createOrUpdateLoopMarker(idx, newPoint)
+        if (this.jobState.areaPoints.length >= 3) {
+          this.recomputeLoopPolygon()
+        }
+      // }
+    },
+    
+
+    createOrUpdateLoopMarker(index, point) {
+      
+      if (!this.map || typeof AMap === 'undefined') return
+      const pos = [point.lng, point.lat]
+      let marker = this.loopMarkers[index]
+      if (marker) {
+        try { marker.setPosition(pos) } catch (e) {}
+        return
+      }
+      // Use smaller icon size
+      let icon = null
+      try {
+        icon = new AMap.Icon({
+          image: "static/icons/poi-marker-default.png",
+          size: new AMap.Size(20, 28),
+          imageSize: new AMap.Size(20, 28)
+        })
+      } catch (e) {
+        icon = "static/icons/poi-marker-default.png"
+      }
+      marker = new AMap.Marker({
+        position: pos,
+        map: this.map,
+        draggable: true,
+        icon,
+        offset: new AMap.Pixel(-10, -28)
+      })
+      // add simple label showing index (P1, P2...)
+      try {
+        const labelContent = `<div class="p-marker-label">P${index + 1}</div>`
+        if (typeof marker.setLabel === 'function') {
+          marker.setLabel({
+            content: labelContent,
+            offset: new AMap.Pixel(10, -36)
+          })
+        } else {
+          marker.label = { content: labelContent }
+        }
+      } catch (e) {}
+      // drag end -> update point and polygon
+      marker.on('dragend', e => {
+        const p = marker.getPosition()
+        const lngv = p.lng || (p.getLng && p.getLng())
+        const latv = p.lat || (p.getLat && p.getLat())
+        if (lngv == null || latv == null) return
+        // Use Vue.set / $set to ensure reactivity for array index assignment
+        if (typeof this.$set === 'function') {
+          this.$set(this.jobState.areaPoints, index, { lng: lngv, lat: latv, timestamp: Date.now() })
+        } else {
+          this.jobState.areaPoints[index] = { lng: lngv, lat: latv, timestamp: Date.now() }
+        }
+        if (this.jobState.areaPoints.length >= 3) this.recomputeLoopPolygon()
+      })
+      // click -> enter replace mode for this index
+      marker.on('click', () => {
+        this.loopReplaceIndex = index
+        uni.showToast({ title: `已选中 P${index + 1},下一次点击将替换该点`, icon: 'none' })
+      })
+      this.loopMarkers[index] = marker
+      // refresh labels for all markers to keep numbering consistent
+      this.refreshLoopMarkerLabels()
+    },
+
+    recomputeLoopPolygon() {
+      if (!this.map) return
+      if (!this.jobState.areaPoints || this.jobState.areaPoints.length < 3) {
+        if (this.loopPolygon && typeof this.loopPolygon.setMap === 'function') {
+          try { this.loopPolygon.setMap(null) } catch (e) {}
+        }
+        this.loopPolygon = null
+        return
+      }
+
+      // Outer path: ensure order as [ [lng,lat], ... ]
+      const outer = this.jobState.areaPoints.map(p => [p.lng, p.lat])
+
+      try {
+        if (this.loopPolygon) {
+          this.loopPolygon.setPath(outer)
+        } else {
+          this.loopPolygon = new AMap.Polygon({
+            map: this.map,
+            path: outer,
+            strokeColor: '#3bb44a',
+            strokeWeight: 2,
+            fillColor: '#3bb44a',
+            fillOpacity: 0.15
+          })
+        }
+      } catch (err) {
+        // eslint-disable-next-line no-console
+        console.warn('[job-create] recompute loop polygon failed', err)
+      }
+    },
+
+    refreshLoopMarkerLabels() {
+      if (!this.loopMarkers || !this.loopMarkers.length) return
+      this.loopMarkers.forEach((m, i) => {
+        try {
+          const content = `<div class="p-marker-label">P${i + 1}</div>`
+          if (typeof m.setLabel === 'function') {
+            m.setLabel({ content, offset: new AMap.Pixel(10, -36) })
+          } else if (m.label) {
+            m.label.content = content
+          }
+        } catch (e) {}
+      })
+    },
+
+    // 清空所有 loop 图形(用于删除区域)
+    clearLoopGraphics() {
+      if (this.loopMarkers && this.loopMarkers.length) {
+        this.loopMarkers.forEach(m => {
+          try { m.setMap(null) } catch (e) {}
+        })
+      }
+      this.loopMarkers = []
+      if (this.loopPolygon && typeof this.loopPolygon.setMap === 'function') {
+        try { this.loopPolygon.setMap(null) } catch (e) {}
+      }
+      this.loopPolygon = null
+      this.loopReplaceIndex = null
+    },
+
+    // 删除某一个点(根据需求:删除后清空整个区域)
+    deleteAreaPoints() {
+      this.jobState.areaPoints = []
+      this.clearLoopGraphics()
+    },
+
+    // 辅助显示
+    formatPoint(p) {
+      if (!p) return '--'
+      return `${p.lng.toFixed(5)}, ${p.lat.toFixed(5)}`
+    },
+    formatPointTime(ts) {
+      if (!ts) return ''
+      const d = new Date(ts)
+      const h = `${d.getHours()}`.padStart(2, '0')
+      const m = `${d.getMinutes()}`.padStart(2, '0')
+      const s = `${d.getSeconds()}`.padStart(2, '0')
+      return `${h}:${m}:${s}`
+    },
+
+    // 添加障碍物标记
+    addObstacleMarker(point, obstacleGroupIndex, pointIndex) {
+      if (!this.map || !this.amapLoaded || typeof AMap === 'undefined') return
+      
+      try {
+        const pos = [point.lng, point.lat]
+        
+        // 创建障碍物图标(使用不同颜色区分)
+        let icon = null
+        try {
+          icon = new AMap.Icon({
+            image: "static/icons/poi-marker-default.png",
+            size: new AMap.Size(16, 22),
+            imageSize: new AMap.Size(16, 22)
+          })
+        } catch (e) {
+          icon = "static/icons/poi-marker-default.png"
+        }
+
+        const marker = new AMap.Marker({
+          position: pos,
+          map: this.map,
+          icon,
+          offset: new AMap.Pixel(-8, -22)
+        })
+
+        // 添加标签显示障碍物编号
+        try {
+          const labelContent = `<div class="obstacle-marker-label">O${obstacleGroupIndex + 1}-${pointIndex + 1}</div>`
+          if (typeof marker.setLabel === 'function') {
+            marker.setLabel({
+              content: labelContent,
+              offset: new AMap.Pixel(8, -28)
+            })
+          }
+        } catch (e) {}
+
+        // 保存标记到数组
+        if (!this.obstacleMarkers[obstacleGroupIndex]) {
+          this.obstacleMarkers[obstacleGroupIndex] = []
+        }
+        this.obstacleMarkers[obstacleGroupIndex].push(marker)
+      } catch (err) {
+        console.warn('[job-create] add obstacle marker failed', err)
+      }
+    },
+
+    // 删除最后一个障碍物标记
+    removeLastObstacleMarker() {
+      const currentGroupIndex = this.jobState.obstacles.length
+      if (this.obstacleMarkers[currentGroupIndex] && this.obstacleMarkers[currentGroupIndex].length > 0) {
+        const marker = this.obstacleMarkers[currentGroupIndex].pop()
+        if (marker && typeof marker.setMap === 'function') {
+          try {
+            marker.setMap(null)
+          } catch (e) {}
+        }
+      }
+    },
+
+    // 更新返航点标记
+    updateReturnMarker(point) {
+      if (!this.map || !this.amapLoaded || typeof AMap === 'undefined') return
+      
+      try {
+        const pos = [point.lng, point.lat]
+        
+        // 如果已存在返航点标记,先删除
+        this.clearReturnMarker()
+
+        // 创建返航点图标(使用特殊颜色)
+        let icon = null
+        try {
+          icon = new AMap.Icon({
+            image: "static/icons/poi-marker-default.png",
+            size: new AMap.Size(20, 28),
+            imageSize: new AMap.Size(20, 28)
+          })
+        } catch (e) {
+          icon = "static/icons/poi-marker-default.png"
+        }
+
+        this.returnMarker = new AMap.Marker({
+          position: pos,
+          map: this.map,
+          icon,
+          offset: new AMap.Pixel(-10, -28)
+        })
+
+        // 添加标签
+        try {
+          const labelContent = '<div class="return-marker-label">返航点</div>'
+          if (typeof this.returnMarker.setLabel === 'function') {
+            this.returnMarker.setLabel({
+              content: labelContent,
+              offset: new AMap.Pixel(10, -36)
+            })
+          }
+        } catch (e) {}
+      } catch (err) {
+        console.warn('[job-create] update return marker failed', err)
+      }
+    },
+
+    // 清除返航点标记
+    clearReturnMarker() {
+      if (this.returnMarker && typeof this.returnMarker.setMap === 'function') {
+        try {
+          this.returnMarker.setMap(null)
+        } catch (e) {}
+      }
+      this.returnMarker = null
+    },
+
+    // 清理所有标记(页面卸载时调用)
+    clearAllMarkers() {
+      // 清理作业区域标记
+      if (this.loopMarkers && this.loopMarkers.length) {
+        this.loopMarkers.forEach(m => {
+          try {
+            if (m && typeof m.setMap === 'function') {
+              m.setMap(null)
+            }
+          } catch (e) {}
+        })
+        this.loopMarkers = []
+      }
+
+      // 清理障碍物标记
+      if (this.obstacleMarkers && this.obstacleMarkers.length) {
+        this.obstacleMarkers.forEach(group => {
+          if (group && group.length) {
+            group.forEach(m => {
+              try {
+                if (m && typeof m.setMap === 'function') {
+                  m.setMap(null)
+                }
+              } catch (e) {}
+            })
+          }
+        })
+        this.obstacleMarkers = []
+      }
+
+      // 清理返航点标记
+      this.clearReturnMarker()
+
+      // 清理实时位置标记
+      if (this.realtimeMarker && typeof this.realtimeMarker.setMap === 'function') {
+        try {
+          this.realtimeMarker.setMap(null)
+        } catch (e) {}
+        this.realtimeMarker = null
+      }
+
+      // 清理多边形
+      if (this.loopPolygon && typeof this.loopPolygon.setMap === 'function') {
+        try {
+          this.loopPolygon.setMap(null)
+        } catch (e) {}
+        this.loopPolygon = null
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+  background-color: #f6f9f7;
+}
+
+.header {
+  padding: 24rpx 28rpx 12rpx;
+}
+
+.title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 12rpx;
+}
+
+.title {
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.device-id {
+  font-size: 24rpx;
+  color: #8c9396;
+}
+
+.step-row {
+  display: flex;
+  padding: 10rpx 8rpx;
+  border-radius: 16rpx;
+  background-color: #eef6f0;
+}
+
+.step-item {
+  flex: 1;
+  display: flex;
+  align-items: center;
+}
+
+.step-index {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 18rpx;
+  border: 2rpx solid #b0c4b8;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 22rpx;
+  color: #7f8c8d;
+  margin-right: 10rpx;
+}
+
+.step-index.active {
+  background: linear-gradient(135deg, #3bb44a, #66cc6a);
+  color: #ffffff;
+  border-color: transparent;
+}
+
+.step-index.done {
+  background-color: #ffffff;
+  color: #3bb44a;
+  border-color: #3bb44a;
+}
+
+.step-texts {
+  display: flex;
+  flex-direction: column;
+}
+
+.step-title {
+  font-size: 24rpx;
+  color: #2c3e50;
+}
+
+.step-sub {
+  font-size: 20rpx;
+  color: #8c9396;
+}
+
+.content {
+  flex: 1;
+  padding: 10rpx 24rpx 130rpx;
+  box-sizing: border-box;
+}
+
+.step-block {
+  display: flex;
+  flex-direction: column;
+  gap: 18rpx;
+}
+
+/* 区域类型 & 路线类型选择卡片 */
+.card.select-card {
+  background-color: #ffffff;
+  border-radius: 20rpx;
+  padding: 20rpx 22rpx 16rpx;
+  box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
+}
+
+.select-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.select-sub {
+  margin-top: 6rpx;
+  font-size: 22rpx;
+  color: #8c9396;
+  line-height: 1.5;
+}
+
+.select-tabs {
+  margin-top: 14rpx;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+
+.select-tab {
+  min-width: 46%;
+  padding: 14rpx 16rpx;
+  border-radius: 16rpx;
+  background-color: #f3f5f7;
+  box-sizing: border-box;
+}
+
+.select-tab.small {
+  min-width: 30%;
+}
+
+.select-tab-label {
+  font-size: 24rpx;
+  color: #2c3e50;
+}
+
+.select-tab-sub {
+  margin-top: 4rpx;
+  font-size: 20rpx;
+  color: #8c9396;
+}
+
+.select-tab.active {
+  background: linear-gradient(135deg, #3bb44a, #66cc6a);
+  box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.26);
+}
+
+.select-tab.active .select-tab-label,
+.select-tab.active .select-tab-sub {
+  color: #ffffff;
+}
+
+.map-card {
+  background-color: #ffffff;
+  border-radius: 20rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
+  overflow: hidden;
+}
+
+.map-card.small .map-body {
+  height: 260rpx;
+}
+
+.map-header {
+  padding: 20rpx 22rpx 10rpx;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+}
+
+.map-header-actions {
+  margin-left: 20rpx;
+}
+
+.map-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.map-sub {
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #8c9396;
+}
+
+.map-body {
+  height: 320rpx;
+  background-color: #d3dce6;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.map-placeholder {
+  font-size: 24rpx;
+  color: #ffffff;
+  text-align: center;
+  white-space: pre-line;
+  width: 100%;
+}
+
+/* AMap 容器样式(确保占满 map-body) */
+#mapContainer {
+  width: 100%;
+  height: 100%;
+}
+
+.map-footer {
+  padding: 12rpx 18rpx 16rpx;
+  background-color: #f7faf8;
+}
+
+.map-hint {
+  font-size: 22rpx;
+  color: #8c9396;
+}
+
+.mode-card {
+  background-color: #ffffff;
+  border-radius: 20rpx;
+  padding: 18rpx 20rpx 14rpx;
+  box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
+}
+
+.mode-title-row {
+  margin-bottom: 10rpx;
+}
+
+.mode-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.mode-sub {
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #8c9396;
+}
+
+.mode-tabs {
+  display: flex;
+  gap: 12rpx;
+}
+
+.mode-tab {
+  flex: 1;
+  padding: 12rpx 0;
+  border-radius: 30rpx;
+  background-color: #f3f5f7;
+  text-align: center;
+  font-size: 24rpx;
+  color: #666;
+}
+
+.mode-tab.active {
+  background: linear-gradient(135deg, #3bb44a, #66cc6a);
+  color: #ffffff;
+  box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.3);
+}
+
+.panel-card {
+  background-color: #ffffff;
+  border-radius: 20rpx;
+  padding: 18rpx 20rpx 14rpx;
+  box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
+}
+
+.panel-row {
+  display: flex;
+  gap: 12rpx;
+}
+
+.panel-row.center {
+  justify-content: space-between;
+  align-items: center;
+}
+
+.panel-desc {
+  margin-top: 8rpx;
+  font-size: 22rpx;
+  color: #8c9396;
+}
+
+.list-card {
+  background-color: #ffffff;
+  border-radius: 20rpx;
+  padding: 18rpx 20rpx 10rpx;
+  box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
+  max-height: 420rpx;
+}
+
+.list-title-row {
+  margin-bottom: 6rpx;
+}
+
+.list-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.list-scroll {
+  max-height: 360rpx;
+}
+
+.list-group {
+  margin-top: 10rpx;
+}
+
+.list-group-header {
+  margin-bottom: 4rpx;
+}
+
+.list-group-title {
+  font-size: 24rpx;
+  color: #555;
+}
+
+.point-list {
+  margin-top: 4rpx;
+}
+
+.point-item {
+  padding: 8rpx 4rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+  display: flex;
+  align-items: center;
+}
+
+.point-item.small {
+  padding-left: 20rpx;
+}
+
+.point-item.active {
+  background-color: #f0f9f2;
+}
+
+.point-label {
+  font-size: 22rpx;
+  color: #3bb44a;
+  margin-right: 8rpx;
+}
+
+.point-coord {
+  flex: 1;
+  font-size: 22rpx;
+  color: #333;
+}
+
+.point-time {
+  font-size: 20rpx;
+  color: #999;
+}
+
+.obstacle-item {
+  margin-top: 6rpx;
+}
+
+.obstacle-title {
+  font-size: 22rpx;
+  color: #666;
+  margin-bottom: 2rpx;
+}
+
+.empty-row {
+  padding: 12rpx 0;
+  font-size: 22rpx;
+  color: #999;
+}
+
+.start-index {
+  flex: 1;
+  align-items: center;
+  justify-content: center;
+  display: flex;
+}
+
+.start-index-text {
+  font-size: 26rpx;
+  color: #2c3e50;
+}
+
+.card.confirm-card {
+  padding: 22rpx 24rpx 10rpx;
+}
+
+.confirm-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #2c3e50;
+  margin-bottom: 12rpx;
+}
+
+.form-item {
+  margin-bottom: 16rpx;
+}
+
+.form-item.required .label::after {
+  content: '*';
+  color: #ff4d4f;
+  margin-left: 4rpx;
+}
+
+.label {
+  display: block;
+  font-size: 26rpx;
+  color: #555;
+  margin-bottom: 8rpx;
+}
+
+.input {
+  width: 100%;
+  height: 80rpx;
+  line-height: 80rpx;
+  padding: 14rpx 18rpx;
+  border-radius: 12rpx;
+  background-color: #f7f7f7;
+  font-size: 26rpx;
+  color: #333;
+  box-sizing: border-box;
+}
+
+.summary-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 8rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.summary-label {
+  font-size: 24rpx;
+  color: #777;
+}
+
+.summary-value {
+  font-size: 24rpx;
+  color: #333;
+}
+
+.tips-card {
+  padding: 20rpx 22rpx 16rpx;
+}
+
+.tips-title {
+  font-size: 26rpx;
+  color: #2c3e50;
+  font-weight: 600;
+  margin-bottom: 6rpx;
+}
+
+.tips-content text {
+  display: block;
+  font-size: 22rpx;
+  color: #666;
+  margin-top: 4rpx;
+}
+
+.footer {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 12rpx 26rpx 24rpx;
+  background-color: #ffffff;
+  box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
+  display: flex;
+  gap: 12rpx;
+  box-sizing: border-box;
+}
+
+.btn {
+  flex: 1;
+  height: 84rpx;
+  line-height: 84rpx;
+  border-radius: 42rpx;
+  font-size: 28rpx;
+}
+
+.btn.primary {
+  background: linear-gradient(135deg, #3bb44a, #66cc6a);
+  color: #ffffff;
+}
+
+.btn.ghost {
+  background-color: #f4f5f7;
+  color: #555;
+}
+
+.btn:disabled {
+  opacity: 0.5;
+}
+
+.btn-location {
+  padding: 8rpx 16rpx;
+  border-radius: 20rpx;
+  background-color: #f0f9f2;
+  border: 1rpx solid #3bb44a;
+  font-size: 24rpx;
+  color: #3bb44a;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 120rpx;
+  height: 56rpx;
+  box-sizing: border-box;
+}
+
+.btn-location-text {
+  font-size: 24rpx;
+  color: #3bb44a;
+}
+
+/* 任务标题行新增作业按钮样式(与详情页保持风格一致) */
+.task-title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.task-title-left {
+  display: flex;
+  flex-direction: column;
+}
+
+.task-title-text {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.task-title-sub {
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #8c9396;
+}
+
+.task-add-btn {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 30rpx;
+  background: linear-gradient(135deg, #3bb44a, #66cc6a);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.3);
+}
+
+.task-add-plus {
+  font-size: 40rpx;
+  color: #ffffff;
+  line-height: 1;
+}
+
+.p-marker-label {
+  background: rgba(59,180,74,0.95);
+  color: #fff;
+  padding: 2px 6px;
+  border-radius: 10px;
+  font-size: 18rpx;
+  line-height: 1;
+}
+
+.obstacle-marker-label {
+  background: rgba(255, 152, 0, 0.95);
+  color: #fff;
+  padding: 2px 6px;
+  border-radius: 10px;
+  font-size: 16rpx;
+  line-height: 1;
+}
+
+.return-marker-label {
+  background: rgba(33, 150, 243, 0.95);
+  color: #fff;
+  padding: 2px 8px;
+  border-radius: 10px;
+  font-size: 18rpx;
+  line-height: 1;
+  font-weight: 600;
+}
+</style>
+
+

+ 683 - 0
pages/device/job-detail/index.vue

@@ -0,0 +1,683 @@
+<template>
+  <view class="page-container">
+    <!-- 顶部状态栏 -->
+    <view class="header-section">
+      <view class="header-left">
+        <text class="device-name">{{ deviceName || '设备名称' }}</text>
+        <view class="status-badge" :class="statusClass">{{ statusText }}</view>
+      </view>
+      <view class="header-right">
+        <view class="info-item">
+          <text class="info-label">进度</text>
+          <text class="info-value">{{ jobProgress}}%</text>
+        </view>
+        <view class="info-item">
+          <text class="info-label">速度</text>
+          <text class="info-value">{{ deviceSpeed}}m/s</text>
+        </view>
+        <view class="info-item">
+          <text class="info-label">电量</text>
+          <text class="info-value">{{ batteryLevel}}%</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 地图与控制面板 -->
+    <view class="main-content">
+      <view class="map-wrapper">
+        <view id="mapContainer" class="map-instance"></view>
+      </view>
+      
+      <view class="control-panel">
+        <view class="job-info-card">
+          <view class="job-info-header">
+            <text class="job-info-title">{{ jobInfo.taskName || '作业任务名称' }}</text>
+            <text class="job-info-tag">{{ areaTypeText }}</text>
+          </view>
+          <text class="job-info-time">创建于 {{ formatTime(jobInfo.createTime) }}</text>
+        </view>
+
+        <view class="action-buttons-group">
+          <button class="action-btn" @click="handleAction('pause')" :disabled="!canPause">暂停作业</button>
+          <button class="action-btn" @click="handleAction('stop')" :disabled="!canStop">停止作业</button>
+        </view>
+        <button class="action-btn recall-btn" @click="handleAction('recall')" :disabled="!canRecall">召回设备</button>
+        <button class="action-btn resume-btn" v-if="jobStatus === 3" @click="handleAction('resume')">继续作业</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getInfo, pauseTask, startTask, stopTask, recallTask, getRealtimeData } from '@/api/services/job.js'
+import coordinateUtils from '@/utils/coordinateUtils.js'
+
+export default {
+  data() {
+    return {
+      jobId: null,
+      deviceId:null,
+      jobInfo: {},
+      map: null,
+      deviceName:'', // 设备名称
+      // 地图元素
+      areaPolygon: null, // 作业区域
+      routePolyline: null, // 规划路线
+      passedPolyline: null, // 已走路线
+      deviceMarker: null, // 设备图标
+      // 状态
+      amapLoaded: false,
+      pollingTimer: null,
+      realtimeTrackPoints: [], // 实时轨迹点:[[lng,lat], ...]
+      lastReportTime: null,
+      // 模拟数据
+      jobProgress: 0,
+      deviceSpeed: 0.0,
+      batteryLevel: 0,
+      // 字典
+      statusMap: {
+        0: '未开始',
+        1: '进行中',
+        2: '已完成',
+        3: '已暂停',
+        4: '已停止',
+        5: '已取消',
+        default: '未知'
+      },
+      areaTypeMap: {
+        1: '回字形',
+        2: '弓字形',
+        3: '自定义',
+        4: '垄沟',
+        default: '未知区域'
+      }
+    }
+  },
+  computed: {
+    jobStatus() {
+      return this.jobInfo.taskStatus
+    },
+    statusText() {
+      return this.statusMap[this.jobStatus] || this.statusMap.default
+    },
+    statusClass() {
+      return `status-${this.jobStatus}`
+    },
+    areaTypeText() {
+      return this.areaTypeMap[this.jobInfo.workAreas && this.jobInfo.workAreas.areaType || 0] || this.areaTypeMap.default
+    },
+    // 按钮可用状态
+    canPause() {
+      return this.jobStatus === 1
+    },
+    canResume() {
+      return this.jobStatus === 3
+    },
+    canStop() {
+      return [0, 1, 3].includes(this.jobStatus)
+    },
+    canRecall() {
+      return [0, 1, 3].includes(this.jobStatus)
+    }
+  },
+  onLoad(options) {
+    if (options.id || options.deviceId) {
+      this.jobId = options.id
+      this.deviceId = options.deviceId
+      this.deviceName = options.deviceName
+      this.loadJobDetails()
+    } else {
+      uni.showToast({ title: '缺少作业ID', icon: 'none' })
+      uni.navigateBack()
+    }
+  },
+  onReady() {
+    this.initMap()
+  },
+  onUnload() {
+    this.clearPolling()
+  },
+  methods: {
+    // 加载作业详情
+    async loadJobDetails() {
+      uni.showLoading({ title: '加载中...' })
+      try {
+        const res = await getInfo(this.jobId)
+        if (res.data.code === 200 &&res.data.data.workAreas) {
+          this.jobInfo = res.data.data || {}
+          // 更新模拟数据
+          // this.jobProgress = this.jobInfo.progress || 0
+          // this.deviceSpeed = this.jobInfo.speed || 0.0
+          // this.batteryLevel = this.jobInfo.battery || 0
+          
+          this.updateMapElements()
+          this.setupPolling()
+        } else {
+          throw new Error(res.data.msg || '加载失败')
+        }
+      } catch (err) {
+        uni.showToast({ title: err.message, icon: 'none' })
+      } finally {
+        uni.hideLoading()
+      }
+    },
+    // 初始化地图
+    initMap() {
+      if (window.AMap) {
+        this.createMap()
+      } else {
+        const script = document.createElement('script')
+        script.src = `https://webapi.amap.com/maps?v=2.0&key=9f2cac7ea18905dd3830cf7360a43a35`
+        script.onload = this.createMap
+        document.head.appendChild(script)
+      }
+    },
+    createMap() {
+      this.map = new AMap.Map('mapContainer', {
+        zoom: 16,
+        viewMode: '2D',
+        pitch: 45
+      })
+      this.map.add(new AMap.TileLayer.Satellite())
+      // ToolBar 在 v2 需要通过 plugin 异步加载;直接 new AMap.ToolBar() 会报:AMap.ToolBar is not a constructor
+      try {
+        AMap.plugin(['AMap.ToolBar'], () => {
+          try {
+            const toolBar = new AMap.ToolBar()
+            this.map.addControl(toolBar)
+          } catch (e) {
+            // eslint-disable-next-line no-console
+            console.warn('[job-detail] init toolbar failed', e)
+          }
+        })
+      } catch (e) {
+        // eslint-disable-next-line no-console
+        console.warn('[job-detail] AMap.plugin ToolBar failed', e)
+      }
+
+      this.amapLoaded = true
+      // 地图准备好后,如果作业信息已加载,则渲染一次静态元素 & 启动轮询
+      if (this.jobInfo && Object.keys(this.jobInfo).length) {
+        this.updateMapElements()
+        this.setupPolling()
+      }
+    },
+    // 更新地图元素
+    updateMapElements() {
+      
+      // 你当前接口返回:jobInfo.workAreas.waypoints 直接就是点数组:[{lng,lat}, ...]
+      // 同时兼容未来可能的结构:
+      // - workAreas 是数组:[{ waypoints:[...] }, ...]
+      // - workAreas 是对象:{ waypoints:[...] }
+      if (!this.amapLoaded) return
+
+      const workAreas = this.jobInfo && this.jobInfo.workAreas
+      let points = []
+
+      // 情况A:workAreas.waypoints = [{lng,lat}, ...](你现在的情况)
+      // 注意:waypoints 有可能被后端返回成字符串 / 对象(单点)/ null,先做数组兜底
+      if (workAreas && workAreas.waypoints) {
+        try {
+          // 如果是字符串,尝试解析为 JSON
+          points = typeof workAreas.waypoints === 'string' 
+            ? JSON.parse(workAreas.waypoints)
+            : workAreas.waypoints;
+          
+          // 确保解析后是数组
+          if (!Array.isArray(points)) {
+            console.warn('[job-detail] workAreas.waypoints is not an array after parsing:', workAreas.waypoints);
+            points = [];
+          }
+        } catch (e) {
+          console.error('[job-detail] Failed to parse workAreas.waypoints:', e);
+          points = [];
+        }
+    }
+
+
+      if (!points.length) return
+      console.log("points",points);
+
+      const waypoints = points
+        .filter(p => p && p.lng != null && p.lat != null)
+        .map(p => [p.lng, p.lat])
+
+        console.log("waypoints",waypoints);
+        
+      if (waypoints.length <= 2) return
+
+      // 1. 绘制作业区域
+      if (!this.areaPolygon) {
+        this.areaPolygon = new AMap.Polygon({
+          map: this.map,
+          path: waypoints,
+          strokeColor: '#28F',
+          strokeWeight: 2,
+          fillColor: '#28F',
+          fillOpacity: 0.1
+        })
+      } else {
+        this.areaPolygon.setPath(waypoints)
+      }
+
+      // 2. 绘制规划路线
+      // 注意:AMap.Polyline 的 path 需要是 [[lng,lat], ...] 或 AMap.LngLat[]。
+      // 这里统一把 routePoints 归一化成 [[lng,lat], ...],避免出现 undefined[0] 这类错误。
+      const rawRoutePoints = this.jobInfo.routePoints || []
+
+      let routePath = []
+      if (Array.isArray(rawRoutePoints) && rawRoutePoints.length) {
+        // 可能是 [{lng,lat},...] 或 [[lng,lat],...]
+        const first = rawRoutePoints[0]
+        if (Array.isArray(first)) {
+          routePath = rawRoutePoints
+        } else {
+          routePath = rawRoutePoints
+            .filter(p => p && p.lng != null && p.lat != null)
+            .map(p => [p.lng, p.lat])
+        }
+      } else {
+        // 没有 routePoints 就用区域边界 waypoints(它已经是 [[lng,lat],...])
+        routePath = waypoints
+      }
+
+      if (routePath.length >= 2) {
+        if (!this.routePolyline) {
+          this.routePolyline = new AMap.Polyline({
+            map: this.map,
+            path: routePath,
+            showDir: true,
+            strokeColor: '#28F',
+            strokeWeight: 6
+          })
+        } else {
+          this.routePolyline.setPath(routePath)
+        }
+      }
+
+      // 3. 绘制已走轨迹 (假设后端返回 passedPoints)
+      const rawPassedPoints = this.jobInfo.passedPoints || []
+
+      let passedPath = []
+      if (Array.isArray(rawPassedPoints) && rawPassedPoints.length) {
+        const first = rawPassedPoints[0]
+        if (Array.isArray(first)) {
+          passedPath = rawPassedPoints
+        } else {
+          passedPath = rawPassedPoints
+            .filter(p => p && p.lng != null && p.lat != null)
+            .map(p => [p.lng, p.lat])
+        }
+      }
+
+      // 3. 绘制已走轨迹:优先用实时轮询轨迹;否则使用后端历史 passedPoints
+      const finalPassedPath = (this.realtimeTrackPoints && this.realtimeTrackPoints.length)
+        ? this.realtimeTrackPoints
+        : passedPath
+
+      if (!this.passedPolyline) {
+        this.passedPolyline = new AMap.Polyline({
+          map: this.map,
+          strokeColor: '#AF5',
+          strokeWeight: 6
+        })
+      }
+      this.passedPolyline.setPath(finalPassedPath)
+
+      // 4. 绘制设备位置
+      const devicePosition = this.jobInfo.currentPosition
+      
+      if (devicePosition && devicePosition.lng) {
+        if (!this.deviceMarker) {
+          this.deviceMarker = new AMap.Marker({ map: this.map, position: [devicePosition.lng, devicePosition.lat]})
+        } else {
+          this.deviceMarker.setPosition([devicePosition.lng, devicePosition.lat])
+        }
+        this.map.setCenter([devicePosition.lng, devicePosition.lat])
+      }
+
+      // 首次渲染静态元素时再 fitView,实时更新时不要频繁 fitView
+      this.map.setFitView()
+    },
+    // 设置轮询(实时位置 + 轨迹)
+    setupPolling() {
+      // 先清一次,避免重复创建 interval
+      this.clearPolling()
+
+      // 没有 deviceId 就无法拉实时数据
+      const deviceId = this.deviceId
+      if (!deviceId) return
+
+      // 仅在进行中/暂停时轮询(你也可以放开为所有状态)
+      if (![1, 3].includes(this.jobStatus)) return
+
+      // 立刻拉一次,避免要等第一轮 interval
+      this.fetchRealtimeAndUpdate()
+
+      // 3 秒轮询一次
+      this.pollingTimer = setInterval(() => {
+        this.fetchRealtimeAndUpdate()
+      }, 3000)
+    },
+
+    clearPolling() {
+      if (this.pollingTimer) {
+        clearInterval(this.pollingTimer)
+        this.pollingTimer = null
+      }
+    },
+
+    async fetchRealtimeAndUpdate() {
+      try {
+        console.log("this.jobInfo",this.jobInfo);
+        
+        const deviceId =this.deviceId
+        if (!deviceId) return
+
+        const res = await getRealtimeData(deviceId)
+        const payload = res && res.data && (res.data.data || res.data)
+
+        if (!payload) return
+
+        // 检查作业状态,如果已完成则提示并退出
+        if (payload.state === 3 || payload.state === 'FINISHED' ) {
+          this.clearPolling()
+          uni.showModal({
+            title: '提示',
+            content: '当前作业已完成,是否退出当前页面',
+            success: (modalRes) => {
+              if (modalRes.confirm) {
+                uni.navigateBack()
+              }
+            }
+          })
+          return
+        }
+
+        // 可选:丢弃时间倒退的数据
+        const reportTime = payload.reportTime
+        if (reportTime && this.lastReportTime && reportTime < this.lastReportTime) {
+          return
+        }
+        if (reportTime) this.lastReportTime = reportTime
+
+        // 更新头部信息(如果接口有)
+        if (payload.progress != null) this.jobProgress = payload.progress
+        if (payload.speed != null) this.deviceSpeed = payload.speed
+        if (payload.battery != null) this.batteryLevel = payload.battery
+
+        const pt = payload.currentPoint
+        if (!pt || pt.x == null || pt.y == null) return
+
+        // currentPoint: {x,y} 这里按接口文档理解为 (lng,lat)
+        const lngLat = [pt.x, pt.y]
+        
+        // 获取行驶方向
+        const direction = payload.direction || 0
+
+        this.updateDeviceMarker(lngLat, direction)
+        this.appendTrackPoint(lngLat)
+        this.updatePassedPolyline()
+      } catch (e) {
+        // eslint-disable-next-line no-console
+        console.warn('[job-detail] fetchRealtimeAndUpdate failed', e)
+      }
+    },
+
+    updateDeviceMarker(lngLat, direction = 0) {
+      if (!this.amapLoaded || !this.map) return
+      if (!lngLat || lngLat.length !== 2) return
+      
+      // 创建一个 Icon
+      var startIcon = new AMap.Icon({
+          // 图标尺寸
+          size: new AMap.Size(45, 45),
+          // 图标的取图地址
+          image: '/static/icons/gecaoji.png',
+          // 图标所用图片大小
+          imageSize: new AMap.Size(45, 45),
+          // 图标取图偏移量
+          // imageOffset: new AMap.Pixel(-9, -3)
+      });
+      
+      if (!this.deviceMarker) {
+        this.deviceMarker = new AMap.Marker({
+          map: this.map,
+          position: lngLat,
+          icon: startIcon,
+          anchor: 'center', // 设置锚点为中心,使图标居中显示在路线上
+          angle: direction // 设置初始旋转角度
+        })
+      } else {
+        this.deviceMarker.setPosition(lngLat)
+        this.deviceMarker.setAngle(direction) // 更新旋转角度
+      }
+
+      // 轻量跟随:中心跟随但不 fitView,避免每次抖动缩放
+      this.map.setCenter(lngLat)
+    },
+
+    appendTrackPoint(lngLat) {
+      if (!lngLat || lngLat.length !== 2) return
+
+      const last = this.realtimeTrackPoints.length
+        ? this.realtimeTrackPoints[this.realtimeTrackPoints.length - 1]
+        : null
+
+      // 去重:相同点不追加
+      if (last && last[0] === lngLat[0] && last[1] === lngLat[1]) return
+
+      this.realtimeTrackPoints.push(lngLat)
+    },
+
+    updatePassedPolyline() {
+      if (!this.amapLoaded || !this.map) return
+
+      // 至少两个点才画线
+      if (!this.realtimeTrackPoints || this.realtimeTrackPoints.length < 2) return
+
+      if (!this.passedPolyline) {
+        this.passedPolyline = new AMap.Polyline({
+          map: this.map,
+          path: this.realtimeTrackPoints,
+          strokeColor: '#AF5',
+          strokeWeight: 6
+        })
+      } else {
+        this.passedPolyline.setPath(this.realtimeTrackPoints)
+      }
+    },
+    // 处理按钮操作
+    async handleAction(action) {
+      const actions = {
+        pause: { api: pauseTask, msg: '作业已暂停' },
+        resume: { api: startTask, msg: '作业已继续' },
+        stop: { api: stopTask, msg: '作业已停止' },
+        recall: { api: recallTask, msg: '召回指令已发送' }
+      }
+      const currentAction = actions[action]
+
+      if (action === 'stop') {
+        uni.showModal({
+          title: '确认停止',
+          content: '停止后无法恢复,确定吗?',
+          success: res => res.confirm && this.executeAction(currentAction)
+        })
+      } else {
+        this.executeAction(currentAction)
+      }
+    },
+    async executeAction({ api, msg }) {
+      try {
+        const res = await api(this.jobId)
+        if (res.data.code === 200) {
+          uni.showToast({ title: msg, icon: 'success' })
+          this.loadJobDetails()
+        } else {
+          throw new Error(res.data.msg || '操作失败')
+        }
+      } catch (err) {
+        uni.showToast({ title: err.message, icon: 'none' })
+      }
+    },
+    // 格式化时间
+    formatTime(timeStr) {
+      if (!timeStr) return '--'
+      return timeStr.substring(0, 16)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-container {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  background-color: #f4f6f8;
+}
+
+.header-section {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  background-color: #ffffff;
+  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
+}
+
+.header-left {
+  display: flex;
+  align-items: center;
+}
+
+.device-name {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+  margin-right: 16rpx;
+}
+
+.status-badge {
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 16rpx;
+  color: #fff;
+}
+.status-0 { background-color: #1890ff; } /* 未开始 */
+.status-1 { background-color: #52c41a; } /* 进行中 */
+.status-2 { background-color: #8c8c8c; } /* 已完成 */
+.status-3 { background-color: #fa8c16; } /* 已暂停 */
+.status-4 { background-color: #f5222d; } /* 已停止 */
+.status-5 { background-color: #bfbfbf; } /* 已取消 */
+
+.header-right {
+  display: flex;
+  /**gap: 24rpx; **/
+}
+
+.info-item {
+  display: flex;
+  align-items: baseline;
+  font-size: 24rpx;
+  padding-left: 10rpx;
+}
+
+.info-label {
+  color: #888;
+  margin-right: 8rpx;
+}
+
+.info-value {
+  font-size: 24rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.main-content {
+  flex: 1;
+  position: relative;
+}
+
+.map-wrapper, .map-instance {
+  width: 100%;
+  height: 100%;
+}
+
+.control-panel {
+  position: absolute;
+  bottom: 30rpx;
+  right: 20rpx;
+  width: 320rpx;
+  background-color: rgba(255, 255, 255, 0.9);
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1);
+  backdrop-filter: blur(10px);
+}
+
+.job-info-card {
+  margin-bottom: 20rpx;
+}
+
+.job-info-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 8rpx;
+}
+
+.job-info-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+  flex: 1;
+}
+
+.job-info-tag {
+  font-size: 20rpx;
+  background-color: #e6f7ff;
+  color: #1890ff;
+  padding: 2rpx 8rpx;
+  border-radius: 6rpx;
+  margin-left: 10rpx;
+}
+
+.job-info-time {
+  font-size: 22rpx;
+  color: #888;
+}
+
+.action-buttons-group {
+  display: flex;
+  gap: 16rpx;
+  margin-bottom: 16rpx;
+}
+
+.action-btn {
+  flex: 1;
+  font-size: 24rpx;
+  padding: 12rpx 0;
+  margin: 0;
+  border-radius: 10rpx;
+  background-color: #1890ff;
+  color: #fff;
+}
+
+.action-btn:disabled {
+  background-color: #d9d9d9;
+  color: #8c8c8c;
+}
+
+.recall-btn {
+  width: 100%;
+  background-color: #faad14;
+  margin-bottom: 16rpx;
+}
+
+.resume-btn {
+  width: 100%;
+  background-color: #52c41a;
+}
+</style>
+

BIN
static/icons/gecaoji.png


BIN
static/icons/poi-marker-default.png


+ 130 - 0
utils/coordinateUtils.js

@@ -0,0 +1,130 @@
+/**
+ * 坐标转换工具类
+ * 将高德地图坐标(GCJ-02)转换为WGS84坐标
+ */
+
+// 坐标转换常量
+const PI = Math.PI
+const A = 6378245.0 // WGS84椭球长半轴
+const EE = 0.00669342162296594323 // WGS84椭球扁率
+
+/**
+ * 判断坐标是否在中国境内
+ * @param {number} lng 经度
+ * @param {number} lat 纬度
+ * @returns {boolean} 是否在中国境内
+ */
+function isInChina(lng, lat) {
+  return lng >= 73.66 && lng <= 135.05 && lat >= 3.86 && lat <= 53.55
+}
+
+/**
+ * 将GCJ-02坐标转换为WGS84坐标
+ * @param {number} gcjLng GCJ-02经度
+ * @param {number} gcjLat GCJ-02纬度
+ * @returns {Object} WGS84坐标 {lng, lat}
+ */
+function gcj02ToWgs84(gcjLng, gcjLat) {
+  if (!isInChina(gcjLng, gcjLat)) {
+    return {
+      lng: gcjLng,
+      lat: gcjLat
+    }
+  }
+
+  let dLat = transformLat(gcjLng - 105.0, gcjLat - 35.0)
+  let dLng = transformLng(gcjLng - 105.0, gcjLat - 35.0)
+
+  const radLat = gcjLat / 180.0 * PI
+  let magic = Math.sin(radLat)
+  magic = 1 - EE * magic * magic
+  const sqrtMagic = Math.sqrt(magic)
+
+  dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI)
+  dLng = (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI)
+
+  const wgsLat = gcjLat - dLat
+  const wgsLng = gcjLng - dLng
+
+  return {
+    lng: wgsLng,
+    lat: wgsLat
+  }
+}
+
+/**
+ * 纬度转换函数
+ * @param {number} x
+ * @param {number} y
+ * @returns {number}
+ */
+function transformLat(x, y) {
+  let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x))
+  ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0
+  ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0
+  ret += (160.0 * Math.sin(y / 12.0 * PI) + 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0
+  return ret
+}
+
+/**
+ * 经度转换函数
+ * @param {number} x
+ * @param {number} y
+ * @returns {number}
+ */
+function transformLng(x, y) {
+  let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x))
+  ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0
+  ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0
+  ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0
+  return ret
+}
+
+/**
+ * 转换坐标点数组
+ * @param {Array} points 坐标点数组,每个点包含lng和lat属性
+ * @returns {Array} 转换后的坐标点数组
+ */
+function convertPointsToWgs84(points) {
+  if (!Array.isArray(points)) {
+    return []
+  }
+
+  return points.map(point => {
+    if (!point || typeof point.lng !== 'number' || typeof point.lat !== 'number') {
+      return point // 如果点数据不完整,返回原数据
+    }
+
+    const converted = gcj02ToWgs84(point.lng, point.lat)
+    return {
+      ...point,
+      lng: converted.lng,
+      lat: converted.lat
+    }
+  })
+}
+
+/**
+ * 转换单个坐标点
+ * @param {Object} point 坐标点对象 {lng, lat, ...}
+ * @returns {Object} 转换后的坐标点对象
+ */
+function convertPointToWgs84(point) {
+  if (!point || typeof point.lng !== 'number' || typeof point.lat !== 'number') {
+    return point
+  }
+
+  const converted = gcj02ToWgs84(point.lng, point.lat)
+  return {
+    ...point,
+    lng: converted.lng,
+    lat: converted.lat
+  }
+}
+
+export default {
+  gcj02ToWgs84,
+  convertPointsToWgs84,
+  convertPointToWgs84,
+  isInChina
+}

+ 42 - 0
utils/huiZiArea.ts

@@ -0,0 +1,42 @@
+export type LngLat = [number, number]
+
+/**
+ * generateInnerPolygon (noop)
+ * 外圈/回字形内圈生成逻辑已移除 — 保留同名导出以避免调用处报错。
+ * 返回空数组表示不生成内圈。
+ */
+export function generateInnerPolygon(outer: Array<LngLat>, insetRatio = 0.25): Array<LngLat> {
+  // outer: expected 4 points in order (clockwise or ccw). We compute the polygon
+  // that is an inward-scaled rectangle centered at the outer centroid.
+  // insetRatio: 0..0.5, how far the inner polygon is from the outer towards center.
+  if (!outer || outer.length < 4) return []
+
+  // clamp insetRatio
+  const r = Math.max(0, Math.min(0.49, insetRatio))
+
+  // compute centroid (average)
+  let cx = 0
+  let cy = 0
+  outer.forEach(p => {
+    cx += p[0]
+    cy += p[1]
+  })
+  cx = cx / outer.length
+  cy = cy / outer.length
+
+  // for each corner, move it toward centroid by ratio r
+  const inner: Array<LngLat> = outer.map((p): LngLat => {
+    const dx = p[0] - cx
+    const dy = p[1] - cy
+    return [cx + dx * (1 - r), cy + dy * (1 - r)]
+  })
+
+  return inner
+}
+
+
+
+
+
+
+

+ 62 - 0
utils/testCoordinateUtils.js

@@ -0,0 +1,62 @@
+/**
+ * 坐标转换工具类测试文件
+ * 用于验证坐标转换功能是否正常工作
+ */
+
+import coordinateUtils from './coordinateUtils.js'
+
+// 测试坐标数据(北京市天安门广场的高德坐标)
+const testGcj02Lng = 116.3974
+const testGcj02Lat = 39.9093
+
+// 预期转换后的WGS84坐标(大致值,用于验证)
+const expectedWgs84Lng = 116.3912
+const expectedWgs84Lat = 39.9073
+
+console.log('=== 坐标转换工具类测试 ===')
+console.log('原始GCJ-02坐标:', { lng: testGcj02Lng, lat: testGcj02Lat })
+
+// 测试单个坐标转换
+const converted = coordinateUtils.gcj02ToWgs84(testGcj02Lng, testGcj02Lat)
+console.log('转换后的WGS84坐标:', converted)
+
+// 验证转换结果是否合理
+const lngDiff = Math.abs(converted.lng - expectedWgs84Lng)
+const latDiff = Math.abs(converted.lat - expectedWgs84Lat)
+
+console.log('经度差异:', lngDiff.toFixed(6), '纬度差异:', latDiff.toFixed(6))
+
+if (lngDiff < 0.01 && latDiff < 0.01) {
+  console.log('✅ 坐标转换测试通过')
+} else {
+  console.log('❌ 坐标转换测试失败,差异过大')
+}
+
+// 测试坐标点数组转换
+const testPoints = [
+  { lng: testGcj02Lng, lat: testGcj02Lat, timestamp: Date.now() },
+  { lng: testGcj02Lng + 0.001, lat: testGcj02Lat + 0.001, timestamp: Date.now() }
+]
+
+console.log('\n=== 测试坐标点数组转换 ===')
+console.log('原始坐标点:', testPoints)
+
+const convertedPoints = coordinateUtils.convertPointsToWgs84(testPoints)
+console.log('转换后坐标点:', convertedPoints)
+
+// 测试单个坐标点转换
+const testPoint = { lng: testGcj02Lng, lat: testGcj02Lat, timestamp: Date.now() }
+console.log('\n=== 测试单个坐标点转换 ===')
+console.log('原始坐标点:', testPoint)
+
+const convertedPoint = coordinateUtils.convertPointToWgs84(testPoint)
+console.log('转换后坐标点:', convertedPoint)
+
+// 测试边界情况
+console.log('\n=== 测试边界情况 ===')
+console.log('无效坐标点:', coordinateUtils.convertPointToWgs84(null))
+console.log('不完整坐标点:', coordinateUtils.convertPointToWgs84({ lng: 116.0 }))
+console.log('空数组:', coordinateUtils.convertPointsToWgs84([]))
+console.log('无效数组:', coordinateUtils.convertPointsToWgs84(null))
+
+console.log('\n=== 测试完成 ===')

+ 166 - 0
定位问题解决方案.md

@@ -0,0 +1,166 @@
+# 地图定位失败问题解决方案
+
+## 问题原因
+
+部署到服务器后地图定位失败的主要原因:
+
+### 1. **HTTPS 要求(最关键)**
+- 浏览器的 Geolocation API 要求必须在**安全上下文**(Secure Context)下才能工作
+- 安全上下文包括:
+  - `https://` 协议
+  - `localhost` 或 `127.0.0.1`(开发环境)
+  - `file://` 协议(本地文件)
+- 如果服务器使用 HTTP 协议,浏览器会直接拒绝定位请求,返回 `PERMISSION_DENIED` 错误
+
+### 2. 时序问题
+- 原代码在地图创建后立即调用定位,但高德定位插件可能还未加载完成
+- 导致 `this.geolocation` 为 `null`,定位失败
+
+### 3. 缓存配置不当
+- 原代码设置了 5 分钟缓存(`maximumAge: 300000`),可能导致使用过期位置
+
+## 解决方案
+
+### 方案一:启用 HTTPS(推荐)
+
+这是最彻底的解决方案,也是生产环境的标准做法。
+
+#### 1. 申请 SSL 证书
+- 免费证书:Let's Encrypt、阿里云免费证书、腾讯云免费证书
+- 付费证书:各大云服务商提供
+
+#### 2. Nginx 配置示例
+```nginx
+server {
+    listen 80;
+    server_name yourdomain.com;
+    # 重定向到 HTTPS
+    return 301 https://$server_name$request_uri;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name yourdomain.com;
+    
+    ssl_certificate /path/to/cert.pem;
+    ssl_certificate_key /path/to/key.pem;
+    
+    location / {
+        root /path/to/your/dist;
+        try_files $uri $uri/ /index.html;
+    }
+}
+```
+
+### 方案二:使用 IP 定位(降级方案)
+
+如果暂时无法启用 HTTPS,可以使用高德的 IP 定位功能(精度较低,约 1-5 公里)。
+
+修改高德定位配置:
+```javascript
+this.geolocation = new AMap.Geolocation({
+  enableHighAccuracy: false,  // 关闭高精度定位
+  timeout: 10000,
+  maximumAge: 0,
+  convert: true,
+  showButton: false,
+  showMarker: false,
+  showCircle: false,
+  panToLocation: false,
+  zoomToAccuracy: false,
+  noIpLocate: 0,  // 允许使用 IP 定位
+  GeoLocationFirst: false  // 优先使用 IP 定位
+})
+```
+
+### 方案三:使用设备实时位置(已实现)
+
+你的代码中已经实现了设备实时位置轮询(`setupRealtimePolling`),可以作为主要定位方式:
+- 优先使用设备上报的实时位置
+- 浏览器定位作为辅助手段
+
+## 代码改进说明
+
+已对你的代码做了以下优化:
+
+### 1. 修复时序问题
+- 在高德定位插件加载完成后才调用定位
+- 增加了 `tryAutoLocation()` 方法,确保插件就绪后再定位
+
+### 2. 增强错误处理
+- 详细的错误日志输出
+- 区分不同的定位失败原因
+- HTTPS 环境检测和提示
+
+### 3. 优化配置
+- 移除缓存(`maximumAge: 0`),确保获取最新位置
+- 增加重试次数(3次 → 5次)
+- 添加详细的控制台日志
+
+### 4. 用户体验优化
+- 自动定位失败时不显示错误提示(避免干扰)
+- 手动定位失败时显示详细错误信息
+- 检测 HTTPS 环境并给出提示
+
+## 测试步骤
+
+### 1. 本地测试(HTTP)
+```bash
+# 启动开发服务器
+npm run dev:h5
+```
+- 访问 `http://localhost:xxxx`
+- 应该能正常定位(localhost 是安全上下文)
+
+### 2. 服务器测试(HTTP)
+- 部署到服务器
+- 访问 `http://your-server-ip`
+- 会提示定位失败(非安全上下文)
+- 可以使用 IP 定位或设备实时位置
+
+### 3. 服务器测试(HTTPS)
+- 配置 SSL 证书
+- 访问 `https://your-domain.com`
+- 应该能正常定位
+
+## 调试方法
+
+### 1. 查看控制台日志
+打开浏览器开发者工具(F12),查看 Console 标签:
+```
+[job-create] 创建地图使用默认中心点: [113.382, 22.5211]
+[job-create] 高德定位插件加载完成
+[job-create] 开始自动定位...
+[job-create] 开始调用高德定位 getCurrentPosition...
+[job-create] 定位回调 status: error result: {...}
+```
+
+### 2. 检查 HTTPS 状态
+在控制台输入:
+```javascript
+console.log('isSecureContext:', window.isSecureContext)
+console.log('protocol:', window.location.protocol)
+```
+
+### 3. 测试高德定位
+在控制台输入:
+```javascript
+navigator.geolocation.getCurrentPosition(
+  pos => console.log('成功:', pos),
+  err => console.log('失败:', err)
+)
+```
+
+## 推荐方案
+
+根据你的实际情况选择:
+
+1. **生产环境**:必须使用 HTTPS + 高精度定位
+2. **测试环境**:可以使用 HTTP + IP 定位 + 设备实时位置
+3. **内网环境**:可以使用 IP 地址访问(如 `http://192.168.x.x`),但定位精度低
+
+## 参考资料
+
+- [MDN - Geolocation API](https://developer.mozilla.org/zh-CN/docs/Web/API/Geolocation_API)
+- [高德地图 JS API - 定位](https://lbs.amap.com/api/javascript-api/guide/services/geolocation)
+- [浏览器安全上下文](https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts)