This commit is contained in:
koko 2025-02-27 16:00:06 +08:00
commit a5dcec3045
37 changed files with 5419 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>監控系統</title>
<script type="text/javascript" src="/requirejs/config.js?"></script>
<script
type="text/javascript"
src="/module/js/com/tridium/js/ext/require/require.min.js?"
></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2133
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "niagara-vue-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^3.0.8",
"@tailwindcss/vite": "^4.0.3",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"echarts": "^5.6.0",
"pinia": "^2.3.1",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.3",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.5"
}
}

BIN
public/build.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

3
public/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"systemName": "關渡醫院中央監控"
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

56
src/App.vue Normal file
View File

@ -0,0 +1,56 @@
<script setup>
import { onMounted, ref, provide } from "vue";
import { RouterLink, RouterView } from "vue-router";
import LeftSidebar from "./components/navbar/LeftSidebar.vue";
import Navbar from "./components/navbar/Navbar.vue";
import useUserStore from "@/stores/useUserStore";
import useNiagaraDataStore from "@/stores/useNiagaraDataStore";
import useNavDataStore from "@/stores/useNavDataStore";
const showSidebar = ref(false);
const systemName = ref("");
const userStore = useUserStore();
const niagaraStore = useNiagaraDataStore();
const navStore = useNavDataStore();
const toggleSidebar = () => {
showSidebar.value = !showSidebar.value;
};
onMounted(async () => {
const json = await fetch("./config.json");
const res = await json.json();
systemName.value = res.systemName;
await userStore.loadUserInfo();
await niagaraStore.getSystemData();
await navStore.getNavData();
});
</script>
<template>
<a-layout class="w-full" style="min-height: 100vh">
<LeftSidebar
:showSidebar="showSidebar"
:toggleSidebar="toggleSidebar"
/>
<Navbar
:systemName="systemName"
:open="toggleSidebar"
:userName="userStore.userName"
/>
<a-layout-content
class="overflow-x-hidden"
:style="{
margin: '5px',
padding: '0px',
background: '#fff',
minHeight: '280px',
}"
>
<RouterView />
</a-layout-content>
</a-layout>
</template>
<style scoped></style>

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

