index.vue.backup 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305
  1. <template>
  2. <view class="page">
  3. <!-- 步骤头部 -->
  4. <view class="header">
  5. <view class="title-row">
  6. <text class="title">新增作业</text>
  7. <text class="device-id">设备:{{ jobState.machineCode || '未知设备' }}</text>
  8. </view>
  9. <view class="step-row">
  10. <view
  11. v-for="step in steps"
  12. :key="step.id"
  13. class="step-item"
  14. >
  15. <view
  16. class="step-index"
  17. :class="{
  18. active: currentStep === step.id,
  19. done: currentStep > step.id
  20. }"
  21. >
  22. <text v-if="currentStep > step.id">✓</text>
  23. <text v-else>{{ step.id }}</text>
  24. </view>
  25. <view class="step-texts">
  26. <text class="step-title">{{ step.title }}</text>
  27. <text class="step-sub">{{ step.sub }}</text>
  28. </view>
  29. </view>
  30. </view>
  31. </view>
  32. <!-- 主体内容 -->
  33. <view class="content">
  34. <!-- Step 1: 选择作业区域类型 & 路线类型 -->
  35. <view v-if="currentStep === 1" class="step-block">
  36. <view class="card select-card">
  37. <view class="select-title">选择作业区域类型</view>
  38. <view class="select-sub">
  39. 不同区域形状将影响后续路线生成方式,请根据实际地块选择。
  40. </view>
  41. <view class="select-tabs">
  42. <view
  43. v-for="item in areaTypes"
  44. :key="item.value"
  45. class="select-tab"
  46. :class="{ active: selectedAreaType === item.value }"
  47. @click="selectAreaType(item.value)"
  48. >
  49. <text class="select-tab-label">{{ item.label }}</text>
  50. <text class="select-tab-sub" v-if="item.desc">
  51. {{ item.desc }}
  52. </text>
  53. </view>
  54. </view>
  55. </view>
  56. <view class="card select-card">
  57. <view class="select-title">选择路线类型</view>
  58. <view class="select-sub">
  59. 依据区域形状推荐的路线类型,后端将按所选类型生成具体作业路线。
  60. </view>
  61. <view class="select-tabs">
  62. <view
  63. v-for="route in availableRouteTypes"
  64. :key="route.value"
  65. class="select-tab small"
  66. :class="{ active: selectedRouteType === route.value }"
  67. @click="selectRouteType(route.value)"
  68. >
  69. <text class="select-tab-label">{{ route.label }}</text>
  70. <text class="select-tab-sub" v-if="route.desc">
  71. {{ route.desc }}
  72. </text>
  73. </view>
  74. </view>
  75. </view>
  76. <view class="card tips-card">
  77. <view class="tips-title">说明</view>
  78. <view class="tips-content">
  79. <text>
  80. - 本步骤仅选择区域类型与路线类型,下一步将进入地图打点新增作业区域。
  81. </text>
  82. <text>
  83. - 当前选择会随作业一起提交至后端,用于指导路线生成策略。
  84. </text>
  85. </view>
  86. </view>
  87. </view>
  88. <!-- Step 2: 地图打点 -->
  89. <view v-else-if="currentStep === 2" class="step-block">
  90. <!-- 地图占位 -->
  91. <view class="map-card">
  92. <view class="map-header">
  93. <text class="map-title">地图预览</text>
  94. <view class="map-header-actions">
  95. <button class="btn-location" @click="manualLocation" :loading="locating">
  96. <text class="btn-location-text">{{ locating ? '定位中...' : '📍 定位' }}</text>
  97. </button>
  98. </view>
  99. <text class="map-sub">通过遥控器移动设备,在地图上逐点记录</text>
  100. </view>
  101. <view class="map-body">
  102. <!-- 始终渲染地图容器以避免渲染时序问题;未加载脚本时容器为空白 -->
  103. <view id="mapContainer"></view>
  104. <text v-if="!amapLoaded" class="map-placeholder">
  105. 地图占位(H5 平台会加载高德地图 SDK){{
  106. '\n'
  107. }}当前模式:{{ modeLabel }}
  108. </text>
  109. </view>
  110. <view class="map-footer">
  111. <text class="map-hint">
  112. 提示:请使用遥控器移动设备到拐点位置,再点击“新增点”记录坐标。
  113. </text>
  114. </view>
  115. </view>
  116. <!-- 模式切换 -->
  117. <view class="mode-card">
  118. <view class="mode-title-row">
  119. <text class="mode-title">点位类型</text>
  120. <text class="mode-sub">在不同模式下分别记录作业区域、障碍物和返航点</text>
  121. </view>
  122. <view class="mode-tabs">
  123. <view
  124. v-for="item in modes"
  125. :key="item.value"
  126. class="mode-tab"
  127. :class="{ active: mode === item.value }"
  128. @click="switchMode(item.value)"
  129. >
  130. <text class="mode-tab-label">{{ item.label }}</text>
  131. </view>
  132. </view>
  133. </view>
  134. <!-- 控制面板 -->
  135. <view class="panel-card">
  136. <view class="panel-row">
  137. <button class="btn primary" @click="addPoint">新增点</button>
  138. <button class="btn ghost" @click="undoPoint" :disabled="!canUndo">
  139. 撤销
  140. </button>
  141. <button
  142. class="btn ghost"
  143. v-if="mode === 'obstacle'"
  144. @click="finishCurrentObstacle"
  145. :disabled="!currentObstacle.length"
  146. >
  147. 完成当前障碍物
  148. </button>
  149. </view>
  150. <view class="panel-desc">
  151. <text v-if="mode === 'area'">
  152. 作业区域至少需要 3 个点,建议为近似矩形。
  153. </text>
  154. <text v-else-if="mode === 'obstacle'">
  155. 每个障碍物可记录多个点,点击“完成当前障碍物”结束本组记录。
  156. </text>
  157. <text v-else>
  158. 仅允许 1 个返航点,重复新增会覆盖已有点。
  159. </text>
  160. </view>
  161. </view>
  162. <!-- 已记录点列表 -->
  163. <view class="list-card">
  164. <view class="list-title-row">
  165. <text class="list-title">已记录点位</text>
  166. </view>
  167. <scroll-view scroll-y class="list-scroll">
  168. <!-- 作业区域点 -->
  169. <view class="list-group">
  170. <view class="list-group-header">
  171. <text class="list-group-title">作业区域点({{ jobState.areaPoints.length }})</text>
  172. </view>
  173. <view
  174. v-if="jobState.areaPoints.length"
  175. class="point-list"
  176. >
  177. <view
  178. v-for="(p, index) in jobState.areaPoints"
  179. :key="'area-' + index"
  180. class="point-item"
  181. >
  182. <text class="point-label">P{{ index + 1 }}</text>
  183. <text class="point-coord">
  184. {{ formatPoint(p) }}
  185. </text>
  186. <text class="point-time">{{ formatPointTime(p.timestamp) }}</text>
  187. </view>
  188. </view>
  189. <view v-else class="empty-row">
  190. <text>暂未记录作业区域点</text>
  191. </view>
  192. </view>
  193. <!-- 障碍物点 -->
  194. <view class="list-group">
  195. <view class="list-group-header">
  196. <text class="list-group-title">
  197. 障碍物({{ jobState.obstacles.length + (currentObstacle.length ? 1 : 0) }} 组)
  198. </text>
  199. </view>
  200. <view
  201. v-if="jobState.obstacles.length || currentObstacle.length"
  202. class="obstacle-list"
  203. >
  204. <!-- 已完成的障碍物组 -->
  205. <view
  206. v-for="(obs, oIdx) in jobState.obstacles"
  207. :key="'obs-' + oIdx"
  208. class="obstacle-item"
  209. >
  210. <text class="obstacle-title">障碍物 {{ oIdx + 1 }}({{ obs.length }} 点)</text>
  211. <view
  212. v-for="(p, pIdx) in obs"
  213. :key="'obs-' + oIdx + '-' + pIdx"
  214. class="point-item small"
  215. >
  216. <text class="point-label">O{{ oIdx + 1 }}-{{ pIdx + 1 }}</text>
  217. <text class="point-coord">
  218. {{ formatPoint(p) }}
  219. </text>
  220. </view>
  221. </view>
  222. <!-- 正在录入中的障碍物(未点击“完成当前障碍物”之前也要实时回显) -->
  223. <view
  224. v-if="currentObstacle.length"
  225. class="obstacle-item"
  226. >
  227. <text class="obstacle-title">障碍物 {{ jobState.obstacles.length + 1 }}(录入中,{{ currentObstacle.length }} 点)</text>
  228. <view
  229. v-for="(p, pIdx) in currentObstacle"
  230. :key="'obs-current-' + pIdx"
  231. class="point-item small"
  232. >
  233. <text class="point-label">O{{ jobState.obstacles.length + 1 }}-{{ pIdx + 1 }}</text>
  234. <text class="point-coord">
  235. {{ formatPoint(p) }}
  236. </text>
  237. </view>
  238. </view>
  239. </view>
  240. <view v-else class="empty-row">
  241. <text>暂未记录障碍物点</text>
  242. </view>
  243. </view>
  244. <!-- 返航点 -->
  245. <view class="list-group">
  246. <view class="list-group-header">
  247. <text class="list-group-title">返航点</text>
  248. </view>
  249. <view v-if="jobState.returnPoint" class="point-item">
  250. <text class="point-label">R</text>
  251. <text class="point-coord">
  252. {{ formatPoint(jobState.returnPoint) }}
  253. </text>
  254. <text class="point-time">
  255. {{ formatPointTime(jobState.returnPoint.timestamp) }}
  256. </text>
  257. </view>
  258. <view v-else class="empty-row">
  259. <text>暂未设置返航点</text>
  260. </view>
  261. </view>
  262. </scroll-view>
  263. </view>
  264. </view>
  265. <!-- Step 3: 起点选择 -->
  266. <view v-else-if="currentStep === 3" class="step-block">
  267. <view class="map-card small">
  268. <view class="map-header">
  269. <text class="map-title">选择起点</text>
  270. <text class="map-sub">
  271. 从已记录的作业区域点中选择作业起点,可通过左右切换预览。
  272. </text>
  273. </view>
  274. <view class="map-body">
  275. <text class="map-placeholder">
  276. 这里显示作业区域示意图(占位){{
  277. '\n'
  278. }}当前起点:P{{ currentStartDisplay }}
  279. </text>
  280. </view>
  281. </view>
  282. <view class="panel-card">
  283. <view class="panel-row center">
  284. <button class="btn ghost" @click="prevStart" :disabled="!canChangeStart">
  285. 上一个
  286. </button>
  287. <view class="start-index">
  288. <text class="start-index-text">
  289. 起点:P{{ currentStartDisplay }}
  290. </text>
  291. </view>
  292. <button class="btn ghost" @click="nextStart" :disabled="!canChangeStart">
  293. 下一个
  294. </button>
  295. </view>
  296. <view class="panel-desc">
  297. <text>
  298. 提示:起点将决定设备的初始行进方向与作业顺序,后端会基于该起点生成具体路线。
  299. </text>
  300. </view>
  301. </view>
  302. <view class="list-card">
  303. <view class="list-title-row">
  304. <text class="list-title">作业区域点列表</text>
  305. </view>
  306. <scroll-view scroll-y class="list-scroll">
  307. <view
  308. v-for="(p, index) in jobState.areaPoints"
  309. :key="'start-' + index"
  310. class="point-item"
  311. :class="{ active: index === jobState.startPointIndex }"
  312. @click="setStartIndex(index)"
  313. >
  314. <text class="point-label">P{{ index + 1 }}</text>
  315. <text class="point-coord">{{ formatPoint(p) }}</text>
  316. </view>
  317. </scroll-view>
  318. </view>
  319. </view>
  320. <!-- Step 4: 作业信息确认 -->
  321. <view v-else-if="currentStep === 4" class="step-block">
  322. <view class="card confirm-card">
  323. <view class="confirm-title">
  324. <text>作业基本信息</text>
  325. </view>
  326. <view class="form-item required">
  327. <text class="label">作业名称</text>
  328. <input
  329. class="input"
  330. v-model="jobState.jobName"
  331. placeholder="请输入作业名称,如“Test device - 北区作业”"
  332. />
  333. </view>
  334. <view class="form-item required">
  335. <text class="label">地块ID</text>
  336. <input
  337. class="input"
  338. v-model="jobState.fieldId"
  339. placeholder="请输入地块ID"
  340. type="text"
  341. />
  342. </view>
  343. <view class="form-item required">
  344. <text class="label">路径宽度(厘米)</text>
  345. <input
  346. class="input"
  347. v-model="jobState.pathWidth"
  348. placeholder="请输入路径宽度,单位:厘米"
  349. type="number"
  350. />
  351. </view>
  352. <view class="summary-row">
  353. <text class="summary-label">作业区域类型</text>
  354. <text class="summary-value">{{ areaTypeLabel }}</text>
  355. </view>
  356. <view class="summary-row">
  357. <text class="summary-label">路线类型</text>
  358. <text class="summary-value">{{ routeTypeLabel }}</text>
  359. </view>
  360. <view class="summary-row">
  361. <text class="summary-label">作业区域点</text>
  362. <text class="summary-value">{{ jobState.areaPoints.length }} 个</text>
  363. </view>
  364. <view class="summary-row">
  365. <text class="summary-label">障碍物</text>
  366. <text class="summary-value">
  367. {{ jobState.obstacles.length }} 组
  368. </text>
  369. </view>
  370. <view class="summary-row">
  371. <text class="summary-label">返航点</text>
  372. <text class="summary-value">
  373. {{ jobState.returnPoint ? '已设置' : '未设置' }}
  374. </text>
  375. </view>
  376. <view class="summary-row">
  377. <text class="summary-label">起点索引</text>
  378. <text class="summary-value">
  379. P{{ currentStartDisplay }}
  380. </text>
  381. </view>
  382. </view>
  383. <view class="card tips-card">
  384. <view class="tips-title">说明</view>
  385. <view class="tips-content">
  386. <text>
  387. - 前端仅负责记录点位与基本配置,并在本页面完成数据完整性校验。
  388. </text>
  389. <text>
  390. - 路线生成、几何合法性校验以及调度逻辑由后端 `/api/job/create` 负责处理。
  391. </text>
  392. </view>
  393. </view>
  394. </view>
  395. </view>
  396. <!-- 步骤导航 -->
  397. <view class="footer">
  398. <button class="btn ghost" @click="prevStep" :disabled="currentStep === 1">
  399. 上一步
  400. </button>
  401. <button
  402. class="btn primary"
  403. v-if="currentStep < 4"
  404. @click="nextStep"
  405. >
  406. 下一步
  407. </button>
  408. <button
  409. class="btn primary"
  410. v-else
  411. :loading="submitting"
  412. @click="submitJob"
  413. >
  414. 完成并提交
  415. </button>
  416. </view>
  417. </view>
  418. </template>
  419. <script>
  420. import { createJob, getRealtimeData } from '@/api/services/job.js'
  421. import coordinateUtils from '@/utils/coordinateUtils.js'
  422. // 地图实例将保存在组件 data 的 `map` 字段中(见 data() 中的 map: null)
  423. // 我们在组件方法里设置一个绑定到全局回调的适配器,以便 AMap 的 script callback 能够调用组件内的初始化方法。
  424. // 简单模拟一个“当前设备坐标”,后续可替换为真实位置上报
  425. function mockCurrentPoint(baseIndex = 0) {
  426. const now = Date.now()
  427. const lng = 120.0 + (baseIndex % 10) * 0.0001
  428. const lat = 30.0 + (baseIndex % 10) * 0.0001
  429. return {
  430. lng,
  431. lat,
  432. timestamp: now
  433. }
  434. }
  435. export default {
  436. data() {
  437. return {
  438. currentStep: 1,
  439. steps: [
  440. { id: 1, title: '区域与路线', sub: '选择作业区域类型与路线类型' },
  441. { id: 2, title: '打点建模', sub: '作业区域 / 障碍物 / 返航点' },
  442. { id: 3, title: '选择起点', sub: '确定作业起点位置' },
  443. { id: 4, title: '信息确认', sub: '填写作业名称并提交' }
  444. ],
  445. // 区域类型与路线类型
  446. areaTypes: [
  447. {
  448. value: 'loopArea',
  449. label: '回字形区域',
  450. desc: '规则四边形地块,适合标准往返或回字形路线',
  451. routes: [{ value: 'loop', label: '回字形路线(loop)' }]
  452. },
  453. {
  454. value: 'bowArea',
  455. label: '弓子形区域',
  456. desc: '一侧为弧形或不规则,适合弓字形或自适应路线',
  457. routes: [{ value: 'bow', label: '弓子形路线(bow)' }]
  458. },
  459. {
  460. value: 'customArea',
  461. label: '自定义区域',
  462. desc: '任意多边形地块,路线由后端自适应规划',
  463. routes: [{ value: 'custom', label: '自定义路线(custom)' }]
  464. },
  465. {
  466. value: 'ridgeArea',
  467. label: '垄沟区域',
  468. desc: '存在大量垄沟或等距行的地块',
  469. routes: [{ value: 'ridge', label: '垄沟路线(ridge)' }]
  470. }
  471. ],
  472. selectedAreaType: 'loopArea',
  473. selectedRouteType: 'loop',
  474. mode: 'area', // area | obstacle | return
  475. modes: [
  476. { value: 'area', label: '作业区域点' },
  477. { value: 'obstacle', label: '障碍物点' },
  478. { value: 'return', label: '返航点' }
  479. ],
  480. currentObstacle: [],
  481. submitting: false,
  482. locating: false,
  483. // 高德地图相关
  484. map: null,
  485. amapLoaded: false,
  486. markers: [],
  487. geolocation: null, // 高德定位实例
  488. obstacleMarkers: [], // 障碍物标记数组
  489. returnMarker: null, // 返航点标记
  490. // 设备实时位置轮询(用于“遥控车 + 实时打点”)
  491. realtimeMarker: null,
  492. realtimeTimer: null,
  493. lastReportTime: null,
  494. latestRealtimeLngLat: null,
  495. polling: false,
  496. // 回字形(loopArea) 编辑相关
  497. loopMarkers: [],
  498. loopPolygon: null,
  499. loopReplaceIndex: null,
  500. // TODO: 将下面的占位 KEY 替换为真实的高德地图 Key(仅 H5 生效)
  501. amapKey: '9f2cac7ea18905dd3830cf7360a43a35',
  502. jscode: '41af52e416d1fd1b15020dac066cec86',
  503. jobState: {
  504. machineCode: '',
  505. areaType: 'loopArea',
  506. routeType: 'loop',
  507. areaPoints: [],
  508. obstacles: [],
  509. returnPoint: null,
  510. startPointIndex: 0,
  511. jobName: '',
  512. fieldId: '',
  513. pathWidth: 100
  514. }
  515. ,
  516. // 回字形编辑相关图形对象(外圈逻辑已移除)
  517. areaPolygon: null
  518. }
  519. },
  520. onLoad(options) {
  521. const { machineCode, id } = options || {}
  522. if (machineCode) {
  523. this.jobState.machineCode = machineCode
  524. this.jobState.machineId = id
  525. }
  526. },
  527. // UniApp 页面就绪生命周期(H5 平台可在此初始化地图)
  528. onReady() {
  529. // 在页面就绪时再加载 AMap 脚本(确保 DOM 容器存在)
  530. if (typeof window !== 'undefined' && typeof document !== 'undefined') {
  531. // eslint-disable-next-line no-console
  532. console.log('[job-create] onReady - loading AMap script')
  533. this.loadScript()
  534. }
  535. },
  536. computed: {
  537. modeLabel() {
  538. const m = this.modes.find(m => m.value === this.mode)
  539. return m ? m.label : ''
  540. },
  541. canUndo() {
  542. if (this.mode === 'area') {
  543. return this.jobState.areaPoints.length > 0
  544. }
  545. if (this.mode === 'obstacle') {
  546. return this.currentObstacle.length > 0
  547. }
  548. if (this.mode === 'return') {
  549. return !!this.jobState.returnPoint
  550. }
  551. return false
  552. },
  553. canChangeStart() {
  554. return this.jobState.areaPoints.length > 0
  555. },
  556. currentStartDisplay() {
  557. if (!this.jobState.areaPoints.length) return '-'
  558. return this.jobState.startPointIndex + 1
  559. },
  560. // 展示当前区域类型与路线类型名称
  561. areaTypeLabel() {
  562. const a = this.areaTypes.find(a => a.value === this.jobState.areaType)
  563. return a ? a.label : ''
  564. },
  565. availableRouteTypes() {
  566. const a = this.areaTypes.find(a => a.value === this.selectedAreaType)
  567. return a ? a.routes : []
  568. },
  569. routeTypeLabel() {
  570. const list = this.areaTypes.reduce((acc, cur) => {
  571. if (cur.routes && cur.routes.length) {
  572. acc.push(...cur.routes)
  573. }
  574. return acc
  575. }, [])
  576. const r = list.find(r => r.value === this.jobState.routeType)
  577. return r ? r.label : this.jobState.routeType
  578. }
  579. },
  580. onUnload() {
  581. // 页面销毁时停止轮询
  582. this.clearRealtimePolling()
  583. // 清理所有地图标记
  584. this.clearAllMarkers()
  585. },
  586. methods: {
  587. loadScript() { // 挂载动态js
  588. // 绑定全局回调,AMap 脚本会调用 window.mapInit
  589. window.mapInit = () => {
  590. this._createMapWhenReady()
  591. }
  592. var script = document.createElement('script');
  593. script.src = `https://webapi.amap.com/maps?v=2.0&key=${this.amapKey}&callback=mapInit`;
  594. document.body.appendChild(script);
  595. this.amapLoaded = true
  596. },
  597. _createMapWhenReady() {
  598. const createWhenReady = () => {
  599. const container = document.getElementById('mapContainer')
  600. if (!container) {
  601. // 容器尚未渲染,延迟重试
  602. setTimeout(createWhenReady, 200)
  603. return
  604. }
  605. // 创建基础地图实例并保存在组件 data.map 中
  606. const defaultCenter = [113.382, 22.5211]
  607. const createMapWithCenter = centerArr => {
  608. try {
  609. this.map = new AMap.Map('mapContainer', {
  610. center: centerArr || defaultCenter,
  611. zoom: 16
  612. })
  613. } catch (err) {
  614. console.error('[job-create] create map failed', err)
  615. return
  616. }
  617. // 添加卫星图层
  618. try {
  619. if (AMap.TileLayer && typeof AMap.TileLayer.Satellite === 'function') {
  620. const sat = new AMap.TileLayer.Satellite()
  621. this.map.add(sat)
  622. }
  623. } catch (layerErr) {
  624. console.warn('[job-create] satellite layer failed', layerErr)
  625. }
  626. // 添加工具栏
  627. try {
  628. AMap.plugin('AMap.ToolBar', () => {
  629. const toolbar = new AMap.ToolBar()
  630. if (this.map && typeof this.map.addControl === 'function') {
  631. this.map.addControl(toolbar)
  632. }
  633. })
  634. } catch (pluginErr) {
  635. console.warn('[job-create] toolbar plugin failed', pluginErr)
  636. }
  637. // 加载高德定位插件(异步加载)
  638. AMap.plugin('AMap.Geolocation', () => {
  639. this.geolocation = new AMap.Geolocation({
  640. enableHighAccuracy: true,
  641. timeout: 10000,
  642. maximumAge: 0, // 不使用缓存,每次都获取最新位置
  643. convert: true, // 自动偏移坐标,偏移后的坐标为高德坐标
  644. showButton: false,
  645. showMarker: false,
  646. showCircle: false,
  647. panToLocation: false,
  648. zoomToAccuracy: false,
  649. // 优先使用浏览器定位,失败后使用IP定位
  650. noIpLocate: 0,
  651. // 使用精确定位
  652. GeoLocationFirst: true
  653. })
  654. console.log('[job-create] 高德定位插件加载完成')
  655. // 插件加载完成后立即尝试定位
  656. this.tryAutoLocation()
  657. })
  658. // 绑定地图点击事件
  659. if (this.map && typeof this.map.on === 'function') {
  660. this.map.on('click', e => {
  661. const lng = e.lnglat && (e.lnglat.lng || (e.lnglat.getLng && e.lnglat.getLng()))
  662. const lat = e.lnglat && (e.lnglat.lat || (e.lnglat.getLat && e.lnglat.getLat()))
  663. if (lng == null || lat == null) return
  664. this.onMapClick({ lng, lat })
  665. })
  666. }
  667. // 地图初始化完成后,如果已选择设备,则开始轮询实时位置
  668. this.setupRealtimePolling()
  669. }
  670. // 创建地图(使用默认中心点)
  671. console.log('[job-create] 创建地图使用默认中心点:', defaultCenter)
  672. createMapWithCenter(defaultCenter)
  673. }
  674. createWhenReady()
  675. },
  676. // 自动定位(插件加载完成后调用)
  677. tryAutoLocation() {
  678. console.log('[job-create] 开始自动定位...')
  679. this._getCurrentLocation(
  680. res => {
  681. console.log('[job-create] 自动定位成功:', res)
  682. const { longitude, latitude } = res || {}
  683. if (longitude != null && latitude != null) {
  684. console.log('[job-create] 使用定位坐标:', [longitude, latitude])
  685. this._centerMapToLngLat(longitude, latitude, 16)
  686. uni.showToast({
  687. title: '已定位到您的位置',
  688. icon: 'success',
  689. duration: 2000
  690. })
  691. } else {
  692. console.warn('[job-create] 定位返回坐标无效')
  693. }
  694. },
  695. err => {
  696. console.warn('[job-create] 自动定位失败:', err)
  697. // 不显示错误提示,避免干扰用户
  698. }
  699. )
  700. },
  701. // 尝试获取设备定位并居中地图(优先使用 uni.getLocation,回退到 navigator 或模拟点)
  702. _getDeviceLocationAndCenter() {
  703. const doCenter = (lng, lat) => {
  704. if (!lng && lng !== 0) return
  705. if (!lat && lat !== 0) return
  706. this._centerMapToLngLat(lng, lat, 18)
  707. // 在当前位置添加一个简单标记(不影响现有图层)
  708. try {
  709. if (this.map && typeof AMap !== 'undefined' && AMap.Marker) {
  710. new AMap.Marker({
  711. position: [lng, lat],
  712. map: this.map
  713. })
  714. }
  715. } catch (err) {
  716. // eslint-disable-next-line no-console
  717. console.warn('[job-create] add marker failed', err)
  718. }
  719. }
  720. // 使用高德定位
  721. this._getCurrentLocation(
  722. res => {
  723. console.log("当前定位", res);
  724. const { longitude, latitude } = res || {}
  725. if (longitude != null && latitude != null) {
  726. doCenter(longitude, latitude)
  727. } else {
  728. const p = this.getMapCenterPoint() || mockCurrentPoint(0)
  729. doCenter(p.lng, p.lat)
  730. }
  731. },
  732. () => {
  733. const p = this.getMapCenterPoint() || mockCurrentPoint(0)
  734. doCenter(p.lng, p.lat)
  735. }
  736. )
  737. // 最后回退到地图中心或模拟点
  738. const p = this.getMapCenterPoint() || mockCurrentPoint(0)
  739. doCenter(p.lng, p.lat)
  740. },
  741. // 将地图居中到指定经纬度并设置缩放(安全调用)
  742. _centerMapToLngLat(lng, lat, zoom = 18) {
  743. if (!this.map) return
  744. try {
  745. if (typeof this.map.setCenter === 'function') {
  746. this.map.setCenter([lng, lat])
  747. }
  748. if (typeof this.map.setZoom === 'function' && typeof zoom === 'number') {
  749. this.map.setZoom(zoom)
  750. }
  751. } catch (err) {
  752. // eslint-disable-next-line no-console
  753. console.warn('[job-create] center map failed', err)
  754. }
  755. },
  756. // AMap 加载由页面顶部的全局 callback `mapInit` 与 `loadScript()` 管理
  757. // 使用高德定位获取当前位置
  758. _getCurrentLocation(successCallback, failCallback, retryCount = 0) {
  759. if (!this.geolocation) {
  760. // 如果还未初始化,等待一段时间后重试,最多重试5次
  761. if (retryCount < 5) {
  762. console.log(`[job-create] 高德定位未初始化,${retryCount + 1}秒后重试...`)
  763. setTimeout(() => {
  764. this._getCurrentLocation(successCallback, failCallback, retryCount + 1)
  765. }, 1000)
  766. return
  767. } else {
  768. console.warn('[job-create] 高德定位未初始化,重试失败')
  769. if (failCallback) failCallback(new Error('高德定位未初始化'))
  770. return
  771. }
  772. }
  773. console.log('[job-create] 开始调用高德定位 getCurrentPosition...')
  774. this.geolocation.getCurrentPosition((status, result) => {
  775. console.log('[job-create] 定位回调 status:', status, 'result:', result)
  776. if (status === 'complete') {
  777. const { lng, lat } = result.position
  778. console.log('[job-create] 高德定位成功:', { lng, lat })
  779. if (successCallback) {
  780. successCallback({
  781. longitude: lng,
  782. latitude: lat
  783. })
  784. }
  785. } else {
  786. console.error('[job-create] 高德定位失败:', result)
  787. let errorMsg = '定位失败'
  788. let errorDetail = ''
  789. switch(result.info) {
  790. case 'FAILED':
  791. errorMsg = '定位失败,请检查网络连接'
  792. errorDetail = '可能原因:网络问题或GPS信号弱'
  793. break
  794. case 'NOT_SUPPORTED':
  795. errorMsg = '浏览器不支持定位功能'
  796. errorDetail = '请使用支持地理定位的现代浏览器'
  797. break
  798. case 'PERMISSION_DENIED':
  799. errorMsg = '定位权限被拒绝'
  800. errorDetail = 'HTTPS环境下需要用户授权定位权限,HTTP环境下浏览器会直接拒绝'
  801. break
  802. case 'PERMISSION_GRANTED':
  803. errorMsg = '定位权限已获取但定位失败'
  804. errorDetail = '可能是GPS信号问题'
  805. break
  806. case 'TIMEOUT':
  807. errorMsg = '定位请求超时'
  808. errorDetail = '请检查网络连接或GPS信号'
  809. break
  810. default:
  811. errorMsg = `定位失败: ${result.info}`
  812. errorDetail = result.message || ''
  813. }
  814. console.warn('[job-create] 定位失败详情:', errorMsg, errorDetail)
  815. if (failCallback) {
  816. failCallback({
  817. code: result.info,
  818. message: errorMsg,
  819. detail: errorDetail
  820. })
  821. }
  822. }
  823. })
  824. },
  825. // 从地图获取中心点(回退到模拟坐标)
  826. getMapCenterPoint() {
  827. if (this.map && typeof this.map.getCenter === 'function') {
  828. const c = this.map.getCenter()
  829. // AMap 返回对象通常包含 lng/lat
  830. return {
  831. lng: c.lng || (c.lng === 0 ? 0 : c.getLng && c.getLng()),
  832. lat: c.lat || (c.lat === 0 ? 0 : c.getLat && c.getLat()),
  833. timestamp: Date.now()
  834. }
  835. }
  836. return null
  837. },
  838. // ---------- end AMap ----------
  839. // 区域类型与路线类型选择
  840. selectAreaType(val) {
  841. this.selectedAreaType = val
  842. const a = this.areaTypes.find(item => item.value === val)
  843. if (a && a.routes && a.routes.length) {
  844. this.selectedRouteType = a.routes[0].value
  845. }
  846. this.jobState.areaType = this.selectedAreaType
  847. this.jobState.routeType = this.selectedRouteType
  848. },
  849. selectRouteType(val) {
  850. this.selectedRouteType = val
  851. this.jobState.routeType = val
  852. },
  853. // 模式切换
  854. switchMode(val) {
  855. this.mode = val
  856. },
  857. // ===================== 设备实时位置轮询(参考 job-detail) =====================
  858. setupRealtimePolling() {
  859. // 仅 Step2 打点建模时需要实时位置
  860. if (this.currentStep !== 2) return
  861. const deviceId = this.jobState.machineId || this.jobState.machineCode
  862. if (!deviceId) return
  863. if (!this.amapLoaded || !this.map) return
  864. // 避免重复开启
  865. if (this.realtimeTimer) return
  866. // 立刻拉一次
  867. this.fetchRealtimeAndUpdate()
  868. this.realtimeTimer = setInterval(() => {
  869. this.fetchRealtimeAndUpdate()
  870. }, 3000)
  871. },
  872. clearRealtimePolling() {
  873. if (this.realtimeTimer) {
  874. clearInterval(this.realtimeTimer)
  875. this.realtimeTimer = null
  876. }
  877. this.polling = false
  878. },
  879. async fetchRealtimeAndUpdate() {
  880. try {
  881. const deviceId = this.jobState.machineCode
  882. if (!deviceId) return
  883. this.polling = true
  884. const res = await getRealtimeData(deviceId)
  885. const payload = res && res.data && (res.data.data || res.data)
  886. if (!payload) return
  887. const reportTime = payload.reportTime
  888. if (reportTime && this.lastReportTime && reportTime < this.lastReportTime) {
  889. return
  890. }
  891. if (reportTime) this.lastReportTime = reportTime
  892. const pt = payload.currentPoint
  893. if (!pt || pt.x == null || pt.y == null) return
  894. const lngLat = [pt.x, pt.y]
  895. this.latestRealtimeLngLat = lngLat
  896. this.updateRealtimeMarker(lngLat)
  897. // 轻量跟随:视野中心跟随车,不频繁 fitView
  898. try {
  899. if (this.map && typeof this.map.setCenter === 'function') {
  900. this.map.setCenter(lngLat)
  901. }
  902. } catch (e) {}
  903. } catch (e) {
  904. // eslint-disable-next-line no-console
  905. console.warn('[job-create] fetchRealtimeAndUpdate failed', e)
  906. } finally {
  907. this.polling = false
  908. }
  909. },
  910. updateRealtimeMarker(lngLat) {
  911. if (!this.map || !this.amapLoaded) return
  912. if (!lngLat || lngLat.length !== 2) return
  913. if (!this.realtimeMarker) {
  914. this.realtimeMarker = new AMap.Marker({
  915. map: this.map,
  916. position: lngLat
  917. })
  918. } else {
  919. this.realtimeMarker.setPosition(lngLat)
  920. }
  921. },
  922. // 新增点(使用模拟坐标/地图中心点)
  923. addPoint() {
  924. // Step2 的“新增点”需要使用设备实时位置:
  925. // - 轮询拿到的 latestRealtimeLngLat 优先
  926. // - 回退到地图中心点(手动拖拽视野时)
  927. // - 再回退到 mock
  928. const realtimeLngLat = this.latestRealtimeLngLat
  929. const realtimePoint = realtimeLngLat && realtimeLngLat.length === 2
  930. ? { lng: realtimeLngLat[0], lat: realtimeLngLat[1], timestamp: Date.now() }
  931. : null
  932. // 回退:地图中心点
  933. let centerPoint = null
  934. if (this.amapLoaded && this.map) {
  935. centerPoint = this.getMapCenterPoint()
  936. }
  937. const fallbackPoint = centerPoint || mockCurrentPoint(0)
  938. const point = realtimePoint || fallbackPoint
  939. if (this.mode === 'area') {
  940. this.onMapClick({ lng: point.lng, lat: point.lat })
  941. } else if (this.mode === 'obstacle') {
  942. this.currentObstacle.push(point)
  943. // 添加障碍物标记
  944. this.addObstacleMarker(point, this.jobState.obstacles.length, this.currentObstacle.length - 1)
  945. } else if (this.mode === 'return') {
  946. if (this.jobState.returnPoint) {
  947. uni.showModal({
  948. title: '覆盖返航点',
  949. content: '已存在返航点,是否覆盖为当前设备位置?',
  950. success: res => {
  951. if (res.confirm) {
  952. this.jobState.returnPoint = point
  953. // 更新返航点标记
  954. this.updateReturnMarker(point)
  955. }
  956. }
  957. })
  958. } else {
  959. this.jobState.returnPoint = point
  960. // 添加返航点标记
  961. this.updateReturnMarker(point)
  962. }
  963. }
  964. },
  965. // 撤销点
  966. undoPoint() {
  967. if (!this.canUndo) return
  968. if (this.mode === 'area') {
  969. // if (this.selectedAreaType === 'loopArea') {
  970. // 删除最后一个点,同时删除对应 draggable marker
  971. const lastIdx = this.jobState.areaPoints.length - 1
  972. if (lastIdx >= 0) {
  973. this.jobState.areaPoints.pop()
  974. const m = this.loopMarkers.pop()
  975. if (m && typeof m.setMap === 'function') {
  976. try { m.setMap(null) } catch (e) {}
  977. }
  978. // 更新 polygon 或清除
  979. if (this.jobState.areaPoints.length >= 3) {
  980. this.recomputeLoopPolygon()
  981. } else {
  982. if (this.loopPolygon && typeof this.loopPolygon.setMap === 'function') {
  983. try { this.loopPolygon.setMap(null) } catch (e) {}
  984. }
  985. this.loopPolygon = null
  986. }
  987. }
  988. // }
  989. // else {
  990. // // 非回字形:删除最后一个点,并同步删除最后一个 marker + 重绘 polygon
  991. // const lastIdx = this.jobState.areaPoints.length - 1
  992. // if (lastIdx >= 0) {
  993. // this.jobState.areaPoints.pop()
  994. // const m = this.markers && this.markers.length ? this.markers.pop() : null
  995. // if (m && typeof m.setMap === 'function') {
  996. // try { m.setMap(null) } catch (e) {}
  997. // }
  998. // }
  999. // if (this.jobState.areaPoints.length >= 3) {
  1000. // this.drawAreaPolygon()
  1001. // } else if (this.areaPolygon) {
  1002. // try { this.areaPolygon.setMap(null) } catch (e) {}
  1003. // this.areaPolygon = null
  1004. // }
  1005. // }
  1006. } else if (this.mode === 'obstacle') {
  1007. this.currentObstacle.pop()
  1008. // 删除最后一个障碍物标记
  1009. this.removeLastObstacleMarker()
  1010. } else if (this.mode === 'return') {
  1011. this.jobState.returnPoint = null
  1012. // 删除返航点标记
  1013. this.clearReturnMarker()
  1014. }
  1015. },
  1016. // 完成当前障碍物
  1017. finishCurrentObstacle() {
  1018. if (!this.currentObstacle.length) return
  1019. this.jobState.obstacles.push([...this.currentObstacle])
  1020. this.currentObstacle = []
  1021. uni.showToast({
  1022. title: '已保存一组障碍物',
  1023. icon: 'success'
  1024. })
  1025. },
  1026. // 将“录入中”的障碍物也并入 jobState.obstacles(用于步骤切换/提交前兜底)
  1027. flushCurrentObstacle() {
  1028. if (this.currentObstacle && this.currentObstacle.length) {
  1029. this.jobState.obstacles.push([...this.currentObstacle])
  1030. this.currentObstacle = []
  1031. }
  1032. },
  1033. // 步骤导航
  1034. prevStep() {
  1035. if (this.currentStep === 1) return
  1036. this.currentStep -= 1
  1037. // 离开 Step2 时停止轮询,避免后台持续请求
  1038. if (this.currentStep !== 2) {
  1039. this.clearRealtimePolling()
  1040. }
  1041. },
  1042. nextStep() {
  1043. // Step1 校验:区域类型与路线类型必选
  1044. if (this.currentStep === 1) {
  1045. if (!this.selectedAreaType || !this.selectedRouteType) {
  1046. uni.showToast({
  1047. title: '请选择作业区域类型和路线类型',
  1048. icon: 'none'
  1049. })
  1050. return
  1051. }
  1052. this.jobState.areaType = this.selectedAreaType
  1053. this.jobState.routeType = this.selectedRouteType
  1054. }
  1055. // Step2 校验:作业区域点 / 返航点
  1056. if (this.currentStep === 2) {
  1057. if (this.jobState.areaPoints.length < 3) {
  1058. uni.showToast({
  1059. title: '作业区域点至少需要 3 个',
  1060. icon: 'none'
  1061. })
  1062. return
  1063. }
  1064. // 离开打点页前,把“录入中”的障碍物也保存下来,避免忘记点“完成当前障碍物”
  1065. this.flushCurrentObstacle()
  1066. if (!this.jobState.returnPoint) {
  1067. uni.showToast({
  1068. title: '请先设置返航点',
  1069. icon: 'none'
  1070. })
  1071. return
  1072. }
  1073. }
  1074. // Step3 校验:需存在作业区域点
  1075. if (this.currentStep === 3) {
  1076. if (!this.jobState.areaPoints.length) {
  1077. uni.showToast({
  1078. title: '请先在上一步记录作业区域点',
  1079. icon: 'none'
  1080. })
  1081. return
  1082. }
  1083. }
  1084. if (this.currentStep < 4) {
  1085. this.currentStep += 1
  1086. // 进入 Step2 时开启轮询(地图可能已初始化)
  1087. if (this.currentStep === 2) {
  1088. this.setupRealtimePolling()
  1089. } else {
  1090. // 离开 Step2 时关闭轮询
  1091. this.clearRealtimePolling()
  1092. }
  1093. }
  1094. },
  1095. // 起点选择
  1096. setStartIndex(index) {
  1097. if (!this.jobState.areaPoints.length) return
  1098. this.jobState.startPointIndex = index
  1099. },
  1100. prevStart() {
  1101. if (!this.canChangeStart) return
  1102. const total = this.jobState.areaPoints.length
  1103. this.jobState.startPointIndex =
  1104. (this.jobState.startPointIndex - 1 + total) % total
  1105. },
  1106. nextStart() {
  1107. if (!this.canChangeStart) return
  1108. const total = this.jobState.areaPoints.length
  1109. this.jobState.startPointIndex =
  1110. (this.jobState.startPointIndex + 1) % total
  1111. },
  1112. // 手动定位
  1113. async manualLocation() {
  1114. if (this.locating) return
  1115. this.locating = true
  1116. const doCenter = (lng, lat) => {
  1117. if (!lng && lng !== 0) return
  1118. if (!lat && lat !== 0) return
  1119. this._centerMapToLngLat(lng, lat, 18)
  1120. uni.showToast({
  1121. title: '已定位到您的位置',
  1122. icon: 'success',
  1123. duration: 2000
  1124. })
  1125. }
  1126. try {
  1127. // 检查是否为HTTPS环境
  1128. const isSecureContext = window.isSecureContext || window.location.protocol === 'https:'
  1129. if (!isSecureContext) {
  1130. console.warn('[job-create] 非HTTPS环境,浏览器可能拒绝定位请求')
  1131. uni.showModal({
  1132. title: '定位提示',
  1133. content: '当前为HTTP环境,浏览器可能拒绝定位请求。建议使用HTTPS访问或使用IP定位。',
  1134. showCancel: false
  1135. })
  1136. }
  1137. // 使用高德定位
  1138. await new Promise((resolve, reject) => {
  1139. this._getCurrentLocation(
  1140. res => {
  1141. const { longitude, latitude } = res || {}
  1142. if (longitude != null && latitude != null) {
  1143. doCenter(longitude, latitude)
  1144. resolve()
  1145. } else {
  1146. reject(new Error('无效坐标'))
  1147. }
  1148. },
  1149. reject
  1150. )
  1151. })
  1152. } catch (err) {
  1153. console.error('[job-create] 手动定位失败:', err)
  1154. let errorMsg = '定位失败'
  1155. let showDetail = false
  1156. if (err.message) {
  1157. errorMsg = err.message
  1158. }
  1159. if (err.detail) {
  1160. showDetail = true
  1161. }
  1162. if (showDetail) {
  1163. uni.showModal({
  1164. title: errorMsg,
  1165. content: err.detail || '请检查浏览器定位权限设置,或确保使用HTTPS访问',
  1166. showCancel: false
  1167. })
  1168. } else {
  1169. uni.showToast({
  1170. title: errorMsg,
  1171. icon: 'none',
  1172. duration: 3000
  1173. })
  1174. }
  1175. } finally {
  1176. this.locating = false
  1177. }
  1178. },
  1179. // 提交作业
  1180. async submitJob() {
  1181. if (!this.jobState.jobName) {
  1182. uni.showToast({
  1183. title: '请填写作业名称',
  1184. icon: 'none'
  1185. })
  1186. return
  1187. }
  1188. if (!this.jobState.fieldId) {
  1189. uni.showToast({
  1190. title: '请填写地块ID',
  1191. icon: 'none'
  1192. })
  1193. return
  1194. }
  1195. if (!this.jobState.pathWidth) {
  1196. uni.showToast({
  1197. title: '请填写路径宽度',
  1198. icon: 'none'
  1199. })
  1200. return
  1201. }
  1202. if (this.jobState.areaPoints.length < 3) {
  1203. uni.showToast({
  1204. title: '作业区域点至少需要 3 个',
  1205. icon: 'none'
  1206. })
  1207. return
  1208. }
  1209. // 提交前兜底:把“录入中”的障碍物也并入 obstacles
  1210. this.flushCurrentObstacle()
  1211. // 将高德坐标转换为WGS84坐标
  1212. const convertedAreaPoints = coordinateUtils.convertPointsToWgs84(this.jobState.areaPoints)
  1213. const convertedObstacles = this.jobState.obstacles.map(obstacleGroup =>
  1214. coordinateUtils.convertPointsToWgs84(obstacleGroup)
  1215. )
  1216. const convertedReturnPoint = this.jobState.returnPoint ?
  1217. coordinateUtils.convertPointToWgs84(this.jobState.returnPoint) : undefined
  1218. // 映射 areaType 到数字
  1219. const areaTypeMap = {
  1220. 'loopArea': 1, // 回字形
  1221. 'bowArea': 2, // 弓字形
  1222. 'customArea': 3, // 自定义
  1223. 'ridgeArea': 4 // 垄沟
  1224. }
  1225. console.log("this.jobState",this.jobState);
  1226. const payload = {
  1227. // deviceId: this.jobState.machineCode,
  1228. deviceId: this.jobState.machineId,
  1229. fieldId: parseInt(this.jobState.fieldId),
  1230. taskName: this.jobState.jobName,
  1231. areaType: areaTypeMap[this.jobState.areaType] || 1,
  1232. waypoints: convertedAreaPoints.map(p => ({ lng: p.lng, lat: p.lat })),
  1233. obstacles: convertedObstacles.flat().map(p => ({ lng: p.lng, lat: p.lat })),
  1234. returnPoint: convertedReturnPoint ? { lng: convertedReturnPoint.lng, lat: convertedReturnPoint.lat } : undefined,
  1235. pathWidth: parseInt(this.jobState.pathWidth)
  1236. }
  1237. console.log("payload",payload);
  1238. this.submitting = true
  1239. uni.showLoading({ title: '提交中...' })
  1240. try {
  1241. const res = await createJob(payload)
  1242. console.log("res收到尽快发货",res);
  1243. const { data } = res || {}
  1244. if (data && data.code === 200) {
  1245. uni.showToast({
  1246. title: '作业创建成功',
  1247. icon: 'success'
  1248. })
  1249. setTimeout(() => {
  1250. uni.navigateBack()
  1251. }, 800)
  1252. } else {
  1253. uni.showToast({
  1254. title: (data && data.msg) || '提交失败',
  1255. icon: 'none'
  1256. })
  1257. }
  1258. } catch (err) {
  1259. console.error('创建作业失败', err)
  1260. uni.showToast({
  1261. title: '网络异常或接口未就绪',
  1262. icon: 'none'
  1263. })
  1264. } finally {
  1265. this.submitting = false
  1266. uni.hideLoading()
  1267. }
  1268. },
  1269. // 区域编辑逻辑(支持所有区域类型)
  1270. // - 点击地图依次记录点(或替换已选点)
  1271. // - 回字形区域使用可拖拽 Marker,其他区域使用普通 Marker
  1272. // - 点击某个点可以进入“替换模式”,下一次地图点击会替换该点
  1273. onMapClick({ lng, lat } = {}) {
  1274. if (!lng && lng !== 0) return
  1275. if (!lat && lat !== 0) return
  1276. // 确保在区域编辑模式下
  1277. if (this.mode !== 'area') return
  1278. const newPoint = { lng, lat, timestamp: Date.now() }
  1279. // 回字形区域特殊处理(支持拖拽和替换)
  1280. // 替换模式下替换指定点
  1281. if (this.loopReplaceIndex != null && this.loopReplaceIndex >= 0 && this.loopReplaceIndex < this.jobState.areaPoints.length) {
  1282. this.jobState.areaPoints.splice(this.loopReplaceIndex, 1, newPoint)
  1283. const m = this.loopMarkers[this.loopReplaceIndex]
  1284. if (m && typeof m.setPosition === 'function') {
  1285. try { m.setPosition([lng, lat]) } catch (e) {}
  1286. }
  1287. this.loopReplaceIndex = null
  1288. if (this.jobState.areaPoints.length >= 3) this.recomputeLoopPolygon()
  1289. return
  1290. }
  1291. // 正常添加点
  1292. this.jobState.areaPoints.push(newPoint)
  1293. const idx = this.jobState.areaPoints.length - 1
  1294. this.createOrUpdateLoopMarker(idx, newPoint)
  1295. if (this.jobState.areaPoints.length >= 3) {
  1296. this.recomputeLoopPolygon()
  1297. }
  1298. // }
  1299. },
  1300. createOrUpdateLoopMarker(index, point) {
  1301. if (!this.map || typeof AMap === 'undefined') return
  1302. const pos = [point.lng, point.lat]
  1303. let marker = this.loopMarkers[index]
  1304. if (marker) {
  1305. try { marker.setPosition(pos) } catch (e) {}
  1306. return
  1307. }
  1308. // Use smaller icon size
  1309. let icon = null
  1310. try {
  1311. icon = new AMap.Icon({
  1312. image: "static/icons/poi-marker-default.png",
  1313. size: new AMap.Size(20, 28),
  1314. imageSize: new AMap.Size(20, 28)
  1315. })
  1316. } catch (e) {
  1317. icon = "static/icons/poi-marker-default.png"
  1318. }
  1319. marker = new AMap.Marker({
  1320. position: pos,
  1321. map: this.map,
  1322. draggable: true,
  1323. icon,
  1324. offset: new AMap.Pixel(-10, -28)
  1325. })
  1326. // add simple label showing index (P1, P2...)
  1327. try {
  1328. const labelContent = `<div class="p-marker-label">P${index + 1}</div>`
  1329. if (typeof marker.setLabel === 'function') {
  1330. marker.setLabel({
  1331. content: labelContent,
  1332. offset: new AMap.Pixel(10, -36)
  1333. })
  1334. } else {
  1335. marker.label = { content: labelContent }
  1336. }
  1337. } catch (e) {}
  1338. // drag end -> update point and polygon
  1339. marker.on('dragend', e => {
  1340. const p = marker.getPosition()
  1341. const lngv = p.lng || (p.getLng && p.getLng())
  1342. const latv = p.lat || (p.getLat && p.getLat())
  1343. if (lngv == null || latv == null) return
  1344. // Use Vue.set / $set to ensure reactivity for array index assignment
  1345. if (typeof this.$set === 'function') {
  1346. this.$set(this.jobState.areaPoints, index, { lng: lngv, lat: latv, timestamp: Date.now() })
  1347. } else {
  1348. this.jobState.areaPoints[index] = { lng: lngv, lat: latv, timestamp: Date.now() }
  1349. }
  1350. if (this.jobState.areaPoints.length >= 3) this.recomputeLoopPolygon()
  1351. })
  1352. // click -> enter replace mode for this index
  1353. marker.on('click', () => {
  1354. this.loopReplaceIndex = index
  1355. uni.showToast({ title: `已选中 P${index + 1},下一次点击将替换该点`, icon: 'none' })
  1356. })
  1357. this.loopMarkers[index] = marker
  1358. // refresh labels for all markers to keep numbering consistent
  1359. this.refreshLoopMarkerLabels()
  1360. },
  1361. recomputeLoopPolygon() {
  1362. if (!this.map) return
  1363. if (!this.jobState.areaPoints || this.jobState.areaPoints.length < 3) {
  1364. if (this.loopPolygon && typeof this.loopPolygon.setMap === 'function') {
  1365. try { this.loopPolygon.setMap(null) } catch (e) {}
  1366. }
  1367. this.loopPolygon = null
  1368. return
  1369. }
  1370. // Outer path: ensure order as [ [lng,lat], ... ]
  1371. const outer = this.jobState.areaPoints.map(p => [p.lng, p.lat])
  1372. try {
  1373. if (this.loopPolygon) {
  1374. this.loopPolygon.setPath(outer)
  1375. } else {
  1376. this.loopPolygon = new AMap.Polygon({
  1377. map: this.map,
  1378. path: outer,
  1379. strokeColor: '#3bb44a',
  1380. strokeWeight: 2,
  1381. fillColor: '#3bb44a',
  1382. fillOpacity: 0.15
  1383. })
  1384. }
  1385. } catch (err) {
  1386. // eslint-disable-next-line no-console
  1387. console.warn('[job-create] recompute loop polygon failed', err)
  1388. }
  1389. },
  1390. refreshLoopMarkerLabels() {
  1391. if (!this.loopMarkers || !this.loopMarkers.length) return
  1392. this.loopMarkers.forEach((m, i) => {
  1393. try {
  1394. const content = `<div class="p-marker-label">P${i + 1}</div>`
  1395. if (typeof m.setLabel === 'function') {
  1396. m.setLabel({ content, offset: new AMap.Pixel(10, -36) })
  1397. } else if (m.label) {
  1398. m.label.content = content
  1399. }
  1400. } catch (e) {}
  1401. })
  1402. },
  1403. // 清空所有 loop 图形(用于删除区域)
  1404. clearLoopGraphics() {
  1405. if (this.loopMarkers && this.loopMarkers.length) {
  1406. this.loopMarkers.forEach(m => {
  1407. try { m.setMap(null) } catch (e) {}
  1408. })
  1409. }
  1410. this.loopMarkers = []
  1411. if (this.loopPolygon && typeof this.loopPolygon.setMap === 'function') {
  1412. try { this.loopPolygon.setMap(null) } catch (e) {}
  1413. }
  1414. this.loopPolygon = null
  1415. this.loopReplaceIndex = null
  1416. },
  1417. // 删除某一个点(根据需求:删除后清空整个区域)
  1418. deleteAreaPoints() {
  1419. this.jobState.areaPoints = []
  1420. this.clearLoopGraphics()
  1421. },
  1422. // 辅助显示
  1423. formatPoint(p) {
  1424. if (!p) return '--'
  1425. return `${p.lng.toFixed(5)}, ${p.lat.toFixed(5)}`
  1426. },
  1427. formatPointTime(ts) {
  1428. if (!ts) return ''
  1429. const d = new Date(ts)
  1430. const h = `${d.getHours()}`.padStart(2, '0')
  1431. const m = `${d.getMinutes()}`.padStart(2, '0')
  1432. const s = `${d.getSeconds()}`.padStart(2, '0')
  1433. return `${h}:${m}:${s}`
  1434. },
  1435. // 添加障碍物标记
  1436. addObstacleMarker(point, obstacleGroupIndex, pointIndex) {
  1437. if (!this.map || !this.amapLoaded || typeof AMap === 'undefined') return
  1438. try {
  1439. const pos = [point.lng, point.lat]
  1440. // 创建障碍物图标(使用不同颜色区分)
  1441. let icon = null
  1442. try {
  1443. icon = new AMap.Icon({
  1444. image: "static/icons/poi-marker-default.png",
  1445. size: new AMap.Size(16, 22),
  1446. imageSize: new AMap.Size(16, 22)
  1447. })
  1448. } catch (e) {
  1449. icon = "static/icons/poi-marker-default.png"
  1450. }
  1451. const marker = new AMap.Marker({
  1452. position: pos,
  1453. map: this.map,
  1454. icon,
  1455. offset: new AMap.Pixel(-8, -22)
  1456. })
  1457. // 添加标签显示障碍物编号
  1458. try {
  1459. const labelContent = `<div class="obstacle-marker-label">O${obstacleGroupIndex + 1}-${pointIndex + 1}</div>`
  1460. if (typeof marker.setLabel === 'function') {
  1461. marker.setLabel({
  1462. content: labelContent,
  1463. offset: new AMap.Pixel(8, -28)
  1464. })
  1465. }
  1466. } catch (e) {}
  1467. // 保存标记到数组
  1468. if (!this.obstacleMarkers[obstacleGroupIndex]) {
  1469. this.obstacleMarkers[obstacleGroupIndex] = []
  1470. }
  1471. this.obstacleMarkers[obstacleGroupIndex].push(marker)
  1472. } catch (err) {
  1473. console.warn('[job-create] add obstacle marker failed', err)
  1474. }
  1475. },
  1476. // 删除最后一个障碍物标记
  1477. removeLastObstacleMarker() {
  1478. const currentGroupIndex = this.jobState.obstacles.length
  1479. if (this.obstacleMarkers[currentGroupIndex] && this.obstacleMarkers[currentGroupIndex].length > 0) {
  1480. const marker = this.obstacleMarkers[currentGroupIndex].pop()
  1481. if (marker && typeof marker.setMap === 'function') {
  1482. try {
  1483. marker.setMap(null)
  1484. } catch (e) {}
  1485. }
  1486. }
  1487. },
  1488. // 更新返航点标记
  1489. updateReturnMarker(point) {
  1490. if (!this.map || !this.amapLoaded || typeof AMap === 'undefined') return
  1491. try {
  1492. const pos = [point.lng, point.lat]
  1493. // 如果已存在返航点标记,先删除
  1494. this.clearReturnMarker()
  1495. // 创建返航点图标(使用特殊颜色)
  1496. let icon = null
  1497. try {
  1498. icon = new AMap.Icon({
  1499. image: "static/icons/poi-marker-default.png",
  1500. size: new AMap.Size(20, 28),
  1501. imageSize: new AMap.Size(20, 28)
  1502. })
  1503. } catch (e) {
  1504. icon = "static/icons/poi-marker-default.png"
  1505. }
  1506. this.returnMarker = new AMap.Marker({
  1507. position: pos,
  1508. map: this.map,
  1509. icon,
  1510. offset: new AMap.Pixel(-10, -28)
  1511. })
  1512. // 添加标签
  1513. try {
  1514. const labelContent = '<div class="return-marker-label">返航点</div>'
  1515. if (typeof this.returnMarker.setLabel === 'function') {
  1516. this.returnMarker.setLabel({
  1517. content: labelContent,
  1518. offset: new AMap.Pixel(10, -36)
  1519. })
  1520. }
  1521. } catch (e) {}
  1522. } catch (err) {
  1523. console.warn('[job-create] update return marker failed', err)
  1524. }
  1525. },
  1526. // 清除返航点标记
  1527. clearReturnMarker() {
  1528. if (this.returnMarker && typeof this.returnMarker.setMap === 'function') {
  1529. try {
  1530. this.returnMarker.setMap(null)
  1531. } catch (e) {}
  1532. }
  1533. this.returnMarker = null
  1534. },
  1535. // 清理所有标记(页面卸载时调用)
  1536. clearAllMarkers() {
  1537. // 清理作业区域标记
  1538. if (this.loopMarkers && this.loopMarkers.length) {
  1539. this.loopMarkers.forEach(m => {
  1540. try {
  1541. if (m && typeof m.setMap === 'function') {
  1542. m.setMap(null)
  1543. }
  1544. } catch (e) {}
  1545. })
  1546. this.loopMarkers = []
  1547. }
  1548. // 清理障碍物标记
  1549. if (this.obstacleMarkers && this.obstacleMarkers.length) {
  1550. this.obstacleMarkers.forEach(group => {
  1551. if (group && group.length) {
  1552. group.forEach(m => {
  1553. try {
  1554. if (m && typeof m.setMap === 'function') {
  1555. m.setMap(null)
  1556. }
  1557. } catch (e) {}
  1558. })
  1559. }
  1560. })
  1561. this.obstacleMarkers = []
  1562. }
  1563. // 清理返航点标记
  1564. this.clearReturnMarker()
  1565. // 清理实时位置标记
  1566. if (this.realtimeMarker && typeof this.realtimeMarker.setMap === 'function') {
  1567. try {
  1568. this.realtimeMarker.setMap(null)
  1569. } catch (e) {}
  1570. this.realtimeMarker = null
  1571. }
  1572. // 清理多边形
  1573. if (this.loopPolygon && typeof this.loopPolygon.setMap === 'function') {
  1574. try {
  1575. this.loopPolygon.setMap(null)
  1576. } catch (e) {}
  1577. this.loopPolygon = null
  1578. }
  1579. }
  1580. }
  1581. }
  1582. </script>
  1583. <style scoped>
  1584. .page {
  1585. display: flex;
  1586. flex-direction: column;
  1587. min-height: 100vh;
  1588. background-color: #f6f9f7;
  1589. }
  1590. .header {
  1591. padding: 24rpx 28rpx 12rpx;
  1592. }
  1593. .title-row {
  1594. display: flex;
  1595. justify-content: space-between;
  1596. align-items: baseline;
  1597. margin-bottom: 12rpx;
  1598. }
  1599. .title {
  1600. font-size: 34rpx;
  1601. font-weight: 600;
  1602. color: #2c3e50;
  1603. }
  1604. .device-id {
  1605. font-size: 24rpx;
  1606. color: #8c9396;
  1607. }
  1608. .step-row {
  1609. display: flex;
  1610. padding: 10rpx 8rpx;
  1611. border-radius: 16rpx;
  1612. background-color: #eef6f0;
  1613. }
  1614. .step-item {
  1615. flex: 1;
  1616. display: flex;
  1617. align-items: center;
  1618. }
  1619. .step-index {
  1620. width: 36rpx;
  1621. height: 36rpx;
  1622. border-radius: 18rpx;
  1623. border: 2rpx solid #b0c4b8;
  1624. display: flex;
  1625. align-items: center;
  1626. justify-content: center;
  1627. font-size: 22rpx;
  1628. color: #7f8c8d;
  1629. margin-right: 10rpx;
  1630. }
  1631. .step-index.active {
  1632. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1633. color: #ffffff;
  1634. border-color: transparent;
  1635. }
  1636. .step-index.done {
  1637. background-color: #ffffff;
  1638. color: #3bb44a;
  1639. border-color: #3bb44a;
  1640. }
  1641. .step-texts {
  1642. display: flex;
  1643. flex-direction: column;
  1644. }
  1645. .step-title {
  1646. font-size: 24rpx;
  1647. color: #2c3e50;
  1648. }
  1649. .step-sub {
  1650. font-size: 20rpx;
  1651. color: #8c9396;
  1652. }
  1653. .content {
  1654. flex: 1;
  1655. padding: 10rpx 24rpx 130rpx;
  1656. box-sizing: border-box;
  1657. }
  1658. .step-block {
  1659. display: flex;
  1660. flex-direction: column;
  1661. gap: 18rpx;
  1662. }
  1663. /* 区域类型 & 路线类型选择卡片 */
  1664. .card.select-card {
  1665. background-color: #ffffff;
  1666. border-radius: 20rpx;
  1667. padding: 20rpx 22rpx 16rpx;
  1668. box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
  1669. }
  1670. .select-title {
  1671. font-size: 26rpx;
  1672. font-weight: 600;
  1673. color: #2c3e50;
  1674. }
  1675. .select-sub {
  1676. margin-top: 6rpx;
  1677. font-size: 22rpx;
  1678. color: #8c9396;
  1679. line-height: 1.5;
  1680. }
  1681. .select-tabs {
  1682. margin-top: 14rpx;
  1683. display: flex;
  1684. flex-wrap: wrap;
  1685. gap: 12rpx;
  1686. }
  1687. .select-tab {
  1688. min-width: 46%;
  1689. padding: 14rpx 16rpx;
  1690. border-radius: 16rpx;
  1691. background-color: #f3f5f7;
  1692. box-sizing: border-box;
  1693. }
  1694. .select-tab.small {
  1695. min-width: 30%;
  1696. }
  1697. .select-tab-label {
  1698. font-size: 24rpx;
  1699. color: #2c3e50;
  1700. }
  1701. .select-tab-sub {
  1702. margin-top: 4rpx;
  1703. font-size: 20rpx;
  1704. color: #8c9396;
  1705. }
  1706. .select-tab.active {
  1707. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1708. box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.26);
  1709. }
  1710. .select-tab.active .select-tab-label,
  1711. .select-tab.active .select-tab-sub {
  1712. color: #ffffff;
  1713. }
  1714. .map-card {
  1715. background-color: #ffffff;
  1716. border-radius: 20rpx;
  1717. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
  1718. overflow: hidden;
  1719. }
  1720. .map-card.small .map-body {
  1721. height: 260rpx;
  1722. }
  1723. .map-header {
  1724. padding: 20rpx 22rpx 10rpx;
  1725. display: flex;
  1726. justify-content: space-between;
  1727. align-items: flex-start;
  1728. }
  1729. .map-header-actions {
  1730. margin-left: 20rpx;
  1731. }
  1732. .map-title {
  1733. font-size: 28rpx;
  1734. font-weight: 600;
  1735. color: #2c3e50;
  1736. }
  1737. .map-sub {
  1738. margin-top: 4rpx;
  1739. font-size: 22rpx;
  1740. color: #8c9396;
  1741. }
  1742. .map-body {
  1743. height: 320rpx;
  1744. background-color: #d3dce6;
  1745. display: flex;
  1746. align-items: center;
  1747. justify-content: center;
  1748. }
  1749. .map-placeholder {
  1750. font-size: 24rpx;
  1751. color: #ffffff;
  1752. text-align: center;
  1753. white-space: pre-line;
  1754. width: 100%;
  1755. }
  1756. /* AMap 容器样式(确保占满 map-body) */
  1757. #mapContainer {
  1758. width: 100%;
  1759. height: 100%;
  1760. }
  1761. .map-footer {
  1762. padding: 12rpx 18rpx 16rpx;
  1763. background-color: #f7faf8;
  1764. }
  1765. .map-hint {
  1766. font-size: 22rpx;
  1767. color: #8c9396;
  1768. }
  1769. .mode-card {
  1770. background-color: #ffffff;
  1771. border-radius: 20rpx;
  1772. padding: 18rpx 20rpx 14rpx;
  1773. box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
  1774. }
  1775. .mode-title-row {
  1776. margin-bottom: 10rpx;
  1777. }
  1778. .mode-title {
  1779. font-size: 26rpx;
  1780. font-weight: 600;
  1781. color: #2c3e50;
  1782. }
  1783. .mode-sub {
  1784. margin-top: 4rpx;
  1785. font-size: 22rpx;
  1786. color: #8c9396;
  1787. }
  1788. .mode-tabs {
  1789. display: flex;
  1790. gap: 12rpx;
  1791. }
  1792. .mode-tab {
  1793. flex: 1;
  1794. padding: 12rpx 0;
  1795. border-radius: 30rpx;
  1796. background-color: #f3f5f7;
  1797. text-align: center;
  1798. font-size: 24rpx;
  1799. color: #666;
  1800. }
  1801. .mode-tab.active {
  1802. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1803. color: #ffffff;
  1804. box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.3);
  1805. }
  1806. .panel-card {
  1807. background-color: #ffffff;
  1808. border-radius: 20rpx;
  1809. padding: 18rpx 20rpx 14rpx;
  1810. box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
  1811. }
  1812. .panel-row {
  1813. display: flex;
  1814. gap: 12rpx;
  1815. }
  1816. .panel-row.center {
  1817. justify-content: space-between;
  1818. align-items: center;
  1819. }
  1820. .panel-desc {
  1821. margin-top: 8rpx;
  1822. font-size: 22rpx;
  1823. color: #8c9396;
  1824. }
  1825. .list-card {
  1826. background-color: #ffffff;
  1827. border-radius: 20rpx;
  1828. padding: 18rpx 20rpx 10rpx;
  1829. box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
  1830. max-height: 420rpx;
  1831. }
  1832. .list-title-row {
  1833. margin-bottom: 6rpx;
  1834. }
  1835. .list-title {
  1836. font-size: 26rpx;
  1837. font-weight: 600;
  1838. color: #2c3e50;
  1839. }
  1840. .list-scroll {
  1841. max-height: 360rpx;
  1842. }
  1843. .list-group {
  1844. margin-top: 10rpx;
  1845. }
  1846. .list-group-header {
  1847. margin-bottom: 4rpx;
  1848. }
  1849. .list-group-title {
  1850. font-size: 24rpx;
  1851. color: #555;
  1852. }
  1853. .point-list {
  1854. margin-top: 4rpx;
  1855. }
  1856. .point-item {
  1857. padding: 8rpx 4rpx;
  1858. border-bottom: 1rpx solid #f0f0f0;
  1859. display: flex;
  1860. align-items: center;
  1861. }
  1862. .point-item.small {
  1863. padding-left: 20rpx;
  1864. }
  1865. .point-item.active {
  1866. background-color: #f0f9f2;
  1867. }
  1868. .point-label {
  1869. font-size: 22rpx;
  1870. color: #3bb44a;
  1871. margin-right: 8rpx;
  1872. }
  1873. .point-coord {
  1874. flex: 1;
  1875. font-size: 22rpx;
  1876. color: #333;
  1877. }
  1878. .point-time {
  1879. font-size: 20rpx;
  1880. color: #999;
  1881. }
  1882. .obstacle-item {
  1883. margin-top: 6rpx;
  1884. }
  1885. .obstacle-title {
  1886. font-size: 22rpx;
  1887. color: #666;
  1888. margin-bottom: 2rpx;
  1889. }
  1890. .empty-row {
  1891. padding: 12rpx 0;
  1892. font-size: 22rpx;
  1893. color: #999;
  1894. }
  1895. .start-index {
  1896. flex: 1;
  1897. align-items: center;
  1898. justify-content: center;
  1899. display: flex;
  1900. }
  1901. .start-index-text {
  1902. font-size: 26rpx;
  1903. color: #2c3e50;
  1904. }
  1905. .card.confirm-card {
  1906. padding: 22rpx 24rpx 10rpx;
  1907. }
  1908. .confirm-title {
  1909. font-size: 28rpx;
  1910. font-weight: 600;
  1911. color: #2c3e50;
  1912. margin-bottom: 12rpx;
  1913. }
  1914. .form-item {
  1915. margin-bottom: 16rpx;
  1916. }
  1917. .form-item.required .label::after {
  1918. content: '*';
  1919. color: #ff4d4f;
  1920. margin-left: 4rpx;
  1921. }
  1922. .label {
  1923. display: block;
  1924. font-size: 26rpx;
  1925. color: #555;
  1926. margin-bottom: 8rpx;
  1927. }
  1928. .input {
  1929. width: 100%;
  1930. height: 80rpx;
  1931. line-height: 80rpx;
  1932. padding: 14rpx 18rpx;
  1933. border-radius: 12rpx;
  1934. background-color: #f7f7f7;
  1935. font-size: 26rpx;
  1936. color: #333;
  1937. box-sizing: border-box;
  1938. }
  1939. .summary-row {
  1940. display: flex;
  1941. justify-content: space-between;
  1942. padding: 8rpx 0;
  1943. border-bottom: 1rpx solid #f0f0f0;
  1944. }
  1945. .summary-label {
  1946. font-size: 24rpx;
  1947. color: #777;
  1948. }
  1949. .summary-value {
  1950. font-size: 24rpx;
  1951. color: #333;
  1952. }
  1953. .tips-card {
  1954. padding: 20rpx 22rpx 16rpx;
  1955. }
  1956. .tips-title {
  1957. font-size: 26rpx;
  1958. color: #2c3e50;
  1959. font-weight: 600;
  1960. margin-bottom: 6rpx;
  1961. }
  1962. .tips-content text {
  1963. display: block;
  1964. font-size: 22rpx;
  1965. color: #666;
  1966. margin-top: 4rpx;
  1967. }
  1968. .footer {
  1969. position: fixed;
  1970. left: 0;
  1971. right: 0;
  1972. bottom: 0;
  1973. padding: 12rpx 26rpx 24rpx;
  1974. background-color: #ffffff;
  1975. box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
  1976. display: flex;
  1977. gap: 12rpx;
  1978. box-sizing: border-box;
  1979. }
  1980. .btn {
  1981. flex: 1;
  1982. height: 84rpx;
  1983. line-height: 84rpx;
  1984. border-radius: 42rpx;
  1985. font-size: 28rpx;
  1986. }
  1987. .btn.primary {
  1988. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1989. color: #ffffff;
  1990. }
  1991. .btn.ghost {
  1992. background-color: #f4f5f7;
  1993. color: #555;
  1994. }
  1995. .btn:disabled {
  1996. opacity: 0.5;
  1997. }
  1998. .btn-location {
  1999. padding: 8rpx 16rpx;
  2000. border-radius: 20rpx;
  2001. background-color: #f0f9f2;
  2002. border: 1rpx solid #3bb44a;
  2003. font-size: 24rpx;
  2004. color: #3bb44a;
  2005. display: flex;
  2006. align-items: center;
  2007. justify-content: center;
  2008. min-width: 120rpx;
  2009. height: 56rpx;
  2010. box-sizing: border-box;
  2011. }
  2012. .btn-location-text {
  2013. font-size: 24rpx;
  2014. color: #3bb44a;
  2015. }
  2016. /* 任务标题行新增作业按钮样式(与详情页保持风格一致) */
  2017. .task-title-row {
  2018. display: flex;
  2019. justify-content: space-between;
  2020. align-items: center;
  2021. }
  2022. .task-title-left {
  2023. display: flex;
  2024. flex-direction: column;
  2025. }
  2026. .task-title-text {
  2027. font-size: 32rpx;
  2028. font-weight: 600;
  2029. color: #2c3e50;
  2030. }
  2031. .task-title-sub {
  2032. margin-top: 4rpx;
  2033. font-size: 22rpx;
  2034. color: #8c9396;
  2035. }
  2036. .task-add-btn {
  2037. width: 60rpx;
  2038. height: 60rpx;
  2039. border-radius: 30rpx;
  2040. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  2041. display: flex;
  2042. align-items: center;
  2043. justify-content: center;
  2044. box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.3);
  2045. }
  2046. .task-add-plus {
  2047. font-size: 40rpx;
  2048. color: #ffffff;
  2049. line-height: 1;
  2050. }
  2051. .p-marker-label {
  2052. background: rgba(59,180,74,0.95);
  2053. color: #fff;
  2054. padding: 2px 6px;
  2055. border-radius: 10px;
  2056. font-size: 18rpx;
  2057. line-height: 1;
  2058. }
  2059. .obstacle-marker-label {
  2060. background: rgba(255, 152, 0, 0.95);
  2061. color: #fff;
  2062. padding: 2px 6px;
  2063. border-radius: 10px;
  2064. font-size: 16rpx;
  2065. line-height: 1;
  2066. }
  2067. .return-marker-label {
  2068. background: rgba(33, 150, 243, 0.95);
  2069. color: #fff;
  2070. padding: 2px 8px;
  2071. border-radius: 10px;
  2072. font-size: 18rpx;
  2073. line-height: 1;
  2074. font-weight: 600;
  2075. }
  2076. </style>