fix: 首頁室內圖表 tabs 切換改為快取優先機制

This commit is contained in:
MJM_2025_05\polly 2025-10-09 09:08:24 +08:00
parent ffdf13d156
commit 13d14d0bb6
2 changed files with 275 additions and 138 deletions

View File

@ -1,34 +1,109 @@
<script setup>
import * as echarts from "echarts";
import { onMounted, ref, markRaw } from "vue";
import { onMounted, onUnmounted, ref, markRaw } from "vue";
const props = defineProps({
option: Object,
class: String,
id: String,
/** 初始 option僅在 init 時套一次;之後請用 ref.chart.setOption 更新) */
option: { type: Object, default: () => ({}) },
/** 外層 className保持相容 */
class: { type: String, default: "" },
/** 容器 id非必要但你現有父層有用 */
id: { type: String, default: "" },
/** 是否自動監聽容器尺寸改變而 resize */
autoresize: { type: Boolean, default: true },
/** 後續 setOption 預設的 notMerge/lazyUpdate父層也可自行傳 */
defaultNotMerge: { type: Boolean, default: false },
defaultLazyUpdate: { type: Boolean, default: true },
});
let chart = ref(null);
let dom = ref(null);
const dom = ref(null);
/** 暴露給父層的 ECharts instancemarkRaw 避免被 Vue 追蹤) */
const chart = ref(null);
let resizeObs = null;
let inited = false;
function init() {
let echart = echarts;
chart.value = markRaw(echart.init(dom.value));
chart.value.setOption(props.option);
if (inited || !dom.value) return;
// init
const instance = echarts.init(dom.value, undefined, {
renderer: "canvas",
// devicePixelRatio: window.devicePixelRatio || 1, //
});
chart.value = markRaw(instance);
// props.option ref.chart.setOption()
if (props.option && Object.keys(props.option).length) {
chart.value.setOption(props.option, props.defaultNotMerge, props.defaultLazyUpdate);
}
// resize display
if (props.autoresize) {
resizeObs = new ResizeObserver(() => {
// tab 0
// requestAnimationFrame frame
requestAnimationFrame(() => {
if (!chart.value) return;
try {
chart.value.resize({ animation: { duration: 0 } });
} catch {}
});
});
resizeObs.observe(dom.value);
}
// resize
document.addEventListener("visibilitychange", handleVisibility, false);
inited = true;
}
function handleVisibility() {
if (document.visibilityState === "visible" && chart.value) {
try {
chart.value.resize({ animation: { duration: 0 } });
} catch {}
}
}
/** 提供一個包裝讓父層可用 ref 調用(可選) */
function setOption(option, notMerge = props.defaultNotMerge, lazyUpdate = props.defaultLazyUpdate) {
if (!chart.value) return;
chart.value.setOption(option, notMerge, lazyUpdate);
}
function resize() {
if (!chart.value) return;
chart.value.resize();
}
onMounted(() => {
if (!chart.value && dom.value) {
init();
if (!inited && dom.value) init();
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleVisibility, false);
if (resizeObs && dom.value) {
try { resizeObs.unobserve(dom.value); } catch {}
}
resizeObs = null;
if (chart.value) {
try { chart.value.dispose(); } catch {}
}
chart.value = null;
inited = false;
});
defineExpose({
chart,
chart, // chart.setOption(...)
setOption, //
resize, // resize
});
</script>
<template>
<div :id="id" :class="class" ref="dom"></div>
<!-- 建議外層給明確高度否則 ECharts 無法正確量到容器尺寸 -->
<div :id="id" :class="class" ref="dom" style="width: 100%; height: 100%"></div>
</template>
<style lang="scss" scoped></style>

View File

