Initial commit

This commit is contained in:
koko 2025-10-14 17:19:43 +08:00
commit cc25dce91c
31 changed files with 3979 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?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

46
README.md Normal file
View File

@ -0,0 +1,46 @@
# 地熱
此專案是使用 Vite + Vue 3 建立的前端管理介面。
## 主要功能
- Vue 3 + Vite
- Element Plus UI
- Pinia 狀態管理
- Vue Router
- ECharts 圖表
- Leaflet 地圖
## 環境需求
- Node.js建議 v18+
- npm 或 yarn
## 安裝
在專案根目錄執行:
```powershell
npm install
# 或使用 yarn
# yarn
```
## 常用指令
請使用 PowerShell或相同 shell在專案根目錄執行
```powershell
npm run dev # 本機開發 (Vite)
npm run build # 產生 production bundle
npm run preview # 本地預覽 build 後的結果
```
這些 scripts 自 `package.json` 取得:
```json
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
```

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>結元能源</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2634
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "fabulous_fe",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.12.2",
"echarts": "^6.0.0",
"element-plus": "^2.11.4",
"leaflet": "^1.9.4",
"leaflet-tilelayer-mbtiles": "^1.4.1",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.7"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

75
src/App.vue Normal file
View File

@ -0,0 +1,75 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { ElConfigProvider } from "element-plus";
import Navbar from "./components/Navbar.vue";
import Sidebar from "./components/Sidebar.vue";
import Breadcrumb from "./components/Breadcrumb.vue";
const router = useRouter();
const route = useRoute();
const size = "default";
const zIndex = 2000;
const isCollapse = ref(false);
const isMobile = ref(false);
const checkIsMobile = () => {
isMobile.value = window.innerWidth <= 768;
};
onMounted(() => {
checkIsMobile();
window.addEventListener("resize", checkIsMobile);
// /plantsMap (hash )
if (window.location.hash === '' || window.location.hash === '#/' || window.location.hash === '#') {
router.replace('/plantsMap');
}
});
onUnmounted(() => {
window.removeEventListener("resize", checkIsMobile);
});
</script>
<template>
<el-config-provider :size="size" :z-index="zIndex">
<div class="common-layout">
<el-container>
<template v-if="!isMobile">
<el-aside :width="isCollapse ? '0px' : '280px'">
<Sidebar :isCollapse="isCollapse" />
</el-aside>
</template>
<template v-else>
<el-drawer
v-if="isMobile"
v-model="isCollapse"
direction="ltr"
:with-header="false"
size="70%"
body-class="mobile-sidebar-drawer"
>
<Sidebar :isCollapse="isCollapse" />
</el-drawer>
</template>
<el-container>
<el-header>
<Navbar v-model:isCollapse="isCollapse" />
</el-header>
<el-main>
<!-- <Breadcrumb /> -->
<router-view />
</el-main>
</el-container>
</el-container>
</div>
</el-config-provider>
</template>
<style scoped>
.el-header {
padding: 0;
}
:deep(.mobile-sidebar-drawer) {
padding: 0 !important;
}
</style>

3
src/assets/bars-3.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>

After

Width:  |  Height:  |  Size: 242 B

4
src/assets/logo.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="537" height="530" viewBox="0 0 537 530" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="355.171" y="82.4884" width="256.214" height="256.214" transform="rotate(45 355.171 82.4884)" fill="#80B559"/>
<path d="M0 265L265 0L333.5 68.5L137 265L333.5 461.5L265 530L0 265Z" fill="#80B559"/>
</svg>

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

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

3
src/assets/x-mark.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>

After

Width:  |  Height:  |  Size: 218 B

78
src/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,78 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

49
src/components.d.ts vendored Normal file
View File

@ -0,0 +1,49 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Breadcrumb: typeof import('./components/Breadcrumb.vue')['default']
ComboBarLineChart: typeof import('./components/Chart/ComboBarLineChart.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElList: typeof import('element-plus/es')['ElList']
ElListItem: typeof import('element-plus/es')['ElListItem']
ElListItemContent: typeof import('element-plus/es')['ElListItemContent']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElRow: typeof import('element-plus/es')['ElRow']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
InfoList: typeof import('./components/InfoList.vue')['default']
LineChart: typeof import('./components/Chart/LineChart.vue')['default']
Navbar: typeof import('./components/Navbar.vue')['default']
PieChart: typeof import('./components/Chart/PieChart.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Sidebar: typeof import('./components/Sidebar.vue')['default']
}
}