1300
src/baja.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,99 @@
<script setup>
import { ref, onMounted, watch } from "vue";
import useNiagaraDataStore from "@/stores/useNiagaraDataStore";
import useAlarmDataStore from "@/stores/useAlarmDataStore";
const niagaraStore = useNiagaraDataStore();
const alarmDataStore = useAlarmDataStore();
//
const columns = [
{ title: "Name", key: "name" },
{ title: "In Alarm Count", key: "alarmCount" },
{ title: "Unacked Count", key: "unackedCount" },
];
//
const initializeAlarmData = () => {
// 使 useAlarmDataStore alarmData
if(!alarmDataStore.alarmData.length){
alarmDataStore.createAlarmData(niagaraStore.alarmList);
}
// alarm
alarmDataStore.alarmData.forEach((alarm, index) => {
if (!alarm.alarmOrd) return;
window.require &&
window.requirejs(["baja!"], (baja) => {
//
const subscriber = new baja.Subscriber();
// changed
subscriber.attach("changed", (prop) => {
// console.log("prop", prop.$getDisplayName(), prop.$getValue());
try {
let alarmCount = alarmDataStore.alarmData[index].alarmCount;
let unackedCount = alarmDataStore.alarmData[index].unackedCount;
if (prop.$getDisplayName() === "In Alarm Count") {
// In Alarm Count
alarmCount = prop.$getValue();
}
if (prop.$getDisplayName() === "Unacked Alarm Count") {
// Unacked Alarm Count
unackedCount = prop.$getValue();
}
// useAlarmDataStore
alarmDataStore.updateAlarmItem(index, alarmCount, unackedCount);
} catch (error) {
console.error(
`處理 ${alarm.name || index} 告警變化失敗: ${error.message}`,
error
);
}
});
// alarm
baja.Ord.make(alarm.alarmOrd)
.get({ subscriber })
.then((result) => {
console.log(`Successfuly subscribed to alarm ${alarm.name}`);
})
.catch((err) => {
console.error(
`訂閱 Alarm ${alarm.name || index} 失敗: ${err.message}`
);
subscriber.detach("changed");
});
});
});
};
watch(
() => niagaraStore.alarmList,
(newValue, oldValue) => {
if (newValue) {
console.log("niagaraStore.alarmList changed:", newValue);
initializeAlarmData();
}
},
{ immediate: true } //
);
</script>
<template>
<div>
<a-table
:columns="columns"
:data-source="alarmDataStore.alarmData"
bordered
>
<template #bodyCell="{ column, record }">
<span>{{ record[column.key] }}</span>
</template>
</a-table>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,111 @@
<script setup>
import { ref, onMounted } from "vue";
import * as echarts from "echarts"; // echarts
//
const yesterdayTodayData = {
categories: [
"00:00",
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
"07:00",
"08:00",
"09:00",
"10:00",
"11:00",
],
values: [
{
name: `2025.01.8 用電量`,
value: [8, 8, 8, 8, 8, 15, 25, 65, 75, 60, 70, 65],
},
{
name: `2025.01.7 用電量`,
value: [10, 10, 10, 10, 10, 20, 30, 80, 90, 70, 80, 85],
},
],
};
//
const weekComparisonData = {
categories: ["Fri", "Sat", "Sun", "Mon", "Tue", "Wed", "Thu"],
values: [
{
name: "本週用電量",
value: [850, 200, 350, 850, 950, 950, 900],
},
{
name: "上週用電量",
value: [800, 150, 300, 750, 900, 900, 800],
},
],
};
const yesterdayTodayChart = ref(null);
const weekComparisonChart = ref(null);
onMounted(() => {
//
const yesterdayTodayEChart = echarts.init(yesterdayTodayChart.value);
yesterdayTodayEChart.setOption(getBarChartOptions(yesterdayTodayData));
//
const weekComparisonEChart = echarts.init(weekComparisonChart.value);
weekComparisonEChart.setOption(getBarChartOptions(weekComparisonData));
});
//
const getBarChartOptions = (data) => {
return {
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
},
},
legend: {
data: data.values.map((item) => item.name),
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true,
},
xAxis: {
type: "category",
data: data.categories,
},
yAxis: {
type: "value",
},
series: data.values.map((item) => ({
name: item.name,
type: "bar",
barGap: 0.1,
emphasis: {
focus: "series",
},
data: item.value,
})),
};
};
</script>
<template>
<a-row :gutter="24" class="p-5">
<a-col :span="12">
<div ref="yesterdayTodayChart" style="width: 100%; height: 350px"></div>
</a-col>
<a-col :span="12">
<div ref="weekComparisonChart" style="width: 100%; height: 350px"></div>
</a-col>
</a-row>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,43 @@
<script setup>
const mockData = [
{
value: 305.5,
label: "今日用電量",
unit:"kWH"
},
{
value: 886.75,
label: "昨日用電量",
unit:"kWH"
},
{
value: 7.84,
label: "即時功率",
unit:"kW"
},
{
value: 20.96,
label: "容積占比",
unit:"%"
},
];
</script>
<template>
<a-row :gutter="24" class="p-5">
<a-col v-for="(item, index) in mockData" :key="index" :span="6">
<a-card class="shadow">
<a-statistic
:title="item.label"
:value="item.value"
:precision="2"
:suffix="item.unit"
:value-style="{ color: index % 2 === 0 ? '#3f8600' : '#1677ff' }"
>
</a-statistic>
</a-card>
</a-col>
</a-row>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,86 @@
<script setup>
import { ref, computed, watch } from "vue";
import useNavDataStore from "@/stores/useNavDataStore";
import { useRouter } from "vue-router";
const navStore = useNavDataStore();
const router = useRouter();
const filteredItems = computed(() => {
const flattenedItems = [];
const selectedBuildingOrd = navStore.selectedBuildingOrd;
//
const flatten = (items) => {
if (!items) return;
items.forEach((item) => {
// ord null
if (item.ord !== "null") {
flattenedItems.push({
key: item.key,
name: item.title,
ord: item.ord,
icon: item.icon,
});
}
//
if (item.children) {
flatten(item.children);
}
});
};
//
if (
navStore.menuList &&
navStore.menuList.length > 0 &&
selectedBuildingOrd
) {
const buildingMenu = navStore.menuList.find(
(item) => item.label === selectedBuildingOrd
);
flatten(buildingMenu.children);
}
return flattenedItems;
});
const handleClick = (ord) => {
if (ord) {
router.push({
name: "baja",
query: { ord: encodeURIComponent(ord) },
});
}
};
</script>
<template>
<div v-if="filteredItems && filteredItems.length > 0">
<a-row :gutter="[8, 16]">
<a-col :span="6" v-for="(item, index) in filteredItems" :key="index">
<a-card
@click="handleClick(item.ord)"
class="shadow"
:bodyStyle="{
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
}"
>
<img
v-if="item.icon"
:src="item.icon"
alt="Icon"
style="width: 30px; height: 30px; vertical-align: middle"
/>
<span class="text-lg">{{ item.name }}</span>
</a-card>
</a-col>
</a-row>
</div>
<div v-else>No items to display.</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,82 @@
<script setup>
import { ref, onMounted, watch, onUnmounted } from "vue";
import * as echarts from "echarts";
const chartContainer = ref(null);
let chartInstance = null;
const props = defineProps({
chartData: {
type: Object,
required: true,
},
});
const initChart = () => {
chartInstance = echarts.init(chartContainer.value);
const option = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow",
},
},
legend: {
data: props.chartData.series.map((item) => item.name),
bottom: "0%",
},
grid: {
top: "5%",
left: "5%",
right: "5%",
bottom: "10%",
containLabel: true,
},
xAxis: {
type: "category",
data: props.chartData.categories || [],
},
yAxis: {
type: "value",
},
series: props.chartData.series.map((item) => ({
...item,
stack: "total",
})),
};
chartInstance.setOption(option);
};
onMounted(() => {
initChart();
});
watch(
() => props.chartData,
(newChartData) => {
if (chartInstance) {
chartInstance.setOption({
xAxis: {
data: newChartData.categories || [],
},
series: newChartData.series.map((item) => ({
...item,
stack: "total",
})),
});
}
},
{ deep: true }
);
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose();
}
});
</script>
<template>
<div ref="chartContainer" style="width: 100%; height: 200px"></div>
</template>

View File

@ -0,0 +1,105 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts"; // echarts
//
const data = {
categories: [
"16:22:29",
"16:22:37",
"16:22:47",
"16:23:00",
"16:23:08",
"16:23:18",
],
series: [
{
name: "即時趨勢",
type: "line",
data: [320, 310, 300, 305, 310, 300],
smooth: true,
lineStyle: {
width: 3,
},
},
{
name: "契約容量",
type: "line",
data: [400, 400, 400, 400, 400, 400],
smooth: true,
lineStyle: {
width: 3,
},
},
{
name: "警戒容量",
type: "line",
data: [350, 350, 350, 350, 350, 350],
smooth: true,
lineStyle: {
width: 3,
},
},
{
name: "偵測值",
type: "line",
data: [280, 300, 290, 295, 300, 290],
smooth: true,
lineStyle: {
width: 3,
},
},
],
};
const demand_chart = ref(null);
const defaultChartOption = ref({
tooltip: {
trigger: "axis",
},
legend: {
data: data.series.map((s) => s.name),
orient: "horizontal",
bottom: "0%",
},
grid: {
top: "10%",
left: "0%",
right: "5%",
bottom: "10%",
containLabel: true,
},
xAxis: {
type: "category",
data: data.categories,
},
yAxis: {
type: "value",
},
series: data.series,
});
let chartInstance = null; //
onMounted(() => {
chartInstance = echarts.init(demand_chart.value);
chartInstance.setOption(defaultChartOption.value);
window.addEventListener("resize", () => {
chartInstance.resize();
});
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose();
}
window.removeEventListener("resize", () => {
chartInstance.resize();
});
});
</script>
<template>
<div ref="demand_chart" style="width: 100%; height: 200px"></div>
</template>
<style scoped></style>

