index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. <template>
  2. <div class="task-page" :style="{ height: `calc(100vh - ${tabBarHeight}px)` }">
  3. <farm-header class="task-page__header" />
  4. <div class="task-top">
  5. <div class="map-container" ref="mapContainer"></div>
  6. </div>
  7. <div class="calendar-container">
  8. <customCalendar ref="calendarRef" @dateSelect="handleDateSelect"></customCalendar>
  9. </div>
  10. <div class="task-list">
  11. <div class="task-tabs">
  12. <div class="tab-item" :class="{ active: activeTab === 'pending' }" @click="handleTabChange('pending')">
  13. 待执行({{ pendingCount }})
  14. </div>
  15. <div class="tab-item" :class="{ active: activeTab === 'completed' }" @click="handleTabChange('completed')">
  16. 已完成({{ completedCount }})
  17. </div>
  18. </div>
  19. <div class="work-task-list">
  20. <div
  21. v-for="item in displayTaskList"
  22. :key="item.id"
  23. class="task-card"
  24. @click.stop="handleViewDetail(item)"
  25. :class="{ 'is-risk': item.isHighRisk }"
  26. >
  27. <div v-if="item.status === 'pending' && item.cornerTip" class="task-card__corner">{{ item.cornerTip }}</div>
  28. <div v-if="item.status === 'completed' && item.cornerTip" class="task-card__corner done-corner" :class="{ 'myself-corner': item.isByMyself }">
  29. <el-icon size="14" class="myself-corner__icon" :class="{ 'myself-corner__icon-success': item.isByMyself }"><SuccessFilled /></el-icon>
  30. {{ item.cornerTip }}
  31. </div>
  32. <div class="task-card__header">
  33. <!-- <img class="task-card__icon" src="@/assets/img/home/task.png" alt="" /> -->
  34. <span class="task-card__title van-ellipsis">{{ item.executeDate + ' ' + item.title }}</span>
  35. <span v-if="item.isNormal" class="badge-tag normal-badge">标准类</span>
  36. <span v-if="item.isAbnormal" class="badge-tag abnormal-badge">异常类</span>
  37. <span v-if="item.isWeather" class="badge-tag weather-badge">气象类</span>
  38. <span v-if="item.isHighRisk" class="badge-tag risk-badge">{{ t("workExecute.highRisk") }}</span>
  39. </div>
  40. <div class="task-card__body">
  41. <div class="info-line">
  42. 触发条件:{{ item.fw_trigger || '--' }}
  43. </div>
  44. <div class="info-line">
  45. {{ t("workExecute.farmSituation") }}{{ item.analysisSummary }}
  46. </div>
  47. <div class="info-line">
  48. {{ t("workExecute.executeAreaLabel") }}{{ item.areaCode }}
  49. </div>
  50. </div>
  51. <div class="task-card__footer">
  52. <div class="footer-l">
  53. <img class="footer-l__icon" src="@/assets/img/home/user-icon-fill.png" alt="">
  54. 外包农事
  55. </div>
  56. <div class="footer-r">
  57. <div class="card-btn card-btn--ghost" @click.stop="handleForward(item)">
  58. {{ t("workExecute.forward") }}
  59. </div>
  60. <div class="card-btn card-btn--primary" @click.stop="handleViewDetail(item)">
  61. 我已完成
  62. </div>
  63. </div>
  64. </div>
  65. </div>
  66. <div v-if="!displayTaskList.length" class="empty-tip">{{ t("workExecute.noData") }}</div>
  67. </div>
  68. </div>
  69. <farm-calendar-popup v-model:show="showFarmCalendarPopup" @confirm="goCompleteFarmInfo" />
  70. <survey-ask-popup v-model:show="showSurveyAskPopup" @confirm="handleSurveyConfirm" />
  71. <!-- 诊断报告弹窗:组件内接口判断 exists=2 且本地未展示过 -->
  72. <diagnosis-report-popup />
  73. <!-- 恢复种植弹窗 -->
  74. <restore-planting-popup />
  75. </div>
  76. </template>
  77. <script setup>
  78. import { computed, nextTick, onActivated, onMounted, ref } from "vue";
  79. import { useStore } from "vuex";
  80. import { useRouter } from "vue-router";
  81. import IndexMap from "./index";
  82. import customCalendar from "./components/calendar.vue";
  83. import farmHeader from "@/components/farmHeader.vue";
  84. import farmCalendarPopup from "@/components/popup/farmCalendarPopup.vue";
  85. import surveyAskPopup from "@/components/popup/surveyAskPopup.vue";
  86. import diagnosisReportPopup from "@/components/popup/diagnosisReportPopup.vue";
  87. import restorePlantingPopup from "@/components/popup/restorePlantingPopup.vue";
  88. import { useI18n } from "@/i18n";
  89. import wx from "weixin-js-sdk";
  90. // ---------- 常量 ----------
  91. /** zoneId / farmId / date 临时固定,后续改为当前分区 / 当天 */
  92. const QUERY = {
  93. zoneId: 41,
  94. farmId: 321,
  95. date: "2025-09-11",
  96. };
  97. /** 任务类型:fw_tasks 标准化农事 / weather_work 气象农事 */
  98. const TASK_KIND = {
  99. NORMAL: "normal",
  100. WEATHER: "weather",
  101. };
  102. const COMPLETED_STATUS_RE = /已完成|已执行|已认证|本人完成/;
  103. const { t } = useI18n();
  104. const store = useStore();
  105. const router = useRouter();
  106. const indexMap = new IndexMap();
  107. const mapContainer = ref(null);
  108. const calendarRef = ref(null);
  109. const showFarmCalendarPopup = ref(false);
  110. /** 调查问卷弹窗:后续由接口决定是否展示 */
  111. const showSurveyAskPopup = ref(false);
  112. const tabBarHeight = computed(() => store.state.home.tabBarHeight);
  113. // ---------- 页面状态 ----------
  114. const filterDate = ref(QUERY.date); // 当前选中日期
  115. const taskList = ref([]); // 已映射的农事卡片(归属接口 date)
  116. const mapCenterPoint = ref(null); // 接口返回的分区中心点 WKT
  117. const activeTab = ref("pending");
  118. // ---------- 计算属性 ----------
  119. const dayTaskList = computed(() =>
  120. taskList.value.filter((item) => item.executeDateFull === filterDate.value)
  121. );
  122. const pendingCount = computed(() => dayTaskList.value.filter((item) => item.status === "pending").length);
  123. const completedCount = computed(() => dayTaskList.value.filter((item) => item.status === "completed").length);
  124. const displayTaskList = computed(() => dayTaskList.value.filter((item) => item.status === activeTab.value));
  125. // ---------- 工具函数 ----------
  126. function getMiniUserId() {
  127. return store.state.home.miniUserId || localStorage.getItem("MINI_USER_ID");
  128. }
  129. /** 与图片一致:zone_id / farm_id / trigger_date(临时写死,后续改当前分区) */
  130. const resolveQueryParams = () => ({
  131. zone_id: QUERY.zoneId,
  132. farm_id: QUERY.farmId,
  133. trigger_date: filterDate.value || QUERY.date,
  134. });
  135. const isCompletedStatus = (status) => COMPLETED_STATUS_RE.test(String(status || ""));
  136. const formatExecuteDate = (dateStr) => {
  137. if (!dateStr) return "";
  138. return String(dateStr).replace(/^\d{4}-(\d{2})-(\d{2})$/, "$1/$2");
  139. };
  140. const buildPointWkt = (lng, lat, index = 0) => {
  141. if (lng == null || lat == null || Number.isNaN(Number(lng)) || Number.isNaN(Number(lat))) return "";
  142. const offset = (index % 6) * 0.00025;
  143. return `POINT(${Number(lng) + offset} ${Number(lat) + offset * 0.6})`;
  144. };
  145. /** 单条任务映射为卡片;kind 区分标准化 / 气象 */
  146. const mapTaskItem = (task, index, { dateStr, lat, lng, zoneId, kind }) => {
  147. const executeDateFull = task.date || dateStr;
  148. const completed = isCompletedStatus(task.fw_status);
  149. const isWeather = kind === TASK_KIND.WEATHER;
  150. const prefix = isWeather ? "weather" : "fw";
  151. return {
  152. id: `${prefix}_${task.manual_id || task.mechanical_id || index}_${executeDateFull}`,
  153. status: completed ? "completed" : "pending",
  154. title: task.fw_name || "",
  155. isHighRisk: false,
  156. isNormal: !isWeather,
  157. isAbnormal: false,
  158. isWeather,
  159. cornerTip: task.fw_status || (completed ? "已完成" : "待执行"),
  160. analysisSummary: task.fw_simple_explain || task.fw_explain || "",
  161. areaCode: zoneId ? `分区 ${zoneId}` : "",
  162. farmName: task.fw_name || "",
  163. executeDate: formatExecuteDate(executeDateFull),
  164. executeDateFull,
  165. zoneId,
  166. manualId: task.manual_id,
  167. mechanicalId: task.mechanical_id,
  168. latitude: lat,
  169. longitude: lng,
  170. wkt: buildPointWkt(lng, lat, index),
  171. };
  172. };
  173. /** 合并 fw_tasks(标准化)+ weather_work(气象) */
  174. const mapZoneTaskList = (data) => {
  175. const dateStr = resolveRangeStart(data);
  176. const lat = data.latitude ?? 22.970229;
  177. const lng = data.longitude ?? 113.746646;
  178. const zoneId = data.zone_id ?? QUERY.zoneId;
  179. const ctx = { dateStr, lat, lng, zoneId };
  180. const normalList = (Array.isArray(data.fw_tasks) ? data.fw_tasks : [])
  181. .filter(Boolean)
  182. .map((task, index) => mapTaskItem(task, index, { ...ctx, kind: TASK_KIND.NORMAL }));
  183. const weatherList = (Array.isArray(data.weather_work) ? data.weather_work : [])
  184. .filter(Boolean)
  185. .map((task, index) =>
  186. mapTaskItem(task, normalList.length + index, { ...ctx, kind: TASK_KIND.WEATHER })
  187. );
  188. return { list: [...normalList, ...weatherList], lat, lng, dateStr, zoneId };
  189. };
  190. const resolveRangeStart = (data) => {
  191. if (data?.start_date) return data.start_date;
  192. if (data?.date) return data.date;
  193. return QUERY.date;
  194. };
  195. // ---------- 业务逻辑 ----------
  196. /** 无果园信息时展示「完善农场信息 / 专属农事日历」弹窗 */
  197. async function checkFarmCalendarPopup() {
  198. const id = getMiniUserId();
  199. if (!id) {
  200. showFarmCalendarPopup.value = false;
  201. return;
  202. }
  203. try {
  204. const res = await VE_API.questionnaire.getFarms({ id });
  205. const list = Array.isArray(res?.data) ? res.data : [];
  206. showFarmCalendarPopup.value = list.length === 0;
  207. } catch (e) {
  208. console.warn("[work_execute] getFarms failed", e);
  209. showFarmCalendarPopup.value = false;
  210. }
  211. }
  212. const refreshMapMarkers = () => {
  213. if (!indexMap.kmap) return;
  214. indexMap.initData(getMapMarkerList(), "farmName", "wkt");
  215. };
  216. /** 地图展示全部日期的待执行农事 */
  217. const getMapMarkerList = () =>
  218. taskList.value.filter((item) => item.status === "pending" && item.wkt);
  219. const syncCalendar = (data) => {
  220. const start = resolveRangeStart(data);
  221. calendarRef.value?.setStartDate?.(start);
  222. nextTick(() => {
  223. const preferDate =
  224. (taskList.value.some((item) => item.executeDateFull === filterDate.value) && filterDate.value) ||
  225. start;
  226. calendarRef.value?.selectDateByValue?.(preferDate);
  227. calendarRef.value?.setSolarTerm?.(taskList.value);
  228. });
  229. };
  230. const fetchZoneFwTaskList = async () => {
  231. try {
  232. const params = resolveQueryParams();
  233. const res = await VE_API.questionnaire.getZoneFwTaskList(params);
  234. const data = res?.data;
  235. if (!data) {
  236. taskList.value = [];
  237. mapCenterPoint.value = null;
  238. calendarRef.value?.setStartDate?.(params.trigger_date);
  239. calendarRef.value?.selectDateByValue?.(params.trigger_date);
  240. calendarRef.value?.setSolarTerm?.([]);
  241. refreshMapMarkers();
  242. return;
  243. }
  244. const { list, lat, lng, dateStr } = mapZoneTaskList(data);
  245. mapCenterPoint.value = `POINT(${lng} ${lat})`;
  246. taskList.value = list;
  247. filterDate.value = dateStr;
  248. syncCalendar(data);
  249. nextTick(() => refreshMapMarkers());
  250. } catch (e) {
  251. console.error("获取农事任务失败", e);
  252. taskList.value = [];
  253. }
  254. };
  255. // ---------- 事件处理 ----------
  256. const handleTabChange = (key) => {
  257. if (activeTab.value === key) return;
  258. activeTab.value = key;
  259. };
  260. const handleDateSelect = (date) => {
  261. const next = date ?? QUERY.date;
  262. if (filterDate.value === next) return;
  263. filterDate.value = next;
  264. fetchZoneFwTaskList();
  265. };
  266. const handleForward = (item) => {
  267. const workName = item?.title || item?.farmName || "农事详情";
  268. const query = {
  269. askInfo: { title: "转发农事", content: "是否分享给好友" },
  270. shareText: `${workName} 已触发,请在最佳农时内及时执行`,
  271. targetUrl: "work_detail",
  272. paramsPage: JSON.stringify({
  273. ...buildFwTaskQuery(item),
  274. fromShare: 1,
  275. }),
  276. imageUrl: "https://birdseye-img.sysuimars.com/birdseye-look-mini/work_img.png",
  277. };
  278. wx.miniProgram.navigateTo({
  279. url: `/pages/subPages/share_page/index?pageParams=${JSON.stringify(query)}&type=sharePage`,
  280. });
  281. };
  282. const buildFwTaskQuery = (item) => ({
  283. zone_id: item?.zoneId ?? QUERY.zoneId,
  284. date: item?.executeDateFull || QUERY.date,
  285. manual_id: item?.manualId,
  286. mechanical_id: item?.mechanicalId,
  287. statusVal: item?.status,
  288. lat: item?.latitude,
  289. lng: item?.longitude,
  290. fw_name: item?.title || item?.farmName || "",
  291. isNormal: item?.isNormal ? 1 : 0,
  292. isAbnormal: item?.isAbnormal ? 1 : 0,
  293. isWeather: item?.isWeather ? 1 : 0,
  294. isHighRisk: item?.isHighRisk ? 1 : 0,
  295. });
  296. const handleViewDetail = (item) => {
  297. router.push({
  298. path: "/work_detail",
  299. query: buildFwTaskQuery(item),
  300. });
  301. };
  302. const goCompleteFarmInfo = () => {
  303. router.push("/entry_information");
  304. };
  305. const handleSurveyConfirm = () => {
  306. showSurveyAskPopup.value = false;
  307. router.push("/survey_entry");
  308. };
  309. const initMap = () => {
  310. if (!mapContainer.value) return;
  311. const fallback = store.state.home.miniUserLocationPoint || "POINT(113.746646 22.970229)";
  312. const point = mapCenterPoint.value || fallback;
  313. if (!indexMap.kmap) {
  314. indexMap.initMap(point, mapContainer.value, true);
  315. }
  316. refreshMapMarkers();
  317. };
  318. onMounted(() => {
  319. checkFarmCalendarPopup();
  320. nextTick(async () => {
  321. await fetchZoneFwTaskList();
  322. initMap();
  323. });
  324. });
  325. onActivated(() => {
  326. checkFarmCalendarPopup();
  327. nextTick(() => {
  328. if (!indexMap.kmap) {
  329. initMap();
  330. return;
  331. }
  332. if (mapContainer.value && indexMap.kmap.map) {
  333. const checkAndUpdateSize = () => {
  334. const container = mapContainer.value;
  335. if (!container) return;
  336. const rect = container.getBoundingClientRect();
  337. if (rect.width > 0 && rect.height > 0) {
  338. indexMap.kmap.map.updateSize();
  339. refreshMapMarkers();
  340. } else {
  341. setTimeout(checkAndUpdateSize, 100);
  342. }
  343. };
  344. setTimeout(checkAndUpdateSize, 200);
  345. }
  346. });
  347. });
  348. </script>
  349. <style lang="scss" scoped>
  350. .task-page {
  351. width: 100%;
  352. height: calc(100vh - 50px);
  353. overflow: auto;
  354. box-sizing: border-box;
  355. background: linear-gradient(180deg, #d1ebff 0%, #ffffff 100%);
  356. .map-container {
  357. width: 100%;
  358. height: 162px;
  359. clip-path: inset(0 round 8px);
  360. }
  361. .calendar-container {
  362. padding: 4px 12px 8px;
  363. }
  364. .task-top {
  365. padding: 10px 12px 0;
  366. }
  367. .task-list {
  368. padding: 0 12px 20px;
  369. }
  370. .task-tabs {
  371. display: flex;
  372. align-items: center;
  373. justify-content: center;
  374. gap: 50px;
  375. margin: 2px 0 26px 0;
  376. .tab-item {
  377. color: #1D2129;
  378. position: relative;
  379. &.active {
  380. color: #2199F8;
  381. font-weight: 500;
  382. &::after {
  383. content: '';
  384. position: absolute;
  385. bottom: -10px;
  386. left: 0;
  387. width: 100%;
  388. height: 2px;
  389. background: #2199F8;
  390. }
  391. }
  392. }
  393. }
  394. .work-task-list {
  395. display: flex;
  396. flex-direction: column;
  397. gap: 10px;
  398. }
  399. .task-card {
  400. position: relative;
  401. padding: 14px 12px 12px;
  402. border-radius: 8px;
  403. background: #fff;
  404. box-sizing: border-box;
  405. overflow: hidden;
  406. box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.04);
  407. &__corner {
  408. position: absolute;
  409. top: 0;
  410. right: 0;
  411. max-width: 78px;
  412. padding: 2px 8px;
  413. border-radius: 0 8px 0 8px;
  414. background: rgba(33, 153, 248, 0.1);
  415. color: #2199F8;
  416. // background: rgba(255, 106, 106, 0.1);
  417. // color: #FF6A6A;
  418. font-size: 12px;
  419. line-height: 16px;
  420. white-space: wrap;
  421. box-sizing: border-box;
  422. text-align: center;
  423. // overflow: hidden;
  424. // text-overflow: ellipsis;
  425. &.done-corner {
  426. max-width: 86px;
  427. height: 24px;
  428. background: rgba(118, 126, 116, 0.1);
  429. display: flex;
  430. align-items: center;
  431. gap: 4px;
  432. color: #959595;
  433. &.myself-corner {
  434. background: rgba(74, 191, 50, 0.1);
  435. color: #4ABF32;
  436. }
  437. .myself-corner__icon {
  438. color: #959595;
  439. flex-shrink: 0;
  440. }
  441. .myself-corner__icon-success {
  442. color: #4ABF32;
  443. }
  444. }
  445. }
  446. &__header {
  447. display: flex;
  448. align-items: center;
  449. gap: 8px;
  450. padding-right: 8px;
  451. min-width: 0;
  452. }
  453. &__icon {
  454. width: 16px;
  455. height: 16px;
  456. flex-shrink: 0;
  457. }
  458. &__title {
  459. max-width: 160px;
  460. font-size: 16px;
  461. font-weight: 500;
  462. color: #000;
  463. }
  464. .badge-tag {
  465. flex-shrink: 0;
  466. height: 18px;
  467. padding: 0 6px;
  468. border-radius: 2px;
  469. font-size: 12px;
  470. }
  471. .badge-tag + .badge-tag {
  472. margin-left: 4px;
  473. }
  474. .risk-badge {
  475. background: #FF6A6A;
  476. color: #FFFFFF;
  477. }
  478. .normal-badge {
  479. background: rgba(33, 153, 248, 0.1);
  480. color: #2199F8;
  481. }
  482. .abnormal-badge {
  483. background: rgba(255, 106, 106, 0.1);
  484. color: #FF6A6A;
  485. }
  486. .weather-badge {
  487. background: rgba(255, 149, 61, 0.1);
  488. color: #FDA202;
  489. }
  490. &__body {
  491. margin-top: 10px;
  492. padding-top: 10px;
  493. border-top: 0.5px solid rgba(0, 0, 0, 0.05);
  494. }
  495. .info-line {
  496. font-size: 14px;
  497. line-height: 20px;
  498. color: rgba(111, 114, 116, 0.6);
  499. & + .info-line {
  500. margin-top: 6px;
  501. }
  502. }
  503. &__footer {
  504. display: flex;
  505. align-items: center;
  506. justify-content: space-between;
  507. gap: 12px;
  508. margin-top: 14px;
  509. .footer-l {
  510. background: rgba(33, 153, 248, 0.1);
  511. height: 20px;
  512. display: flex;
  513. align-items: center;
  514. gap: 6px;
  515. padding: 0 6px;
  516. border-radius: 2px;
  517. font-size: 12px;
  518. color: #2199F8;
  519. .footer-l__icon {
  520. width: 14px;
  521. height: 14px;
  522. flex-shrink: 0;
  523. }
  524. }
  525. .footer-r {
  526. display: flex;
  527. align-items: center;
  528. gap: 10px;
  529. }
  530. }
  531. .card-btn {
  532. height: 32px;
  533. border-radius: 20px;
  534. text-align: center;
  535. line-height: 32px;
  536. font-size: 12px;
  537. &--ghost {
  538. background: #F6F6F6;
  539. color: #000000;
  540. padding: 0 20px;
  541. }
  542. &--primary {
  543. background: #2199F8;
  544. color: #FFFFFF;
  545. padding: 0 16px;
  546. }
  547. }
  548. }
  549. .empty-tip {
  550. padding: 40px 0;
  551. text-align: center;
  552. color: #86909c;
  553. font-size: 14px;
  554. }
  555. }
  556. </style>