View File

@ -0,0 +1,19 @@
<template>
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(item, idx) in breadcrumbs" :key="item.path || idx" :to="item.path && idx !== breadcrumbs.length - 1 ? { path: item.path } : undefined">
<span v-if="!item.path || idx === breadcrumbs.length - 1">{{ item.meta && item.meta.title ? item.meta.title : item.name }}</span>
<span v-else>{{ item.meta && item.meta.title ? item.meta.title : item.name }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const breadcrumbs = computed(() => {
// route.matched
return route.matched.filter(r => r.meta && r.meta.title);
});
</script>

View File

@ -0,0 +1,103 @@
<template>
<div :id="chartId" style="width: 100%; min-height: 250px"></div>
</template>
<script setup>
import { onMounted, onUnmounted, nextTick, computed } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
data: {
type: Array,
required: true
},
title: {
type: String,
default: ''
}
})
const chartId = `comboChart-${Math.random().toString(36).slice(2)}`
let chartInstance = null
const years = computed(() => props.data.map(item => item.year))
//
const plantNames = computed(() => {
const set = new Set();
props.data.forEach(item => {
item.data.forEach(d => set.add(d.name));
});
return Array.from(set);
});
// series
function getSeries() {
const barSeries = plantNames.value.map(name => ({
name,
type: 'bar',
data: props.data.map(item => {
const found = item.data.find(d => d.name === name);
return found ? found.value : 0;
}),
barMaxWidth: 32
}));
//
const totalSeries = {
name: '總發電量',
type: 'line',
data: props.data.map(item => item.data.reduce((sum, d) => sum + d.value, 0)),
symbol: 'circle',
symbolSize: 10,
lineStyle: { width: 3, color: '#409EFF' },
itemStyle: { color: '#409EFF', borderColor: '#fff', borderWidth: 2 }
};
return [...barSeries, totalSeries];
}
onMounted(() => {
nextTick(() => {
const chartDom = document.getElementById(chartId)
if (chartDom) {
chartInstance = echarts.init(chartDom)
chartInstance.setOption({
title: props.title ? {
text: props.title,
left: 'center',
textStyle: { fontSize: 16, color: '#333' }
} : undefined,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
legend: {
bottom: 0
},
grid: {
left: 10,
right: 10,
top: 30,
bottom: 30,
containLabel: true
},
xAxis: {
type: 'category',
data: years.value
},
yAxis: {
type: 'value',
minInterval: 1,
name: 'MW',
},
series: getSeries()
})
}
})
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
}
})
</script>

View File

@ -0,0 +1,95 @@
<template>
<div :id="chartId" style="width: 100%; min-height: 250px"></div>
</template>
<script setup>
import { onMounted, onUnmounted, nextTick, computed } from "vue";
import * as echarts from "echarts";
const props = defineProps({
data: {
type: Array,
required: true,
},
title: {
type: String,
default: "",
},
});
const chartId = `lineChart-${Math.random().toString(36).slice(2)}`;
let chartInstance = null;
const xData = computed(() => props.data.map((item) => item.time));
const yData = computed(() => props.data.map((item) => item.value));
onMounted(() => {
nextTick(() => {
const chartDom = document.getElementById(chartId);
if (chartDom) {
chartInstance = echarts.init(chartDom);
chartInstance.setOption({
title: props.title
? {
text: props.title,
left: "center",
textStyle: { fontSize: 16, color: "#333" },
}
: undefined,
tooltip: {
trigger: "axis",
},
legend: {
show: true,
data: ["發電量"],
bottom: 0,
},
grid: {
left: 10,
right: 10,
top: 30,
bottom: 30,
containLabel: true,
},
xAxis: {
type: "category",
data: xData.value,
boundaryGap: false,
},
yAxis: {
type: "value",
minInterval: 1,
name: "MW",
},
series: [
{
name: "發電量",
type: "line",
data: yData.value,
symbol: "circle",
symbolSize: 8,
lineStyle: {
width: 3,
color: "#409EFF",
},
itemStyle: {
color: "#409EFF",
borderColor: "#fff",
borderWidth: 2,
},
areaStyle: {
color: "rgba(64,158,255,0.08)",
},
},
],
});
}
});
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose();
}
});
</script>

