|
|
@@ -1,1006 +0,0 @@
|
|
|
-<template>
|
|
|
- <div class="plan-page">
|
|
|
- <custom-header name="农事规划"></custom-header>
|
|
|
- <div class="plan-content">
|
|
|
- <div class="filter-wrap">
|
|
|
- <div class="season-tabs">
|
|
|
- <div
|
|
|
- v-for="s in seasons"
|
|
|
- :key="s.value"
|
|
|
- class="season-tab"
|
|
|
- :class="{ active: s.value === activeSeason }"
|
|
|
- @click="activeSeason = s.value"
|
|
|
- >
|
|
|
- {{ s.label }}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="status-filter">
|
|
|
- <div v-for="status in statusList" :key="status.value" class="status-item" :class="status.color">
|
|
|
- <div class="status-dot"></div>
|
|
|
- <span class="status-text">{{ status.label }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 三行循环时间线 -->
|
|
|
- <div class="cycle-timeline-container">
|
|
|
- <div class="cycle-timeline">
|
|
|
- <div
|
|
|
- v-for="(row, rowIndex) in timelineRows"
|
|
|
- :key="rowIndex"
|
|
|
- class="cycle-row"
|
|
|
- :class="{ 'odd-index': rowIndex % 2 === 1 }"
|
|
|
- >
|
|
|
- <div
|
|
|
- v-for="(item, itemIndex) in row.items"
|
|
|
- :key="itemIndex"
|
|
|
- class="cycle-item"
|
|
|
- @click="handleRowClick(item)"
|
|
|
- :class="[item.type + '-item']"
|
|
|
- >
|
|
|
- <!-- 节气节点 -->
|
|
|
- <template v-if="item.type === 'term'">
|
|
|
- <!-- <div class="cycle-term-dot"></div> -->
|
|
|
- <div class="cycle-term-label">{{ item.name || item.id }}</div>
|
|
|
- </template>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 生育期名称(根据时间范围显示在对应行) -->
|
|
|
- <div class="cycle-phenology-wrap" v-if="getPhenologyBarsForRow(rowIndex).length > 0">
|
|
|
- <div
|
|
|
- v-for="p in getPhenologyBarsForRow(rowIndex)"
|
|
|
- :key="p.id"
|
|
|
- class="cycle-label"
|
|
|
- :class="p.color"
|
|
|
- :style="
|
|
|
- isOddVisualRow(rowIndex)
|
|
|
- ? { right: p.left, width: p.width }
|
|
|
- : { left: p.left, width: p.width }
|
|
|
- "
|
|
|
- >
|
|
|
- {{ p.name }}
|
|
|
- <div v-if="p.arranges && p.arranges.length" class="arranges">
|
|
|
- <div v-for="a in p.arranges" :key="a.id" :class="['cycle-task-box', a.status]">
|
|
|
- <div class="cycle-task-text">{{ a.farmWorkName || a.name }}</div>
|
|
|
- <!-- 任务连接器 -->
|
|
|
- <div class="cycle-task-connector"></div>
|
|
|
- <div
|
|
|
- v-if="a.status === 'complete' || a.status === 'warning'"
|
|
|
- class="status-icon"
|
|
|
- :class="a.status"
|
|
|
- >
|
|
|
- <el-icon v-if="a.status === 'complete'" size="16" color="#1CA900"
|
|
|
- ><SuccessFilled
|
|
|
- /></el-icon>
|
|
|
- <el-icon v-else size="18" color="#FF953D"><WarnTriangleFilled /></el-icon>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <!-- 行连接器 -->
|
|
|
- <div
|
|
|
- v-if="rowIndex < timelineRows.length - 1"
|
|
|
- class="cycle-connector"
|
|
|
- :class="[
|
|
|
- rowIndex % 2 === 1 ? 'middle-connector' : 'top-connector',
|
|
|
- getConnectorColorClass(rowIndex),
|
|
|
- ]"
|
|
|
- >
|
|
|
- <img v-if="isConnectorGray(rowIndex)" src="@/assets/img/monitor/defalut-arrow.png" alt="" />
|
|
|
- <img v-else src="@/assets/img/monitor/arrow.png" alt="" />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="control-section">
|
|
|
- <div class="toggle-group">
|
|
|
- <el-switch v-model="isDefaultEnabled" />
|
|
|
- <span class="toggle-label">{{ isDefaultEnabled ? "默认" : "" }}发起农情需求</span>
|
|
|
- </div>
|
|
|
- <div class="add-button-group">
|
|
|
- <div class="add-button button" @click="addNewTask">新增农事</div>
|
|
|
- <div class="button" @click="manageTask">农事管理</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <!-- 农事信息弹窗 -->
|
|
|
- <detail-dialog ref="detailDialogRef"></detail-dialog>
|
|
|
- <!-- 新增:激活上传弹窗 -->
|
|
|
- <active-upload-popup></active-upload-popup>
|
|
|
-</template>
|
|
|
-
|
|
|
-<script setup>
|
|
|
-import { reactive, ref, onMounted, nextTick, onBeforeUnmount } from "vue";
|
|
|
-import customHeader from "@/components/customHeader.vue";
|
|
|
-import { useRouter, useRoute } from "vue-router";
|
|
|
-import detailDialog from "@/components/detailDialog.vue";
|
|
|
-import activeUploadPopup from "@/components/popup/activeUploadPopup.vue";
|
|
|
-const router = useRouter();
|
|
|
-const route = useRoute();
|
|
|
-
|
|
|
-// 状态列表数据
|
|
|
-const seasons = reactive([
|
|
|
- { value: "spring", label: "春季" },
|
|
|
- { value: "summer", label: "夏季" },
|
|
|
- { value: "autumn", label: "秋季" },
|
|
|
- { value: "winter", label: "冬季" },
|
|
|
-]);
|
|
|
-const activeSeason = ref("spring");
|
|
|
-
|
|
|
-const statusList = reactive([
|
|
|
- { value: "pending", label: "待触发", color: "gray" },
|
|
|
- { value: "executing", label: "待完成", color: "blue" },
|
|
|
- { value: "completed", label: "已完成", color: "green" },
|
|
|
- { value: "expired", label: "已过期", color: "orange" },
|
|
|
-]);
|
|
|
-
|
|
|
-// 切换开关状态
|
|
|
-const isDefaultEnabled = ref(true);
|
|
|
-
|
|
|
-// 时间线行数据(由接口节气生成)
|
|
|
-const timelineRows = reactive([]);
|
|
|
-
|
|
|
-// 目标定位日期(当前生育期参考点)
|
|
|
-const targetDate = new Date("2025-04-04T00:00:00");
|
|
|
-// 每行“当前生育期”标记的位置样式(按行索引)
|
|
|
-const phenologyPositions = ref({});
|
|
|
-
|
|
|
-// 生育期条(按行分组)
|
|
|
-const phenologyBarsByRow = ref([]);
|
|
|
-// 每一行可视区域的实际像素宽度(用于将最小像素宽度换算为百分比)
|
|
|
-const rowWidths = ref([]);
|
|
|
-// 节气 id 到对象的索引,便于通过 id 查找节气日期
|
|
|
-let solarTermIdToTerm = {};
|
|
|
-// 接口返回的生育期数据
|
|
|
-const phenologyList = ref([]);
|
|
|
-
|
|
|
-// 安全日期解析(兼容 'YYYY-MM-DD HH:mm:ss' / 'YYYY/MM/DD HH:mm:ss')
|
|
|
-const parseDate = (val) => {
|
|
|
- if (!val) return null;
|
|
|
- if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
|
|
|
- if (typeof val === "number") return new Date(val);
|
|
|
- if (typeof val === "string") {
|
|
|
- // 统一到可被 Safari 解析的格式
|
|
|
- const s = val.replace(/-/g, "/").replace("T", " ");
|
|
|
- const d = new Date(s);
|
|
|
- return isNaN(d.getTime()) ? null : d;
|
|
|
- }
|
|
|
- return null;
|
|
|
-};
|
|
|
-
|
|
|
-onMounted(() => {
|
|
|
- getFarmWorkPlan();
|
|
|
- window.addEventListener("resize", handleResize, { passive: true });
|
|
|
-});
|
|
|
-onBeforeUnmount(() => {
|
|
|
- window.removeEventListener("resize", handleResize);
|
|
|
-});
|
|
|
-
|
|
|
-const handleResize = () => {
|
|
|
- // 重新测量并基于最新宽度重算条目
|
|
|
- nextTick(() => {
|
|
|
- measureRowWidths();
|
|
|
- // 需要基于最新数据重算
|
|
|
- if (phenologyList.value && phenologyList.value.length && cachedValidSolarTerms.value) {
|
|
|
- groupPhenologyBarsByRow(phenologyList.value, cachedValidSolarTerms.value);
|
|
|
- }
|
|
|
- });
|
|
|
-};
|
|
|
-
|
|
|
-// 缓存已过滤/排序后的节气用于重复计算
|
|
|
-const cachedValidSolarTerms = ref(null);
|
|
|
-
|
|
|
-const getFarmWorkPlan = () => {
|
|
|
- const paramFarmId = Number(route.query.farmId) || undefined;
|
|
|
- VE_API.monitor
|
|
|
- .farmWorkPlan({ farmId: paramFarmId ?? 92844 }) // 优先使用路由传入的 farmId
|
|
|
- .then(({ data, code }) => {
|
|
|
- if (code === 0) {
|
|
|
- const solarTermsList = data.solarTermsList;
|
|
|
- // 仅保留 type === 1 的节气,按需要的顺序(示例:反转)
|
|
|
- // 取 type===1 的节气,并按日期降序排序(晚到早)
|
|
|
- const validSolarTerms = Array.isArray(solarTermsList)
|
|
|
- ? solarTermsList
|
|
|
- .filter((t) => t && t.type === 1 && t.createDate)
|
|
|
- .sort((a, b) => {
|
|
|
- const da = parseDate(a.createDate)?.getTime() ?? 0;
|
|
|
- const db = parseDate(b.createDate)?.getTime() ?? 0;
|
|
|
- return db - da;
|
|
|
- })
|
|
|
- : [];
|
|
|
- cachedValidSolarTerms.value = validSolarTerms;
|
|
|
- generateTimelineData(validSolarTerms);
|
|
|
- computeCurrentPhenologyPositions(validSolarTerms, targetDate);
|
|
|
- // 保存生育期数据并生成各行生育期条
|
|
|
- phenologyList.value = Array.isArray(data.phenologyList) ? data.phenologyList : [];
|
|
|
- // 生成 id->term 的索引
|
|
|
- solarTermIdToTerm = {};
|
|
|
- validSolarTerms.forEach((t) => {
|
|
|
- if (t && (t.id || t.solarTermsId)) solarTermIdToTerm[t.id ?? t.solarTermsId] = t;
|
|
|
- });
|
|
|
- // 先等待 DOM 渲染完成后测量每行宽度,再据此计算最小可显示宽度
|
|
|
- nextTick(() => {
|
|
|
- measureRowWidths();
|
|
|
- groupPhenologyBarsByRow(phenologyList.value, validSolarTerms);
|
|
|
- });
|
|
|
- }
|
|
|
- })
|
|
|
- .catch((error) => {
|
|
|
- console.error("获取农事规划数据失败:", error);
|
|
|
- });
|
|
|
-};
|
|
|
-
|
|
|
-// 测量每一行生育期容器的实际宽度
|
|
|
-const measureRowWidths = () => {
|
|
|
- const rows = document.querySelectorAll(".cycle-timeline .cycle-row");
|
|
|
- const widths = [];
|
|
|
- rows.forEach((rowEl, idx) => {
|
|
|
- const wrap = rowEl.querySelector(".cycle-phenology-wrap");
|
|
|
- widths[idx] = wrap ? wrap.offsetWidth : 0;
|
|
|
- });
|
|
|
- rowWidths.value = widths;
|
|
|
-};
|
|
|
-
|
|
|
-// 生成时间轴数据
|
|
|
-const generateTimelineData = (solarTerms) => {
|
|
|
- // 清空
|
|
|
- timelineRows.splice(0, timelineRows.length);
|
|
|
-
|
|
|
- // 无数据则给一行示例
|
|
|
- if (!solarTerms || solarTerms.length === 0) {
|
|
|
- timelineRows.push({
|
|
|
- items: [
|
|
|
- { type: "task", status: "default", taskName: "梢期", taskDesc: "杀虫" },
|
|
|
- { type: "term", name: "节气" },
|
|
|
- { type: "task", status: "default", taskName: "梢期", taskDesc: "杀虫" },
|
|
|
- { type: "term", name: "节气" },
|
|
|
- { type: "task", status: "default", taskName: "梢期", taskDesc: "杀虫" },
|
|
|
- { type: "term", name: "节气" },
|
|
|
- ],
|
|
|
- });
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const itemsPerRow = 6; // 任务/节气交替
|
|
|
- const termsPerRow = 3; // 每行3个节气
|
|
|
- const totalRows = Math.ceil(solarTerms.length / termsPerRow);
|
|
|
-
|
|
|
- for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
|
|
|
- const rowItems = [];
|
|
|
- const startTermIndex = rowIndex * termsPerRow;
|
|
|
-
|
|
|
- for (let i = 0; i < itemsPerRow; i++) {
|
|
|
- if (i % 2 === 0) {
|
|
|
- // 任务位
|
|
|
- const taskData = getTaskDataForIndex(Math.floor(i / 2));
|
|
|
- rowItems.push({
|
|
|
- type: "task",
|
|
|
- status: taskData.status,
|
|
|
- taskName: taskData.taskName,
|
|
|
- taskDesc: taskData.taskDesc,
|
|
|
- icon: taskData.icon,
|
|
|
- });
|
|
|
- } else {
|
|
|
- // 节气位
|
|
|
- const termIndex = startTermIndex + Math.floor(i / 2);
|
|
|
- if (termIndex < solarTerms.length) {
|
|
|
- const term = solarTerms[termIndex] || {};
|
|
|
- rowItems.push({
|
|
|
- type: "term",
|
|
|
- status: "default",
|
|
|
- name: term.name || term.solarTermsName || term.termName || "节气",
|
|
|
- id: term.id,
|
|
|
- createDate: term.createDate,
|
|
|
- });
|
|
|
- } else {
|
|
|
- // 不足时补任务
|
|
|
- rowItems.push({
|
|
|
- type: "task",
|
|
|
- status: "default",
|
|
|
- taskName: "梢期",
|
|
|
- taskDesc: "杀虫",
|
|
|
- });
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- timelineRows.push({ items: rowItems });
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 任务占位数据(可按需接后端)
|
|
|
-const getTaskDataForIndex = (index) => {
|
|
|
- const defaultTasks = [
|
|
|
- { status: "default", taskName: "梢期", taskDesc: "杀虫" },
|
|
|
- { status: "active", taskName: "梢期", taskDesc: "营养" },
|
|
|
- { status: "complete", taskName: "梢期", taskDesc: "修剪", icon: { type: "complete" } },
|
|
|
- { status: "warning", taskName: "梢期", taskDesc: "施肥", icon: { type: "warning" } },
|
|
|
- { status: "normal", taskName: "梢期", taskDesc: "灌溉", icon: { type: "normal" } },
|
|
|
- ];
|
|
|
- return defaultTasks[index % defaultTasks.length];
|
|
|
-};
|
|
|
-
|
|
|
-// 计算“当前生育期”在各行的定位(只在包含目标日期的那一行显示)
|
|
|
-const computeCurrentPhenologyPositions = (solarTerms, date) => {
|
|
|
- phenologyPositions.value = {};
|
|
|
- if (!Array.isArray(solarTerms) || solarTerms.length === 0 || !(date instanceof Date)) return;
|
|
|
-
|
|
|
- const termsPerRow = 3;
|
|
|
- const totalRows = Math.ceil(solarTerms.length / termsPerRow);
|
|
|
-
|
|
|
- // 1) 找到最接近目标日期的节气(按时间升序)
|
|
|
- const termsAsc = solarTerms
|
|
|
- .filter((t) => t && t.createDate)
|
|
|
- .slice()
|
|
|
- .sort((a, b) => (parseDate(a.createDate)?.getTime() ?? 0) - (parseDate(b.createDate)?.getTime() ?? 0));
|
|
|
- if (termsAsc.length === 0) return;
|
|
|
-
|
|
|
- const targetMs = date.getTime();
|
|
|
- let nearest = termsAsc[0];
|
|
|
- let bestDiff = Math.abs((parseDate(nearest.createDate)?.getTime() ?? 0) - targetMs);
|
|
|
- for (let i = 1; i < termsAsc.length; i++) {
|
|
|
- const ms = parseDate(termsAsc[i].createDate)?.getTime() ?? 0;
|
|
|
- const diff = Math.abs(ms - targetMs);
|
|
|
- if (diff < bestDiff) {
|
|
|
- bestDiff = diff;
|
|
|
- nearest = termsAsc[i];
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 2) 将该节气映射回当前(降序)数组中的索引与行
|
|
|
- const nearestIdxDesc = solarTerms.findIndex((t) => t && nearest && t.id === nearest.id);
|
|
|
- const rowIndex = Math.max(0, Math.floor(nearestIdxDesc / termsPerRow));
|
|
|
-
|
|
|
- const startIdx = rowIndex * termsPerRow;
|
|
|
- const endIdx = Math.min(startIdx + termsPerRow - 1, solarTerms.length - 1);
|
|
|
- if (startIdx > endIdx) return;
|
|
|
-
|
|
|
- const rowTerms = solarTerms.slice(startIdx, endIdx + 1);
|
|
|
- // 视觉顺序用于方向(偶数行正向,奇数行反向),但时间范围应取该行真实最早/最晚
|
|
|
- const rowDates = rowTerms
|
|
|
- .map((t) => parseDate(t?.createDate))
|
|
|
- .filter((d) => d && !isNaN(d.getTime()))
|
|
|
- .map((d) => d.getTime());
|
|
|
- if (rowDates.length === 0) return;
|
|
|
- const minMs = Math.min(...rowDates);
|
|
|
- const maxMs = Math.max(...rowDates);
|
|
|
- const rowStart = new Date(minMs);
|
|
|
- const rowEnd = new Date(maxMs);
|
|
|
-
|
|
|
- // 3) 若目标日期不在该行范围内,则就近夹到边界(避免跨行导致丢失)
|
|
|
- let anchorMs = targetMs;
|
|
|
- if (anchorMs < minMs) anchorMs = minMs;
|
|
|
- if (anchorMs > maxMs) anchorMs = maxMs;
|
|
|
-
|
|
|
- // 4) 计算在该行范围内的比例
|
|
|
- const total = Math.max(1, maxMs - minMs);
|
|
|
- const ratio = Math.max(0, Math.min(1, (anchorMs - minMs) / total));
|
|
|
- const percent = `${(ratio * 100).toFixed(2)}%`;
|
|
|
-
|
|
|
- // 5) 偶数行用 left,奇数行用 right,与 Z 字方向一致
|
|
|
- if (rowIndex % 2 === 1) {
|
|
|
- phenologyPositions.value[rowIndex] = { right: percent };
|
|
|
- } else {
|
|
|
- phenologyPositions.value[rowIndex] = { left: percent };
|
|
|
- }
|
|
|
-};
|
|
|
-// moved above with other refs
|
|
|
-
|
|
|
-// 将生育期条按行计算定位与宽度
|
|
|
-const groupPhenologyBarsByRow = (phenologyList, solarTerms) => {
|
|
|
- phenologyBarsByRow.value = [];
|
|
|
- if (
|
|
|
- !Array.isArray(phenologyList) ||
|
|
|
- phenologyList.length === 0 ||
|
|
|
- !Array.isArray(solarTerms) ||
|
|
|
- solarTerms.length === 0
|
|
|
- ) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const termsPerRow = 3;
|
|
|
- const totalRows = Math.ceil(solarTerms.length / termsPerRow);
|
|
|
-
|
|
|
- // 行范围:使用该行包含的节气最早/最晚时间(按真实时间线性映射)
|
|
|
- const rowRanges = [];
|
|
|
- for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
|
|
|
- const startIdx = rowIndex * termsPerRow;
|
|
|
- const endIdx = Math.min(startIdx + termsPerRow - 1, solarTerms.length - 1);
|
|
|
- const rowTerms = solarTerms.slice(startIdx, endIdx + 1);
|
|
|
- const rowDates = rowTerms
|
|
|
- .map((t) => parseDate(t?.createDate))
|
|
|
- .filter((d) => d && !isNaN(d.getTime()))
|
|
|
- .map((d) => d.getTime());
|
|
|
- if (rowDates.length === 0) continue;
|
|
|
- const minMs = Math.min(...rowDates);
|
|
|
- const maxMs = Math.max(...rowDates);
|
|
|
- const rowStart = new Date(minMs);
|
|
|
- const rowEnd = new Date(maxMs);
|
|
|
- const totalMs = Math.max(1, rowEnd.getTime() - rowStart.getTime());
|
|
|
- rowRanges.push({ rowIndex, rowStart, rowEnd, totalMs });
|
|
|
- phenologyBarsByRow.value[rowIndex] = [];
|
|
|
- }
|
|
|
-
|
|
|
- // 中点归属法:每条生育期归属到中点所在的行;在行内按 Z 字方向计算 left/right 与 width
|
|
|
- phenologyList.forEach((p, pIndex) => {
|
|
|
- const list = Array.isArray(p?.reproductiveList) ? p.reproductiveList : [];
|
|
|
- const baseColorClass = pIndex % 2 === 0 ? "blue" : "orange";
|
|
|
- list.forEach((r) => {
|
|
|
- // 优先使用节气 id 对应的节气日期
|
|
|
- let sTermDate = null;
|
|
|
- let eTermDate = null;
|
|
|
- if (r?.startSolarTermId && solarTermIdToTerm[r.startSolarTermId]?.createDate) {
|
|
|
- sTermDate = parseDate(solarTermIdToTerm[r.startSolarTermId].createDate);
|
|
|
- }
|
|
|
- if (r?.endSolarTermId && solarTermIdToTerm[r.endSolarTermId]?.createDate) {
|
|
|
- eTermDate = parseDate(solarTermIdToTerm[r.endSolarTermId].createDate);
|
|
|
- }
|
|
|
-
|
|
|
- const s = sTermDate || parseDate(r?.startDate);
|
|
|
- const e = eTermDate || parseDate(r?.endDate);
|
|
|
- if (!s || !e) return;
|
|
|
- const start = new Date(Math.min(s.getTime(), e.getTime()));
|
|
|
- const end = new Date(Math.max(s.getTime(), e.getTime()));
|
|
|
- if (end < start) return;
|
|
|
- const mid = new Date(start.getTime() + (end.getTime() - start.getTime()) / 2);
|
|
|
-
|
|
|
- // 找到中点所在行;若不在任何行,则归最近行
|
|
|
- let target = rowRanges.find(({ rowStart, rowEnd }) => mid >= rowStart && mid <= rowEnd);
|
|
|
- if (!target) {
|
|
|
- target = rowRanges.reduce((best, curr) => {
|
|
|
- const dist =
|
|
|
- mid < curr.rowStart
|
|
|
- ? curr.rowStart.getTime() - mid.getTime()
|
|
|
- : mid.getTime() - curr.rowEnd.getTime();
|
|
|
- if (!best || dist < best.dist) return { dist, curr };
|
|
|
- return best;
|
|
|
- }, null)?.curr;
|
|
|
- }
|
|
|
- if (!target) return;
|
|
|
-
|
|
|
- // 位置:基于真实的 startDate(不截断),确保相邻条的间距 = (后一个startDate - 前一个endDate) 的时间差映射
|
|
|
- const startRatio = (start.getTime() - target.rowStart.getTime()) / target.totalMs;
|
|
|
-
|
|
|
- // 宽度:基于真实的 endDate - startDate 的时间差
|
|
|
- const actualDuration = end.getTime() - start.getTime();
|
|
|
- const widthRatio = actualDuration / target.totalMs;
|
|
|
-
|
|
|
- // 限制到行范围内
|
|
|
- const leftRatio = Math.max(0, Math.min(1, startRatio));
|
|
|
- const rightRatio = Math.max(0, Math.min(1, (end.getTime() - target.rowStart.getTime()) / target.totalMs));
|
|
|
- let clampedWidthRatio = Math.max(0.001, Math.min(widthRatio, rightRatio - leftRatio));
|
|
|
-
|
|
|
- // 强制最小显示宽度:若换算到像素后小于 CSS 中的 min-width:22px,则使用最小可见宽度
|
|
|
- const MIN_LABEL_PX = 22; // 与样式 .cycle-label 的最小宽度保持一致
|
|
|
- const rowWidthPx = rowWidths.value?.[target.rowIndex] || 0;
|
|
|
- let leftPercent = leftRatio * 100;
|
|
|
- let widthPercent = clampedWidthRatio * 100;
|
|
|
- if (rowWidthPx > 0) {
|
|
|
- const minPercent = (MIN_LABEL_PX / rowWidthPx) * 100;
|
|
|
- if (widthPercent < minPercent) {
|
|
|
- widthPercent = minPercent;
|
|
|
- }
|
|
|
- // 若越界则左移以保证完全可见
|
|
|
- if (leftPercent + widthPercent > 100) {
|
|
|
- leftPercent = Math.max(0, 100 - widthPercent);
|
|
|
- }
|
|
|
- // 回填为比例供后续使用
|
|
|
- clampedWidthRatio = widthPercent / 100;
|
|
|
- } else {
|
|
|
- // 无法测量时,保底给一个不至于 0 的最小显示比例(以 360px 近似,22px/360≈6.1%)
|
|
|
- if (widthPercent < 6.2) {
|
|
|
- widthPercent = 6.2;
|
|
|
- if (leftPercent + widthPercent > 100) leftPercent = Math.max(0, 100 - widthPercent);
|
|
|
- clampedWidthRatio = widthPercent / 100;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const isFuture = start.getTime() > Date.now();
|
|
|
- const colorToUse = isFuture ? "" : baseColorClass;
|
|
|
- // 组装农事安排:按 reproductiveId 归属到当前生育期
|
|
|
- const arrangeList = Array.isArray(r.farmWorkArrangeList)
|
|
|
- ? r.farmWorkArrangeList.filter((fw) => !fw.reproductiveId || fw.reproductiveId === r.id)
|
|
|
- : [];
|
|
|
- const arrangeItems = arrangeList.map((fw) => {
|
|
|
- let status = "default";
|
|
|
- const t = fw.farmWorkType;
|
|
|
- if (t == null || t === 0) {
|
|
|
- status = "default";
|
|
|
- } else if (t >= 1 && t <= 4) {
|
|
|
- status = "normal";
|
|
|
- } else if (t === 5) {
|
|
|
- status = "complete";
|
|
|
- } else if (t === 6) {
|
|
|
- status = "warning";
|
|
|
- }
|
|
|
- return {
|
|
|
- id: fw.id,
|
|
|
- name: fw.farmWorkName,
|
|
|
- status,
|
|
|
- };
|
|
|
- });
|
|
|
-
|
|
|
- // // 附加两条测试数据:已完成、已过期
|
|
|
- // arrangeItems.push(
|
|
|
- // { id: `${r.id}-test-complete`, name: "测试完成", status: "complete" },
|
|
|
- // { id: `${r.id}-test-warning`, name: "测试过期", status: "warning" }
|
|
|
- // );
|
|
|
-
|
|
|
- phenologyBarsByRow.value[target.rowIndex].push({
|
|
|
- id: r.id || `${p.id || "p"}-${start.getTime()}-${end.getTime()}`,
|
|
|
- name: r.name && r.name.trim() ? r.name.trim() : r.phenologyName || "生育期",
|
|
|
- left: `${leftPercent.toFixed(4)}%`,
|
|
|
- width: `${(clampedWidthRatio * 100).toFixed(4)}%`,
|
|
|
- startTime: start.getTime(), // 用于排序,确保相邻条的顺序正确
|
|
|
- color: colorToUse,
|
|
|
- arranges: arrangeItems,
|
|
|
- });
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- // 每行内部按 startTime 排序,确保相邻条的间距正确反映时间差
|
|
|
- phenologyBarsByRow.value.forEach((rowBars) => {
|
|
|
- rowBars.sort((a, b) => (a.startTime || 0) - (b.startTime || 0));
|
|
|
- });
|
|
|
-};
|
|
|
-
|
|
|
-// 获取指定行的生育期条
|
|
|
-const getPhenologyBarsForRow = (rowIndex) => {
|
|
|
- return phenologyBarsByRow.value[rowIndex] || [];
|
|
|
-};
|
|
|
-
|
|
|
-// 视觉奇偶:自下而上计算奇偶(与 UI Z 字一致)
|
|
|
-const isOddVisualRow = (rowIndex) => {
|
|
|
- const total = timelineRows.length;
|
|
|
- if (total <= 0) return rowIndex % 2 === 1;
|
|
|
- const visualIndex = total - 1 - rowIndex;
|
|
|
- return visualIndex % 2 === 1;
|
|
|
-};
|
|
|
-
|
|
|
-// 新增农事
|
|
|
-const addNewTask = () => {
|
|
|
- router.push({
|
|
|
- path: "/modify_work",
|
|
|
- query: { data: JSON.stringify(["生长异常"]), gardenId: 766, isAdd: true },
|
|
|
- });
|
|
|
-};
|
|
|
-
|
|
|
-const manageTask = () => {
|
|
|
- router.push({
|
|
|
- path: "/agri_services_manage",
|
|
|
- query: {
|
|
|
- type: "manage",
|
|
|
- },
|
|
|
- });
|
|
|
-};
|
|
|
-
|
|
|
-const detailDialogRef = ref(null);
|
|
|
-
|
|
|
-const handleRowClick = (item) => {
|
|
|
- if (item.status === "complete") {
|
|
|
- router.push({
|
|
|
- path: "/review_work",
|
|
|
- query: {
|
|
|
- miniJson: JSON.stringify({ id: item.id,goBack: true })
|
|
|
- },
|
|
|
- });
|
|
|
- } else if (item.type !== "term" && item.status === "default") {
|
|
|
- detailDialogRef.value.showDialog();
|
|
|
- } else if (item.status === "warning" || item.status === "normal") {
|
|
|
- router.push({
|
|
|
- path: "/completed_work",
|
|
|
- query: {
|
|
|
- miniJson: JSON.stringify({ id: item.id })
|
|
|
- },
|
|
|
- });
|
|
|
- // router.push({
|
|
|
- // path: "/services_agri",
|
|
|
- // query: {
|
|
|
- // id: item.id,
|
|
|
- // status: item.status,
|
|
|
- // },
|
|
|
- // });
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-// 行连接器颜色:若后续生育期未开始则灰色,否则保持其颜色(蓝/橙)
|
|
|
-const getConnectorColorClass = (rowIndex) => {
|
|
|
- const nextIndex = rowIndex + 1;
|
|
|
- const bars = getPhenologyBarsForRow(nextIndex);
|
|
|
- if (!bars || bars.length === 0) return "";
|
|
|
-
|
|
|
- const nextIsOddIndex = nextIndex % 2 === 1; // 奇数行为左侧连接器
|
|
|
-
|
|
|
- const parsePercent = (val) => {
|
|
|
- if (typeof val !== "string") return 0;
|
|
|
- const n = parseFloat(val.replace("%", ""));
|
|
|
- return isNaN(n) ? 0 : n;
|
|
|
- };
|
|
|
-
|
|
|
- let target = bars[0];
|
|
|
- if (nextIsOddIndex) {
|
|
|
- // 左侧:选最靠左的条
|
|
|
- target = bars.reduce((best, cur) => (parsePercent(cur.left) < parsePercent(best.left) ? cur : best), bars[0]);
|
|
|
- } else {
|
|
|
- // 右侧:选最靠右的条(left + width 最大)
|
|
|
- const score = (b) => parsePercent(b.left) + parsePercent(b.width);
|
|
|
- target = bars.reduce((best, cur) => (score(cur) > score(best) ? cur : best), bars[0]);
|
|
|
- }
|
|
|
-
|
|
|
- // 未来(未开始)时,color 为空串;过去/当前一律显示蓝色
|
|
|
- const hasStarted = !!target?.color;
|
|
|
- return hasStarted ? "" : "connector-gray";
|
|
|
-};
|
|
|
-
|
|
|
-// 行连接器是否为灰色(用于切换箭头图片)
|
|
|
-const isConnectorGray = (rowIndex) => getConnectorColorClass(rowIndex) === "connector-gray";
|
|
|
-</script>
|
|
|
-
|
|
|
-<style scoped lang="scss">
|
|
|
-.plan-page {
|
|
|
- width: 100%;
|
|
|
- height: 100vh;
|
|
|
- background: #fff;
|
|
|
- .plan-content {
|
|
|
- .filter-wrap {
|
|
|
- background: #fff;
|
|
|
- padding: 13px 12px;
|
|
|
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
|
- border-radius: 0 0 20px 20px;
|
|
|
- .status-filter {
|
|
|
- background: #fff;
|
|
|
- padding: 3px 17px;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 16px;
|
|
|
- font-size: 12px;
|
|
|
-
|
|
|
- .status-item {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- gap: 6px;
|
|
|
- flex: 1;
|
|
|
- &.gray {
|
|
|
- color: #c4c6c9;
|
|
|
- .status-dot {
|
|
|
- background-color: #c4c6c9;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &.blue {
|
|
|
- color: #2199f8;
|
|
|
- .status-dot {
|
|
|
- background-color: #2199f8;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &.green {
|
|
|
- color: #1ca900;
|
|
|
- .status-dot {
|
|
|
- background-color: #1ca900;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &.orange {
|
|
|
- color: #ff953d;
|
|
|
- .status-dot {
|
|
|
- background-color: #ff953d;
|
|
|
- }
|
|
|
- }
|
|
|
- .status-dot {
|
|
|
- width: 6px;
|
|
|
- height: 6px;
|
|
|
- border-radius: 50%;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- .season-tabs {
|
|
|
- display: flex;
|
|
|
- gap: 8px;
|
|
|
- margin-bottom: 12px;
|
|
|
- .season-tab {
|
|
|
- flex: 1;
|
|
|
- padding: 7px;
|
|
|
- text-align: center;
|
|
|
- background: #f3f3f3;
|
|
|
- color: #898a8a;
|
|
|
- border-radius: 3px;
|
|
|
- border: 1px solid transparent;
|
|
|
- font-size: 12px;
|
|
|
- }
|
|
|
- .season-tab.active {
|
|
|
- background: #ffffff;
|
|
|
- color: #2199f8;
|
|
|
- border-color: #2199f8;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 循环时间线样式
|
|
|
- .cycle-timeline-container {
|
|
|
- padding: 35px 15px 25px;
|
|
|
- height: calc(100vh - 135px - 69px - 60px);
|
|
|
- overflow-y: auto;
|
|
|
- overflow-x: hidden;
|
|
|
- .cycle-timeline {
|
|
|
- position: relative;
|
|
|
- .cycle-row {
|
|
|
- position: relative;
|
|
|
- display: flex;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
- margin-bottom: 60px;
|
|
|
- padding-right: 30px;
|
|
|
- &.odd-index {
|
|
|
- padding: 0;
|
|
|
- padding-left: 30px;
|
|
|
- flex-direction: row-reverse;
|
|
|
- .cycle-phenology-wrap {
|
|
|
- left: 6px;
|
|
|
- width: calc(100% - 13px);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &:last-child {
|
|
|
- margin-bottom: 0;
|
|
|
- .cycle-phenology-wrap {
|
|
|
- left: 20px;
|
|
|
- width: calc(100% - 10px);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 水平时间线
|
|
|
- &::before {
|
|
|
- content: "";
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 6px;
|
|
|
- right: 6px;
|
|
|
- height: 5px;
|
|
|
- border-left: 2px solid #fff;
|
|
|
- border-right: 2px solid #fff;
|
|
|
- background: #e8e8e8;
|
|
|
- transform: translateY(-50%);
|
|
|
- z-index: 1;
|
|
|
- }
|
|
|
-
|
|
|
- .cycle-item {
|
|
|
- position: relative;
|
|
|
- z-index: 2;
|
|
|
- top: 12px;
|
|
|
-
|
|
|
- &.term-item {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- align-items: center;
|
|
|
- top: -11px;
|
|
|
-
|
|
|
- .cycle-term-dot {
|
|
|
- width: 6px;
|
|
|
- height: 6px;
|
|
|
- background: #c7c7c7;
|
|
|
- border-radius: 50%;
|
|
|
- margin-bottom: 4px;
|
|
|
- }
|
|
|
-
|
|
|
- .cycle-term-label {
|
|
|
- font-size: 11px;
|
|
|
- color: #c7c7c7;
|
|
|
- margin-top: 16px;
|
|
|
- }
|
|
|
-
|
|
|
- &.active {
|
|
|
- .cycle-term-dot {
|
|
|
- background: #858383;
|
|
|
- }
|
|
|
- .cycle-term-label {
|
|
|
- color: #858383;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- .cycle-phenology-wrap {
|
|
|
- position: absolute;
|
|
|
- top: -23px;
|
|
|
- left: 6px;
|
|
|
- width: calc(100% - 10px);
|
|
|
- z-index: 3;
|
|
|
- height: 100px;
|
|
|
- overflow: hidden;
|
|
|
- .cycle-label {
|
|
|
- position: absolute;
|
|
|
- color: #4e4e4e;
|
|
|
- font-size: 12px;
|
|
|
- min-width: 24px;
|
|
|
- height: 20px;
|
|
|
- line-height: 20px;
|
|
|
- text-align: center;
|
|
|
- background: rgba(180, 182, 183, 0.1);
|
|
|
- border-bottom: 6px solid #e8e8e8;
|
|
|
- }
|
|
|
- .cycle-label + .cycle-label {
|
|
|
- border-right: 1px solid #fff;
|
|
|
- }
|
|
|
- .cycle-label.blue {
|
|
|
- color: #2199f8;
|
|
|
- background: rgba(33, 153, 248, 0.1);
|
|
|
- border-bottom-color: #2199f8;
|
|
|
- }
|
|
|
- .cycle-label.orange {
|
|
|
- color: #ff953d;
|
|
|
- background: #fff2e7;
|
|
|
- border-bottom-color: #ff953d;
|
|
|
- }
|
|
|
- .arranges {
|
|
|
- display: flex;
|
|
|
- gap: 8px;
|
|
|
- padding-top: 26px;
|
|
|
- justify-content: center;
|
|
|
- flex-wrap: nowrap;
|
|
|
- // 使用与任务框一致的视觉风格
|
|
|
- .cycle-task-box {
|
|
|
- border: 1px solid rgba(199, 199, 199, 0.5);
|
|
|
- border-radius: 2px;
|
|
|
- width: 36px;
|
|
|
- height: 36px;
|
|
|
- min-width: 36px;
|
|
|
- line-height: 15px;
|
|
|
- font-size: 12px;
|
|
|
- box-sizing: border-box;
|
|
|
- padding: 2px 0;
|
|
|
- text-align: center;
|
|
|
- position: relative;
|
|
|
- color: #c7c7c7;
|
|
|
- .status-icon {
|
|
|
- position: absolute;
|
|
|
- bottom: -10px;
|
|
|
- right: -10px;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- .cycle-task-connector {
|
|
|
- position: absolute;
|
|
|
- top: -4px;
|
|
|
- left: 50%;
|
|
|
- transform: translateX(-50%);
|
|
|
- width: 0;
|
|
|
- height: 0;
|
|
|
- border-left: 4px solid transparent;
|
|
|
- border-right: 4px solid transparent;
|
|
|
- border-bottom: 4px solid #dde1e7;
|
|
|
- }
|
|
|
-
|
|
|
- .cycle-task-box.warning {
|
|
|
- border-color: #ff953d;
|
|
|
- }
|
|
|
- .cycle-task-box.warning .cycle-task-text {
|
|
|
- color: #ff953d;
|
|
|
- }
|
|
|
- .cycle-task-box.warning + .cycle-task-connector,
|
|
|
- .cycle-task-box.warning .cycle-task-connector {
|
|
|
- border-bottom-color: #ff953d;
|
|
|
- }
|
|
|
-
|
|
|
- .cycle-task-box.complete {
|
|
|
- border-color: #1ca900;
|
|
|
- }
|
|
|
- .cycle-task-box.complete .cycle-task-text {
|
|
|
- color: #1ca900;
|
|
|
- }
|
|
|
- .cycle-task-box.complete + .cycle-task-connector,
|
|
|
- .cycle-task-box.complete .cycle-task-connector {
|
|
|
- border-bottom-color: #1ca900;
|
|
|
- }
|
|
|
-
|
|
|
- .cycle-task-box.normal {
|
|
|
- border-color: #2199f8;
|
|
|
- }
|
|
|
- .cycle-task-box.normal .cycle-task-text {
|
|
|
- color: #2199f8;
|
|
|
- }
|
|
|
- .cycle-task-box.normal + .cycle-task-connector,
|
|
|
- .cycle-task-box.normal .cycle-task-connector {
|
|
|
- border-bottom-color: #2199f8;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- .cycle-connector {
|
|
|
- position: absolute;
|
|
|
- right: 0;
|
|
|
- top: 45.5px;
|
|
|
- transform: translateY(-50%);
|
|
|
- width: 2px;
|
|
|
- height: 87px;
|
|
|
- border: 5px solid #9dcaf7;
|
|
|
- border-left: none;
|
|
|
- background: transparent;
|
|
|
- img{
|
|
|
- width: 13px;
|
|
|
- height: 13px;
|
|
|
- position: absolute;
|
|
|
- top: 50%;
|
|
|
- transform: translateY(-50%);
|
|
|
- left: -8px;
|
|
|
- z-index: 1;
|
|
|
- }
|
|
|
-
|
|
|
- &.top-connector {
|
|
|
- border-top-right-radius: 5px;
|
|
|
- border-bottom-right-radius: 5px;
|
|
|
- img{
|
|
|
- left: -2px;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &.middle-connector {
|
|
|
- border-top-left-radius: 5px;
|
|
|
- border-bottom-left-radius: 5px;
|
|
|
- left: 0;
|
|
|
- border-right: none;
|
|
|
- border-left: 5px solid #9dcaf7;
|
|
|
- }
|
|
|
-
|
|
|
- // 动态颜色
|
|
|
- &.connector-gray {
|
|
|
- border-color: #c4c6c9;
|
|
|
- }
|
|
|
- &.connector-gray.middle-connector {
|
|
|
- border-left-color: #c4c6c9;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 控制区域样式
|
|
|
- .control-section {
|
|
|
- position: fixed;
|
|
|
- width: 100%;
|
|
|
- left: 0;
|
|
|
- box-sizing: border-box;
|
|
|
- bottom: 0px;
|
|
|
- background: #fff;
|
|
|
- padding: 16px 12px;
|
|
|
- display: flex;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
- border-top: 1px solid #f0f0f0;
|
|
|
-
|
|
|
- .toggle-group {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
-
|
|
|
- .toggle-label {
|
|
|
- font-size: 13px;
|
|
|
- color: #141414;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- .add-button-group {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
- .button {
|
|
|
- color: #2199f8;
|
|
|
- border-radius: 25px;
|
|
|
- padding: 9px 15px;
|
|
|
- border: 1px solid #2199f8;
|
|
|
- }
|
|
|
- .add-button {
|
|
|
- background: linear-gradient(120deg, #76c3ff 0%, #2199f8 100%);
|
|
|
- color: white;
|
|
|
- border: 1px solid transparent;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-</style>
|