patrolPanel.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. <template>
  2. <div class="patrol-panel">
  3. <!-- 品种切换 -->
  4. <div class="variety-bar">
  5. <div class="variety-bar__tags">
  6. <div
  7. v-for="item in zoneList"
  8. :key="item.zone_id"
  9. class="variety-bar__tag"
  10. :class="{ active: item.zone_id === selectedZoneId }"
  11. @click="handleSelectZone(item)"
  12. >
  13. {{ item.zone_name }}
  14. </div>
  15. </div>
  16. <div class="variety-bar__add" @click="goAddVariety">
  17. <el-icon>
  18. <Plus />
  19. </el-icon>
  20. <span>新增品种</span>
  21. </div>
  22. </div>
  23. <div
  24. v-for="item in patrolList"
  25. :key="item.id"
  26. class="tracking-item"
  27. :class="`tracking-item--${item.theme}`"
  28. @click="goGrowthTrack(item)"
  29. >
  30. <div class="tracking-item__header">
  31. <div class="tracking-item__header-row">
  32. <div class="tracking-item__header-left">
  33. <span class="tracking-item__level">
  34. <el-icon><BellFilled /></el-icon>
  35. {{ item.level }}
  36. </span>
  37. <span class="tracking-item__title van-ellipsis">
  38. {{ item.title }}
  39. <span v-if="item.zone" class="tracking-item__zone"> ({{ item.zone }})</span>
  40. </span>
  41. </div>
  42. <div class="tracking-item__share" @click.stop="handleShare(item)">
  43. <Icon size="14" color="#2199F8" name="share" />
  44. <span>{{ t("agriFile.forward") }}</span>
  45. </div>
  46. </div>
  47. </div>
  48. <div class="tracking-item__body">
  49. <div class="tracking-item__action">
  50. <div class="tracking-item__action-main">
  51. <div class="tracking-item__icon">
  52. <img :src="item.icon" alt="" />
  53. <span class="tracking-item__reason">{{ item.subject }}</span>
  54. </div>
  55. <div class="tracking-item__issue">{{ item.issue }}</div>
  56. </div>
  57. <div class="tracking-item__btn" @click.stop="handleUploadClick(item)">
  58. <el-icon>
  59. <Plus />
  60. </el-icon>
  61. <span>{{ t("agriFile.uploadPhoto") }}</span>
  62. </div>
  63. </div>
  64. </div>
  65. </div>
  66. <div class="crop-archive">
  67. <div class="crop-archive__title">{{ t("agriFile.cropArchive") }}</div>
  68. <PhenologyTrackTimelineItem :list="cropArchiveList" />
  69. </div>
  70. <album-upload-popup v-model:show="showUploadPopup" @confirm="handleUploadConfirm" />
  71. <tip-popup v-model:show="showSuccessPopup" type="executeSuccess" text="您已上传成功" text2="请等待诊断报告生成" hideBtn>
  72. <template #footer>
  73. <div class="bottom-btn-container">
  74. <div class="bottom-btn primary-btn" @click="handleComplete">完成</div>
  75. </div>
  76. </template>
  77. </tip-popup>
  78. </div>
  79. </template>
  80. <script setup>
  81. import { onActivated, onMounted, ref } from "vue";
  82. import { useRouter } from "vue-router";
  83. import { Icon } from "vant";
  84. import { ElMessage } from "element-plus";
  85. import { Plus } from "@element-plus/icons-vue";
  86. import { useI18n } from "@/i18n";
  87. import PhenologyTrackTimelineItem from "@/components/pageComponents/PhenologyTrackTimelineItem.vue";
  88. import albumUploadPopup from "./albumUploadPopup.vue";
  89. import tipPopup from "@/components/popup/tipPopup.vue";
  90. import wx from "weixin-js-sdk";
  91. const { t } = useI18n();
  92. const router = useRouter();
  93. const PATROL_ICON = require("@/assets/img/report/wh-icon.png");
  94. // ---------- 页面状态 ----------
  95. const zoneList = ref([]);
  96. const selectedZoneId = ref(null);
  97. const selectedZoneName = ref("");
  98. const patrolList = ref([
  99. {
  100. id: 1,
  101. type: "growth",
  102. theme: "blue",
  103. level: "",
  104. title: "",
  105. subject: "",
  106. issue: "",
  107. desc: "",
  108. icon: PATROL_ICON,
  109. },
  110. {
  111. id: 2,
  112. type: "abnormal",
  113. theme: "blue",
  114. level: "",
  115. title: "",
  116. subject: "",
  117. issue: "",
  118. icon: PATROL_ICON,
  119. },
  120. ]);
  121. const cropArchiveList = ref([]);
  122. const showUploadPopup = ref(false);
  123. const showSuccessPopup = ref(false);
  124. const uploadZoneId = ref("");
  125. // ---------- 工具函数 ----------
  126. /** 外部感知任务 → 巡园卡片(仅取第一条) */
  127. const mapInteractTaskToPatrol = (task) => {
  128. const content = task?.interact_content || {};
  129. return {
  130. id: task.zone_id || task.interact_code,
  131. type: "growth",
  132. theme: "blue",
  133. level: content.interact_level || "",
  134. title: t("agriFile.patrolGrowthTitle") || "",
  135. desc: content.interact_reason || "",
  136. issue: content.interact_reason || "",
  137. subject: content.interact_title || "",
  138. icon: PATROL_ICON,
  139. zoneId: task.zone_id,
  140. interactCode: task.interact_code,
  141. raw: task,
  142. };
  143. };
  144. const mapPhenoRecordToArchive = (item, index, zoneName) => ({
  145. id: [item.zone_id, item.enter_date, item.period_code, index]
  146. .filter((v) => v != null && v !== "")
  147. .join("_"),
  148. date: item.enter_date || "",
  149. zone_name: item.zone_name || zoneName || "",
  150. content: item.sentence || (item.period_name ? `到达 ${item.period_name}` : ""),
  151. period_code: item.period_code,
  152. period_name: item.period_name,
  153. shoot_type: item.shoot_type,
  154. zone_id: item.zone_id,
  155. crop_code: item.crop_code,
  156. });
  157. const getSelectedFarm = () => {
  158. try {
  159. return JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
  160. } catch {
  161. return {};
  162. }
  163. };
  164. const getSelectedFarmId = () => {
  165. const stored = localStorage.getItem("selectedFarmId");
  166. if (stored != null && stored !== "") {
  167. return Number(stored);
  168. }
  169. const farm = getSelectedFarm();
  170. const id = farm.farm_id ?? farm.id;
  171. return id != null && id !== "" ? Number(id) : null;
  172. };
  173. /** 清空第一条上的感知任务残留,回退为 mock 文案 */
  174. const clearFirstPatrolInteract = () => {
  175. if (!patrolList.value.length) return;
  176. const item = patrolList.value[0];
  177. patrolList.value[0] = {
  178. ...item,
  179. id: 1,
  180. level: t("agriFile.riskLevel2"),
  181. title: t("agriFile.patrolGrowthTitle"),
  182. zone: selectedZoneName.value || t("agriFile.zoneOne"),
  183. subject: t("agriFile.patrolSubject"),
  184. issue: t("agriFile.patrolIssue"),
  185. desc: "",
  186. zoneId: undefined,
  187. interactCode: undefined,
  188. raw: undefined,
  189. };
  190. };
  191. // ---------- 业务逻辑:加载 ----------
  192. const applySelectedZone = (zone) => {
  193. selectedZoneId.value = zone?.zone_id ?? null;
  194. selectedZoneName.value = zone?.zone_name || "";
  195. };
  196. const fetchZones = async () => {
  197. const farmId = getSelectedFarmId();
  198. if (farmId == null) {
  199. zoneList.value = [];
  200. applySelectedZone(null);
  201. return;
  202. }
  203. try {
  204. const res = await VE_API.questionnaire.getZones({ farm_id: farmId });
  205. const zones = Array.isArray(res?.data?.zones) ? res.data.zones : [];
  206. zoneList.value = zones;
  207. // 优先沿用当前选中;否则默认第一个
  208. const matched = zones.find((z) => z.zone_id === selectedZoneId.value);
  209. applySelectedZone(matched || zones[0] || null);
  210. } catch (e) {
  211. console.warn("[patrolPanel] getZones failed", e);
  212. zoneList.value = [];
  213. applySelectedZone(null);
  214. }
  215. };
  216. const fetchPatrolInteractTask = async () => {
  217. const zoneId = selectedZoneId.value;
  218. if (zoneId == null || zoneId === "") {
  219. clearFirstPatrolInteract();
  220. return;
  221. }
  222. try {
  223. const res = await VE_API.questionnaire.getExternalInteractTasks({ zone_id: zoneId });
  224. const tasks = res?.data?.interact_tasks;
  225. const first = Array.isArray(tasks) ? tasks[0] : null;
  226. if (!first) {
  227. clearFirstPatrolInteract();
  228. return;
  229. }
  230. const mapped = mapInteractTaskToPatrol(first);
  231. if (patrolList.value.length) {
  232. patrolList.value[0] = {
  233. ...patrolList.value[0],
  234. ...mapped,
  235. };
  236. } else {
  237. patrolList.value = [mapped];
  238. }
  239. } catch (e) {
  240. console.warn("[patrolPanel] getExternalInteractTasks failed", e);
  241. }
  242. };
  243. const fetchCropArchive = async () => {
  244. const farm = getSelectedFarm();
  245. const zoneId = selectedZoneId.value;
  246. if (zoneId == null || zoneId === "") {
  247. cropArchiveList.value = [];
  248. return;
  249. }
  250. const query = {
  251. farm_id: farm.farm_id ?? farm.id,
  252. zone_id: zoneId,
  253. crop_code: farm.category_code,
  254. };
  255. try {
  256. const res = await VE_API.entry.getPhenoRecord(query);
  257. if (res?.code !== 200) {
  258. cropArchiveList.value = [];
  259. return;
  260. }
  261. const list = Array.isArray(res.data) ? res.data : [];
  262. const fallbackZone = selectedZoneName.value || t("agriFile.zoneOne");
  263. cropArchiveList.value = list
  264. .map((item, index) => mapPhenoRecordToArchive(item, index, fallbackZone))
  265. .sort((a, b) => String(b.date).localeCompare(String(a.date)));
  266. } catch (e) {
  267. console.warn("[patrolPanel] getPhenoRecord failed", e);
  268. cropArchiveList.value = [];
  269. }
  270. };
  271. const fillMockLabels = () => {
  272. const albumName = selectedZoneName.value || t("agriFile.zoneOne");
  273. patrolList.value = patrolList.value.map((item, index) => {
  274. // 第一条由外部感知任务接口填充,不覆盖
  275. if (index === 0 && item.interactCode) return item;
  276. return {
  277. ...item,
  278. level: t("agriFile.riskLevel2"),
  279. title: t(index === 0 ? "agriFile.patrolGrowthTitle" : "agriFile.patrolAbnormalTitle"),
  280. zone: albumName,
  281. subject: t("agriFile.patrolSubject"),
  282. issue: t("agriFile.patrolIssue"),
  283. };
  284. });
  285. };
  286. /** 按当前选中品种刷新巡园卡片与作物档案 */
  287. const reloadBySelectedZone = () => {
  288. clearFirstPatrolInteract();
  289. fillMockLabels();
  290. fetchPatrolInteractTask();
  291. fetchCropArchive();
  292. };
  293. const loadPatrolData = async () => {
  294. // 先清掉上一农场感知任务,避免 fillMockLabels 因 interactCode 保留旧数据
  295. clearFirstPatrolInteract();
  296. await fetchZones();
  297. fillMockLabels();
  298. fetchPatrolInteractTask();
  299. fetchCropArchive();
  300. };
  301. // ---------- 事件处理 ----------
  302. const handleSelectZone = (zone) => {
  303. if (!zone || zone.zone_id === selectedZoneId.value) return;
  304. applySelectedZone(zone);
  305. reloadBySelectedZone();
  306. };
  307. const goAddVariety = () => {
  308. router.push({
  309. path: "/add_zone",
  310. query: { type: "region", isSuccessPopup: "0" },
  311. });
  312. };
  313. const goGrowthTrack = (item) => {
  314. router.push({
  315. path: "/growth_track",
  316. query: {
  317. id: item?.id,
  318. type: item?.type || "growth",
  319. pageName:
  320. item.type === "growth"
  321. ? t("agriFile.patrolGrowthTitle")
  322. : t("agriFile.patrolAbnormalTitle"),
  323. },
  324. });
  325. };
  326. const handleShare = (item) => {
  327. const query = {
  328. askInfo: { title: "转发巡园要点", content: "是否分享给好友" },
  329. shareText: item?.title || "速长窗口不追肥?后期发育慢人一步,直接掉队!",
  330. targetUrl: "growth_track",
  331. paramsPage: JSON.stringify({
  332. id: item?.id,
  333. type: item?.type || "growth",
  334. fromShare: 1,
  335. }),
  336. imageUrl: "https://birdseye-img.sysuimars.com/temp/field.png",
  337. };
  338. wx.miniProgram.navigateTo({
  339. url: `/pages/subPages/share_page/index?pageParams=${JSON.stringify(query)}&type=sharePage`,
  340. });
  341. };
  342. const handleUploadClick = (item) => {
  343. uploadZoneId.value = item?.zoneId ?? item?.id ?? selectedZoneId.value ?? "";
  344. showUploadPopup.value = true;
  345. };
  346. const handleUploadConfirm = async ({ images, date } = {}) => {
  347. const cloud_url = Array.isArray(images) ? images : [];
  348. if (!cloud_url.length) {
  349. ElMessage.warning("请先上传照片");
  350. return;
  351. }
  352. const farm = getSelectedFarm();
  353. try {
  354. const res = await VE_API.questionnaire.uploadImages({
  355. cloud_url,
  356. farm_id: farm.farm_id ?? farm.id ?? "",
  357. zone_id: uploadZoneId.value || selectedZoneId.value || "",
  358. category_code: farm.category_code || "",
  359. zone_name: selectedZoneName.value || farm.variety_name || "",
  360. create_time: date,
  361. });
  362. if (res?.code === 200) {
  363. showSuccessPopup.value = true;
  364. return;
  365. }
  366. ElMessage.error(res?.msg || "上传失败,请稍后再试");
  367. } catch {
  368. ElMessage.error("上传失败,请稍后再试");
  369. }
  370. };
  371. const handleComplete = () => {
  372. showSuccessPopup.value = false;
  373. };
  374. // ---------- 生命周期 ----------
  375. onMounted(loadPatrolData);
  376. onActivated(loadPatrolData);
  377. defineExpose({
  378. fetchPatrolInteractTask,
  379. fetchCropArchive,
  380. loadPatrolData,
  381. });
  382. </script>
  383. <style lang="scss" scoped>
  384. .patrol-panel {
  385. .variety-bar {
  386. display: flex;
  387. align-items: center;
  388. justify-content: space-between;
  389. gap: 8px;
  390. margin-bottom: 10px;
  391. &__tags {
  392. display: flex;
  393. align-items: center;
  394. flex-wrap: wrap;
  395. gap: 6px;
  396. flex: 1;
  397. min-width: 0;
  398. }
  399. &__tag {
  400. padding: 4px 16px;
  401. border-radius: 4px;
  402. background: #fff;
  403. color: #4e5969;
  404. font-size: 14px;
  405. &.active {
  406. background: #2199f8;
  407. color: #fff;
  408. }
  409. }
  410. &__add {
  411. flex: none;
  412. display: flex;
  413. align-items: center;
  414. gap: 2px;
  415. padding: 5px 10px;
  416. border-radius: 4px;
  417. border: 1px solid #2199f8;
  418. background: rgba(33, 153, 248, 0.1);
  419. color: #2199f8;
  420. font-size: 14px;
  421. }
  422. }
  423. .tracking-item {
  424. width: 100%;
  425. margin-bottom: 16px;
  426. box-shadow: 0px 4px 4px 0px rgba(33, 153, 248, 0.1);
  427. &:last-of-type {
  428. margin-bottom: 0;
  429. }
  430. &__header {
  431. position: relative;
  432. padding: 10px 10px 20px;
  433. border-radius: 10px 10px 0 0;
  434. background: linear-gradient(180deg, #86caff 0%, #2199f8 48.5%);
  435. }
  436. &__header-row {
  437. display: flex;
  438. align-items: center;
  439. justify-content: space-between;
  440. }
  441. &__header-left {
  442. display: flex;
  443. align-items: center;
  444. gap: 5px;
  445. }
  446. &__level {
  447. display: flex;
  448. align-items: center;
  449. gap: 4px;
  450. padding: 1px 4px;
  451. border-radius: 2px;
  452. background: #fff;
  453. color: #ff6a6a;
  454. font-size: 12px;
  455. }
  456. &__title {
  457. color: #fff;
  458. font-size: 16px;
  459. font-weight: 500;
  460. }
  461. &__zone {
  462. font-size: 12px;
  463. }
  464. &__share {
  465. display: flex;
  466. align-items: center;
  467. gap: 4px;
  468. padding: 2px 10px;
  469. border-radius: 25px;
  470. background: #fff;
  471. color: #2199f8;
  472. font-size: 12px;
  473. }
  474. &__body {
  475. position: relative;
  476. padding: 10px;
  477. margin-top: -8px;
  478. border-radius: 10px;
  479. background: #fff;
  480. &::before {
  481. content: "";
  482. position: absolute;
  483. top: -6px;
  484. left: 21%;
  485. width: 0;
  486. height: 0;
  487. border-left: 7px solid transparent;
  488. border-right: 7px solid transparent;
  489. border-bottom: 7px solid #fff;
  490. transform: translateX(-50%);
  491. }
  492. }
  493. &__action {
  494. display: flex;
  495. align-items: center;
  496. justify-content: space-between;
  497. border: 1px solid rgba(33, 153, 248, 0.2);
  498. border-radius: 6px;
  499. padding: 8px;
  500. }
  501. &__icon {
  502. display: flex;
  503. align-items: center;
  504. gap: 5px;
  505. img {
  506. width: 18px;
  507. height: 16px;
  508. }
  509. }
  510. &__reason {
  511. color: #2199f8;
  512. font-weight: 500;
  513. }
  514. &__issue {
  515. margin-top: 2px;
  516. color: #2199f8;
  517. font-size: 12px;
  518. }
  519. &__action-main {
  520. flex: 1;
  521. padding-right: 4px;
  522. }
  523. &__btn {
  524. flex: none;
  525. display: flex;
  526. align-items: center;
  527. gap: 2px;
  528. padding: 7px 10px;
  529. border-radius: 5px;
  530. background: #2199f8;
  531. color: #fff;
  532. font-size: 12px;
  533. }
  534. }
  535. .crop-archive {
  536. padding: 10px;
  537. border-radius: 10px;
  538. background: #fff;
  539. &__title {
  540. position: relative;
  541. display: inline-block;
  542. z-index: 1;
  543. font-size: 16px;
  544. font-weight: 500;
  545. margin-bottom: 5px;
  546. &::after {
  547. content: "";
  548. position: absolute;
  549. left: 0;
  550. bottom: 2px;
  551. z-index: -1;
  552. width: 90px;
  553. height: 6px;
  554. border-radius: 25px;
  555. background: linear-gradient(90deg, #2199f8 0%, rgba(255, 255, 255, 0.37) 100%);
  556. opacity: 0.4;
  557. }
  558. }
  559. }
  560. }
  561. .bottom-btn-container {
  562. display: flex;
  563. justify-content: space-between;
  564. gap: 12px;
  565. width: 100%;
  566. .bottom-btn {
  567. padding: 0 25px;
  568. border-radius: 4px;
  569. font-size: 16px;
  570. height: 40px;
  571. line-height: 40px;
  572. box-sizing: border-box;
  573. font-weight: 500;
  574. text-align: center;
  575. &.primary-btn {
  576. flex: 1;
  577. background: #2199f8;
  578. color: #fff;
  579. }
  580. }
  581. }
  582. </style>