View File

@ -0,0 +1,76 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import * as echarts from "echarts";
const chartContainer = ref(null);
let chart = null;
onMounted(() => {
initChart();
});
const initChart = () => {
chart = echarts.init(chartContainer.value);
const option = {
tooltip: {
trigger: "item",
triggerOn: "mousemove",
},
series: [
{
type: "sankey",
layout: "none",
emphasis: {
focus: "adjacency",
},
data: [
{ name: "總用電" },
{ name: "照明" },
{ name: "空調" },
{ name: "設備A" },
{ name: "設備B" },
{ name: "其他" },
],
links: [
{ source: "總用電", target: "照明", value: 100 },
{ source: "總用電", target: "空調", value: 150 },
{ source: "總用電", target: "設備A", value: 80 },
{ source: "總用電", target: "設備B", value: 120 },
{ source: "總用電", target: "其他", value: 50 },
],
lineStyle: {
color: "gradient",
opacity: 0.7,
curveness: 0.5,
},
itemStyle: {
borderWidth: 0,
},
},
],
};
chart.setOption(option); //
// 調
window.addEventListener("resize", () => {
chart.resize();
});
};
//
onUnmounted(() => {
if (chart) {
chart.dispose();
}
window.removeEventListener("resize", () => {
chart.resize();
});
});
</script>
<template>
<div ref="chartContainer" style="width: 100%; height: 200px"></div>
</template>
<style scoped></style>

View File

@ -0,0 +1,74 @@
<script setup>
import { defineProps, ref, watch, computed, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import useNavDataStore from "@/stores/useNavDataStore";
const router = useRouter();
const props = defineProps({
showSidebar: {
type: Boolean,
},
toggleSidebar: {
type: Function,
},
});
const navStore = useNavDataStore();
const selectedKeys = ref([]); // menu item
const openKeys = ref([]); // submenu
const preOpenKeys = ref([]); // submenu
const filteredItems = computed(() => {
const selectedBuildingOrd = navStore.selectedBuildingOrd;
if (
navStore.menuList &&
navStore.menuList.length > 0 &&
selectedBuildingOrd
) {
const buildingMenu = navStore.menuList[0].children.find(
(item) => item.key === selectedBuildingOrd
);
return buildingMenu.children;
}
return [];
});
const handleClick = (ord) => {
if (ord) {
router.push({
name: "baja",
query: { ord: encodeURIComponent(ord) },
});
}
};
// openKeys
watch(
() => openKeys.value,
(_val, oldVal) => {
preOpenKeys.value = oldVal;
},
{ deep: true }
);
</script>
<template>
<a-drawer
:open="showSidebar"
@close="toggleSidebar"
:closable="false"
placement="left"
width="300"
bodyStyle="padding: 0"
>
<a-menu mode="inline" theme="light">
<template v-for="(item, index) in filteredItems" :key="index">
<a-menu-item v-if="!item.children" :key="item.key" @click="handleClick(item.ord)">
{{ item.label }}
</a-menu-item>
<a-sub-menu v-else :title="item.label" :key="`submenu-${item.key}`">
<a-menu-item v-for="child in item.children" :key="child.key" @click="handleClick(child.ord)">
{{ child.label }}
</a-menu-item>
</a-sub-menu>
</template>
</a-menu>
</a-drawer>
</template>

View File

@ -0,0 +1,111 @@
<script setup>
import { ref, watch, computed } from "vue";
import { useRouter } from "vue-router";
import useNiagaraDataStore from "@/stores/useNiagaraDataStore";
import useAlarmDataStore from "@/stores/useAlarmDataStore";
const niagaraStore = useNiagaraDataStore();
const alarmDataStore = useAlarmDataStore();
// alarmCount
const totalAlarmCount = computed(() => {
return alarmDataStore.alarmData.reduce((total, child) => total + (child.alarmCount || 0), 0);
});
const initializeAlarmData = () => {
// 使 useAlarmDataStore alarmData
if (!alarmDataStore.alarmData.length) {
alarmDataStore.createAlarmData(niagaraStore.alarmList);
}
// alarm
alarmDataStore.alarmData.forEach((alarm, index) => {
if (!alarm.alarmOrd) return;
window.require &&
window.requirejs(["baja!"], (baja) => {
//
const subscriber = new baja.Subscriber();
// changed
subscriber.attach("changed", (prop) => {
// console.log("prop", prop.$getDisplayName(), prop.$getValue());
try {
let alarmCount = alarmDataStore.alarmData[index].alarmCount;
let unackedCount = alarmDataStore.alarmData[index].unackedCount;
if (prop.$getDisplayName() === "In Alarm Count") {
// In Alarm Count
alarmCount = prop.$getValue();
}
if (prop.$getDisplayName() === "Unacked Alarm Count") {
// Unacked Alarm Count
unackedCount = prop.$getValue();
}
// useAlarmDataStore
alarmDataStore.updateAlarmItem(index, alarmCount, unackedCount);
} catch (error) {
console.error(
`處理 ${alarm.name || index} 告警變化失敗: ${error.message}`,
error
);
}
});
// alarm
baja.Ord.make(alarm.alarmOrd)
.get({ subscriber })
.then((result) => {
console.log(`Successfuly subscribed to alarm ${alarm.name}`);
})
.catch((err) => {
console.error(
`訂閱 Alarm ${alarm.name || index} 失敗: ${err.message}`
);
subscriber.detach("changed");
});
});
});
};
watch(
() => niagaraStore.alarmList.children,
(newValue, oldValue) => {
if (newValue) {
console.log("niagaraStore.alarmList changed:", newValue);
initializeAlarmData();
}
},
{ immediate: true } //
);
</script>
<template>
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item v-for="child in alarmDataStore.alarmData" :key="child.key">
<router-link
:to="{
name: 'baja',
query: { ord: encodeURIComponent(child.Ord) },
}"
class="flex items-center justify-between gap-8"
>
<span>{{ child.name }}</span>
<span>{{ child.alarmCount }}/{{ child.unackedCount }}</span>
</router-link>
</a-menu-item>
</a-menu>
</template>
<a-badge :count="totalAlarmCount" :overflow-count="999"
>
<a class="flex flex-col items-center">
<font-awesome-icon :icon="['fas', 'bell']" size="2x" />
<span class="text-sm">告警</span>
</a>
</a-badge>
</a-dropdown>
</template>
<style lang="css" scoped></style>

