|
@@ -9,7 +9,7 @@
|
|
|
:key="s.value"
|
|
:key="s.value"
|
|
|
class="season-tab"
|
|
class="season-tab"
|
|
|
:class="{ active: s.value === activeSeason }"
|
|
:class="{ active: s.value === activeSeason }"
|
|
|
- @click="activeSeason = s.value"
|
|
|
|
|
|
|
+ @click="handleSeasonClick(s.value)"
|
|
|
>
|
|
>
|
|
|
{{ s.label }}
|
|
{{ s.label }}
|
|
|
</div>
|
|
</div>
|
|
@@ -21,79 +21,62 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
- <!-- 三行循环时间线 -->
|
|
|
|
|
- <div class="cycle-timeline-container">
|
|
|
|
|
- <div class="cycle-timeline">
|
|
|
|
|
|
|
+ <div class="timeline-container" ref="timelineContainerRef">
|
|
|
|
|
+ <div class="timeline-list" :style="getListStyle">
|
|
|
|
|
+ <div class="timeline-middle-line"></div>
|
|
|
|
|
+ <!-- 物候期覆盖条(progress 为起点,progress2 为终点,单位 %) -->
|
|
|
<div
|
|
<div
|
|
|
- v-for="(row, rowIndex) in timelineRows"
|
|
|
|
|
- :key="rowIndex"
|
|
|
|
|
- class="cycle-row"
|
|
|
|
|
- :class="{ 'odd-index': rowIndex % 2 === 1 }"
|
|
|
|
|
|
|
+ v-for="(p, idx) in phenologyList"
|
|
|
|
|
+ :key="p.id ?? idx"
|
|
|
|
|
+ class="phenology-bar"
|
|
|
|
|
+ :style="getPhenologyBarStyle(p)"
|
|
|
>
|
|
>
|
|
|
- <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 class="reproductive-list">
|
|
|
<div
|
|
<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 }
|
|
|
|
|
- "
|
|
|
|
|
|
|
+ v-for="(r, rIdx) in Array.isArray(p.reproductiveList) ? p.reproductiveList : []"
|
|
|
|
|
+ :key="r.id ?? rIdx"
|
|
|
|
|
+ class="reproductive-item"
|
|
|
>
|
|
>
|
|
|
- {{ 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>
|
|
|
|
|
|
|
+ {{ r.name }}
|
|
|
|
|
+ <div class="arranges">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="(fw, aIdx) in Array.isArray(r.farmWorkArrangeList)
|
|
|
|
|
+ ? r.farmWorkArrangeList
|
|
|
|
|
+ : []"
|
|
|
|
|
+ :key="fw.id ?? aIdx"
|
|
|
|
|
+ class="arrange-box"
|
|
|
|
|
+ :class="getArrangeStatusClass(fw)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="arrange-text">{{ fw.farmWorkName }}</span>
|
|
|
<div
|
|
<div
|
|
|
- v-if="a.status === 'complete' || a.status === 'warning'"
|
|
|
|
|
|
|
+ v-if="
|
|
|
|
|
+ getArrangeStatusClass(fw) === 'status-complete' ||
|
|
|
|
|
+ getArrangeStatusClass(fw) === 'status-warning'
|
|
|
|
|
+ "
|
|
|
class="status-icon"
|
|
class="status-icon"
|
|
|
- :class="a.status"
|
|
|
|
|
|
|
+ :class="getArrangeStatusClass(fw)"
|
|
|
>
|
|
>
|
|
|
- <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>
|
|
|
|
|
|
|
+ <el-icon
|
|
|
|
|
+ v-if="getArrangeStatusClass(fw) === '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>
|
|
|
</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 v-for="t in solarTerms" :key="t.id" class="timeline-term" :style="getTermStyle(t)">
|
|
|
|
|
+ <span class="term-name">{{ t.displayName }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
<div class="control-section">
|
|
<div class="control-section">
|
|
|
<div class="toggle-group">
|
|
<div class="toggle-group">
|
|
|
<el-switch v-model="isDefaultEnabled" />
|
|
<el-switch v-model="isDefaultEnabled" />
|
|
@@ -113,11 +96,12 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { reactive, ref, onMounted, nextTick, onBeforeUnmount } from "vue";
|
|
|
|
|
|
|
+import { reactive, ref, onMounted, computed } from "vue";
|
|
|
import customHeader from "@/components/customHeader.vue";
|
|
import customHeader from "@/components/customHeader.vue";
|
|
|
import { useRouter, useRoute } from "vue-router";
|
|
import { useRouter, useRoute } from "vue-router";
|
|
|
import detailDialog from "@/components/detailDialog.vue";
|
|
import detailDialog from "@/components/detailDialog.vue";
|
|
|
import activeUploadPopup from "@/components/popup/activeUploadPopup.vue";
|
|
import activeUploadPopup from "@/components/popup/activeUploadPopup.vue";
|
|
|
|
|
+import { SuccessFilled, WarningFilled } from "@element-plus/icons-vue";
|
|
|
const router = useRouter();
|
|
const router = useRouter();
|
|
|
const route = useRoute();
|
|
const route = useRoute();
|
|
|
|
|
|
|
@@ -128,7 +112,7 @@ const seasons = reactive([
|
|
|
{ value: "autumn", label: "秋季" },
|
|
{ value: "autumn", label: "秋季" },
|
|
|
{ value: "winter", label: "冬季" },
|
|
{ value: "winter", label: "冬季" },
|
|
|
]);
|
|
]);
|
|
|
-const activeSeason = ref("spring");
|
|
|
|
|
|
|
+const activeSeason = ref("");
|
|
|
|
|
|
|
|
const statusList = reactive([
|
|
const statusList = reactive([
|
|
|
{ value: "pending", label: "待触发", color: "gray" },
|
|
{ value: "pending", label: "待触发", color: "gray" },
|
|
@@ -137,95 +121,58 @@ const statusList = reactive([
|
|
|
{ value: "expired", label: "已过期", color: "orange" },
|
|
{ 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 solarTerms = ref([]);
|
|
|
const phenologyList = ref([]);
|
|
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(() => {
|
|
onMounted(() => {
|
|
|
getFarmWorkPlan();
|
|
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 getFarmWorkPlan = () => {
|
|
|
- const paramFarmId = Number(route.query.farmId) || undefined;
|
|
|
|
|
VE_API.monitor
|
|
VE_API.monitor
|
|
|
- .farmWorkPlan({ farmId: paramFarmId ?? 92844 }) // 优先使用路由传入的 farmId
|
|
|
|
|
|
|
+ .farmWorkPlan({ farmId: route.query.farmId })
|
|
|
.then(({ data, code }) => {
|
|
.then(({ data, code }) => {
|
|
|
if (code === 0) {
|
|
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;
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const list = Array.isArray(data?.solarTermsList) ? data.solarTermsList : [];
|
|
|
|
|
+ const filtered = list
|
|
|
|
|
+ .filter((t) => t && t.type === 1)
|
|
|
|
|
+ .map((t) => ({
|
|
|
|
|
+ id:
|
|
|
|
|
+ t.id ??
|
|
|
|
|
+ t.solarTermsId ??
|
|
|
|
|
+ t.termId ??
|
|
|
|
|
+ `${t.name || t.solarTermsName || t.termName || "term"}-${t.createDate || ""}`,
|
|
|
|
|
+ displayName: t.name || t.solarTermsName || t.termName || "节气",
|
|
|
|
|
+ createDate: t.createDate || null,
|
|
|
|
|
+ progress: Number(t.progress) || 0,
|
|
|
|
|
+ }));
|
|
|
|
|
+ solarTerms.value = filtered;
|
|
|
|
|
+ // 物候期数据
|
|
|
|
|
+ phenologyList.value = Array.isArray(data?.phenologyList)
|
|
|
|
|
+ ? data.phenologyList.map((it) => ({
|
|
|
|
|
+ id: it.id ?? it.phenologyId ?? it.name ?? `${it.progress}-${it.progress2}`,
|
|
|
|
|
+ progress: Number(it.progress) || 0, // 起点 %
|
|
|
|
|
+ progress2: Number(it.progress2) || 0, // 终点 %
|
|
|
|
|
+ // 兼容多种可能的开始时间字段
|
|
|
|
|
+ startTimeMs: safeParseDate(
|
|
|
|
|
+ it.startDate || it.beginDate || it.startTime || it.start || it.start_at
|
|
|
|
|
+ ),
|
|
|
|
|
+ reproductiveList: Array.isArray(it.reproductiveList) ? it.reproductiveList : [],
|
|
|
|
|
+ }))
|
|
|
: [];
|
|
: [];
|
|
|
- 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);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // // 测试数据:补充完成/预警案例以展示样式
|
|
|
|
|
+ // if (phenologyList.value.length > 0) {
|
|
|
|
|
+ // const rlist = phenologyList.value[0]?.reproductiveList;
|
|
|
|
|
+ // if (Array.isArray(rlist) && rlist.length > 0) {
|
|
|
|
|
+ // const first = rlist[0];
|
|
|
|
|
+ // if (!Array.isArray(first.farmWorkArrangeList)) first.farmWorkArrangeList = [];
|
|
|
|
|
+ // first.farmWorkArrangeList.push(
|
|
|
|
|
+ // { id: "test-complete", farmWorkName: "测试完成", farmWorkType: 5 },
|
|
|
|
|
+ // { id: "test-warning", farmWorkName: "测试预警", farmWorkType: 6 }
|
|
|
|
|
+ // );
|
|
|
|
|
+ // }
|
|
|
|
|
+ // }
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
.catch((error) => {
|
|
.catch((error) => {
|
|
@@ -233,333 +180,8 @@ const getFarmWorkPlan = () => {
|
|
|
});
|
|
});
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// 测量每一行生育期容器的实际宽度
|
|
|
|
|
-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 isDefaultEnabled = ref(true);
|
|
|
// 新增农事
|
|
// 新增农事
|
|
|
const addNewTask = () => {
|
|
const addNewTask = () => {
|
|
|
router.push({
|
|
router.push({
|
|
@@ -578,66 +200,107 @@ const manageTask = () => {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const detailDialogRef = ref(null);
|
|
const detailDialogRef = ref(null);
|
|
|
|
|
+const timelineContainerRef = ref(null);
|
|
|
|
|
|
|
|
-const handleRowClick = (item) => {
|
|
|
|
|
- if (item.status === "complete") {
|
|
|
|
|
- router.push({
|
|
|
|
|
- path: "/review_work",
|
|
|
|
|
- query: {
|
|
|
|
|
- id: item.id,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- } 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: {
|
|
|
|
|
- id: item.id,
|
|
|
|
|
- status: item.status,
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- // router.push({
|
|
|
|
|
- // path: "/services_agri",
|
|
|
|
|
- // query: {
|
|
|
|
|
- // id: item.id,
|
|
|
|
|
- // status: item.status,
|
|
|
|
|
- // },
|
|
|
|
|
- // });
|
|
|
|
|
|
|
+// 安全解析时间到时间戳(ms)
|
|
|
|
|
+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;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// 行连接器颜色:若后续生育期未开始则灰色,否则保持其颜色(蓝/橙)
|
|
|
|
|
-const getConnectorColorClass = (rowIndex) => {
|
|
|
|
|
- const nextIndex = rowIndex + 1;
|
|
|
|
|
- const bars = getPhenologyBarsForRow(nextIndex);
|
|
|
|
|
- if (!bars || bars.length === 0) return "";
|
|
|
|
|
-
|
|
|
|
|
- const nextIsOddIndex = nextIndex % 2 === 1; // 奇数行为左侧连接器
|
|
|
|
|
|
|
+// 计算节气列表容器高度与项位置
|
|
|
|
|
+const getListStyle = computed(() => {
|
|
|
|
|
+ const total = (solarTerms.value?.length || 0) * 100;
|
|
|
|
|
+ const minH = 50 + total + 50; // 上下各 50
|
|
|
|
|
+ return { minHeight: `${minH}px` };
|
|
|
|
|
+});
|
|
|
|
|
|
|
|
- const parsePercent = (val) => {
|
|
|
|
|
- if (typeof val !== "string") return 0;
|
|
|
|
|
- const n = parseFloat(val.replace("%", ""));
|
|
|
|
|
- return isNaN(n) ? 0 : n;
|
|
|
|
|
|
|
+const getTermStyle = (t) => {
|
|
|
|
|
+ const p = Math.max(0, Math.min(100, Number(t?.progress) || 0));
|
|
|
|
|
+ const total = (solarTerms.value?.length || 0) * 100;
|
|
|
|
|
+ const top = 50 + (p / 100) * total;
|
|
|
|
|
+ return {
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ top: `${top}px`,
|
|
|
|
|
+ left: 0,
|
|
|
|
|
+ transform: "translateY(-50%)",
|
|
|
|
|
+ width: "30px",
|
|
|
|
|
+ height: "20px",
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ alignItems: "center",
|
|
|
};
|
|
};
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
- 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]);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// 点击季节 → 滚动到对应节气(立春/立夏/立秋/立冬)
|
|
|
|
|
+const handleSeasonClick = (seasonValue) => {
|
|
|
|
|
+ activeSeason.value = seasonValue;
|
|
|
|
|
+ const mapping = {
|
|
|
|
|
+ spring: "立春",
|
|
|
|
|
+ summer: "立夏",
|
|
|
|
|
+ autumn: "立秋",
|
|
|
|
|
+ winter: "立冬",
|
|
|
|
|
+ };
|
|
|
|
|
+ const targetName = mapping[seasonValue];
|
|
|
|
|
+ if (!targetName) return;
|
|
|
|
|
+ const target = (solarTerms.value || []).find((t) => (t?.displayName || "") === targetName);
|
|
|
|
|
+ if (!target) return;
|
|
|
|
|
+ const p = Math.max(0, Math.min(100, Number(target.progress) || 0));
|
|
|
|
|
+ const total = (solarTerms.value?.length || 0) * 100;
|
|
|
|
|
+ const targetTop = 50 + (p / 100) * total; // 内容内的像素位置
|
|
|
|
|
+ const wrap = timelineContainerRef.value;
|
|
|
|
|
+ if (!wrap) return;
|
|
|
|
|
+ const viewH = wrap.clientHeight || 0;
|
|
|
|
|
+ const maxScroll = Math.max(0, wrap.scrollHeight - viewH);
|
|
|
|
|
+ // 将目标位置稍微靠上(使用 0.35 视口高度做偏移)
|
|
|
|
|
+ let scrollTop = Math.max(0, targetTop - viewH * 0.1);
|
|
|
|
|
+ if (scrollTop > maxScroll) scrollTop = maxScroll;
|
|
|
|
|
+ wrap.scrollTo({ top: scrollTop, behavior: "smooth" });
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
- // 未来(未开始)时,color 为空串;过去/当前一律显示蓝色
|
|
|
|
|
- const hasStarted = !!target?.color;
|
|
|
|
|
- return hasStarted ? "" : "connector-gray";
|
|
|
|
|
|
|
+// 物候期覆盖条样式(使用像素计算,避免 100% 高度为 0 的问题)
|
|
|
|
|
+const getPhenologyBarStyle = (item) => {
|
|
|
|
|
+ const p1 = Math.max(0, Math.min(100, Number(item?.progress) || 0));
|
|
|
|
|
+ const p2 = Math.max(0, Math.min(100, Number(item?.progress2) || 0));
|
|
|
|
|
+ const start = Math.min(p1, p2);
|
|
|
|
|
+ const end = Math.max(p1, p2);
|
|
|
|
|
+ const total = (solarTerms.value?.length || 0) * 100; // 有效绘制区高度(px)
|
|
|
|
|
+ const topPx = 50 + (start / 100) * total;
|
|
|
|
|
+ const heightPx = Math.max(2, ((end - start) / 100) * total);
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ const isFuture = Number.isFinite(item?.startTimeMs) ? item.startTimeMs > now : start > 0; // 无开始时间时按起点>0近似
|
|
|
|
|
+ // 反转:大于当前时间用灰色,小于等于当前时间用蓝色
|
|
|
|
|
+ const barColor = isFuture ? "rgba(145, 145, 145, 0.1)" : "#2199F8";
|
|
|
|
|
+ const beforeBg = isFuture ? "rgba(145, 145, 145, 0.1)" : "rgba(33, 153, 248, 0.1)";
|
|
|
|
|
+ return {
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ left: "46px",
|
|
|
|
|
+ width: "25px",
|
|
|
|
|
+ top: `${topPx}px`,
|
|
|
|
|
+ height: `${heightPx}px`,
|
|
|
|
|
+ background: barColor,
|
|
|
|
|
+ color: isFuture ? "#747778" : "#fff",
|
|
|
|
|
+ "--bar-before-bg": beforeBg,
|
|
|
|
|
+ zIndex: 2,
|
|
|
|
|
+ };
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// 行连接器是否为灰色(用于切换箭头图片)
|
|
|
|
|
-const isConnectorGray = (rowIndex) => getConnectorColorClass(rowIndex) === "connector-gray";
|
|
|
|
|
|
|
+// 农事状态样式映射(0:默认,1-4:正常,5:完成,6:预警)
|
|
|
|
|
+const getArrangeStatusClass = (fw) => {
|
|
|
|
|
+ const t = fw?.farmWorkType;
|
|
|
|
|
+ if (t == null || t === 0) return "status-default";
|
|
|
|
|
+ if (t >= 1 && t <= 4) return "status-normal";
|
|
|
|
|
+ if (t === 5) return "status-complete";
|
|
|
|
|
+ if (t === 6) return "status-warning";
|
|
|
|
|
+ return "status-default";
|
|
|
|
|
+};
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
<style scoped lang="scss">
|
|
@@ -721,243 +384,149 @@ const isConnectorGray = (rowIndex) => getConnectorColorClass(rowIndex) === "conn
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // 循环时间线样式
|
|
|
|
|
- .cycle-timeline-container {
|
|
|
|
|
- padding: 35px 15px 25px;
|
|
|
|
|
- height: calc(100vh - 135px - 69px - 60px);
|
|
|
|
|
- overflow-y: auto;
|
|
|
|
|
- overflow-x: hidden;
|
|
|
|
|
- .cycle-timeline {
|
|
|
|
|
|
|
+ .timeline-container {
|
|
|
|
|
+ height: calc(100vh - 93px - 40px - 73px);
|
|
|
|
|
+ overflow: auto;
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ padding: 0 12px;
|
|
|
|
|
+ .timeline-list {
|
|
|
position: relative;
|
|
position: relative;
|
|
|
- .cycle-row {
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ .timeline-middle-line {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ left: 15px; /* 位于节气文字列中间(列宽约30px) */
|
|
|
|
|
+ top: 50px;
|
|
|
|
|
+ bottom: 50px;
|
|
|
|
|
+ width: 2px;
|
|
|
|
|
+ background: #e8e8e8;
|
|
|
|
|
+ z-index: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ .phenology-bar {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: stretch;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ &::before {
|
|
|
|
|
+ content: "";
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ left: 25px;
|
|
|
|
|
+ width: calc(100vw - 100px);
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ background: var(--bar-before-bg, rgba(201, 201, 201, 0.1));
|
|
|
|
|
+ z-index: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ .reproductive-list {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-auto-rows: 1fr; /* 子项等高,整体等分父高度 */
|
|
|
|
|
+ align-items: stretch;
|
|
|
|
|
+ justify-items: center; /* 子项居中 */
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ }
|
|
|
|
|
+ .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;
|
|
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: "";
|
|
|
|
|
|
|
+ .arranges {
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
- top: 0;
|
|
|
|
|
- left: 6px;
|
|
|
|
|
- right: 6px;
|
|
|
|
|
- height: 5px;
|
|
|
|
|
- border-left: 2px solid #fff;
|
|
|
|
|
- border-right: 2px solid #fff;
|
|
|
|
|
- background: #e8e8e8;
|
|
|
|
|
|
|
+ left: 48px; /* 列与中线右侧一段距离 */
|
|
|
|
|
+ top: 50%;
|
|
|
transform: translateY(-50%);
|
|
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;
|
|
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 {
|
|
|
|
|
|
|
+ .arrange-box {
|
|
|
|
|
+ width: 36px;
|
|
|
|
|
+ height: 36px;
|
|
|
|
|
+ border: 1px solid rgba(199, 199, 199, 0.6);
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ color: #a5a7a9;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
- gap: 8px;
|
|
|
|
|
- padding-top: 26px;
|
|
|
|
|
|
|
+ align-items: center;
|
|
|
justify-content: center;
|
|
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;
|
|
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ .arrange-text {
|
|
|
|
|
+ writing-mode: horizontal-tb;
|
|
|
|
|
+ line-height: 14px;
|
|
|
text-align: center;
|
|
text-align: center;
|
|
|
- position: relative;
|
|
|
|
|
- color: #c7c7c7;
|
|
|
|
|
- .status-icon {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- bottom: -10px;
|
|
|
|
|
- right: -10px;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ padding-left: 3px;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- .cycle-task-connector {
|
|
|
|
|
|
|
+ .status-icon {
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
- top: -4px;
|
|
|
|
|
- left: 50%;
|
|
|
|
|
- transform: translateX(-50%);
|
|
|
|
|
|
|
+ right: -10px;
|
|
|
|
|
+ bottom: -10px;
|
|
|
|
|
+ z-index: 3;
|
|
|
|
|
+ }
|
|
|
|
|
+ &::before {
|
|
|
|
|
+ content: "";
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ left: -4px;
|
|
|
|
|
+ top: 50%;
|
|
|
|
|
+ transform: translateY(-50%);
|
|
|
width: 0;
|
|
width: 0;
|
|
|
height: 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;
|
|
|
|
|
|
|
+ border-top: 4px solid transparent;
|
|
|
|
|
+ border-bottom: 4px solid transparent;
|
|
|
|
|
+ border-right: 4px solid currentColor; /* 与文字/边框颜色一致 */
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- .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;
|
|
|
|
|
|
|
+ .arrange-box + .arrange-box {
|
|
|
|
|
+ margin-right: 16px;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- &.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;
|
|
|
|
|
|
|
+ .arrange-box.status-warning {
|
|
|
|
|
+ border-color: #ff953d;
|
|
|
|
|
+ color: #ff953d;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // 动态颜色
|
|
|
|
|
- &.connector-gray {
|
|
|
|
|
- border-color: #c4c6c9;
|
|
|
|
|
|
|
+ .arrange-box.status-complete {
|
|
|
|
|
+ border-color: #1ca900;
|
|
|
|
|
+ color: #1ca900;
|
|
|
}
|
|
}
|
|
|
- &.connector-gray.middle-connector {
|
|
|
|
|
- border-left-color: #c4c6c9;
|
|
|
|
|
|
|
+ .arrange-box.status-normal {
|
|
|
|
|
+ border-color: #2199f8;
|
|
|
|
|
+ color: #2199f8;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ .reproductive-item + .reproductive-item {
|
|
|
|
|
+ border-top: 2px solid #fff;
|
|
|
|
|
+ }
|
|
|
|
|
+ .phenology-bar + .phenology-bar {
|
|
|
|
|
+ border-top: 2px solid #fff;
|
|
|
|
|
+ }
|
|
|
|
|
+ .timeline-term {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ width: 30px;
|
|
|
|
|
+ padding-right: 16px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ z-index: 2; /* 置于中线之上 */
|
|
|
|
|
+ .term-name {
|
|
|
|
|
+ display: inline-block;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 46px;
|
|
|
|
|
+ line-height: 30px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ word-break: break-all;
|
|
|
|
|
+ writing-mode: vertical-rl;
|
|
|
|
|
+ text-orientation: upright;
|
|
|
|
|
+ color: rgba(174, 174, 174, 0.6);
|
|
|
|
|
+ letter-spacing: 2px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 控制区域样式
|
|
// 控制区域样式
|