fix: 首頁室內圖表 tabs 切換改為快取優先機制
This commit is contained in:
parent
ffdf13d156
commit
13d14d0bb6
@ -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 instance(markRaw 避免被 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>
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user