View File

@ -0,0 +1,44 @@
<script setup>
import { ref, watch } from "vue";
import { useRouter } from "vue-router";
import useNavDataStore from "@/stores/useNavDataStore";
const navStore = useNavDataStore();
const router = useRouter();
const buildmenu = ref([]);
const handleBuildClick = (key) => {
navStore.setSelectedBuildingOrd(key);
};
watch(
() => navStore.menuList,
(newValue, oldValue) => {
if (newValue && newValue.length > 0 && newValue[0].children) {
buildmenu.value = newValue[0].children;
navStore.setSelectedBuildingOrd(newValue[0].children[0].key);
}
},
{ immediate: true }
);
</script>
<template>
<a-select
v-if="buildmenu && buildmenu.length > 0"
:default-value="buildmenu[0] ? buildmenu[0].key : null"
@change="handleBuildClick"
placeholder="請選擇建築"
>
<a-select-option
v-for="item in buildmenu"
:key="item.key"
:value="item.key"
>
{{ item.label }}
</a-select-option>
</a-select>
</template>
<style lang="css" scoped></style>

View File

@ -0,0 +1,152 @@
<script setup>
import { ref, onMounted, watch, onUnmounted } from "vue";
import useNiagaraDataStore from "@/stores/useNiagaraDataStore";
import {
imagesWeatherDay,
imagesWeatherNight,
orderWeather,
} from "@/constants";
const niagaraStore = useNiagaraDataStore();
const weatherStateText = ref("N/A");
const weatherStateImage = ref(null);
const actualWeather = ref("N/A");
const temperature = ref("N/A");
const humidity = ref("N/A");
const actualNighttime = ref("N/A");
const dateTime = ref("N/A");
let intervalId = null;
//
const initializeData = () => {
niagaraStore.weatherList.children.forEach((item, index) => {
if (!item.ord) return;
window.require &&
window.requirejs(["baja!"], (baja) => {
// subscriber
const subscriber = new baja.Subscriber();
subscriber.attach("changed", (prop) => {
console.log(
"weather",
prop,
prop.$getDisplayName(),
prop.$getValue().getValueDisplay()
);
if (item.name === "Out temperature" && prop.$displayName === "Out") {
temperature.value = prop.$getValue().getValueDisplay();
} else if (
item.name === "Out humidity" &&
prop.$displayName === "Out"
) {
humidity.value = prop.$getValue().getValueDisplay();
} else if (item.name === "Weather" && prop.$displayName === "Out") {
actualWeather.value = prop.$getValue().getValueDisplay();
} else if (item.name === "Nighttime" && prop.$displayName === "Out") {
actualNighttime.value = !(prop.$getValue().getValueDisplay());
}
loadWeather(actualWeather.value);
});
// baja.Ord.make
baja.Ord.make(item.ord)
.get({ subscriber })
.then((result) => {
console.log(`Successfuly subscribed to weather ${item.name}`);
})
.catch((err) => {
console.error(
`訂閱 weather ${item.name || index} 失敗: ${err.message}`
);
subscriber.detach("changed");
});
});
});
};
//
const loadWeather = (actualWeather) => {
let weatherStateTextTemp = "Unknown";
let weatherStateImageTemp = null;
const orderLength = orderWeather.length - 1;
if (orderLength < actualWeather) {
console.error(
"天氣狀態超出範圍!最大限度:" +
orderLength +
"| 實際值:" +
actualWeather
);
weatherStateTextTemp = "Unknown";
weatherStateImageTemp = imagesWeatherDay[weatherStateTextTemp];
} else if (actualWeather === "none") {
console.warn("未設定天氣狀態或點不存在");
weatherStateTextTemp = "Unknown";
weatherStateImageTemp = imagesWeatherDay[weatherStateTextTemp];
} else {
const actualWeatherNum = Number(actualWeather);
if (actualNighttime.value) {
console.log("Nighttime activ");
weatherStateTextTemp = orderWeather[actualWeatherNum];
weatherStateImageTemp = imagesWeatherNight[weatherStateTextTemp];
} else {
console.log("Daytime activ");
weatherStateTextTemp = orderWeather[actualWeatherNum];
weatherStateImageTemp = imagesWeatherDay[weatherStateTextTemp];
}
}
weatherStateImage.value = weatherStateImageTemp;
weatherStateText.value = weatherStateTextTemp; //
};
//
const updateTime = () => {
const date = new Date();
window.require &&
window.requirejs(["baja!"], (baja) => {
const bAbsTime = baja.AbsTime.make({ jsDate: date });
bAbsTime.toDateTimeString().then((dateTimeStr) => {
dateTime.value = dateTimeStr;
});
});
};
watch(
() => niagaraStore.weatherList.children,
(newValue, oldValue) => {
if (newValue) {
console.log("niagaraStore.weatherList changed:", newValue);
initializeData();
}
},
{ immediate: true } //
);
onMounted(() => {
updateTime();
intervalId = setInterval(updateTime, 1000);
});
onUnmounted(() => {
clearInterval(intervalId);
});
</script>
<template>
<div class="leading-none pt-2">
<p class="flex items-center">
<img
v-if="weatherStateImage"
:src="weatherStateImage"
alt="Weather Icon"
style="width: 20px; height: 20px; vertical-align: middle"
/>
- {{ weatherStateText }}
</p>
<p>{{ temperature }} | {{ humidity }}</p>
<p>{{ dateTime }}</p>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,170 @@
<script setup>
import { computed, defineProps } from "vue";
import { useRouter } from "vue-router";
import NavBuild from "./NavBuild.vue";
import NavWeather from "./NavWeather.vue";
import NavAlarm from "./NavAlarm.vue";
import useNiagaraDataStore from "@/stores/useNiagaraDataStore";
import useNavDataStore from "@/stores/useNavDataStore";
const router = useRouter();
const niagaraStore = useNiagaraDataStore();
const navStore = useNavDataStore();
const props = defineProps({
open: Function,
userName: String,
});
const logoUrl = computed(
() => niagaraStore.headerList.children?.[0]?.ord || ""
);
const systemName = computed(
() => niagaraStore.headerList.children?.[1]?.name || "系統監控"
);
const homeData = computed(() => niagaraStore.headerList.children?.[2] || {});
const systemData = computed(() => niagaraStore.headerList.children?.[3] || {});
const dynamicMenu = computed(() => niagaraStore.DynamicList.children || []);
const userList = computed(() => niagaraStore.userList?.children || []);
</script>
<template>
<a-layout-header class="header">
<div class="flex items-center">
<img v-if="logoUrl" :src="logoUrl" alt="Logo" class="logo" />
<a href="./index.html" class="text-2xl font-bold mx-4">{{
systemName
}}</a>
<NavBuild />
</div>
<ul class="nav-menu flex gap-10">
<li>
<router-link
:to="
homeData.ord !== 'null'
? {
name: 'baja',
query: { ord: encodeURIComponent(homeData.ord) },
}
: '/'
"
>
<img
v-if="homeData.icon"
:src="homeData.icon"
alt="home_icon"
class="icon"
/>
<font-awesome-icon v-else :icon="['fas', 'home']" size="2x" />
<span class="text-sm">首頁</span>
</router-link>
</li>
<li>
<a
@click="props.open"
>
<img
v-if="systemData.icon"
:src="systemData.icon"
alt="system_icon"
class="icon"
/>
<font-awesome-icon v-else :icon="['fas', 'tv']" size="2x" />
<span class="text-sm">系統監控</span>
</a>
</li>
<li v-for="item in dynamicMenu" :key="item.key">
<router-link
:to="{ name: 'baja', query: { ord: encodeURIComponent(item.ord) } }"
>
<img v-if="item.icon" :src="item.icon" alt="menu_icon" class="icon" />
<font-awesome-icon v-else :icon="['fas', 'tv']" size="2x" />
<span class="text-sm">{{ item.name }}</span>
</router-link>
</li>
</ul>
<ul class="nav-menu flex gap-10">
<li>
<NavWeather />
</li>
<li>
<NavAlarm />
</li>
<li>
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item v-for="child in userList" :key="child.key">
<router-link
:to="{
name: 'baja',
query: { ord: encodeURIComponent(child.ord) },
}"
>
{{ child.name }}
</router-link>
</a-menu-item>
<a-menu-item>
<a href="/logout">登出</a>
</a-menu-item>
</a-menu>
</template>
<a class="flex flex-col items-center pt-3">
<font-awesome-icon :icon="['fas', 'user']" size="2x" />
<span class="text-sm">{{ props.userName }}</span>
</a>
</a-dropdown>
</li>
</ul>
</a-layout-header>
</template>
<style lang="css" scoped>
.nav-menu li a {
display: flex;
flex-direction: column;
align-items: center;
}
.header {
background-color: #fff;
width: 100%;
top: 0;
z-index: 4;
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header.active {
position: fixed;
top: -75px;
animation: slide-in 0.5s cubic-bezier(0.33, 0.85, 0.4, 0.96) forwards;
box-shadow: 0 2px 25px rgba(0, 0, 0, 0.1);
}
@keyframes slide-in {
0% {
transform: translateY(0);
}
100% {
transform: translateY(75px);
}
}
.logo {
width: 30px;
height: 30px;
vertical-align: middle;
}
.icon {
width: 30px;
height: 30px;
vertical-align: middle;
}
</style>

64
src/constants/images.js Normal file
View File

@ -0,0 +1,64 @@
// src/constants/images.js
export const imagesWeatherDay = {
Clear: "/file/UI_images/weather/dayClear.svg",
Sunny: "/file/UI_images/weather/daySunny.svg",
MostlySunny: "/file/UI_images/weather/dayMostlySunny.svg",
PartlySunny: "/file/UI_images/weather/dayPartlySunny.svg",
Fair: "/file/UI_images/weather/dayFair.svg",
FewClouds: "/file/UI_images/weather/dayPartlySunny.svg",
PartlyCloudy: "/file/UI_images/weather/dayPartlyCloudy.svg",
MostlyCloudy: "/file/UI_images/weather/dayMostlyCloudy.svg",
Cloudy: "/file/UI_images/weather/cloudy.svg",
Overcast: "/file/UI_images/weather/cloudy.svg",
LightRain: "/file/UI_images/weather/lightRain.svg",
Rain: "/file/UI_images/weather/rain.svg",
HeavyRain: "/file/UI_images/weather/heavyRain.svg",
Thunderstorms: "/file/UI_images/weather/thunderstorms.svg",
FreezingRain: "/file/UI_images/weather/freezingRain.svg",
Fog: "/file/UI_images/weather/fog.svg",
Haze: "/file/UI_images/weather/haze.svg",
Snow: "/file/UI_images/weather/snow.svg",
Windy: "/file/UI_images/weather/windy.svg",
Misty: "/file/UI_images/weather/fog.svg",
Dust: "/file/UI_images/weather/dust.svg",
Unknown: "/file/UI_images/weather/unknown.svg",
Tsunami: "/file/UI_images/weather/unknown.svg",
Tornado: "/file/UI_images/weather/unknown.svg",
Flood: "/file/UI_images/weather/unknown.svg",
Fire: "/file/UI_images/weather/unknown.svg",
Hurricane: "/file/UI_images/weather/unknown.svg",
Earthquake: "/file/UI_images/weather/unknown.svg",
Volcano: "/file/UI_images/weather/unknown.svg",
};
export const imagesWeatherNight = {
Clear: "/file/UI_images/weather/nightClear.svg",
Sunny: "/file/UI_images/weather/nightSunny.svg",
MostlySunny: "/file/UI_images/weather/nightMostlySunny.svg",
PartlySunny: "/file/UI_images/weather/nightPartlySunny.svg",
Fair: "/file/UI_images/weather/nightFair.svg",
FewClouds: "/file/UI_images/weather/nightPartlySunny.svg",
PartlyCloudy: "/file/UI_images/weather/nightPartlyCloudy.svg",
MostlyCloudy: "/file/UI_images/weather/nightMostlyCloudy.svg",
Cloudy: "/file/UI_images/weather/cloudy.svg",
Overcast: "/file/UI_images/weather/cloudy.svg",
LightRain: "/file/UI_images/weather/lightRain.svg",
Rain: "/file/UI_images/weather/rain.svg",
HeavyRain: "/file/UI_images/weather/heavyRain.svg",
Thunderstorms: "/file/UI_images/weather/thunderstorms.svg",
FreezingRain: "/file/UI_images/weather/freezingRain.svg",
Fog: "/file/UI_images/weather/fog.svg",
Haze: "/file/UI_images/weather/haze.svg",
Snow: "/file/UI_images/weather/snow.svg",
Windy: "/file/UI_images/weather/windy.svg",
Misty: "/file/UI_images/weather/fog.svg",
Dust: "/file/UI_images/weather/dust.svg",
Unknown: "/file/UI_images/weather/unknown.svg",
Tsunami: "/file/UI_images/weather/unknown.svg",
Tornado: "/file/UI_images/weather/unknown.svg",
Flood: "/file/UI_images/weather/unknown.svg",
Fire: "/file/UI_images/weather/unknown.svg",
Hurricane: "/file/UI_images/weather/unknown.svg",
Earthquake: "/file/UI_images/weather/unknown.svg",
Volcano: "/file/UI_images/weather/unknown.svg",
};

2
src/constants/index.js Normal file
View File

@ -0,0 +1,2 @@
export * from './weather';
export * from './images';

31
src/constants/weather.js Normal file
View File

@ -0,0 +1,31 @@
export const orderWeather = [
"Clear",
"Sunny",
"MostlySunny",
"PartlySunny",
"Fair",
"FewClouds",
"PartlyCloudy",
"MostlyCloudy",
"Cloudy",
"Overcast",
"LightRain",
"Rain",
"HeavyRain",
"Thunderstorms",
"FreezingRain",
"Fog",
"Haze",
"Snow",
"Windy",
"Misty",
"Dust",
"Unknown",
"Tsunami",
"Tornado",
"Flood",
"Fire",
"Hurricane",
"Earthquake",
"Volcano",
];

45
src/main.js Normal file
View File

@ -0,0 +1,45 @@
import { createApp } from "vue";
import "./style.css";
import Antd from "ant-design-vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import { library } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faTv,
faChartPie,
faBell,
faUser,
faDesktop,
faCog,
faSearch,
faHome,
faUserCircle,
faUserEdit,
faInfoCircle,
faSignOutAlt,
faAngleDown
} from "@fortawesome/free-solid-svg-icons";
library.add(
faTv,
faChartPie,
faBell,
faUser,
faDesktop,
faCog,
faSearch,
faHome,
faUserCircle,
faUserEdit,
faInfoCircle,
faSignOutAlt,
faAngleDown
);
const pinia = createPinia();
const app = createApp(App);
app.component("font-awesome-icon", FontAwesomeIcon);
app.use(pinia);
app.use(router);
app.use(Antd);
app.mount("#app");

