index.vue 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221
  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 setup>
  420. import { ref, reactive, computed, nextTick } from 'vue'
  421. import { onLoad, onReady, onUnload } from '@dcloudio/uni-app'
  422. import { createJob, getRealtimeData } from '@/api/services/job.js'
  423. import coordinateUtils from '@/utils/coordinateUtils.js'
  424. // ==================== Reactive State ====================
  425. // Simple values
  426. const currentStep = ref(1)
  427. const selectedAreaType = ref('loopArea')
  428. const selectedRouteType = ref('loop')
  429. const mode = ref('area')
  430. const submitting = ref(false)
  431. const locating = ref(false)
  432. const map = ref(null)
  433. const amapLoaded = ref(false)
  434. const geolocation = ref(null)
  435. const realtimeMarker = ref(null)
  436. const realtimeTimer = ref(null)
  437. const lastReportTime = ref(null)
  438. const latestRealtimeLngLat = ref(null)
  439. const polling = ref(false)
  440. const loopPolygon = ref(null)
  441. const loopReplaceIndex = ref(null)
  442. const areaPolygon = ref(null)
  443. const returnMarker = ref(null)
  444. const amapKey = ref('9f2cac7ea18905dd3830cf7360a43a35')
  445. const jscode = ref('41af52e416d1fd1b15020dac066cec86')
  446. // Arrays
  447. const currentObstacle = ref([])
  448. const markers = ref([])
  449. const obstacleMarkers = ref([])
  450. const loopMarkers = ref([])
  451. // Complex state object
  452. const jobState = reactive({
  453. machineCode: '',
  454. machineId: '',
  455. areaType: 'loopArea',
  456. routeType: 'loop',
  457. areaPoints: [],
  458. obstacles: [],
  459. returnPoint: null,
  460. startPointIndex: 0,
  461. jobName: '',
  462. fieldId: '',
  463. pathWidth: 100
  464. })
  465. // Constants
  466. const steps = ref([
  467. { id: 1, title: '区域与路线', sub: '选择作业区域类型与路线类型' },
  468. { id: 2, title: '打点建模', sub: '作业区域 / 障碍物 / 返航点' },
  469. { id: 3, title: '选择起点', sub: '确定作业起点位置' },
  470. { id: 4, title: '信息确认', sub: '填写作业名称并提交' }
  471. ])
  472. const areaTypes = ref([
  473. {
  474. value: 'loopArea',
  475. label: '回字形区域',
  476. desc: '规则四边形地块,适合标准往返或回字形路线',
  477. routes: [{ value: 'loop', label: '回字形路线(loop)' }]
  478. },
  479. {
  480. value: 'bowArea',
  481. label: '弓子形区域',
  482. desc: '一侧为弧形或不规则,适合弓字形或自适应路线',
  483. routes: [{ value: 'bow', label: '弓子形路线(bow)' }]
  484. },
  485. {
  486. value: 'customArea',
  487. label: '自定义区域',
  488. desc: '任意多边形地块,路线由后端自适应规划',
  489. routes: [{ value: 'custom', label: '自定义路线(custom)' }]
  490. },
  491. {
  492. value: 'ridgeArea',
  493. label: '垄沟区域',
  494. desc: '存在大量垄沟或等距行的地块',
  495. routes: [{ value: 'ridge', label: '垄沟路线(ridge)' }]
  496. }
  497. ])
  498. const modes = ref([
  499. { value: 'area', label: '作业区域点' },
  500. { value: 'obstacle', label: '障碍物点' },
  501. { value: 'return', label: '返航点' }
  502. ])
  503. // ==================== Computed Properties ====================
  504. const modeLabel = computed(() => {
  505. const m = modes.value.find(m => m.value === mode.value)
  506. return m ? m.label : ''
  507. })
  508. const canUndo = computed(() => {
  509. if (mode.value === 'area') {
  510. return jobState.areaPoints.length > 0
  511. }
  512. if (mode.value === 'obstacle') {
  513. return currentObstacle.value.length > 0
  514. }
  515. if (mode.value === 'return') {
  516. return !!jobState.returnPoint
  517. }
  518. return false
  519. })
  520. const canChangeStart = computed(() => {
  521. return jobState.areaPoints.length > 0
  522. })
  523. const currentStartDisplay = computed(() => {
  524. if (!jobState.areaPoints.length) return '-'
  525. return jobState.startPointIndex + 1
  526. })
  527. const areaTypeLabel = computed(() => {
  528. const a = areaTypes.value.find(a => a.value === jobState.areaType)
  529. return a ? a.label : ''
  530. })
  531. const availableRouteTypes = computed(() => {
  532. const a = areaTypes.value.find(a => a.value === selectedAreaType.value)
  533. return a ? a.routes : []
  534. })
  535. const routeTypeLabel = computed(() => {
  536. const list = areaTypes.value.reduce((acc, cur) => {
  537. if (cur.routes && cur.routes.length) {
  538. acc.push(...cur.routes)
  539. }
  540. return acc
  541. }, [])
  542. const r = list.find(r => r.value === jobState.routeType)
  543. return r ? r.label : jobState.routeType
  544. })
  545. // ==================== Helper Functions ====================
  546. // Simple mock function for current point
  547. function mockCurrentPoint(baseIndex = 0) {
  548. const now = Date.now()
  549. const lng = 120.0 + (baseIndex % 10) * 0.0001
  550. const lat = 30.0 + (baseIndex % 10) * 0.0001
  551. return {
  552. lng,
  553. lat,
  554. timestamp: now
  555. }
  556. }
  557. // ==================== Lifecycle Hooks ====================
  558. onLoad((options) => {
  559. const { machineCode, id } = options || {}
  560. if (machineCode) {
  561. jobState.machineCode = machineCode
  562. jobState.machineId = id
  563. }
  564. })
  565. onReady(() => {
  566. if (typeof window !== 'undefined' && typeof document !== 'undefined') {
  567. console.log('[job-create] onReady - loading AMap script')
  568. loadScript()
  569. }
  570. })
  571. onUnload(() => {
  572. clearRealtimePolling()
  573. clearAllMarkers()
  574. })
  575. // ==================== Map Functions ====================
  576. const loadScript = () => {
  577. // #ifdef H5
  578. window.mapInit = () => {
  579. _createMapWhenReady()
  580. }
  581. const script = document.createElement('script')
  582. script.src = `https://webapi.amap.com/maps?v=2.0&key=${amapKey.value}&callback=mapInit`
  583. document.body.appendChild(script)
  584. amapLoaded.value = true
  585. // #endif
  586. // #ifndef H5
  587. console.warn('当前平台不支持动态加载高德地图脚本,请使用 uni-app 地图组件')
  588. // #endif
  589. }
  590. const _createMapWhenReady = () => {
  591. const createWhenReady = () => {
  592. // #ifdef H5
  593. const container = document.getElementById('mapContainer')
  594. if (!container) {
  595. setTimeout(createWhenReady, 200)
  596. return
  597. }
  598. // #endif
  599. const defaultCenter = [113.382, 22.5211]
  600. const createMapWithCenter = centerArr => {
  601. try {
  602. map.value = new AMap.Map('mapContainer', {
  603. center: centerArr || defaultCenter,
  604. zoom: 16
  605. })
  606. } catch (err) {
  607. console.error('[job-create] create map failed', err)
  608. return
  609. }
  610. try {
  611. if (AMap.TileLayer && typeof AMap.TileLayer.Satellite === 'function') {
  612. const sat = new AMap.TileLayer.Satellite()
  613. map.value.add(sat)
  614. }
  615. } catch (layerErr) {
  616. console.warn('[job-create] satellite layer failed', layerErr)
  617. }
  618. try {
  619. AMap.plugin('AMap.ToolBar', () => {
  620. const toolbar = new AMap.ToolBar()
  621. if (map.value && typeof map.value.addControl === 'function') {
  622. map.value.addControl(toolbar)
  623. }
  624. })
  625. } catch (pluginErr) {
  626. console.warn('[job-create] toolbar plugin failed', pluginErr)
  627. }
  628. AMap.plugin('AMap.Geolocation', () => {
  629. geolocation.value = new AMap.Geolocation({
  630. enableHighAccuracy: true,
  631. timeout: 10000,
  632. maximumAge: 0,
  633. convert: true,
  634. showButton: false,
  635. showMarker: false,
  636. showCircle: false,
  637. panToLocation: false,
  638. zoomToAccuracy: false,
  639. noIpLocate: 0,
  640. GeoLocationFirst: true
  641. })
  642. console.log('[job-create] 高德定位插件加载完成')
  643. tryAutoLocation()
  644. })
  645. if (map.value && typeof map.value.on === 'function') {
  646. map.value.on('click', e => {
  647. const lng = e.lnglat && (e.lnglat.lng || (e.lnglat.getLng && e.lnglat.getLng()))
  648. const lat = e.lnglat && (e.lnglat.lat || (e.lnglat.getLat && e.lnglat.getLat()))
  649. if (lng == null || lat == null) return
  650. onMapClick({ lng, lat })
  651. })
  652. }
  653. setupRealtimePolling()
  654. }
  655. console.log('[job-create] 创建地图使用默认中心点:', defaultCenter)
  656. createMapWithCenter(defaultCenter)
  657. }
  658. createWhenReady()
  659. }
  660. const tryAutoLocation = () => {
  661. console.log('[job-create] 开始自动定位...')
  662. _getCurrentLocation(
  663. res => {
  664. console.log('[job-create] 自动定位成功:', res)
  665. const { longitude, latitude } = res || {}
  666. if (longitude != null && latitude != null) {
  667. console.log('[job-create] 使用定位坐标:', [longitude, latitude])
  668. _centerMapToLngLat(longitude, latitude, 16)
  669. uni.showToast({
  670. title: '已定位到您的位置',
  671. icon: 'success',
  672. duration: 2000
  673. })
  674. } else {
  675. console.warn('[job-create] 定位返回坐标无效')
  676. }
  677. },
  678. err => {
  679. console.warn('[job-create] 自动定位失败:', err)
  680. }
  681. )
  682. }
  683. const _getDeviceLocationAndCenter = () => {
  684. const doCenter = (lng, lat) => {
  685. if (!lng && lng !== 0) return
  686. if (!lat && lat !== 0) return
  687. _centerMapToLngLat(lng, lat, 18)
  688. try {
  689. if (map.value && typeof AMap !== 'undefined' && AMap.Marker) {
  690. new AMap.Marker({
  691. position: [lng, lat],
  692. map: map.value
  693. })
  694. }
  695. } catch (err) {
  696. console.warn('[job-create] add marker failed', err)
  697. }
  698. }
  699. _getCurrentLocation(
  700. res => {
  701. console.log("当前定位", res)
  702. const { longitude, latitude } = res || {}
  703. if (longitude != null && latitude != null) {
  704. doCenter(longitude, latitude)
  705. } else {
  706. const p = getMapCenterPoint() || mockCurrentPoint(0)
  707. doCenter(p.lng, p.lat)
  708. }
  709. },
  710. () => {
  711. const p = getMapCenterPoint() || mockCurrentPoint(0)
  712. doCenter(p.lng, p.lat)
  713. }
  714. )
  715. const p = getMapCenterPoint() || mockCurrentPoint(0)
  716. doCenter(p.lng, p.lat)
  717. }
  718. const _centerMapToLngLat = (lng, lat, zoom = 18) => {
  719. if (!map.value) return
  720. try {
  721. if (typeof map.value.setCenter === 'function') {
  722. map.value.setCenter([lng, lat])
  723. }
  724. if (typeof map.value.setZoom === 'function' && typeof zoom === 'number') {
  725. map.value.setZoom(zoom)
  726. }
  727. } catch (err) {
  728. console.warn('[job-create] center map failed', err)
  729. }
  730. }
  731. const _getCurrentLocation = (successCallback, failCallback, retryCount = 0) => {
  732. if (!geolocation.value) {
  733. if (retryCount < 5) {
  734. console.log(`[job-create] 高德定位未初始化,${retryCount + 1}秒后重试...`)
  735. setTimeout(() => {
  736. _getCurrentLocation(successCallback, failCallback, retryCount + 1)
  737. }, 1000)
  738. return
  739. } else {
  740. console.warn('[job-create] 高德定位未初始化,重试失败')
  741. if (failCallback) failCallback(new Error('高德定位未初始化'))
  742. return
  743. }
  744. }
  745. console.log('[job-create] 开始调用高德定位 getCurrentPosition...')
  746. geolocation.value.getCurrentPosition((status, result) => {
  747. console.log('[job-create] 定位回调 status:', status, 'result:', result)
  748. if (status === 'complete') {
  749. const { lng, lat } = result.position
  750. console.log('[job-create] 高德定位成功:', { lng, lat })
  751. if (successCallback) {
  752. successCallback({
  753. longitude: lng,
  754. latitude: lat
  755. })
  756. }
  757. } else {
  758. console.error('[job-create] 高德定位失败:', result)
  759. let errorMsg = '定位失败'
  760. let errorDetail = ''
  761. switch(result.info) {
  762. case 'FAILED':
  763. errorMsg = '定位失败,请检查网络连接'
  764. errorDetail = '可能原因:网络问题或GPS信号弱'
  765. break
  766. case 'NOT_SUPPORTED':
  767. errorMsg = '浏览器不支持定位功能'
  768. errorDetail = '请使用支持地理定位的现代浏览器'
  769. break
  770. case 'PERMISSION_DENIED':
  771. errorMsg = '定位权限被拒绝'
  772. errorDetail = 'HTTPS环境下需要用户授权定位权限,HTTP环境下浏览器会直接拒绝'
  773. break
  774. case 'PERMISSION_GRANTED':
  775. errorMsg = '定位权限已获取但定位失败'
  776. errorDetail = '可能是GPS信号问题'
  777. break
  778. case 'TIMEOUT':
  779. errorMsg = '定位请求超时'
  780. errorDetail = '请检查网络连接或GPS信号'
  781. break
  782. default:
  783. errorMsg = `定位失败: ${result.info}`
  784. errorDetail = result.message || ''
  785. }
  786. console.warn('[job-create] 定位失败详情:', errorMsg, errorDetail)
  787. if (failCallback) {
  788. failCallback({
  789. code: result.info,
  790. message: errorMsg,
  791. detail: errorDetail
  792. })
  793. }
  794. }
  795. })
  796. }
  797. const getMapCenterPoint = () => {
  798. if (map.value && typeof map.value.getCenter === 'function') {
  799. const c = map.value.getCenter()
  800. return {
  801. lng: c.lng || (c.lng === 0 ? 0 : c.getLng && c.getLng()),
  802. lat: c.lat || (c.lat === 0 ? 0 : c.getLat && c.getLat()),
  803. timestamp: Date.now()
  804. }
  805. }
  806. return null
  807. }
  808. // ==================== UI Interaction Functions ====================
  809. const selectAreaType = (val) => {
  810. selectedAreaType.value = val
  811. const a = areaTypes.value.find(item => item.value === val)
  812. if (a && a.routes && a.routes.length) {
  813. selectedRouteType.value = a.routes[0].value
  814. }
  815. jobState.areaType = selectedAreaType.value
  816. jobState.routeType = selectedRouteType.value
  817. }
  818. const selectRouteType = (val) => {
  819. selectedRouteType.value = val
  820. jobState.routeType = val
  821. }
  822. const switchMode = (val) => {
  823. mode.value = val
  824. }
  825. // ==================== Real-time Polling Functions ====================
  826. const setupRealtimePolling = () => {
  827. if (currentStep.value !== 2) return
  828. const deviceId = jobState.machineId || jobState.machineCode
  829. if (!deviceId) return
  830. if (!amapLoaded.value || !map.value) return
  831. if (realtimeTimer.value) return
  832. fetchRealtimeAndUpdate()
  833. realtimeTimer.value = setInterval(() => {
  834. fetchRealtimeAndUpdate()
  835. }, 3000)
  836. }
  837. const clearRealtimePolling = () => {
  838. if (realtimeTimer.value) {
  839. clearInterval(realtimeTimer.value)
  840. realtimeTimer.value = null
  841. }
  842. polling.value = false
  843. }
  844. const fetchRealtimeAndUpdate = async () => {
  845. try {
  846. const deviceId = jobState.machineCode
  847. if (!deviceId) return
  848. polling.value = true
  849. const res = await getRealtimeData(deviceId)
  850. const payload = res && res.data && (res.data.data || res.data)
  851. if (!payload) return
  852. const reportTime = payload.reportTime
  853. if (reportTime && lastReportTime.value && reportTime < lastReportTime.value) {
  854. return
  855. }
  856. if (reportTime) lastReportTime.value = reportTime
  857. const pt = payload.currentPoint
  858. if (!pt || pt.x == null || pt.y == null) return
  859. const lngLat = [pt.x, pt.y]
  860. latestRealtimeLngLat.value = lngLat
  861. updateRealtimeMarker(lngLat)
  862. try {
  863. if (map.value && typeof map.value.setCenter === 'function') {
  864. map.value.setCenter(lngLat)
  865. }
  866. } catch (e) {}
  867. } catch (e) {
  868. console.warn('[job-create] fetchRealtimeAndUpdate failed', e)
  869. } finally {
  870. polling.value = false
  871. }
  872. }
  873. const updateRealtimeMarker = (lngLat) => {
  874. if (!map.value || !amapLoaded.value) return
  875. if (!lngLat || lngLat.length !== 2) return
  876. if (!realtimeMarker.value) {
  877. realtimeMarker.value = new AMap.Marker({
  878. map: map.value,
  879. position: lngLat
  880. })
  881. } else {
  882. realtimeMarker.value.setPosition(lngLat)
  883. }
  884. }
  885. // ==================== Point Management Functions ====================
  886. const addPoint = () => {
  887. const realtimeLngLat = latestRealtimeLngLat.value
  888. const realtimePoint = realtimeLngLat && realtimeLngLat.length === 2
  889. ? { lng: realtimeLngLat[0], lat: realtimeLngLat[1], timestamp: Date.now() }
  890. : null
  891. let centerPoint = null
  892. if (amapLoaded.value && map.value) {
  893. centerPoint = getMapCenterPoint()
  894. }
  895. const fallbackPoint = centerPoint || mockCurrentPoint(0)
  896. const point = realtimePoint || fallbackPoint
  897. if (mode.value === 'area') {
  898. onMapClick({ lng: point.lng, lat: point.lat })
  899. } else if (mode.value === 'obstacle') {
  900. currentObstacle.value.push(point)
  901. addObstacleMarker(point, jobState.obstacles.length, currentObstacle.value.length - 1)
  902. } else if (mode.value === 'return') {
  903. if (jobState.returnPoint) {
  904. uni.showModal({
  905. title: '覆盖返航点',
  906. content: '已存在返航点,是否覆盖为当前设备位置?',
  907. success: res => {
  908. if (res.confirm) {
  909. jobState.returnPoint = point
  910. updateReturnMarker(point)
  911. }
  912. }
  913. })
  914. } else {
  915. jobState.returnPoint = point
  916. updateReturnMarker(point)
  917. }
  918. }
  919. }
  920. const undoPoint = () => {
  921. if (!canUndo.value) return
  922. if (mode.value === 'area') {
  923. const lastIdx = jobState.areaPoints.length - 1
  924. if (lastIdx >= 0) {
  925. jobState.areaPoints.pop()
  926. const m = loopMarkers.value.pop()
  927. if (m && typeof m.setMap === 'function') {
  928. try { m.setMap(null) } catch (e) {}
  929. }
  930. if (jobState.areaPoints.length >= 3) {
  931. recomputeLoopPolygon()
  932. } else {
  933. if (loopPolygon.value && typeof loopPolygon.value.setMap === 'function') {
  934. try { loopPolygon.value.setMap(null) } catch (e) {}
  935. }
  936. loopPolygon.value = null
  937. }
  938. }
  939. } else if (mode.value === 'obstacle') {
  940. currentObstacle.value.pop()
  941. removeLastObstacleMarker()
  942. } else if (mode.value === 'return') {
  943. jobState.returnPoint = null
  944. clearReturnMarker()
  945. }
  946. }
  947. const finishCurrentObstacle = () => {
  948. if (!currentObstacle.value.length) return
  949. jobState.obstacles.push([...currentObstacle.value])
  950. currentObstacle.value = []
  951. uni.showToast({
  952. title: '已保存一组障碍物',
  953. icon: 'success'
  954. })
  955. }
  956. const flushCurrentObstacle = () => {
  957. if (currentObstacle.value && currentObstacle.value.length) {
  958. jobState.obstacles.push([...currentObstacle.value])
  959. currentObstacle.value = []
  960. }
  961. }
  962. // ==================== Step Navigation Functions ====================
  963. const prevStep = () => {
  964. if (currentStep.value === 1) return
  965. currentStep.value -= 1
  966. if (currentStep.value !== 2) {
  967. clearRealtimePolling()
  968. }
  969. }
  970. const nextStep = () => {
  971. if (currentStep.value === 1) {
  972. if (!selectedAreaType.value || !selectedRouteType.value) {
  973. uni.showToast({
  974. title: '请选择作业区域类型和路线类型',
  975. icon: 'none'
  976. })
  977. return
  978. }
  979. jobState.areaType = selectedAreaType.value
  980. jobState.routeType = selectedRouteType.value
  981. }
  982. if (currentStep.value === 2) {
  983. if (jobState.areaPoints.length < 3) {
  984. uni.showToast({
  985. title: '作业区域点至少需要 3 个',
  986. icon: 'none'
  987. })
  988. return
  989. }
  990. flushCurrentObstacle()
  991. if (!jobState.returnPoint) {
  992. uni.showToast({
  993. title: '请先设置返航点',
  994. icon: 'none'
  995. })
  996. return
  997. }
  998. }
  999. if (currentStep.value === 3) {
  1000. if (!jobState.areaPoints.length) {
  1001. uni.showToast({
  1002. title: '请先在上一步记录作业区域点',
  1003. icon: 'none'
  1004. })
  1005. return
  1006. }
  1007. }
  1008. if (currentStep.value < 4) {
  1009. currentStep.value += 1
  1010. if (currentStep.value === 2) {
  1011. setupRealtimePolling()
  1012. } else {
  1013. clearRealtimePolling()
  1014. }
  1015. }
  1016. }
  1017. // ==================== Start Point Selection Functions ====================
  1018. const setStartIndex = (index) => {
  1019. if (!jobState.areaPoints.length) return
  1020. jobState.startPointIndex = index
  1021. }
  1022. const prevStart = () => {
  1023. if (!canChangeStart.value) return
  1024. const total = jobState.areaPoints.length
  1025. jobState.startPointIndex =
  1026. (jobState.startPointIndex - 1 + total) % total
  1027. }
  1028. const nextStart = () => {
  1029. if (!canChangeStart.value) return
  1030. const total = jobState.areaPoints.length
  1031. jobState.startPointIndex =
  1032. (jobState.startPointIndex + 1) % total
  1033. }
  1034. // ==================== Manual Location Function ====================
  1035. const manualLocation = async () => {
  1036. if (locating.value) return
  1037. locating.value = true
  1038. const doCenter = (lng, lat) => {
  1039. if (!lng && lng !== 0) return
  1040. if (!lat && lat !== 0) return
  1041. _centerMapToLngLat(lng, lat, 18)
  1042. uni.showToast({
  1043. title: '已定位到您的位置',
  1044. icon: 'success',
  1045. duration: 2000
  1046. })
  1047. }
  1048. try {
  1049. const isSecureContext = window.isSecureContext || window.location.protocol === 'https:'
  1050. if (!isSecureContext) {
  1051. console.warn('[job-create] 非HTTPS环境,浏览器可能拒绝定位请求')
  1052. uni.showModal({
  1053. title: '定位提示',
  1054. content: '当前为HTTP环境,浏览器可能拒绝定位请求。建议使用HTTPS访问或使用IP定位。',
  1055. showCancel: false
  1056. })
  1057. }
  1058. await new Promise((resolve, reject) => {
  1059. _getCurrentLocation(
  1060. res => {
  1061. const { longitude, latitude } = res || {}
  1062. if (longitude != null && latitude != null) {
  1063. doCenter(longitude, latitude)
  1064. resolve()
  1065. } else {
  1066. reject(new Error('无效坐标'))
  1067. }
  1068. },
  1069. reject
  1070. )
  1071. })
  1072. } catch (err) {
  1073. console.error('[job-create] 手动定位失败:', err)
  1074. let errorMsg = '定位失败'
  1075. let showDetail = false
  1076. if (err.message) {
  1077. errorMsg = err.message
  1078. }
  1079. if (err.detail) {
  1080. showDetail = true
  1081. }
  1082. if (showDetail) {
  1083. uni.showModal({
  1084. title: errorMsg,
  1085. content: err.detail || '请检查浏览器定位权限设置,或确保使用HTTPS访问',
  1086. showCancel: false
  1087. })
  1088. } else {
  1089. uni.showToast({
  1090. title: errorMsg,
  1091. icon: 'none',
  1092. duration: 3000
  1093. })
  1094. }
  1095. } finally {
  1096. locating.value = false
  1097. }
  1098. }
  1099. // ==================== Submit Job Function ====================
  1100. const submitJob = async () => {
  1101. if (!jobState.jobName) {
  1102. uni.showToast({
  1103. title: '请填写作业名称',
  1104. icon: 'none'
  1105. })
  1106. return
  1107. }
  1108. if (!jobState.fieldId) {
  1109. uni.showToast({
  1110. title: '请填写地块ID',
  1111. icon: 'none'
  1112. })
  1113. return
  1114. }
  1115. if (!jobState.pathWidth) {
  1116. uni.showToast({
  1117. title: '请填写路径宽度',
  1118. icon: 'none'
  1119. })
  1120. return
  1121. }
  1122. if (jobState.areaPoints.length < 3) {
  1123. uni.showToast({
  1124. title: '作业区域点至少需要 3 个',
  1125. icon: 'none'
  1126. })
  1127. return
  1128. }
  1129. flushCurrentObstacle()
  1130. const convertedAreaPoints = coordinateUtils.convertPointsToWgs84(jobState.areaPoints)
  1131. const convertedObstacles = jobState.obstacles.map(obstacleGroup =>
  1132. coordinateUtils.convertPointsToWgs84(obstacleGroup)
  1133. )
  1134. const convertedReturnPoint = jobState.returnPoint ?
  1135. coordinateUtils.convertPointToWgs84(jobState.returnPoint) : undefined
  1136. const areaTypeMap = {
  1137. 'loopArea': 1,
  1138. 'bowArea': 2,
  1139. 'customArea': 3,
  1140. 'ridgeArea': 4
  1141. }
  1142. console.log("jobState", jobState)
  1143. const payload = {
  1144. deviceId: jobState.machineId,
  1145. fieldId: parseInt(jobState.fieldId),
  1146. taskName: jobState.jobName,
  1147. areaType: areaTypeMap[jobState.areaType] || 1,
  1148. waypoints: convertedAreaPoints.map(p => ({ lng: p.lng, lat: p.lat })),
  1149. obstacles: convertedObstacles.flat().map(p => ({ lng: p.lng, lat: p.lat })),
  1150. returnPoint: convertedReturnPoint ? { lng: convertedReturnPoint.lng, lat: convertedReturnPoint.lat } : undefined,
  1151. pathWidth: parseInt(jobState.pathWidth)
  1152. }
  1153. console.log("payload", payload)
  1154. submitting.value = true
  1155. uni.showLoading({ title: '提交中...' })
  1156. try {
  1157. const res = await createJob(payload)
  1158. console.log("res收到尽快发货", res)
  1159. const { data } = res || {}
  1160. if (data && data.code === 200) {
  1161. uni.showToast({
  1162. title: '作业创建成功',
  1163. icon: 'success'
  1164. })
  1165. setTimeout(() => {
  1166. uni.navigateBack()
  1167. }, 800)
  1168. } else {
  1169. uni.showToast({
  1170. title: (data && data.msg) || '提交失败',
  1171. icon: 'none'
  1172. })
  1173. }
  1174. } catch (err) {
  1175. console.error('创建作业失败', err)
  1176. uni.showToast({
  1177. title: '网络异常或接口未就绪',
  1178. icon: 'none'
  1179. })
  1180. } finally {
  1181. submitting.value = false
  1182. uni.hideLoading()
  1183. }
  1184. }
  1185. // ==================== Map Click Handler ====================
  1186. const onMapClick = ({ lng, lat } = {}) => {
  1187. if (!lng && lng !== 0) return
  1188. if (!lat && lat !== 0) return
  1189. if (mode.value !== 'area') return
  1190. const newPoint = { lng, lat, timestamp: Date.now() }
  1191. if (loopReplaceIndex.value != null && loopReplaceIndex.value >= 0 && loopReplaceIndex.value < jobState.areaPoints.length) {
  1192. jobState.areaPoints.splice(loopReplaceIndex.value, 1, newPoint)
  1193. const m = loopMarkers.value[loopReplaceIndex.value]
  1194. if (m && typeof m.setPosition === 'function') {
  1195. try { m.setPosition([lng, lat]) } catch (e) {}
  1196. }
  1197. loopReplaceIndex.value = null
  1198. if (jobState.areaPoints.length >= 3) recomputeLoopPolygon()
  1199. return
  1200. }
  1201. jobState.areaPoints.push(newPoint)
  1202. const idx = jobState.areaPoints.length - 1
  1203. createOrUpdateLoopMarker(idx, newPoint)
  1204. if (jobState.areaPoints.length >= 3) {
  1205. recomputeLoopPolygon()
  1206. }
  1207. }
  1208. const createOrUpdateLoopMarker = (index, point) => {
  1209. if (!map.value || typeof AMap === 'undefined') return
  1210. const pos = [point.lng, point.lat]
  1211. let marker = loopMarkers.value[index]
  1212. if (marker) {
  1213. try { marker.setPosition(pos) } catch (e) {}
  1214. return
  1215. }
  1216. let icon = null
  1217. try {
  1218. icon = new AMap.Icon({
  1219. image: "static/icons/poi-marker-default.png",
  1220. size: new AMap.Size(20, 28),
  1221. imageSize: new AMap.Size(20, 28)
  1222. })
  1223. } catch (e) {
  1224. icon = "static/icons/poi-marker-default.png"
  1225. }
  1226. marker = new AMap.Marker({
  1227. position: pos,
  1228. map: map.value,
  1229. draggable: true,
  1230. icon,
  1231. offset: new AMap.Pixel(-10, -28)
  1232. })
  1233. try {
  1234. const labelContent = `<div class="p-marker-label">P${index + 1}</div>`
  1235. if (typeof marker.setLabel === 'function') {
  1236. marker.setLabel({
  1237. content: labelContent,
  1238. offset: new AMap.Pixel(10, -36)
  1239. })
  1240. } else {
  1241. marker.label = { content: labelContent }
  1242. }
  1243. } catch (e) {}
  1244. marker.on('dragend', e => {
  1245. const p = marker.getPosition()
  1246. const lngv = p.lng || (p.getLng && p.getLng())
  1247. const latv = p.lat || (p.getLat && p.getLat())
  1248. if (lngv == null || latv == null) return
  1249. jobState.areaPoints[index] = { lng: lngv, lat: latv, timestamp: Date.now() }
  1250. if (jobState.areaPoints.length >= 3) recomputeLoopPolygon()
  1251. })
  1252. marker.on('click', () => {
  1253. loopReplaceIndex.value = index
  1254. uni.showToast({ title: `已选中 P${index + 1},下一次点击将替换该点`, icon: 'none' })
  1255. })
  1256. loopMarkers.value[index] = marker
  1257. refreshLoopMarkerLabels()
  1258. }
  1259. const recomputeLoopPolygon = () => {
  1260. if (!map.value) return
  1261. if (!jobState.areaPoints || jobState.areaPoints.length < 3) {
  1262. if (loopPolygon.value && typeof loopPolygon.value.setMap === 'function') {
  1263. try { loopPolygon.value.setMap(null) } catch (e) {}
  1264. }
  1265. loopPolygon.value = null
  1266. return
  1267. }
  1268. const outer = jobState.areaPoints.map(p => [p.lng, p.lat])
  1269. try {
  1270. if (loopPolygon.value) {
  1271. loopPolygon.value.setPath(outer)
  1272. } else {
  1273. loopPolygon.value = new AMap.Polygon({
  1274. map: map.value,
  1275. path: outer,
  1276. strokeColor: '#3bb44a',
  1277. strokeWeight: 2,
  1278. fillColor: '#3bb44a',
  1279. fillOpacity: 0.15
  1280. })
  1281. }
  1282. } catch (err) {
  1283. console.warn('[job-create] recompute loop polygon failed', err)
  1284. }
  1285. }
  1286. const refreshLoopMarkerLabels = () => {
  1287. if (!loopMarkers.value || !loopMarkers.value.length) return
  1288. loopMarkers.value.forEach((m, i) => {
  1289. try {
  1290. const content = `<div class="p-marker-label">P${i + 1}</div>`
  1291. if (typeof m.setLabel === 'function') {
  1292. m.setLabel({ content, offset: new AMap.Pixel(10, -36) })
  1293. } else if (m.label) {
  1294. m.label.content = content
  1295. }
  1296. } catch (e) {}
  1297. })
  1298. }
  1299. const clearLoopGraphics = () => {
  1300. if (loopMarkers.value && loopMarkers.value.length) {
  1301. loopMarkers.value.forEach(m => {
  1302. try { m.setMap(null) } catch (e) {}
  1303. })
  1304. }
  1305. loopMarkers.value = []
  1306. if (loopPolygon.value && typeof loopPolygon.value.setMap === 'function') {
  1307. try { loopPolygon.value.setMap(null) } catch (e) {}
  1308. }
  1309. loopPolygon.value = null
  1310. loopReplaceIndex.value = null
  1311. }
  1312. const deleteAreaPoints = () => {
  1313. jobState.areaPoints = []
  1314. clearLoopGraphics()
  1315. }
  1316. // ==================== Formatting Functions ====================
  1317. const formatPoint = (p) => {
  1318. if (!p) return '--'
  1319. return `${p.lng.toFixed(5)}, ${p.lat.toFixed(5)}`
  1320. }
  1321. const formatPointTime = (ts) => {
  1322. if (!ts) return ''
  1323. const d = new Date(ts)
  1324. const h = `${d.getHours()}`.padStart(2, '0')
  1325. const m = `${d.getMinutes()}`.padStart(2, '0')
  1326. const s = `${d.getSeconds()}`.padStart(2, '0')
  1327. return `${h}:${m}:${s}`
  1328. }
  1329. // ==================== Obstacle Marker Functions ====================
  1330. const addObstacleMarker = (point, obstacleGroupIndex, pointIndex) => {
  1331. if (!map.value || !amapLoaded.value || typeof AMap === 'undefined') return
  1332. try {
  1333. const pos = [point.lng, point.lat]
  1334. let icon = null
  1335. try {
  1336. icon = new AMap.Icon({
  1337. image: "static/icons/poi-marker-default.png",
  1338. size: new AMap.Size(16, 22),
  1339. imageSize: new AMap.Size(16, 22)
  1340. })
  1341. } catch (e) {
  1342. icon = "static/icons/poi-marker-default.png"
  1343. }
  1344. const marker = new AMap.Marker({
  1345. position: pos,
  1346. map: map.value,
  1347. icon,
  1348. offset: new AMap.Pixel(-8, -22)
  1349. })
  1350. try {
  1351. const labelContent = `<div class="obstacle-marker-label">O${obstacleGroupIndex + 1}-${pointIndex + 1}</div>`
  1352. if (typeof marker.setLabel === 'function') {
  1353. marker.setLabel({
  1354. content: labelContent,
  1355. offset: new AMap.Pixel(8, -28)
  1356. })
  1357. }
  1358. } catch (e) {}
  1359. if (!obstacleMarkers.value[obstacleGroupIndex]) {
  1360. obstacleMarkers.value[obstacleGroupIndex] = []
  1361. }
  1362. obstacleMarkers.value[obstacleGroupIndex].push(marker)
  1363. } catch (err) {
  1364. console.warn('[job-create] add obstacle marker failed', err)
  1365. }
  1366. }
  1367. const removeLastObstacleMarker = () => {
  1368. const currentGroupIndex = jobState.obstacles.length
  1369. if (obstacleMarkers.value[currentGroupIndex] && obstacleMarkers.value[currentGroupIndex].length > 0) {
  1370. const marker = obstacleMarkers.value[currentGroupIndex].pop()
  1371. if (marker && typeof marker.setMap === 'function') {
  1372. try {
  1373. marker.setMap(null)
  1374. } catch (e) {}
  1375. }
  1376. }
  1377. }
  1378. // ==================== Return Marker Functions ====================
  1379. const updateReturnMarker = (point) => {
  1380. if (!map.value || !amapLoaded.value || typeof AMap === 'undefined') return
  1381. try {
  1382. const pos = [point.lng, point.lat]
  1383. clearReturnMarker()
  1384. let icon = null
  1385. try {
  1386. icon = new AMap.Icon({
  1387. image: "static/icons/poi-marker-default.png",
  1388. size: new AMap.Size(20, 28),
  1389. imageSize: new AMap.Size(20, 28)
  1390. })
  1391. } catch (e) {
  1392. icon = "static/icons/poi-marker-default.png"
  1393. }
  1394. returnMarker.value = new AMap.Marker({
  1395. position: pos,
  1396. map: map.value,
  1397. icon,
  1398. offset: new AMap.Pixel(-10, -28)
  1399. })
  1400. try {
  1401. const labelContent = '<div class="return-marker-label">返航点</div>'
  1402. if (typeof returnMarker.value.setLabel === 'function') {
  1403. returnMarker.value.setLabel({
  1404. content: labelContent,
  1405. offset: new AMap.Pixel(10, -36)
  1406. })
  1407. }
  1408. } catch (e) {}
  1409. } catch (err) {
  1410. console.warn('[job-create] update return marker failed', err)
  1411. }
  1412. }
  1413. const clearReturnMarker = () => {
  1414. if (returnMarker.value && typeof returnMarker.value.setMap === 'function') {
  1415. try {
  1416. returnMarker.value.setMap(null)
  1417. } catch (e) {}
  1418. }
  1419. returnMarker.value = null
  1420. }
  1421. // ==================== Cleanup Functions ====================
  1422. const clearAllMarkers = () => {
  1423. if (loopMarkers.value && loopMarkers.value.length) {
  1424. loopMarkers.value.forEach(m => {
  1425. try {
  1426. if (m && typeof m.setMap === 'function') {
  1427. m.setMap(null)
  1428. }
  1429. } catch (e) {}
  1430. })
  1431. loopMarkers.value = []
  1432. }
  1433. if (obstacleMarkers.value && obstacleMarkers.value.length) {
  1434. obstacleMarkers.value.forEach(group => {
  1435. if (group && group.length) {
  1436. group.forEach(m => {
  1437. try {
  1438. if (m && typeof m.setMap === 'function') {
  1439. m.setMap(null)
  1440. }
  1441. } catch (e) {}
  1442. })
  1443. }
  1444. })
  1445. obstacleMarkers.value = []
  1446. }
  1447. clearReturnMarker()
  1448. if (realtimeMarker.value && typeof realtimeMarker.value.setMap === 'function') {
  1449. try {
  1450. realtimeMarker.value.setMap(null)
  1451. } catch (e) {}
  1452. realtimeMarker.value = null
  1453. }
  1454. if (loopPolygon.value && typeof loopPolygon.value.setMap === 'function') {
  1455. try {
  1456. loopPolygon.value.setMap(null)
  1457. } catch (e) {}
  1458. loopPolygon.value = null
  1459. }
  1460. }
  1461. </script>
  1462. <style scoped>
  1463. .page {
  1464. display: flex;
  1465. flex-direction: column;
  1466. min-height: 100vh;
  1467. background-color: #f6f9f7;
  1468. }
  1469. .header {
  1470. padding: 24rpx 28rpx 12rpx;
  1471. }
  1472. .title-row {
  1473. display: flex;
  1474. justify-content: space-between;
  1475. align-items: baseline;
  1476. margin-bottom: 12rpx;
  1477. }
  1478. .title {
  1479. font-size: 34rpx;
  1480. font-weight: 600;
  1481. color: #2c3e50;
  1482. }
  1483. .device-id {
  1484. font-size: 24rpx;
  1485. color: #8c9396;
  1486. }
  1487. .step-row {
  1488. display: flex;
  1489. padding: 10rpx 8rpx;
  1490. border-radius: 16rpx;
  1491. background-color: #eef6f0;
  1492. }
  1493. .step-item {
  1494. flex: 1;
  1495. display: flex;
  1496. align-items: center;
  1497. }
  1498. .step-index {
  1499. width: 36rpx;
  1500. height: 36rpx;
  1501. border-radius: 18rpx;
  1502. border: 2rpx solid #b0c4b8;
  1503. display: flex;
  1504. align-items: center;
  1505. justify-content: center;
  1506. font-size: 22rpx;
  1507. color: #7f8c8d;
  1508. margin-right: 10rpx;
  1509. }
  1510. .step-index.active {
  1511. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1512. color: #ffffff;
  1513. border-color: transparent;
  1514. }
  1515. .step-index.done {
  1516. background-color: #ffffff;
  1517. color: #3bb44a;
  1518. border-color: #3bb44a;
  1519. }
  1520. .step-texts {
  1521. display: flex;
  1522. flex-direction: column;
  1523. }
  1524. .step-title {
  1525. font-size: 24rpx;
  1526. color: #2c3e50;
  1527. }
  1528. .step-sub {
  1529. font-size: 20rpx;
  1530. color: #8c9396;
  1531. }
  1532. .content {
  1533. flex: 1;
  1534. padding: 10rpx 24rpx 130rpx;
  1535. box-sizing: border-box;
  1536. }
  1537. .step-block {
  1538. display: flex;
  1539. flex-direction: column;
  1540. gap: 18rpx;
  1541. }
  1542. /* 区域类型 & 路线类型选择卡片 */
  1543. .card.select-card {
  1544. background-color: #ffffff;
  1545. border-radius: 20rpx;
  1546. padding: 20rpx 22rpx 16rpx;
  1547. box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
  1548. }
  1549. .select-title {
  1550. font-size: 26rpx;
  1551. font-weight: 600;
  1552. color: #2c3e50;
  1553. }
  1554. .select-sub {
  1555. margin-top: 6rpx;
  1556. font-size: 22rpx;
  1557. color: #8c9396;
  1558. line-height: 1.5;
  1559. }
  1560. .select-tabs {
  1561. margin-top: 14rpx;
  1562. display: flex;
  1563. flex-wrap: wrap;
  1564. gap: 12rpx;
  1565. }
  1566. .select-tab {
  1567. min-width: 46%;
  1568. padding: 14rpx 16rpx;
  1569. border-radius: 16rpx;
  1570. background-color: #f3f5f7;
  1571. box-sizing: border-box;
  1572. }
  1573. .select-tab.small {
  1574. min-width: 30%;
  1575. }
  1576. .select-tab-label {
  1577. font-size: 24rpx;
  1578. color: #2c3e50;
  1579. }
  1580. .select-tab-sub {
  1581. margin-top: 4rpx;
  1582. font-size: 20rpx;
  1583. color: #8c9396;
  1584. }
  1585. .select-tab.active {
  1586. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1587. box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.26);
  1588. }
  1589. .select-tab.active .select-tab-label,
  1590. .select-tab.active .select-tab-sub {
  1591. color: #ffffff;
  1592. }
  1593. .map-card {
  1594. background-color: #ffffff;
  1595. border-radius: 20rpx;
  1596. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
  1597. overflow: hidden;
  1598. }
  1599. .map-card.small .map-body {
  1600. height: 260rpx;
  1601. }
  1602. .map-header {
  1603. padding: 20rpx 22rpx 10rpx;
  1604. display: flex;
  1605. justify-content: space-between;
  1606. align-items: flex-start;
  1607. }
  1608. .map-header-actions {
  1609. margin-left: 20rpx;
  1610. }
  1611. .map-title {
  1612. font-size: 28rpx;
  1613. font-weight: 600;
  1614. color: #2c3e50;
  1615. }
  1616. .map-sub {
  1617. margin-top: 4rpx;
  1618. font-size: 22rpx;
  1619. color: #8c9396;
  1620. }
  1621. .map-body {
  1622. height: 320rpx;
  1623. background-color: #d3dce6;
  1624. display: flex;
  1625. align-items: center;
  1626. justify-content: center;
  1627. }
  1628. .map-placeholder {
  1629. font-size: 24rpx;
  1630. color: #ffffff;
  1631. text-align: center;
  1632. white-space: pre-line;
  1633. width: 100%;
  1634. }
  1635. /* AMap 容器样式(确保占满 map-body) */
  1636. #mapContainer {
  1637. width: 100%;
  1638. height: 100%;
  1639. }
  1640. .map-footer {
  1641. padding: 12rpx 18rpx 16rpx;
  1642. background-color: #f7faf8;
  1643. }
  1644. .map-hint {
  1645. font-size: 22rpx;
  1646. color: #8c9396;
  1647. }
  1648. .mode-card {
  1649. background-color: #ffffff;
  1650. border-radius: 20rpx;
  1651. padding: 18rpx 20rpx 14rpx;
  1652. box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
  1653. }
  1654. .mode-title-row {
  1655. margin-bottom: 10rpx;
  1656. }
  1657. .mode-title {
  1658. font-size: 26rpx;
  1659. font-weight: 600;
  1660. color: #2c3e50;
  1661. }
  1662. .mode-sub {
  1663. margin-top: 4rpx;
  1664. font-size: 22rpx;
  1665. color: #8c9396;
  1666. }
  1667. .mode-tabs {
  1668. display: flex;
  1669. gap: 12rpx;
  1670. }
  1671. .mode-tab {
  1672. flex: 1;
  1673. padding: 12rpx 0;
  1674. border-radius: 30rpx;
  1675. background-color: #f3f5f7;
  1676. text-align: center;
  1677. font-size: 24rpx;
  1678. color: #666;
  1679. }
  1680. .mode-tab.active {
  1681. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1682. color: #ffffff;
  1683. box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.3);
  1684. }
  1685. .panel-card {
  1686. background-color: #ffffff;
  1687. border-radius: 20rpx;
  1688. padding: 18rpx 20rpx 14rpx;
  1689. box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
  1690. }
  1691. .panel-row {
  1692. display: flex;
  1693. gap: 12rpx;
  1694. }
  1695. .panel-row.center {
  1696. justify-content: space-between;
  1697. align-items: center;
  1698. }
  1699. .panel-desc {
  1700. margin-top: 8rpx;
  1701. font-size: 22rpx;
  1702. color: #8c9396;
  1703. }
  1704. .list-card {
  1705. background-color: #ffffff;
  1706. border-radius: 20rpx;
  1707. padding: 18rpx 20rpx 10rpx;
  1708. box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.03);
  1709. max-height: 420rpx;
  1710. }
  1711. .list-title-row {
  1712. margin-bottom: 6rpx;
  1713. }
  1714. .list-title {
  1715. font-size: 26rpx;
  1716. font-weight: 600;
  1717. color: #2c3e50;
  1718. }
  1719. .list-scroll {
  1720. max-height: 360rpx;
  1721. }
  1722. .list-group {
  1723. margin-top: 10rpx;
  1724. }
  1725. .list-group-header {
  1726. margin-bottom: 4rpx;
  1727. }
  1728. .list-group-title {
  1729. font-size: 24rpx;
  1730. color: #555;
  1731. }
  1732. .point-list {
  1733. margin-top: 4rpx;
  1734. }
  1735. .point-item {
  1736. padding: 8rpx 4rpx;
  1737. border-bottom: 1rpx solid #f0f0f0;
  1738. display: flex;
  1739. align-items: center;
  1740. }
  1741. .point-item.small {
  1742. padding-left: 20rpx;
  1743. }
  1744. .point-item.active {
  1745. background-color: #f0f9f2;
  1746. }
  1747. .point-label {
  1748. font-size: 22rpx;
  1749. color: #3bb44a;
  1750. margin-right: 8rpx;
  1751. }
  1752. .point-coord {
  1753. flex: 1;
  1754. font-size: 22rpx;
  1755. color: #333;
  1756. }
  1757. .point-time {
  1758. font-size: 20rpx;
  1759. color: #999;
  1760. }
  1761. .obstacle-item {
  1762. margin-top: 6rpx;
  1763. }
  1764. .obstacle-title {
  1765. font-size: 22rpx;
  1766. color: #666;
  1767. margin-bottom: 2rpx;
  1768. }
  1769. .empty-row {
  1770. padding: 12rpx 0;
  1771. font-size: 22rpx;
  1772. color: #999;
  1773. }
  1774. .start-index {
  1775. flex: 1;
  1776. align-items: center;
  1777. justify-content: center;
  1778. display: flex;
  1779. }
  1780. .start-index-text {
  1781. font-size: 26rpx;
  1782. color: #2c3e50;
  1783. }
  1784. .card.confirm-card {
  1785. padding: 22rpx 24rpx 10rpx;
  1786. }
  1787. .confirm-title {
  1788. font-size: 28rpx;
  1789. font-weight: 600;
  1790. color: #2c3e50;
  1791. margin-bottom: 12rpx;
  1792. }
  1793. .form-item {
  1794. margin-bottom: 16rpx;
  1795. }
  1796. .form-item.required .label::after {
  1797. content: '*';
  1798. color: #ff4d4f;
  1799. margin-left: 4rpx;
  1800. }
  1801. .label {
  1802. display: block;
  1803. font-size: 26rpx;
  1804. color: #555;
  1805. margin-bottom: 8rpx;
  1806. }
  1807. .input {
  1808. width: 100%;
  1809. height: 80rpx;
  1810. line-height: 80rpx;
  1811. padding: 14rpx 18rpx;
  1812. border-radius: 12rpx;
  1813. background-color: #f7f7f7;
  1814. font-size: 26rpx;
  1815. color: #333;
  1816. box-sizing: border-box;
  1817. }
  1818. .summary-row {
  1819. display: flex;
  1820. justify-content: space-between;
  1821. padding: 8rpx 0;
  1822. border-bottom: 1rpx solid #f0f0f0;
  1823. }
  1824. .summary-label {
  1825. font-size: 24rpx;
  1826. color: #777;
  1827. }
  1828. .summary-value {
  1829. font-size: 24rpx;
  1830. color: #333;
  1831. }
  1832. .tips-card {
  1833. padding: 20rpx 22rpx 16rpx;
  1834. }
  1835. .tips-title {
  1836. font-size: 26rpx;
  1837. color: #2c3e50;
  1838. font-weight: 600;
  1839. margin-bottom: 6rpx;
  1840. }
  1841. .tips-content text {
  1842. display: block;
  1843. font-size: 22rpx;
  1844. color: #666;
  1845. margin-top: 4rpx;
  1846. }
  1847. .footer {
  1848. position: fixed;
  1849. left: 0;
  1850. right: 0;
  1851. bottom: 0;
  1852. padding: 12rpx 26rpx 24rpx;
  1853. background-color: #ffffff;
  1854. box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
  1855. display: flex;
  1856. gap: 12rpx;
  1857. box-sizing: border-box;
  1858. }
  1859. .btn {
  1860. flex: 1;
  1861. height: 84rpx;
  1862. line-height: 84rpx;
  1863. border-radius: 42rpx;
  1864. font-size: 28rpx;
  1865. }
  1866. .btn.primary {
  1867. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1868. color: #ffffff;
  1869. }
  1870. .btn.ghost {
  1871. background-color: #f4f5f7;
  1872. color: #555;
  1873. }
  1874. .btn:disabled {
  1875. opacity: 0.5;
  1876. }
  1877. .btn-location {
  1878. padding: 8rpx 16rpx;
  1879. border-radius: 20rpx;
  1880. background-color: #f0f9f2;
  1881. border: 1rpx solid #3bb44a;
  1882. font-size: 24rpx;
  1883. color: #3bb44a;
  1884. display: flex;
  1885. align-items: center;
  1886. justify-content: center;
  1887. min-width: 120rpx;
  1888. height: 56rpx;
  1889. box-sizing: border-box;
  1890. }
  1891. .btn-location-text {
  1892. font-size: 24rpx;
  1893. color: #3bb44a;
  1894. }
  1895. /* 任务标题行新增作业按钮样式(与详情页保持风格一致) */
  1896. .task-title-row {
  1897. display: flex;
  1898. justify-content: space-between;
  1899. align-items: center;
  1900. }
  1901. .task-title-left {
  1902. display: flex;
  1903. flex-direction: column;
  1904. }
  1905. .task-title-text {
  1906. font-size: 32rpx;
  1907. font-weight: 600;
  1908. color: #2c3e50;
  1909. }
  1910. .task-title-sub {
  1911. margin-top: 4rpx;
  1912. font-size: 22rpx;
  1913. color: #8c9396;
  1914. }
  1915. .task-add-btn {
  1916. width: 60rpx;
  1917. height: 60rpx;
  1918. border-radius: 30rpx;
  1919. background: linear-gradient(135deg, #3bb44a, #66cc6a);
  1920. display: flex;
  1921. align-items: center;
  1922. justify-content: center;
  1923. box-shadow: 0 4rpx 12rpx rgba(59, 180, 74, 0.3);
  1924. }
  1925. .task-add-plus {
  1926. font-size: 40rpx;
  1927. color: #ffffff;
  1928. line-height: 1;
  1929. }
  1930. .p-marker-label {
  1931. background: rgba(59,180,74,0.95);
  1932. color: #fff;
  1933. padding: 2px 6px;
  1934. border-radius: 10px;
  1935. font-size: 18rpx;
  1936. line-height: 1;
  1937. }
  1938. .obstacle-marker-label {
  1939. background: rgba(255, 152, 0, 0.95);
  1940. color: #fff;
  1941. padding: 2px 6px;
  1942. border-radius: 10px;
  1943. font-size: 16rpx;
  1944. line-height: 1;
  1945. }
  1946. .return-marker-label {
  1947. background: rgba(33, 150, 243, 0.95);
  1948. color: #fff;
  1949. padding: 2px 8px;
  1950. border-radius: 10px;
  1951. font-size: 18rpx;
  1952. line-height: 1;
  1953. font-weight: 600;
  1954. }
  1955. </style>