View File

@ -0,0 +1,90 @@
<template>
<div :id="chartId" style="width: 100%; min-height: 200px"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
data: {
type: Array,
required: true
},
title: {
type: String,
default: ''
}
})
const chartId = `pieChart-${Math.random().toString(36).slice(2)}`
let chartInstance = null
onMounted(() => {
nextTick(() => {
const chartDom = document.getElementById(chartId)
if (chartDom) {
chartInstance = echarts.init(chartDom)
chartInstance.setOption({
tooltip: {
trigger: 'item',
formatter: '{b} : {d}%'
},
title: props.title
? {
text: props.title,
left: 'center',
textStyle: { fontSize: 16, color: '#333' }
}
: undefined,
series: [
{
type: 'pie',
radius: '90%',
avoidLabelOverlap: false,
label: {
show: true,
alignTo: 'edge',
position: 'outer',
formatter: '{b} : {d}%',
edgeDistance: 10,
minMargin: 0,
lineHeight: 10
},
labelLine: {
length: 10,
length2: 0,
maxSurfaceAngle: 80
},
labelLayout: function (params) {
const isLeft = params.labelRect.x < chartInstance.getWidth() / 2
const points = params.labelLinePoints
points[2][0] = isLeft
? params.labelRect.x
: params.labelRect.x + params.labelRect.width
return {
labelLinePoints: points,
y: params.labelRect.y - 4
}
},
data: props.data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
})
}
})
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
}
})
</script>

View File

@ -0,0 +1,60 @@
<template>
<ul class="info-list">
<li v-for="item in data" :key="item.label">
<span class="label">{{ item.label }}</span>
<span class="value">{{ item.value }}</span>
<span class="unit">{{ item.unit }}</span>
</li>
</ul>
</template>
<script setup>
const props = defineProps({
data: {
type: Array,
required: true,
},
});
</script>
<style scoped>
.info-list {
list-style: none;
padding: 0;
margin: 0;
height: 175px;
overflow-y: auto;
}
.info-list li {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-list li:first-child {
padding-top: 0;
}
.info-list li:last-child {
border-bottom: none;
}
.info-list .label {
color: #666;
font-size: 0.9rem;
width: 30%;
}
.info-list .value {
color: #409eff;
font-size: 1.2rem;
font-weight: bold;
margin: 0 8px;
width: 60%;
text-align: center;
}
.info-list .unit {
color: #888;
width: 30%;
font-size: 0.85rem;
text-align: end;
}
</style>

77
src/components/Navbar.vue Normal file
View File

@ -0,0 +1,77 @@
<template>
<nav class="nav">
<div class="title-area">
<el-button class="collapse-btn" @click="toggleCollapse">
<img
v-if="collapseState"
src="../assets/bars-3.svg"
alt="展開"
style="width: 24px; height: 24px"
/>
<img
v-else
src="../assets/x-mark.svg"
alt="收合"
style="width: 24px; height: 24px"
/>
</el-button>
<span class="title">工控運維管理平台</span>
</div>
<el-dropdown size="large">
<el-button type="text" circle>
<el-avatar :icon="UserFilled" />
</el-button>
<template #dropdown>
<el-dropdown-menu style="font-size: large;">
<el-dropdown-item>個人資料</el-dropdown-item>
<el-dropdown-item>帳號登出</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</nav>
</template>
<script setup lang="ts">
import { computed, defineProps, defineEmits } from "vue";
import { UserFilled } from "@element-plus/icons-vue";
const props = defineProps({
isCollapse: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["update:isCollapse"]);
const collapseState = computed({
get: () => props.isCollapse,
set: (val: boolean) => emit("update:isCollapse", val),
});
const toggleCollapse = () => {
collapseState.value = !collapseState.value;
};
</script>
<style scoped>
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
background-color: #ffffff;
box-shadow: 0px 0px 28px 0px rgba(86, 61, 124, 0.13);
}
.title-area {
display: flex;
align-items: center;
}
.title {
margin-bottom: 0;
font-size: 1.5rem;
font-weight: 600;
color: #333;
padding-left: 10px;
}
.collapse-btn {
vertical-align: middle;
padding: 20px 15px;
}
</style>

192
src/components/Sidebar.vue Normal file
View File

@ -0,0 +1,192 @@
<template>
<el-menu
:default-active="$route.name"
class="el-menu-vertical-demo"
background-color="#00152a"
active-text-color="#fff"
text-color="#95a2b5"
unique-opened
>
<div class="title-area">
<img
src="../assets/logo.svg"
alt="logo"
style="width: 40px; height: 40px"
/>
<span>結元能源</span>
</div>
<el-sub-menu index="overview">
<template #title>
<el-icon><Location /></el-icon>
<span>總覽</span>
</template>
<el-menu-item index="PlantsMap" @click="$router.push('/plantsMap')">地圖總覽</el-menu-item>
<el-menu-item index="PlantsOverview" @click="$router.push('/plants')">電廠總覽</el-menu-item>
</el-sub-menu>
<el-sub-menu index="factory-info">
<template #title>
<el-icon><Postcard /></el-icon>
<span>電廠資訊</span>
</template>
<el-menu-item index="factory-1">四磺子坪</el-menu-item>
<el-menu-item index="factory-2">宜蘭大清水</el-menu-item>
<el-menu-item index="factory-3">宜蘭小清水</el-menu-item>
</el-sub-menu>
<el-sub-menu index="inspection">
<template #title>
<el-icon><DocumentChecked /></el-icon>
<span>巡檢系統</span>
</template>
<el-menu-item index="inspection-task">巡檢任務</el-menu-item>
<el-menu-item index="inspection-setting">巡檢設定</el-menu-item>
</el-sub-menu>
<el-sub-menu index="report">
<template #title>
<el-icon><DataLine /></el-icon>
<span>報表查詢</span>
</template>
<el-menu-item index="report-factory">電廠報表</el-menu-item>
</el-sub-menu>
<el-sub-menu index="alert">
<template #title>
<el-icon><Bell /></el-icon>
<span>即時告警</span>
</template>
<el-menu-item index="alert-event">異常事件查詢</el-menu-item>
</el-sub-menu>
<el-sub-menu index="material">
<template #title>
<el-icon><MessageBox /></el-icon>
<span>備料管理</span>
</template>
<el-menu-item index="material-item">備品料件管理</el-menu-item>
<el-menu-item index="material-location">倉庫櫃位管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="security">
<template #title>
<el-icon><VideoCamera /></el-icon>
<span>智慧安防</span>
</template>
<el-menu-item index="security-system">安防系統</el-menu-item>
</el-sub-menu>
<el-sub-menu index="system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系統設定</span>
</template>
<el-menu-item index="system-factory">電廠設定</el-menu-item>
<el-menu-item index="system-account">帳號設定</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
<script setup>
import { computed, toRefs, defineProps } from "vue";
import {
Document,
Menu as IconMenu,
Location,
Setting,
Postcard,
DocumentChecked,
DataLine,
Bell,
MessageBox,
VideoCamera
} from "@element-plus/icons-vue";
</script>
<style scoped>
.el-menu-vertical-demo {
min-height: 100vh;
height: 100%;
background-image: linear-gradient(
270deg,
rgba(51, 148, 225, 0.18),
transparent
);
background-color: #00152a;
}
.title-area {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
gap: 10px;
font-size: 1.8rem;
color: #fff;
}
:deep(.el-sub-menu__title) {
font-size: 1.2rem;
}
:deep(.el-sub-menu__title:hover) {
color: #fff !important;
background-color: #102e48 !important;
}
:deep(.el-menu-item) {
font-size: 1.1rem;
padding-top: 30px !important;
padding-bottom: 30px !important;
padding-left: 60px !important;
position: relative;
}
:deep(.el-menu-item:hover) {
color: #fff !important;
}
/* 為 menu-item 添加圓點 */
:deep(.el-menu-item::before) {
content: '';
position: absolute;
left:30px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #95a2b5;
}
/* 選中狀態的圓點為藍色 */
:deep(.el-menu-item.is-active::after) {
content: '';
position: absolute;
right:25px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #24b3a4;
}
/* 展開狀態的子選單標題 */
:deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
background-color: #102e48 !important;
color: #fff !important;
box-shadow: inset 3px 0 0 #627ca0;
}
/* 子選單內容背景 */
:deep(.el-menu.el-menu--inline) {
background: #000;
position: relative;
}
:deep(.el-menu.el-menu--inline::before) {
content: '';
position: absolute;
left:32.5px;
top: 50%;
transform: translateY(-50%);
width: .8px;
height: 110%;
background-color: #95a2b5;
z-index: 80;
}
</style>

10
src/main.js Normal file
View File

@ -0,0 +1,10 @@
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import router from "./router";
import { createPinia } from "pinia";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

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

@ -0,0 +1,26 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "../views/Home.vue";
import PlantsOverview from "../views/PlantsOverview.vue";
const routes = [
{
path: "/plantsMap",
name: "PlantsMap",
component: Home,
meta: { title: "地圖總覽", icon: "Location", menuGroup: "overview" },
},
{
path: "/plants",
name: "PlantsOverview",
component: PlantsOverview,
meta: { title: "電廠總覽", icon: "Postcard", menuGroup: "overview" },
},
// 你可以依需求繼續擴充其他路由
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;

0
src/store/index.js Normal file
View File

39
src/style.css Normal file
View File

@ -0,0 +1,39 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: #213547;
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
}
#app {
margin: 0 auto;
width: 100%;
height: 100%;
}
/* 統一 el-card 樣式 */
.el-card.custom-card {
border-radius: 6px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.08);
background: #fff;
border: none;
margin-bottom: 20px;
}
.el-card.custom-card .el-card__header{
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.8px;
color: #213547;
}