27
src/router/index.js Normal file
View File

@ -0,0 +1,27 @@
import { createRouter, createWebHashHistory } from "vue-router";
import DashboardPage from "@/views/dashboard/DashboardPage.vue";
import SystemPage from "@/views/system/SystemPage.vue";
import EnergyChart from "@/views/energy/EnergyChart.vue";
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "dashboardPage",
component: DashboardPage,
},
{
path: "/baja",
name: "baja",
component: SystemPage,
},
{
path: "/energy",
name: "energy",
component: EnergyChart,
}
],
});
export default router;

View File

@ -0,0 +1,34 @@
// stores/useAlarmDataStore.js
import { defineStore } from "pinia";
import { ref } from "vue";
const useAlarmDataStore = defineStore("alarmData", () => {
const alarmData = ref([]);
// 建立 alarmData 的函數
const createAlarmData = (alarmList) => {
alarmData.value = alarmList.children.map((item, index) => ({
key: item.key,
name: item.name || `Alarm ${index + 1}`,
alarmCount: 0,
unackedCount: 0,
alarmOrd: item.children && item.children[0] ? item.children[0].ord : null, // 儲存 alarmOrd
Ord: item.ord ? item.ord : null,
}));
};
// 更新特定 alarm 數據的函數
const updateAlarmItem = (index, alarmCount, unackedCount) => {
if (index >= 0 && index < alarmData.value.length) {
alarmData.value.splice(index, 1, {
...alarmData.value[index],
alarmCount,
unackedCount,
});
}
};
return { alarmData, createAlarmData, updateAlarmItem };
});
export default useAlarmDataStore;

