feat: 新增 progress bar 切換圖表功能

This commit is contained in:
MJM_2025_05\polly 2025-09-02 15:32:50 +08:00
parent 458cfc7eb0
commit fbf694ccae
4 changed files with 420 additions and 124 deletions

View File

@ -1,10 +1,10 @@
<template>
<nav
class="sticky top-0 w-full h-14 md:h-16 bg-white/70 bg-opacity-80 shadow-md flex justify-between items-center px-4 sm:px-6 lg:px-8 z-[1000]"
class="sticky top-0 w-full h-14 lg:h-16 bg-white/70 bg-opacity-80 shadow-md flex justify-between items-center px-4 sm:px-6 lg:px-8 z-[1000]"
>
<!-- 左側Logo + 機構切換 -->
<div
class="flex items-center gap-4 sm:gap-8 shrink-0 w-[240px] md:w-[300px] lg:w-[340px]"
class="flex items-center gap-4 sm:gap-8 shrink-0 w-[240px] md:w-[280px] lg:w-[340px]"
>
<RouterLink to="/" class="h-9 md:h-11">
<img src="/img/logo.png" alt="Logo" class="h-full w-auto" />
@ -62,7 +62,7 @@
<!-- 中間導覽手機隱藏平板以上顯示桌機放大間距 -->
<div
class="hidden md:grid min-w-[300px] grid-cols-3 items-center bg-white shadow-md rounded-full md:px-0 lg:min-w-[420px]"
class="hidden lg:grid min-w-[300px] grid-cols-3 items-center bg-white shadow-md rounded-full md:px-0 xl:min-w-[360px]"
>
<RouterLink to="/" v-slot="{ href, navigate, isExactActive }">
<a
@ -102,7 +102,7 @@
</div>
<!-- 右側通知 + 使用者手機只顯示圖示平板顯示文字 -->
<div class="hidden md:flex items-center gap-3 sm:gap-6">
<div class="hidden lg:flex items-center gap-3 sm:gap-6">
<button
class="btn bg-white text-brand-black hover:opacity-90 shadow-md border-none rounded-full p-2"
aria-label="通知"
@ -156,7 +156,7 @@
<!-- 手機漢堡按鈕-->
<button
class="md:hidden btn bg-white text-brand-black hover:opacity-90 shadow-md border-none rounded-md p-2"
class="lg:hidden btn bg-white text-brand-black hover:opacity-90 shadow-md border-none rounded-md p-2"
@click="toggleMobileMenu"
:aria-expanded="isMobileMenuOpen"
aria-controls="mobile-menu"
@ -187,8 +187,8 @@
<!-- 手機全螢幕 ham menu白底hover active -->
<div
id="mobile-menu"
class="md:hidden fixed inset-0 z-[1100] bg-white p-4 flex flex-col gap-4 transition-opacity duration-200"
id="mobile-menu-overlay"
class="sm:hidden fixed inset-0 z-[1100] bg-white p-4 flex flex-col gap-4 transition-opacity duration-200"
:class="
isMobileMenuOpen
? 'opacity-100 pointer-events-auto'
@ -357,6 +357,146 @@
<div class="mt-auto text-xs text-gray-500">© U-ARK</div>
</div>
</div>
<!-- 平板直式 modal 640px1023px 顯示位置在漢堡鈕下方 -->
<div
v-show="isMobileMenuOpen"
class="hidden sm:block lg:hidden absolute right-4 top-16 z-[1100] w-[88vw] sm:w-[240px] bg-white rounded-xl shadow-xl border border-gray-100 p-4"
role="dialog"
aria-modal="true"
aria-label="主選單(平板)"
@click.stop
>
<!-- 頂部標題 + 關閉 -->
<div class="flex items-center justify-between mb-2">
<p class="font-semibold text-brand-black text-base">選單</p>
</div>
<!-- 導覽icon + 文字 -->
<nav class="space-y-2">
<RouterLink to="/" v-slot="{ href, navigate, isExactActive }">
<a
:href="href"
@click="
navigate;
closeMobileMenu();
"
:class="[
'flex items-center gap-3 rounded-lg px-3 py-3 transition-colors',
isExactActive ? 'bg-brand-green-light' : 'hover:bg-gray-100',
]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M4 19v-9q0-.475.213-.9t.587-.7l6-4.5q.525-.4 1.2-.4t1.2.4l6 4.5q.375.275.588.7T20 10v9q0 .825-.588 1.413T18 21h-3q-.425 0-.712-.288T14 20v-5q0-.425-.288-.712T13 14h-2q-.425 0-.712.288T10 15v5q0 .425-.288.713T9 21H6q-.825 0-1.412-.587T4 19"
/>
</svg>
<span class="text-brand-black">首頁</span>
</a>
</RouterLink>
<RouterLink to="/operation" v-slot="{ href, navigate, isActive }">
<a
:href="href"
@click="
navigate;
closeMobileMenu();
"
:class="[
'flex items-center gap-3 rounded-lg px-3 py-3 transition-colors',
isActive ? 'bg-brand-green-light' : 'hover:bg-gray-100',
]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20 13.75a.75.75 0 0 0-.75-.75h-3a.75.75 0 0 0-.75.75v6.75H14V4.25c0-.728-.002-1.2-.048-1.546c-.044-.325-.115-.427-.172-.484s-.159-.128-.484-.172C12.949 2.002 12.478 2 11.75 2s-1.2.002-1.546.048c-.325.044-.427.115-.484.172s-.128.159-.172.484c-.046.347-.048.818-.048 1.546V20.5H8V8.75A.75.75 0 0 0 7.25 8h-3a.75.75 0 0 0-.75.75V20.5H1.75a.75.75 0 0 0 0 1.5h20a.75.75 0 0 0 0-1.5H20z"
/>
</svg>
<span class="text-brand-black">營運</span>
</a>
</RouterLink>
<RouterLink to="/nursing" v-slot="{ href, navigate, isActive }">
<a
:href="href"
@click="
navigate;
closeMobileMenu();
"
:class="[
'flex items-center gap-3 rounded-lg px-3 py-3 transition-colors',
isActive ? 'bg-brand-green-light' : 'hover:bg-gray-100',
]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M22 9.95v4.11a1.78 1.78 0 0 1-1.78 1.78h-4.39v4.39a1.73 1.73 0 0 1-.52 1.25a1.8 1.8 0 0 1-1.26.52H9.94a1.8 1.8 0 0 1-1.26-.52a1.8 1.8 0 0 1-.52-1.25v-4.39H3.78A1.78 1.78 0 0 1 2 14.06V9.95a1.78 1.78 0 0 1 1.78-1.78h4.38V3.78a1.8 1.8 0 0 1 1.103-1.646A1.8 1.8 0 0 1 9.94 2H14a1.8 1.8 0 0 1 1.26.52a1.77 1.77 0 0 1 .52 1.26v4.39h4.39c.472.003.924.19 1.26.52A1.78 1.78 0 0 1 22 9.95"
/>
</svg>
<span class="text-brand-black">照護</span>
</a>
</RouterLink>
</nav>
<hr class="border-gray-200 my-3" />
<!-- 通知 + 使用者放在全螢幕 menu 同樣 hover active -->
<div class="space-y-2">
<button
class="w-full flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-100 text-left"
@click="closeMobileMenu()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M12.45 16.002a2.5 2.5 0 0 1-4.9 0zM9.998 2c3.149 0 5.744 2.335 5.984 5.355l.013.223l.005.224l-.001 3.606l.954 2.587l.025.085l.016.086l.005.089c0 .315-.196.59-.522.707l-.114.033l-.114.01H3.751a.8.8 0 0 1-.259-.047c-.287-.105-.476-.372-.482-.716l.004-.117l.034-.13l.95-2.584L4 7.793l.004-.225C4.127 4.451 6.771 2 9.998 2"
/>
</svg>
<span class="text-brand-black">通知</span>
</button>
<button
class="w-full flex items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-gray-100 text-left"
@click="closeMobileMenu()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M7 7a5 5 0 1 1 10 0A5 5 0 0 1 7 7M3.5 19a5 5 0 0 1 5-5h7a5 5 0 0 1 5 5v2h-17z"
/>
</svg>
<span class="text-brand-black">使用者</span>
</button>
</div>
</div>
</nav>
</template>
@ -440,12 +580,12 @@ const handleKeydown = (e) => {
onMounted(() => {
document.addEventListener("click", onClickOutside);
document.addEventListener("keydown", handleKeydown); //
document.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
document.removeEventListener("click", onClickOutside);
document.removeEventListener("keydown", handleKeydown); //
document.removeEventListener("keydown", handleKeydown);
});
</script>

View File

@ -1,12 +1,22 @@
<template>
<div class="space-y-2">
<!-- 標題列可選 icon 插槽 -->
<div
class="space-y-2 cursor-pointer group outline-none"
role="button"
tabindex="0"
@click="handleSelect"
@keydown.enter.prevent="handleSelect"
@keydown.space.prevent="handleSelect"
>
<!-- 標題列 -->
<div class="flex items-center gap-2">
<label class="block font-medium">{{ label }}</label>
<label
class="block font-medium transition-colors duration-200 group-hover:text-brand-purple-dark cursor-pointer"
>{{ label }}</label
>
<slot name="icon" />
</div>
<!-- 進度條文字覆蓋在進度條上預設靠左 -->
<!-- 進度條 -->
<div class="relative">
<progress
v-bind="$attrs"
@ -21,10 +31,24 @@
></progress>
<span
class="pointer-events-none absolute bottom-0 flex items-center text-[20px] font-nats text-brand-black/80"
class="w-full absolute bottom-0 flex justify-between items-center text-[20px] font-nats pe-5 text-brand-black/80 group-hover:text-brand-purple-dark"
:class="textPosClass"
>
{{ currentLocale }} / {{ totalLocale }}
<span aria-hidden="true">
<svg
class="text-brand-gray/50"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5"
/>
</svg>
</span>
</span>
</div>
</div>
@ -32,18 +56,22 @@
<script setup>
import { computed } from "vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
label: { type: String, required: true },
current: { type: Number, required: true },
total: { type: Number, required: true },
textAlign: { type: String, default: "left" }, // left | center | right
textAlign: { type: String, default: "left" },
chartKey: { type: String, required: true },
currentLegend: { type: String, required: true },
totalLegend: { type: String, required: true },
height: { type: [Number, String], default: 5 },
});
const heightClass = computed(() => `h-${props.height}`);
const emit = defineEmits(["select"]);
const heightClass = computed(() => `h-${props.height}`);
const textPosClass = computed(() => {
switch (props.textAlign) {
case "center":
@ -57,4 +85,13 @@ const textPosClass = computed(() => {
const currentLocale = computed(() => props.current.toLocaleString());
const totalLocale = computed(() => props.total.toLocaleString());
function handleSelect() {
//
emit("select", {
key: props.chartKey,
legends: [props.currentLegend, props.totalLegend],
titleText: `progress ${currentLocale.value} / ${totalLocale.value}`,
});
}
</script>

View File

@ -1,59 +1,102 @@
<template>
<section
class="grid grid-cols-3 gap-2 h-[calc(100vh-72px-32px)] justify-center"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 lg:h-[calc(100vh-72px-32px)] h-auto justify-center"
>
<!-- 左側 -->
<section class="grid grid-rows-12 gap-2">
<!-- 機構照片 + 機構說明 + 核心資料 bars + chart -->
<section
class="h-[740px] row-span-9 bg-white/50 rounded-md shadow py-6 px-4 flex flex-col items-start gap-4"
class="grid grid-rows-12 gap-2 order-2 lg:order-1 min-h-0 lg:h-full"
>
<!-- 上半照片/說明 + 進度條獨立 section -->
<section
class="row-span-5 bg-white/50 rounded-md shadow py-6 px-4 grid grid-cols-1 md:grid-cols-2 items-start gap-6 min-h-0"
>
<!-- 照片 + 說明手機隱藏平板/桌機顯示 -->
<div
class="w-full h-[400px] grid grid-cols-2 justify-center items-start gap-6 px-2"
class="w-full grid grid-rows-12 justify-start items-start gap-6 hidden sm:grid"
>
<div
class="w-full col-span-1 grid grid-rows-12 justify-start items-start gap-6"
>
<div class="h-[240px] row-span-6">
<div class="h-[200px] row-span-6">
<img
src="/img/building/headquarter.png"
alt="機構照片"
class="w-full h-full rounded-md object-cover"
class="w-full h-full rounded-sm object-cover"
/>
</div>
<div class="row-span-4">
<p class="text-brand-gray text-sm">
<p
class="text-brand-gray bg-white/50 text-sm border rounded-sm px-2 py-3 border-brand-gray-light"
>
崇恩長期照顧集團是國內第一家取得ISO認證的長期照顧機構集合了醫師群與資深護理群及照顧服務員們在大南部地區照顧每一位需要我們的長輩
</p>
</div>
</div>
<!-- Progress bars -->
<div class="w-full col-span-1 flex flex-col gap-6">
<div class="w-full flex flex-col gap-4">
<!-- Residents -->
<ProgressBar
label="現在住民/全立案床數"
:current="240"
:total="360"
chartKey="residents"
currentLegend="現在住民"
totalLegend="全立案床數"
@select="({ key }) => updateChartByKey(key)"
/>
<!-- Vacancy -->
<ProgressBar
label="目前空床數/全立案床數"
:current="120"
:total="360"
chartKey="vacancy"
currentLegend="目前空床數"
totalLegend="全立案床數"
@select="({ key }) => updateChartByKey(key)"
/>
<!-- Hospitalized -->
<ProgressBar
label="今日住院/當月累積住院"
:current="8"
:total="50"
chartKey="hospitalized"
currentLegend="今日住院"
totalLegend="當月累積住院"
@select="({ key }) => updateChartByKey(key)"
/>
<!-- Move-in -->
<ProgressBar
label="今日新人入住/當月累積入住"
:current="12"
:total="50"
chartKey="movein"
currentLegend="今日入住"
totalLegend="當月累積入住"
@select="({ key }) => updateChartByKey(key)"
/>
<!-- Move-out -->
<ProgressBar
label="今日離院/累積離院"
:current="8"
:total="50"
chartKey="moveout"
currentLegend="今日離院"
totalLegend="累積離院"
@select="({ key }) => updateChartByKey(key)"
/>
<ProgressBar label="今日離院/累積離院" :current="8" :total="50" />
</div>
</div>
<div class="w-full h-[240px] flex justify-center items-center">
<!-- chart -->
</section>
<!-- 下半圖表獨立 section有內距 -->
<section
class="row-span-4 bg-white/50 rounded-md shadow min-h-0 flex items-stretch"
>
<!-- 這層專門控制圖表外圍留白 -->
<div
class="w-full h-full p-3 sm:p-4 md:p-6 min-h-[200px] sm:min-h-[240px] md:min-h-[300px]"
>
<div ref="chartEl" class="w-full h-full"></div>
</div>
</section>
@ -131,13 +174,19 @@
</section>
</section>
<!-- 中間 -->
<section class="bg-white/50 rounded-md shadow p-3">
<div ref="mapEl" class="w-full h-full rounded-md overflow-hidden"></div>
<section class="bg-white/50 rounded-md shadow p-3 order-1 lg:order-2">
<div
ref="mapEl"
class="w-full rounded-md overflow-hidden min-h-[240px] sm:min-h-[300px] md:h-[420px] lg:h-full"
style="aspect-ratio: 4 / 3"
></div>
</section>
<!-- 右側 -->
<section class="bg-white/50 rounded-md shadow p-3 flex flex-col gap-2">
<section class="flex flex-col gap-2 order-4 lg:order-3">
<!-- 表格 今日活動 -->
<section class="rounded-md shadow p-3 flex flex-col min-h-0 gap-3">
<section
class="bg-white/50 rounded-md shadow p-3 flex flex-col min-h-0 gap-3"
>
<h3 class="text-xl font-bold">今日活動</h3>
<!-- 可捲動內容區水平 -->
<div class="flex flex-col gap-4 mb-6">
@ -204,7 +253,9 @@
</div>
</section>
<!-- 表格 今日異常事件 -->
<section class="rounded-md shadow p-3 flex flex-col min-h-0 gap-3">
<section
class="bg-white/50 rounded-md shadow p-3 flex flex-col min-h-0 gap-3"
>
<h3 class="text-xl font-bold">今日異常事件</h3>
<!-- 可捲動內容區水平 -->
<div class="flex flex-col gap-4 mb-6">
@ -216,7 +267,7 @@
<tr>
<th class="w-[100px]">時間</th>
<th class="w-[160px]">機構</th>
<th class="w-[180px]">事件</th>
<th class="w-[100px]">事件</th>
<th class="w-[120px] text-center">查看詳情</th>
</tr>
@ -275,7 +326,9 @@
</div>
</section>
<!-- 表格 今日派車總表 -->
<section class="rounded-md shadow p-3 flex flex-col min-h-0 gap-3">
<section
class="bg-white/50 rounded-md shadow p-3 flex flex-col min-h-0 gap-3"
>
<h3 class="text-xl font-bold">今日派車總表</h3>
<!-- 可捲動內容區水平 -->
@ -358,6 +411,8 @@ import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { brand } from "@/styles/palette";
const activeKey = ref("residents");
// 7 M/D
function last7DaysLabels() {
const labels = [];
@ -370,13 +425,104 @@ function last7DaysLabels() {
return labels;
}
// Demo 0~100
const dataA = [15, 58, 25, 75, 90, 38, 76];
const dataB = [26, 78, 85, 32, 30, 52, 72];
const chartEl = ref(null);
let chart;
// 調
const chartDataMap = {
residents: {
legends: ["現在住民", "全立案床數"],
a: [15, 58, 25, 75, 90, 38, 76],
b: [26, 78, 85, 32, 30, 52, 72],
},
vacancy: {
legends: ["目前空床數", "全立案床數"],
a: [30, 22, 18, 40, 55, 28, 33],
b: Array(7).fill(60),
},
hospitalized: {
legends: ["今日住院", "當月累積住院"],
a: [2, 6, 3, 9, 8, 4, 5],
b: [5, 12, 18, 22, 28, 36, 42],
},
movein: {
legends: ["今日入住", "當月累積入住"],
a: [1, 3, 2, 4, 5, 3, 6],
b: [4, 8, 12, 17, 22, 30, 36],
},
moveout: {
legends: ["今日離院", "累積離院"],
a: [1, 2, 1, 3, 2, 1, 2],
b: [5, 7, 9, 12, 14, 15, 17],
},
};
// ProgressBar current/total
const progressTitleMap = {
residents: { current: 240, total: 360 },
vacancy: { current: 120, total: 360 },
hospitalized: { current: 8, total: 50 },
movein: { current: 12, total: 50 },
moveout: { current: 8, total: 50 },
};
function updateChartByKey(key) {
activeKey.value = key;
const conf = chartDataMap[key];
chart.setOption({
color: [brand.green, brand.purple],
title: {
// legend
text: `${conf.legends[0]}${conf.legends[1]}`,
left: "center",
top: 0,
textStyle: {
color: brand.gray,
fontSize: 14,
fontWeight: 600,
fontFamily: '"Noto Sans TC"',
},
},
legend: {
data: conf.legends,
bottom: 8,
icon: "circle",
itemWidth: 10,
itemHeight: 10,
itemGap: 24,
textStyle: { color: brand.gray },
},
yAxis: {
type: "value",
name: "數量",
nameGap: 12,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: brand.gray },
splitLine: { show: true, lineStyle: { color: brand.grayLight } },
},
series: [
{
name: conf.legends[0],
type: "line",
data: conf.a,
symbol: "circle",
symbolSize: 6,
lineStyle: { width: 2 },
},
{
name: conf.legends[1],
type: "line",
data: conf.b,
symbol: "circle",
symbolSize: 6,
lineStyle: { width: 2 },
},
],
});
}
onMounted(() => {
if (!chartEl.value) return;
chart = echarts.init(chartEl.value);
@ -384,67 +530,23 @@ onMounted(() => {
const labels = last7DaysLabels();
chart.setOption({
color: [brand.green, brand.purple], //
tooltip: {
trigger: "axis",
axisPointer: { type: "line" },
confine: true,
},
legend: {
bottom: 8,
icon: "circle",
itemWidth: 10,
itemHeight: 10,
itemGap: 24,
textStyle: { color: brand.gray },
data: ["現在住民", "全立案人數"],
},
grid: { left: 36, right: 16, top: 16, bottom: 48, containLabel: true },
grid: { left: 36, right: 16, top: 44, bottom: 56, containLabel: true },
tooltip: { trigger: "axis", axisPointer: { type: "line" }, confine: true },
xAxis: {
type: "category",
boundaryGap: false,
data: labels,
data: last7DaysLabels(),
axisLine: { lineStyle: { color: brand.gray } },
axisTick: { show: false },
axisLabel: { color: brand.gray },
splitLine: { show: false },
},
yAxis: {
type: "value",
min: 0,
max: 100,
interval: 20,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: brand.gray },
splitLine: {
show: true,
lineStyle: { color: brand.grayLight },
},
},
series: [
{
name: "現在住民",
type: "line",
data: dataA,
symbol: "circle",
symbolSize: 6,
lineStyle: { width: 2 },
itemStyle: { opacity: 1 },
},
{
name: "全立案人數",
type: "line",
data: dataB,
symbol: "circle",
symbolSize: 6,
lineStyle: { width: 2 },
itemStyle: { opacity: 1 },
},
],
animationDuration: 500,
yAxis: { type: "value", name: "數量" },
series: [],
});
updateChartByKey("residents");
// RWD
const onResize = () => chart?.resize();
window.addEventListener("resize", onResize);
@ -608,9 +710,25 @@ onMounted(() => {
paddingBottomRight: [24, 24],
maxZoom: 15,
});
//
setTimeout(() => map?.invalidateSize(), 0);
// ECharts
const onResize = () => {
chart?.resize?.();
map?.invalidateSize?.();
};
window.addEventListener("resize", onResize);
// 便
map.__onResize = onResize;
});
onBeforeUnmount(() => {
if (map?.__onResize) window.removeEventListener("resize", map.__onResize);
if (chart?.__onResize) window.removeEventListener("resize", chart.__onResize);
if (map) {
map.remove();
map = null;

View File

@ -9,6 +9,7 @@ export const brand = {
purpleLight: "#D5E1FF",
purpleDark: "#7089CA",
yellow: "#E1F391",
yellowDark: "#C4E920",
black: "#424242",
gray: "#828282",
grayLight: "#E9E9E9",