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> <script setup>
import * as echarts from "echarts"; import * as echarts from "echarts";
import { onMounted, ref, markRaw } from "vue"; import { onMounted, onUnmounted, ref, markRaw } from "vue";
const props = defineProps({ const props = defineProps({
option: Object, /** 初始 option僅在 init 時套一次;之後請用 ref.chart.setOption 更新) */
class: String, option: { type: Object, default: () => ({}) },
id: String, /** 外層 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); const dom = ref(null);
let dom = ref(null); /** 暴露給父層的 ECharts instancemarkRaw 避免被 Vue 追蹤) */
const chart = ref(null);
let resizeObs = null;
let inited = false;
function init() { function init() {
let echart = echarts; if (inited || !dom.value) return;
chart.value = markRaw(echart.init(dom.value)); // init
chart.value.setOption(props.option); 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(() => { onMounted(() => {
if (!chart.value && dom.value) { if (!inited && dom.value) init();
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({ defineExpose({
chart, chart, // chart.setOption(...)
setOption, //
resize, // resize
}); });
</script> </script>
<template> <template>
<div :id="id" :class="class" ref="dom"></div> <!-- 建議外層給明確高度否則 ECharts 無法正確量到容器尺寸 -->
<div :id="id" :class="class" ref="dom" style="width: 100%; height: 100%"></div>
</template> </template>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

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