fix: 首頁圖表修正及MQTT串接

This commit is contained in:
MJM_2025_05\polly 2025-08-04 17:02:53 +08:00
parent 2e15353384
commit d822b3074a
16 changed files with 383 additions and 260 deletions

View File

@ -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`;

View File

@ -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,
});
};

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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>

View File

@ -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"
/> />

View File

@ -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"

View File

@ -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>

View File

@ -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"
> >

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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, // option1: 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>

View File

@ -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, // option1: 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>

View File

@ -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>