View File

@ -0,0 +1,91 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import useUserStore from "@/stores/useUserStore";
const useNavDataStore = defineStore("nav", () => {
const userStore = useUserStore();
const selectedBuildingOrd = ref(null);
const menuList = ref([]);
const setSelectedBuildingOrd = (ord) => {
selectedBuildingOrd.value = ord;
};
const getNavData = () => {
if (window.require && window.requirejs) {
window.requirejs(["baja!"], (baja) => {
console.log("進入 bajaSubscriber 準備執行 BQL 訂閱");
const ord = `local:|foxs:|file:^UI_Framework/System/S_${userStore.userRole}.nav`;
baja.Ord.make(ord)
.get()
.then((nav) => {
if (!nav || !nav.$fileData) {
console.error("未能取得有效 NAV 資料!");
} else {
console.log("成功讀取 NAV 資料", nav);
// 將 XML 文本解析成 DOM 結構
fetch(nav.$fileData.read)
.then((response) => response.text())
.then((xmlText) => parseXmlData(xmlText))
.catch((err) => {
console.error(`讀取 NAV 檔案失敗:${err.message}`);
});
}
})
.catch((err) => {
console.error(`讀取 NAV 檔案失敗:${err.message}`);
});
});
} else {
console.error("未能載入 Baja 環境,請確認依賴已正確加載。");
}
};
const parseXmlData = (xmlText) => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "application/xml");
const navNodes = xmlDoc.getElementsByTagName("node");
if (!navNodes.length) {
console.error("無法解析 NAV XML 結構。");
return;
}
// 只處理第一個節點
const firstNode = navNodes[0];
menuList.value = firstNode ? [parseNode(firstNode, "1")] : [];
console.log("menuList.value", menuList.value);
};
// 遞迴解析 XML 結構
const parseNode = (node, key) => {
const name = node.getAttribute("name");
const ord = node.getAttribute("ord");
const icon = node.getAttribute("icon");
// 處理子節點遞迴
const childrenNodes = Array.from(node.children);
const children = childrenNodes.length > 0 ? childrenNodes.map((child, index) => parseNode(child, `${key}-${index + 1}`)) : null;
return {
key,
label: name,
title: name,
icon,
ord,
children,
};
};
return {
selectedBuildingOrd,
setSelectedBuildingOrd,
menuList,
getNavData,
};
});
export default useNavDataStore;

