fix: 修正營運頁圖表為箱型圖

This commit is contained in:
MJM_2025_05\polly 2025-09-03 14:54:27 +08:00
parent fbf694ccae
commit ccfef49c47
9 changed files with 1460 additions and 869 deletions

View File

@ -1,3 +1,314 @@
<template>
<div class="relative w-full h-full min-h-full">
<div id="forge-preview" ref="forgeDom" class="absolute inset-0"></div>
<!-- Popovers永遠顯示在每顆 sprite 上方跟著相機更新 -->
<div class="absolute inset-0 z-20 pointer-events-none opacity-90">
<div
v-for="L in filteredLabels"
:key="L.id"
class="absolute -translate-x-1/2 -translate-y-full cursor-pointer"
:style="{
left: L.x + 'px',
top: L.y - 10 + 'px',
zIndex: activeLabelId === L.id ? 40 : 30,
}"
@click="bringToFrontById(L.id)"
>
<div
class="pointer-events-auto relative bg-white/95 border border-gray-300 rounded-md shadow px-3 py-2 text-sm cursor-pointer"
style="will-change: transform"
@click.stop="openResidentModal(L, $event)"
role="button"
tabindex="0"
@keydown.enter.prevent.stop="openResidentModal(L, $event)"
@keydown.space.prevent.stop="openResidentModal(L, $event)"
aria-label="查看 {{ L.data.name }} 詳細資訊"
>
<!-- 箭頭下方置中指向 sprite -->
<!-- 外層邊框色 -->
<span
class="absolute left-1/2 -bottom-2 -translate-x-1/2 w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-white"
aria-hidden="true"
></span>
<ul class="list-none">
<!-- 第一行床號永遠顯示 -->
<li class="flex justify-between items-center gap-1">
<div>
<span
class="inline-block w-2 h-2 rounded-full mr-1 align-middle"
:style="{
backgroundColor:
L.data.state === 'offnormal' ? BRAND_RED : BRAND_GREEN,
}"
></span>
<!-- 住院中才顯示黃色 icon -->
<span
v-if="L.data.special"
class="w-2 h-2 text-brand-yellow-dark inline-block align-middle me-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<!-- 床號永遠顯示 -->
<span class="align-middle">{{ L.data.name }}</span>
</div>
<!-- 眼睛 icon -->
<div class="text-gray-400" title="查看詳細">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
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>
</div>
</li>
<!-- 第二行住民基本資料 -->
<li>
{{
isVacantWithoutSpecial(L)
? "-"
: `${L.data.residentsName}${L.data.residentsSex}${L.data.residentsAge}`
}}
</li>
<!-- 第三行入住日期 -->
<li>
{{
isVacantWithoutSpecial(L)
? "-"
: `入住日期:${L.data.startTime}`
}}
</li>
</ul>
</div>
</div>
</div>
<!-- 樓層切換 -->
<div
class="absolute top-4 left-4 text-sm flex justify-center items-center gap-2 z-10 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">樓層</p>
<button
class="px-4 py-2 rounded-md"
:class="activeFloor === '9F' ? 'bg-brand-green-light' : 'bg-white'"
@click="onClickFloor('9F')"
aria-pressed="activeFloor==='9F'"
>
1F
</button>
<button
class="px-4 py-2 rounded-md hover:bg-gray-100"
:class="activeFloor === '8F' ? 'bg-brand-green-light' : 'bg-white'"
@click="onClickFloor('8F')"
aria-pressed="activeFloor==='8F'"
>
2F
</button>
</div>
<!-- 床位資訊-->
<div
class="absolute top-16 left-4 text-sm flex justify-center items-center z-10 bg-white border rounded-md shadow ps-4"
>
<p class="py-2">床位資訊</p>
<div class="flex justify-start items-center">
<!-- 無顯示 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'none' ? 'bg-brand-green-light bg-opacity-50' : ''
"
@click="selectInfo({ label: '無顯示', value: 'none' })"
:aria-pressed="selectedInfo === 'none'"
>
<span>不顯示</span>
</button>
<!-- 有住民 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'occupied'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '有住民', value: 'occupied' })"
:aria-pressed="selectedInfo === 'occupied'"
>
<span class="w-2 h-2 bg-brand-green rounded-full inline-block"></span>
<span>有住民</span>
</button>
<!-- 空床 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'vacant'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '空床', value: 'vacant' })"
:aria-pressed="selectedInfo === 'vacant'"
>
<span class="w-2 h-2 bg-brand-red rounded-full inline-block"></span>
<span>空床</span>
</button>
<!-- 住院中 + 黃色警示 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'hospitalized'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '住院中', value: 'hospitalized' })"
:aria-pressed="selectedInfo === 'hospitalized'"
>
<span class="w-2 h-2 text-brand-yellow-dark inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span>住院中</span>
</button>
<!-- 請假中 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'leave' ? 'bg-brand-green-light bg-opacity-50' : ''
"
@click="selectInfo({ label: '請假中', value: 'leave' })"
:aria-pressed="selectedInfo === 'leave'"
>
<span class="w-2 h-2 text-gray-400 inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span>請假中</span>
</button>
</div>
</div>
<!-- 分區切換多選預設不選顯示全部 -->
<div
class="absolute bottom-4 left-4 text-sm flex justify-center items-center gap-2 z-10 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">查看分區</p>
<button
v-for="zone in zones"
:key="zone"
class="px-4 py-2 rounded-md hover:bg-gray-100"
:class="
isZoneSelected(zone)
? 'bg-brand-green-light bg-opacity-50'
: 'bg-white'
"
@click="toggleZone(zone)"
:aria-pressed="isZoneSelected(zone)"
>
{{ zone }}
</button>
</div>
</div>
<!-- 中央 Modal病患資訊 -->
<div
v-if="modalOpen"
class="fixed inset-0 z-[120] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/40" @click="closeResidentModal"></div>
<!-- Modal 內容 -->
<div
class="relative z-[130] w-[min(92vw,500px)] max-h-[84vh] overflow-auto bg-white rounded-2xl shadow-xl p-6"
@click.stop
>
<!-- 標題 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold tracking-widest">病患資訊</h3>
<button
class="px-3 py-1 rounded-md hover:bg-gray-100"
@click="closeResidentModal"
aria-label="關閉"
>
</button>
</div>
<!-- 內容清單 -->
<ul class="list-none text-sm space-y-2">
<li><span class="text-gray-500">姓名</span>{{ modalData?.name }}</li>
<li><span class="text-gray-500">性別</span>{{ modalData?.sex }}</li>
<li><span class="text-gray-500">年齡</span>{{ modalData?.age }}</li>
<li>
<span class="text-gray-500">入住日期</span
>{{ modalData?.startTime }}
</li>
<li>
<span class="text-gray-500">身體狀況</span
>{{ modalData?.healthStatus }}
</li>
<li>
<span class="text-gray-500">服藥狀況</span
>{{ modalData?.medicationStatus }}
</li>
<li>
<span class="text-gray-500">特殊事件</span
>{{ modalData?.specialEvent }}
</li>
<li class="whitespace-pre-wrap break-words">
<span class="text-gray-500">備註</span>{{ modalData?.note }}
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute } from "vue-router";
@ -389,7 +700,7 @@ function rebuildLabelsAfterNextRender() {
}
// ====== ======
const zones = ["VIP", "換管", "失能", "失智"];
const zones = ["一般", "氧氣"];
const selectedZones = ref(new Set());
const toggleZone = (zone) => {
const set = new Set(selectedZones.value);
@ -569,318 +880,6 @@ onUnmounted(() => {
document.removeEventListener("keydown", onKeydownInfo);
});
</script>
<template>
<div class="relative w-full h-full min-h-full">
<div id="forge-preview" ref="forgeDom" class="absolute inset-0"></div>
<!-- Popovers永遠顯示在每顆 sprite 上方跟著相機更新 -->
<div class="absolute inset-0 z-20 pointer-events-none opacity-90">
<div
v-for="L in filteredLabels"
:key="L.id"
class="absolute -translate-x-1/2 -translate-y-full cursor-pointer"
:style="{
left: L.x + 'px',
top: L.y - 10 + 'px',
zIndex: activeLabelId === L.id ? 40 : 30,
}"
@click="bringToFrontById(L.id)"
>
<div
class="pointer-events-auto relative bg-white/95 border border-gray-300 rounded-md shadow px-3 py-2 text-sm cursor-pointer"
style="will-change: transform"
@click.stop="openResidentModal(L, $event)"
role="button"
tabindex="0"
@keydown.enter.prevent.stop="openResidentModal(L, $event)"
@keydown.space.prevent.stop="openResidentModal(L, $event)"
aria-label="查看 {{ L.data.name }} 詳細資訊"
>
<!-- 箭頭下方置中指向 sprite -->
<!-- 外層邊框色 -->
<span
class="absolute left-1/2 -bottom-2 -translate-x-1/2 w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-white"
aria-hidden="true"
></span>
<ul class="list-none">
<!-- 第一行床號永遠顯示 -->
<li class="flex justify-between items-center gap-1">
<div>
<span
class="inline-block w-2 h-2 rounded-full mr-1 align-middle"
:style="{
backgroundColor:
L.data.state === 'offnormal' ? BRAND_RED : BRAND_GREEN,
}"
></span>
<!-- 住院中才顯示黃色 icon -->
<span
v-if="L.data.special"
class="w-2 h-2 text-brand-yellow-dark inline-block align-middle me-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<!-- 床號永遠顯示 -->
<span class="align-middle">{{ L.data.name }}</span>
</div>
<!-- 眼睛 icon -->
<div class="text-gray-400" title="查看詳細">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
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>
</div>
</li>
<!-- 第二行住民基本資料 -->
<li>
{{
isVacantWithoutSpecial(L)
? "-"
: `${L.data.residentsName}${L.data.residentsSex}${L.data.residentsAge}`
}}
</li>
<!-- 第三行入住日期 -->
<li>
{{
isVacantWithoutSpecial(L)
? "-"
: `入住日期:${L.data.startTime}`
}}
</li>
</ul>
</div>
</div>
</div>
<!-- 樓層切換 -->
<div
class="absolute top-4 left-4 text-sm flex justify-center items-center gap-2 z-10 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">樓層</p>
<button
class="px-4 py-2 rounded-md"
:class="activeFloor === '9F' ? 'bg-brand-green-light' : 'bg-white'"
@click="onClickFloor('9F')"
aria-pressed="activeFloor==='9F'"
>
1F
</button>
<button
class="px-4 py-2 rounded-md hover:bg-gray-100"
:class="activeFloor === '8F' ? 'bg-brand-green-light' : 'bg-white'"
@click="onClickFloor('8F')"
aria-pressed="activeFloor==='8F'"
>
2F
</button>
</div>
<!-- 分區切換多選預設不選顯示全部 -->
<div
class="absolute top-16 left-4 text-sm flex justify-center items-center gap-2 z-10 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">分區</p>
<button
v-for="zone in zones"
:key="zone"
class="px-4 py-2 rounded-md hover:bg-gray-100"
:class="
isZoneSelected(zone)
? 'bg-brand-green-light bg-opacity-50'
: 'bg-white'
"
@click="toggleZone(zone)"
:aria-pressed="isZoneSelected(zone)"
>
{{ zone }}
</button>
</div>
<!-- 床位資訊-->
<div
class="absolute bottom-4 left-4 text-sm flex justify-center items-center z-10 bg-white border rounded-md shadow ps-4"
>
<p class="py-2">床位資訊</p>
<div class="flex justify-start items-center">
<!-- 無顯示 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'none' ? 'bg-brand-green-light bg-opacity-50' : ''
"
@click="selectInfo({ label: '無顯示', value: 'none' })"
:aria-pressed="selectedInfo === 'none'"
>
<span>不顯示</span>
</button>
<!-- 有住民 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'occupied'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '有住民', value: 'occupied' })"
:aria-pressed="selectedInfo === 'occupied'"
>
<span class="w-2 h-2 bg-brand-green rounded-full inline-block"></span>
<span>有住民</span>
</button>
<!-- 空床 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'vacant'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '空床', value: 'vacant' })"
:aria-pressed="selectedInfo === 'vacant'"
>
<span class="w-2 h-2 bg-brand-red rounded-full inline-block"></span>
<span>空床</span>
</button>
<!-- 住院中 + 黃色警示 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'hospitalized'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '住院中', value: 'hospitalized' })"
:aria-pressed="selectedInfo === 'hospitalized'"
>
<span class="w-2 h-2 text-brand-yellow-dark inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span>住院中</span>
</button>
<!-- 請假中 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'leave' ? 'bg-brand-green-light bg-opacity-50' : ''
"
@click="selectInfo({ label: '請假中', value: 'leave' })"
:aria-pressed="selectedInfo === 'leave'"
>
<span class="w-2 h-2 text-gray-400 inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span>請假中</span>
</button>
</div>
</div>
</div>
<!-- 中央 Modal病患資訊 -->
<div
v-if="modalOpen"
class="fixed inset-0 z-[120] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/40" @click="closeResidentModal"></div>
<!-- Modal 內容 -->
<div
class="relative z-[130] w-[min(92vw,500px)] max-h-[84vh] overflow-auto bg-white rounded-2xl shadow-xl p-6"
@click.stop
>
<!-- 標題 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold tracking-widest">病患資訊</h3>
<button
class="px-3 py-1 rounded-md hover:bg-gray-100"
@click="closeResidentModal"
aria-label="關閉"
>
</button>
</div>
<!-- 內容清單 -->
<ul class="list-none text-sm space-y-2">
<li><span class="text-gray-500">姓名</span>{{ modalData?.name }}</li>
<li><span class="text-gray-500">性別</span>{{ modalData?.sex }}</li>
<li><span class="text-gray-500">年齡</span>{{ modalData?.age }}</li>
<li>
<span class="text-gray-500">入住日期</span
>{{ modalData?.startTime }}
</li>
<li>
<span class="text-gray-500">身體狀況</span
>{{ modalData?.healthStatus }}
</li>
<li>
<span class="text-gray-500">服藥狀況</span
>{{ modalData?.medicationStatus }}
</li>
<li>
<span class="text-gray-500">特殊事件</span
>{{ modalData?.specialEvent }}
</li>
<li class="whitespace-pre-wrap break-words">
<span class="text-gray-500">備註</span>{{ modalData?.note }}
</li>
</ul>
</div>
</div>
</template>
<style>
.adsk-viewing-viewer {
background-color: transparent !important;

View File

View File

@ -1,6 +1,6 @@
<template>
<nav
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]"
class="sticky top-0 w-full h-14 lg:h-16 bg-white/50 bg-opacity-80 shadow-md flex justify-between items-center px-4 sm:px-6 lg:px-8 z-[80]"
>
<!-- 左側Logo + 機構切換 -->
<div
@ -10,7 +10,7 @@
<img src="/img/logo.png" alt="Logo" class="h-full w-auto" />
</RouterLink>
<div class="relative">
<div class="relative" v-if="!isHome">
<!-- 手機到桌機都顯示機構名稱維持桌機尺寸 -->
<div
ref="triggerRef"
@ -18,13 +18,13 @@
role="button"
tabindex="0"
aria-haspopup="true"
:aria-expanded="isOpen"
@click="toggle"
@keydown.enter.prevent="toggle"
@keydown.space.prevent="toggle"
:aria-expanded="isFacilityOpen"
@click="toggleFacility"
@keydown.enter.prevent="toggleFacility"
@keydown.space.prevent="toggleFacility"
>
<!-- 直接顯示不再隱藏 -->
<span>{{ displayLabel }}</span>
<span class="tracking-wider">{{ displayLabel }}</span>
<!-- caret icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
@ -38,9 +38,9 @@
</svg>
</div>
<!-- Dropdown小螢幕靠左桌機可改靠右 -->
<!-- Dropdown-->
<div
v-show="isOpen"
v-show="isFacilityOpen"
ref="panelRef"
class="absolute top-12 left-0 md:top-12 md:left-0 lg:left-0 z-50 w-56 md:w-64 rounded-md border border-gray-100 bg-white shadow-lg p-2"
@click.stop
@ -50,7 +50,7 @@
v-for="item in facilities"
:key="item"
@click="selectItem(item)"
class="px-3 py-2 rounded-md cursor-pointer hover:bg-gray-100"
class="px-3 py-2 rounded-md cursor-pointer hover:bg-gray-100 tracking-wider"
:class="selectedItem === item ? 'bg-brand-green-light' : ''"
>
{{ item }}
@ -508,28 +508,41 @@ import {
onUnmounted,
defineOptions,
nextTick,
watch,
} from "vue";
import { useRoute } from "vue-router";
// ====== ======
const route = useRoute();
const isHome = computed(() => route.name === "home" || route.path === "/");
// Dropdown
const isOpen = ref(false);
watch(
() => route.fullPath,
(p) => {
if (p === "/" || route.name === "home") isOpen.value = false;
}
);
// ====== Dropdown======
const isOpen = ref(false);
const isFacilityOpen = ref(false);
const triggerRef = ref(null);
const panelRef = ref(null);
const facilities = [
"總部",
"護祐護理之家",
"崇祐護理之家",
"崇祐長照中心(養護型)",
"育祐護理之家",
"崇恩長照中心(養護型)",
"崇恩護理之家",
"崇祐護理之家",
"護祐護理之家",
"育祐護理之家",
"崇智護理之家",
"崇恩居家護理所",
"傳心日間照顧中心",
"敬慈居家服務中心",
"崇恩長照中心(養護型)",
"崇祐長照中心(養護型)",
"傳祐長照中心(養護型)",
"中安崇恩長照中心(養護型)",
"崇智護理之家",
"慈祐長照中心(養護型)",
"中安崇恩長照中心(養護型)",
];
const selectedItem = ref(facilities[0]);
@ -543,8 +556,8 @@ const displayLabel = computed(() => {
});
defineOptions({ name: "NavBar" });
const toggle = () => {
isOpen.value = !isOpen.value;
const toggleFacility = () => {
isFacilityOpen.value = !isFacilityOpen.value;
};
const onClickOutside = (e) => {
@ -553,7 +566,7 @@ const onClickOutside = (e) => {
const clickedInsideTrigger = triggerRef.value.contains(t);
const clickedInsidePanel = panelRef.value.contains(t);
if (!clickedInsideTrigger && !clickedInsidePanel) {
isOpen.value = false;
isFacilityOpen.value = false;
}
};
@ -573,8 +586,8 @@ const closeMobileMenu = () => {
// ====== Esc ======
const handleKeydown = (e) => {
if (e.key === "Escape") {
isOpen.value = false; // dropdown
isMobileMenuOpen.value = false; //
isFacilityOpen.value = false; // dropdown
isMobileMenuOpen.value = false; // ham menu
}
};

View File

@ -20,7 +20,7 @@
<div class="relative">
<progress
v-bind="$attrs"
class="progress w-full h-5 bg-brand-gray-light text-brand-green-light text-left rounded-none [&::-webkit-progress-bar]:rounded-none [&::-webkit-progress-value]:rounded-none [&::-moz-progress-bar]:rounded-none"
class="progress w-full h-5 bg-brand-gray-lighter text-brand-green-light text-left rounded-none [&::-webkit-progress-bar]:rounded-none [&::-webkit-progress-value]:rounded-none [&::-moz-progress-bar]:rounded-none"
:class="heightClass"
:value="current"
:max="total"

View File

@ -8,7 +8,7 @@
>
<!-- 上半照片/說明 + 進度條獨立 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"
class="row-span-5 bg-white/70 rounded-md shadow p-6 grid grid-cols-1 md:grid-cols-2 items-start gap-6 min-h-0"
>
<!-- 照片 + 說明手機隱藏平板/桌機顯示 -->
<div
@ -21,9 +21,9 @@
class="w-full h-full rounded-sm object-cover"
/>
</div>
<div class="row-span-4">
<div class="row-span-6">
<p
class="text-brand-gray bg-white/50 text-sm border rounded-sm px-2 py-3 border-brand-gray-light"
class="text-brand-gray bg-white/70 text-sm border rounded-sm px-2 py-3 border-brand-gray-light"
>
崇恩長期照顧集團是國內第一家取得ISO認證的長期照顧機構集合了醫師群與資深護理群及照顧服務員們在大南部地區照顧每一位需要我們的長輩
</p>
@ -67,7 +67,7 @@
<!-- Move-in -->
<ProgressBar
label="今日新入住/當月累積入住"
label="今日新入住/當月累積入住"
:current="12"
:total="50"
chartKey="movein"
@ -78,12 +78,12 @@
<!-- Move-out -->
<ProgressBar
label="今日離院/累積離院"
label="今日離院/當月累積離院"
:current="8"
:total="50"
chartKey="moveout"
currentLegend="今日離院"
totalLegend="累積離院"
totalLegend="當月累積離院"
@select="({ key }) => updateChartByKey(key)"
/>
</div>
@ -91,7 +91,7 @@
<!-- 下半圖表獨立 section有內距 -->
<section
class="row-span-4 bg-white/50 rounded-md shadow min-h-0 flex items-stretch"
class="row-span-4 bg-white/70 rounded-md shadow min-h-0 flex items-stretch"
>
<!-- 這層專門控制圖表外圍留白 -->
<div
@ -103,7 +103,7 @@
<!-- 素資料 -->
<section class="row-span-3 grid grid-cols-2 gap-2">
<div class="col-span-1 bg-white/50 rounded-md shadow p-6">
<div class="col-span-1 bg-white/70 rounded-md shadow p-6">
<div
class="border border-brand-yellow rounded-md w-full h-full flex flex-col justify-start items-center gap-3 p-3"
>
@ -137,7 +137,7 @@
</div>
</div>
</div>
<div class="col-span-1 bg-white/50 rounded-md shadow p-6">
<div class="col-span-1 bg-white/70 rounded-md shadow p-6">
<div
class="border border-brand-yellow rounded-md w-full h-full flex flex-col justify-start items-center gap-3 p-3"
>
@ -173,52 +173,45 @@
</div>
</section>
</section>
<!-- 中間 -->
<section class="bg-white/50 rounded-md shadow p-3 order-1 lg:order-2">
<section class="bg-white/70 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="flex flex-col gap-2 order-4 lg:order-3">
<!-- 表格 今日活動 -->
<section
class="bg-white/50 rounded-md shadow p-3 flex flex-col min-h-0 gap-3"
class="bg-white/70 rounded-md shadow p-6 flex flex-col min-h-0 gap-3"
>
<h3 class="text-xl font-bold">今日活動</h3>
<h3 class="text-2xl font-bold">今日活動</h3>
<!-- 可捲動內容區水平 -->
<div class="flex flex-col gap-4 mb-6">
<div class="w-full overflow-x-auto overflow-y-auto">
<table class="table whitespace-nowrap">
<thead
class="bg-brand-gray-light text-brand-black sticky top-0 z-[1]"
class="bg-brand-gray-lighter text-brand-black sticky top-0 z-[1]"
>
<tr>
<th class="w-[120px]">時間</th>
<th class="w-[160px]">機構</th>
<th class="w-[160px]">活動名稱</th>
<th class="w-[120px] text-center">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in pagedRows"
:key="row.id"
class="transition-colors duration-150 hover:bg-brand-gray-light focus-visible:bg-gray-100/70"
class="transition-colors duration-150 hover:bg-brand-gray-lighter focus-visible:bg-gray-100/70"
>
<td>{{ row.time }}</td>
<td>{{ row.org }}</td>
<td class="truncate">{{ row.title }}</td>
<td class="text-center">
<button
class="btn btn-link btn-xs px-0 text-brand-purple-dark !no-underline hover:!no-underline hover:opacity-80 focus-visible:!no-underline"
@click="viewDetail(row)"
>
查看詳情
</button>
</td>
</tr>
</tbody>
</table>
@ -226,12 +219,12 @@
</div>
<!-- 分頁 -->
<div class="mt-3 flex items-center justify-between px-3">
<span class="text-sm text-gray-500">
<span class="text-sm text-brand-gray">
{{ total }} 每頁 {{ pageSize }}
</span>
<div class="inline-flex items-center gap-2">
<button
class="btn btn-sm btn-outline disabled:!opacity-100 disabled:!text-gray-500 disabled:!border-gray-300 disabled:cursor-not-allowed"
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === 1"
@click="currentPage--"
>
@ -243,7 +236,7 @@
</span>
<button
class="btn btn-sm btn-outline disabled:!opacity-100 disabled:!text-gray-500 disabled:!border-gray-300 disabled:cursor-not-allowed"
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
@ -254,22 +247,20 @@
</section>
<!-- 表格 今日異常事件 -->
<section
class="bg-white/50 rounded-md shadow p-3 flex flex-col min-h-0 gap-3"
class="bg-white/70 rounded-md shadow p-6 flex flex-col min-h-0 gap-3"
>
<h3 class="text-xl font-bold">今日異常事件</h3>
<h3 class="text-2xl font-bold">今日異常事件</h3>
<!-- 可捲動內容區水平 -->
<div class="flex flex-col gap-4 mb-6">
<div class="w-full overflow-x-auto overflow-y-auto">
<table class="table whitespace-nowrap">
<thead
class="bg-brand-gray-light text-brand-black sticky top-0 z-[1]"
class="bg-brand-gray-lighter text-brand-black sticky top-0 z-[1]"
>
<tr>
<th class="w-[100px]">時間</th>
<th class="w-[160px]">機構</th>
<th class="w-[100px]">事件</th>
<th class="w-[120px] text-center">查看詳情</th>
</tr>
</thead>
@ -277,20 +268,11 @@
<tr
v-for="row in pagedIncidentRows"
:key="row.id"
class="transition-colors duration-150 hover:bg-brand-gray-light focus-visible:bg-gray-100/70"
class="transition-colors duration-150 hover:bg-brand-gray-lighter focus-visible:bg-gray-100/70"
>
<td>{{ row.time }}</td>
<td class="truncate" :title="row.org">{{ row.org }}</td>
<td class="truncate" :title="row.event">{{ row.event }}</td>
<td class="text-center">
<button
class="btn btn-link btn-xs px-0 text-brand-purple-dark !no-underline hover:!no-underline hover:opacity-80 focus-visible:!no-underline"
@click="viewDetailIncident(row)"
>
查看詳情
</button>
</td>
</tr>
</tbody>
</table>
@ -299,26 +281,26 @@
<!-- 分頁固定在底 -->
<div class="mt-3 flex items-center justify-between">
<span class="ml-3 text-sm text-gray-500">
<span class="ml-3 text-sm text-brand-gray">
{{ incidentTotal }} 每頁 {{ incidentPageSize }}
</span>
<div class="inline-flex items-center gap-2">
<button
class="btn btn-sm btn-outline disabled:!opacity-100 disabled:!text-gray-500 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === 1"
@click="currentPage--"
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="incidentPage === 1"
@click="incidentPage--"
>
上一頁
</button>
<span class="px-2 text-sm tabular-nums text-brand-purple-dark">
{{ currentPage }} / {{ totalPages }}
{{ incidentPage }} / {{ incidentTotalPages }}
</span>
<button
class="btn btn-sm btn-outline disabled:!opacity-100 disabled:!text-gray-500 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === totalPages"
@click="currentPage++"
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="incidentPage === incidentTotalPages"
@click="incidentPage++"
>
下一頁
</button>
@ -327,22 +309,21 @@
</section>
<!-- 表格 今日派車總表 -->
<section
class="bg-white/50 rounded-md shadow p-3 flex flex-col min-h-0 gap-3"
class="bg-white/70 rounded-md shadow p-6 flex flex-col min-h-0 gap-3"
>
<h3 class="text-xl font-bold">今日派車總表</h3>
<h3 class="text-2xl font-bold">今日派車總表</h3>
<!-- 可捲動內容區水平 -->
<div class="flex flex-col gap-4 mb-6">
<div class="w-full overflow-x-auto">
<table class="table whitespace-nowrap">
<thead
class="bg-brand-gray-light text-brand-black sticky top-0 z-[1]"
class="bg-brand-gray-lighter text-brand-black sticky top-0 z-[1]"
>
<tr>
<th class="w-[120px]">時間</th>
<th class="w-[160px]">機構</th>
<th class="w-[120px]">聯絡人</th>
<th class="w-[120px] text-center">操作</th>
</tr>
</thead>
@ -350,21 +331,13 @@
<tr
v-for="row in pagedDispatchRows"
:key="row.id"
class="transition-colors duration-150 hover:bg-brand-gray-light focus-visible:bg-gray-100/70"
class="transition-colors duration-150 hover:bg-brand-gray-lighter focus-visible:bg-gray-100/70"
>
<td>{{ row.time }}</td>
<td class="truncate" :title="row.org">{{ row.org }}</td>
<td class="truncate" :title="row.contact">
{{ row.contact }}
</td>
<td class="text-center">
<button
class="btn btn-link btn-xs px-0 text-brand-purple-dark !no-underline hover:!no-underline hover:opacity-80 focus-visible:!no-underline"
@click="viewDispatchDetail(row)"
>
查看詳情
</button>
</td>
</tr>
</tbody>
</table>
@ -373,26 +346,26 @@
<!-- 分頁固定在底 -->
<div class="mt-3 flex items-center justify-between">
<span class="ml-3 text-sm text-gray-500">
<span class="ml-3 text-sm text-brand-gray">
{{ dispatchTotal }} 每頁 {{ dispatchPageSize }}
</span>
<div class="inline-flex items-center">
<button
class="btn btn-sm btn-outline disabled:!opacity-100 disabled:!text-gray-500 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === 1"
@click="currentPage--"
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="dispatchPage === 1"
@click="dispatchPage--"
>
上一頁
</button>
<span class="px-2 text-sm tabular-nums text-brand-purple-dark">
{{ currentPage }} / {{ totalPages }}
{{ dispatchPage }} / {{ dispatchTotalPages }}
</span>
<button
class="btn btn-sm btn-outline disabled:!opacity-100 disabled:!text-gray-500 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === totalPages"
@click="currentPage++"
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="dispatchPage === dispatchTotalPages"
@click="dispatchPage++"
>
下一頁
</button>
@ -466,24 +439,48 @@ const progressTitleMap = {
moveout: { current: 8, total: 50 },
};
// 1) helper utilities
function mkTitle(text, subtext = "", opts = {}) {
return {
text,
subtext,
left: "center",
top: 6, //
itemGap: 6, //
textStyle: {
color: brand.black,
fontSize: 20,
fontWeight: 600,
fontFamily: '"Noto Sans TC"',
},
subtextStyle: {
color: brand.gray,
fontSize: 14,
fontWeight: 400,
fontFamily: '"Noto Sans TC"',
lineHeight: 20,
},
...opts,
};
}
// 2) updateChartByKey legends
function updateChartByKey(key) {
activeKey.value = key;
const conf = chartDataMap[key];
const title = `${conf.legends[0]} ${conf.legends[1]}`;
const subTitle = "(近 7 天)";
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"',
},
},
//
title: mkTitle(title, subTitle),
// grid.top
grid: { top: 70, containLabel: true },
legend: {
data: conf.legends,
bottom: 8,
@ -493,15 +490,22 @@ function updateChartByKey(key) {
itemGap: 24,
textStyle: { color: brand.gray },
},
yAxis: {
type: "value",
name: "數量",
nameGap: 12,
axisLine: { show: false },
axisTick: { show: false },
nameLocation: "middle",
nameGap: 40,
axisLine: { show: true },
axisTick: { show: true },
axisLabel: { color: brand.gray },
splitLine: { show: true, lineStyle: { color: brand.grayLight } },
splitArea: {
show: true,
areaStyle: { color: [brand.white, brand.grayLighter] },
},
},
series: [
{
name: conf.legends[0],
@ -665,32 +669,61 @@ onMounted(() => {
<div class="flex flex-col gap-2">
<!-- 第一列icon + 名稱-->
<div class="inline-flex justify-start items-center text-brand-purple-dark font-noto gap-1">
<div class="inline-flex justify-between items-center text-brand-purple-dark font-noto">
<div class="inline-flex justify-start items-center gap-1">
<span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="currentColor" d="M5 19v-8.692q0-.384.172-.727t.474-.565l5.385-4.078q.423-.323.966-.323t.972.323l5.385 4.077q.303.222.474.566q.172.343.172.727V19q0 .402-.299.701T18 20h-3.384q-.344 0-.576-.232q-.232-.233-.232-.576v-4.769q0-.343-.232-.575q-.233-.233-.576-.233h-2q-.343 0-.575.233q-.233.232-.233.575v4.77q0 .343-.232.575T9.385 20H6q-.402 0-.701-.299T5 19"/>
</svg>
</span>
<strong class="text-[14px]">${p.name}</strong>
</div>
<div>
<span class="text-brand-gray/50">
<svg
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>
</div>
</div>
<!-- 第二列統計卡群 -->
<div class="flex justify-center items-center gap-1">
<div class="flex flex-col justify-center items-center gap-1 border p-2 rounded-md">
<div class="text-[16px]"><strong>住民</strong></div>
<div class="font-nats text-xl text-brand-purple-dark"><p>36 / 49</p></div>
</div>
<div class="inline-block rounded-md border border-gray-300 overflow-hidden">
<table class="min-w-[160px] border-collapse">
<thead class="bg-brand-gray-lighter text-brand-black">
<tr>
<th class="px-3 py-2 text-center border-r border-gray-300">住民</th>
<th class="px-3 py-2 text-center border-r border-gray-300">空床</th>
<th class="px-3 py-2 text-center">住院</th>
</tr>
</thead>
<tbody>
<tr>
<td class="px-3 py-2 border-r border-gray-300">
<span class="font-nats text-xl text-brand-purple-dark">36 / 49</span>
</td>
<td class="px-3 py-2 border-r border-gray-300">
<span class="font-nats text-xl text-brand-purple-dark">12 / 49</span>
</td>
<td class="px-3 py-2">
<span class="font-nats text-xl text-brand-purple-dark">1 / 20</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col justify-center items-center gap-1 border p-2 rounded-md">
<div class="text-[16px]"><strong>空床</strong></div>
<div class="font-nats text-xl text-brand-purple-dark"><p>12 / 49</p></div>
</div>
<div class="flex flex-col justify-center items-center gap-1 border p-2 rounded-md">
<div class="text-[16px]"><strong>住院</strong></div>
<div class="font-nats text-xl text-brand-purple-dark"><p>1 / 20</p></div>
</div>
</div>
</div>
</div>
`,
@ -698,7 +731,7 @@ onMounted(() => {
permanent: true, //
direction: "top", // pin
offset: [0, -(ICON_H + TIP_GAP)],
opacity: 0.9,
opacity: 0.95,
}
);
});
@ -823,11 +856,6 @@ const pagedDispatchRows = computed(() => {
const start = (dispatchPage.value - 1) * dispatchPageSize;
return dispatchRows.value.slice(start, start + dispatchPageSize);
});
//
function viewDispatchDetail(row) {
console.log("派車詳情:", row);
}
</script>
<style scoped></style>

View File

@ -1,14 +1,16 @@
<template>
<section
class="flex flex-col gap-2 h-[calc(100vh-72px-24px)] overflow-hidden"
class="flex flex-col gap-2 h-[calc(100vh-72px-32px)] overflow-hidden text-brand-black"
>
<!-- 高度比重2 -->
<div class="flex-[2] grid grid-cols-4 gap-2">
<!-- 高度比重2 -->
<section class="flex-[2] grid grid-cols-3 gap-2">
<!-- 住民人數 Card -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openModal('residents')"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">住民人</p>
<p class="text-sm">現在住民立案床</p>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -28,11 +30,14 @@
<p class="text-[12px]"></p>
</div>
</div>
<!-- 住院人數 Card -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openModal('inpatients')"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">住院人數</p>
<p class="text-sm">今日住院當月累積住院</p>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -52,11 +57,152 @@
<p class="text-[12px]"></p>
</div>
</div>
<!-- ===== Modal放在同一個 <template> 建議貼在最底部===== -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
v-if="showModal"
class="fixed inset-0 z-[100] flex items-center justify-center"
@keydown.esc="closeModal"
tabindex="0"
>
<!-- backdrop -->
<div class="absolute inset-0 bg-black/40" @click="closeModal"></div>
<!-- dialog -->
<div
class="relative bg-white w-[92vw] max-w-5xl max-h-[86vh] overflow-auto rounded-2xl shadow-xl px-12 py-8"
role="dialog"
aria-modal="true"
>
<!-- header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-2xl font-bold text-gray-800">
{{ modalTitle }}
</h3>
<button
class="p-2 rounded hover:bg-gray-100"
@click="closeModal"
aria-label="關閉"
>
</button>
</div>
<!-- body住民資訊A/B + 表格 -->
<div v-if="modalType === 'residents'" class="space-y-8">
<section>
<h4 class="text-lg font-semibold text-gray-700 mb-3">A </h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm text-brand-black">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">性別</th>
<th class="px-3 py-2">年齡</th>
<th class="px-3 py-2">身體狀況</th>
<th class="px-3 py-2">備註</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in residentsA"
:key="r.bed"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ r.bed }}</td>
<td class="px-3 py-2">{{ r.name }}</td>
<td class="px-3 py-2">{{ r.gender }}</td>
<td class="px-3 py-2">{{ r.age }}</td>
<td class="px-3 py-2">{{ r.condition }}</td>
<td class="px-3 py-2">{{ r.note }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section>
<h4 class="text-lg font-semibold text-gray-700 mb-3">B </h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm text-brand-black">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">性別</th>
<th class="px-3 py-2">年齡</th>
<th class="px-3 py-2">身體狀況</th>
<th class="px-3 py-2">備註</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in residentsB"
:key="r.bed"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ r.bed }}</td>
<td class="px-3 py-2">{{ r.name }}</td>
<td class="px-3 py-2">{{ r.gender }}</td>
<td class="px-3 py-2">{{ r.age }}</td>
<td class="px-3 py-2">{{ r.condition }}</td>
<td class="px-3 py-2">{{ r.note }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<!-- body住院資訊 -->
<div v-else-if="modalType === 'inpatients'">
<h4 class="text-lg font-semibold text-gray-700 mb-3">清單</h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm text-brand-black">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">醫院與科別</th>
<th class="px-3 py-2">病歷號</th>
<th class="px-3 py-2">狀況</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in inpatients"
:key="p.recordNo"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ p.bed }}</td>
<td class="px-3 py-2">{{ p.name }}</td>
<td class="px-3 py-2">{{ p.hospitalDept }}</td>
<td class="px-3 py-2 font-mono">{{ p.recordNo }}</td>
<td class="px-3 py-2">{{ p.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- footer -->
<div class="mt-6 text-right">
<button
class="btn bg-brand-green text-white px-4 py-2 rounded-md"
@click="closeModal"
>
關閉
</button>
</div>
</div>
</div>
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">其他人數</p>
<p class="text-sm">今日離院當月累積離院</p>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -76,316 +222,506 @@
<p class="text-[12px]"></p>
</div>
</div>
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">其他人數</p>
<span>
<svg
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>
</div>
<div class="flex justify-center items-baseline gap-1">
<p class="text-[36px] xl:text-[40px] font-nats">0/49</p>
<p class="text-[12px]"></p>
</div>
</div>
</div>
</section>
<!-- 高度比重2 -->
<div class="flex-[2] grid grid-cols-4 gap-2">
<div
class="col-span-1 bg-white bg-opacity-70 text-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">其他人數</p>
<span>
<svg
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>
</div>
<div class="flex justify-center items-baseline gap-1">
<p class="text-[36px] font-nats">36/49</p>
<p class="text-[12px]"></p>
</div>
</div>
<div
class="col-span-1 bg-white bg-opacity-70 text-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">其他人數</p>
<span>
<svg
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>
</div>
<div class="flex justify-center items-baseline gap-1">
<p class="text-[36px] font-nats">1/36</p>
<p class="text-[12px]"></p>
</div>
</div>
<div
class="col-span-1 bg-white bg-opacity-70 text-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">其他人數</p>
<span>
<svg
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>
</div>
<div class="flex justify-center items-baseline gap-1">
<p class="text-[36px] font-nats">0/49</p>
<p class="text-[12px]"></p>
</div>
</div>
<div
class="col-span-1 bg-white bg-opacity-70 text-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">其他人數</p>
<span>
<svg
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>
</div>
<div class="flex justify-center items-baseline gap-1">
<p class="text-[36px] font-nats">0/49</p>
<p class="text-[12px]"></p>
</div>
</div>
</div>
<!-- 高度比重5 -->
<div class="flex-[5] grid grid-cols-2 gap-2 text-gray-300">
<div
class="col-span-1 bg-white bg-opacity-70 rounded-md shadow px-4 py-3 flex flex-col justify-center items-center"
>
<p>暫無內容</p>
<!-- <h4 class="text-xl text-gray-600 font-bold">近三個月比較</h4>
<div ref="chartARef" class="w-[90%] h-[90%]"></div> -->
</div>
<div
class="col-span-1 bg-white bg-opacity-70 rounded-md shadow px-4 py-3 flex flex-col justify-center items-center"
>
<p>暫無內容</p>
<!-- <h4 class="text-xl text-gray-600 font-bold">近六個月比較</h4>
<div ref="chartBRef" class="w-[90%] h-[90%]"></div> -->
</div>
</div>
<!-- 高度比重5 -->
<div
class="bg-white bg-opacity-70 text-gray-300 rounded-md shadow px-4 py-3 flex flex-col justify-center items-center flex-[5]"
<!-- 高度比重10 -->
<!-- 未完成的知會事項與代辦事項 -->
<section
class="bg-white/70 rounded-md shadow p-6 flex flex-col min-h-0 gap-4 flex-[10]"
>
<p>暫無內容</p>
<!-- <h4 class="text-xl text-gray-600 font-bold">與去年同期比較</h4>
<div ref="chartCRef" class="w-[90%] h-[90%]"></div> -->
</div>
<h3 class="text-2xl font-bold">未完成的知會事項與代辦事項</h3>
<!-- 可捲動內容區水平 -->
<div class="flex flex-col gap-4 mb-6 flex-1 min-h-0">
<div class="w-full flex-1 min-h-0 overflow-x-auto overflow-y-auto">
<table class="table w-full whitespace-nowrap">
<thead
class="bg-brand-gray-lighter text-brand-black sticky top-0 z-[1]"
>
<tr>
<th class="px-3 py-2 text-left">住民</th>
<th class="px-3 py-2 text-left">類型</th>
<th class="px-3 py-2 text-left">表單</th>
<th class="px-3 py-2 text-left">日期</th>
<th class="px-3 py-2 text-left">逾期天數</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in pagedRows"
:key="row.id"
class="transition-colors duration-150 hover:bg-brand-gray-lighter focus-visible:bg-gray-100/70"
>
<td class="truncate">{{ row.resident }}</td>
<td>{{ row.type }}</td>
<td class="truncate">{{ row.form }}</td>
<td>{{ formatDate(row.date) }}</td>
<td class="text-brand-red">{{ row.overdueDays ?? 0 }} </td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分頁 -->
<div class="mt-3 flex items-center justify-between px-3">
<span class="text-sm text-gray-500">
{{ total }} 每頁 {{ pageSize }}
</span>
<div class="inline-flex items-center gap-2">
<button
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一頁
</button>
<span class="px-2 text-sm tabular-nums text-brand-purple-dark">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一頁
</button>
</div>
</div>
</section>
</section>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from "vue";
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import * as echarts from "echarts";
import { brand } from "@/styles/palette";
// import watch
// ---- DOM Refs ----
const chartARef = ref(null);
const chartBRef = ref(null);
const chartCRef = ref(null);
/* =========================
未完成的知會事項與代辦事項
- 10 /
- 分頁邏輯邊界保護
- 假資料欄位與表頭一致
========================= */
// ---- Chart instances ----
let chartA, chartB, chartC;
// 10
const pageSize = ref(10);
const currentPage = ref(1);
//
function genTrend(len, start = 50, drift = 0.6, noise = 20) {
// API
const todoRows = ref([
// id
{
id: 1,
resident: "王小明",
type: "醫囑簽章",
form: "用藥知會單",
date: "2025-08-26",
overdueDays: 3,
},
{
id: 2,
resident: "陳美麗",
type: "護理評估",
form: "入院評估表",
date: "2025-08-27",
overdueDays: 5,
},
{
id: 3,
resident: "林大志",
type: "家屬同意",
form: "手術同意書",
date: "2025-08-28",
overdueDays: 2,
},
{
id: 4,
resident: "張翠華",
type: "衛教回覆",
form: "跌倒防護衛教",
date: "2025-08-28",
overdueDays: 7,
},
{
id: 5,
resident: "黃國榮",
type: "復健紀錄",
form: "PT 日誌",
date: "2025-08-29",
overdueDays: 4,
},
{
id: 6,
resident: "李佩珊",
type: "家屬同意",
form: "外出同意書",
date: "2025-08-29",
overdueDays: 3,
},
{
id: 7,
resident: "吳大同",
type: "醫囑簽章",
form: "換藥醫囑",
date: "2025-08-30",
overdueDays: 2,
},
{
id: 8,
resident: "周怡君",
type: "護理評估",
form: "壓瘡評估表",
date: "2025-08-30",
overdueDays: 5,
},
{
id: 9,
resident: "曾文龍",
type: "家屬同意",
form: "轉院同意書",
date: "2025-08-31",
overdueDays: 1,
},
{
id: 10,
resident: "蔡淑芬",
type: "醫囑簽章",
form: "抽血醫囑",
date: "2025-08-31",
overdueDays: 3,
},
{
id: 11,
resident: "許建宏",
type: "衛教回覆",
form: "糖尿病衛教單",
date: "2025-09-01",
overdueDays: 6,
},
{
id: 12,
resident: "簡婉婷",
type: "復健紀錄",
form: "OT 日誌",
date: "2025-09-01",
overdueDays: 4,
},
{
id: 13,
resident: "王小明",
type: "醫囑簽章",
form: "檢驗醫囑",
date: "2025-09-01",
overdueDays: 2,
},
{
id: 14,
resident: "陳美麗",
type: "護理評估",
form: "疼痛評估表",
date: "2025-09-01",
overdueDays: 3,
},
{
id: 15,
resident: "林大志",
type: "家屬同意",
form: "管路同意書",
date: "2025-09-01",
overdueDays: 5,
},
{
id: 16,
resident: "張翠華",
type: "衛教回覆",
form: "跌倒防護衛教",
date: "2025-09-02",
overdueDays: 7,
},
{
id: 17,
resident: "黃國榮",
type: "復健紀錄",
form: "PT 日誌",
date: "2025-09-02",
overdueDays: 4,
},
{
id: 18,
resident: "李佩珊",
type: "家屬同意",
form: "手術同意書",
date: "2025-09-02",
overdueDays: 2,
},
{
id: 19,
resident: "吳大同",
type: "醫囑簽章",
form: "換藥醫囑",
date: "2025-09-02",
overdueDays: 3,
},
{
id: 20,
resident: "周怡君",
type: "護理評估",
form: "入院評估表",
date: "2025-09-02",
overdueDays: 5,
},
{
id: 21,
resident: "曾文龍",
type: "家屬同意",
form: "外出同意書",
date: "2025-09-02",
overdueDays: 1,
},
{
id: 22,
resident: "蔡淑芬",
type: "醫囑簽章",
form: "抽血醫囑",
date: "2025-09-02",
overdueDays: 3,
},
{
id: 23,
resident: "許建宏",
type: "衛教回覆",
form: "糖尿病衛教單",
date: "2025-09-02",
overdueDays: 6,
},
{
id: 24,
resident: "簡婉婷",
type: "復健紀錄",
form: "OT 日誌",
date: "2025-09-02",
overdueDays: 4,
},
]);
// /
const total = computed(() => todoRows.value.length);
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / pageSize.value))
);
//
const pagedRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
return todoRows.value.slice(start, start + pageSize.value);
});
// 1 currentPage
watch([totalPages, currentPage], () => {
if (currentPage.value > totalPages.value)
currentPage.value = totalPages.value;
if (currentPage.value < 1) currentPage.value = 1;
});
// YYYY-MM-DD
function formatDate(d) {
const dt = typeof d === "string" ? new Date(d) : d;
if (Number.isNaN(dt.getTime())) return String(d ?? "");
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, "0");
const day = String(dt.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/* ===== Modal 狀態 ===== */
const showModal = ref(false);
const modalType = ref(null);
const modalTitle = computed(() => {
if (modalType.value === "residents") return "住民資訊";
if (modalType.value === "inpatients") return "住院資訊";
return "";
});
function openModal(type) {
modalType.value = type;
showModal.value = true;
nextTick(() => {
const dialog = document.querySelector('[role="dialog"]');
if (dialog) dialog.focus();
});
}
function closeModal() {
showModal.value = false;
modalType.value = null;
}
/* ===== 工具產生不含「4」的床位號 ===== */
function generateBeds(count, start = 1001) {
const arr = [];
let v = start;
for (let i = 0; i < len; i++) {
v = v + drift + (Math.random() * noise * 2 - noise);
arr.push(Number(v.toFixed(1)));
let n = start;
while (arr.length < count) {
if (!String(n).includes("4")) arr.push(n);
n++;
}
return arr;
}
const commonGrid = {
left: 40,
right: 20,
top: 50,
bottom: 30,
containLabel: true,
};
const commonTooltip = { trigger: "axis", axisPointer: { type: "shadow" } };
/* ===== 假資料:住民 ===== */
const bedsResidents = generateBeds(12, 1001);
const residentsAll = [
{
bed: bedsResidents[0],
name: "王小明",
gender: "男",
age: 78,
condition: "穩定",
note: "喜歡下棋",
area: "A",
},
{
bed: bedsResidents[1],
name: "陳美麗",
gender: "女",
age: 82,
condition: "需輕度協助",
note: "糖尿病控制中",
area: "A",
},
{
bed: bedsResidents[2],
name: "林大志",
gender: "男",
age: 74,
condition: "穩定",
note: "行動慢",
area: "A",
},
{
bed: bedsResidents[3],
name: "張翠華",
gender: "女",
age: 80,
condition: "復健中",
note: "膝關節置換術後",
area: "A",
},
{
bed: bedsResidents[4],
name: "黃國榮",
gender: "男",
age: 85,
condition: "需中度協助",
note: "夜間易醒",
area: "A",
},
{
bed: bedsResidents[5],
name: "李佩珊",
gender: "女",
age: 76,
condition: "穩定",
note: "對花粉過敏",
area: "A",
},
{
bed: bedsResidents[6],
name: "吳大同",
gender: "男",
age: 79,
condition: "穩定",
note: "喜歡園藝",
area: "B",
},
{
bed: bedsResidents[7],
name: "周怡君",
gender: "女",
age: 81,
condition: "需輕度協助",
note: "高血壓",
area: "B",
},
{
bed: bedsResidents[8],
name: "曾文龍",
gender: "男",
age: 77,
condition: "復健中",
note: "髖關節手術後",
area: "B",
},
{
bed: bedsResidents[9],
name: "蔡淑芬",
gender: "女",
age: 83,
condition: "穩定",
note: "喜歡編織",
area: "B",
},
{
bed: bedsResidents[10],
name: "許建宏",
gender: "男",
age: 75,
condition: "需中度協助",
note: "睡眠品質不佳",
area: "B",
},
{
bed: bedsResidents[11],
name: "簡婉婷",
gender: "女",
age: 78,
condition: "穩定",
note: "對海鮮過敏",
area: "B",
},
];
// A
function buildOptionA() {
const x = Array.from({ length: 8 }, (_, i) => `${i + 1}`);
const legends = ["6月", "7月", "8月"];
const series = legends.map((label, idx) => ({
name: label,
type: "bar",
emphasis: { focus: "series" },
data: genTrend(x.length, 40 + idx * 2, 0.5 + idx * 0.1, 1.5),
}));
const residentsA = residentsAll.filter((r) => r.area === "A");
const residentsB = residentsAll.filter((r) => r.area === "B");
return {
legend: { top: 10 },
tooltip: commonTooltip,
grid: commonGrid,
xAxis: { type: "category", data: x },
yAxis: { type: "value" },
//
color: [brand.green, brand.greenLight, brand.yellow],
series,
};
/* ===== 假資料:住院 ===== */
const bedsInpatient = generateBeds(4, 1020);
const inpatients = [
{
bed: bedsInpatient[0],
name: "劉書豪",
hospitalDept: "台大醫院/心臟內科",
recordNo: "A1023001",
status: "加護中",
},
{
bed: bedsInpatient[1],
name: "高雅筑",
hospitalDept: "榮總/新陳代謝科",
recordNo: "B1023007",
status: "住院觀察",
},
{
bed: bedsInpatient[2],
name: "方志明",
hospitalDept: "長庚/骨科",
recordNo: "C1023011",
status: "術後恢復",
},
{
bed: bedsInpatient[3],
name: "鄭于庭",
hospitalDept: "國泰/神經內科",
recordNo: "D1023020",
status: "檢查中",
},
];
/* ESC 關閉 */
function onKeydown(e) {
if (e.key === "Escape") closeModal();
}
// B
function buildOptionB() {
const x = Array.from({ length: 8 }, (_, i) => `${i + 1}`);
const legends = ["3月", "4月", "5月", "6月", "7月", "8月"];
const series = legends.map((label, idx) => ({
name: label,
type: "bar",
emphasis: { focus: "series" },
data: genTrend(x.length, 35 + idx * 1.5, 0.4 + idx * 0.06, 1.6),
}));
return {
legend: { type: "scroll", top: 10 },
tooltip: commonTooltip,
grid: commonGrid,
xAxis: { type: "category", data: x },
yAxis: { type: "value" },
color: [
brand.green,
brand.greenLight,
brand.yellow,
brand.purpleLight,
brand.purple,
brand.gray,
],
series,
};
}
// C
function buildOptionC() {
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`);
const lastYear = genTrend(12, 48, 0.3, 1.8);
const thisYear = lastYear.map((v, i) =>
Number((v + 1 + Math.sin(i / 2)).toFixed(1))
);
return {
legend: { top: 10, data: ["去年", "今年"] },
tooltip: { trigger: "axis" },
grid: commonGrid,
xAxis: { type: "category", data: months, boundaryGap: false },
yAxis: { type: "value" },
color: [brand.green, brand.purple],
series: [
{ name: "去年", type: "line", smooth: true, data: lastYear },
{ name: "今年", type: "line", smooth: true, data: thisYear },
],
};
}
// & resize
function initCharts() {
if (chartARef.value && !chartA) {
chartA = echarts.init(chartARef.value);
chartA.setOption(buildOptionA());
}
if (chartBRef.value && !chartB) {
chartB = echarts.init(chartBRef.value);
chartB.setOption(buildOptionB());
}
if (chartCRef.value && !chartC) {
chartC = echarts.init(chartCRef.value);
chartC.setOption(buildOptionC());
}
handleResize();
}
let resizeObserver;
function handleResize() {
const resize = () => {
chartA && chartA.resize();
chartB && chartB.resize();
chartC && chartC.resize();
};
window.addEventListener("resize", resize);
const rootEl = document.querySelector("section.flex.flex-col");
if (rootEl) {
resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(rootEl);
}
}
function disposeCharts() {
resizeObserver && resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
chartA && chartA.dispose();
chartB && chartB.dispose();
chartC && chartC.dispose();
chartA = chartB = chartC = null;
}
onMounted(async () => {
await nextTick();
initCharts();
});
onUnmounted(() => {
disposeCharts();
});
onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
</script>

View File

@ -3,15 +3,15 @@
class="flex flex-col gap-2 h-[calc(100vh-72px-32px)] overflow-hidden"
>
<!-- 高度比重2 -->
<div class="flex-[2] grid grid-cols-4 gap-2">
<div class="flex-[2] grid grid-cols-3 gap-2">
<!-- 住民人數 Card -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openModal('residents')"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">住民人</p>
<span>
<p class="text-sm">現在住民立案床</p>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -33,12 +33,12 @@
<!-- 住院人數 Card -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openModal('inpatients')"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">住院人數</p>
<span>
<p class="text-sm">今日住院當月累積住院</p>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -61,7 +61,7 @@
<!-- ===== Modal放在同一個 <template> 建議貼在最底部===== -->
<div
v-if="showModal"
class="fixed inset-0 z-[100] flex items-center justify-center"
class="fixed inset-0 z-[90] flex items-center justify-center"
@keydown.esc="closeModal"
tabindex="0"
>
@ -95,7 +95,7 @@
<div class="overflow-x-auto">
<table class="min-w-full text-sm text-brand-black">
<thead>
<tr class="text-left bg-gray-50">
<tr class="text-left bg-brand-gray-lighter">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">性別</th>
@ -108,7 +108,7 @@
<tr
v-for="r in residentsA"
:key="r.bed"
class="border-b last:border-b-0 hover:bg-gray-50"
class="border-b last:border-b-0 hover:bg-brand-gray-lighter"
>
<td class="px-3 py-2 font-mono">{{ r.bed }}</td>
<td class="px-3 py-2">{{ r.name }}</td>
@ -127,7 +127,7 @@
<div class="overflow-x-auto">
<table class="min-w-full text-sm text-brand-black">
<thead>
<tr class="text-left bg-gray-50">
<tr class="text-left bg-brand-gray-lighter">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">性別</th>
@ -140,7 +140,7 @@
<tr
v-for="r in residentsB"
:key="r.bed"
class="border-b last:border-b-0 hover:bg-gray-50"
class="border-b last:border-b-0 hover:bg-brand-gray-lighter"
>
<td class="px-3 py-2 font-mono">{{ r.bed }}</td>
<td class="px-3 py-2">{{ r.name }}</td>
@ -157,7 +157,6 @@
<!-- body住院資訊 -->
<div v-else-if="modalType === 'inpatients'">
<h4 class="text-lg font-semibold text-gray-700 mb-3">清單</h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm text-brand-black">
<thead>
@ -189,7 +188,7 @@
<!-- footer -->
<div class="mt-6 text-right">
<button
class="btn bg-brand-green text-white px-4 py-2 rounded-md "
class="btn bg-brand-green text-white border-none px-4 py-2 rounded-md"
@click="closeModal"
>
關閉
@ -199,34 +198,10 @@
</div>
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">其他人數</p>
<span>
<svg
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>
</div>
<div class="flex justify-center items-baseline gap-1">
<p class="text-[36px] xl:text-[40px] font-nats">0/49</p>
<p class="text-[12px]"></p>
</div>
</div>
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">其他人數</p>
<p class="text-sm">今日離院當月累積離院</p>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -250,26 +225,26 @@
<!-- 高度比重5 -->
<div class="flex-[5] grid grid-cols-2 gap-2">
<!-- 佔床率 -->
<div
class="col-span-1 bg-white bg-opacity-70 rounded-md shadow p-5 flex flex-col justify-center items-start"
class="col-span-1 bg-white bg-opacity-70 rounded-md shadow p-2 flex justify-center items-center"
>
<h4 class="text-xl text-gray-600 font-bold">近三個月比較</h4>
<div ref="chartARef" class="w-full h-full"></div>
<div ref="chartARef" class="w-full h-[90%] min-h-[200px]"></div>
</div>
<!-- 住院率 -->
<div
class="col-span-1 bg-white bg-opacity-70 rounded-md shadow p-5 flex flex-col justify-center items-start"
class="col-span-1 bg-white bg-opacity-70 rounded-md shadow p-2 flex justify-center items-center"
>
<h4 class="text-xl text-gray-600 font-bold">近六個月比較</h4>
<div ref="chartBRef" class="w-full h-full"></div>
<div ref="chartBRef" class="w-full h-[90%] min-h-[200px]"></div>
</div>
</div>
<!-- 高度比重5 -->
<div
class="bg-white bg-opacity-70 rounded-md shadow p-5 flex flex-col justify-center items-start flex-[5]"
class="bg-white bg-opacity-70 rounded-md shadow p-2 flex flex-col justify-center items-start flex-[5]"
>
<h4 class="text-xl text-gray-600 font-bold">與去年同期比較</h4>
<div ref="chartCRef" class="w-full h-full"></div>
<!-- 每日佔床率比較 -->
<div ref="chartCRef" class="w-full h-[90%] min-h-[200px]"></div>
</div>
</section>
</template>
@ -298,6 +273,89 @@ function genTrend(len, start = 50, drift = 0.6, noise = 20) {
return arr;
}
// 0~100base variation
function genRates(n, base = 82, variation = 10) {
const clamp = (v) => Math.max(0, Math.min(100, v));
const arr = [];
for (let i = 0; i < n; i++) {
const noise = (Math.random() * 2 - 1) * variation;
arr.push(Number(clamp(base + noise).toFixed(1)));
}
return arr;
}
// N ["4","5","6","7","8","9"]
function getLastNMonthLabels(n = 6) {
const now = new Date();
const labels = [];
for (let i = n - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
labels.push(`${d.getMonth() + 1}`);
}
return labels;
}
//
function quantile(sorted, q) {
const pos = (sorted.length - 1) * q;
const base = Math.floor(pos);
const rest = pos - base;
return sorted[base + 1] !== undefined
? sorted[base] + rest * (sorted[base + 1] - sorted[base])
: sorted[base];
}
// => [min, Q1, median, Q3, max] [[xIndex, value], ...]
function toBoxplot(groups) {
const boxData = [];
const outliers = [];
groups.forEach((arr, idx) => {
const sorted = [...arr].sort((a, b) => a - b);
const q1 = quantile(sorted, 0.25);
const med = quantile(sorted, 0.5);
const q3 = quantile(sorted, 0.75);
const iqr = q3 - q1;
const lowerFence = q1 - 1.5 * iqr;
const upperFence = q3 + 1.5 * iqr;
const inside = sorted.filter((v) => v >= lowerFence && v <= upperFence);
const min = inside.length ? inside[0] : q1; //
const max = inside.length ? inside[inside.length - 1] : q3;
//
sorted.forEach((v) => {
if (v < lowerFence || v > upperFence) outliers.push([idx, v]);
});
boxData.push([
+min.toFixed(1),
+q1.toFixed(1),
+med.toFixed(1),
+q3.toFixed(1),
+max.toFixed(1),
]);
});
return { boxData, outliers };
}
// BoxPlot
function makeBoxGroups(
groupLabels,
pointsPerGroup = 30,
base = 82,
step = -1.5
) {
const raw = groupLabels.map((_, i) =>
genRates(pointsPerGroup, base + i * step, 12)
);
const means = raw.map((arr) =>
Number((arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(1))
);
return { raw, means };
}
const commonGrid = {
left: 40,
right: 20,
@ -307,87 +365,241 @@ const commonGrid = {
};
const commonTooltip = { trigger: "axis", axisPointer: { type: "shadow" } };
// A
function mkTitle(text, subtext = "", opts = {}) {
const useInline = !!subtext;
if (useInline) {
return {
text: `{main|${text}} {sub|${subtext}}`,
left: 8,
top: 2,
textStyle: {
rich: {
main: {
color: brand.black,
fontSize: 24,
fontWeight: 600,
fontFamily: '"Noto Sans TC"',
},
sub: {
color: brand.gray,
fontSize: 14,
fontWeight: 400,
fontFamily: '"Noto Sans TC"',
align: "left",
},
},
},
subtext: "",
...opts,
};
}
return {
text,
subtext: "",
textStyle: {
color: brand.black,
fontSize: 24,
fontWeight: 600,
fontFamily: '"Noto Sans TC"',
},
...opts,
};
}
const baseGrid = {
top: 48,
right: 16,
bottom: 24,
left: 24,
containLabel: true,
};
// +
function buildOptionA() {
const x = Array.from({ length: 8 }, (_, i) => `op${i + 1}`);
const legends = ["6月", "7月", "8月"];
const series = legends.map((label, idx) => ({
name: label,
type: "bar",
emphasis: { focus: "series" },
data: genTrend(x.length, 40 + idx * 2, 0.5 + idx * 0.1, 1.5),
barWidth: 5,
}));
return {
legend: { top: 10 },
tooltip: commonTooltip,
grid: commonGrid,
xAxis: { type: "category", data: x },
yAxis: { type: "value" },
//
color: [brand.green, brand.greenLight, brand.yellow],
series,
};
}
// B
function buildOptionB() {
const x = Array.from({ length: 8 }, (_, i) => `op${i + 1}`);
const legends = ["3月", "4月", "5月", "6月", "7月", "8月"];
const series = legends.map((label, idx) => ({
name: label,
type: "bar",
emphasis: { focus: "series" },
data: genTrend(x.length, 35 + idx * 1.5, 0.4 + idx * 0.06, 1.6),
barWidth: 5,
}));
return {
legend: { type: "scroll", top: 10 },
tooltip: commonTooltip,
grid: commonGrid,
xAxis: { type: "category", data: x },
yAxis: { type: "value" },
color: [
brand.green,
brand.greenLight,
brand.yellow,
brand.purpleLight,
brand.purple,
brand.purpleDark,
],
series,
};
}
// C
function buildOptionC() {
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`);
const lastYear = genTrend(12, 36, 0.8, 2);
const thisYear = lastYear.map(
(v, i) => Number((v + 5 + Math.sin(i / 2) * 3).toFixed(1)) //
const labels = getLastNMonthLabels(6); //
// 30 80~92% 0~100
const raw = labels.map((_, i) =>
genRates(30, 88 - i * 1.5, 10).map((v) => Math.max(0, Math.min(100, v)))
);
const { boxData, outliers } = toBoxplot(raw);
const BOX = brand.purpleDark;
return {
legend: { top: 10, data: ["去年", "今年"] },
tooltip: { trigger: "axis" },
grid: commonGrid,
xAxis: { type: "category", data: months, boundaryGap: false },
yAxis: { type: "value" },
color: [brand.purple, brand.green],
tooltip: {
trigger: "item",
formatter: (p) => {
if (p.seriesType === "boxplot") {
const [min, med, max] = p.data;
return [
`<b>${labels[p.dataIndex]} 佔床率</b>`,
`最高:${max}%`,
`中位數:${med}%`,
`最低:${min}%`,
// `<span style="opacity:.6"> 1.5×IQR </span>`,
].join("<br/>");
}
// if (p.seriesType === "scatter") {
// return `<b>${labels[p.data[0]]}</b><br/>${p.data[1]}%`;
// }
return "";
},
},
title: mkTitle("佔床率", "(近 6 個月)"),
grid: { ...commonGrid, top: 60 },
xAxis: { type: "category", data: labels },
yAxis: {
type: "value",
name: "比例",
nameLocation: "middle", // y 90
nameGap: 45,
min: 0,
max: 100,
axisLabel: { formatter: "{value}%" },
axisLine: { show: true },
axisTick: { show: true },
splitArea: {
show: true,
areaStyle: { color: [brand.white, brand.grayLighter] },
}, //
},
series: [
{
name: "去年",
type: "line",
smooth: true,
data: lastYear,
name: "箱型圖",
type: "boxplot",
data: boxData,
itemStyle: { color: brand.greenLight, borderColor: BOX },
lineStyle: { color: BOX },
},
{ name: "今年", type: "line", smooth: true, data: thisYear },
],
};
}
// +
function buildOptionB() {
const labels = getLastNMonthLabels(6); //
// 30 6~14% 0~40
const raw = labels.map((_, i) =>
genRates(30, 8 + i * 1.2, 6).map((v) => Math.max(0, Math.min(40, v)))
);
const { boxData, outliers } = toBoxplot(raw);
const BOX = brand.purpleDark;
return {
tooltip: {
trigger: "item",
formatter: (p) => {
if (p.seriesType === "boxplot") {
const [min, med, max] = p.data;
return [
`<b>${labels[p.dataIndex]} 住院率</b>`,
`最高:${max}%`,
`中位數:${med}%`,
`最低:${min}%`,
// `<span style="opacity:.6"> 1.5×IQR </span>`,
].join("<br/>");
}
// if (p.seriesType === "scatter") {
// return `<b>${labels[p.data[0]]}</b><br/>${p.data[1]}%`;
// }
return "";
},
},
title: mkTitle("住院率", "(近 6 個月)"),
grid: { ...commonGrid, top: 60 },
xAxis: { type: "category", data: labels },
yAxis: {
type: "value",
name: "比例",
nameLocation: "middle", // y 90
nameGap: 45,
min: 0,
max: 40,
axisLabel: { formatter: "{value}%" },
axisLine: { show: true },
axisTick: { show: true },
splitArea: {
show: true,
areaStyle: { color: [brand.white, brand.grayLighter] },
},
},
series: [
{
name: "箱型圖",
type: "boxplot",
data: boxData,
itemStyle: { color: brand.greenLight, borderColor: BOX },
lineStyle: { color: BOX },
},
],
};
}
// 30 × 3
function buildOptionC() {
const days = Array.from({ length: 30 }, (_, i) => `${i + 1}`);
const thisMonth = genRates(30, 86, 6).map((v, i) =>
Number((v + Math.sin(i / 4) * 2).toFixed(1))
);
const lastMonth = genRates(30, 83, 7).map((v, i) =>
Number((v + Math.cos(i / 5) * 2).toFixed(1))
);
const lastYearSameMonth = genRates(30, 80, 8).map((v, i) =>
Number((v + Math.sin(i / 3) * 1.5).toFixed(1))
);
// legend grid
const LEGEND_TOP = 28; // 24 title
const GRID_TOP = 64;
return {
title: {
...mkTitle("每日佔床率比較", "(近 30 天)"),
top: 6,
},
legend: {
top: LEGEND_TOP,
data: ["機構 A", "本機構", "機構 B"],
},
tooltip: { trigger: "axis", valueFormatter: (v) => `${v}%` },
grid: {
...commonGrid,
top: GRID_TOP,
containLabel: true,
},
xAxis: { type: "category", data: days, boundaryGap: false },
yAxis: {
type: "value",
name: "比例",
nameLocation: "middle",
nameGap: 45,
min: 0,
max: 100,
axisLabel: { formatter: "{value}%" },
axisLine: { show: true },
axisTick: { show: true },
splitArea: {
show: true,
areaStyle: { color: [brand.white, brand.grayLighter] },
},
},
color: [brand.green, brand.purple, brand.yellow],
series: [
{ name: "機構 A", type: "line", data: thisMonth },
{ name: "本機構", type: "line", data: lastMonth },
{ name: "機構 B", type: "line", data: lastYearSameMonth },
],
};
}
// & resize
function initCharts() {
if (chartARef.value && !chartA) {

View File

@ -13,5 +13,7 @@ export const brand = {
black: "#424242",
gray: "#828282",
grayLight: "#E9E9E9",
grayLighter: "#F6F6F6",
grayDark: "#D2D2D2",
white: "#ffffff"
};

View File

@ -15,7 +15,7 @@ module.exports = {
"2xl": "2.5rem",
},
screens: {
// 讓 container 在各斷點對齊自訂的寬度
// 讓 container 在各斷點對齊自訂的寬度
sm: "640px",
md: "768px",
lg: "1024px",
@ -33,14 +33,14 @@ module.exports = {
},
extend: {
fontFamily: {
// 讓你能寫 class="font-noto"
// class="font-noto"
noto: ["Noto Sans TC", "sans-serif"],
// 讓你能寫 class="font-nats"
// class="font-nats"
nats: ["NATS-Regular", "sans-serif"],
},
colors: {
brand: {
green: { DEFAULT: "#34D5C8", light: "#C4FBE5", dark:"#0CA99C" },
green: { DEFAULT: "#34D5C8", light: "#C4FBE5", dark: "#0CA99C" },
red: "#FF8678",
purple: {
DEFAULT: "#A5BEFF",
@ -54,6 +54,7 @@ module.exports = {
black: "#424242",
gray: {
DEFAULT: "#828282",
lighter: "#EEEEEE",
light: "#E9E9E9",
dark: "#D2D2D2",
},