Compare commits

...

11 Commits

48 changed files with 1851 additions and 213 deletions

View File

@ -1,4 +1,4 @@
VITE_API_BASEURL = "https://ibms-cvilux-api.production.mjmtech.com.tw" VITE_API_BASEURL = "https://ibms-cvilux-api.production.mjmtech.com.tw"
VITE_FILE_API_BASEURL = "https://cgems.cvilux-group.com:8088" VITE_FILE_API_BASEURL = "https://cgems.cvilux-group.com:8088"
VITE_MQTT_BASEURL = "wss://mqttwss.mjm-staging.developers-homelab.net" # VITE_MQTT_BASEURL = "wss://mqttwss.mjm-staging.developers-homelab.net"
VITE_FORGE_BASEURL = "https://cgems.cvilux-group.com:8088/dist" VITE_FORGE_BASEURL = "https://cgems.cvilux-group.com:8088/dist"

View File

@ -1,4 +1,4 @@
VITE_API_BASEURL = "https://ibms-cvilux-api.production.mjmtech.com.tw" VITE_API_BASEURL = "https://ibms-cvilux-api.production.mjmtech.com.tw"
VITE_FILE_API_BASEURL = "https://cgems.cvilux-group.com:8088" VITE_FILE_API_BASEURL = "https://cgems.cvilux-group.com:8088"
VITE_MQTT_BASEURL = "wss://mqttwss.mjm-staging.developers-homelab.net" # VITE_MQTT_BASEURL = "wss://mqttwss.mjm-staging.developers-homelab.net"
# VITE_FORGE_BASEURL = "https://cgems.cvilux-group.com:8088/dist" # VITE_FORGE_BASEURL = "https://cgems.cvilux-group.com:8088/dist"

View File

@ -1,3 +1,3 @@
VITE_API_BASEURL = "https://ibms-cvilux-demo-api.production.mjmtech.com.tw" VITE_API_BASEURL = "https://ibms-cvilux-demo-api.production.mjmtech.com.tw"
VITE_FILE_API_BASEURL = "https://cgems.cvilux-group.com:8088" VITE_FILE_API_BASEURL = "https://cgems.cvilux-group.com:8088"
VITE_MQTT_BASEURL = "wss://mqttwss.mjm-staging.developers-homelab.net" # VITE_MQTT_BASEURL = "wss://mqttwss.mjm-staging.developers-homelab.net"

7
package-lock.json generated
View File

@ -22,6 +22,7 @@
"echarts": "^5.4.3", "echarts": "^5.4.3",
"jquery-ui": "^1.14.1", "jquery-ui": "^1.14.1",
"json-schema-generator": "^2.0.6", "json-schema-generator": "^2.0.6",
"leaflet": "^1.9.4",
"mqtt": "^5.10.3", "mqtt": "^5.10.3",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"requirejs": "^2.3.6", "requirejs": "^2.3.6",
@ -3350,6 +3351,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/libphonenumber-js": { "node_modules/libphonenumber-js": {
"version": "1.10.60", "version": "1.10.60",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.60.tgz", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.60.tgz",

View File

@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"build:staging": "vite build --mode staging" "build:staging": "vite build --mode staging"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
@ -24,6 +24,7 @@
"echarts": "^5.4.3", "echarts": "^5.4.3",
"jquery-ui": "^1.14.1", "jquery-ui": "^1.14.1",
"json-schema-generator": "^2.0.6", "json-schema-generator": "^2.0.6",
"leaflet": "^1.9.4",
"mqtt": "^5.10.3", "mqtt": "^5.10.3",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"requirejs": "^2.3.6", "requirejs": "^2.3.6",

BIN
public/CviLux_globalmap.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
public/CviLux_globalmap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

View File

@ -36,4 +36,6 @@ export const DELETE_ASSET_ELECTYPE_API = `/AssetManage/DeleteElecType`;
export const POST_ASSET_ELEC_SETTING_API = `/AssetManage/SaveAssetSetting`; export const POST_ASSET_ELEC_SETTING_API = `/AssetManage/SaveAssetSetting`;
export const POST_ASSET_MQTT_PUBLISH_API = `/api/mqtt/publish`; export const POST_ASSET_MQTT_PUBLISH_API = `/api/mqtt/publish`;
export const POST_MQTT_TOPIC_API = `api/Device/MQTTTopicTest`;
export const POST_MQTT_TOPIC_STOP_API = `api/Device/MQTTTopicTestStop`;

View File