View File

@ -0,0 +1,145 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import useUserStore from "@/stores/useUserStore";
const useNiagaraDataStore = defineStore("niagara", () => {
const userStore = useUserStore();
const systemList = ref([]);
const headerList = ref([]);
const weatherList = ref([]);
const alarmList = ref([]);
const userList = ref([]);
const DynamicList = ref([]);
const getSystemData = () => {
if (window.require && window.requirejs) {
window.requirejs(["baja!"], (baja) => {
console.log("進入 bajaSubscriber 準備執行 BQL 訂閱");
const ord = `local:|foxs:|file:^UI_Framework/Function/F_${userStore.userRole}.nav`;
baja.Ord.make(ord)
.get()
.then((nav) => {
if (!nav || !nav.$fileData) {
console.error("未能取得有效 NAV 資料!");
} else {
console.log("成功讀取 NAV 資料", nav);
// 將 XML 文本解析成 DOM 結構
fetch(nav.$fileData.read)
.then((response) => response.text())
.then((xmlText) => parseXmlData(xmlText))
.catch((err) => {
console.error(`讀取 NAV 檔案失敗:${err.message}`);
});
}
})
.catch((err) => {
console.error(`讀取 NAV 檔案失敗:${err.message}`);
});
});
} else {
console.error("未能載入 Baja 環境,請確認依賴已正確加載。");
}
};
const parseXmlData = (xmlText) => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "application/xml");
const navNodes = xmlDoc.getElementsByTagName("node");
if (!navNodes.length) {
console.error("無法解析 NAV XML 結構。");
return;
}
// 轉換 XML node 結構為物件
const firstNode = navNodes[0];
systemList.value = firstNode ? [parseNode(firstNode, "1")] : [];
console.log("systemList.value", systemList.value);
const Fix_Node = systemList.value[0].children.find(
(node) => node.name === "Fix_Item"
);
const Dynamic_Node = systemList.value[0].children.find(
(node) => node.name === "Dynamic_Item"
);
// 查找 "Fix_Item" 節點下各節點的list
if (Fix_Node && Fix_Node.children) {
headerList.value =
Fix_Node.children.find((node) => node.name === "header") || null;
weatherList.value =
Fix_Node.children.find((node) => node.name === "weather") || null;
alarmList.value =
Fix_Node.children.find((node) => node.name === "alarmlist") || null;
userList.value =
Fix_Node.children.find((node) => node.name === "user") || null;
}
if (Dynamic_Node && Dynamic_Node.children) {
DynamicList.value = Dynamic_Node || null;
}
console.log(
"Fix_Item 節點下各節點的list",
headerList.value,
weatherList.value,
alarmList.value,
userList.value
);
console.log(
"Dynamic_Item 節點下各節點的list",
DynamicList.value
);
};
// 遞迴解析 XML 結構
const parseNode = (node, key) => {
const name = node.getAttribute("name");
const ord = node.getAttribute("ord");
const icon = node.getAttribute("icon");
// 處理子節點遞迴
const childrenNodes = Array.from(node.children);
const children =
childrenNodes.length > 0
? childrenNodes.map((child, index) =>
parseNode(child, `${key}-${index + 1}`)
)
: null;
return {
key,
name,
ord,
children,
icon,
};
};
const findNodeByName = (nodes, name) => {
for (const node of nodes) {
if (node.name === name) {
return node;
}
if (node.children) {
const result = findNodeByName(node.children, name);
if (result) return result;
}
}
return null;
};
return {
systemList,
headerList,
weatherList,
alarmList,
userList,
DynamicList,
getSystemData,
};
});
export default useNiagaraDataStore;

View File

@ -0,0 +1,38 @@
import { defineStore } from "pinia";
import { ref } from "vue";
const useUserStore = defineStore("user", () => {
const userName = ref("webUser");
const userRole = ref("");
const loadUserInfo = () => {
return new Promise((resolve, reject) => {
window.require &&
window.requirejs(["baja!"], (baja) => {
let currentUserName = baja.getUserName();
userName.value = currentUserName;
baja.Ord.make(`station:|slot:/Services/UserService/${currentUserName}`)
.get()
.then((user) => {
const rolesString = user.getRoles(); // 取得角色字串
if (rolesString) {
const rolesArray = rolesString.split(",");
if (rolesArray.length > 0) {
userRole.value = rolesArray[0].trim();
console.log("選取的角色:", userRole.value);
}
}
resolve();
})
.catch((err) => {
console.error(`訂閱失敗: ${err.message}`);
reject(err);
});
});
});
};
return { userName, userRole, loadUserInfo };
});
export default useUserStore;

