index.vue 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. <template>
  2. <div class="task-page" v-loading="pageLoading" element-loading-background="rgba(15, 15, 15, 0.55)"
  3. :style="{ height: `calc(100vh - ${tabBarHeight}px)` }">
  4. <farm-header class="task-page__header" />
  5. <div class="task-top">
  6. <div class="map-container" ref="mapContainer"></div>
  7. </div>
  8. <div class="calendar-container">
  9. <customCalendar ref="calendarRef" @dateSelect="handleDateSelect"></customCalendar>
  10. </div>
  11. <div class="task-list">
  12. <div class="task-toolbar">
  13. <div class="task-tabs">
  14. <div class="tab-item" :class="{ active: activeTab === 'pending' }"
  15. @click="handleTabChange('pending')">
  16. 待执行 {{ pendingCount }}
  17. </div>
  18. <div class="tab-item" :class="{ active: activeTab === 'completed' }"
  19. @click="handleTabChange('completed')">
  20. 已完成 {{ completedCount }}
  21. </div>
  22. </div>
  23. <div class="task-sort" :class="{ active: sortByUrgency }" @click="sortByUrgency = !sortByUrgency">
  24. <span class="task-sort__icon">
  25. <i /><i /><i />
  26. </span>
  27. <span>按紧急程度</span>
  28. </div>
  29. </div>
  30. <div v-if="zoneList.length" class="zone-chips">
  31. <div v-for="zone in zoneList" :key="zone.id" class="zone-chip"
  32. :class="{ active: String(activeZoneId) === String(zone.id) }" @click="handleZoneChange(zone.id)">
  33. {{ zone.name || "分区" }}
  34. </div>
  35. </div>
  36. <div class="work-task-list">
  37. <div v-for="item in displayTaskList" :key="item.id" class="task-card" :class="{
  38. 'is-risk': item.isHighRisk,
  39. 'is-weather': item.isWeather,
  40. 'is-abnormal': item.isAbnormal,
  41. 'is-normal': item.isNormal,
  42. }" @click.stop="handleViewDetail(item)">
  43. <div class="task-card__content">
  44. <div v-if="item.isHighRisk" class="task-card__risk">
  45. {{ t("workExecute.highRisk") }}
  46. </div>
  47. <div class="task-card__header">
  48. <span class="task-card__title van-ellipsis">
  49. {{ item.executeDate + " " + item.title }}
  50. </span>
  51. <span class="task-card__tag">高风险</span>
  52. </div>
  53. <div class="task-card__tags">
  54. <span v-if="item.isNormal" class="badge-tag normal-badge">标准类</span>
  55. <span v-if="item.isAbnormal" class="badge-tag abnormal-badge">异常类</span>
  56. <span v-if="item.isWeather" class="badge-tag weather-badge">气象类</span>
  57. <span v-if="item.status === 'pending' && item.cornerTip" class="badge-tag status-badge">
  58. {{ item.cornerTip }}
  59. </span>
  60. <span v-if="item.status === 'completed' && item.cornerTip"
  61. class="badge-tag status-badge is-done" :class="{ 'is-myself': item.isByMyself }">
  62. {{ item.cornerTip }}
  63. </span>
  64. </div>
  65. <div class="task-card__body">
  66. <div class="info-row">
  67. <span class="info-row__label">农情研判</span>
  68. <span class="info-row__value">{{ item.analysisSummary || "--" }}</span>
  69. </div>
  70. <div class="info-row">
  71. <span class="info-row__label">执行区域</span>
  72. <span class="info-row__value">{{ item.areaCode || "--" }}</span>
  73. </div>
  74. <div v-if="item.fw_trigger" class="info-row">
  75. <span class="info-row__label">触发条件</span>
  76. <span class="info-row__value">{{ item.fw_trigger }}</span>
  77. </div>
  78. </div>
  79. <div class="task-card__footer">
  80. <div class="footer-l">
  81. <img src="@/assets/img/farm/farm.png" alt="外包农事" class="outsource-icon">
  82. 外包农事
  83. </div>
  84. <div class="footer-r">
  85. <div class="card-btn card-btn--ghost" @click.stop="handleForward(item)">
  86. <Icon size="14" color="rgba(255, 255, 255, 0.6)" name="share" />
  87. 派发工人
  88. </div>
  89. <div class="card-btn card-btn--primary" @click.stop="handleViewDetail(item)">
  90. <img src="@/assets/img/farm/shield.png" alt="我已完成" class="complete-icon">
  91. 我已完成
  92. </div>
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. <div v-if="!displayTaskList.length" class="empty-tip">{{ t("workExecute.noData") }}</div>
  98. </div>
  99. </div>
  100. <farm-calendar-popup v-model:show="showFarmCalendarPopup" @confirm="goCompleteFarmInfo" />
  101. <survey-ask-popup v-model:show="showSurveyAskPopup" @confirm="handleSurveyConfirm" />
  102. <!-- 诊断报告弹窗:组件内接口判断 exists=2 且本地未展示过 -->
  103. <diagnosis-report-popup />
  104. <!-- 恢复种植弹窗 -->
  105. <restore-planting-popup />
  106. </div>
  107. </template>
  108. <script setup>
  109. import { computed, nextTick, onActivated, onDeactivated, onMounted, ref } from "vue";
  110. import { useStore } from "vuex";
  111. import { useRouter } from "vue-router";
  112. import IndexMap from "./index";
  113. import customCalendar from "./components/calendar.vue";
  114. import farmHeader from "@/components/farmHeader.vue";
  115. import farmCalendarPopup from "@/components/popup/farmCalendarPopup.vue";
  116. import surveyAskPopup from "@/components/popup/surveyAskPopup.vue";
  117. import diagnosisReportPopup from "@/components/popup/diagnosisReportPopup.vue";
  118. import restorePlantingPopup from "@/components/popup/restorePlantingPopup.vue";
  119. import { useI18n } from "@/i18n";
  120. import { saveSelectedFarm } from "@/utils/selectedFarm";
  121. import wx from "weixin-js-sdk";
  122. import { Icon } from 'vant';
  123. // ---------- 常量 ----------
  124. /** 任务类型:fw_tasks 标准农事 / weather_work 气象农事 / abnormal_fw_tasks 异常农事 */
  125. const TASK_KIND = {
  126. NORMAL: "normal",
  127. WEATHER: "weather",
  128. ABNORMAL: "abnormal",
  129. };
  130. /** 方案文案优先取人工,接口里标准任务是字符串,气象/异常是按方案拆开的对象 */
  131. const SCHEME_TEXT_KEYS = ["人工方案", "机械方案", "人工", "机械"];
  132. /** 列表进详情:整条任务缓存,详情页不再打 fw_task_details */
  133. const FW_TASK_CACHE_KEY = "WORK_EXECUTE_FW_TASK_DETAIL";
  134. const COMPLETED_STATUS_RE = /已完成|已执行|已认证|本人完成/;
  135. const { t } = useI18n();
  136. const store = useStore();
  137. const router = useRouter();
  138. const indexMap = new IndexMap();
  139. const mapContainer = ref(null);
  140. const calendarRef = ref(null);
  141. const showFarmCalendarPopup = ref(false);
  142. /** 调查问卷弹窗:后续由接口决定是否展示 */
  143. const showSurveyAskPopup = ref(false);
  144. const tabBarHeight = computed(() => store.state.home.tabBarHeight);
  145. // ---------- 工具函数 ----------
  146. function getMiniUserId() {
  147. return store.state.home.miniUserId || localStorage.getItem("MINI_USER_ID");
  148. }
  149. function getTodayDate() {
  150. const now = new Date();
  151. const y = now.getFullYear();
  152. const m = String(now.getMonth() + 1).padStart(2, "0");
  153. const d = String(now.getDate()).padStart(2, "0");
  154. return `${y}-${m}-${d}`;
  155. }
  156. function getSelectedFarm() {
  157. try {
  158. return JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
  159. } catch {
  160. return {};
  161. }
  162. }
  163. function getSelectedFarmId() {
  164. const stored = localStorage.getItem("selectedFarmId");
  165. if (stored != null && stored !== "") return Number(stored);
  166. const farm = getSelectedFarm();
  167. const id = farm.farm_id ?? farm.id;
  168. return id != null && id !== "" ? Number(id) : null;
  169. }
  170. function getSelectedZoneId() {
  171. const farm = getSelectedFarm();
  172. const id = farm.zone_id ?? farm.zoneId;
  173. return id != null && id !== "" ? Number(id) : null;
  174. }
  175. /** crop_type 取作物编码,如 LCH;各入口字段不统一 */
  176. function getSelectedCropType() {
  177. const farm = getSelectedFarm();
  178. return (
  179. farm.category_code ||
  180. farm.crop_code ||
  181. farm.crop_type ||
  182. farm.farm_category ||
  183. farm.farm_variety ||
  184. ""
  185. );
  186. }
  187. // ---------- 页面状态 ----------
  188. const filterDate = ref(getTodayDate()); // 当前选中日期
  189. const taskList = ref([]); // 已映射的农事卡片(归属接口 date)
  190. const mapCenterPoint = ref(null); // 接口返回的分区中心点 WKT
  191. const activeTab = ref("pending");
  192. const pageLoading = ref(false);
  193. const zoneList = ref([]);
  194. const activeZoneId = ref(null);
  195. /** 按紧急程度:高风险优先 */
  196. const sortByUrgency = ref(false);
  197. // ---------- 计算属性 ----------
  198. const dayTaskList = computed(() =>
  199. taskList.value.filter((item) => {
  200. if (item.executeDateFull !== filterDate.value) return false;
  201. if (activeZoneId.value == null || activeZoneId.value === "") return true;
  202. return String(item.zoneId) === String(activeZoneId.value);
  203. })
  204. );
  205. const pendingCount = computed(() => dayTaskList.value.filter((item) => item.status === "pending").length);
  206. const completedCount = computed(() => dayTaskList.value.filter((item) => item.status === "completed").length);
  207. const displayTaskList = computed(() => {
  208. const list = dayTaskList.value.filter((item) => item.status === activeTab.value);
  209. if (!sortByUrgency.value) return list;
  210. return [...list].sort((a, b) => Number(!!b.isHighRisk) - Number(!!a.isHighRisk));
  211. });
  212. /** 日历窗口:今天起连续 7 天 */
  213. function getCalendarDateRange() {
  214. const start = new Date();
  215. return Array.from({ length: 7 }, (_, i) => {
  216. const d = new Date(start);
  217. d.setDate(start.getDate() + i);
  218. const y = d.getFullYear();
  219. const m = String(d.getMonth() + 1).padStart(2, "0");
  220. const day = String(d.getDate()).padStart(2, "0");
  221. return `${y}-${m}-${day}`;
  222. });
  223. }
  224. /** zone_id 优先用分区筛选;否则退回农场缓存分区 */
  225. const resolveQueryParams = (triggerDate = getTodayDate()) => {
  226. const params = {
  227. zone_id: activeZoneId.value ?? getSelectedZoneId(),
  228. farm_id: getSelectedFarmId(),
  229. trigger_date: triggerDate,
  230. };
  231. const cropType = getSelectedCropType();
  232. if (cropType) params.crop_type = cropType;
  233. return params;
  234. };
  235. async function fetchZoneList() {
  236. const farmId = getSelectedFarmId();
  237. if (farmId == null) {
  238. zoneList.value = [];
  239. activeZoneId.value = null;
  240. return;
  241. }
  242. try {
  243. const res = await VE_API.questionnaire.getZones({ farm_id: farmId });
  244. const zones = Array.isArray(res?.data?.zones) ? res.data.zones : [];
  245. zoneList.value = zones
  246. .map((zone) => ({
  247. id: zone.zone_id,
  248. name: zone.zone_name || `分区${zone.zone_id ?? ""}`,
  249. }))
  250. .filter((zone) => zone.id != null && zone.id !== "");
  251. const cachedZoneId = getSelectedZoneId();
  252. const stillExists = zoneList.value.some(
  253. (zone) => String(zone.id) === String(activeZoneId.value)
  254. );
  255. if (stillExists) return;
  256. const preferred = zoneList.value.find(
  257. (zone) => String(zone.id) === String(cachedZoneId)
  258. );
  259. activeZoneId.value = preferred?.id ?? zoneList.value[0]?.id ?? null;
  260. } catch (e) {
  261. console.warn("[work_execute] getZones failed", e);
  262. zoneList.value = [];
  263. activeZoneId.value = getSelectedZoneId();
  264. }
  265. }
  266. const isCompletedStatus = (status) => COMPLETED_STATUS_RE.test(String(status || ""));
  267. const formatExecuteDate = (dateStr) => {
  268. if (!dateStr) return "";
  269. return String(dateStr).replace(/^\d{4}-(\d{2})-(\d{2})$/, "$1/$2");
  270. };
  271. const buildPointWkt = (lng, lat, index = 0) => {
  272. if (lng == null || lat == null || Number.isNaN(Number(lng)) || Number.isNaN(Number(lat))) return "";
  273. const offset = (index % 6) * 0.00025;
  274. return `POINT(${Number(lng) + offset} ${Number(lat) + offset * 0.6})`;
  275. };
  276. /** 名称/说明可能是字符串,也可能是 { 人工方案, 机械方案 } / { 人工, 机械 } */
  277. const pickSchemeText = (value) => {
  278. if (value == null || value === "") return "";
  279. if (typeof value === "string" || typeof value === "number") return String(value);
  280. if (typeof value !== "object") return "";
  281. for (const key of SCHEME_TEXT_KEYS) {
  282. if (value[key]) return String(value[key]);
  283. }
  284. const first = Object.values(value).find((item) => item != null && item !== "");
  285. return first != null ? String(first) : "";
  286. };
  287. /** 单条任务映射为卡片;kind 区分标准 / 气象 / 异常 */
  288. const mapTaskItem = (task, index, { dateStr, lat, lng, zoneId, kind }) => {
  289. const executeDateFull = task.date || dateStr;
  290. const isWeather = kind === TASK_KIND.WEATHER;
  291. const isAbnormal = kind === TASK_KIND.ABNORMAL;
  292. const statusText = pickSchemeText(task.fw_status || task.status_text);
  293. const completed = isCompletedStatus(statusText);
  294. const prefix = isAbnormal ? "abnormal" : isWeather ? "weather" : "fw";
  295. const title = pickSchemeText(task.fw_name);
  296. const trigger = pickSchemeText(task.fw_trigger) || (isAbnormal ? task.abnormal_type || "" : "");
  297. return {
  298. id: `${prefix}_${task.manual_id || task.mechanical_id || pickSchemeText(task.fw_code) || index}_${executeDateFull}_${index}`,
  299. status: completed ? "completed" : "pending",
  300. title,
  301. isHighRisk: false,
  302. isNormal: kind === TASK_KIND.NORMAL,
  303. isAbnormal,
  304. isWeather,
  305. cornerTip: statusText || (completed ? "已完成" : "待执行"),
  306. analysisSummary:
  307. pickSchemeText(task.fw_explain_simple) ||
  308. pickSchemeText(task.fw_simple_explain) ||
  309. pickSchemeText(task.fw_explain),
  310. fw_trigger: trigger,
  311. areaCode: zoneId ? `分区 ${zoneId}` : "",
  312. farmName: title,
  313. executeDate: formatExecuteDate(executeDateFull),
  314. executeDateFull,
  315. zoneId,
  316. manualId: task.manual_id,
  317. mechanicalId: task.mechanical_id,
  318. latitude: lat,
  319. longitude: lng,
  320. wkt: buildPointWkt(lng, lat, index),
  321. // 详情页直接消费原始列表项,避免再请求 fw_task_details
  322. rawTask: task,
  323. };
  324. };
  325. /** 合并三类农事;无单条日期时归到本次请求日 */
  326. const mapZoneTaskList = (data, requestDate) => {
  327. const dateStr = requestDate || resolveRangeStart(data);
  328. const lat = data.latitude ?? 22.970229;
  329. const lng = data.longitude ?? 113.746646;
  330. const zoneId = data.zone_id ?? getSelectedZoneId();
  331. const ctx = { dateStr, lat, lng, zoneId };
  332. const mapList = (raw, kind, offset) =>
  333. (Array.isArray(raw) ? raw : [])
  334. .filter(Boolean)
  335. .map((task, index) => mapTaskItem(task, offset + index, { ...ctx, kind }));
  336. const normalList = mapList(data.fw_tasks, TASK_KIND.NORMAL, 0);
  337. const weatherList = mapList(data.weather_work, TASK_KIND.WEATHER, normalList.length);
  338. const abnormalList = mapList(
  339. data.abnormal_fw_tasks,
  340. TASK_KIND.ABNORMAL,
  341. normalList.length + weatherList.length
  342. );
  343. return { list: [...normalList, ...weatherList, ...abnormalList], lat, lng, dateStr, zoneId };
  344. };
  345. const resolveRangeStart = (data) => {
  346. if (data?.start_date) return data.start_date;
  347. if (data?.date) return data.date;
  348. return getTodayDate();
  349. };
  350. // ---------- 业务逻辑 ----------
  351. /** 只同步选中态和农事角标,不改日历起始日(窗口固定为今天起 7 天) */
  352. const syncCalendar = () => {
  353. nextTick(() => {
  354. calendarRef.value?.selectDateByValue?.(filterDate.value);
  355. calendarRef.value?.setSolarTerm?.(taskList.value);
  356. });
  357. };
  358. /** 无果园信息时展示「完善农场信息 / 专属农事日历」弹窗 */
  359. async function checkFarmCalendarPopup() {
  360. const id = getMiniUserId();
  361. if (!id) {
  362. showFarmCalendarPopup.value = false;
  363. return;
  364. }
  365. try {
  366. const res = await VE_API.questionnaire.getFarms({ id });
  367. const list = Array.isArray(res?.data) ? res.data : [];
  368. showFarmCalendarPopup.value = list.length === 0;
  369. } catch (e) {
  370. console.warn("[work_execute] getFarms failed", e);
  371. showFarmCalendarPopup.value = false;
  372. }
  373. }
  374. /** 无选中农场时写入列表首项,保证 zone_fw_task_list 有 farm_id */
  375. async function ensureSelectedFarm() {
  376. if (getSelectedFarmId() != null) return true;
  377. const id = getMiniUserId();
  378. if (!id) return false;
  379. try {
  380. const res = await VE_API.questionnaire.getFarms({ id });
  381. const list = Array.isArray(res?.data) ? res.data : [];
  382. return list[0] ? saveSelectedFarm(list[0]) : false;
  383. } catch (e) {
  384. console.warn("[work_execute] ensureSelectedFarm failed", e);
  385. return false;
  386. }
  387. }
  388. const refreshMapMarkers = () => {
  389. if (!indexMap.kmap) return;
  390. indexMap.initData(getMapMarkerList(), "farmName", "wkt");
  391. };
  392. /** 与日历一致:展示 7 天窗口内全部待执行农事(列表仍按选中日过滤) */
  393. const getMapMarkerList = () =>
  394. taskList.value.filter((item) => item.status === "pending" && item.wkt);
  395. /**
  396. * 按日历 7 天逐日请求后合并(接口按 trigger_date 返回当日任务)。
  397. * 切换日期只本地过滤列表,地图仍用完整 7 天点位。
  398. */
  399. const fetchZoneFwTaskList = async () => {
  400. try {
  401. const dates = getCalendarDateRange();
  402. const settled = await Promise.all(
  403. dates.map((triggerDate) =>
  404. VE_API.questionnaire
  405. .getZoneFwTaskList(resolveQueryParams(triggerDate))
  406. .then((res) => ({ triggerDate, data: res?.data || null }))
  407. .catch((e) => {
  408. console.warn("[work_execute] zone_fw_task_list failed", triggerDate, e);
  409. return { triggerDate, data: null };
  410. })
  411. )
  412. );
  413. const merged = [];
  414. const seen = new Set();
  415. let lat = null;
  416. let lng = null;
  417. for (const { triggerDate, data } of settled) {
  418. if (!data) continue;
  419. const mapped = mapZoneTaskList(data, triggerDate);
  420. if (lat == null && mapped.lat != null) lat = mapped.lat;
  421. if (lng == null && mapped.lng != null) lng = mapped.lng;
  422. for (const item of mapped.list) {
  423. if (seen.has(item.id)) continue;
  424. seen.add(item.id);
  425. merged.push(item);
  426. }
  427. }
  428. mapCenterPoint.value =
  429. lat != null && lng != null ? `POINT(${lng} ${lat})` : null;
  430. taskList.value = merged;
  431. if (!merged.length) {
  432. calendarRef.value?.selectDateByValue?.(filterDate.value);
  433. calendarRef.value?.setSolarTerm?.([]);
  434. } else {
  435. syncCalendar();
  436. }
  437. nextTick(() => refreshMapMarkers());
  438. } catch (e) {
  439. console.error("获取农事任务失败", e);
  440. taskList.value = [];
  441. refreshMapMarkers();
  442. }
  443. };
  444. // ---------- 事件处理 ----------
  445. const handleTabChange = (key) => {
  446. if (activeTab.value === key) return;
  447. activeTab.value = key;
  448. };
  449. const handleZoneChange = async (zoneId) => {
  450. if (String(activeZoneId.value) === String(zoneId)) return;
  451. activeZoneId.value = zoneId;
  452. pageLoading.value = true;
  453. try {
  454. await fetchZoneFwTaskList();
  455. nextTick(() => refreshMapMarkers());
  456. } finally {
  457. pageLoading.value = false;
  458. }
  459. };
  460. /** 切日期:只过滤下方列表;地图与日历角标共用完整 7 天数据 */
  461. const handleDateSelect = (date) => {
  462. const next = date ?? getTodayDate();
  463. if (filterDate.value === next) return;
  464. filterDate.value = next;
  465. };
  466. const handleForward = (item) => {
  467. const workName = item?.title || item?.farmName || "农事详情";
  468. const query = {
  469. askInfo: { title: "转发农事", content: "是否分享给好友" },
  470. shareText: `${workName} 已触发,请在最佳农时内及时执行`,
  471. targetUrl: "work_detail",
  472. paramsPage: JSON.stringify({
  473. ...buildFwTaskQuery(item),
  474. fromShare: 1,
  475. }),
  476. imageUrl: "https://birdseye-img.sysuimars.com/birdseye-look-mini/work_img.png",
  477. };
  478. wx.miniProgram.navigateTo({
  479. url: `/pages/subPages/share_page/index?pageParams=${JSON.stringify(query)}&type=sharePage`,
  480. });
  481. };
  482. const buildFwTaskQuery = (item) => ({
  483. zone_id: item?.zoneId ?? getSelectedZoneId(),
  484. date: item?.executeDateFull || getTodayDate(),
  485. manual_id: item?.manualId,
  486. mechanical_id: item?.mechanicalId,
  487. statusVal: item?.status,
  488. lat: item?.latitude,
  489. lng: item?.longitude,
  490. fw_name: item?.title || item?.farmName || "",
  491. isNormal: item?.isNormal ? 1 : 0,
  492. isAbnormal: item?.isAbnormal ? 1 : 0,
  493. isWeather: item?.isWeather ? 1 : 0,
  494. isHighRisk: item?.isHighRisk ? 1 : 0,
  495. fromZoneList: 1,
  496. });
  497. /** 写入详情页缓存:原始接口字段 + 列表侧元信息 */
  498. const saveFwTaskCache = (item) => {
  499. if (!item?.rawTask) return;
  500. sessionStorage.setItem(
  501. FW_TASK_CACHE_KEY,
  502. JSON.stringify({
  503. raw: item.rawTask,
  504. meta: {
  505. zone_id: item.zoneId ?? getSelectedZoneId(),
  506. date: item.executeDateFull || getTodayDate(),
  507. lat: item.latitude,
  508. lng: item.longitude,
  509. statusVal: item.status,
  510. statusText: item.cornerTip || "",
  511. fw_name: item.title || item.farmName || "",
  512. isNormal: item.isNormal ? 1 : 0,
  513. isAbnormal: item.isAbnormal ? 1 : 0,
  514. isWeather: item.isWeather ? 1 : 0,
  515. isHighRisk: item.isHighRisk ? 1 : 0,
  516. },
  517. })
  518. );
  519. };
  520. const handleViewDetail = (item) => {
  521. saveFwTaskCache(item);
  522. router.push({
  523. path: "/work_detail",
  524. query: buildFwTaskQuery(item),
  525. });
  526. };
  527. const goCompleteFarmInfo = () => {
  528. router.push("/entry_information");
  529. };
  530. const handleSurveyConfirm = () => {
  531. showSurveyAskPopup.value = false;
  532. router.push("/survey_entry");
  533. };
  534. /** 当前选中农场的 farm_location(POINT) */
  535. function resolveSelectedFarmLocationPoint() {
  536. const farm = getSelectedFarm();
  537. const candidates = [
  538. farm.farm_location,
  539. farm.wkt,
  540. farm.geom_wkt,
  541. localStorage.getItem("selectedFarmPoint"),
  542. ];
  543. for (const item of candidates) {
  544. if (typeof item === "string" && /^POINT\s*\(/i.test(item.trim())) {
  545. return item.trim();
  546. }
  547. }
  548. return "";
  549. }
  550. const initMap = () => {
  551. if (!mapContainer.value) return;
  552. // 中心点:接口分区中心 → 选中农场 farm_location → 登录定位 → 兜底
  553. const fallback = store.state.home.miniUserLocationPoint || "POINT(113.746646 22.970229)";
  554. const point = mapCenterPoint.value || resolveSelectedFarmLocationPoint() || fallback;
  555. if (!indexMap.kmap) {
  556. indexMap.initMap(point, mapContainer.value, true);
  557. }
  558. refreshMapMarkers();
  559. };
  560. /** keep-alive 下切农场 / 再次进入只走 onActivated,需在此拉列表 */
  561. const refreshPage = async () => {
  562. if (pageLoading.value) return;
  563. pageLoading.value = true;
  564. try {
  565. await ensureSelectedFarm();
  566. // 无农场时不打任务列表(弹窗由 checkFarmCalendarPopup 负责)
  567. if (getSelectedFarmId() == null) {
  568. taskList.value = [];
  569. zoneList.value = [];
  570. activeZoneId.value = null;
  571. if (!indexMap.kmap) initMap();
  572. return;
  573. }
  574. await fetchZoneList();
  575. await fetchZoneFwTaskList();
  576. if (!indexMap.kmap) {
  577. initMap();
  578. return;
  579. }
  580. if (!mapContainer.value || !indexMap.kmap.map) return;
  581. const checkAndUpdateSize = () => {
  582. const container = mapContainer.value;
  583. if (!container) return;
  584. const rect = container.getBoundingClientRect();
  585. if (rect.width > 0 && rect.height > 0) {
  586. indexMap.kmap.map.updateSize();
  587. refreshMapMarkers();
  588. } else {
  589. setTimeout(checkAndUpdateSize, 100);
  590. }
  591. };
  592. setTimeout(checkAndUpdateSize, 200);
  593. } finally {
  594. pageLoading.value = false;
  595. }
  596. };
  597. onMounted(() => {
  598. checkFarmCalendarPopup();
  599. nextTick(refreshPage);
  600. });
  601. onActivated(() => {
  602. checkFarmCalendarPopup();
  603. nextTick(refreshPage);
  604. });
  605. onDeactivated(() => {
  606. showFarmCalendarPopup.value = false;
  607. });
  608. </script>
  609. <style lang="scss" scoped>
  610. .task-page {
  611. width: 100%;
  612. height: calc(100vh - 50px);
  613. overflow: auto;
  614. box-sizing: border-box;
  615. background: url('@/assets/img/agricultural/page-bg.png') no-repeat top center;
  616. background-size: 100% 250px;
  617. ::v-deep {
  618. .el-loading-spinner .path {
  619. stroke: rgba(255, 212, 137, 0.8);
  620. }
  621. }
  622. .map-container {
  623. width: 100%;
  624. height: 162px;
  625. border-radius: 12px;
  626. overflow: hidden;
  627. background: #1a1a1a;
  628. }
  629. .calendar-container {
  630. padding: 10px 12px 8px;
  631. }
  632. .task-top {
  633. padding: 10px 12px 0;
  634. }
  635. .task-list {
  636. padding: 0 12px 20px;
  637. }
  638. .task-toolbar {
  639. display: flex;
  640. align-items: center;
  641. justify-content: space-between;
  642. gap: 12px;
  643. margin: 4px 0 10px;
  644. }
  645. .task-tabs {
  646. display: inline-flex;
  647. align-items: center;
  648. gap: 0;
  649. padding: 4px;
  650. border-radius: 6px;
  651. background: rgba(255, 255, 255, 0.05);
  652. .tab-item {
  653. min-width: 82px;
  654. height: 30px;
  655. padding: 0 14px;
  656. border-radius: 6px;
  657. color: rgba(255, 255, 255, 0.5);
  658. font-size: 14px;
  659. line-height: 30px;
  660. text-align: center;
  661. cursor: pointer;
  662. box-sizing: border-box;
  663. &.active {
  664. color: #fff;
  665. background: rgba(255, 255, 255, 0.1);
  666. border: 1px solid rgba(255, 255, 255, 0.12);
  667. }
  668. }
  669. }
  670. .task-sort {
  671. display: inline-flex;
  672. align-items: center;
  673. gap: 6px;
  674. flex-shrink: 0;
  675. font-size: 12px;
  676. line-height: 18px;
  677. color: rgba(255, 255, 255, 0.45);
  678. cursor: pointer;
  679. &.active {
  680. color: #ffd489;
  681. }
  682. &__icon {
  683. display: inline-flex;
  684. flex-direction: column;
  685. justify-content: center;
  686. gap: 2px;
  687. width: 12px;
  688. i {
  689. display: block;
  690. height: 1.5px;
  691. border-radius: 1px;
  692. background: currentColor;
  693. &:nth-child(1) {
  694. width: 100%;
  695. }
  696. &:nth-child(2) {
  697. width: 75%;
  698. }
  699. &:nth-child(3) {
  700. width: 50%;
  701. }
  702. }
  703. }
  704. }
  705. .zone-chips {
  706. display: flex;
  707. gap: 8px;
  708. overflow-x: auto;
  709. margin-bottom: 14px;
  710. padding-bottom: 2px;
  711. -webkit-overflow-scrolling: touch;
  712. &::-webkit-scrollbar {
  713. display: none;
  714. }
  715. }
  716. .zone-chip {
  717. flex-shrink: 0;
  718. height: 28px;
  719. padding: 0 12px;
  720. border-radius: 4px;
  721. font-size: 12px;
  722. line-height: 28px;
  723. color: rgba(255, 255, 255, 0.6);
  724. background: rgba(255, 255, 255, 0.05);
  725. backdrop-filter: blur(4px);
  726. border: 1px solid transparent;
  727. box-sizing: border-box;
  728. cursor: pointer;
  729. &.active {
  730. background: rgba(255, 255, 255, 0.1);
  731. border-color: rgba(255, 255, 255, 0.1);
  732. }
  733. }
  734. .work-task-list {
  735. display: flex;
  736. flex-direction: column;
  737. gap: 16px;
  738. }
  739. .task-card {
  740. position: relative;
  741. box-sizing: border-box;
  742. overflow: hidden;
  743. width: 100%;
  744. padding: 10px;
  745. border-radius: 12px;
  746. background: linear-gradient(180deg, #1A191D 0%, #121216 100%);
  747. box-shadow: inset 1px 0 0 #FFD489;
  748. &::after {
  749. content: "";
  750. position: absolute;
  751. right: 0;
  752. top: 0;
  753. width: 110px;
  754. height: 42px;
  755. border-radius: 0 12px 0 0;
  756. background: url('@/assets/img/agricultural/card-bg.png') no-repeat bottom left / 100% 100%;
  757. }
  758. &.is-weather {
  759. box-shadow: inset 1px 0 0 #2199F8;
  760. }
  761. &.is-abnormal,
  762. &.is-risk {
  763. box-shadow: inset 1px 0 0 #FF6A6A;
  764. }
  765. &__risk {
  766. position: absolute;
  767. top: 12px;
  768. right: 12px;
  769. height: 22px;
  770. padding: 0 8px;
  771. border-radius: 6px;
  772. background: #c93c3c;
  773. color: #fff;
  774. font-size: 12px;
  775. line-height: 22px;
  776. }
  777. &__header {
  778. display: flex;
  779. align-items: center;
  780. justify-content: space-between;
  781. min-width: 0;
  782. .task-card__tag {
  783. height: 20px;
  784. padding: 0 7px;
  785. border: 1px solid rgba(208, 49, 46, 0.4);
  786. background: #822422;
  787. border-radius: 4px;
  788. font-size: 12px;
  789. line-height: 20px;
  790. box-sizing: border-box;
  791. color: #fff;
  792. }
  793. }
  794. &__title {
  795. font-size: 16px;
  796. font-weight: 500;
  797. line-height: 22px;
  798. color: #fff;
  799. }
  800. &__tags {
  801. display: flex;
  802. flex-wrap: wrap;
  803. gap: 6px;
  804. margin-top: 8px;
  805. }
  806. .badge-tag {
  807. flex-shrink: 0;
  808. height: 20px;
  809. padding: 0 7px;
  810. border-radius: 4px;
  811. font-size: 12px;
  812. line-height: 20px;
  813. box-sizing: border-box;
  814. border: 1px solid transparent;
  815. }
  816. .normal-badge {
  817. color: #FFD489;
  818. border-color: rgba(255, 212, 137, 0.12);
  819. background: rgba(255, 212, 137, 0.05);
  820. }
  821. .abnormal-badge {
  822. color: #FF6A6A;
  823. background: rgba(255, 106, 106, 0.05);
  824. border-color: rgba(255, 106, 106, 0.12);
  825. }
  826. .weather-badge {
  827. color: #2199F8;
  828. background: rgba(33, 153, 248, 0.05);
  829. border-color: rgba(33, 153, 248, 0.12);
  830. }
  831. .status-badge {
  832. color: #1a1408;
  833. background: #ffd489;
  834. border-color: #ffd489;
  835. &.is-done {
  836. color: rgba(255, 255, 255, 0.65);
  837. background: rgba(255, 255, 255, 0.1);
  838. border-color: transparent;
  839. }
  840. &.is-myself {
  841. color: #7dff9a;
  842. background: rgba(74, 191, 50, 0.16);
  843. }
  844. }
  845. &__body {
  846. margin-top: 12px;
  847. }
  848. .info-row {
  849. display: flex;
  850. align-items: flex-start;
  851. gap: 12px;
  852. font-size: 13px;
  853. line-height: 20px;
  854. &+.info-row {
  855. margin-top: 8px;
  856. }
  857. &__label {
  858. flex-shrink: 0;
  859. font-size: 12px;
  860. color: rgba(255, 255, 255, 0.5);
  861. }
  862. &__value {
  863. flex: 1;
  864. min-width: 0;
  865. color: rgba(255, 255, 255, 0.9);
  866. }
  867. }
  868. &__footer {
  869. display: flex;
  870. align-items: center;
  871. justify-content: space-between;
  872. gap: 12px;
  873. margin-top: 8px;
  874. padding-top: 8px;
  875. border-top: 1px solid rgba(255, 255, 255, 0.05);
  876. .footer-l {
  877. font-size: 12px;
  878. color: rgba(255, 255, 255, 0.5);
  879. display: flex;
  880. align-items: center;
  881. gap: 6px;
  882. .outsource-icon {
  883. width: 11px;
  884. }
  885. }
  886. .footer-r {
  887. display: flex;
  888. align-items: center;
  889. gap: 8px;
  890. }
  891. }
  892. .card-btn {
  893. height: 30px;
  894. border-radius: 6px;
  895. text-align: center;
  896. line-height: 30px;
  897. font-size: 12px;
  898. box-sizing: border-box;
  899. display: flex;
  900. align-items: center;
  901. gap: 4px;
  902. padding: 0 10px;
  903. .complete-icon {
  904. width: 14px;
  905. }
  906. &--ghost {
  907. color: rgba(255, 255, 255, 0.6);
  908. background: rgba(255, 255, 255, 0.05);
  909. }
  910. &--primary {
  911. color: #FFD489;
  912. background: rgba(255, 212, 137, 0.1);
  913. }
  914. }
  915. &.is-abnormal .card-btn--primary,
  916. &.is-risk .card-btn--primary {
  917. color: #ffd489;
  918. background: transparent;
  919. border: 1px solid rgba(255, 212, 137, 0.45);
  920. }
  921. }
  922. .empty-tip {
  923. padding: 40px 0;
  924. text-align: center;
  925. color: rgba(255, 255, 255, 0.35);
  926. font-size: 14px;
  927. }
  928. }
  929. </style>