fix: 首頁圖表修正及MQTT串接
This commit is contained in:
parent
2e15353384
commit
d822b3074a
@ -2,4 +2,8 @@ export const GET_SYSTEM_FLOOR_LIST_API = `/api/Device/GetFloor`;
|
|||||||
export const GET_SYSTEM_DEVICE_LIST_API = `/api/Device/GetDeviceList`;
|
export const GET_SYSTEM_DEVICE_LIST_API = `/api/Device/GetDeviceList`;
|
||||||
export const GET_SYSTEM_REALTIME_API = `/api/Device/GetRealTimeData`;
|
export const GET_SYSTEM_REALTIME_API = `/api/Device/GetRealTimeData`;
|
||||||
|
|
||||||
export const GET_SYSTEM_CONFIG_API = `/api/GetSystemConfig`;
|
export const GET_SYSTEM_CONFIG_API = `/api/GetSystemConfig`;
|
||||||
|
|
||||||
|
export const POST_MQTT_TOPIC_API = `api/Device/MQTTTopicTest`;
|
||||||
|
export const POST_MQTT_TOPIC_STOP_API = `api/Device/MQTTTopicTestStop`;
|
||||||
|
|
||||||
|
@ -2,7 +2,9 @@ import {
|
|||||||
GET_SYSTEM_FLOOR_LIST_API,
|
GET_SYSTEM_FLOOR_LIST_API,
|
||||||
GET_SYSTEM_DEVICE_LIST_API,
|
GET_SYSTEM_DEVICE_LIST_API,
|
||||||
GET_SYSTEM_REALTIME_API,
|
GET_SYSTEM_REALTIME_API,
|
||||||
GET_SYSTEM_CONFIG_API
|
GET_SYSTEM_CONFIG_API,
|
||||||
|
POST_MQTT_TOPIC_API,
|
||||||
|
POST_MQTT_TOPIC_STOP_API,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import instance from "@/util/request";
|
import instance from "@/util/request";
|
||||||
import apihandler from "@/util/apihandler";
|
import apihandler from "@/util/apihandler";
|
||||||
@ -45,3 +47,21 @@ export const getSystemConfig = async (building_guid) => {
|
|||||||
code: res.code,
|
code: res.code,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const postMqttTopic = async ({ iotTag, Topic }) => {
|
||||||
|
const res = await instance.post(POST_MQTT_TOPIC_API, { iotTag, Topic });
|
||||||
|
|
||||||
|
return apihandler(res.code, res.data, {
|
||||||
|
msg: res.msg,
|
||||||
|
code: res.code,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postMqttTopicStop = async ({ iotTag, Topic }) => {
|
||||||
|
const res = await instance.post(POST_MQTT_TOPIC_STOP_API, { iotTag, Topic });
|
||||||
|
|
||||||
|
return apihandler(res.code, res.data, {
|
||||||
|
msg: res.msg,
|
||||||
|
code: res.code,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
@ -48,9 +48,10 @@
|
|||||||
"this_year": "今年",
|
"this_year": "今年",
|
||||||
"last_year": "去年",
|
"last_year": "去年",
|
||||||
"refrig_chart": "冷藏趨勢",
|
"refrig_chart": "冷藏趨勢",
|
||||||
"indoor_chart": "室內趨勢",
|
"indoor_chart": "室內",
|
||||||
"temperature": "温度",
|
"temperature": "温度",
|
||||||
"humidity": "湿度",
|
"humidity": "湿度",
|
||||||
|
"no_data":"无数据",
|
||||||
"alerts_data": "异常资料"
|
"alerts_data": "异常资料"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
@ -47,10 +47,11 @@
|
|||||||
"last_month": "上月",
|
"last_month": "上月",
|
||||||
"this_year": "今年",
|
"this_year": "今年",
|
||||||
"last_year": "去年",
|
"last_year": "去年",
|
||||||
"refrig_chart": "冷藏趨勢",
|
"refrig_chart": "冷藏",
|
||||||
"indoor_chart": "室內趨勢",
|
"indoor_chart": "室內",
|
||||||
"temperature": "溫度",
|
"temperature": "溫度",
|
||||||
"humidity": "濕度",
|
"humidity": "濕度",
|
||||||
|
"no_data": "無資料",
|
||||||
"alerts_data": "異常資料"
|
"alerts_data": "異常資料"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
@ -47,10 +47,11 @@
|
|||||||
"last_month": "Last month",
|
"last_month": "Last month",
|
||||||
"this_year": "This year",
|
"this_year": "This year",
|
||||||
"last_year": "Last year",
|
"last_year": "Last year",
|
||||||
"refrig_chart": "Refrigeration chart",
|
"refrig_chart": "Refrigeration",
|
||||||
"indoor_chart": "Indoor chart",
|
"indoor_chart": "Indoor",
|
||||||
"temperature": "Temp.",
|
"temperature": "Temp.",
|
||||||
"humidity": "Hum.",
|
"humidity": "Hum.",
|
||||||
|
"no_data":"No data",
|
||||||
"alerts_data": "Abnormal data"
|
"alerts_data": "Abnormal data"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
|
@ -12,13 +12,24 @@ const { searchParams, changeParams } = useSearchParam();
|
|||||||
const companyOptions = ref([]);
|
const companyOptions = ref([]);
|
||||||
const iotSchemaOptions = ref([]);
|
const iotSchemaOptions = ref([]);
|
||||||
const elecTypeOptions = ref([]);
|
const elecTypeOptions = ref([]);
|
||||||
|
|
||||||
|
const iotSchemaTag = ref("");
|
||||||
const getCompany = async () => {
|
const getCompany = async () => {
|
||||||
const res = await getOperationCompanyList();
|
const res = await getOperationCompanyList();
|
||||||
companyOptions.value = res.data.map((d) => ({ ...d, key: d.id }));
|
companyOptions.value = res.data.map((d) => ({ ...d, key: d.id }));
|
||||||
};
|
};
|
||||||
const getIOTSchemaOptions = async (id) => {
|
const getIOTSchemaOptions = async (id) => {
|
||||||
const res = await getIOTSchema(Number(id));
|
const res = await getIOTSchema(Number(id));
|
||||||
iotSchemaOptions.value = res.data.map((d) => ({ ...d, key: d.id }));
|
const data = res.data || [];
|
||||||
|
|
||||||
|
iotSchemaOptions.value = data.map((d) => ({ ...d, key: d.id }));
|
||||||
|
|
||||||
|
// 取出第一筆的 tagIoT,提供給最深層元件使用
|
||||||
|
if (data.length > 0 && data[0].tagIoT) {
|
||||||
|
iotSchemaTag.value = data[0].tagIoT;
|
||||||
|
} else {
|
||||||
|
iotSchemaTag.value = "";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const getElecType = async () => {
|
const getElecType = async () => {
|
||||||
const res = await getElecTypeList();
|
const res = await getElecTypeList();
|
||||||
@ -52,6 +63,7 @@ provide("asset_modal_options", {
|
|||||||
departmentList,
|
departmentList,
|
||||||
floors,
|
floors,
|
||||||
});
|
});
|
||||||
|
provide("iotSchemaTag", iotSchemaTag);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -121,15 +121,15 @@ const closeModal = () => {
|
|||||||
width="1600"
|
width="1600"
|
||||||
>
|
>
|
||||||
<template #modalContent>
|
<template #modalContent>
|
||||||
<form ref="form" class="grid grid-cols-5 gap-5">
|
<form ref="form" class="grid grid-cols-4 gap-5">
|
||||||
<div class="col-span-5 lg:col-span-2">
|
<div class="col-span-4 lg:col-span-2">
|
||||||
<div class="lg:grid lg:grid-cols-2 items-end">
|
<div class="lg:grid lg:grid-cols-2 items-end gap-2">
|
||||||
<AssetTableModalLeft
|
<AssetTableModalLeft
|
||||||
:current_component_key="current_component_key"
|
:current_component_key="current_component_key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-5 lg:col-span-3">
|
<div class="col-span-4 lg:col-span-2">
|
||||||
<AssetTableModalRight
|
<AssetTableModalRight
|
||||||
:current_component_key="current_component_key"
|
:current_component_key="current_component_key"
|
||||||
/>
|
/>
|
||||||
|
@ -15,7 +15,8 @@ const { searchParams, changeParams } = useSearchParam();
|
|||||||
const { updateLeftFields, formErrorMsg, formState } = inject(
|
const { updateLeftFields, formErrorMsg, formState } = inject(
|
||||||
"asset_table_modal_form"
|
"asset_table_modal_form"
|
||||||
);
|
);
|
||||||
const { companyOptions, iotSchemaOptions, elecTypeOptions, departmentList } = inject("asset_modal_options");
|
const { companyOptions, iotSchemaOptions, elecTypeOptions, departmentList } =
|
||||||
|
inject("asset_modal_options");
|
||||||
const store = useUserInfoStore();
|
const store = useUserInfoStore();
|
||||||
let schema = {
|
let schema = {
|
||||||
full_name: yup.string().nullable(true),
|
full_name: yup.string().nullable(true),
|
||||||
@ -100,13 +101,13 @@ watch(
|
|||||||
formState.value.response_schema_id = newVal[0].id;
|
formState.value.response_schema_id = newVal[0].id;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- information -->
|
<!-- information -->
|
||||||
<Input :value="formState" width="290" name="full_name">
|
<Input :value="formState" class="min-w-[180px] w-full" name="full_name">
|
||||||
<template #topLeft>{{ $t("assetManagement.device_name") }}</template>
|
<template #topLeft>{{ $t("assetManagement.device_name") }}</template>
|
||||||
<template #bottomLeft
|
<template #bottomLeft
|
||||||
><span class="text-error text-base">
|
><span class="text-error text-base">
|
||||||
@ -114,9 +115,10 @@ watch(
|
|||||||
</span></template
|
</span></template
|
||||||
></Input
|
></Input
|
||||||
>
|
>
|
||||||
<div class="flex items-center w-72">
|
<div class="flex items-center w-full">
|
||||||
<Select
|
<Select
|
||||||
:value="formState"
|
:value="formState"
|
||||||
|
class="min-w-[180px] w-full"
|
||||||
selectClass="border-info focus-within:border-info"
|
selectClass="border-info focus-within:border-info"
|
||||||
name="department_id"
|
name="department_id"
|
||||||
Attribute="name"
|
Attribute="name"
|
||||||
@ -127,7 +129,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
:value="formState"
|
:value="formState"
|
||||||
width="290"
|
class="min-w-[180px] w-full"
|
||||||
name="device_number"
|
name="device_number"
|
||||||
v-if="store.user.user_name == 'webUser'"
|
v-if="store.user.user_name == 'webUser'"
|
||||||
>
|
>
|
||||||
@ -140,9 +142,10 @@ watch(
|
|||||||
</span></template
|
</span></template
|
||||||
></Input
|
></Input
|
||||||
>
|
>
|
||||||
<div class="flex items-center w-72">
|
<div class="flex items-center w-full">
|
||||||
<Select
|
<Select
|
||||||
:value="formState"
|
:value="formState"
|
||||||
|
class="min-w-[180px] w-full"
|
||||||
selectClass="border-info focus-within:border-info"
|
selectClass="border-info focus-within:border-info"
|
||||||
name="response_schema_id"
|
name="response_schema_id"
|
||||||
Attribute="name"
|
Attribute="name"
|
||||||
@ -152,19 +155,22 @@ watch(
|
|||||||
<template #topLeft>IoT</template>
|
<template #topLeft>IoT</template>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center w-72" v-if="searchParams.mainSys_id==26">
|
<div class="flex items-center w-full" v-if="searchParams.mainSys_id == 26">
|
||||||
<Select
|
<Select
|
||||||
:value="formState"
|
:value="formState"
|
||||||
|
class="min-w-[180px] w-full"
|
||||||
selectClass="border-info focus-within:border-info"
|
selectClass="border-info focus-within:border-info"
|
||||||
name="elec_type_id"
|
name="elec_type_id"
|
||||||
Attribute="name"
|
Attribute="name"
|
||||||
:options="elecTypeOptions"
|
:options="elecTypeOptions"
|
||||||
:required="true"
|
:required="true"
|
||||||
>
|
>
|
||||||
<template #topLeft>{{$t("energy.electricity_classification")}}</template>
|
<template #topLeft>{{
|
||||||
|
$t("energy.electricity_classification")
|
||||||
|
}}</template>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Input :value="formState" width="290" name="asset_number">
|
<Input :value="formState" class="min-w-[180px] w-full" name="asset_number">
|
||||||
<template #topLeft>{{ $t("assetManagement.asset_number") }}</template>
|
<template #topLeft>{{ $t("assetManagement.asset_number") }}</template>
|
||||||
<template #bottomLeft
|
<template #bottomLeft
|
||||||
><span class="text-error text-base">
|
><span class="text-error text-base">
|
||||||
@ -172,7 +178,11 @@ watch(
|
|||||||
</span></template
|
</span></template
|
||||||
></Input
|
></Input
|
||||||
>
|
>
|
||||||
<DateGroup :items="buying_date" width="290" :withLine="false">
|
<DateGroup
|
||||||
|
:items="buying_date"
|
||||||
|
class="min-w-[180px] w-full"
|
||||||
|
:withLine="false"
|
||||||
|
>
|
||||||
<template #topLeft>{{ $t("assetManagement.buying_date") }}</template>
|
<template #topLeft>{{ $t("assetManagement.buying_date") }}</template>
|
||||||
<template #bottomLeft
|
<template #bottomLeft
|
||||||
><span class="text-error text-base">
|
><span class="text-error text-base">
|
||||||
@ -180,7 +190,7 @@ watch(
|
|||||||
</span></template
|
</span></template
|
||||||
>
|
>
|
||||||
</DateGroup>
|
</DateGroup>
|
||||||
<Input :value="formState" width="290" name="brand">
|
<Input :value="formState" class="min-w-[180px] w-full" name="brand">
|
||||||
<template #topLeft>{{ $t("assetManagement.brand") }}</template>
|
<template #topLeft>{{ $t("assetManagement.brand") }}</template>
|
||||||
<template #bottomLeft
|
<template #bottomLeft
|
||||||
><span class="text-error text-base">
|
><span class="text-error text-base">
|
||||||
@ -188,7 +198,7 @@ watch(
|
|||||||
</span></template
|
</span></template
|
||||||
></Input
|
></Input
|
||||||
>
|
>
|
||||||
<Input :value="formState" width="290" name="device_model">
|
<Input :value="formState" class="min-w-[180px] w-full" name="device_model">
|
||||||
<template #topLeft>{{ $t("assetManagement.modal") }}</template>
|
<template #topLeft>{{ $t("assetManagement.modal") }}</template>
|
||||||
<template #bottomLeft
|
<template #bottomLeft
|
||||||
><span class="text-error text-base">
|
><span class="text-error text-base">
|
||||||
@ -196,9 +206,10 @@ watch(
|
|||||||
</span></template
|
</span></template
|
||||||
></Input
|
></Input
|
||||||
>
|
>
|
||||||
<div class="flex items-center w-72">
|
<div class="flex items-center w-full">
|
||||||
<Select
|
<Select
|
||||||
:value="formState"
|
:value="formState"
|
||||||
|
class="min-w-[180px] w-full"
|
||||||
selectClass="border-info focus-within:border-info"
|
selectClass="border-info focus-within:border-info"
|
||||||
name="operation_id"
|
name="operation_id"
|
||||||
Attribute="name"
|
Attribute="name"
|
||||||
|
@ -3,23 +3,79 @@ import { onMounted, ref, inject, watch, computed } from "vue";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import mqtt from "mqtt";
|
import mqtt from "mqtt";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { postMqttTopic, postMqttTopicStop } from "@/apis/system";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { openToast, cancelToastOpen } = inject("app_toast");
|
const { openToast, cancelToastOpen } = inject("app_toast");
|
||||||
const { formState } = inject("asset_table_modal_form");
|
const { formState } = inject("asset_table_modal_form");
|
||||||
const BASEURL = import.meta.env.VITE_MQTT_BASEURL;
|
const BASEURL = import.meta.env.VITE_MQTT_BASEURL;
|
||||||
|
const iotSchemaTag = inject("iotSchemaTag");
|
||||||
|
|
||||||
// MQTT相關
|
// MQTT相關
|
||||||
const mqttClient = ref(null); // MQTT客戶端
|
const mqttClient = ref(null); // MQTT客戶端
|
||||||
const receivedMessages = ref([]); // 儲存接收到的訊息
|
const receivedMessages = ref([]); // 儲存接收到的訊息
|
||||||
const countdown = ref(60); // 倒計時初始為 60 秒
|
const countdown = ref(60); // 倒計時初始為 60 秒
|
||||||
|
const hasStartedCountdown = ref(false); // 是否已開始倒數
|
||||||
let timer = null; // 記錄計時器
|
let timer = null; // 記錄計時器
|
||||||
|
let mqttInterval = null;
|
||||||
|
const mqttCardDataList = ref([]); // 顯示在畫面上的卡片資料
|
||||||
|
|
||||||
const openModal = () => {
|
const openModal = async () => {
|
||||||
if (!mqttClient.value) {
|
if (!mqttClient.value) {
|
||||||
connectMqtt();
|
connectMqtt();
|
||||||
}
|
}
|
||||||
mqtt_test.showModal();
|
mqtt_test.showModal();
|
||||||
startCountdown(); // 開始倒計時
|
|
||||||
|
// 先立即呼叫一次
|
||||||
|
try {
|
||||||
|
await postMqttTopic({
|
||||||
|
iotTag: iotSchemaTag?.value,
|
||||||
|
Topic: formState.value.topic,
|
||||||
|
});
|
||||||
|
// console.log("首次 postMqttTopic API 已呼叫");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("首次 postMqttTopic 發送失敗", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 啟動每 5 秒重複呼叫
|
||||||
|
mqttInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await postMqttTopic({
|
||||||
|
iotTag: iotSchemaTag?.value,
|
||||||
|
Topic: formState.value.topic,
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log("postMqttTopic 回傳:", res?.data);
|
||||||
|
|
||||||
|
const payload = res?.data;
|
||||||
|
|
||||||
|
// 確保 payload 結構正確
|
||||||
|
if (payload && payload.data && payload.time) {
|
||||||
|
const timeAlreadyExists = mqttCardDataList.value.some(
|
||||||
|
(item) => item.time === payload.time
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!timeAlreadyExists) {
|
||||||
|
mqttCardDataList.value.unshift({
|
||||||
|
...payload.data,
|
||||||
|
time: payload.time,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ⬇第一次收到資料才開始倒數
|
||||||
|
if (!hasStartedCountdown.value) {
|
||||||
|
hasStartedCountdown.value = true;
|
||||||
|
startCountdown();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// console.log("已存在相同時間略過:", payload.time);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// console.warn("回傳資料結構錯誤或缺少 time");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("postMqttTopic 呼叫失敗:", error);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectMqtt = () => {
|
const connectMqtt = () => {
|
||||||
@ -35,13 +91,13 @@ const connectMqtt = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mqttClient.value.on("connect", () => {
|
mqttClient.value.on("connect", () => {
|
||||||
console.log("MQTT 已連接");
|
// console.log("MQTT 已連接");
|
||||||
if (topic) {
|
if (topic) {
|
||||||
mqttClient.value.subscribe(topic, (err) => {
|
mqttClient.value.subscribe(topic, (err) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
console.log(`已訂閱主題: ${topic}`);
|
// console.log(`已訂閱主題: ${topic}`);
|
||||||
} else {
|
} else {
|
||||||
console.error("訂閱失敗: ", err);
|
// console.error("訂閱失敗: ", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -66,32 +122,55 @@ const connectMqtt = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startCountdown = () => {
|
const startCountdown = () => {
|
||||||
if (timer) return; // 防止重複啟動計時器
|
countdown.value = 60;
|
||||||
|
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
if (countdown.value > 0) {
|
if (countdown.value > 1) {
|
||||||
countdown.value--;
|
countdown.value--;
|
||||||
} else {
|
} else {
|
||||||
onCancel(); // 1分鐘後如果沒有收到訊息則觸發 onCancel
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
onCancel(); // 60秒結束自動關閉
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = async () => {
|
||||||
|
// 清空資料與狀態
|
||||||
receivedMessages.value = [];
|
receivedMessages.value = [];
|
||||||
|
mqttCardDataList.value = [];
|
||||||
|
countdown.value = 60;
|
||||||
|
hasStartedCountdown.value = false;
|
||||||
|
|
||||||
mqtt_test.close();
|
mqtt_test.close();
|
||||||
|
|
||||||
// 斷開 MQTT 連線
|
// 停止 API 呼叫
|
||||||
|
try {
|
||||||
|
await postMqttTopicStop({
|
||||||
|
iotTag: iotSchemaTag?.value,
|
||||||
|
Topic: formState.value.topic,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("postMqttTopicStop 發送失敗", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 關閉 MQTT
|
||||||
if (mqttClient.value) {
|
if (mqttClient.value) {
|
||||||
mqttClient.value.end();
|
mqttClient.value.end();
|
||||||
mqttClient.value = null;
|
mqttClient.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置倒計時
|
// 清除 API Interval
|
||||||
|
if (mqttInterval) {
|
||||||
|
clearInterval(mqttInterval);
|
||||||
|
mqttInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除倒數 Timer
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
timer = null; // 清除計時器引用
|
timer = null;
|
||||||
}
|
}
|
||||||
countdown.value = 60;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -109,39 +188,66 @@ const onCancel = () => {
|
|||||||
<Modal id="mqtt_test" title="MQTT Topic" :onCancel="onCancel" width="400">
|
<Modal id="mqtt_test" title="MQTT Topic" :onCancel="onCancel" width="400">
|
||||||
<template #modalContent>
|
<template #modalContent>
|
||||||
<!-- 顯示接收到的訊息 -->
|
<!-- 顯示接收到的訊息 -->
|
||||||
<div v-if="receivedMessages.length > 0" class="overflow-y-auto h-96">
|
<div v-if="mqttCardDataList.length > 0" class="overflow-y-auto h-96 mt-4">
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="(message, index) in receivedMessages"
|
v-for="(item, index) in mqttCardDataList"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="bg-base-200 rounded-md text-wrap shadow shadow-slate-400 p-4 my-2 me-2"
|
class="bg-base-200 rounded-md text-wrap shadow shadow-slate-400 p-4 my-2 me-2"
|
||||||
>
|
>
|
||||||
<strong class="text-base block text-info mb-2"
|
<strong
|
||||||
>{{ message.topic }} :</strong
|
class="text-base block text-info mb-2 flex justify-between items-center"
|
||||||
>
|
>
|
||||||
<p class="text-sm break-words">{{ message.message }}</p>
|
<span>
|
||||||
<p class="text-xs text-slate-200 pt-2">
|
<FontAwesomeIcon :icon="['fas', 'clock']" class="me-1" />
|
||||||
<FontAwesomeIcon :icon="['fas', 'clock']" class="me-1" />
|
{{ dayjs(item.time).format("YYYY-MM-DD HH:mm:ss") }}
|
||||||
{{ message.timestamp }}
|
</span>
|
||||||
</p>
|
|
||||||
|
<!-- 只在第一筆資料加上「最新」標籤 -->
|
||||||
|
<span
|
||||||
|
v-if="index === 0"
|
||||||
|
class="text-xs text-white bg-green-600 px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<!-- 動態顯示除了 time 以外的所有欄位 -->
|
||||||
|
<template v-for="[key, value] in Object.entries(item)" :key="key">
|
||||||
|
<p v-if="key !== 'time'" class="text-sm break-words">
|
||||||
|
<strong>{{ key }}:</strong>{{ value }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<!-- 顯示 loading 和倒計時 -->
|
|
||||||
<p v-else class="text-center mt-20">
|
<!-- 顯示 loading 和倒計時(只有沒資料才顯示) -->
|
||||||
|
<p v-if="mqttCardDataList.length === 0" class="text-center mt-20">
|
||||||
<Loading />
|
<Loading />
|
||||||
<br />
|
<br />
|
||||||
<span class="text-base">{{ countdown }} seconds</span>
|
<span class="text-base">Loading...</span>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template #modalAction>
|
<template #modalAction>
|
||||||
<button
|
<div class="relative w-full flex justify-end items-center gap-12">
|
||||||
type="reset"
|
<!-- 資料出現後才顯示倒數計時,置中顯示 -->
|
||||||
class="btn btn-outline-success mr-2"
|
<div
|
||||||
@click.prevent="onCancel"
|
v-if="mqttCardDataList.length > 0"
|
||||||
>
|
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-2 text-sm"
|
||||||
{{ t("button.cancel") }}
|
>
|
||||||
</button>
|
<span>Auto close in</span>
|
||||||
|
<span>{{ countdown }}s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="reset"
|
||||||
|
class="btn btn-outline-success"
|
||||||
|
@click.prevent="onCancel"
|
||||||
|
>
|
||||||
|
{{ t("button.cancel") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
@ -34,20 +34,23 @@ const defaultOption = (map, data = []) => {
|
|||||||
// 生成坐標數據,根據坐標值的不同設置不同顏色
|
// 生成坐標數據,根據坐標值的不同設置不同顏色
|
||||||
const formattedData = data.map((coordinate) => {
|
const formattedData = data.map((coordinate) => {
|
||||||
const coordString = JSON.stringify(coordinate);
|
const coordString = JSON.stringify(coordinate);
|
||||||
|
|
||||||
// 解析 device_coordinate 為數值陣列進行比對
|
// 解析 device_coordinate 為數值陣列進行比對
|
||||||
let isSelected = false;
|
let isSelected = false;
|
||||||
if (formState.value.device_coordinate) {
|
if (formState.value.device_coordinate) {
|
||||||
try {
|
try {
|
||||||
const deviceCoord = JSON.parse(formState.value.device_coordinate);
|
const deviceCoord = JSON.parse(formState.value.device_coordinate);
|
||||||
// 比對數值而非字串,避免精度問題
|
// 比對數值而非字串,避免精度問題
|
||||||
isSelected = coordinate.length === deviceCoord.length &&
|
isSelected =
|
||||||
coordinate.every((val, index) => Math.abs(val - deviceCoord[index]) < 0.001);
|
coordinate.length === deviceCoord.length &&
|
||||||
|
coordinate.every(
|
||||||
|
(val, index) => Math.abs(val - deviceCoord[index]) < 0.001
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('解析 device_coordinate 失敗:', e);
|
console.warn("解析 device_coordinate 失敗:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: coordString,
|
name: coordString,
|
||||||
value: coordinate,
|
value: coordinate,
|
||||||
@ -121,15 +124,15 @@ watch(
|
|||||||
const getCoordinate = (position) => {
|
const getCoordinate = (position) => {
|
||||||
formState.value.device_coordinate = JSON.stringify(position);
|
formState.value.device_coordinate = JSON.stringify(position);
|
||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 平面圖 -->
|
<!-- 平面圖 -->
|
||||||
|
|
||||||
<div class="flex gap-4 mb-5">
|
<div class="flex gap-4 mb-5 w-full">
|
||||||
<Select
|
<Select
|
||||||
:value="formState"
|
:value="formState"
|
||||||
|
class="min-w-[180px] w-full"
|
||||||
selectClass="border-info focus-within:border-info"
|
selectClass="border-info focus-within:border-info"
|
||||||
name="floor_guid"
|
name="floor_guid"
|
||||||
Attribute="full_name"
|
Attribute="full_name"
|
||||||
@ -140,7 +143,7 @@ const getCoordinate = (position) => {
|
|||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
:value="formState"
|
:value="formState"
|
||||||
width="270"
|
class="min-w-[180px] w-full"
|
||||||
name="device_coordinate"
|
name="device_coordinate"
|
||||||
:disabled="true"
|
:disabled="true"
|
||||||
>
|
>
|
||||||
|
@ -126,7 +126,7 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap justify-between">
|
<div class="flex flex-wrap justify-between">
|
||||||
<div
|
<div
|
||||||
class="order-3 lg:order-1 w-full lg:w-1/4 h-full flex flex-col justify-start z-10 border-dashboard px-12"
|
class="order-3 lg:order-1 w-full lg:w-1/4 h-full flex flex-col justify-start z-10 border-dashboard gap-5"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<DashboardProduct />
|
<DashboardProduct />
|
||||||
@ -151,7 +151,7 @@ onUnmounted(() => {
|
|||||||
<DashboardSysCard :data="systemData"/>
|
<DashboardSysCard :data="systemData"/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="order-last w-full lg:w-1/4 flex flex-col justify-start border-dashboard z-20"
|
class="order-last w-full lg:w-1/4 flex flex-col justify-start border-dashboard z-20 gap-5"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<DashboardElectricity />
|
<DashboardElectricity />
|
||||||
@ -168,6 +168,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style lang="css" scoped>
|
<style lang="css" scoped>
|
||||||
.border-dashboard {
|
.border-dashboard {
|
||||||
@apply lg:border lg:border-light-info bg-dark-info bg-opacity-70 lg:rounded-md p-3 first:mt-0 last:mb-0;
|
@apply lg:border lg:border-light-info bg-dark-info bg-opacity-70 lg:rounded-md py-12 px-8 first:mt-0 last:mb-0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -174,7 +174,7 @@ const handleCancel = () => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="props.data.Online_color" class="hover:bg-gray-600">
|
<!-- <tr v-if="props.data.Online_color" class="hover:bg-gray-600">
|
||||||
<td class="p-2 border">Online 顏色</td>
|
<td class="p-2 border">Online 顏色</td>
|
||||||
<td class="p-2 border">
|
<td class="p-2 border">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@ -187,8 +187,8 @@ const handleCancel = () => {
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr> -->
|
||||||
<tr v-if="props.data.Offline_color" class="hover:bg-gray-600">
|
<!-- <tr v-if="props.data.Offline_color" class="hover:bg-gray-600">
|
||||||
<td class="p-2 border">Offline 顏色</td>
|
<td class="p-2 border">Offline 顏色</td>
|
||||||
<td class="p-2 border">
|
<td class="p-2 border">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@ -201,8 +201,8 @@ const handleCancel = () => {
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr> -->
|
||||||
<tr v-if="props.data.Error_color" class="hover:bg-gray-600">
|
<!-- <tr v-if="props.data.Error_color" class="hover:bg-gray-600">
|
||||||
<td class="p-2 border">Error 顏色</td>
|
<td class="p-2 border">Error 顏色</td>
|
||||||
<td class="p-2 border">
|
<td class="p-2 border">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@ -215,7 +215,7 @@ const handleCancel = () => {
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr> -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -121,9 +121,16 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
taipower_data,
|
taipower_data,
|
||||||
() => {
|
() => {
|
||||||
// 依照每日顯示
|
// 日期排序(由舊到新)
|
||||||
const days = taipower_data.value.map((item) => item.day);
|
const sorted = [...taipower_data.value].sort(
|
||||||
const carbonTotal = taipower_data.value.map((item) => item.carbon);
|
(a, b) => new Date(a.day) - new Date(b.day)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 取最後 7 筆(若不足則全取)
|
||||||
|
const recent = sorted.length > 7 ? sorted.slice(-7) : sorted;
|
||||||
|
|
||||||
|
const days = recent.map((item) => item.day);
|
||||||
|
const carbonTotal = recent.map((item) => item.carbon);
|
||||||
|
|
||||||
// 更新圖表資料
|
// 更新圖表資料
|
||||||
defaultChartOption.value.xAxis.data = days;
|
defaultChartOption.value.xAxis.data = days;
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
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 { ref, watch, computed, onUnmounted } from "vue";
|
||||||
import { ref, watch, onMounted, onUnmounted, computed } from "vue";
|
|
||||||
import useActiveBtn from "@/hooks/useActiveBtn";
|
import useActiveBtn from "@/hooks/useActiveBtn";
|
||||||
import { getDashboardTemp } from "@/apis/dashboard";
|
|
||||||
import useSearchParams from "@/hooks/useSearchParam";
|
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";
|
||||||
@ -12,16 +10,15 @@ import { useI18n } from "vue-i18n";
|
|||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { searchParams } = useSearchParams();
|
const { searchParams } = useSearchParams();
|
||||||
const buildingStore = useBuildingStore();
|
const buildingStore = useBuildingStore();
|
||||||
const intervalType = "immediateTemp";
|
|
||||||
|
// 狀態與按鈕邏輯
|
||||||
const timeoutTimer = ref("");
|
const timeoutTimer = ref("");
|
||||||
|
|
||||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||||
|
|
||||||
const data = ref([]);
|
|
||||||
const other_real_temp_chart = ref(null);
|
|
||||||
const currentOptionType = ref(1); // 1: 溫度, 2: 濕度
|
const currentOptionType = ref(1); // 1: 溫度, 2: 濕度
|
||||||
const noData = ref(false);
|
const noData = ref(true); // 目前顯示「無資料」
|
||||||
|
|
||||||
|
// 圖表參數預設(暫不使用)
|
||||||
|
const other_real_temp_chart = ref(null);
|
||||||
const defaultChartOption = ref({
|
const defaultChartOption = ref({
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
@ -54,31 +51,12 @@ const defaultChartOption = ref({
|
|||||||
series: [],
|
series: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const getData = async () => {
|
// 按鈕名稱根據語系更新
|
||||||
const res = await getDashboardTemp({
|
|
||||||
building_guid: buildingStore.selectedBuilding.building_guid,
|
|
||||||
tempOption: 2, // 參數 tempOption 1:室溫 2:冷藏
|
|
||||||
timeInterval: 1,
|
|
||||||
option: currentOptionType.value, // 參數 option:1:溫度 2:濕度
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.isSuccess) {
|
|
||||||
const key = "冷藏溫度";
|
|
||||||
const label = currentOptionType.value === 1 ? "溫度" : "濕度";
|
|
||||||
console.log(`冷藏${label}資料:`, res.data[key]);
|
|
||||||
data.value = res.data[key] || [];
|
|
||||||
noData.value = !data.value || data.value.length === 0;
|
|
||||||
} else {
|
|
||||||
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,
|
||||||
() => {
|
() => {
|
||||||
@ -87,68 +65,22 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 切換 tab(目前無實際資料行為)
|
||||||
watch(
|
watch(
|
||||||
selectedBtn,
|
selectedBtn,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
if (timeoutTimer.value) {
|
if (timeoutTimer.value) {
|
||||||
clearInterval(timeoutTimer.value);
|
clearInterval(timeoutTimer.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newValue?.key === 1 || newValue?.key === 2) {
|
if (newValue?.key === 1 || newValue?.key === 2) {
|
||||||
currentOptionType.value = newValue.key;
|
currentOptionType.value = newValue.key;
|
||||||
getData();
|
// getData() 預留空間
|
||||||
timeoutTimer.value = setInterval(() => {
|
|
||||||
getData();
|
|
||||||
}, 60 * 1000);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
// 清除計時器
|
||||||
data,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue?.length > 0 && other_real_temp_chart.value?.chart) {
|
|
||||||
const firstItem = newValue[0];
|
|
||||||
if (firstItem?.data?.length > 0) {
|
|
||||||
const validData = firstItem.data.filter(
|
|
||||||
(item) => item.value !== null && item.value !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validData.length > 0) {
|
|
||||||
const minValue = Math.min(...validData.map(({ value }) => value));
|
|
||||||
const maxValue = Math.max(...validData.map(({ value }) => value));
|
|
||||||
|
|
||||||
other_real_temp_chart.value.chart.setOption({
|
|
||||||
legend: {
|
|
||||||
data: newValue.map(({ full_name }) => full_name),
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
data: firstItem.data.map(({ time }) =>
|
|
||||||
dayjs(time).format("HH:mm:ss")
|
|
||||||
),
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
min: Math.floor(minValue),
|
|
||||||
max: Math.ceil(maxValue),
|
|
||||||
},
|
|
||||||
series: newValue.map(({ full_name, data }, index) => ({
|
|
||||||
name: full_name,
|
|
||||||
type: "line",
|
|
||||||
data: data.map(({ value }) => value),
|
|
||||||
showSymbol: false,
|
|
||||||
itemStyle: {
|
|
||||||
color: SECOND_CHART_COLOR[index % SECOND_CHART_COLOR.length],
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (timeoutTimer.value) {
|
if (timeoutTimer.value) {
|
||||||
clearInterval(timeoutTimer.value);
|
clearInterval(timeoutTimer.value);
|
||||||
@ -170,15 +102,8 @@ onUnmounted(() => {
|
|||||||
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") }}
|
||||||
</div>
|
</div>
|
||||||
<LineChart
|
|
||||||
v-else
|
|
||||||
id="dashboard_other_real_temp"
|
|
||||||
class="min-h-[260px] max-h-fit"
|
|
||||||
:option="defaultChartOption"
|
|
||||||
ref="other_real_temp_chart"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped></style>
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
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, onMounted, onUnmounted, computed } from "vue";
|
import { ref, watch, onUnmounted, computed } 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 useSearchParams from "@/hooks/useSearchParam";
|
||||||
@ -12,26 +12,21 @@ import { useI18n } from "vue-i18n";
|
|||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { searchParams } = useSearchParams();
|
const { searchParams } = useSearchParams();
|
||||||
const buildingStore = useBuildingStore();
|
const buildingStore = useBuildingStore();
|
||||||
const intervalType = "immediateTemp";
|
const timeoutTimer = ref(null); // 定時器參考
|
||||||
const timeoutTimer = ref("");
|
|
||||||
|
|
||||||
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
|
||||||
|
|
||||||
const data = ref([]);
|
const allTempData = ref([]);
|
||||||
const other_real_temp_chart = ref(null);
|
const currentOptionType = ref(1); // 當前顯示類型:1 = 溫度,2 = 濕度
|
||||||
const currentOptionType = ref(1); // 1: 溫度, 2: 濕度
|
const noData = ref(false); // 無資料顯示控制
|
||||||
const noData = ref(false);
|
const indoorChartRef = ref(null);
|
||||||
|
|
||||||
|
// 預設圖表設定
|
||||||
const defaultChartOption = ref({
|
const defaultChartOption = ref({
|
||||||
tooltip: {
|
tooltip: { trigger: "axis" },
|
||||||
trigger: "axis",
|
|
||||||
},
|
|
||||||
legend: {
|
legend: {
|
||||||
data: [],
|
data: [],
|
||||||
textStyle: {
|
textStyle: { color: "#ffffff", fontSize: 16 },
|
||||||
color: "#ffffff",
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
top: "10%",
|
top: "10%",
|
||||||
@ -54,104 +49,125 @@ const defaultChartOption = ref({
|
|||||||
series: [],
|
series: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const getData = async (tempOption) => {
|
// 同時取得走廊與原料室的資料
|
||||||
const res = await getDashboardTemp({
|
const getData = async () => {
|
||||||
building_guid: buildingStore.selectedBuilding.building_guid,
|
const buildingGuid = buildingStore.selectedBuilding?.building_guid;
|
||||||
tempOption, // 參數 tempOption 1:室溫 2:冷藏
|
if (!buildingGuid) return;
|
||||||
timeInterval: 1,
|
|
||||||
option: currentOptionType.value, // 參數 option:1:溫度 2:濕度
|
try {
|
||||||
});
|
const res = await getDashboardTemp({
|
||||||
|
building_guid: buildingGuid,
|
||||||
|
tempOption: 1, // 室溫區域(冷藏為 tempOption: 2)
|
||||||
|
timeInterval: 1,
|
||||||
|
option: currentOptionType.value,
|
||||||
|
});
|
||||||
|
|
||||||
if (res.isSuccess) {
|
|
||||||
const key = "室溫";
|
const key = "室溫";
|
||||||
const label = currentOptionType.value === 1 ? "溫度" : "濕度";
|
allTempData.value = res.isSuccess ? res.data?.[key] ?? [] : [];
|
||||||
// console.log(`室內${label}資料:`, res.data[key]);
|
noData.value = allTempData.value.length === 0;
|
||||||
data.value = res.data[key] || [];
|
} catch (e) {
|
||||||
noData.value = !data.value || data.value.length === 0;
|
console.error("getDashboardTemp error", e);
|
||||||
} else {
|
allTempData.value = [];
|
||||||
noData.value = true;
|
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 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 切換溫度/濕度按鈕後更新資料與啟動定時器
|
||||||
watch(
|
watch(
|
||||||
selectedBtn,
|
selectedBtn,
|
||||||
(newValue) => {
|
(newVal) => {
|
||||||
if (timeoutTimer.value) {
|
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||||
clearInterval(timeoutTimer.value);
|
if ([1, 2].includes(newVal?.key)) {
|
||||||
}
|
currentOptionType.value = newVal.key;
|
||||||
|
getData();
|
||||||
if (newValue?.key === 1 || newValue?.key === 2) {
|
timeoutTimer.value = setInterval(getData, 60000); // 每分鐘自動更新一次
|
||||||
currentOptionType.value = newValue.key; // 1:溫度, 2:濕度
|
|
||||||
getData(1); // tempOption 固定為 1
|
|
||||||
timeoutTimer.value = setInterval(() => {
|
|
||||||
getData(1);
|
|
||||||
}, 60 * 1000);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: 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(
|
||||||
data,
|
allTempData,
|
||||||
(newValue) => {
|
(newVal) => {
|
||||||
if (newValue?.length > 0 && other_real_temp_chart.value?.chart) {
|
if (!newVal?.length || !indoorChartRef.value?.chart) return;
|
||||||
const firstItem = newValue[0];
|
|
||||||
if (firstItem?.data?.length > 0) {
|
|
||||||
const validData = firstItem.data.filter(
|
|
||||||
(item) => item.value !== null && item.value !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validData.length > 0) {
|
const firstValid = newVal.find((d) => d.data?.length);
|
||||||
const minValue = Math.min(...validData.map(({ value }) => value));
|
if (!firstValid) return;
|
||||||
const maxValue = Math.max(...validData.map(({ value }) => value));
|
|
||||||
|
|
||||||
other_real_temp_chart.value.chart.setOption({
|
const sampledXAxis = sampleData(firstValid.data).map(({ time }) =>
|
||||||
legend: {
|
dayjs(time).format("HH:mm:ss")
|
||||||
data: newValue.map(({ full_name }) => full_name),
|
);
|
||||||
},
|
|
||||||
xAxis: {
|
const allValues = newVal
|
||||||
data: firstItem.data.map(({ time }) =>
|
.flatMap((d) => sampleData(d.data))
|
||||||
dayjs(time).format("HH:mm:ss")
|
.map((d) => d.value)
|
||||||
),
|
.filter((v) => v != null);
|
||||||
},
|
|
||||||
yAxis: {
|
if (!allValues.length) return;
|
||||||
min: Math.floor(minValue),
|
|
||||||
max: Math.ceil(maxValue),
|
const minVal = Math.min(...allValues);
|
||||||
},
|
const maxVal = Math.max(...allValues);
|
||||||
series: newValue.map(({ full_name, data }, index) => ({
|
|
||||||
name: full_name,
|
const yMin = Math.floor(minVal) - 1;
|
||||||
type: "line",
|
const yMax = Math.ceil(maxVal) + 1;
|
||||||
data: data.map(({ value }) => value),
|
|
||||||
showSymbol: false,
|
indoorChartRef.value.chart.setOption({
|
||||||
itemStyle: {
|
legend: {
|
||||||
color: SECOND_CHART_COLOR[index % SECOND_CHART_COLOR.length],
|
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 }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
// 離開元件時清除定時器
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (timeoutTimer.value) {
|
if (timeoutTimer.value) clearInterval(timeoutTimer.value);
|
||||||
clearInterval(timeoutTimer.value);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -169,13 +185,13 @@ onUnmounted(() => {
|
|||||||
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") }}
|
||||||
</div>
|
</div>
|
||||||
<LineChart
|
<LineChart
|
||||||
id="dashboard_other_real_temp"
|
id="dashboard_other_real_temp"
|
||||||
class="min-h-[260px] max-h-fit"
|
class="min-h-[260px] max-h-fit"
|
||||||
:option="defaultChartOption"
|
:option="defaultChartOption"
|
||||||
ref="other_real_temp_chart"
|
ref="indoorChartRef"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -8,12 +8,15 @@ import dayjs from "dayjs";
|
|||||||
|
|
||||||
const { searchParams } = useSearchParam();
|
const { searchParams } = useSearchParam();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { tableData } = inject("history_table_data");
|
|
||||||
const history_chart = ref(null);
|
|
||||||
|
|
||||||
|
// 從上層 inject 接收歷史圖表資料
|
||||||
|
const { tableData } = inject("history_table_data");
|
||||||
|
const history_chart = ref(null); // 用來存取圖表元件實例
|
||||||
|
|
||||||
|
// 預設圖表設定物件
|
||||||
const defaultChartOption = {
|
const defaultChartOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis", // 滑鼠移動到 X 軸會觸發 tooltip
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
data: [],
|
data: [],
|
||||||
@ -27,7 +30,7 @@ const defaultChartOption = {
|
|||||||
left: "0%",
|
left: "0%",
|
||||||
right: "0%",
|
right: "0%",
|
||||||
bottom: "0%",
|
bottom: "0%",
|
||||||
containLabel: true,
|
containLabel: true, // 保留 label 顯示空間
|
||||||
},
|
},
|
||||||
toolbox: {
|
toolbox: {
|
||||||
show: true,
|
show: true,
|
||||||
@ -45,6 +48,7 @@ const defaultChartOption = {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: "category",
|
type: "category",
|
||||||
splitLine: { show: false },
|
splitLine: { show: false },
|
||||||
|
axisTick: { alignWithLabel: true },
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
formatter: (value) =>
|
formatter: (value) =>
|
||||||
@ -54,20 +58,24 @@ const defaultChartOption = {
|
|||||||
},
|
},
|
||||||
data: [],
|
data: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: "value",
|
type: "value",
|
||||||
splitLine: { show: false },
|
splitLine: { show: false },
|
||||||
axisLabel: { color: "#ffffff" },
|
axisLabel: { color: "#ffffff" },
|
||||||
min: "dataMin",
|
// Y 軸最小值 -1,並無條件捨去小數,最大值 +1,無條件進位
|
||||||
max: "dataMax",
|
min: (value) => Math.floor(value.min - 1),
|
||||||
|
max: (value) => Math.ceil(value.max + 1),
|
||||||
},
|
},
|
||||||
series: [],
|
series: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 格式化資料為圖表所需格式
|
||||||
const formatChartData = (data) => {
|
const formatChartData = (data) => {
|
||||||
return data.reduce((acc, item) => {
|
return data.reduce((acc, item) => {
|
||||||
const seriesKey = `${item.device_name || ""}_${item.item_name || ""}`;
|
const seriesKey = `${item.device_name || ""}_${item.item_name || ""}`;
|
||||||
acc[seriesKey] = {
|
acc[seriesKey] = {
|
||||||
|
// 時間戳記與對應的數值陣列
|
||||||
timestamps: item.data.map((d) =>
|
timestamps: item.data.map((d) =>
|
||||||
dayjs(d.time).format("YYYY-MM-DD HH:mm")
|
dayjs(d.time).format("YYYY-MM-DD HH:mm")
|
||||||
),
|
),
|
||||||
@ -82,21 +90,26 @@ const formatChartData = (data) => {
|
|||||||
}, {});
|
}, {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 當 tableData 變動時更新圖表
|
||||||
watch(
|
watch(
|
||||||
tableData,
|
tableData,
|
||||||
(newData) => {
|
(newData) => {
|
||||||
if (newData?.length > 0) {
|
if (newData?.length > 0) {
|
||||||
const formattedData = formatChartData(newData);
|
const formattedData = formatChartData(newData);
|
||||||
|
|
||||||
|
// 取得原始 X 軸時間資料並做抽樣
|
||||||
const xDataRaw = formattedData[Object.keys(formattedData)[0]].timestamps;
|
const xDataRaw = formattedData[Object.keys(formattedData)[0]].timestamps;
|
||||||
const totalPoints = xDataRaw.length;
|
const totalPoints = xDataRaw.length;
|
||||||
const tickCount = 30;
|
const tickCount = 30; // 目標最多顯示 30 個刻度
|
||||||
const interval = Math.max(1, Math.floor(totalPoints / (tickCount - 1)));
|
const interval = Math.max(1, Math.floor(totalPoints / (tickCount - 1)));
|
||||||
const sampledTime = xDataRaw.filter((_, idx) => idx % interval === 0);
|
const sampledTime = xDataRaw.filter((_, idx) => idx % interval === 0);
|
||||||
|
|
||||||
|
// 確保最後一筆時間有包含
|
||||||
if (sampledTime[sampledTime.length - 1] !== xDataRaw[totalPoints - 1]) {
|
if (sampledTime[sampledTime.length - 1] !== xDataRaw[totalPoints - 1]) {
|
||||||
sampledTime.push(xDataRaw[totalPoints - 1]);
|
sampledTime.push(xDataRaw[totalPoints - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 組裝每一筆 series 資料(每條折線)
|
||||||
const series = Object.keys(formattedData).map((seriesKey, index) => {
|
const series = Object.keys(formattedData).map((seriesKey, index) => {
|
||||||
const { timestamps, values, maxValue, minValue, averageValue } =
|
const { timestamps, values, maxValue, minValue, averageValue } =
|
||||||
formattedData[seriesKey];
|
formattedData[seriesKey];
|
||||||
@ -109,11 +122,12 @@ watch(
|
|||||||
name: seriesKey,
|
name: seriesKey,
|
||||||
type: "line",
|
type: "line",
|
||||||
data: sampledTime.map((t) => timeToValue[t] ?? null),
|
data: sampledTime.map((t) => timeToValue[t] ?? null),
|
||||||
showSymbol: false,
|
showSymbol: false, // 不顯示每個資料點的圓點
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: SECOND_CHART_COLOR[index % SECOND_CHART_COLOR.length],
|
color: SECOND_CHART_COLOR[index % SECOND_CHART_COLOR.length],
|
||||||
},
|
},
|
||||||
markPoint: {
|
markPoint: {
|
||||||
|
// 最大值與最小值標記
|
||||||
data: [
|
data: [
|
||||||
maxValue !== null
|
maxValue !== null
|
||||||
? { type: "max", name: "Max", value: maxValue }
|
? { type: "max", name: "Max", value: maxValue }
|
||||||
@ -124,6 +138,7 @@ watch(
|
|||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
},
|
},
|
||||||
markLine: {
|
markLine: {
|
||||||
|
// 平均值虛線
|
||||||
data:
|
data:
|
||||||
averageValue !== null
|
averageValue !== null
|
||||||
? [
|
? [
|
||||||
@ -137,24 +152,25 @@ watch(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更新圖表內容
|
||||||
history_chart.value?.chart.setOption(
|
history_chart.value?.chart.setOption(
|
||||||
{
|
{
|
||||||
...defaultChartOption,
|
...defaultChartOption,
|
||||||
legend: {
|
legend: {
|
||||||
...defaultChartOption.legend,
|
...defaultChartOption.legend,
|
||||||
data: Object.keys(formattedData),
|
data: Object.keys(formattedData), // 圖例顯示每一組設備名稱
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
...defaultChartOption.xAxis,
|
...defaultChartOption.xAxis,
|
||||||
data: sampledTime,
|
data: sampledTime, // 更新 X 軸資料
|
||||||
},
|
},
|
||||||
series,
|
series, // 更新 Y 軸系列資料
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true } // 深層監聽資料變動
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -167,4 +183,4 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped></style>
|
||||||
|
Loading…
Reference in New Issue
Block a user