FarmWorkPlanTimeline.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. <template>
  2. <div
  3. class="timeline-container"
  4. ref="timelineContainerRef"
  5. :class="{ 'timeline-container-plant': pageType === 'plant' }"
  6. >
  7. <div class="timeline-list" :style="getListStyle">
  8. <div class="timeline-middle-line"></div>
  9. <!-- 物候期覆盖条(progress 为起点,progress2 为终点,单位 %) -->
  10. <div
  11. v-for="(p, idx) in phenologyList"
  12. :key="p.id ?? idx"
  13. class="phenology-bar"
  14. :style="getPhenologyBarStyle(p)"
  15. >
  16. <div class="reproductive-list">
  17. <div
  18. v-for="(r, rIdx) in Array.isArray(p.reproductiveList) ? p.reproductiveList : []"
  19. :key="r.id ?? rIdx"
  20. class="reproductive-item"
  21. :class="{
  22. 'horizontal-text': getReproductiveItemHeight(p) < 30,
  23. 'vertical-lr-text': getReproductiveItemHeight(p) >= 30,
  24. }"
  25. :style="
  26. getReproductiveItemHeight(p) < 30
  27. ? { '--item-height': `${getReproductiveItemHeight(p)}px` }
  28. : {}
  29. "
  30. >
  31. {{ r.name }}
  32. <div class="arranges">
  33. <div
  34. v-for="(fw, aIdx) in Array.isArray(r.farmWorkArrangeList) ? r.farmWorkArrangeList : []"
  35. :key="fw.id ?? aIdx"
  36. class="arrange-card"
  37. :class="getArrangeStatusClass(fw)"
  38. @click="handleRowClick(fw)"
  39. >
  40. <div class="card-header">
  41. <div class="header-left">
  42. <span class="farm-work-name">{{ fw.farmWorkName || "农事名称" }}</span>
  43. <span class="tag-standard">标准农事</span>
  44. </div>
  45. <div class="header-right">托管农事</div>
  46. </div>
  47. <div class="card-content">
  48. <span>{{ fw.interactionQuestion || "暂无提示" }}</span>
  49. <span v-if="!disableClick" class="edit-link" @click.stop="handleEdit(fw)"
  50. >点击编辑</span
  51. >
  52. </div>
  53. <div
  54. v-if="
  55. getArrangeStatusClass(fw) === 'status-complete' ||
  56. getArrangeStatusClass(fw) === 'status-warning'
  57. "
  58. class="status-icon"
  59. :class="getArrangeStatusClass(fw)"
  60. >
  61. <el-icon
  62. v-if="getArrangeStatusClass(fw) === 'status-complete'"
  63. size="16"
  64. color="#1CA900"
  65. >
  66. <SuccessFilled />
  67. </el-icon>
  68. <el-icon v-else size="18" color="#FF953D">
  69. <WarnTriangleFilled />
  70. </el-icon>
  71. </div>
  72. </div>
  73. </div>
  74. </div>
  75. </div>
  76. </div>
  77. <div v-for="t in solarTerms" :key="t.id" class="timeline-term" :style="getTermStyle(t)">
  78. <span class="term-name">{{ t.displayName }}</span>
  79. </div>
  80. </div>
  81. </div>
  82. <!-- 互动设置弹窗 -->
  83. <interact-popup
  84. ref="interactPopupRef"
  85. @handleSaveSuccess="getFarmWorkPlan"
  86. ></interact-popup>
  87. </template>
  88. <script setup>
  89. import { ref, computed, nextTick, watch } from "vue";
  90. import interactPopup from "@/components/popup/interactPopup.vue";
  91. import { ElMessage } from "element-plus";
  92. const props = defineProps({
  93. // 农场 ID,用于请求农事规划数据
  94. farmId: {
  95. type: [String, Number],
  96. default: null,
  97. },
  98. // 页面类型:种植方案 / 农事规划,用来控制高度样式
  99. pageType: {
  100. type: String,
  101. default: "",
  102. },
  103. // 是否禁用所有点击事件(用于只读展示)
  104. disableClick: {
  105. type: Boolean,
  106. default: false,
  107. },
  108. containerId: {
  109. type: [Number, String],
  110. default: null,
  111. },
  112. });
  113. const emits = defineEmits(["row-click"]);
  114. const solarTerms = ref([]);
  115. const phenologyList = ref([]);
  116. const timelineContainerRef = ref(null);
  117. // 标记是否为首次加载
  118. const isInitialLoad = ref(true);
  119. // 获取当前季节
  120. const getCurrentSeason = () => {
  121. const month = new Date().getMonth() + 1; // 1-12
  122. if (month >= 3 && month <= 5) {
  123. return "spring"; // 春季:3-5月
  124. } else if (month >= 6 && month <= 8) {
  125. return "summer"; // 夏季:6-8月
  126. } else if (month >= 9 && month <= 10) {
  127. return "autumn"; // 秋季:9-10月
  128. } else {
  129. return "winter"; // 冬季:11-2月
  130. }
  131. };
  132. // 安全解析时间到时间戳(ms)
  133. const safeParseDate = (val) => {
  134. if (!val) return NaN;
  135. if (val instanceof Date) return val.getTime();
  136. if (typeof val === "number") return val;
  137. if (typeof val === "string") {
  138. // 兼容 "YYYY-MM-DD HH:mm:ss" -> Safari
  139. const s = val.replace(/-/g, "/").replace("T", " ");
  140. const d = new Date(s);
  141. return isNaN(d.getTime()) ? NaN : d.getTime();
  142. }
  143. return NaN;
  144. };
  145. // 计算最小progress值(第一个节气的progress)
  146. const minProgress = computed(() => {
  147. if (!solarTerms.value || solarTerms.value.length === 0) return 0;
  148. const progresses = solarTerms.value.map((t) => Number(t?.progress) || 0).filter((p) => !isNaN(p));
  149. return progresses.length > 0 ? Math.min(...progresses) : 0;
  150. });
  151. // 计算最大progress值
  152. const maxProgress = computed(() => {
  153. if (!solarTerms.value || solarTerms.value.length === 0) return 100;
  154. const progresses = solarTerms.value.map((t) => Number(t?.progress) || 0).filter((p) => !isNaN(p));
  155. return progresses.length > 0 ? Math.max(...progresses) : 100;
  156. });
  157. // 列表高度
  158. const getListStyle = computed(() => {
  159. const minP = minProgress.value;
  160. const maxP = maxProgress.value;
  161. const range = Math.max(1, maxP - minP); // 避免除0
  162. const total = (solarTerms.value?.length || 0) * 1200;
  163. const minH = range === 0 ? 0 : total;
  164. return { minHeight: `${minH}px` };
  165. });
  166. const getTermStyle = (t) => {
  167. const p = Math.max(0, Math.min(100, Number(t?.progress) || 0));
  168. const minP = minProgress.value;
  169. const maxP = maxProgress.value;
  170. const range = Math.max(1, maxP - minP); // 避免除0
  171. const total = (solarTerms.value?.length || 0) * 1200;
  172. // 将progress映射到0开始的位置,最小progress对应top: 0
  173. const normalizedP = range > 0 ? ((p - minP) / range) * 100 : 0;
  174. const top = (normalizedP / 100) * total;
  175. return {
  176. position: "absolute",
  177. top: `${top}px`,
  178. left: 0,
  179. width: "30px",
  180. height: "20px",
  181. display: "flex",
  182. alignItems: "flex-start",
  183. };
  184. };
  185. // 点击季节 → 滚动到对应节气(立春/立夏/立秋/立冬)
  186. const handleSeasonClick = (seasonValue) => {
  187. const mapping = {
  188. spring: "立春",
  189. summer: "立夏",
  190. autumn: "立秋",
  191. winter: "立冬",
  192. };
  193. const targetName = mapping[seasonValue];
  194. if (!targetName) return;
  195. const target = (solarTerms.value || []).find((t) => (t?.displayName || "") === targetName);
  196. if (!target) return;
  197. const p = Math.max(0, Math.min(100, Number(target.progress) || 0));
  198. const minP = minProgress.value;
  199. const maxP = maxProgress.value;
  200. const range = Math.max(1, maxP - minP);
  201. const total = (solarTerms.value?.length || 0) * 1200;
  202. const normalizedP = range > 0 ? ((p - minP) / range) * 100 : 0;
  203. const targetTop = (normalizedP / 100) * total; // 内容内的像素位置
  204. const wrap = timelineContainerRef.value;
  205. if (!wrap) return;
  206. const viewH = wrap.clientHeight || 0;
  207. const maxScroll = Math.max(0, wrap.scrollHeight - viewH);
  208. // 将目标位置稍微靠上(使用 0.35 视口高度做偏移)
  209. let scrollTop = Math.max(0, targetTop - viewH * 0.1);
  210. if (scrollTop > maxScroll) scrollTop = maxScroll;
  211. wrap.scrollTo({ top: scrollTop, behavior: "smooth" });
  212. };
  213. // 物候期覆盖条样式
  214. const getPhenologyBarStyle = (item) => {
  215. const p1 = Math.max(0, Math.min(100, Number(item?.progress) || 0));
  216. const p2 = Math.max(0, Math.min(100, Number(item?.progress2) || 0));
  217. const start = Math.min(p1, p2);
  218. const end = Math.max(p1, p2);
  219. const minP = minProgress.value;
  220. const maxP = maxProgress.value;
  221. const range = Math.max(1, maxP - minP);
  222. const total = (solarTerms.value?.length || 0) * 1200; // 有效绘制区高度(px)
  223. // 将progress映射到0开始的位置
  224. const normalizedStart = range > 0 ? ((start - minP) / range) * 100 : 0;
  225. const normalizedEnd = range > 0 ? ((end - minP) / range) * 100 : 0;
  226. let topPx = (normalizedStart / 100) * total;
  227. let heightPx = Math.max(2, ((normalizedEnd - normalizedStart) / 100) * total);
  228. // 顶部对齐
  229. const firstTermTop = 0;
  230. const minTop = firstTermTop + 10;
  231. if (topPx < minTop) {
  232. const diff = minTop - topPx;
  233. topPx = minTop;
  234. heightPx = Math.max(2, heightPx - diff);
  235. }
  236. // 底部对齐
  237. const lastTermTop = (100 / 100) * total;
  238. const maxBottom = lastTermTop + 35;
  239. const barBottom = topPx + heightPx;
  240. if (barBottom > maxBottom) {
  241. heightPx = Math.max(2, maxBottom - topPx);
  242. }
  243. const now = Date.now();
  244. const isFuture = Number.isFinite(item?.startTimeMs) ? item.startTimeMs > now : start > 0;
  245. const barColor = isFuture ? "rgba(145, 145, 145, 0.1)" : "#2199F8";
  246. const beforeBg = isFuture ? "rgba(145, 145, 145, 0.1)" : "rgba(33, 153, 248, 0.1)";
  247. return {
  248. position: "absolute",
  249. left: "46px",
  250. width: "25px",
  251. top: `${topPx}px`,
  252. height: `${heightPx}px`,
  253. background: barColor,
  254. color: isFuture ? "#747778" : "#fff",
  255. "--bar-before-bg": beforeBg,
  256. zIndex: 2,
  257. };
  258. };
  259. // 农事状态样式映射(0:默认,1-4:正常,5:完成,6:预警)
  260. const getArrangeStatusClass = (fw) => {
  261. const t = fw?.flowStatus;
  262. if (t == null) return "status-default";
  263. if (t >= 0 && t <= 4) return "status-normal";
  264. if (t === 5) return "status-complete";
  265. if (t === 6) return "status-warning";
  266. return "status-default";
  267. };
  268. // 计算 phenology-bar 的高度(px)
  269. const getPhenologyBarHeight = (item) => {
  270. const p1 = Math.max(0, Math.min(100, Number(item?.progress) || 0));
  271. const p2 = Math.max(0, Math.min(100, Number(item?.progress2) || 0));
  272. const start = Math.min(p1, p2);
  273. const end = Math.max(p1, p2);
  274. const minP = minProgress.value;
  275. const maxP = maxProgress.value;
  276. const range = Math.max(1, maxP - minP);
  277. const total = (solarTerms.value?.length || 0) * 1200;
  278. const normalizedStart = range > 0 ? ((start - minP) / range) * 100 : 0;
  279. const normalizedEnd = range > 0 ? ((end - minP) / range) * 100 : 0;
  280. const heightPx = Math.max(2, ((normalizedEnd - normalizedStart) / 100) * total);
  281. return heightPx;
  282. };
  283. // 计算 reproductive-item 的高度(px)
  284. const getReproductiveItemHeight = (phenologyItem) => {
  285. const barHeight = getPhenologyBarHeight(phenologyItem);
  286. const listLength = Array.isArray(phenologyItem?.reproductiveList) ? phenologyItem.reproductiveList.length : 1;
  287. return listLength > 0 ? barHeight / listLength : barHeight;
  288. };
  289. const handleRowClick = (item) => {
  290. emits("row-click", item);
  291. };
  292. const interactPopupRef = ref(null);
  293. const handleEdit = (item) => {
  294. if (props.disableClick) return;
  295. if (interactPopupRef.value) {
  296. interactPopupRef.value.showPopup(item);
  297. }
  298. };
  299. const containerIdData = ref(null);
  300. // 获取农事规划数据
  301. const getFarmWorkPlan = () => {
  302. if (!props.farmId && !props.containerId) return;
  303. let savedScrollTop = 0;
  304. if (!isInitialLoad.value && timelineContainerRef.value) {
  305. savedScrollTop = timelineContainerRef.value.scrollTop || 0;
  306. }
  307. VE_API.monitor
  308. .farmWorkPlan({ farmId: props.farmId, containerId: props.containerId })
  309. .then(({ data, code }) => {
  310. if (code === 0) {
  311. containerIdData.value = data.phenologyList[0].containerSpaceTimeId;
  312. const list = Array.isArray(data?.solarTermsList) ? data.solarTermsList : [];
  313. const filtered = list
  314. .filter((t) => t && t.type === 1)
  315. .map((t) => ({
  316. id:
  317. t.id ??
  318. t.solarTermsId ??
  319. t.termId ??
  320. `${t.name || t.solarTermsName || t.termName || "term"}-${t.createDate || ""}`,
  321. displayName: t.name || t.solarTermsName || t.termName || "节气",
  322. createDate: t.createDate || null,
  323. progress: Number(t.progress) || 0,
  324. }));
  325. solarTerms.value = filtered;
  326. // 物候期数据
  327. phenologyList.value = Array.isArray(data?.phenologyList)
  328. ? data.phenologyList.map((it) => {
  329. const reproductiveList = Array.isArray(it.reproductiveList)
  330. ? it.reproductiveList.map((r) => {
  331. const farmWorkArrangeList = Array.isArray(r.farmWorkArrangeList)
  332. ? r.farmWorkArrangeList.map((fw) => ({
  333. ...fw,
  334. containerSpaceTimeId: it.containerSpaceTimeId,
  335. }))
  336. : [];
  337. return {
  338. ...r,
  339. farmWorkArrangeList,
  340. };
  341. })
  342. : [];
  343. return {
  344. id: it.id ?? it.phenologyId ?? it.name ?? `${it.progress}-${it.progress2}`,
  345. progress: Number(it.progress) || 0, // 起点 %
  346. progress2: Number(it.progress2) || 0, // 终点 %
  347. startTimeMs: safeParseDate(
  348. it.startDate || it.beginDate || it.startTime || it.start || it.start_at
  349. ),
  350. reproductiveList,
  351. };
  352. })
  353. : [];
  354. nextTick(() => {
  355. if (isInitialLoad.value) {
  356. const currentSeason = getCurrentSeason();
  357. handleSeasonClick(currentSeason);
  358. isInitialLoad.value = false;
  359. } else if (timelineContainerRef.value && savedScrollTop > 0) {
  360. timelineContainerRef.value.scrollTop = savedScrollTop;
  361. }
  362. });
  363. }
  364. })
  365. .catch((error) => {
  366. console.error("获取农事规划数据失败:", error);
  367. ElMessage.error("获取农事规划数据失败");
  368. });
  369. };
  370. watch(
  371. () => props.farmId || props.containerId,
  372. (val) => {
  373. if (val) {
  374. isInitialLoad.value = true;
  375. getFarmWorkPlan();
  376. }
  377. },
  378. { immediate: true }
  379. );
  380. </script>
  381. <style scoped lang="scss">
  382. .timeline-container {
  383. height: 100%;
  384. overflow: auto;
  385. position: relative;
  386. box-sizing: border-box;
  387. .timeline-list {
  388. position: relative;
  389. }
  390. .timeline-middle-line {
  391. position: absolute;
  392. left: 15px; /* 位于节气文字列中间(列宽约30px) */
  393. top: 0;
  394. bottom: 0;
  395. width: 2px;
  396. background: #e8e8e8;
  397. z-index: 1;
  398. }
  399. .phenology-bar {
  400. display: flex;
  401. align-items: stretch;
  402. justify-content: center;
  403. box-sizing: border-box;
  404. .reproductive-list {
  405. display: grid;
  406. grid-auto-rows: 1fr; /* 子项等高,整体等分父高度 */
  407. align-items: stretch;
  408. justify-items: center; /* 子项居中 */
  409. width: 100%;
  410. height: 100%;
  411. box-sizing: border-box;
  412. }
  413. .reproductive-item {
  414. font-size: 12px;
  415. text-align: center;
  416. word-break: break-all;
  417. writing-mode: vertical-rl;
  418. text-orientation: upright;
  419. letter-spacing: 3px;
  420. width: 100%;
  421. line-height: 23px;
  422. color: inherit;
  423. position: relative;
  424. &.horizontal-text {
  425. writing-mode: horizontal-tb;
  426. text-orientation: mixed;
  427. letter-spacing: normal;
  428. line-height: calc(var(--item-height, 15px) - 3px);
  429. }
  430. &.vertical-lr-text {
  431. writing-mode: vertical-lr;
  432. text-orientation: upright;
  433. letter-spacing: 3px;
  434. line-height: 26px;
  435. }
  436. .arranges {
  437. position: absolute;
  438. left: 40px; /* 列与中线右侧一段距离 */
  439. top: 0;
  440. z-index: 3;
  441. display: flex;
  442. max-width: calc(100vw - 100px);
  443. gap: 12px;
  444. letter-spacing: 0px;
  445. .arrange-card {
  446. width: 97%;
  447. border: 0.5px solid #2199f8;
  448. border-radius: 8px;
  449. background: #fff;
  450. box-sizing: border-box;
  451. position: relative;
  452. padding: 8px;
  453. writing-mode: horizontal-tb;
  454. .card-header {
  455. display: flex;
  456. justify-content: space-between;
  457. align-items: center;
  458. .header-left {
  459. display: flex;
  460. align-items: center;
  461. gap: 8px;
  462. .farm-work-name {
  463. font-size: 14px;
  464. font-weight: 500;
  465. color: #1d2129;
  466. }
  467. .tag-standard {
  468. padding: 0 8px;
  469. background: rgba(119, 119, 119, 0.1);
  470. border-radius: 25px;
  471. font-weight: 400;
  472. font-size: 12px;
  473. color: #000;
  474. }
  475. }
  476. .header-right {
  477. font-size: 12px;
  478. color: #808080;
  479. padding: 0 8px;
  480. border-radius: 25px;
  481. }
  482. }
  483. .card-content {
  484. color: #909090;
  485. text-align: left;
  486. line-height: 1.55;
  487. margin: 4px 0 2px 0;
  488. .edit-link {
  489. color: #2199f8;
  490. margin-left: 5px;
  491. }
  492. }
  493. .status-icon {
  494. position: absolute;
  495. right: -8px;
  496. bottom: -8px;
  497. z-index: 3;
  498. }
  499. &::before {
  500. content: "";
  501. position: absolute;
  502. left: -6px;
  503. top: 50%;
  504. transform: translateY(-50%);
  505. width: 0;
  506. height: 0;
  507. border-top: 5px solid transparent;
  508. border-bottom: 5px solid transparent;
  509. border-right: 5px solid #2199f8;
  510. }
  511. }
  512. .arrange-card.status-warning {
  513. border-color: #ff953d;
  514. &::before {
  515. border-right-color: #ff953d;
  516. }
  517. }
  518. .arrange-card.status-complete {
  519. border-color: #1ca900;
  520. &::before {
  521. border-right-color: #1ca900;
  522. }
  523. }
  524. .arrange-card.status-normal {
  525. border-color: #2199f8;
  526. &::before {
  527. border-right-color: #2199f8;
  528. }
  529. }
  530. }
  531. }
  532. }
  533. .reproductive-item + .reproductive-item {
  534. border-top: 2px solid #fff;
  535. }
  536. .phenology-bar + .phenology-bar {
  537. border-top: 2px solid #fff;
  538. }
  539. .timeline-term {
  540. position: absolute;
  541. width: 30px;
  542. padding-right: 16px;
  543. display: flex;
  544. align-items: flex-start;
  545. z-index: 2; /* 置于中线之上 */
  546. .term-name {
  547. display: inline-block;
  548. width: 100%;
  549. height: 46px;
  550. line-height: 30px;
  551. background: #f5f7fb;
  552. font-size: 13px;
  553. word-break: break-all;
  554. writing-mode: vertical-rl;
  555. text-orientation: upright;
  556. color: rgba(174, 174, 174, 0.6);
  557. text-align: center;
  558. }
  559. }
  560. }
  561. </style>