| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729 |
- <template>
- <div class="timeline-container" ref="timelineContainerRef">
- <div class="timeline-list">
- <empty v-if="isEmpty" image="https://birdseye-img.sysuimars.com/birdseye-look-mini/custom-empty-image.png"
- image-size="80" description="暂无数据" class="empty-state" />
- <template v-else>
- <div v-for="(p, idx) in phenologyList" :key="`phenology-${uniqueTimestamp}-${idx}`"
- class="phenology-bar">
- <div class="phenology-title" :class="{
- 'phenology-red': isPhenologyBarGrayByWorkStatus(p),
- 'phenology-blue': !isPhenologyBarGrayByWorkStatus(p),
- }" v-if="shouldShowPhenologyBarTitle(idx)">
- {{ p.phenologyName }}
- </div>
- <div v-for="(r, rIdx) in Array.isArray(p.reproductiveList) ? p.reproductiveList : []"
- :key="`reproductive-${uniqueTimestamp}-${idx}-${rIdx}`" class="reproductive-item">
- <div class="arranges">
- <div v-for="(fw, aIdx) in Array.isArray(r.farmWorkArrangeList) ? r.farmWorkArrangeList : []"
- :key="`arrange-${uniqueTimestamp}-${idx}-${rIdx}-${aIdx}`" class="arrange-card" :class="[
- getArrangeStatusClass(fw),
- {
- 'last-card':
- aIdx === r.farmWorkArrangeList.length - 1 &&
- rIdx !== r.farmWorkArrangeList.length - 1,
- },
- ]" @click="handleRowClick(fw)">
- <div class="card-content">
- <div class="card-left" @click.stop="handleStatusDetail(fw)" style="width: 100%">
- <div class="left-info">
- <div class="left-date">{{ formatDate(fw.createTime) }}</div>
- <div class="text">
- <span class="van-ellipsis">{{ fw.title }}</span>
- <el-icon>
- <ArrowRight />
- </el-icon>
- </div>
- </div>
- <div class="farm-info" v-if="fw.work_status === 0 || fw.work_status === 1">
- <div class="info-left">
- <div>{{ fw.work_reason }}</div>
- </div>
- <div class="info-right">{{ fw.interaction_issue }}</div>
- </div>
- <div class="title-text van-ellipsis">{{ fw.work_time }} ({{ fw.crop_type_name
- }})</div>
- </div>
- <div class="status-right" :style="statusColorObj[fw.work_status]"
- v-if="fw.work_status !== 3 && fw.work_status !== 5">{{
- workStatusObj[fw.work_status] }}</div>
- </div>
- </div>
- </div>
- <template v-if="!shouldShowPhenologyBarTitle(idx)">
- <div class="phenology-name" :class="{
- 'phenology-red': isPhenologyRowGrayByWorkStatus(r),
- 'text-blue': !isPhenologyRowGrayByWorkStatus(r),
- }">
- {{ p.phenologyName }}
- </div>
- </template>
- </div>
- </div>
- </template>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, nextTick, watch, onMounted, onUnmounted, onActivated, onDeactivated } from "vue";
- import { useRouter, useRoute, onBeforeRouteLeave } from "vue-router";
- import { ElMessage } from "element-plus";
- import { Empty } from "vant";
- const router = useRouter();
- const route = useRoute();
- const props = defineProps({
- // 农场 ID,用于请求农事规划数据
- farmId: {
- type: [String, Number],
- default: null,
- },
- });
- const emits = defineEmits(["row-click", "card-click"]);
- const phenologyList = ref([]);
- const timelineContainerRef = ref(null);
- const getTimelineScrollKey = () =>
- `timelineScrollTop:${props.farmId ?? "none"}:${route.path}`;
- const saveTimelineScrollTop = () => {
- if (!timelineContainerRef.value) return;
- const scrollTop = timelineContainerRef.value.scrollTop || 0;
- sessionStorage.setItem(getTimelineScrollKey(), scrollTop.toString());
- };
- const restoreTimelineScrollTop = () => {
- if (!timelineContainerRef.value) return false;
- const savedScrollTop = sessionStorage.getItem(getTimelineScrollKey());
- if (savedScrollTop == null) return false;
- const scrollTop = Number(savedScrollTop);
- if (Number.isNaN(scrollTop)) return false;
- const maxScrollTop = Math.max(
- 0,
- (timelineContainerRef.value.scrollHeight || 0) - (timelineContainerRef.value.clientHeight || 0)
- );
- timelineContainerRef.value.scrollTop = Math.min(scrollTop, maxScrollTop);
- return true;
- };
- const restoreTimelineScrollTopWithRetry = (retryCount = 4) => {
- const restored = restoreTimelineScrollTop();
- if (restored || retryCount <= 0) return restored;
- setTimeout(() => {
- restoreTimelineScrollTopWithRetry(retryCount - 1);
- }, 60);
- return false;
- };
- // 生成唯一的时间戳,用于确保 key 的唯一性
- const uniqueTimestamp = ref(Date.now());
- // 标记是否为空数据
- const isEmpty = ref(false);
- // 标记是否正在请求数据,防止重复请求
- const isRequesting = ref(false);
- // 记录上一次请求作用域,避免相同参数重复请求
- const lastRequestedFarmId = ref(null);
- const farmWorkPlanScopeKey = () => JSON.stringify([props.farmId ?? null]);
- const resetTimelineData = () => {
- phenologyList.value = [];
- };
- const setEmptyTimelineData = () => {
- resetTimelineData();
- isEmpty.value = true;
- };
- const safeParseDate = (val) => {
- if (!val) return NaN;
- if (val instanceof Date) return val.getTime();
- if (typeof val === "number") return val;
- if (typeof val === "string") {
- // 兼容 "YYYY-MM-DD HH:mm:ss" -> Safari
- const s = val.replace(/-/g, "/").replace("T", " ");
- const d = new Date(s);
- return isNaN(d.getTime()) ? NaN : d.getTime();
- }
- return NaN;
- };
- // 顶部物候标题:相邻两段 phenologyName 相同时只显示一次(保留第一段)
- const shouldShowPhenologyBarTitle = (idx) => {
- const list = phenologyList.value;
- if (!list?.length || idx < 0 || idx >= list.length) return false;
- if (idx === 0) return true;
- return list[idx]?.phenologyName !== list[idx - 1]?.phenologyName;
- };
- const archiveTypeObj = {
- 1: "管理信息",
- 2: "物候进程",
- 3: "物候进程",
- 4: "异常发现",
- 5: "气象风险",
- 6: "农事进度",
- };
- const workStatusObj = {
- 0: "待校准",
- 1: "机动执行",
- 2: "待执行",
- 3: "未激活",
- 4: "已认证",
- 5: "已失效",
- };
- const statusColorObj = {
- 2: {
- color: "#fff",
- background: "#2199F8",
- },
- 4: {
- color: "#4ABF32",
- background: "rgba(74, 191, 50, 0.2)",
- },
- };
- // 农事卡片状态样式(work_status)
- const getArrangeStatusClass = (fw) => {
- const t = fw?.work_status;
- if (t == 0 || t == 1) return "status-orange";
- if (t == 2) return "status-normal";
- if (t == 4) return "status-green-info";
- if (t == 6) return "status-normal-farm";
- return "future-card";
- };
- const handleRowClick = (item) => {
- // 跳转前记录当前滚动位置
- saveTimelineScrollTop();
- emits("row-click", item);
- };
- /**
- * 新接口 farm_works:`data` 为物候阶段数组,每项含 `phenology_name`、`works`(农事明细)。
- * 转为本组件使用的 phenologyName + reproductiveList[].farmWorkArrangeList 结构。
- */
- const pickPhenologyStageName = (stage) => {
- const candidates = [stage.phenology_name, stage.phenelogy_name, stage.phenologyName];
- for (const c of candidates) {
- if (c != null && String(c).trim() !== "") return String(c).trim();
- }
- return "";
- };
- const workTimeToCreateTimeIso = (workTime) => {
- if (workTime == null || workTime === "") return null;
- const s = String(workTime).trim();
- if (!s) return null;
- const y = new Date().getFullYear();
- const parts = s.split(/[-/]/).map((p) => p.trim());
- if (parts.length >= 3 && parts[0].length === 4) {
- const yy = parts[0];
- const mo = parts[1].padStart(2, "0");
- const da = parts[2].padStart(2, "0");
- return `${yy}-${mo}-${da}`;
- }
- if (parts.length >= 2) {
- const mo = parts[0].padStart(2, "0");
- const da = parts[1].padStart(2, "0");
- return `${y}-${mo}-${da}`;
- }
- return null;
- };
- const mapWorkStatusToArchiveType = (workStatus) => {
- const n = Number(workStatus);
- // 新接口 work_status 与档案 archiveType 含义不一定一致;仅对「待执行」沿用原 UI
- if (n === 2) return 2;
- return 6;
- };
- const normalizeFarmWorksPhenologyList = (data) => {
- const list = Array.isArray(data) ? data : [];
- return list.map((stage, idx) => {
- const name = pickPhenologyStageName(stage);
- const works = Array.isArray(stage.works) ? stage.works : [];
- const farmWorkArrangeList = works.map((w) => {
- const title = String(w.work_name ?? "").trim();
- const iso = workTimeToCreateTimeIso(w.work_time);
- return {
- ...w,
- createTime: iso,
- title: title || "农事",
- archiveType: mapWorkStatusToArchiveType(w.work_status),
- };
- });
- const timesMs = farmWorkArrangeList
- .map((fw) => safeParseDate(fw.createTime))
- .filter((ms) => !Number.isNaN(ms) && ms > 0);
- const minMs = timesMs.length ? Math.min(...timesMs) : NaN;
- const startDate =
- !Number.isNaN(minMs) && minMs > 0
- ? (() => {
- const d = new Date(minMs);
- const m = `${d.getMonth() + 1}`.padStart(2, "0");
- const day = `${d.getDate()}`.padStart(2, "0");
- return `${d.getFullYear()}-${m}-${day}`;
- })()
- : null;
- return {
- id: stage.id ?? works[0]?.phenology_code ?? `phenology-${idx}`,
- phenologyName: name || `物候期${idx + 1}`,
- startDate,
- startTimeMs: !Number.isNaN(minMs) && minMs > 0 ? minMs : undefined,
- reproductiveList: [
- {
- phenologyName: name || `物候期${idx + 1}`,
- farmWorkArrangeList,
- },
- ],
- };
- });
- };
- /** 侧栏物候条颜色:该组内所有农事 work_status 均为 3 时为灰,否则为蓝 */
- const isPhenologyRowGrayByWorkStatus = (reproductive) => {
- const fws = Array.isArray(reproductive?.farmWorkArrangeList) ? reproductive.farmWorkArrangeList : [];
- if (fws.length === 0) return false;
- return fws.every((fw) => Number(fw?.work_status) === 3);
- };
- /** 顶部物候标题条:同一物候下全部农事 work_status 均为 3 时为灰,否则为蓝 */
- const isPhenologyBarGrayByWorkStatus = (phenology) => {
- const reps = Array.isArray(phenology?.reproductiveList) ? phenology.reproductiveList : [];
- const all = reps.flatMap((r) => (Array.isArray(r?.farmWorkArrangeList) ? r.farmWorkArrangeList : []));
- if (all.length === 0) return false;
- return all.every((fw) => Number(fw?.work_status) === 3);
- };
- // 获取农事规划数据
- const getFarmWorkPlan = () => {
- resetTimelineData();
- if (!props.farmId) return;
- const scopeKey = farmWorkPlanScopeKey();
- if (isRequesting.value || lastRequestedFarmId.value === scopeKey) return;
- isRequesting.value = true;
- lastRequestedFarmId.value = scopeKey;
- uniqueTimestamp.value = Date.now();
- isEmpty.value = false;
- const params = {
- farm_id: props.farmId,
- crop_variety: JSON.parse(localStorage.getItem("selectedFarmData")).farm_variety,
- };
- VE_API.monitor.getPhenologyList(params)
- .then(({ data, code }) => {
- const ok = code === 200 || code === 0;
- if (ok) {
- phenologyList.value = normalizeFarmWorksPhenologyList(data);
- isEmpty.value = phenologyList.value.length === 0;
- } else {
- setEmptyTimelineData();
- }
- })
- .catch((error) => {
- console.error("获取农事规划数据失败:", error);
- ElMessage.error("获取农事规划数据失败");
- setEmptyTimelineData();
- })
- .finally(() => {
- isRequesting.value = false;
- });
- };
- const updateFarmWorkPlan = () => {
- resetTimelineData();
- isEmpty.value = false;
- getFarmWorkPlan();
- };
- watch(
- () => props.farmId,
- (val, oldVal) => {
- if (!props.farmId) return;
- const changed = oldVal == null || val !== oldVal;
- if (changed) {
- lastRequestedFarmId.value = null;
- }
- updateFarmWorkPlan();
- },
- { immediate: true }
- );
- const handleStatusDetail = (fw) => {
- saveTimelineScrollTop();
- emits('card-click');
- if (fw?.work_status === 4) {
- router.push({
- path: "/work_detail",
- query: {
- title: fw?.title,
- },
- });
- }
- // if (fw?.archiveType === 2) {
- // return;
- // }
- // if (fw?.archiveType === 6) {
- // router.push({
- // path: "/work_detail",
- // query: {
- // miniJson: JSON.stringify({
- // paramsPage: JSON.stringify({
- // farmId: 98570,
- // farmWorkLibId: '832268348690534411',
- // recordId: "832268363366404096",
- // reproductiveId: 149,
- // }),
- // }),
- // },
- // });
- // } else {
- // router.push({
- // path: "/agricultural_detail",
- // query: {
- // id: fw?.id,
- // title: archiveTypeObj[fw?.archiveType],
- // content: fw?.title,
- // },
- // });
- // }
- };
- // 格式化日期为 MM-DD 格式
- const formatDate = (dateStr) => {
- if (!dateStr) return "--";
- const date = new Date(dateStr);
- if (Number.isNaN(date.getTime())) return dateStr;
- const m = `${date.getMonth() + 1}`.padStart(2, "0");
- const d = `${date.getDate()}`.padStart(2, "0");
- return `${m}-${d}`;
- };
- defineExpose({
- updateFarmWorkPlan,
- });
- onMounted(() => {
- nextTick(() => {
- requestAnimationFrame(() => {
- restoreTimelineScrollTopWithRetry();
- });
- });
- });
- onUnmounted(() => {
- saveTimelineScrollTop();
- });
- onActivated(() => {
- nextTick(() => {
- requestAnimationFrame(() => {
- restoreTimelineScrollTopWithRetry();
- });
- });
- });
- onDeactivated(() => {
- saveTimelineScrollTop();
- });
- onBeforeRouteLeave(() => {
- saveTimelineScrollTop();
- });
- </script>
- <style scoped lang="scss">
- @mixin arrange-card-status($color, $content-color: null, $border-color: null, $content-bg: null, $text-color: null) {
- border-color: $color;
- @if $content-bg !=null {
- background: $content-bg;
- }
- .card-content {
- @if $text-color !=null {
- color: $text-color;
- }
- }
- .card-left {
- .left-info {
- .left-date {
- color: $text-color;
- border-color: $text-color;
- }
- .text {
- color: $text-color;
- }
- }
- .title-text {
- @if $color !=null {
- color: $color;
- }
- @if $content-color !=null {
- background: $content-color;
- }
- @if $border-color !=null {
- border-color: $border-color;
- }
- }
- }
- &::before {
- border-right-color: $color;
- }
- }
- .timeline-container {
- height: 100%;
- overflow: auto;
- position: relative;
- box-sizing: border-box;
- .timeline-list {
- position: relative;
- }
- .phenology-bar {
- align-items: stretch;
- justify-content: center;
- box-sizing: border-box;
- position: relative;
- .phenology-title {
- width: 18px;
- top: 0;
- bottom: 0;
- left: 0;
- height: auto;
- color: #fff;
- font-size: 12px;
- position: absolute;
- z-index: 10;
- text-align: center;
- display: flex;
- align-items: baseline;
- justify-content: center;
- writing-mode: vertical-rl;
- text-orientation: upright;
- letter-spacing: 3px;
- word-break: break-all;
- &.phenology-blue {
- background: #2199f8;
- }
- &.phenology-red {
- background: #f1f1f1;
- color: #808080;
- }
- }
- .reproductive-item {
- font-size: 12px;
- text-align: center;
- word-break: break-all;
- writing-mode: vertical-rl;
- text-orientation: upright;
- letter-spacing: 3px;
- width: 100%;
- line-height: 23px;
- color: inherit;
- position: relative;
- .phenology-name {
- width: 18px;
- line-height: 16px;
- height: 100%;
- color: #fff;
- padding: 4px 0;
- font-size: 12px;
- box-sizing: border-box;
- writing-mode: vertical-rl;
- text-orientation: upright;
- top: 0;
- bottom: 0;
- left: 0;
- position: absolute;
- &.phenology-red {
- background: #f1f1f1;
- color: #808080;
- }
- &.text-blue {
- background: rgba(33, 153, 248, 0.15);
- color: #2199f8;
- border: 1px solid #2199f8;
- line-height: 16px;
- box-sizing: border-box;
- }
- }
- .arranges {
- display: flex;
- max-width: calc(100vw - 63px);
- min-width: calc(100vw - 58px);
- gap: 5px;
- letter-spacing: 0px;
- .arrange-card {
- width: 95%;
- border: 0.5px solid #2199f8;
- border-radius: 8px;
- background: #fff;
- box-sizing: border-box;
- position: relative;
- padding: 8px 15px 8px 10px;
- writing-mode: horizontal-tb;
- margin-bottom: 10px;
- .card-content {
- color: #242424;
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: 14px;
- .card-left {
- width: 100%;
- .left-info {
- display: flex;
- align-items: center;
- gap: 6px;
- .left-date {
- color: rgba(0, 0, 0, 0.4);
- border: 0.5px solid rgba(0, 0, 0, 0.4);
- border-radius: 2px;
- font-size: 12px;
- width: 36px;
- height: 20px;
- line-height: 20px;
- }
- .text {
- display: flex;
- align-items: center;
- gap: 2px;
- color: rgba(0, 0, 0, 0.4);
- width: calc(100% - 90px);
- }
- }
- .title-text {
- margin-top: 5px;
- width: fit-content;
- max-width: 100%;
- text-align: left;
- color: rgba(0, 0, 0, 0.4);
- padding: 0 6px;
- border-radius: 2px;
- font-size: 12px;
- box-sizing: border-box;
- border: 0.5px solid rgba(0, 0, 0, 0.4);
- }
- .farm-info {
- font-size: 12px;
- color: #626262;
- background: #F7F7F7;
- padding: 5px;
- border-radius: 5px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- text-align: left;
- margin-top: 6px;
- .info-right {
- background: #FF953D;
- border-radius: 2px;
- padding: 1px 5px;
- color: #fff;
- }
- }
- }
- .status-right {
- position: absolute;
- right: 0;
- top: 0;
- font-size: 12px;
- color: #fff;
- background: #FF953D;
- border-radius: 2px;
- padding: 0 5px;
- }
- }
- &::before {
- content: "";
- position: absolute;
- left: -5px;
- top: 50%;
- transform: translateY(-50%);
- width: 0;
- height: 0;
- border-top: 5px solid transparent;
- border-bottom: 5px solid transparent;
- border-right: 5px solid #2199f8;
- }
- }
- .arrange-card.status-normal {
- @include arrange-card-status(#2199F8, null, #2199F8, null, #000);
- }
- .arrange-card.status-green-info {
- @include arrange-card-status(#4ABF32, #ECFFE8, rgba(74, 191, 50, 0.5), rgba(28, 169, 0, 0.1), rgba(0, 0, 0, 0.4));
- }
- .arrange-card.status-orange {
- @include arrange-card-status(#FF953D, null, #FF953D, #fff, #000);
- }
- .arrange-card.status-normal-farm {
- @include arrange-card-status(#2199F8, #E9F5FF, #2199F8, rgba(33, 153, 248, 0.1), rgba(0, 0, 0, 0.4));
- }
- // 未激活等:灰色描边卡片
- .arrange-card.future-card {
- @include arrange-card-status(rgba(187, 187, 187, 0.6), null, rgba(187, 187, 187, 0.6), #fff, rgba(187, 187, 187, 0.6));
- }
- }
- }
- }
- .reproductive-item+.reproductive-item {
- margin-top: 4px;
- padding-top: 0;
- }
- .phenology-bar+.phenology-bar {
- margin-top: 4px;
- padding-top: 0;
- }
- .empty-state {
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 200px;
- width: 100%;
- }
- }
- </style>
|