1
src/style.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -0,0 +1,33 @@
<script setup>
import { ref, onMounted, watch } from "vue";
import DashboardStat from "@/components/dashboard/dashboardStat.vue";
import DashboardElecChart from "@/components/dashboard/dashboardElecChart.vue";
import DashboardTag from "@/components/dashboard/dashboardTag.vue";
import DashboardAlert from "@/components/dashboard/dashboardAlert.vue";
</script>
<template>
<a-row :gutter="24" class="p-5">
<a-col :span="8">
<a-image width="100%" src="./build.jpg" class="rounded shadow-lg" />
</a-col>
<a-col :span="16">
<!-- 用電數據 -->
<DashboardStat />
<!-- 用電圖表 -->
<DashboardElecChart />
</a-col>
</a-row>
<a-row :gutter="24" class="p-5">
<a-col :span="16">
<!-- <DashboardTag /> -->
</a-col>
<a-col :span="8" class="">
<!-- 告警 -->
<!-- <DashboardAlert /> -->
</a-col>
</a-row>
</template>
<style scoped></style>

View File

@ -0,0 +1,126 @@
<script setup>
import { onMounted, ref, computed } from "vue";
import EnergySankey from "@/components/energy/EnergySankey.vue";
import EnergyLine from "@/components/energy/EnergyLine.vue";
import EnergyBar from "@/components/energy/EnergyBar.vue";
const statisticData = ref([
{ title: "今年電費累計", value: 58792.56, unit: "元" },
{ title: "區間電費", value: 1234.78, unit: "元" },
{ title: "今年碳排當量累計", value: 3456.23, unit: "公斤" },
{ title: "區間碳排當量", value: 15.67, unit: "公斤" },
{ title: "今年用電度數", value: 1890.45, unit: "kWh" },
{ title: "區間用電度數", value: 123.9, unit: "kWh" },
]);
//
const monthlyElectricityData = ref({
categories: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
series: [
{
name: "基本電量",
type: "bar",
data: [120, 100, 110, 130, 150, 160, 140, 150, 130, 120, 110, 130],
},
{
name: "流動電量",
type: "bar",
data: [80, 60, 70, 90, 100, 110, 90, 100, 80, 70, 60, 80],
},
{
name: "總電量",
type: "bar",
data: [200, 160, 180, 220, 250, 270, 230, 250, 210, 190, 170, 210],
},
],
});
//
const monthlyCarbonData = ref({
categories: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
series: [
{
name: "碳排當量",
type: "bar",
data: [50, 40, 45, 55, 60, 65, 55, 60, 50, 45, 40, 50],
},
],
});
</script>
<template>
<a-row :gutter="24" class="p-5">
<!-- 用電即時分佈 -->
<a-col :span="12">
<h3 class="text-lg font-medium">用電即時分佈</h3>
<EnergySankey />
</a-col>
<!-- 即時需量 -->
<a-col :span="12">
<h3 class="text-lg font-medium">即時需量</h3>
<EnergyLine />
</a-col>
<!-- 資訊區 -->
<a-col :span="8" class="mt-10">
<a-card class="">
<a-card-grid
v-for="(item, index) in statisticData"
:key="index"
style="width: 50%; padding: 5px"
:class="{ 'bg-stone-100': index == 0 || index == 3 || index == 4 }"
>
<a-statistic
:title="item.title"
:value="item.value"
:suffix="item.unit"
/></a-card-grid>
</a-card>
</a-col>
<!-- 每月用電分析 -->
<a-col :span="8" class="mt-10">
<h3 class="text-lg font-medium">每月用電分析</h3>
<EnergyBar :chartData="monthlyElectricityData" />
</a-col>
<!-- 每月碳排當量 (kgCO2e) -->
<a-col :span="8" class="mt-10">
<h3 class="text-lg font-medium">每月碳排當量 (kgCO2e)</h3>
<EnergyBar :chartData="monthlyCarbonData" />
</a-col>
<!-- 每月用電分析 -->
<a-col :span="8" class="mt-10">
<h3 class="text-lg font-medium">每月用電分析</h3>
<EnergyBar :chartData="monthlyElectricityData" />
</a-col>
<!-- 每月碳排當量 (kgCO2e) -->
<a-col :span="16" class="mt-10">
<h3 class="text-lg font-medium">每月碳排當量 (kgCO2e)</h3>
<EnergyBar :chartData="monthlyElectricityData" />
</a-col>
</a-row>
</template>

View File

@ -0,0 +1,38 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const ordUrl = computed(() => {
const ord = route.query.ord || "";
const decodedOrd = decodeURIComponent(ord);
console.log("route.query.ord:", ord); //
console.log("decodedOrd:", decodedOrd); //
return decodedOrd;
});
const iframeSrc = computed(() => {
if (!ordUrl.value) {
return ""; // ordUrl src
}
if (
ordUrl.value.startsWith("http://") ||
ordUrl.value.startsWith("https://")
) {
return ordUrl.value; // http 使 ordUrl src
} else {
return `/ord?${ordUrl.value}|view:?fullScreen=true`; // Niagara
}
});
</script>
<template>
<iframe
v-if="iframeSrc"
:src="iframeSrc"
width="100%"
:style="{ height: 'calc(100vh - 90px)' }"
></iframe>
</template>

21
vite.config.js Normal file
View File

@ -0,0 +1,21 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
base: process.env.NODE_ENV === "production" ? "./" : "/",
build: {
outDir: process.env.NODE_ENV === "production" ? "../dist" : "./dist",
emptyOutDir: true,
},
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
port: 3000, // 指定端口為 3000
},
});