@ -27,6 +27,8 @@ import {
DELETE_ASSET_ELECTYPE_API, DELETE_ASSET_ELECTYPE_API,
POST_ASSET_ELEC_SETTING_API, POST_ASSET_ELEC_SETTING_API,
POST_ASSET_MQTT_PUBLISH_API, POST_ASSET_MQTT_PUBLISH_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";
@ -359,3 +361,21 @@ export const postMQTTpublish = async ({ Topic, Payload }) => {
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

@ -4,3 +4,4 @@ export const DELETE_BUILDING_API = `/AssetManage/DeleteBuilding`;
export const GET_AUTHPAGE_API = `/api/GetUsrFroList`; export const GET_AUTHPAGE_API = `/api/GetUsrFroList`;
export const GET_SUBAUTHPAGE_API = `/api/Device/GetMainSub`; export const GET_SUBAUTHPAGE_API = `/api/Device/GetMainSub`;
export const GET_ALL_DEVICE_API = `/api/Device/GetAllDevice`; export const GET_ALL_DEVICE_API = `/api/Device/GetAllDevice`;
export const GET_FUNCTION_LIST_API = `/api/function/get-function-list`;

View File

@ -5,6 +5,7 @@ import {
GET_AUTHPAGE_API, GET_AUTHPAGE_API,
GET_SUBAUTHPAGE_API, GET_SUBAUTHPAGE_API,
GET_ALL_DEVICE_API, GET_ALL_DEVICE_API,
GET_FUNCTION_LIST_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";
@ -39,9 +40,9 @@ export const deleteBuildings = async (building_guid) => {
}); });
}; };
export const getAuth = async (lang) => { export const getAuth = async (building_id) => {
const res = await instance.post(GET_AUTHPAGE_API, { const res = await instance.get(GET_FUNCTION_LIST_API, {
lang, params: { building_id },
}); });
return apihandler(res.code, res.data, { return apihandler(res.code, res.data, {
msg: res.msg, msg: res.msg,
@ -50,7 +51,7 @@ export const getAuth = async (lang) => {
}; };
export const getAllSysSidebar = async (building_guid) => { export const getAllSysSidebar = async (building_guid) => {
const res = await instance.post(GET_SUBAUTHPAGE_API, {building_guid}); const res = await instance.post(GET_SUBAUTHPAGE_API, { building_guid });
return apihandler(res.code, res.data, { return apihandler(res.code, res.data, {
msg: res.msg, msg: res.msg,
code: res.code, code: res.code,

View File

@ -1,6 +1,6 @@
export const GET_REALTIME_DIST_API = `/api/Energe/GetRealTimeDistribution`; export const GET_REALTIME_DIST_API = `/api/Energe/GetRealTimeDistribution`;
export const GET_ELECUSE_DAY_API = `/api/Energe/GetElecUseDay`; export const GET_ELECUSE_DAY_API = `/api/Energe/GetElecUseDay`;
export const GET_TAI_POWER_API = `/api/Energe/GetTaipower`; export const GET_TAI_POWER_API = `/api/energy-manager/power-usage`;
export const GET_SIDEBAR_API = `/api/GetSideBar`; export const GET_SIDEBAR_API = `/api/GetSideBar`;
export const GET_SEARCH_API = `/api/Energe/GetFilter`; export const GET_SEARCH_API = `/api/Energe/GetFilter`;

View File

@ -0,0 +1,8 @@
export const GET_SITES_SYSTEM_STATUS_API = `/api/monitoring/sites-system-status`;
export const GET_SITES_SYSTEM_ENERGY_COST_RANK_API = `/api/energy-manager/all-site/energy-cost-rank`;
export const GET_SITES_SYSTEM_ENERGY_COST_TREND_API = `/api/energy-manager/all-site/energy-cost-trend`;
export const GET_SITES_SYSTEM_ENERGY_COST_GROWTH_API = `/api/energy-manager/all-site/energy-cost-growth-rate`;
export const GET_USER_API = `/api/user/user-list`;

View File

@ -0,0 +1,64 @@
import {
GET_SITES_SYSTEM_STATUS_API,
GET_SITES_SYSTEM_ENERGY_COST_RANK_API,
GET_SITES_SYSTEM_ENERGY_COST_TREND_API,
GET_SITES_SYSTEM_ENERGY_COST_GROWTH_API,
GET_USER_API,
} from "./api";
import instance from "@/util/request";
import apihandler from "@/util/apihandler";
export const getSystemStatus = async (building_ids) => {
const res = await instance.post(GET_SITES_SYSTEM_STATUS_API, building_ids);
return apihandler(res.code, res.data, {
msg: res.msg,
code: res.code,
});
};
export const getSystemEnergyCostRank = async (building_ids) => {
const res = await instance.post(GET_SITES_SYSTEM_ENERGY_COST_RANK_API, building_ids);
return apihandler(res.code, res.data, {
msg: res.msg,
code: res.code,
});
};
export const getSystemEnergyCostTrend = async (building_ids) => {
const res = await instance.post(GET_SITES_SYSTEM_ENERGY_COST_TREND_API, building_ids);
return apihandler(res.code, res.data, {
msg: res.msg,
code: res.code,
});
}
export const getSystemEnergyCostGrowth = async (building_ids) => {
const res = await instance.get(GET_SITES_SYSTEM_ENERGY_COST_GROWTH_API, building_ids);
return apihandler(res.code, res.data, {
msg: res.msg,
code: res.code,
});
}
export const getUserList = async (params = {}) => {
const {
page = 1,
pageSize = 9999999
} = params;
const requestData = {
Page: page,
PageSize: pageSize
};
const res = await instance.post(GET_USER_API, requestData);
return apihandler(res.code, res.data, {
msg: res.msg,
code: res.code,
});
}

View File

@ -1,3 +1,4 @@
export const GET_SYSTEM_FLOOR_LIST_API = `/api/Device/GetFloor`; 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_DEVICE_POWER_TOGGLE_API = `/api/device-events/power-toggle`;

View File

@ -2,6 +2,7 @@ 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_DEVICE_POWER_TOGGLE_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";
@ -42,3 +43,16 @@ export const getSystemRealTime = async (device_list) => {
code: res.code, code: res.code,
}); });
}; };
export const toggleDevicePower = async ({topic_publish, device_item_id,new_value}) => {
const res = await instance.post(GET_SYSTEM_DEVICE_POWER_TOGGLE_API, {
topic_publish,
device_item_id,
new_value,
});
return apihandler(res.code, res.data, {
msg: res.msg,
code: res.code,
});
}

View File

@ -1,11 +1,18 @@
<script setup> <script setup>
import { onMounted } from "vue"; import { onMounted,watch } from "vue";
import useBuildingStore from "@/stores/useBuildingStore"; import useBuildingStore from "@/stores/useBuildingStore";
import { useRouter } from "vue-router";
const store = useBuildingStore(); const store = useBuildingStore();
const router = useRouter();
const selectBuilding = (bui) => { const selectBuilding = (bui) => {
store.selectedBuilding = bui; // selectedBuildingwatch store.selectedBuilding = bui; // selectedBuildingwatch
if (bui.is_headquarter == true) {
router.replace({ path: "/headquarters" });
} else {
router.replace({ path: "/dashboard" });
}
}; };
onMounted(() => { onMounted(() => {

View File

@ -9,7 +9,7 @@ import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
const { locale } = useI18n(); const { locale, t } = useI18n();
const store = useUserInfoStore(); const store = useUserInfoStore();
const buildingStore = useBuildingStore(); const buildingStore = useBuildingStore();
const route = useRoute(); const route = useRoute();
@ -17,8 +17,8 @@ const openKeys = ref([]); // 追蹤當前打開的子菜單
const menu_array = ref([]); const menu_array = ref([]);
const currentAuthCode = ref(""); const currentAuthCode = ref("");
const iniFroList = async () => { const iniFroList = async (building_id) => {
const res = await getAuth(locale.value); const res = await getAuth(building_id);
store.updateAuthPage( store.updateAuthPage(
res.data.map((d) => res.data.map((d) =>
@ -72,23 +72,21 @@ watch(
(newVal) => { (newVal) => {
if (newVal !== null) { if (newVal !== null) {
getSubMonitorPage(newVal.building_guid); getSubMonitorPage(newVal.building_guid);
iniFroList(newVal.building_guid);
} }
} }
); );
watch(locale, () => {
iniFroList();
});
onMounted(() => {
iniFroList();
});
</script> </script>
<template> <template>
<ul class="px-1 menu-box my-2"> <ul class="px-1 menu-box my-2">
<li class="flex flex-col items-center justify-center"> <li class="flex flex-col items-center justify-center">
<router-link <router-link
:to="{ name: 'dashboard' }" :to="{
name:
buildingStore.selectedBuilding?.is_headquarter === true
? 'headquarters'
: 'dashboard',
}"
class="flex lg:flex-col justify-center items-center btn-group text-white" class="flex lg:flex-col justify-center items-center btn-group text-white"
> >
<font-awesome-icon <font-awesome-icon
@ -96,7 +94,7 @@ onMounted(() => {
size="2x" size="2x"
class="w-10 m-auto" class="w-10 m-auto"
/> />
<span>{{ $t("home") }}</span> <span>{{ $t("navbar.home") }}</span>
</router-link> </router-link>
</li> </li>
<li <li
@ -132,11 +130,11 @@ onMounted(() => {
size="2x" size="2x"
class="w-10 m-auto" class="w-10 m-auto"
/> />
<span>{{ page.subName }}</span> <span>{{ $t(`navbar.${page.showView}`) }}</span>
</a> </a>
<router-link <router-link
v-else v-else
:to="page.navigate" :to="`/` + page.showView"
type="link" type="link"
class="flex lg:flex-col justify-center items-center btn-group text-white" class="flex lg:flex-col justify-center items-center btn-group text-white"
> >
@ -145,7 +143,7 @@ onMounted(() => {
size="2x" size="2x"
class="w-10 m-auto" class="w-10 m-auto"
/> />
<span>{{ page.subName }}</span> <span>{{ $t(`navbar.${page.showView}`) }}</span>
</router-link> </router-link>
</li> </li>
</ul> </ul>
@ -169,7 +167,7 @@ onMounted(() => {
<a-sub-menu <a-sub-menu
v-for="main in menu_array" v-for="main in menu_array"
:key="main.main_system_tag" :key="main.main_system_tag"
:title="main.full_name" :title="main.resource ? t(`navbar.${main.resource}`) : main.full_name"
v-if="menu_array.length > 0 && open" v-if="menu_array.length > 0 && open"
> >
<a-menu-item <a-menu-item
@ -192,13 +190,13 @@ onMounted(() => {
params: { params: {
main_system_id: main.main_system_tag, main_system_id: main.main_system_tag,
sub_system_id: sub.sub_system_tag, sub_system_id: sub.sub_system_tag,
...(currentAuthCode === 'PF2' || currentAuthCode === 'PF11' ...(currentAuthCode === 'PF2' || currentAuthCode === 'PF11'
? { type: sub.type } ? { type: sub.type }
: { floor_id: 'main' }), : { floor_id: 'main' }),
}, },
}" }"
> >
{{ sub.full_name }} {{ sub.resource ? $t(`navbar.${sub.resource}`): sub.full_name }}
</router-link> </router-link>
</a-menu-item> </a-menu-item>
</a-sub-menu> </a-sub-menu>

View File

@ -13,6 +13,39 @@
"name": "名称", "name": "名称",
"time": "时间" "time": "时间"
}, },
"navbar": {
"home": "首页",
"sysMonBtnList": "系统监控",
"historyData": "历史资料",
"energyManagement": "能源管理",
"alert": "告警",
"operation": "运维管理",
"graphManagement": "图资管理",
"AssetManagement": "资产管理",
"accountManagement": "帐号管理",
"UserManagement": "帐号管理",
"Setting": "系统设定",
"energy_analysis": "能源分析",
"consumption_report": "用电报表",
"chart_analysis": "图表分析",
"historical_curve": "历史曲线",
"quick_metering": "快速抄表",
"electricity_classification": "用电分类",
"daily_report": "日报表",
"weekly_report": "周报表",
"monthly_report": "月报表",
"annual_report": "年报表",
"setting":"系统设置",
"device":"设置设置",
"department": "部门",
"settings_2d_3d": "2D/3D 显示设置",
"maintenance_vendor": "维修厂商",
"building": "厂区",
"floor": "楼层",
"demand": "电表",
"time_based_pricing": "时段电价",
"mqtt_result": "MQTT 结果"
},
"upload": { "upload": {
"title": "选择一个文件或拖放到这里", "title": "选择一个文件或拖放到这里",
"description": "档案不超过 10MB", "description": "档案不超过 10MB",
@ -89,11 +122,11 @@
"reset_value": "复归值", "reset_value": "复归值",
"edit_automatic_demand": "编辑自动需量", "edit_automatic_demand": "编辑自动需量",
"elec_bills": "今年电费累计(元)", "elec_bills": "今年电费累计(元)",
"interval_elec_charges": "区间电费(元)", "interval_elec_charges": "本月电费(元)",
"year_carbon_emission": "今年碳排当量累计(公斤)", "year_carbon_emission": "今年碳排当量累计(公斤)",
"interval_carbon_emission": "区间碳排当量", "interval_carbon_emission": "本月碳排当量",
"year_elec_consumption": "今年用电度数(kWh)", "year_elec_consumption": "今年用电度数(kWh)",
"interval_elec_consumption": "区间用电度数(kWh)", "interval_elec_consumption": "本月用电度数(kWh)",
"monthly_elec_consumption": "每月用电分析", "monthly_elec_consumption": "每月用电分析",
"monthly_carbon_emission_and_reduction": "每月碳排当量 (kgCO2e)", "monthly_carbon_emission_and_reduction": "每月碳排当量 (kgCO2e)",
"monthly_bill_power": "每月计费度数 (kWh)", "monthly_bill_power": "每月计费度数 (kWh)",
@ -155,7 +188,9 @@
"off_peak_contract": "离峰契约", "off_peak_contract": "离峰契约",
"variable_electricity_charge": "流动电费", "variable_electricity_charge": "流动电费",
"saturday": "周六", "saturday": "周六",
"sunday_and_off_peak_days": "周日及离峰日" "sunday_and_off_peak_days": "周日及离峰日",
"past_elec_data": "去年±数值(百分比)",
"past_month_elec_data": "上月±数值(百分比)"
}, },
"alarm": { "alarm": {
"title": "显示警告", "title": "显示警告",
@ -396,6 +431,7 @@
}, },
"msg": { "msg": {
"sure_to_delete": "是否确认删除该项目?", "sure_to_delete": "是否确认删除该项目?",
"is_headquarters": "该帐号为总部帐号,是否仍要删除该项目?",
"sure_to_delete_permanent": "是否确认永久删除该项目?", "sure_to_delete_permanent": "是否确认永久删除该项目?",
"delete_success": "删除成功", "delete_success": "删除成功",
"delete_failed": "删除失败", "delete_failed": "删除失败",

View File

@ -10,9 +10,42 @@
"in_otal": "筆資料", "in_otal": "筆資料",
"skip_to": "跳至", "skip_to": "跳至",
"serial_number": "序號", "serial_number": "序號",
"name": "名", "name": "",
"time": "時間" "time": "時間"
}, },
"navbar": {
"home": "首頁",
"sysMonBtnList": "系統監控",
"historyData": "歷史資料",
"energyManagement": "能源管理",
"alert": "告警",
"operation": "運維管理",
"graphManagement": "圖資管理",
"AssetManagement": "資產管理",
"accountManagement": "帳號管理",
"UserManagement": "帳號管理",
"Setting": "系統設定",
"energy_analysis": "能耗分析",
"consumption_report": "用電報表",
"chart_analysis": "圖表分析",
"historical_curve": "歷史曲線",
"quick_metering": "快速抄表",
"electricity_classification": "用電分類",
"daily_report": "日報表",
"weekly_report": "周報表",
"monthly_report": "月報表",
"annual_report": "年報表",
"setting":"系統設定",
"device":"設備設定",
"department": "部門",
"settings_2d_3d": "2D/3D 顯示設定",
"maintenance_vendor": "維運廠商",
"building": "廠區",
"floor": "樓層",
"demand": "電表",
"time_based_pricing": "時段電價",
"mqtt_result": "MQTT 結果"
},
"upload": { "upload": {
"title": "選擇一個文件或拖放到這裡", "title": "選擇一個文件或拖放到這裡",
"description": "檔案不超過 10MB", "description": "檔案不超過 10MB",
@ -89,11 +122,11 @@
"reset_value": "復歸值", "reset_value": "復歸值",
"edit_automatic_demand": "編輯自動需量", "edit_automatic_demand": "編輯自動需量",
"elec_bills": "今年電費累計(元)", "elec_bills": "今年電費累計(元)",
"interval_elec_charges": "區間電費(元)", "interval_elec_charges": "本月電費(元)",
"year_carbon_emission": "今年碳排當量累計(公斤)", "year_carbon_emission": "今年碳排當量累計(公斤)",
"interval_carbon_emission": "區間碳排當量", "interval_carbon_emission": "本月碳排當量",
"year_elec_consumption": "今年用電度數(kWh)", "year_elec_consumption": "今年用電度數(kWh)",
"interval_elec_consumption": "區間用電度數(kWh)", "interval_elec_consumption": "本月用電度數(kWh)",
"monthly_elec_consumption": "每月用電分析", "monthly_elec_consumption": "每月用電分析",
"monthly_carbon_emission_and_reduction": "每月碳排當量 (kgCO2e)", "monthly_carbon_emission_and_reduction": "每月碳排當量 (kgCO2e)",
"monthly_bill_power": "每月計費度數 (kWh)", "monthly_bill_power": "每月計費度數 (kWh)",
@ -155,7 +188,9 @@
"off_peak_contract": "離峰契約", "off_peak_contract": "離峰契約",
"variable_electricity_charge": "流動電費", "variable_electricity_charge": "流動電費",
"saturday": "週六", "saturday": "週六",
"sunday_and_off_peak_days": "週日及離峰日" "sunday_and_off_peak_days": "週日及離峰日",
"past_elec_data": "去年±數值(百分比)",
"past_month_elec_data": "上月±數值(百分比)"
}, },
"alarm": { "alarm": {
"title": "顯示警告", "title": "顯示警告",
@ -396,17 +431,18 @@
}, },
"msg": { "msg": {
"sure_to_delete": "是否確認刪除該項目?", "sure_to_delete": "是否確認刪除該項目?",
"is_headquarters": "該帳號為總部帳號,是否仍要刪除該項目?",
"sure_to_delete_permanent": "是否確認永久刪除該項目?", "sure_to_delete_permanent": "是否確認永久刪除該項目?",
"delete_success": "刪除成功", "delete_success": "刪除成功",
"delete_failed": "刪除失敗", "delete_failed": "刪除失敗",
"mqtt_refresh": "重新設定成功", "mqtt_refresh": "重新設定成功",
"schema_name_required": "架構名稱欄位必填", "schema_name_required": "架構名稱欄位必填",
"incorrect_format":"格式不正確", "incorrect_format": "格式不正確",
"send_successfully":"送出成功", "send_successfully": "送出成功",
"edit_successfully":"修改成功" "edit_successfully": "修改成功"
}, },
"setting": { "setting": {
"electricity_meter":"電表", "electricity_meter": "電表",
"MQTT_parse": "MQTT 解析", "MQTT_parse": "MQTT 解析",
"schema": "架構", "schema": "架構",
"point": "點位", "point": "點位",

View File

@ -13,6 +13,39 @@
"name": "Name", "name": "Name",
"time": "Time" "time": "Time"
}, },
"navbar": {
"home": "Home",
"sysMonBtnList": "Monitoring",
"historyData": "History Data",
"energyManagement": "Energy",
"alert": "Alert",
"operation": "Maintenance",
"graphManagement": "Graph",
"AssetManagement": "Devices",
"accountManagement": "Account",
"UserManagement": "Account",
"Setting": "Setting",
"energy_analysis": "Energy Analysis",
"consumption_report": "Consumption Report",
"chart_analysis": "Chart Analysis",
"historical_curve": "Historical Curve",
"quick_metering": "Quick Metering",
"electricity_classification": "Electricity Classification",
"daily_report": "Daily Report",
"weekly_report": "Weekly Report",
"monthly_report": "Monthly Report",
"annual_report": "Annual Report",
"setting":"Settings",
"device":"Device",
"department": "Department",
"settings_2d_3d": "2D/3D Display Settings",
"maintenance_vendor": "Vendor",
"building": "Building",
"floor": "Floor",
"demand": "Demand",
"time_based_pricing": "Time-based Pricing",
"mqtt_result": "MQTT Result"
},
"upload": { "upload": {
"title": "Select a file or drag and drop here", "title": "Select a file or drag and drop here",
"description": "File size cannot exceed 10MB", "description": "File size cannot exceed 10MB",
@ -89,11 +122,11 @@
"reset_value": "Reset Value", "reset_value": "Reset Value",
"edit_automatic_demand": "Edit automatic demand", "edit_automatic_demand": "Edit automatic demand",
"elec_bills": "Total electricity bills this year (yuan)", "elec_bills": "Total electricity bills this year (yuan)",
"interval_elec_charges": "Interval electricity charges (yuan)", "interval_elec_charges": "Electricity charges for this month (yuan)",
"year_carbon_emission": "Cumulative carbon emission equivalent this year (kg)", "year_carbon_emission": "Cumulative carbon emission equivalent this year (kg)",
"interval_carbon_emission": "Interval carbon emission equivalent", "interval_carbon_emission": "This month's carbon emission equivalent",
"year_elec_consumption": "This year's electricity consumption (kWh)", "year_elec_consumption": "This year's electricity consumption (kWh)",
"interval_elec_consumption": "Interval electricity consumption (kWh)", "interval_elec_consumption": "This month's electricity consumption (kWh)",
"monthly_elec_consumption": "Monthly electricity consumption analysis", "monthly_elec_consumption": "Monthly electricity consumption analysis",
"monthly_carbon_emission_and_reduction": "Monthly carbon emission equivalent (kgCO2e)", "monthly_carbon_emission_and_reduction": "Monthly carbon emission equivalent (kgCO2e)",
"monthly_bill_power": "Monthly billing power (kWh)", "monthly_bill_power": "Monthly billing power (kWh)",
@ -155,7 +188,9 @@
"off_peak_contract": "Off-Peak Demand Charge", "off_peak_contract": "Off-Peak Demand Charge",
"variable_electricity_charge": "Variable Electricity Charge", "variable_electricity_charge": "Variable Electricity Charge",
"saturday": "Saturday", "saturday": "Saturday",
"sunday_and_off_peak_days": "Sunday and Off-Peak Days" "sunday_and_off_peak_days": "Sunday and Off-Peak Days",
"past_elec_data": "Last year ± value (percentage)",
"past_month_elec_data": "Last month ± value (percentage)"
}, },
"alarm": { "alarm": {
"title": "Warning", "title": "Warning",
@ -396,6 +431,7 @@
}, },
"msg": { "msg": {
"sure_to_delete": "Are you sure to delete this item?", "sure_to_delete": "Are you sure to delete this item?",
"is_headquarters": "This account is a headquarters account. Are you sure you want to delete this item?",
"sure_to_delete_permanent": "Are you sure you want to permanently delete this item?", "sure_to_delete_permanent": "Are you sure you want to permanently delete this item?",
"delete_success": "Delete successfully", "delete_success": "Delete successfully",
"delete_failed": "Delete failed", "delete_failed": "Delete failed",

View File

@ -2,69 +2,49 @@ export const AUTHPAGES = [
{ {
authCode: "PF0", authCode: "PF0",
icon: "home", icon: "home",
navigate: "/dashboard",
}, },
{ {
authCode: "PF1", authCode: "PF1",
icon: "tv", icon: "tv",
navigate: "/system",
}, },
{ {
authCode: "PF2", authCode: "PF2",
icon: "chart-pie", icon: "chart-pie",
pageName: "energyManagement",
navigate: "/energyManagement",
}, },
{ {
authCode: "PF3", authCode: "PF3",
icon: "chart-area", icon: "chart-area",
navigate: "/historyData",
}, },
{ {
authCode: "PF4", authCode: "PF4",
icon: "chart-line", icon: "chart-line",
navigate: "/historyData",
}, },
{ {
authCode: "PF5", authCode: "PF5",
icon: "bell", icon: "bell",
pageName: "alert",
navigate: "/alert",
}, },
{ {
authCode: "PF6", authCode: "PF6",
icon: "server", icon: "server",
pageName: "operation",
navigate: "/operation",
}, },
{ {
authCode: "PF7", authCode: "PF7",
icon: "image", icon: "image",
pageName: "graphManagement",
navigate: "/graphManagement",
}, },
{ {
authCode: "PF8", authCode: "PF8",
icon: "user", icon: "user",
pageName: "accountManagement",
navigate: "/accountManagement",
}, },
{ {
authCode: "PF9", authCode: "PF9",
icon: "database", icon: "database",
pageName: "AssetManagement",
navigate: "/assetManagement",
}, },
{ {
authCode: "PF10", authCode: "PF10",
icon: "leaf", icon: "leaf",
pageName: "ProductSetting",
navigate: "/productSetting",
}, },
{ {
authCode: "PF11", authCode: "PF11",
icon: "cog", icon: "cog",
pageName: "Setting",
navigate: "/Setting",
}, },
]; ];

View File

@ -18,6 +18,7 @@ export default function useForgeHeatmap() {
//create the heatmap //create the heatmap
function getSensorValue(device, sensorType, pointData) { function getSensorValue(device, sensorType, pointData) {
console.log("heatmap", device, realtimeData.value);
const dev = realtimeData.value.find( const dev = realtimeData.value.find(
({ device_number }) => device_number === device.id ({ device_number }) => device_number === device.id
); );

View File

@ -9,12 +9,15 @@ import AlertManagement from "@/views/alert/AlertManagement.vue";
import ProductSetting from "@/views/productSetting/ProductSetting.vue"; import ProductSetting from "@/views/productSetting/ProductSetting.vue";
import EnergyManagement from "@/views/energyManagement/EnergyManagement.vue"; import EnergyManagement from "@/views/energyManagement/EnergyManagement.vue";
import SettingManagement from "@/views/setting/SettingManagement.vue"; import SettingManagement from "@/views/setting/SettingManagement.vue";
import HeadquartersManagement from "@/views/headquarters/HeadquartersManagement.vue";
import UserManagement from "@/views/headquarters/HeadquartersAccountManagement.vue";
import Login from "@/views/login/Login.vue"; import Login from "@/views/login/Login.vue";
import useUserInfoStore from "@/stores/useUserInfoStore"; import useUserInfoStore from "@/stores/useUserInfoStore";
import useBuildingStore from "@/stores/useBuildingStore";
import useGetCookie from "@/hooks/useGetCookie"; import useGetCookie from "@/hooks/useGetCookie";
import System from "@/views/system/System.vue"; import System from "@/views/system/System.vue";
import SystemFloor from "@/views/system/SystemFloor.vue"; import SystemFloor from "@/views/system/SystemFloor.vue";
import Test from "@/views/Test.vue"; import Test from "@/views/Test.vue";
import SystemMain from "@/views/system/SystemMain.vue"; import SystemMain from "@/views/system/SystemMain.vue";
@ -90,6 +93,16 @@ const router = createRouter({
name: "setting", name: "setting",
component: SettingManagement, component: SettingManagement,
}, },
{
path: "/headquarters",
name: "headquarters",
component: HeadquartersManagement,
},
{
path: "/UserManagement",
name: "UserManagement",
component: UserManagement,
},
{ {
path: "/mytestfile/mjm", path: "/mytestfile/mjm",
name: "mytestfile", name: "mytestfile",
@ -106,9 +119,11 @@ router.beforeEach(async (to, from, next) => {
const auth = useUserInfoStore(); const auth = useUserInfoStore();
const token = useGetCookie("JWT-Authorization"); const token = useGetCookie("JWT-Authorization");
const user_name = useGetCookie("user_name"); const user_name = useGetCookie("user_name");
const buildingStore = useBuildingStore();
if ((authRequired && !token) || to.path === "/") { if ((authRequired && !token) || to.path === "/") {
auth.user.token = ""; auth.user.token = "";
buildingStore.deleteBuilding();
next({ path: "/login" }); next({ path: "/login" });
} else if (!authRequired) { } else if (!authRequired) {
document.cookie = "JWT-Authorization=; Max-Age=0"; document.cookie = "JWT-Authorization=; Max-Age=0";

View File

@ -84,8 +84,8 @@ const useBuildingStore = defineStore("buildingInfo", () => {
// 獲取2D、3D顯示與否 // 獲取2D、3D顯示與否
const fetchDashboard2D3D = async (BuildingId) => { const fetchDashboard2D3D = async (BuildingId) => {
const res = await getDashboard2D3D(BuildingId); const res = await getDashboard2D3D(BuildingId);
showForgeArea.value = res.data.is3DEnabled; showForgeArea.value = res.data?.is3DEnabled || false;
previewImageExt.value = res.data.previewImageExt || ""; previewImageExt.value = res.data?.previewImageExt || "";
}; };
// 清除localStorage建築物 // 清除localStorage建築物

View File

@ -4,7 +4,6 @@ import { ref } from "vue";
const useUserInfoStore = defineStore("userInfo", () => { const useUserInfoStore = defineStore("userInfo", () => {
const user = ref({ const user = ref({
token: "", token: "",
expires: 0,
user_name:"", user_name:"",
}); });

View File

@ -12,13 +12,23 @@ 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(""); // IOT Schema tagIoT
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();
@ -51,6 +61,7 @@ provide("asset_modal_options", {
elecTypeOptions, elecTypeOptions,
departmentList, departmentList,
floors, floors,
iotSchemaTag
}); });
</script> </script>

View File

@ -148,13 +148,15 @@ const edit = async (id) => {
// changeParams({ ...searchParams.value, main_id: record.id }); // changeParams({ ...searchParams.value, main_id: record.id });
const res = await getAssetSingle(id); const res = await getAssetSingle(id);
if (res.isSuccess) { if (res.isSuccess) {
res.data.oriFile = res.data.oriFile.map((file, index) => ({ res.data.oriFile = res.data.oriFile
...file, .filter((file) => file.saveName)
key: index, .map((file, index) => ({
src: file.file_url, ...file,
name: file.orgName, key: index,
ext: file.saveName.split(".")[file.saveName.split(".").length - 1], src: file.file_url,
})); name: file.orgName,
ext: file.saveName.split(".")[file.saveName.split(".").length - 1],
}));
res.data.sub_device = res.data.sub_device?.map( res.data.sub_device = res.data.sub_device?.map(
({ device_number, points }) => ({ ({ device_number, points }) => ({
device_number, device_number,

View File

@ -6,7 +6,9 @@ import AssetTableModalLeft from "./AssetTableModalLeft.vue";
import AssetTableModalRight from "./AssetTableModalRight.vue"; import AssetTableModalRight from "./AssetTableModalRight.vue";
import useFormErrorMessage from "@/hooks/useFormErrorMessage"; import useFormErrorMessage from "@/hooks/useFormErrorMessage";
import * as yup from "yup"; import * as yup from "yup";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const { openToast } = inject("app_toast"); const { openToast } = inject("app_toast");
const { searchParams, changeParams } = useSearchParam(); const { searchParams, changeParams } = useSearchParam();
@ -75,8 +77,11 @@ const onOk = async () => {
main_id: props.editRecord ? props.editRecord.main_id : 0, main_id: props.editRecord ? props.editRecord.main_id : 0,
}); });
if (res.isSuccess) { if (res.isSuccess) {
openToast("success", t("msg.send_successfully"), "#asset_add_table_item");
props.getData(); props.getData();
closeModal(); setTimeout(() => {
closeModal();
}, 1000);
} else { } else {
openToast("error", res.msg, "#asset_add_table_item"); openToast("error", res.msg, "#asset_add_table_item");
} }
@ -102,12 +107,20 @@ const closeModal = () => {
</script> </script>
<template> <template>
<button class="btn btn-sm btn-add mr-3" @click.stop.prevent="openModal" :disabled="!searchParams.subSys_id"> <button
class="btn btn-sm btn-add mr-3"
@click.stop.prevent="openModal"
:disabled="!searchParams.subSys_id"
>
<font-awesome-icon :icon="['fas', 'plus']" />{{ $t("button.add") }} <font-awesome-icon :icon="['fas', 'plus']" />{{ $t("button.add") }}
</button> </button>
<Modal <Modal
id="asset_add_table_item" id="asset_add_table_item"
:title="editRecord?.main_id ? $t('assetManagement.edit_device') : $t('assetManagement.add_device')" :title="
editRecord?.main_id
? $t('assetManagement.edit_device')
: $t('assetManagement.add_device')
"
:onCancel="closeModal" :onCancel="closeModal"
:width="1600" :width="1600"
> >

View File

@ -32,7 +32,9 @@ const getMenuData = async () => {
const getData = async (id) => { const getData = async (id) => {
const res = await getGraphData(id); const res = await getGraphData(id);
if (res.isSuccess) { if (res.isSuccess) {
dataSource.value = res.data.map((d) => ({ ...d, key: d.id })); dataSource.value = res.data
.filter((d) => d.oriSavName)
.map((d) => ({ ...d, key: d.id }));
} }
}; };

View File

@ -1,102 +1,126 @@
<script setup> <script setup>
import { onMounted, ref, inject, watch, computed } from "vue"; import { onMounted, ref, inject, watch, computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { postMQTTpublish } from "@/apis/asset"; import { postMQTTpublish, postMqttTopic, postMqttTopicStop } from "@/apis/asset";
import mqtt from "mqtt"; import mqtt from "mqtt";
import dayjs from "dayjs"; import dayjs from "dayjs";
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 { iotSchemaTag } = inject("asset_modal_options");
// MQTT // 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) {
connectMqtt();
}
mqtt_test.showModal(); mqtt_test.showModal();
startCountdown(); //
};
const connectMqtt = () => { //
const topic = formState.value.topic || ""; // try {
const mqttHost = `${BASEURL}`; await postMqttTopic({
const protocol = "wss"; // "ws" "wss" iotTag: iotSchemaTag?.value,
mqttClient.value = mqtt.connect(mqttHost, { Topic: formState.value.topic,
protocol,
reconnectPeriod: 1000, //
username: "admin", // MQTT
password: "mjmadmin@99", // MQTT
port: 443,
});
mqttClient.value.on("connect", () => {
console.log("MQTT 已連接");
if (topic) {
mqttClient.value.subscribe(topic, (err) => {
if (!err) {
console.log(`已訂閱主題: ${topic}`);
} else {
console.error("訂閱失敗: ", err);
}
});
}
});
mqttClient.value.on("message", (topic, message) => {
//
const now = dayjs(); // 使 dayjs()
const timestamp = now.format("YYYY-MM-DD HH:mm:ss");
receivedMessages.value.push({
topic,
message: message.toString(),
timestamp: timestamp,
}); });
clearInterval(timer); // // console.log(" postMqttTopic API ");
}); } catch (error) {
console.error("首次 postMqttTopic 發送失敗", error);
}
mqttClient.value.on("error", (err) => { // 5
console.error("MQTT 連線錯誤: ", err); 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 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
if (mqttClient.value) { try {
mqttClient.value.end(); await postMqttTopicStop({
mqttClient.value = null; iotTag: iotSchemaTag?.value,
Topic: formState.value.topic,
});
} catch (error) {
console.error("postMqttTopicStop 發送失敗", error);
} }
// // API Interval
if (mqttInterval) {
clearInterval(mqttInterval);
mqttInterval = null;
}
// Timer
if (timer) { if (timer) {
clearInterval(timer); clearInterval(timer);
timer = null; // timer = null;
} }
countdown.value = 60;
}; };
const onSubmit = async () => { const onSubmit = async () => {
const Topic = formState.value.topic; const Topic = formState.value.topic_publish;
let Payload = ""; let Payload = "";
try { try {
Payload = JSON.stringify(JSON.parse(formState.value.publish_message)); Payload = JSON.stringify(JSON.parse(formState.value.publish_message));
@ -130,7 +154,7 @@ const onSubmit = async () => {
</div> </div>
<div class="flex flex-col col-span-2 border-t-gray-400 border-t py-5"> <div class="flex flex-col col-span-2 border-t-gray-400 border-t py-5">
<Input :value="formState" name="topic"> <Input :value="formState" name="topic_publish">
<template #topLeft>MQTT publish topic</template> <template #topLeft>MQTT publish topic</template>
</Input> </Input>
<Textarea :value="formState" name="publish_message"> <Textarea :value="formState" name="publish_message">
@ -142,42 +166,69 @@ const onSubmit = async () => {
</button> </button>
</div> </div>
<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

@ -127,8 +127,9 @@ const resetModalForm = () => {
}; };
}; };
const removeAccount = async (id) => { const removeAccount = async (id, notHeadquarters) => {
openToast("warning", t("msg.sure_to_delete"), "body", async () => { const message = notHeadquarters ? t("msg.sure_to_delete") : t("msg.is_headquarters");
openToast("warning", message, "body", async () => {
await cancelToastOpen(); await cancelToastOpen();
const res = await delAccount(id); const res = await delAccount(id);
if (res.isSuccess) { if (res.isSuccess) {
@ -202,7 +203,7 @@ const removeAccount = async (id) => {
</button> </button>
<button <button
class="btn btn-sm btn-error text-white" class="btn btn-sm btn-error text-white"
@click.stop.prevent="() => removeAccount(record.userinfo_guid)" @click.stop.prevent="() => removeAccount(record.userinfo_guid,record.canDeleted)"
> >
{{ $t("button.delete") }} {{ $t("button.delete") }}
</button> </button>

View File

@ -35,16 +35,23 @@ watch(
(newBuilding) => { (newBuilding) => {
if (newBuilding) { if (newBuilding) {
formState.value.building_guid = newBuilding.building_guid; formState.value.building_guid = newBuilding.building_guid;
imgBaseUrl.value = store.previewImageExt
? `${FILE_BASEURL}/upload/setting/previewImage/${newBuilding.building_guid}${store.previewImageExt}`
: import.meta.env.MODE === "production"
? "dist/build_img.jpg"
: "/build_img.jpg";
} }
}, },
{ immediate: true, deep: true } { immediate: true, deep: true }
); );
watch(
[() => store.previewImageExt, () => formState.value.building_guid],
([newExt, buildingGuid]) => {
if (newExt && buildingGuid) {
imgBaseUrl.value = `${FILE_BASEURL}/upload/setting/previewImage/${buildingGuid}${newExt}`;
} else {
imgBaseUrl.value = `${FILE_BASEURL}/upload/build_img.jpg`;
}
},
{ immediate: true }
);
watch( watch(
() => formState.value, () => formState.value,
(newVal) => { (newVal) => {
@ -139,7 +146,7 @@ onUnmounted(() => {
<template v-else> <template v-else>
<img <img
alt="build" alt="build"
:src="imgBaseUrl || '/build_img.jpg'" :src="imgBaseUrl"
class="area-img-box w-full h-[460px] block relative rounded-sm mt-3" class="area-img-box w-full h-[460px] block relative rounded-sm mt-3"
/> />
</template> </template>

View File

@ -88,7 +88,7 @@ watch(
<th <th
class="text-base border text-white text-center bg-cyan-600 bg-opacity-30" class="text-base border text-white text-center bg-cyan-600 bg-opacity-30"
> >
{{ $t("table.time") }} {{ $t("operation.updated_time") }}
</th> </th>
</tr> </tr>
</thead> </thead>

View File

@ -28,6 +28,7 @@ const {
} = useActiveBtn("multiple"); } = useActiveBtn("multiple");
const taipower_data = ref([]); const taipower_data = ref([]);
const yearly_taipower_data = ref([]);
const carbonValue = ref(null); const carbonValue = ref(null);
const search_data = computed(() => { const search_data = computed(() => {
return { return {
@ -41,9 +42,10 @@ const search_data = computed(() => {
const getData = async (value) => { const getData = async (value) => {
const res = await getTaipower(value); const res = await getTaipower(value);
if (res.isSuccess) { if (res.isSuccess) {
taipower_data.value = res.data taipower_data.value = res.data?.monthlyUsage
? res.data.sort((a, b) => a.month.localeCompare(b.month)) ? res.data?.monthlyUsage.sort((a, b) => a.month.localeCompare(b.month))
: []; : [];
yearly_taipower_data.value = res.data?.yearlyUsage;
} }
}; };
@ -96,7 +98,12 @@ watch(
} }
); );
provide("energy_data", { taipower_data, search_data, carbonValue }); provide("energy_data", {
taipower_data,
yearly_taipower_data,
search_data,
carbonValue,
});
</script> </script>
<template> <template>

View File

@ -4,7 +4,7 @@ import { twMerge } from "tailwind-merge";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { taipower_data } = inject("energy_data"); const { taipower_data, yearly_taipower_data } = inject("energy_data");
const daysInMonth = (month) => { const daysInMonth = (month) => {
const [year, monthNumber] = month.split("-"); const [year, monthNumber] = month.split("-");
@ -19,7 +19,10 @@ const calculateData = () => {
item.month.startsWith(currentYear) item.month.startsWith(currentYear)
); );
const totalElecBills = filteredData.reduce((sum, item) => sum + item.costTotal, 0); const totalElecBills = filteredData.reduce(
(sum, item) => sum + item.costTotal,
0
);
const latestMonthData = filteredData[filteredData.length - 1]; const latestMonthData = filteredData[filteredData.length - 1];
const latestMonth = latestMonthData ? latestMonthData.month : ""; const latestMonth = latestMonthData ? latestMonthData.month : "";
const monthDays = latestMonth ? daysInMonth(latestMonth) : 0; const monthDays = latestMonth ? daysInMonth(latestMonth) : 0;
@ -37,29 +40,53 @@ const calculateData = () => {
); );
return [ return [
{ title: t("energy.elec_bills"), data: totalElecBills.toLocaleString() }, {
title: t("energy.elec_bills"),
data: totalElecBills.toLocaleString(),
past: yearly_taipower_data.value.costTotalGrowthPercent
? yearly_taipower_data.value.costTotalGrowthPercent.toLocaleString()
: 0,
},
{ {
title: t("energy.interval_elec_charges"), title: t("energy.interval_elec_charges"),
time: monthTxt, time: monthTxt,
data: latestMonthData ? latestMonthData.costTotal.toLocaleString() : 0, data: latestMonthData ? latestMonthData.costTotal.toLocaleString() : 0,
past:
latestMonthData && latestMonthData.costTotalGrowthPercent
? latestMonthData.costTotalGrowthPercent.toLocaleString()
: 0,
}, },
{ {
title: t("energy.year_carbon_emission"), title: t("energy.year_carbon_emission"),
data: totalCarbonEmission.toLocaleString(), data: totalCarbonEmission.toLocaleString(),
past: yearly_taipower_data.value.carbonGrowthPercent
? yearly_taipower_data.value.carbonGrowthPercent.toLocaleString()
: 0,
}, },
{ {
title: t("energy.interval_carbon_emission"), title: t("energy.interval_carbon_emission"),
time: monthTxt, time: monthTxt,
data: latestMonthData ? latestMonthData.carbon.toLocaleString() : 0, data: latestMonthData ? latestMonthData.carbon.toLocaleString() : 0,
past:
latestMonthData && latestMonthData.carbonGrowthPercent
? latestMonthData.carbonGrowthPercent.toLocaleString()
: 0,
}, },
{ {
title: t("energy.year_elec_consumption"), title: t("energy.year_elec_consumption"),
data: totalElecConsumption.toLocaleString(), data: totalElecConsumption.toLocaleString(),
past: yearly_taipower_data.value.kWhGrowthPercent
? yearly_taipower_data.value.kWhGrowthPercent.toLocaleString()
: 0,
}, },
{ {
title: t("energy.interval_elec_consumption"), title: t("energy.interval_elec_consumption"),
time: monthTxt, time: monthTxt,
data: latestMonthData ? latestMonthData.kWh.toLocaleString() : 0, data: latestMonthData ? latestMonthData.kWh.toLocaleString() : 0,
past:
latestMonthData && latestMonthData.kWhGrowthPercent
? latestMonthData.kWhGrowthPercent.toLocaleString()
: 0,
}, },
]; ];
}; };
@ -95,7 +122,9 @@ watch(
> >
<div class="title">{{ item.title }}</div> <div class="title">{{ item.title }}</div>
<div v-if="item.time" class="time">{{ item.time }}</div> <div v-if="item.time" class="time">{{ item.time }}</div>
<div class="data">{{ item.data }}</div> <div class="data">
<span>{{ item.data }}</span> / <span>{{ item.past }}</span>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,123 @@
<script setup>
import Table from "@/components/customUI/Table.vue";
// import AccountModal from "./AccountModal.vue";
import {
getUserList
} from "@/apis/headquarters";
import { onMounted, ref, inject, computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const { openToast, cancelToastOpen } = inject("app_toast");
const dataSource = ref([]);
const loading = ref(false);
const searchData = ref({
Full_name: "",
Role_full_name: "",
});
const columns = computed(() => [
{
title: t("accountManagement.index"),
key: "index",
},
{
title: t("accountManagement.name"),
key: "full_name",
filter: true,
},
{
title: t("accountManagement.account"),
key: "account",
},
{
title: t("accountManagement.role"),
key: "role_full_name",
},
{
title: t("accountManagement.email"),
key: "email",
},
{
title: t("accountManagement.phone"),
key: "phone",
},
{
title: t("accountManagement.created_at"),
key: "created_at",
},
{
title: t("accountManagement.operation"),
key: "operation",
},
]);
const getDataSource = async () => {
loading.value = true;
const res = await getUserList();
dataSource.value = res.data?.users || [];
loading.value = false;
};
const onSearch = () => {
getDataSource();
};
const onReset = () => {
getDataSource();
};
const getUser = async (id) => {
// const res = await getAccountOneUser(id);
openModal(res.data);
};
onMounted(() => {
getDataSource();
});
</script>
<template>
<h1 class="text-2xl font-extrabold mb-2">
{{ $t("accountManagement.account_title") }}
</h1>
<Table :columns="columns" :dataSource="dataSource" :loading="loading">
<template #beforeTable>
<div class="flex items-center mb-8">
<Input
:placeholder="t('accountManagement.name_placeholder')"
name="Full_name"
:value="searchData"
class="mr-3 w-96"
/>
<Input
:placeholder="t('accountManagement.role_placeholder')"
name="Role_full_name"
:value="searchData"
/>
<button class="btn btn-search ml-5" @click.stop.prevent="onSearch">
<font-awesome-icon :icon="['fas', 'search']" />
{{ $t("button.search") }}
</button>
<button class="btn btn-neutral mx-4" @click.stop.prevent="onReset">
{{ $t("button.reset") }}
</button>
</div>
</template>
<template #bodyCell="{ record, column, index }">
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.key === 'operation'">
<button
class="btn btn-sm btn-success text-white mr-2"
@click.stop.prevent="() => getUser(record.userinfo_guid)"
>
{{ $t("button.edit") }}
</button>
</template>
<template v-else>
{{ record[column.key] }}
</template>
</template>
</Table>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,50 @@
<script setup>
import { ref, computed, watch, onUnmounted } from "vue";
import SysMap from "./components/SysMap.vue";
import SysProgress from "./components/SysProgress.vue";
import ElecRank from "./components/ElecRank.vue";
import ElecTrends from "./components/ElecTrends.vue";
import ElecCompare from "./components/ElecCompare.vue";
</script>
<template>
<div class="grid grid-cols-1 xl:grid-cols-4 gap-4 my-2">
<div class="col-span-1 grid grid-cols-1 md:grid-cols-2 md:grid-rows-1 xl:grid-cols-1 xl:grid-rows-2 gap-4">
<div class="area-img-box">
<img
alt="build"
src="/build_img.jpg"
class="w-full object-cover border-cyan-400 shadow-cyan-500/40"
/>
<p
class="p-4 h-full text-gray-100 text-base font-light bg-gray-800/60 backdrop-blur-md border-t border-cyan-400/30 shadow-inner"
>
深耕電子精密連接器光通信元件軟性排線線纜組件PCBA電子機板電子成品專業製造廠並代理電子零組件做為整合行銷公司創立於1990年產品行銷全球以穩定快速以及高品質知名;
未來瀚荃會持續精進提供更快更好以及高附加價值的產品與服務來滿足您的需求
</p>
</div>
<!--在線狀態-->
<SysProgress />
</div>
<div class="col-span-2 h-full border border-cyan-400 shadow-md shadow-cyan-500/40">
<img
src="/CviLux_globalmap.png"
alt=""
class="w-full h-full"
/>
</div>
<div class="col-span-1 grid grid-cols-1 xl:grid-rows-3 gap-4">
<ElecRank />
<ElecTrends
/>
<ElecCompare />
</div>
</div>
</template>
<style lang="scss" scoped>
.area-img-box {
@apply border border-light-info bg-gray-900/80 backdrop-blur-lg relative overflow-hidden shadow-md shadow-blue-300;
}
</style>

View File

@ -0,0 +1,316 @@
<script setup>
import { ref, onMounted, watch, computed, onUnmounted } from "vue";
import * as echarts from "echarts";
import { getSystemEnergyCostGrowth } from "@/apis/headquarters";
import BarChart from "@/components/chart/BarChart.vue";
import { useI18n } from "vue-i18n";
const { locale, t } = useI18n();
const energyCostGrowthData = ref({ day: [], week: [], month: [], year: [] });
const chartData = ref([]);
const currentType = ref({
name: "day",
});
const energyTypeList = ref([
{
title: t("dashboard.daily_relative_change"),
key: "day",
},
{
title: t("dashboard.weekly_relative_change"),
key: "week",
},
{
title: t("dashboard.monthly_relative_change"),
key: "month",
},
{
title: t("dashboard.yearly_relative_change"),
key: "year",
},
]);
let intervalId = null;
const labels = computed(() => {
switch (currentType.value.name) {
case "day":
return [t("dashboard.today"), t("dashboard.yesterday")];
case "week":
return [t("dashboard.this_week"), t("dashboard.last_week")];
case "month":
return [t("dashboard.this_month"), t("dashboard.last_month")];
case "year":
return [t("dashboard.this_year"), t("dashboard.last_year")];
default:
return [t("dashboard.today"), t("dashboard.yesterday")];
}
});
const barWidth = 30; // Set barWidth
const barChartOptions = computed(() => ({
xAxis: {
type: "category",
data: chartData.value.map((item) => item.name),
axisLine: { lineStyle: { color: "#fff" } },
},
yAxis: { type: "value", show: false },
grid: {
left: "-10%",
right: "1%",
bottom: "3%",
top: "10%",
containLabel: true,
},
series: [
{
name: "當前",
data: chartData.value.map((item) => item.current),
type: "bar",
barWidth: barWidth,
barGap: "-10%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#186B80" },
{ offset: 1, color: "#50C3E3" },
]),
shadowBlur: 5,
shadowColor: "rgba(0, 0, 0, 0.3)",
shadowOffsetY: 2,
shadowOffsetX: 5,
},
z: 3,
},
{
name: "對比",
data: chartData.value.map((item) => item.last),
type: "bar",
barWidth: barWidth,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#988F2C" },
{ offset: 1, color: "#FFF26D" },
]),
shadowBlur: 5,
shadowColor: "rgba(0, 0, 0, 0.3)",
shadowOffsetY: 2,
shadowOffsetX: 5,
},
},
{
// this top
z: 6,
type: "pictorialBar",
symbolPosition: "end",
data: chartData.value.map((item) => item.current),
symbol: "diamond",
symbolOffset: ["-45%", "-50%"],
symbolSize: [barWidth, barWidth * 0.5],
itemStyle: {
borderWidth: 0,
color: "#50C3E3",
},
},
{
// this bot
z: 6,
type: "pictorialBar",
symbolPosition: "start",
data: chartData.value.map((item) => item.current),
symbol: "diamond",
symbolOffset: ["-45%", "50%"],
symbolSize: [barWidth, barWidth * 0.5],
itemStyle: {
borderWidth: 0,
color: "#50C3E3",
},
},
{
// last top
z: 3,
type: "pictorialBar",
symbolPosition: "end",
data: chartData.value.map((item) => item.last),
symbol: "diamond",
symbolOffset: ["45%", "-50%"],
symbolSize: [barWidth, barWidth * 0.5],
itemStyle: {
borderWidth: 0,
color: "#FFF26D",
},
},
{
// last bot
z: 3,
type: "pictorialBar",
symbolPosition: "start",
data: chartData.value.map((item) => item.last),
symbol: "diamond",
symbolOffset: ["45%", "50%"],
symbolSize: [barWidth, barWidth * 0.5],
itemStyle: {
borderWidth: 0,
color: "#FFF26D",
},
},
],
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: function (params) {
let tooltipText = `<div>${params[0].axisValueLabel}</div>`;
const filteredParams = params.filter((item) => item.seriesType === "bar");
filteredParams.forEach((item) => {
tooltipText += `<div>${item.marker} ${item.value}</div>`;
});
return tooltipText;
},
},
}));
async function fetchEnergyCostGrowth() {
try {
const res = await getSystemEnergyCostGrowth();
energyCostGrowthData.value = res.data || {
day: [],
week: [],
month: [],
year: [],
};
updateChartData();
} catch (error) {
console.error("Error fetching energy cost growth:", error);
energyCostGrowthData.value = { day: [], week: [], month: [], year: [] };
chartData.value = [];
}
}
function updateChartData() {
const list = energyCostGrowthData.value[currentType.value.name] || [];
chartData.value = list.map((item) => ({
name: item.name,
current: item.current,
last: item.last,
difference: ((item.current ?? 0) - (item.last ?? 0)).toFixed(2),
percentage: item.percentage,
}));
}
watch(
() => currentType.value.name,
(newValue) => {
if (newValue) {
updateChartData();
}
},
{
immediate: true,
}
);
onMounted(() => {
fetchEnergyCostGrowth();
if (intervalId) {
clearInterval(intervalId);
}
intervalId = setInterval(() => {
fetchEnergyCostGrowth();
}, 60 * 60 * 1000);
});
onUnmounted(() => {
clearInterval(intervalId);
});
</script>
<template>
<div class="flex flex-wrap">
<div class="w-full chart-data relative px-3">
<div class="flex flex-wrap items-center justify-between">
<h2 class="font-light pt-1 px-1">
{{ $t("dashboard.relative_energy_consumption") }}
</h2>
<Select
:value="currentType"
class="!w-24"
selectClass="border-info focus-within:border-info btn-xs text-xs"
name="name"
Attribute="title"
:options="energyTypeList"
:isTopLabelExist="false"
:isBottomLabelExist="false"
>
</Select>
</div>
<div class="h-[100px]">
<BarChart
id="dashboard_chart_compare"
class="h-full"
:option="barChartOptions"
/>
</div>
<!-- 表格數據展示 -->
<div class="flex justify-between">
<div
v-for="(data, index) in chartData"
:key="index"
class="text-center mx-1"
:style="{ width: 100 / chartData.length + '%' }"
>
<div
class="text-xs bg-cyan-900 p-1 border border-cyan-100 border-opacity-20"
>
{{ labels[0] }}
</div>
<div
class="text-sm bg-cyan-900 p-1 border border-cyan-100 border-opacity-20"
>
{{ data.current ?? "-" }}
</div>
<div
class="text-xs bg-cyan-900 p-1 border border-cyan-100 border-opacity-20"
>
{{ labels[1] }}
</div>
<div
class="text-sm bg-cyan-900 p-1 border border-cyan-100 border-opacity-20"
>
{{ data.last ?? "-" }}
</div>
<div
class="text-sm bg-cyan-900 p-1 border border-cyan-100 border-opacity-20"
>
<span
:class="{
'text-red-500': data.difference > 0,
'text-green-500': data.difference < 0,
}"
>
{{
data.difference
? (data.difference > 0 ? "+" : "") + data.difference
: "-"
}}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.chart-data:before {
@apply absolute -left-0 -top-2 h-10 w-10 bg-no-repeat z-10;
content: "";
background: url(@ASSET/img/chart-data-background01.svg) center center;
}
.chart-data::after {
@apply absolute -right-1 -bottom-3 h-10 w-10 bg-no-repeat z-10;
content: "";
background: url(@ASSET/img/chart-data-background02.svg) center center;
}
</style>

View File

@ -0,0 +1,140 @@
<script setup>
import { ref, watch, computed, onUnmounted } from "vue";
import { getSystemEnergyCostRank } from "@/apis/headquarters";
import { useI18n } from "vue-i18n";
import useBuildingStore from "@/stores/useBuildingStore";
const store = useBuildingStore();
const { t } = useI18n();
const energyCostData = ref({});
const energyTypeList = ref([
{
title: t("dashboard.today_energy_consumption"),
key: "day",
},
{
title: t("dashboard.this_month_energy_consumption"),
key: "month",
},
]);
const currentEnergyType = ref({
name: "month",
});
let intervalId = null;
const currentEnergyData = computed(() => {
if (!energyCostData.value) {
return [];
}
return currentEnergyType.value.name === "month"
? energyCostData.value?.month || []
: energyCostData.value?.day || [];
});
const getEnergyRank = async () => {
try {
const res = await getSystemEnergyCostRank({
building_ids: store.buildings.map((building) => building.building_guid),
});
energyCostData.value = res.data;
} catch (error) {
console.error("Error fetching energy cost rank:", error);
}
};
watch(
() => store.buildings,
(newBuilding) => {
if (newBuilding) {
getEnergyRank();
if (intervalId) {
clearInterval(intervalId);
}
intervalId = setInterval(() => {
getEnergyRank();
}, 60 * 60 * 1000);
}
},
{ immediate: true }
);
onUnmounted(() => {
clearInterval(intervalId);
});
</script>
<template>
<div class="state-box-col relative">
<!-- 標題和切換按鈕 -->
<div class="flex justify-between items-center mb-2">
<h2 class="font-light relative">
{{ $t("dashboard.energy_ranking") }}
</h2>
<Select
:value="currentEnergyType"
class="!w-24"
selectClass="border-info focus-within:border-info btn-xs text-xs"
name="name"
Attribute="title"
:options="energyTypeList"
:isTopLabelExist="false"
:isBottomLabelExist="false"
>
</Select>
</div>
<!-- 能耗排名列表 -->
<div class="overflow-y-auto" style="height: 200px;">
<table class="table table-sm text-center">
<tbody>
<tr
v-for="(item, index) in currentEnergyData"
:key="index"
:class="[
{ 'text-red-300': index + 1 === 1 },
{ 'text-orange-300': index + 1 === 2 },
{ 'text-yellow-300': index + 1 === 3 },
{ 'text-teal-300': index + 1 > 3 },
]"
>
<td class="px-0 align-top">
<p class="flex items-center">
<font-awesome-icon :icon="['fas', 'crown']" class="me-1" />
{{ index + 1 }}
</p>
</td>
<td class="align-top whitespace-nowrap px-0">
{{ item.site_name }}
</td>
<td class="align-top">{{ item.name }}</td>
<td class="align-top ps-0">{{ item.value }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style lang="scss" scoped>
.state-box-col {
@apply border-2 border-light-info rounded-sm p-2 text-white relative;
}
.state-box-col:before {
@apply absolute left-0 right-0 -top-0.5 m-auto h-2 w-36 bg-no-repeat bg-center z-10;
content: "";
background-image: url(@ASSET/img/state-box-top.png);
}
.state-box-col:after {
@apply absolute left-0 right-0 -bottom-0.5 m-auto h-2 w-36 bg-no-repeat bg-center z-10;
content: "";
background-image: url(@ASSET/img/state-box-bottom.png);
}
tr td {
@apply text-[13px] text-start;
}
</style>

View File

@ -0,0 +1,208 @@
<script setup>
import { ref, onMounted, watch, onUnmounted } from "vue";
import * as echarts from "echarts";
import { getSystemEnergyCostTrend } from "@/apis/headquarters";
import BarChart from "@/components/chart/BarChart.vue";
import { useI18n } from "vue-i18n";
import dayjs from "dayjs";
import useBuildingStore from "@/stores/useBuildingStore";
const storeBuild = useBuildingStore();
const { t } = useI18n();
const chartData = ref([]);
const buildingList = ref([]);
const energyCostData = ref([]);
const weekComparisonOption = ref({});
const currentType = ref({});
let intervalId = null;
// option
const generateCylinderChartOption = (data) => {
const barWidth = 15;
return {
xAxis: {
type: "category",
data: data.map((item) => item.date),
axisLine: {
lineStyle: {
color: "#fff",
},
},
},
yAxis: {
type: "value",
name: "kWh",
axisLine: {
lineStyle: {
color: "#fff",
},
},
splitLine: {
show: false,
},
},
series: [
{
data: data.map((item) => item.energy),
type: "bar",
barWidth: barWidth,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: "#1F7B47" },
{ offset: 1, color: "#247E95" },
]),
shadowBlur: 5,
shadowColor: "rgba(0, 0, 0, 0.5)",
shadowOffsetY: 5,
},
},
{
z: 15,
type: "pictorialBar",
symbolPosition: "end",
data: data.map((item) => item.energy),
symbol: "diamond",
symbolOffset: [0, -5],
symbolSize: [barWidth, barWidth * 0.5],
itemStyle: {
color: "#62E39A",
},
},
{
z: 10,
type: "pictorialBar",
data: data.map((item) => item.energy),
symbol: "diamond",
symbolSize: [barWidth, barWidth * 0.5],
symbolOffset: [0, 6],
itemStyle: {
color: "#247E95",
},
},
],
grid: {
left: "0%",
right: "0%",
bottom: "0%",
top: "16%",
containLabel: true,
},
tooltip: {
trigger: "axis",
formatter: function (params) {
const item = params[0];
return `<p>${item.name}</p> <p>${item.marker}Energy consumption : ${item.value}</p>`;
},
},
};
};
const processEnergyData = async () => {
try {
const res = await getSystemEnergyCostTrend({
building_id: currentType.value.name,
});
energyCostData.value = res.data.trend || [];
if (!energyCostData.value || energyCostData.value.length === 0) {
chartData.value = [];
weekComparisonOption.value = generateCylinderChartOption(chartData.value);
return;
}
const dailyData = [...energyCostData.value].sort(
(a, b) => new Date(a.time) - new Date(b.time)
);
chartData.value = dailyData.map((item) => ({
date: dayjs(item.time).format("MM/DD"),
energy: item.value,
}));
weekComparisonOption.value = generateCylinderChartOption(chartData.value);
} catch (error) {
console.error("Error fetching energy cost trend:", error);
}
};
watch(
() => storeBuild.buildings,
(newValue) => {
if (newValue) {
currentType.value = {
name: newValue[0]?.building_guid || "all",
};
buildingList.value = [
...newValue.map((building) => ({
title: building.full_name,
key: building.building_guid,
})),
];
}
},
{
immediate: true,
}
);
// currentType
watch(
() => currentType.value.name,
(newValue) => {
if (newValue) {
processEnergyData();
if (intervalId) {
clearInterval(intervalId);
}
intervalId = setInterval(() => {
processEnergyData();
}, 60 * 60 * 1000);
}
},
{
immediate: true,
}
);
onUnmounted(() => {
clearInterval(intervalId);
});
</script>
<template>
<div class="w-full chart-data relative px-3">
<div class="flex flex-wrap items-center justify-between">
<h2 class="font-light pt-1 px-1">
{{ $t("dashboard.last_30_days_energy_trend") }}
</h2>
<Select
:value="currentType"
class="w-[8.5rem] my-2"
selectClass="border-info focus-within:border-info btn-xs text-xs"
name="name"
Attribute="title"
:options="buildingList"
:isTopLabelExist="false"
:isBottomLabelExist="false"
>
</Select>
</div>
<div class="h-[200px]">
<BarChart
id="dashboard_chart_week_comparison"
class="h-full"
:option="weekComparisonOption"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.chart-data:before {
@apply absolute -left-0 -top-1 h-10 w-10 bg-no-repeat z-10;
content: "";
background: url(@ASSET/img/chart-data-background01.svg) center center;
}
.chart-data::after {
@apply absolute -right-1 bottom-1 h-10 w-10 bg-no-repeat z-10;
content: "";
background: url(@ASSET/img/chart-data-background02.svg) center center;
}
</style>

View File

@ -0,0 +1,139 @@
<script setup>
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { onMounted, ref } from "vue";
import { nextTick } from "vue";
const leafletmapContainer = ref(null);
const selectedFactory = ref("");
let map = null;
let markerRefs = [];
const customOptions = {
minWidth: 250,
};
const markers = [
{
position: [31.29834, 120.58319],
popup: {
title: "CCT瀚荃蘇州廠",
img: "https://picsum.photos/id/700/600/400",
deviceNumber: 10,
online: 8,
offline: 2,
},
},
{
position: [29.56301, 106.55156],
popup: {
title: "CCT瀚荃重慶廠",
img: "https://picsum.photos/id/701/600/400",
deviceNumber: 20,
online: 15,
offline: 5,
},
},
{
position: [23.02067, 113.75179],
popup: {
title: "CCT瀚荃東莞廠",
img: "https://picsum.photos/id/702/600/400",
deviceNumber: 30,
online: 25,
offline: 5,
},
},
{
position: [25.16742, 121.44587],
popup: {
title: "CCT瀚荃淡水廠",
img: "https://picsum.photos/id/703/600/400",
deviceNumber: 46,
online: 25,
offline: 21,
},
},
];
onMounted(() => {
map = L.map(leafletmapContainer.value, {
center: [31.35, 113.4],
zoom: 5,
});
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
markerRefs = markers.map(({ position, popup }) => {
const marker = L.marker(position)
.bindPopup(
`
<div class="font-bold text-lg mb-2">${popup.title}</div>
<img src="${popup.img}" class="w-full rounded mb-2" />
<div class="flex justify-between text-base mt-2">
<div class="text-center">
設備總數<br><span class="text-white text-2xl">${popup.deviceNumber}</span>
</div>
<div class="text-center">
在線設備<br><span class="text-green-500 text-2xl">${popup.online}</span>
</div>
<div class="text-center">
離線設備<br><span class="text-red-600 text-2xl">${popup.offline}</span>
</div>
</div>
`,
customOptions
)
.addTo(map);
return marker;
});
});
function focusFactory(idx) {
if (!map || !markerRefs[idx]) return;
const pos = markers[idx].position;
map.flyTo(pos, 6, { animate: true });
markerRefs[idx].openPopup();
}
</script>
<template>
<div class="relative w-full h-full">
<div class="absolute top-4 right-4 z-20 flex items-center gap-2">
<select
id="factory-select"
v-model="selectedFactory"
class="select select-sm bg-cyan-950 rounded-md border-info focus-within:border-info"
@change="focusFactory(selectedFactory)"
>
<option value="" disabled>下屬共計 5 家子企業</option>
<option v-for="(m, idx) in markers" :key="idx" :value="idx">
{{ m.popup.title }}
</option>
</select>
</div>
<div class="leafletmapContainer z-10" ref="leafletmapContainer"></div>
</div>
</template>
<style lang="scss">
.leafletmapContainer {
width: 100%;
height: 100%;
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: #164e63 !important; //
color: #ffffff;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);
}
.leaflet-container a.leaflet-popup-close-button{
color: #fff;
font-size: 1rem;
padding-top: 0.4rem;
}
</style>

View File

@ -0,0 +1,151 @@
<script setup>
import { ref, computed, watch, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { getSystemStatus } from "@/apis/headquarters";
import useBuildingStore from "@/stores/useBuildingStore";
import SysProgressModal from "./SysProgressModal.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const router = useRouter();
const store = useBuildingStore();
const equipmentData = ref([]);
const modalData = ref({});
let intervalId = null;
const openModal = (item) => {
modalData.value = item;
system_status_modal.showModal();
};
const onCancel = () => {
modalData.value = {};
system_status_modal.close();
};
const getAlarmsInfos = async () => {
try {
const res = await getSystemStatus({
building_ids: store.buildings.map((building) => building.building_guid),
});
const apiData = res.data;
// equipmentData
if (apiData && apiData.alarm) {
equipmentData.value = apiData.alarm.map((item) => ({
label: item.system_name,
online: item.online || 0,
offline: item.offline || 0,
alarm: item.alarm || 0,
}));
}
} catch (error) {
console.error("Error fetching alarm info:", error);
}
};
watch(
() => store.buildings,
(newBuilding) => {
if (newBuilding) {
getAlarmsInfos();
if (intervalId) {
clearInterval(intervalId);
}
intervalId = setInterval(() => {
getAlarmsInfos();
}, 30000);
}
},
{ immediate: true }
);
onUnmounted(() => {
clearInterval(intervalId);
});
</script>
<template>
<SysProgressModal :onCancel="onCancel" :modalData="modalData" />
<div class="w-full state-box-col relative">
<div class="state-box">
<div class="title">
<img class="state-title01" src="@ASSET/img/state-title01.svg" />
<span class="">{{$t("dashboard.system_status")}}</span>
<img class="state-title02" src="@ASSET/img/state-title02.svg" />
</div>
<table class="table table-sm text-center">
<thead>
<tr class="border-cyan-400 text-cyan-100">
<th></th>
<th>{{ $t("alert.online") }}</th>
<th>{{ $t("alert.offline") }}</th>
<th>{{ $t("alert.alarm") }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in equipmentData"
:key="index"
class="border-cyan-400 cursor-pointer hover:text-info"
@click.stop.prevent="openModal(item)"
>
<th class="px-0 text-start">{{ item.label }}</th>
<td>
{{ item.online.length }}
</td>
<td>
{{ item.offline.length }}
</td>
<td>
{{ item.alarm.length }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style lang="scss" scoped>
.state-box-col:before {
@apply absolute left-0 right-0 -top-0.5 m-auto h-2 w-36 bg-no-repeat bg-center z-10;
content: "";
background-image: url(@ASSET/img/state-box-top.png);
}
.state-box-col:after {
@apply absolute left-0 right-0 -bottom-0.5 m-auto h-2 w-36 bg-no-repeat bg-center z-10;
content: "";
background-image: url(@ASSET/img/state-box-bottom.png);
}
.state-box-col {
@apply border border-light-info shadow-md shadow-blue-300 rounded-sm py-2 px-6 text-white relative;
}
.state-box:after {
@apply absolute right-3 top-3 w-4 h-4 bg-no-repeat bg-center z-10;
content: "";
background-image: url(@ASSET/img/state-title01.svg);
}
.state-box:before {
@apply absolute right-0.5 bottom-5 w-4 h-32 bg-no-repeat bg-center z-10;
content: "";
background-image: url(@ASSET/img/state-ul-background02.svg);
}
.state-box .title {
@apply relative flex items-center mb-1;
}
.state-box .title .state-title01 {
@apply w-4 mr-1.5;
}
.state-box .title .state-title02 {
@apply w-5 ml-1.5;
}
</style>

View File

@ -0,0 +1,132 @@
<script setup>
import { ref, onMounted, defineProps, inject, watch } from "vue";
import useActiveBtn from "@/hooks/useActiveBtn";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps({
onCancel: Function,
modalData: Object,
});
const { items, changeActiveBtn, setItems, selectedBtn } = useActiveBtn();
const detailData = ref([]);
onMounted(() => {
setItems([
{
title: t("alert.online"),
key: "online",
active: true,
},
{
title: t("alert.offline"),
key: "offline",
active: false,
},
{
title: t("alert.alarm"),
key: "alarm",
active: false,
},
]);
});
watch(selectedBtn, (newVal, oldVal) => {
if (newVal) {
detailData.value = props.modalData[newVal.key];
}
});
watch(
() => props.modalData,
(newVal, oldVal) => {
if (newVal) {
changeActiveBtn(items.value[0]);
}
},
{ immediate: true }
);
</script>
<template>
<Modal id="system_status_modal" :onCancel="onCancel" :width="600">
<template #modalTitle>
<div class="flex items-center justify-between">
<ButtonGroup
:items="items"
:withLine="true"
className="btn-sm"
:onclick="
(e, item) => {
changeActiveBtn(item);
}
"
/>
<button
type="link"
class="btn-link btn-text-without-border px-2"
@click="onCancel"
>
<font-awesome-icon :icon="['fas', 'times']" class="text-[#a5abb1]" />
</button>
</div>
</template>
<template #modalContent>
<div class="overflow-x-auto">
<table class="table text-base mt-5">
<thead>
<tr>
<th
class="text-base border text-white text-center bg-cyan-600 bg-opacity-30"
>
{{ $t("table.serial_number") }}
</th>
<th
class="text-base border text-white text-center bg-cyan-600 bg-opacity-30"
>
{{ $t("history.building_name") }}
</th>
<th
class="text-base border text-white text-center bg-cyan-600 bg-opacity-30"
>
{{ $t("table.name") }}
</th>
<th
class="text-base border text-white text-center bg-cyan-600 bg-opacity-30"
>
{{ $t("operation.updated_time") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-if="detailData?.length > 0"
v-for="(equipment, index) in detailData"
:key="index"
class="hover:bg-gray-700"
>
<td class="border text-white text-center">
{{ index + 1 }}
</td>
<td class="border text-white text-center">
{{ equipment.building_name }}
</td>
<td class="border text-white text-center">
{{ equipment.name }}
</td>
<td class="border text-white text-center">
{{ equipment.time || "-" }}
</td>
</tr>
<tr v-else>
<td colspan="4" class="border text-white text-center">
{{ $t("table.no_data") }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
</Modal>
</template>
<style lang="scss" scoped></style>

View File

@ -37,8 +37,15 @@ const doLogin = async () => {
const res = await Login(value); const res = await Login(value);
if (res.isSuccess) { if (res.isSuccess) {
store.user = res.data; store.user = res.data;
localStorage.setItem("CviBuildingList", JSON.stringify(res.data.buildingIdList)); localStorage.setItem(
router.replace({ path: "/dashboard" }); "CviBuildingList",
JSON.stringify(res.data.building_infos)
);
if (res.data.building_infos.map((b) => b.is_headquarter).includes(true)) {
router.replace({ path: "/headquarters" });
} else {
router.replace({ path: "/dashboard" });
}
} else { } else {
openToast("error", res.msg); openToast("error", res.msg);
} }
@ -47,7 +54,7 @@ const doLogin = async () => {
<template> <template>
<div <div
class="absolute top-10 left-1/2 -translate-x-1/2 z-20 border-4 rounded-3xl p-5 bg-white bg-opacity-60" class="absolute top-10 left-1/2 -translate-x-1/2 z-20 border-2 border-slate-200/40 shadow-lg shadow-slate-100/20 rounded-3xl p-5 bg-gradient-to-br from-slate-600 to-slate-800 bg-opacity-60"
> >
<div class="flex flex-col items-start w-96"> <div class="flex flex-col items-start w-96">
<div <div
@ -58,7 +65,7 @@ const doLogin = async () => {
</div> </div>
<div class="w-full flex flex-col items-end my-2"> <div class="w-full flex flex-col items-end my-2">
<div class="w-full flex justify-between"> <div class="w-full flex justify-between">
<label class="mr-2 text-2xl text-black" for="account">{{ <label class="mr-2 text-2xl text-slate-200" for="account">{{
$t("account") $t("account")
}}</label> }}</label>
<input <input
@ -74,7 +81,7 @@ const doLogin = async () => {
</div> </div>
<div class="w-full flex flex-col items-end my-2"> <div class="w-full flex flex-col items-end my-2">
<div class="w-full flex justify-between relative"> <div class="w-full flex justify-between relative">
<label class="mr-2 text-2xl text-black" for="password">{{ <label class="mr-2 text-2xl text-slate-200" for="password">{{
$t("password") $t("password")
}}</label> }}</label>
<input <input

View File

@ -130,11 +130,6 @@ watch(
(newBuilding) => { (newBuilding) => {
if (Boolean(newBuilding)) { if (Boolean(newBuilding)) {
getData(); getData();
imgBaseUrl.value = buildingStore.previewImageExt
? `${FILE_BASEURL}/upload/setting/previewImage/${newBuilding.building_guid}${buildingStore.previewImageExt}`
: import.meta.env.MODE === "production"
? "dist/build_img.jpg"
: "/build_img.jpg";
} }
}, },
{ {
@ -143,6 +138,18 @@ watch(
} }
); );
watch(
[() => buildingStore.previewImageExt, () => buildingStore.selectedBuilding?.building_guid],
([newExt, buildingGuid]) => {
if (newExt && buildingGuid) {
imgBaseUrl.value = `${FILE_BASEURL}/upload/setting/previewImage/${buildingGuid}${newExt}`;
} else {
imgBaseUrl.value = `${FILE_BASEURL}/upload/build_img.jpg`;
}
},
{ immediate: true }
);
watch( watch(
() => deptData.value, () => deptData.value,
(newVal) => { (newVal) => {
@ -184,7 +191,7 @@ const getAllDeviceRealtime = async () => {
const res = await getSystemRealTime( const res = await getSystemRealTime(
subscribeData.value.map((d) => d.device_number) subscribeData.value.map((d) => d.device_number)
); );
console.log(res.data); console.log("realtimeData",res.data);
realtimeData.value = res.data; realtimeData.value = res.data;
}; };
await fetchData(); // await fetchData(); //
@ -262,13 +269,13 @@ const getCurrentInfoModalData = async (e, position, value) => {
document.getElementById("system_info_modal").showModal(); document.getElementById("system_info_modal").showModal();
}; };
const selectedDeviceRealtime = computed( const selectedDeviceRealtime = computed(() => {
() => const deviceNumber = selectedDevice.value?.value?.device_number;
realtimeData.value?.find( if (!deviceNumber) return [];
({ device_number }) => return realtimeData.value
device_number === selectedDevice.value?.value?.device_number .filter(item => item.device_number === deviceNumber && Array.isArray(item.data))
)?.data .flatMap(item => item.data.map(dataItem => ({ ...dataItem, topic_publish: item.topic_publish })));
); });
const clearSelectedDeviceInfo = () => { const clearSelectedDeviceInfo = () => {
selectedDevice.value.value = null; selectedDevice.value.value = null;

View File

@ -25,8 +25,8 @@ const getDeviceStatusColor = (device) => {
if (device.full_name === 'SmartSocket-AA001') return 'red'; if (device.full_name === 'SmartSocket-AA001') return 'red';
if (device.full_name === 'SmartSocket-AA003' || device.full_name === 'SmartSocket-AA004') return 'gray'; if (device.full_name === 'SmartSocket-AA003' || device.full_name === 'SmartSocket-AA004') return 'gray';
const state = getDeviceRealtimeState(device.device_number); const state = getDeviceRealtimeState(device.device_number);
if (state === 'offnormal' || state === '') return device.device_close_color || 'gray'; if (state === 'offnormal' || state === '') return device.device_close_color || '#9D9D9D';
return device.device_normal_color; return device.device_normal_color || '#009100';
}; };
// //

View File

@ -1,8 +1,9 @@
<script setup> <script setup>
import { ref, computed, inject, watch } from "vue"; import { ref, computed, inject, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { toggleDevicePower } from "@/apis/system";
import dayjs from "dayjs"; import dayjs from "dayjs";
const { openToast } = inject("app_toast");
const { t } = useI18n(); const { t } = useI18n();
const { selectedDevice, selectedDeviceRealtime } = inject( const { selectedDevice, selectedDeviceRealtime } = inject(
"system_selectedDevice" "system_selectedDevice"
@ -13,7 +14,7 @@ const groupedData = computed(() => {
if (selectedDeviceRealtime?.value) { if (selectedDeviceRealtime?.value) {
selectedDeviceRealtime.value.forEach((record) => { selectedDeviceRealtime.value.forEach((record) => {
const { ori_device_number, time } = record; const { ori_device_number, time, topic_publish } = record;
if (!grouped[ori_device_number]) { if (!grouped[ori_device_number]) {
grouped[ori_device_number] = { grouped[ori_device_number] = {
@ -26,6 +27,8 @@ const groupedData = computed(() => {
if (record.point === d.points) { if (record.point === d.points) {
grouped[ori_device_number].data.push({ grouped[ori_device_number].data.push({
...d, ...d,
device_item_id: record.device_item_id,
topic_publish: record.topic_publish,
value: record.value, value: record.value,
}); });
} }
@ -36,9 +39,19 @@ const groupedData = computed(() => {
return grouped; return grouped;
}); });
const togglePowerSwitch = (e, val) => { const togglePowerSwitch = async (e, device_item_id, topic_publish) => {
const isChecked = e.target.checked; const isChecked = e.target.checked;
console.log("Power Switch", e, val, "狀態:", isChecked); const res = await toggleDevicePower({
device_item_id,
topic_publish,
new_value: isChecked,
});
if (res.isSuccess) {
openToast("success", t("msg.edit_successfully"), "#system_info_modal");
} else {
openToast("error", res.msg, "#system_info_modal");
}
}; };
</script> </script>
@ -64,21 +77,23 @@ const togglePowerSwitch = (e, val) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(group, index) in group.data" :key="index"> <tr v-for="(items, index) in group.data" :key="index">
<td class="border text-white text-lg text-center"> <td class="border text-white text-lg text-center">
{{ group.full_name }} {{ items.full_name }}
</td> </td>
<td class="border text-white text-lg text-center"> <td class="border text-white text-lg text-center">
<template v-if="group.full_name === 'Power Switch'"> <template v-if="items.full_name === 'Power Switch'">
<input <input
type="checkbox" type="checkbox"
class="toggle toggle-success" class="toggle toggle-success"
:checked="group.value" :checked="items.value"
@change="togglePowerSwitch($event, group.value)" @change="
togglePowerSwitch($event, items.device_item_id, items.topic_publish)
"
/> />
</template> </template>
<template v-else> <template v-else>
{{ group.value }} {{ items.value }}
</template> </template>
</td> </td>
</tr> </tr>