273
src/views/Home.vue Normal file
View File

@ -0,0 +1,273 @@
<script setup>
import { ref, reactive, onMounted, onUnmounted } from "vue";
import PieChart from "../components/Chart/PieChart.vue";
import LineChart from "../components/Chart/LineChart.vue";
import ComboBarLineChart from "../components/Chart/ComboBarLineChart.vue";
import InfoList from "../components/InfoList.vue";
import markerIconBlue from "../assets/marker-icon-blue.png";
import markerShadow from "../assets/marker-shadow.png";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
const avgData = [
{
year: 2021,
data: [
{ name: "大清水", value: 1120 },
{ name: "小清水", value: 890 },
{ name: "四磺子坪", value: 870 },
],
},
{
year: 2022,
data: [
{ name: "大清水", value: 1134 },
{ name: "小清水", value: 1120 },
{ name: "四磺子坪", value: 780 },
],
},
{
year: 2023,
data: [
{ name: "大清水", value: 1145 },
{ name: "小清水", value: 1115 },
{ name: "四磺子坪", value: 975 },
],
},
{
year: 2024,
data: [
{ name: "大清水", value: 1172 },
{ name: "小清水", value: 1135 },
{ name: "四磺子坪", value: 1115 },
],
},
{
year: 2025,
data: [
{ name: "大清水", value: 1185 },
{ name: "小清水", value: 1143 },
{ name: "四磺子坪", value: 1121 },
],
},
];
const weekTrend = [
{ time: "周一", value: 120 },
{ time: "周二", value: 132 },
{ time: "周三", value: 101 },
{ time: "周四", value: 134 },
{ time: "周五", value: 90 },
{ time: "周六", value: 230 },
{ time: "周日", value: 210 },
];
const unconfirmed = ref(2014);
const elecData = ref([
{ value: 42, name: "大清水" },
{ value: 26, name: "小清水" },
{ value: 32, name: "四磺子坪" },
]);
const plantOverview = reactive([
{ label: "電廠數量", value: 9, unit: "座" },
{ label: "總裝置容量", value: 189, unit: "MW" },
{ label: "總發電量", value: 290, unit: "MW" },
{ label: "總收益", value: 54123, unit: "萬元" },
]);
const plantAccumulate = reactive([
{ label: "大清水", value: 234, unit: "MW" },
{ label: "小清水", value: 189, unit: "MW" },
{ label: "四磺子坪", value: 100, unit: "MW" },
]);
// icon
const geothermalIcon = L.icon({
iconUrl: markerIconBlue,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowUrl: markerShadow,
shadowSize: [41, 41],
});
// marker
const geothermalMarkers = [
{
position: [25.19544, 121.60236],
popup: {
title: "四磺子坪",
img: "https://picsum.photos/200/100?random=1",
deviceNumber: 12,
online: 11,
desc: "11 MW",
},
},
{
position: [24.61231, 121.63697],
popup: {
title: "清水發電廠",
img: "https://picsum.photos/200/100?random=2",
deviceNumber: 8,
online: 8,
desc: "24 MW",
},
},
];
let timer = null;
onMounted(() => {
// OpenStreetMap
const map = L.map("leaflet-map").setView([25.05, 121.6], 9);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution: "© OpenStreetMap contributors",
}).addTo(map);
geothermalMarkers.forEach(({ position, popup }) => {
L.marker(position, { icon: geothermalIcon })
.addTo(map)
.bindTooltip(
`<h4>${popup.title}</h4>
<div class="info">
<div>發電量 : ${popup.desc}</div>
</div>
`,
{
direction: "top",
permanent: true,
className: "custom-tooltip",
offset: [0, -50], // Y 30px
}
);
});
timer = setInterval(() => {
unconfirmed.value++;
const income = plantOverview.find((item) => item.label === "總收益");
if (income) income.value += 10;
const acccome = plantAccumulate.find((item) => item.label === "四磺子坪");
if (acccome) acccome.value += 1;
}, 5 * 1000);
});
onUnmounted(() => {
clearInterval(timer);
});
</script>
<template>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :xs="24" :lg="7">
<el-card shadow class="custom-card">
<template #header>
<span>異常狀態</span>
</template>
<el-row :gutter="20">
<el-col :span="12" style="margin-bottom: 10px">
<el-statistic
title="已復歸"
:value="1234"
value-style="color: #67C23A; font-size: 2rem;"
/>
</el-col>
<el-col :span="12">
<el-statistic
title="未復歸"
:value="1345"
value-style="color: #F56C6C; font-size: 2rem;"
/>
</el-col>
<el-col :span="12">
<el-statistic
title="已確認"
:value="1120"
value-style="color: #409EFF; font-size: 2rem;"
/>
</el-col>
<el-col :span="12">
<el-statistic
title="未確認"
:value="unconfirmed"
value-style="color: #E6A23C; font-size: 2rem;"
/>
</el-col>
</el-row>
</el-card>
<el-card shadow class="custom-card">
<template #header>
<span>發電分布</span>
</template>
<PieChart :data="elecData" />
</el-card>
</el-col>
<el-col :xs="24" :lg="10">
<el-card shadow class="custom-card">
<template #header>
<span>今日發電量</span>
</template>
<div id="leaflet-map" style="height: 470px; width: 100%"></div>
</el-card>
</el-col>
<el-col :xs="24" :lg="7">
<el-card shadow class="custom-card">
<template #header>
<span>電廠概覽</span>
</template>
<InfoList :data="plantOverview" />
</el-card>
<el-card shadow class="custom-card">
<template #header>
<span>累積發電</span>
</template>
<InfoList :data="plantAccumulate" />
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow class="custom-card">
<template #header>
<span>發電趨勢</span>
</template>
<LineChart :data="weekTrend" />
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow class="custom-card">
<template #header>
<span>年平均發電比較</span>
</template>
<ComboBarLineChart :data="avgData" />
</el-card>
</el-col>
</el-row>
</template>
<script setup></script>
<style scoped>
:deep(.el-statistic__head) {
font-size: 0.85rem;
}
:deep(.custom-tooltip) {
background: rgba(255, 255, 255, 0.9);
border-radius: 5px;
box-shadow: 0px 4px 8px rgba(44, 62, 80, 0.24);
padding: 10px;
}
:deep(.custom-tooltip) h4 {
margin-bottom: 5px;
font-size: 1rem;
color: #343a40;
margin-top: 0;
}
:deep(.custom-tooltip) img {
width: 100%;
border-radius: 10px;
}
:deep(.custom-tooltip) .info {
font-size: .9rem;
}
</style>

View File

@ -0,0 +1,14 @@
<template>
<h1>電廠總覽</h1>
<p>這裡是電廠總覽頁面請依需求補充內容</p>
</template>
<script setup>
//
</script>
<style scoped>
.plants-overview {
padding: 2rem;
}
</style>

21
vite.config.js Normal file
View File

@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [
vue(),
AutoImport({
imports: ["vue", "vue-router"],
dts: "src/auto-imports.d.ts",
}),
Components({
resolvers: [ElementPlusResolver()],
dts: "src/components.d.ts",
}),
],
});