@ -1,60 +1,105 @@
<script setup>
// Tab + + + setOption
// 1)
// 2) ECharts update
// 3) 30 20
// 4) Tab jank
import LineChart from "@/components/chart/LineChart.vue";
import { SECOND_CHART_COLOR } from "@/constant";
import dayjs from "dayjs";
import { ref, watch, onUnmounted, computed } from "vue";
import { ref, watch, onUnmounted, computed, nextTick } from "vue";
import useActiveBtn from "@/hooks/useActiveBtn";
import { getDashboardTemp } from "@/apis/dashboard";
import useSearchParams from "@/hooks/useSearchParam";
import useBuildingStore from "@/stores/useBuildingStore";
import { useI18n } from "vue-i18n";
const { t, locale } = useI18n();
const { searchParams } = useSearchParams();
const buildingStore = useBuildingStore();
const timeoutTimer = ref(null); //
// --- UI/ ---
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
const currentOptionType = ref(1); // 1 = 2 =
// --- ---
const allTempData = ref([]);
const currentOptionType = ref(1); // 1 = 2 =
const noData = ref(false); //
const noData = ref(false);
const indoorChartRef = ref(null);
// getDashboardTemp chart
watch(
() => buildingStore.selectedBuilding?.building_guid,
async (guid) => {
if (!guid) return;
// --- ---
const POLL_MS = 60000; // 60
const timeoutTimer = ref(null);
let lastAcceptedSeq = 0; //
let seqCounter = 0; //
await buildingStore.getSysConfig(guid);
const showRoom = buildingStore.sysConfig?.value?.show_room;
function clearPolling() {
if (timeoutTimer.value) {
clearInterval(timeoutTimer.value);
timeoutTimer.value = null;
}
}
function startPolling() {
if (timeoutTimer.value) return; //
timeoutTimer.value = setInterval(() => safeGetData(false), POLL_MS);
}
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
// --- + ---
const cache = new Map();
const cacheKey = (guid, opt) => `${guid}__${opt}`;
function writeCache(guid, opt, data) {
cache.set(cacheKey(guid, opt), data);
}
function readCache(guid, opt) {
return cache.get(cacheKey(guid, opt));
}
if (showRoom === false) {
noData.value = true;
return; // getData
}
//
async function safeGetData(force = true) {
const buildingGuid = buildingStore.selectedBuilding?.building_guid;
if (!buildingGuid) return;
//
noData.value = false;
getData();
timeoutTimer.value = setInterval(getData, 60000); //
},
{ immediate: true }
);
const mySeq = ++seqCounter; //
//
try {
const res = await getDashboardTemp({
building_guid: buildingGuid,
tempOption: 1, //
timeInterval: 1,
option: currentOptionType.value,
});
if (mySeq < lastAcceptedSeq) return; //
lastAcceptedSeq = mySeq;
const key = "室溫";
const data = res?.isSuccess ? res.data?.[key] ?? [] : [];
allTempData.value = data;
noData.value = data.length === 0;
writeCache(buildingGuid, currentOptionType.value, data);
} catch (err) {
if (mySeq < lastAcceptedSeq) return; //
console.error("getDashboardTemp error", err);
allTempData.value = [];
noData.value = true;
}
}
//
const defaultChartOption = ref({
animation: false,
animationDuration: 0,
animationDurationUpdate: 150,
animationEasingUpdate: "linear",
tooltip: { trigger: "axis" },
legend: {
data: [],
top: 0, //
top: 0,
textStyle: { color: "#ffffff", fontSize: 12 },
},
grid: {
top: "35%",
top: "35%",
left: "0%",
right: "0%",
bottom: "0%",
@ -74,129 +119,143 @@ const defaultChartOption = ref({
series: [],
});
const getData = async () => {
const buildingGuid = buildingStore.selectedBuilding?.building_guid;
if (!buildingGuid) return;
try {
const res = await getDashboardTemp({
building_guid: buildingGuid,
tempOption: 1, //
timeInterval: 1,
option: currentOptionType.value,
});
const key = "室溫";
allTempData.value = res.isSuccess ? res.data?.[key] ?? [] : [];
noData.value = allTempData.value.length === 0;
} catch (e) {
console.error("getDashboardTemp error", e);
allTempData.value = [];
noData.value = true;
// 20
function sampleData(data = [], maxCount = 20) {
const len = data.length;
if (len <= maxCount) return data;
const sampled = [];
const step = (len - 1) / (maxCount - 1);
for (let i = 0; i < maxCount; i++) {
const index = Math.round(i * step);
sampled.push(data[index]);
}
};
return sampled;
}
//
//
const buttonItems = computed(() => [
{ key: 1, title: t("dashboard.temperature"), active: true },
{ key: 2, title: t("dashboard.humidity"), active: false },
]);
//
watch(
() => locale.value,
() => setItems(buttonItems.value),
{ immediate: true }
);
//
// /
// /
// 1)
// 2)
watch(
selectedBtn,
(newVal) => {
if ([1, 2].includes(newVal?.key)) {
currentOptionType.value = newVal.key;
() => selectedBtn.value?.key,
async (key) => {
if (!(key === 1 || key === 2)) return;
currentOptionType.value = key;
// show_room true
if (buildingStore.sysConfig?.value?.show_room) {
getData();
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
timeoutTimer.value = setInterval(getData, 60000);
}
const guid = buildingStore.selectedBuilding?.building_guid;
if (!guid || buildingStore.sysConfig?.value?.show_room === false) return;
const cached = readCache(guid, key);
if (cached) {
allTempData.value = cached;
noData.value = cached.length === 0;
// nextTick DOM/Chart setOption
await nextTick();
applyToChart(cached);
}
//
safeGetData(false);
// timer
},
{ immediate: true, deep: true }
{ immediate: true }
);
//
function sampleData(data = [], maxCount = 30) {
const len = data.length;
if (len <= maxCount) return data;
//
watch(
() => buildingStore.selectedBuilding?.building_guid,
async (guid) => {
if (!guid) return;
const sampled = [];
const step = (len - 1) / (maxCount - 1);
clearPolling();
lastAcceptedSeq = 0;
seqCounter = 0;
for (let i = 0; i < maxCount; i++) {
const index = Math.round(i * step);
sampled.push(data[index]);
}
await buildingStore.getSysConfig(guid);
const showRoom = buildingStore.sysConfig?.value?.show_room;
return sampled;
if (showRoom === false) {
noData.value = true;
return; //
}
noData.value = false;
const cached = readCache(guid, currentOptionType.value);
if (cached) {
allTempData.value = cached;
noData.value = cached.length === 0;
await nextTick();
applyToChart(cached);
}
await safeGetData();
startPolling();
},
{ immediate: true }
);
// setOption
function applyToChart(newVal) {
const chart = indoorChartRef.value?.chart;
if (!chart || !newVal?.length) return;
const firstValid = newVal.find((d) => d.data?.length);
if (!firstValid) return;
const sampledXAxis = sampleData(firstValid.data).map(({ time }) =>
dayjs(time).format("HH:mm:ss")
);
const series = newVal.map((d, i) => ({
name: d.full_name,
type: "line",
data: sampleData(d.data).map(({ value }) => value),
showSymbol: false,
//
animation: false,
itemStyle: { color: SECOND_CHART_COLOR[i % SECOND_CHART_COLOR.length] },
}));
const vals = series.flatMap((s) => s.data).filter((v) => v != null);
if (!vals.length) return;
chart.setOption(
{
animation: false,
animationDurationUpdate: 150,
animationEasingUpdate: "linear",
legend: { data: newVal.map((d) => d.full_name) },
xAxis: { data: sampledXAxis },
yAxis: {
min: Math.floor(Math.min(...vals)) - 1,
max: Math.ceil(Math.max(...vals)) + 1,
},
series,
},
false,
true
);
}
//
watch(
allTempData,
(newVal) => {
if (!newVal?.length || !indoorChartRef.value?.chart) return;
// deep
watch(allTempData, (newVal) => {
applyToChart(newVal);
});
const firstValid = newVal.find((d) => d.data?.length);
if (!firstValid) return;
const sampledXAxis = sampleData(firstValid.data).map(({ time }) =>
dayjs(time).format("HH:mm:ss")
);
const allValues = newVal
.flatMap((d) => sampleData(d.data))
.map((d) => d.value)
.filter((v) => v != null);
if (!allValues.length) return;
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const yMin = Math.floor(minVal) - 1;
const yMax = Math.ceil(maxVal) + 1;
indoorChartRef.value.chart.setOption({
legend: {
data: newVal.map((d) => d.full_name),
},
xAxis: {
data: sampledXAxis,
},
yAxis: {
min: yMin,
max: yMax,
},
series: newVal.map((d, i) => ({
name: d.full_name,
type: "line",
data: sampleData(d.data).map(({ value }) => value),
showSymbol: false,
itemStyle: {
color: SECOND_CHART_COLOR[i % SECOND_CHART_COLOR.length],
},
})),
});
},
{ deep: true }
);
//
onUnmounted(() => {
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
clearPolling();
});
</script>
@ -204,18 +263,21 @@ onUnmounted(() => {
<h3 class="text-info text-xl text-center">
{{ $t("dashboard.indoor_chart") }}
</h3>
<div class="w-full flex justify-center items-center relative">
<ButtonConnectedGroup
:items="items"
:onclick="(e, item) => changeActiveBtn(item)"
/>
</div>
<div
v-if="noData"
class="text-center text-white text-lg min-h-[260px] flex items-center justify-center"
>
{{ $t("dashboard.no_data") }}
</div>
<LineChart
v-if="!noData"
id="dashboard_other_real_temp"