fix: 修改首頁葷素區,變成可 click 出現 modal 的卡片
This commit is contained in:
parent
3b87ee1c45
commit
4fee3a6e3a
@ -14,7 +14,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>諾亞克 U-ARK 戰情中心</title>
|
||||
</head>
|
||||
<body class="w-screen bg-brand-gray-light font-noto text-brand-black">
|
||||
<body class="w-screen bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter font-noto text-brand-black">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
10
src/App.vue
10
src/App.vue
@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
|
||||
<component :is="layoutComponent" />
|
||||
<section>
|
||||
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
|
||||
<component :is="layoutComponent" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -9,7 +11,9 @@ import { useRoute } from "vue-router";
|
||||
|
||||
// 懶載入各 Layout
|
||||
const layouts = {
|
||||
headquarter: defineAsyncComponent(() => import("@/layouts/HeadquarterLayout.vue")),
|
||||
headquarter: defineAsyncComponent(() =>
|
||||
import("@/layouts/HeadquarterLayout.vue")
|
||||
),
|
||||
map: defineAsyncComponent(() => import("@/layouts/ForgeLayout.vue")),
|
||||
};
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,893 +0,0 @@
|
||||
<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";
|
||||
import useForgeSprite from "@/hooks/forge/useForgeSprite";
|
||||
|
||||
// ====== 基本變數 / 注入 ======
|
||||
const route = useRoute();
|
||||
const forgeDom = ref(null);
|
||||
let viewer = null;
|
||||
const viewerReady = ref(false);
|
||||
const activeFloor = ref("9F"); // 預設 9F
|
||||
|
||||
let unbindSpriteClick = null;
|
||||
let THREE = null;
|
||||
|
||||
const {
|
||||
updateDataVisualization: dvInit,
|
||||
createSprites,
|
||||
forgeClickListener,
|
||||
cardfitToView,
|
||||
clear: clearSprites,
|
||||
__staticDevices, // 用來塞模擬資料
|
||||
} = useForgeSprite();
|
||||
|
||||
// ====== 常數(樓層 / 顏色 / 模型路徑) ======
|
||||
const FLOOR_DBIDS = { "9F": 14973, "8F": 14262 };
|
||||
const BRAND_GREEN = "#34D5C8";
|
||||
const BRAND_RED = "#FF8678";
|
||||
const MODEL_SVF_PATH = `/upload/forge/0.svf`;
|
||||
|
||||
// ====== THREE 入口(避免版本衝突) ======
|
||||
function getThree() {
|
||||
if (THREE) return THREE;
|
||||
THREE =
|
||||
(globalThis.Autodesk &&
|
||||
globalThis.Autodesk.Viewing &&
|
||||
(globalThis.Autodesk.Viewing.THREE ||
|
||||
globalThis.Autodesk.Viewing.Private?.THREE)) ||
|
||||
globalThis.THREE ||
|
||||
null;
|
||||
if (!THREE)
|
||||
throw new Error("[Forge] THREE 尚未就緒(Viewer 還沒載好或版本差異)");
|
||||
return THREE;
|
||||
}
|
||||
|
||||
// ====== Resident Modal 狀態 ======
|
||||
const modalOpen = ref(false);
|
||||
const modalData = ref(null);
|
||||
|
||||
function buildResidentModalData(d) {
|
||||
const vacant = d?.state === "offnormal" && !d?.special; // 空床
|
||||
return {
|
||||
name: vacant ? "-" : d?.residentsName ?? "-",
|
||||
sex: vacant ? "-" : d?.residentsSex ?? "-",
|
||||
age: vacant ? "-" : d?.residentsAge ?? "-",
|
||||
startTime: vacant ? "-" : d?.startTime ?? "-",
|
||||
healthStatus: vacant ? "-" : d?.healthStatus ?? "一般",
|
||||
medicationStatus: vacant ? "-" : d?.medicationStatus ?? "規律服藥",
|
||||
specialEvent: d?.special
|
||||
? d?.specialEvent ?? "住院中"
|
||||
: d?.specialEvent ?? "-",
|
||||
note: vacant ? "-" : d?.note ?? "-",
|
||||
};
|
||||
}
|
||||
|
||||
function openResidentModal(L, e) {
|
||||
e?.stopPropagation?.();
|
||||
bringToFrontById(L.id);
|
||||
modalData.value = buildResidentModalData(L.data);
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeResidentModal() {
|
||||
modalOpen.value = false;
|
||||
}
|
||||
|
||||
// ====== 初始化樓層還原 ======
|
||||
function resolveInitialFloor() {
|
||||
const q = (route.query?.floor || "").toString().toUpperCase();
|
||||
if (q === "8F" || q === "9F") return q;
|
||||
const saved = localStorage.getItem("uark-floor");
|
||||
if (saved === "8F" || saved === "9F") return saved;
|
||||
return "9F";
|
||||
}
|
||||
|
||||
// ====== Forge Viewer 初始化 / 載入 ======
|
||||
function initViewer(container) {
|
||||
return new Promise((resolve) => {
|
||||
Autodesk.Viewing.Initializer({ env: "Local", language: "en" }, () => {
|
||||
const config = {
|
||||
extensions: ["Autodesk.DataVisualization", "Autodesk.DocumentBrowser"],
|
||||
};
|
||||
viewer = new Autodesk.Viewing.GuiViewer3D(container, config);
|
||||
Autodesk.Viewing.Private.InitParametersSetting.alpha = true;
|
||||
viewer.start();
|
||||
getThree();
|
||||
viewer.setGroundShadow(true);
|
||||
viewer.impl.renderer().setClearAlpha(0);
|
||||
viewer.impl.glrenderer().setClearColor(0xffffff, 0);
|
||||
viewer.impl.invalidate(true);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadModel(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
viewer.loadModel(filePath, {}, (model) => resolve(model), reject);
|
||||
});
|
||||
}
|
||||
|
||||
function hideViewCubeAndHome() {
|
||||
const tryHide = () => {
|
||||
const ext = viewer?.getExtension?.("Autodesk.ViewCubeUi");
|
||||
if (ext?.setVisible) ext.setVisible(false);
|
||||
};
|
||||
tryHide();
|
||||
viewer.addEventListener(Autodesk.Viewing.EXTENSION_LOADED_EVENT, (e) => {
|
||||
if (e.extensionId === "Autodesk.ViewCubeUi") tryHide();
|
||||
});
|
||||
}
|
||||
|
||||
function waitObjectTree(model) {
|
||||
return new Promise((resolve) => {
|
||||
if (model.getData().instanceTree) return resolve();
|
||||
const onTree = () => {
|
||||
viewer.removeEventListener(
|
||||
Autodesk.Viewing.OBJECT_TREE_CREATED_EVENT,
|
||||
onTree
|
||||
);
|
||||
resolve();
|
||||
};
|
||||
viewer.addEventListener(Autodesk.Viewing.OBJECT_TREE_CREATED_EVENT, onTree);
|
||||
});
|
||||
}
|
||||
|
||||
// ====== 幾何工具 ======
|
||||
function getNodeWorldBounds(model, dbId) {
|
||||
const THREE = getThree();
|
||||
const tree = model.getInstanceTree?.();
|
||||
const fragList = model.getFragmentList?.();
|
||||
const box = new THREE.Box3();
|
||||
if (!tree || !fragList) return box;
|
||||
tree.enumNodeFragments(
|
||||
dbId,
|
||||
(fragId) => {
|
||||
const fb = new THREE.Box3();
|
||||
fragList.getWorldBounds(fragId, fb);
|
||||
box.union(fb);
|
||||
},
|
||||
true
|
||||
);
|
||||
return box;
|
||||
}
|
||||
|
||||
function positionsFromRatios(box, ratios, zLiftRatio = 0.05) {
|
||||
const THREE = getThree();
|
||||
const { min, max } = box;
|
||||
const w = max.x - min.x,
|
||||
h = max.y - min.y,
|
||||
zH = max.z - min.z;
|
||||
const z = max.z + Math.max(0.2, zH * zLiftRatio); // 稍微浮起
|
||||
return ratios.map(
|
||||
([rx, ry]) => new THREE.Vector3(min.x + rx * w, min.y + ry * h, z)
|
||||
);
|
||||
}
|
||||
|
||||
// 每層的「不工整」固定佈局(相對比例)
|
||||
const LAYOUT_RATIOS = {
|
||||
"9F": [
|
||||
[0.18, 0.26],
|
||||
[0.5, 0.24],
|
||||
[0.78, 0.29],
|
||||
[0.16, 0.48],
|
||||
[0.23, 0.95],
|
||||
[0.86, 0.53],
|
||||
],
|
||||
"8F": [
|
||||
[0.89, 0.68],
|
||||
[0.85, 0.23],
|
||||
],
|
||||
};
|
||||
|
||||
// ====== 床號序列(避開數字 4) ======
|
||||
function hasDigit4(n) {
|
||||
return n.toString().includes("4");
|
||||
}
|
||||
function generateBedNumbers(count, start = 1001) {
|
||||
const out = [];
|
||||
let n = start;
|
||||
while (out.length < count) {
|
||||
if (!hasDigit4(n)) out.push(n);
|
||||
n++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const BED_SERIES_ALL = generateBedNumbers(8, 1001);
|
||||
const BED_SERIES_BY_FLOOR = {
|
||||
"9F": BED_SERIES_ALL.slice(0, 6),
|
||||
"8F": BED_SERIES_ALL.slice(6, 8),
|
||||
};
|
||||
|
||||
// ====== 假資料 / 權重工具 ======
|
||||
function seededRand(seed) {
|
||||
const s = Math.sin(seed * 9301 + 49297) * 233280;
|
||||
return s - Math.floor(s);
|
||||
}
|
||||
function pick(arr, seed) {
|
||||
return arr[Math.floor(seededRand(seed) * arr.length)];
|
||||
}
|
||||
function ageFromSeed(seed) {
|
||||
return 65 + Math.floor(seededRand(seed + 7) * 31);
|
||||
} // 65~95
|
||||
function startDateFromSeed(seed) {
|
||||
const now = new Date();
|
||||
const daysAgo = 30 + Math.floor(seededRand(seed + 13) * 365); // 1~12 個月內
|
||||
const d = new Date(now.getTime() - daysAgo * 86400000);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}/${mm}/${dd}`;
|
||||
}
|
||||
|
||||
const NAME_POOL = [
|
||||
"王雅雯",
|
||||
"陳志明",
|
||||
"林冠廷",
|
||||
"張淑芬",
|
||||
"黃子涵",
|
||||
"李建宏",
|
||||
"吳佩珊",
|
||||
"周育賢",
|
||||
"趙雅筑",
|
||||
"許庭瑋",
|
||||
"郭怡君",
|
||||
"洪嘉文",
|
||||
];
|
||||
const HEALTH_POOL = ["一般", "良好", "需觀察", "虛弱"];
|
||||
const MEDICATION_POOL = ["規律服藥", "偶爾忘記", "暫停服藥", "需家屬協助"];
|
||||
const SPECIAL_POOL = ["-"];
|
||||
const NOTE_POOL = ["-", "對花生過敏", "家屬每週三探視", "夜間需加護理巡房"];
|
||||
const SEX_POOL = ["男", "女"];
|
||||
|
||||
function pickAreaLetter(seed) {
|
||||
const r = seededRand(seed);
|
||||
if (r < 0.4) return "A";
|
||||
return "B";
|
||||
}
|
||||
|
||||
// ====== 樓層顯示 / Sprites 建立 ======
|
||||
async function showLevel(levelKey) {
|
||||
const levelDbId = FLOOR_DBIDS[levelKey];
|
||||
if (!viewer || !viewer.model || !levelDbId) return;
|
||||
const ids = (() => {
|
||||
const tree = viewer.model.getInstanceTree();
|
||||
const leafs = [];
|
||||
const walk = (id) => {
|
||||
const childCount = tree.getChildCount(id);
|
||||
if (childCount === 0) leafs.push(id);
|
||||
else tree.enumNodeChildren(id, walk);
|
||||
};
|
||||
walk(levelDbId);
|
||||
return leafs.length ? leafs : [levelDbId];
|
||||
})();
|
||||
viewer.isolate(ids);
|
||||
viewer.fitToView(ids);
|
||||
viewer.setGhosting(false);
|
||||
viewer.impl.invalidate(true);
|
||||
}
|
||||
|
||||
async function rebuildSpritesForFloor(floorKey) {
|
||||
const box = getNodeWorldBounds(viewer.model, FLOOR_DBIDS[floorKey]);
|
||||
const is9F = floorKey === "9F";
|
||||
|
||||
const ratios = LAYOUT_RATIOS[floorKey] || [];
|
||||
const poses = positionsFromRatios(box, ratios);
|
||||
|
||||
const bedNos =
|
||||
BED_SERIES_BY_FLOOR[floorKey] || generateBedNumbers(poses.length, 1001);
|
||||
|
||||
__staticDevices.length = 0;
|
||||
const GREEN = BRAND_GREEN,
|
||||
RED = BRAND_RED;
|
||||
|
||||
poses.forEach((p, i) => {
|
||||
const bedNo = bedNos[i] ?? 1001 + i;
|
||||
const spriteIdBase = is9F ? 90000 : 91000;
|
||||
const spriteDbId = spriteIdBase + i + 1;
|
||||
|
||||
const area = pickAreaLetter(spriteDbId);
|
||||
const isRed = is9F ? i >= poses.length - 2 : false;
|
||||
|
||||
__staticDevices.push({
|
||||
name: `${area} 區 ${bedNo}`,
|
||||
forge_dbid: FLOOR_DBIDS[floorKey],
|
||||
spriteDbId,
|
||||
device_coordinate_3d: { x: p.x, y: p.y, z: p.z },
|
||||
device_normal_color: BRAND_GREEN,
|
||||
device_close_color: BRAND_RED,
|
||||
state: isRed ? "offnormal" : "normal",
|
||||
special: isRed && i === poses.length - 2, // 只挑第一個紅色 sprite 加入黃色標示
|
||||
residentsName: pick(NAME_POOL, spriteDbId),
|
||||
residentsSex: pick(SEX_POOL, spriteDbId + 1),
|
||||
residentsAge: ageFromSeed(spriteDbId + 2),
|
||||
startTime: startDateFromSeed(spriteDbId + 3),
|
||||
|
||||
avatarUrl: Math.random() > 0.4 ? pick(spriteDbId + 4) : null,
|
||||
healthStatus: pick(HEALTH_POOL, spriteDbId + 5),
|
||||
medicationStatus: pick(MEDICATION_POOL, spriteDbId + 6),
|
||||
specialEvent:
|
||||
isRed && i === poses.length - 2
|
||||
? "住院中"
|
||||
: pick(SPECIAL_POOL, spriteDbId + 7),
|
||||
note: pick(NOTE_POOL, spriteDbId + 8),
|
||||
});
|
||||
});
|
||||
|
||||
await createSprites(); // 清舊加新
|
||||
}
|
||||
|
||||
// ====== Popover(常駐在每顆 sprite 上方) ======
|
||||
const labels = ref([]); // [{ id, x, y, data }]
|
||||
let camEvtCleanup = null;
|
||||
|
||||
const activeLabelId = ref(null);
|
||||
function bringToFrontById(id) {
|
||||
activeLabelId.value = id;
|
||||
}
|
||||
|
||||
function worldToScreen(pos3) {
|
||||
const p = viewer.worldToClient(pos3); // 相對於 viewer 容器的座標
|
||||
return { x: p.x, y: p.y };
|
||||
}
|
||||
|
||||
function rebuildLabelsForCurrentFloor() {
|
||||
labels.value = __staticDevices.map((d) => {
|
||||
const { x, y } = worldToScreen(
|
||||
new (getThree().Vector3)(
|
||||
d.device_coordinate_3d.x,
|
||||
d.device_coordinate_3d.y,
|
||||
d.device_coordinate_3d.z
|
||||
)
|
||||
);
|
||||
return {
|
||||
id: d.spriteDbId,
|
||||
x,
|
||||
y,
|
||||
data: d,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateLabelPositions() {
|
||||
if (!labels.value.length) return;
|
||||
labels.value = labels.value.map((L) => {
|
||||
const { x, y } = worldToScreen(
|
||||
new (getThree().Vector3)(
|
||||
L.data.device_coordinate_3d.x,
|
||||
L.data.device_coordinate_3d.y,
|
||||
L.data.device_coordinate_3d.z
|
||||
)
|
||||
);
|
||||
return { ...L, x, y };
|
||||
});
|
||||
}
|
||||
|
||||
function attachCameraEventsForLabels() {
|
||||
camEvtCleanup?.(); // 先清掉舊的
|
||||
const onUpdate = () => requestAnimationFrame(updateLabelPositions);
|
||||
viewer.addEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, onUpdate);
|
||||
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, onUpdate);
|
||||
viewer.addEventListener(Autodesk.Viewing.VIEWER_RESIZE_EVENT, onUpdate);
|
||||
camEvtCleanup = () => {
|
||||
viewer.removeEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, onUpdate);
|
||||
viewer.removeEventListener(
|
||||
Autodesk.Viewing.RENDER_PRESENTED_EVENT,
|
||||
onUpdate
|
||||
);
|
||||
viewer.removeEventListener(Autodesk.Viewing.VIEWER_RESIZE_EVENT, onUpdate);
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildLabelsAfterNextRender() {
|
||||
const once = () => {
|
||||
rebuildLabelsForCurrentFloor();
|
||||
updateLabelPositions();
|
||||
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
||||
};
|
||||
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
||||
}
|
||||
|
||||
// ====== 分區切換(多選|預設不選=顯示全部) ======
|
||||
const zones = ["一般", "氧氣"];
|
||||
const selectedZones = ref(new Set());
|
||||
const toggleZone = (zone) => {
|
||||
const set = new Set(selectedZones.value);
|
||||
if (set.has(zone)) set.delete(zone);
|
||||
else set.add(zone);
|
||||
selectedZones.value = set;
|
||||
};
|
||||
const isZoneSelected = (zone) => selectedZones.value.has(zone);
|
||||
|
||||
// ====== 床位資訊選單 / 顯示控制 ======
|
||||
const soloSpriteId = ref(null); // 單獨顯示:被點擊 sprite 的 id(null 表全域/多顯示模式)
|
||||
const infoOptions = [
|
||||
{ label: "無顯示", value: "none" },
|
||||
{ label: "有住民", value: "occupied" },
|
||||
{ label: "空床", value: "vacant" },
|
||||
{ label: "住院中", value: "hospitalized" },
|
||||
{ label: "請假中", value: "leave" }, // ← 新增
|
||||
];
|
||||
const infoOpen = ref(false);
|
||||
const selectedInfo = ref(infoOptions[0].value); // 'none'
|
||||
|
||||
const filteredLabels = computed(() => {
|
||||
// 單獨顯示模式
|
||||
if (soloSpriteId.value != null) {
|
||||
if (selectedInfo.value === "none" || selectedInfo.value === "leave")
|
||||
return [];
|
||||
return labels.value.filter((L) => L.id === soloSpriteId.value);
|
||||
}
|
||||
|
||||
// none / leave = 全隱
|
||||
if (selectedInfo.value === "none" || selectedInfo.value === "leave") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const info = selectedInfo.value; // 'occupied' | 'vacant' | 'hospitalized'
|
||||
const selectedSet = selectedZones.value; // Set
|
||||
|
||||
return labels.value.filter((L) => {
|
||||
// ===== 分區多選判斷 =====
|
||||
const inZone =
|
||||
selectedSet.size === 0
|
||||
? true
|
||||
: L.data?.zone
|
||||
? selectedSet.has(L.data.zone)
|
||||
: true;
|
||||
|
||||
// ===== 燈號狀態判斷 =====
|
||||
const isRed = L.data.state === "offnormal";
|
||||
const isHosp = isRed && !!L.data.special; // 住院中:紅 + special
|
||||
const isGreen = !isRed && !L.data.special; // 有住民:綠
|
||||
|
||||
let stateOk = true;
|
||||
if (info === "occupied") stateOk = isGreen;
|
||||
if (info === "vacant") stateOk = isRed; // 空床 = 只要是紅色,都包含(含住院)
|
||||
if (info === "hospitalized") stateOk = isHosp;
|
||||
|
||||
return inZone && stateOk;
|
||||
});
|
||||
});
|
||||
|
||||
const isVacantWithoutSpecial = (L) =>
|
||||
L?.data?.state === "offnormal" && !L?.data?.special;
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
const selectInfo = (opt) => {
|
||||
// 切換全域顯示 → 關閉單獨顯示
|
||||
soloSpriteId.value = null;
|
||||
|
||||
selectedInfo.value = opt.value;
|
||||
infoOpen.value = false;
|
||||
emit("change", opt.value);
|
||||
};
|
||||
|
||||
const infoTriggerRef = ref(null);
|
||||
const infoPanelRef = ref(null);
|
||||
|
||||
const onClickOutsideInfo = (e) => {
|
||||
const t = e.target;
|
||||
if (!infoTriggerRef.value || !infoPanelRef.value) return;
|
||||
const insideTrigger = infoTriggerRef.value.contains(t);
|
||||
const insidePanel = infoPanelRef.value.contains(t);
|
||||
if (!insideTrigger && !insidePanel) infoOpen.value = false;
|
||||
};
|
||||
|
||||
const onKeydownInfo = (e) => {
|
||||
if (e.key === "Escape") infoOpen.value = false;
|
||||
};
|
||||
|
||||
// ====== 套用樓層 / 切換 ======
|
||||
async function applyFloor(next) {
|
||||
// 進新樓層前清空單獨顯示
|
||||
soloSpriteId.value = null;
|
||||
|
||||
activeFloor.value = next;
|
||||
localStorage.setItem("uark-floor", next);
|
||||
await showLevel(next);
|
||||
await rebuildSpritesForFloor(next); // 只畫當前樓層的 sprites
|
||||
viewer.impl.invalidate(true); // 強制要求一個新幀
|
||||
rebuildLabelsAfterNextRender(); // 等下一次渲染完成再對齊 labels
|
||||
}
|
||||
|
||||
async function onClickFloor(next) {
|
||||
if (next === activeFloor.value) return;
|
||||
if (!viewerReady.value) return;
|
||||
await applyFloor(next);
|
||||
}
|
||||
|
||||
// ====== 生命週期 ======
|
||||
onMounted(async () => {
|
||||
activeFloor.value = resolveInitialFloor();
|
||||
|
||||
await initViewer(forgeDom.value);
|
||||
const model = await loadModel(MODEL_SVF_PATH);
|
||||
await waitObjectTree(model);
|
||||
hideViewCubeAndHome();
|
||||
|
||||
// 讓 DataVisualization 綁定這個 viewer
|
||||
await dvInit(viewer);
|
||||
|
||||
// 綁定 sprite 點擊
|
||||
unbindSpriteClick = forgeClickListener(async ({ data }) => {
|
||||
// 進入「單獨顯示」模式,只顯示被點到那顆 popover
|
||||
soloSpriteId.value =
|
||||
soloSpriteId.value === data.spriteDbId ? null : data.spriteDbId;
|
||||
bringToFrontById(data.spriteDbId);
|
||||
|
||||
await cardfitToView({
|
||||
forge_dbid: data.forge_dbid,
|
||||
spriteDbId: data.spriteDbId,
|
||||
back: 30,
|
||||
});
|
||||
});
|
||||
attachCameraEventsForLabels();
|
||||
|
||||
await applyFloor("9F");
|
||||
viewerReady.value = true; // 放在 applyFloor 之後較穩
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", onClickOutsideInfo);
|
||||
document.addEventListener("keydown", onKeydownInfo);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query?.floor,
|
||||
async (v) => {
|
||||
if (!viewerReady.value) return;
|
||||
const q = (v || "").toString().toUpperCase();
|
||||
if (q === "8F" || q === "9F") {
|
||||
await applyFloor(q);
|
||||
}
|
||||
attachCameraEventsForLabels();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
try {
|
||||
unbindSpriteClick?.();
|
||||
camEvtCleanup?.();
|
||||
} catch {}
|
||||
try {
|
||||
clearSprites();
|
||||
} catch {}
|
||||
|
||||
if (viewer) {
|
||||
try {
|
||||
const ext = viewer.getExtension?.("Autodesk.DataVisualization");
|
||||
ext?.removeAllViewables?.();
|
||||
viewer.unloadExtension?.("Autodesk.DataVisualization");
|
||||
} catch {}
|
||||
viewer.tearDown?.();
|
||||
viewer.finish?.();
|
||||
viewer = null;
|
||||
}
|
||||
|
||||
document.removeEventListener("click", onClickOutsideInfo);
|
||||
document.removeEventListener("keydown", onKeydownInfo);
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.adsk-viewing-viewer {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
#guiviewer3d-toolbar {
|
||||
display: none;
|
||||
}
|
||||
.adsk-viewcube {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
@ -1,17 +1,16 @@
|
||||
<template>
|
||||
<section id="app" class="flex flex-col min-h-screen">
|
||||
<NavBar class="fixed" />
|
||||
|
||||
<main
|
||||
class="w-full p-4 pt-[80px] from-brand-green-light/20 via-white/40 to-white/10"
|
||||
class="w-full p-4 pt-[80px]"
|
||||
>
|
||||
<section class="grid grid-cols-7 gap-4 overflow-hidden">
|
||||
<!-- 左側:地圖 -->
|
||||
<section
|
||||
class="col-span-3 bg-gradient-to-br rounded-md h-full overflow-hidden"
|
||||
class="col-span-3 rounded-md h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center text-gray-500"
|
||||
class="w-full h-full flex items-center justify-center text-brand-gray"
|
||||
>
|
||||
<Forge />
|
||||
</div>
|
||||
|
@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<section
|
||||
id="app"
|
||||
class="flex flex-col min-h-screen bg-gradient-to-r from-brand-gray-lighter/50 via-brand-purple-light/20 to-white/50"
|
||||
>
|
||||
<section id="app" class="flex flex-col min-h-screen">
|
||||
<NavBar />
|
||||
<main class="w-full p-4 pt-[80px] overflow-x-hidden">
|
||||
<RouterView class="h-[calc(100vh-72px-40px)]" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-0 w-full h-14 lg:h-16 bg-white/50 backdrop-blur-sm shadow-md grid grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6 lg:px-8 z-[80]"
|
||||
class="fixed top-0 w-full h-14 lg:h-16 bg-white/20 backdrop-blur-sm border-b-2 grid grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6 lg:px-8 z-[80]"
|
||||
>
|
||||
<!-- 左側:Logo + 機構切換 -->
|
||||
<div
|
||||
@ -13,7 +13,7 @@
|
||||
<div class="relative" v-if="!isHome">
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="btn bg-brand-green text-white hover:opacity-90 shadow-md border-none rounded-full px-6 lg:px-8 h-10 gap-2"
|
||||
class="btn bg-brand-green text-white hover:opacity-90 shadow border-none rounded-full px-6 lg:px-8 h-10 gap-2"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
@ -40,7 +40,7 @@
|
||||
<div
|
||||
v-show="isFacilityOpen"
|
||||
ref="panelRef"
|
||||
class="absolute top-12 left-0 z-50 w-56 md:w-64 rounded-md border border-gray-100 bg-white shadow-lg p-2"
|
||||
class="absolute top-12 left-0 z-50 w-56 md:w-64 rounded-md border border-brand-gray-light bg-white shadow-lg p-2"
|
||||
@click.stop
|
||||
>
|
||||
<ul class="max-h-48 overflow-y-auto text-brand-black">
|
||||
@ -48,7 +48,7 @@
|
||||
v-for="item in facilities"
|
||||
:key="item"
|
||||
@click="selectItem(item)"
|
||||
class="px-3 py-2 rounded-md cursor-pointer hover:bg-gray-100 tracking-wider"
|
||||
class="px-3 py-2 rounded-md cursor-pointer hover:bg-brand-gray-light tracking-wider"
|
||||
:class="selectedItem === item ? 'bg-brand-green-light' : ''"
|
||||
>
|
||||
{{ item }}
|
||||
@ -60,7 +60,7 @@
|
||||
|
||||
<!-- 中間:導覽(平板以上顯示;置中) -->
|
||||
<div
|
||||
class="col-span-1 justify-self-center hidden lg:flex items-center rounded-full min-w-[300px] w-[300px] bg-white/60 shadow-md"
|
||||
class="col-span-1 justify-self-center hidden lg:flex items-center rounded-full min-w-[300px] w-[300px] bg-white/60 shadow"
|
||||
>
|
||||
<nav class="w-full grid grid-cols-3 divide-x divide-transparent">
|
||||
<RouterLink to="/" v-slot="{ href, navigate, isExactActive }">
|
||||
@ -106,7 +106,7 @@
|
||||
class="col-span-1 justify-self-end 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"
|
||||
class="btn bg-white text-brand-black hover:opacity-90 shadow border-none rounded-full p-2"
|
||||
aria-label="通知"
|
||||
>
|
||||
<!-- 鈴鐺 -->
|
||||
@ -124,7 +124,7 @@
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="btn bg-white text-brand-black hover:opacity-90 shadow-md border-none rounded-full px-3 md:px-5 h-9 md:h-10 gap-2 md:gap-3"
|
||||
class="btn bg-white text-brand-black hover:opacity-90 shadow border-none rounded-full px-3 md:px-5 h-9 md:h-10 gap-2 md:gap-3"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -158,7 +158,7 @@
|
||||
|
||||
<!-- 手機:漢堡按鈕-->
|
||||
<button
|
||||
class="lg:hidden btn bg-white text-brand-black hover:opacity-90 shadow-md border-none rounded-md p-2 justify-self-end"
|
||||
class="lg:hidden btn bg-white text-brand-black hover:opacity-90 shadow border-none rounded-md p-2 justify-self-end"
|
||||
@click="toggleMobileMenu"
|
||||
:aria-expanded="isMobileMenuOpen"
|
||||
aria-controls="mobile-menu"
|
||||
@ -354,7 +354,7 @@
|
||||
<!-- 平板直式 modal:在 640px~1023px 顯示,位置在漢堡鈕下方 -->
|
||||
<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"
|
||||
class="hidden sm:block lg:hidden absolute right-4 top-16 z-[1100] w-[88vw] sm:w-[240px] bg-white rounded-xl shadow-md border border-gray-100 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="主選單(平板)"
|
||||
|
129
src/pages/home/components/DietSummary.vue
Normal file
129
src/pages/home/components/DietSummary.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<section class="grid grid-cols-2 gap-2">
|
||||
<!-- 卡片:葷/素 -->
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="col-span-1 bg-white/30 rounded-md shadow p-4 cursor-pointer active:opacity-80 group"
|
||||
>
|
||||
<div
|
||||
class="relative border border-brand-yellow group-hover:border-brand-green-light rounded-md w-full h-full flex flex-col justify-center items-center gap-1 transition-colors"
|
||||
>
|
||||
<!-- 中間圓形徽章 + ICON -->
|
||||
<div
|
||||
class="relative text-brand-yellow group-hover:text-brand-green-light transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="90"
|
||||
height="90"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13 2.05v3.03c3.39.49 6 3.39 6 6.92c0 .9-.18 1.75-.5 2.54l2.62 1.53c.56-1.24.88-2.62.88-4.07c0-5.18-3.95-9.45-9-9.95M12 19a7 7 0 0 1-7-7c0-3.53 2.61-6.43 6-6.92V2.05c-5.06.5-9 4.76-9 9.95a10 10 0 0 0 10 10c3.3 0 6.23-1.61 8.05-4.09l-2.6-1.53A6.89 6.89 0 0 1 12 19"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="w-9 h-9 flex justify-center items-center p-2 bg-brand-yellow group-hover:bg-brand-green-light text-brand-black absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 數據 -->
|
||||
<div class="flex flex-col justify-center items-center gap-4">
|
||||
<p class="text-lg text-center">總數:{{ item.data.total }}</p>
|
||||
<div class="px-3">
|
||||
<table
|
||||
class="table-fixed border border-brand-gray-light border-collapse mx-auto w-auto"
|
||||
>
|
||||
<thead
|
||||
class="bg-brand-yellow group-hover:bg-brand-green-light text-brand-black border-b border-brand-gray/30 transition-colors"
|
||||
>
|
||||
<tr class="text-center">
|
||||
<th
|
||||
class="text-sm p-2 text-center border-r border-brand-gray/30"
|
||||
>
|
||||
一般
|
||||
</th>
|
||||
<th
|
||||
class="text-sm p-2 text-center border-r border-brand-gray/30"
|
||||
>
|
||||
碎食
|
||||
</th>
|
||||
<th class="text-sm p-2 text-center">管灌</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="text-center">
|
||||
<td
|
||||
class="px-3 py-1 border-r border-t border-brand-gray-light w-[80px] text-center"
|
||||
>
|
||||
<span
|
||||
class="font-nats text-xl text-brand-black block text-center"
|
||||
>
|
||||
{{ item.data.normal }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-3 py-1 border-r border-t border-brand-gray-light w-[80px] text-center"
|
||||
>
|
||||
<span
|
||||
class="font-nats text-xl text-brand-black block text-center"
|
||||
>
|
||||
{{ item.data.soft }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-3 py-1 border-t border-brand-gray-light w-[80px] text-center"
|
||||
>
|
||||
<span
|
||||
class="font-nats text-xl text-brand-black block text-center"
|
||||
>
|
||||
{{ item.data.tube }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
class="text-brand-gray/50 absolute top-5 right-5"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
// 結構:{ meat: { total, normal, soft, tube }, veg: { ... } }
|
||||
diet: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
meat: { total: 0, normal: 0, soft: 0, tube: 0 },
|
||||
veg: { total: 0, normal: 0, soft: 0, tube: 0 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const items = computed(() => [
|
||||
{ key: "meat", label: "葷", data: props.diet?.meat ?? {} },
|
||||
{ key: "veg", label: "素", data: props.diet?.veg ?? {} },
|
||||
]);
|
||||
</script>
|
82
src/pages/home/components/FacilityHeaderInfo.vue
Normal file
82
src/pages/home/components/FacilityHeaderInfo.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- 頂部資訊條:機構名稱 + 返回 -->
|
||||
<div
|
||||
class="absolute top-0 left-0 z-30 flex justify-between items-center w-full px-2 py-1 bg-brand-gray-lighter/80"
|
||||
>
|
||||
<div
|
||||
class="w-full h-[24px] flex justify-start items-center gap-3 text-brand-black"
|
||||
>
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 24">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13 3a2 2 0 0 1 1.995 1.85L15 5v14h1V9.5a.5.5 0 0 1 .41-.492L16.5 9H18a2 2 0 0 1 1.995 1.85L20 11v8h1a1 1 0 0 1 .117 1.993L21 21H3a1 1 0 0 1-.117-1.993L3 19h1V5a2 2 0 0 1 1.85-1.995L6 3zm-2 12H8v2h3zm0-4H8v2h3zm0-4H8v2h3z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-sm tracking-wider">{{ facilityDisplayName }}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="selectedFacility !== 'ALL'"
|
||||
class="inline-flex items-center justify-center rounded px-2 py-1 hover:bg-white/80 hover:text-brand-black cursor-pointer select-none"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="emitBack"
|
||||
@keyup.enter="emitBack"
|
||||
@keyup.space.prevent="emitBack"
|
||||
aria-label="返回總部"
|
||||
title="返回"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 48 48">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M44 40.836q-7.34-8.96-13.036-10.168t-10.846-.365V41L4 23.545L20.118 7v10.167q9.523.075 16.192 6.833q6.668 6.758 7.69 16.836Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 照片 + 說明(手機隱藏、平板/桌機顯示) -->
|
||||
<div class="w-full grid grid-rows-12 justify-start items-start gap-4 hidden sm:grid">
|
||||
<div class="h-[230px] row-span-7">
|
||||
<img
|
||||
:src="photo"
|
||||
alt="機構照片"
|
||||
class="w-full h-full rounded-sm object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="row-span-5">
|
||||
<p class="text-brand-gray bg-white/30 text-sm border rounded-sm px-2 py-3 border-brand-gray-light">
|
||||
{{ desc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
facilityDisplayName: { type: String, required: true },
|
||||
selectedFacility: { type: String, required: true }, // 控制返回鈕顯示
|
||||
photo: { type: String, required: true },
|
||||
desc: { type: String, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["back"]);
|
||||
|
||||
function emitBack() {
|
||||
emit("back");
|
||||
}
|
||||
</script>
|
485
src/pages/home/components/MapPane.vue
Normal file
485
src/pages/home/components/MapPane.vue
Normal file
@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<section
|
||||
class="relative bg-white/30 rounded-md shadow p-3 min-h-0 lg:h-full flex flex-col overscroll-contain"
|
||||
>
|
||||
<!-- 顯示資訊切換條(置頂置中) -->
|
||||
<div class="sticky top-6 z-[50] h-0">
|
||||
<div class="relative pointer-events-none flex justify-center w-full">
|
||||
<div
|
||||
class="pointer-events-auto inline-flex items-center text-sm text-brand-gray-dark bg-white bg-opacity-90 border border-gray-300 rounded-md shadow ps-3"
|
||||
role="group"
|
||||
aria-label="顯示資訊切換"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<p class="py-2 pr-2">顯示資訊|</p>
|
||||
|
||||
<!-- 不顯示 -->
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md hover:bg-brand-gray-lighter',
|
||||
props.infoMode === 'none' &&
|
||||
'bg-brand-purple-dark text-white hover:bg-brand-purple-dark hover:text-white',
|
||||
]"
|
||||
@click="setInfoMode('none')"
|
||||
:aria-pressed="props.infoMode === 'none'"
|
||||
>
|
||||
<span class="inline-block">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 15 15"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3.64 2.27L7.5 6.13l3.84-3.84A.92.92 0 0 1 12 2a1 1 0 0 1 1 1a.9.9 0 0 1-.27.66L8.84 7.5l3.89 3.89A.9.9 0 0 1 13 12a1 1 0 0 1-1 1a.92.92 0 0 1-.69-.27L7.5 8.87l-3.85 3.85A.92.92 0 0 1 3 13a1 1 0 0 1-1-1a.9.9 0 0 1 .27-.66L6.16 7.5L2.27 3.61A.9.9 0 0 1 2 3a1 1 0 0 1 1-1c.24.003.47.1.64.27"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>不顯示</span>
|
||||
</button>
|
||||
|
||||
<!-- 入住情況 -->
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md hover:bg-brand-gray-lighter',
|
||||
props.infoMode === 'residents' &&
|
||||
'bg-brand-purple-dark text-white hover:bg-brand-purple-dark hover:text-white',
|
||||
]"
|
||||
@click="setInfoMode('residents')"
|
||||
:aria-pressed="props.infoMode === 'residents'"
|
||||
>
|
||||
<span class="inline-block">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3 14s-1 0-1-1s1-4 6-4s6 3 6 4s-1 1-1 1zm5-6a3 3 0 1 0 0-6a3 3 0 0 0 0 6"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>入住情況</span>
|
||||
</button>
|
||||
|
||||
<!-- 飲食情況 -->
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md hover:bg-brand-gray-lighter',
|
||||
props.infoMode === 'diet' &&
|
||||
'bg-brand-purple-dark text-white hover:bg-brand-purple-dark hover:text-white',
|
||||
]"
|
||||
@click="setInfoMode('diet')"
|
||||
:aria-pressed="props.infoMode === 'diet'"
|
||||
>
|
||||
<span class="inline-block">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.5 2.75a.75.75 0 0 0-1.5 0v4.015c-2.026-1.358-4.867-.881-6.293 1.215L2.353 18.786c-.556.818-.45 1.91.255 2.608a2.1 2.1 0 0 0 2.667.23l10.79-7.469A4.454 4.454 0 0 0 17.24 8h4.009a.75.75 0 0 0 0-1.5h-2.69l3.22-3.22a.75.75 0 0 0-1.06-1.06L17.5 5.439z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>飲食情況</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 地圖本體 -->
|
||||
<div
|
||||
ref="mapEl"
|
||||
class="relative z-[0] w-full flex-1 rounded-md overflow-hidden min-h-[300px] md:min-h-[360px] lg:min-h-[420px] overscroll-contain touch-none select-none"
|
||||
></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
/** Props / Emits **/
|
||||
const props = defineProps({
|
||||
// v-model:infoMode
|
||||
infoMode: { type: String, default: "none" }, // 'none' | 'residents' | 'diet'
|
||||
// 所有機構的資料,用於 tooltip 內容
|
||||
dataset: { type: Object, required: true },
|
||||
// 標記座標,可外部覆蓋
|
||||
locations: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{
|
||||
name: "A 機構",
|
||||
addr: "高雄市楠梓區立仁街131、133號",
|
||||
lat: 22.7409648895,
|
||||
lng: 120.3354644775,
|
||||
},
|
||||
{
|
||||
name: "B 機構",
|
||||
addr: "高雄市楠梓區常德路317巷9弄27號",
|
||||
lat: 22.7366924286,
|
||||
lng: 120.3363342285,
|
||||
},
|
||||
{
|
||||
name: "C 機構",
|
||||
addr: "高雄市楠梓區宏昌街135、137號",
|
||||
lat: 22.717588,
|
||||
lng: 120.29406,
|
||||
},
|
||||
{
|
||||
name: "D 機構",
|
||||
addr: "高雄市左營區民族一路980號",
|
||||
lat: 22.678054,
|
||||
lng: 120.3192,
|
||||
},
|
||||
{
|
||||
name: "E 機構",
|
||||
addr: "高雄市三民區黃興路336號",
|
||||
lat: 22.651488,
|
||||
lng: 120.33731,
|
||||
},
|
||||
{
|
||||
name: "F 機構",
|
||||
addr: "高雄市小港區沿海一路377號",
|
||||
lat: 22.5644512177,
|
||||
lng: 120.3544540405,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["update:infoMode", "select-facility"]);
|
||||
|
||||
function setInfoMode(mode) {
|
||||
emit("update:infoMode", mode);
|
||||
}
|
||||
|
||||
/** Leaflet base icons (scoped,不改全域) **/
|
||||
const base = import.meta.env.BASE_URL ?? "/";
|
||||
const iconBase = `${base}img/leaflet/`;
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: `${iconBase}marker-icon.png`,
|
||||
iconRetinaUrl: `${iconBase}marker-icon-2x.png`,
|
||||
shadowUrl: `${iconBase}marker-shadow.png`,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
|
||||
/** Map state **/
|
||||
const mapEl = ref(null);
|
||||
let map = null;
|
||||
let markers = [];
|
||||
|
||||
/** Tooltip HTML helpers **/
|
||||
function getResidentsHtml(name) {
|
||||
const f = props.dataset[name];
|
||||
const p = f?.progress || {};
|
||||
const residents = p.residents
|
||||
? [p.residents.current, p.residents.total]
|
||||
: [0, 0];
|
||||
const vacancy = p.vacancy ? [p.vacancy.current, p.vacancy.total] : [0, 0];
|
||||
const hosp = p.hospitalized
|
||||
? [p.hospitalized.current, p.hospitalized.total]
|
||||
: [0, 0];
|
||||
return `
|
||||
<div class="tip p-2" style="cursor:pointer; touch-action:none; overscroll-behavior:contain" data-facility="${name}">
|
||||
<div class="flex flex-col gap-2">
|
||||
<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]">${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="inline-block rounded-md border border-brand-gray/30 overflow-hidden">
|
||||
<table class="table-fixed min-w-[180px] border-collapse">
|
||||
<thead class="bg-brand-purple-light text-brand-black">
|
||||
<tr class="text-center">
|
||||
<th class="text-[14px] p-2 text-center border-r border-brand-gray/30 w-[80px]">住民</th>
|
||||
<th class="text-[14px] p-2 text-center border-r border-brand-gray/30 w-[80px]">空床</th>
|
||||
<th class="text-[14px] p-2 text-center w-[80px]">住院</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="text-center">
|
||||
<td class="px-3 py-1 border-r border-brand-gray/30 w-[80px]"><span class="font-nats text-xl text-brand-purple-dark">${residents[0]} / ${residents[1]}</span></td>
|
||||
<td class="px-3 py-1 border-r border-brand-gray/30 w-[80px]"><span class="font-nats text-xl text-brand-purple-dark">${vacancy[0]} / ${vacancy[1]}</span></td>
|
||||
<td class="px-3 py-1 w-[80px]"><span class="font-nats text-xl text-brand-purple-dark">${hosp[0]} / ${hosp[1]}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function getDietHtml(name) {
|
||||
const d = props.dataset[name]?.diet || {
|
||||
meat: { total: 0, normal: 0, soft: 0, tube: 0 },
|
||||
veg: { total: 0, normal: 0, soft: 0, tube: 0 },
|
||||
};
|
||||
return `
|
||||
<div class="tip p-2" style="cursor:pointer; touch-action:none; overscroll-behavior:contain" data-facility="${name}">
|
||||
<div class="flex flex-col gap-2">
|
||||
<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]">${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="inline-block rounded-md border border-brand-gray/30 overflow-hidden">
|
||||
<table class="table-fixed w-[240px] min-w-[200px] border-collapse">
|
||||
<thead class="bg-brand-purple-light text-brand-black">
|
||||
<tr class="text-center">
|
||||
<th class="p-2 text-center border-r border-brand-gray/30 w-[60px] text-[14px]">類別</th>
|
||||
<th class="p-2 text-center border-r border-brand-gray/30 w-[60px] text-[14px]">總數</th>
|
||||
<th class="p-2 text-center border-r border-brand-gray/30 w-[60px] text-[14px]">一般</th>
|
||||
<th class="p-2 text-center border-r border-brand-gray/30 w-[60px] text-[14px]">碎食</th>
|
||||
<th class="p-2 text-center w-[60px] text-[14px]">管灌</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="text-center border-b border-brand-gray/30">
|
||||
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="text-[14px] font-noto text-brand-dark">葷</span></td>
|
||||
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.meat.total}</span></td>
|
||||
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.meat.normal}</span></td>
|
||||
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.meat.soft}</span></td>
|
||||
<td class="px-2 py-1 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.meat.tube}</span></td>
|
||||
</tr>
|
||||
<tr class="text-center">
|
||||
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="text-[14px] font-noto text-brand-dark">素</span></td>
|
||||
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.veg.total}</span></td>
|
||||
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.veg.normal}</span></td>
|
||||
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.veg.soft}</span></td>
|
||||
<td class="px-2 py-1 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.veg.tube}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function getTooltipHtml(mode, name) {
|
||||
if (mode === "residents") return getResidentsHtml(name);
|
||||
if (mode === "diet") return getDietHtml(name);
|
||||
return "";
|
||||
}
|
||||
|
||||
/** helpers **/
|
||||
function refreshAllTooltips() {
|
||||
if (!markers?.length) return;
|
||||
for (const m of markers) m.getTooltip?.()?.update?.();
|
||||
}
|
||||
|
||||
function syncTooltips() {
|
||||
if (!Array.isArray(markers)) return;
|
||||
const iconH =
|
||||
(defaultIcon.options.iconSize && defaultIcon.options.iconSize[1]) || 41;
|
||||
const offset = [0, -(iconH + 8)];
|
||||
|
||||
for (const m of markers) {
|
||||
const name = m.__name || m.options?.title || "";
|
||||
|
||||
if (props.infoMode === "none") {
|
||||
m.unbindTooltip?.();
|
||||
continue;
|
||||
}
|
||||
|
||||
const html = getTooltipHtml(props.infoMode, name);
|
||||
const cur = m.getTooltip?.();
|
||||
const needsRebind = !cur || cur.getContent?.() !== html;
|
||||
|
||||
if (needsRebind) {
|
||||
m.unbindTooltip?.();
|
||||
m.bindTooltip(html, {
|
||||
permanent: true,
|
||||
direction: "top",
|
||||
offset,
|
||||
opacity: 0.95,
|
||||
interactive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const t = m.getTooltip?.();
|
||||
if (t && typeof t.getElement === "function") {
|
||||
const el = t.getElement();
|
||||
if (el && !el.__bound) {
|
||||
el.__bound = true;
|
||||
el.style.cursor = "pointer";
|
||||
el.setAttribute("tabindex", "-1");
|
||||
|
||||
const block = (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
[
|
||||
"wheel",
|
||||
"mousewheel",
|
||||
"DOMMouseScroll",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
"mousedown",
|
||||
"mousemove",
|
||||
].forEach((type) =>
|
||||
el.addEventListener(type, block, { passive: false })
|
||||
);
|
||||
|
||||
el.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
block(e);
|
||||
const facility = el.getAttribute("data-facility") || name;
|
||||
emit("select-facility", facility);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
el.addEventListener("dblclick", block, { passive: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (map?.invalidateSize) {
|
||||
requestAnimationFrame(() => {
|
||||
map.invalidateSize();
|
||||
refreshAllTooltips();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Lifecycle **/
|
||||
onMounted(() => {
|
||||
if (!mapEl.value) return;
|
||||
|
||||
// 地圖邊界(高雄)
|
||||
const BOUNDS_LOOSE = L.latLngBounds([22.35, 120.0], [23.05, 120.75]);
|
||||
|
||||
map = L.map(mapEl.value, {
|
||||
center: [22.6273, 120.3014],
|
||||
zoom: 13,
|
||||
minZoom: 12,
|
||||
maxZoom: 20,
|
||||
zoomSnap: 0.25,
|
||||
zoomDelta: 0.25,
|
||||
maxBounds: BOUNDS_LOOSE,
|
||||
maxBoundsViscosity: 0.8,
|
||||
preferCanvas: true,
|
||||
zoomControl: false,
|
||||
keyboard: false, // 防止點擊時把地圖容器 focus 進而觸發視窗捲動
|
||||
});
|
||||
|
||||
const container = map.getContainer();
|
||||
|
||||
container.removeAttribute("tabindex"); // 讓它成為可聚焦元素
|
||||
container.addEventListener("mousedown", () => container.blur(), {
|
||||
passive: true,
|
||||
}); // 若不小心被聚焦,立刻 blur
|
||||
// 顯式阻止預設行為(一定要 passive:false)
|
||||
const block = (e) => e.preventDefault();
|
||||
["wheel", "mousewheel", "DOMMouseScroll", "touchmove"].forEach((ev) => {
|
||||
container.addEventListener(ev, block, { passive: false });
|
||||
});
|
||||
|
||||
["wheel", "mousewheel", "DOMMouseScroll", "touchmove"].forEach((ev) => {
|
||||
container.addEventListener(ev, block, { passive: false });
|
||||
});
|
||||
|
||||
L.DomEvent.disableScrollPropagation(container); // 滾輪不往視窗冒泡
|
||||
L.DomEvent.disableClickPropagation(container); // 點擊/拖曳不往上冒泡
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 20,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
const group = L.featureGroup();
|
||||
markers = [];
|
||||
props.locations.forEach((p) => {
|
||||
const marker = L.marker([p.lat, p.lng], {
|
||||
title: p.name,
|
||||
icon: defaultIcon,
|
||||
interactive: false, // 不吃滑鼠事件 → 不會出現 pointer
|
||||
});
|
||||
marker.__name = p.name;
|
||||
marker.addTo(group);
|
||||
markers.push(marker);
|
||||
});
|
||||
group.addTo(map);
|
||||
|
||||
const bounds = group.getBounds().pad(0.06);
|
||||
map.fitBounds(bounds, {
|
||||
paddingTopLeft: [24, 140],
|
||||
paddingBottomRight: [24, 24],
|
||||
maxZoom: 15,
|
||||
});
|
||||
|
||||
// 初次依當前模式綁定 tooltip
|
||||
syncTooltips();
|
||||
|
||||
// 初始後多次 invalidateSize,避免白屏
|
||||
requestAnimationFrame(() => {
|
||||
const m = map;
|
||||
m?.invalidateSize?.();
|
||||
requestAnimationFrame(() => {
|
||||
m?.invalidateSize?.();
|
||||
refreshAllTooltips();
|
||||
});
|
||||
});
|
||||
|
||||
// 互動後更新 tooltip 位置
|
||||
map.on("zoomend", refreshAllTooltips);
|
||||
map.on("moveend", refreshAllTooltips);
|
||||
map.on("resize", refreshAllTooltips);
|
||||
|
||||
// 視窗尺寸改變
|
||||
const onResize = () => {
|
||||
map?.invalidateSize?.();
|
||||
refreshAllTooltips();
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
map.__onResize = onResize;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (map?.__onResize) window.removeEventListener("resize", map.__onResize);
|
||||
if (map) {
|
||||
map.off("zoomend", refreshAllTooltips);
|
||||
map.off("moveend", refreshAllTooltips);
|
||||
map.off("resize", refreshAllTooltips);
|
||||
map.remove();
|
||||
map = null;
|
||||
}
|
||||
});
|
||||
|
||||
/** Re-bind when mode changes **/
|
||||
watch(
|
||||
() => props.infoMode,
|
||||
async () => {
|
||||
await nextTick();
|
||||
requestAnimationFrame(() => {
|
||||
syncTooltips();
|
||||
requestAnimationFrame(() => {
|
||||
map?.invalidateSize?.();
|
||||
refreshAllTooltips();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
</script>
|
@ -30,7 +30,7 @@
|
||||
></progress>
|
||||
|
||||
<span
|
||||
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 left-2"
|
||||
class="w-full absolute bottom-0 flex justify-between items-center text-[20px] font-nats pe-5 text-brand-gray group-hover:text-brand-purple-dark left-2"
|
||||
>
|
||||
{{ animatedValueLocale }} / {{ totalLocale }}
|
||||
<span aria-hidden="true">
|
69
src/pages/home/components/ProgressGroup.vue
Normal file
69
src/pages/home/components/ProgressGroup.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col gap-5">
|
||||
<!-- 逐條渲染 ProgressBar -->
|
||||
<ProgressBar
|
||||
v-for="it in items"
|
||||
:key="`${it.key}-${selectedFacilityKey}`"
|
||||
:label="it.label"
|
||||
:current="it.current"
|
||||
:total="it.total"
|
||||
:chartKey="it.key"
|
||||
:currentLegend="it.currentLegend"
|
||||
:totalLegend="it.totalLegend"
|
||||
@select="onSelect"
|
||||
data-animate
|
||||
data-animate-from="0"
|
||||
data-animate-duration="700"
|
||||
class="[&::-webkit-progress-value]:transition-all [&::-webkit-progress-value]:duration-700 [&::-moz-progress-bar]:transition-all [&::-moz-progress-bar]:duration-700"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
import ProgressBar from "./ProgressBar.vue";
|
||||
|
||||
/**
|
||||
* items 期待的結構:
|
||||
* [
|
||||
* {
|
||||
* key: 'residents' | 'vacancy' | 'hospitalized' | 'movein' | 'moveout',
|
||||
* label: string,
|
||||
* current: number,
|
||||
* total: number,
|
||||
* currentLegend: string,
|
||||
* totalLegend: string
|
||||
* },
|
||||
* ...
|
||||
* ]
|
||||
*/
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
// 簡單驗證:每個項目至少要有 key/label/current/total
|
||||
validator(arr) {
|
||||
return Array.isArray(arr) && arr.every(
|
||||
(x) =>
|
||||
x &&
|
||||
typeof x.key === "string" &&
|
||||
typeof x.label === "string" &&
|
||||
typeof x.current === "number" &&
|
||||
typeof x.total === "number"
|
||||
);
|
||||
},
|
||||
},
|
||||
// 用來在切換機構時重置動畫與 key(保持你原本的效果)
|
||||
selectedFacilityKey: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
|
||||
function onSelect(payload) {
|
||||
// payload 由 ProgressBar 傳上來,通常是 { key }
|
||||
emit("select", payload);
|
||||
}
|
||||
</script>
|
162
src/pages/home/components/TrendChart.vue
Normal file
162
src/pages/home/components/TrendChart.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<!-- 整個「下半:圖表」卡片 -->
|
||||
<section class="row-span-4 bg-white/30 backdrop-blur-sm 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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
import { brand } from "@/styles/palette";
|
||||
|
||||
const props = defineProps({
|
||||
activeKey: { type: String, default: "residents" },
|
||||
// 結構同 currentFacility.charts:
|
||||
// { [key]: { legends: [string, string], a: number[], b: number[] }, ... }
|
||||
charts: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const chartEl = ref(null);
|
||||
let chart = null;
|
||||
|
||||
// 工具:近 7 天標籤
|
||||
function last7DaysLabels() {
|
||||
const labels = [];
|
||||
const now = new Date();
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() - i);
|
||||
labels.push(`${d.getMonth() + 1}/${d.getDate()}`);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
// 標題
|
||||
function mkTitle(text, subtext = "", opts = {}) {
|
||||
return {
|
||||
text,
|
||||
subtext,
|
||||
left: "center",
|
||||
top: 12,
|
||||
itemGap: 8,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function applyChartOption() {
|
||||
if (!chart) return;
|
||||
const conf = props.charts?.[props.activeKey];
|
||||
if (!conf) return;
|
||||
|
||||
const title = `${conf.legends?.[0] ?? ""} / ${conf.legends?.[1] ?? ""}`;
|
||||
const subTitle = "(近 7 天)";
|
||||
|
||||
chart.setOption({
|
||||
color: [brand.green, brand.purple],
|
||||
title: mkTitle(title, subTitle),
|
||||
grid: { top: 84, left: 36, right: 16, bottom: 56, containLabel: true },
|
||||
tooltip: { trigger: "axis", axisPointer: { type: "line" }, confine: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
boundaryGap: false,
|
||||
data: last7DaysLabels(),
|
||||
axisLine: { lineStyle: { color: brand.gray } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: brand.gray },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
legend: {
|
||||
data: conf.legends || [],
|
||||
bottom: 8,
|
||||
icon: "circle",
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 24,
|
||||
textStyle: { color: brand.gray },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "數量",
|
||||
nameLocation: "middle",
|
||||
nameGap: 52,
|
||||
nameTextStyle: { padding: [0, 8, 0, 8] },
|
||||
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] ?? "",
|
||||
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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
if (!chartEl.value) return;
|
||||
chart = echarts.init(chartEl.value);
|
||||
applyChartOption();
|
||||
}
|
||||
|
||||
function disposeChart() {
|
||||
if (chart) {
|
||||
chart.dispose();
|
||||
chart = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
chart?.resize?.();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initChart();
|
||||
window.addEventListener("resize", onWindowResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", onWindowResize);
|
||||
disposeChart();
|
||||
});
|
||||
|
||||
// 只要 activeKey 或 charts(內容/參照)變化,就重繪
|
||||
watch(
|
||||
() => [props.activeKey, props.charts],
|
||||
() => applyChartOption(),
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
File diff suppressed because it is too large
Load Diff
@ -56,13 +56,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日離院 Card -->
|
||||
<!-- 今日結案 Card -->
|
||||
<div
|
||||
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
@click="openPanel('discharges')"
|
||||
>
|
||||
<div class="flex justify-center items-center gap-4">
|
||||
<p>今日離院/當月累積離院</p>
|
||||
<p>今日結案/當月累積結案</p>
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -96,7 +96,7 @@
|
||||
<!-- A. 未完成的知會事項與代辦事項-->
|
||||
<div v-if="!showPanel" key="todos" class="absolute inset-0">
|
||||
<section
|
||||
class="bg-white/50 rounded-md shadow p-6 flex flex-col min-h-0 gap-4 h-full"
|
||||
class="bg-white/30 rounded-md shadow p-6 flex flex-col min-h-0 gap-4 h-full"
|
||||
>
|
||||
<h3 class="text-2xl font-bold">未完成的知會事項與代辦事項</h3>
|
||||
<div class="flex flex-col gap-4 mb-6 flex-1 min-h-0">
|
||||
@ -169,7 +169,7 @@
|
||||
<!-- B. Panel -->
|
||||
<div v-else key="panel" class="absolute inset-0">
|
||||
<section
|
||||
class="relative w-full h-full bg-white/50 backdrop-blur-sm rounded-md shadow overflow-hidden p-0"
|
||||
class="relative w-full h-full bg-white/30 backdrop-blur-sm rounded-md shadow overflow-hidden p-0"
|
||||
role="region"
|
||||
:aria-labelledby="'panel-title'"
|
||||
>
|
||||
@ -324,7 +324,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 內容:今日離院(底部固定分頁;移出 scroll) -->
|
||||
<!-- 內容:今日結案(底部固定分頁;移出 scroll) -->
|
||||
<div v-else-if="panelType === 'discharges'" class="space-y-8">
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
@ -479,7 +479,7 @@ const panelType = ref(null);
|
||||
const panelTitle = computed(() => {
|
||||
if (panelType.value === "residents") return "住民資訊";
|
||||
if (panelType.value === "inpatients") return "住院資訊";
|
||||
if (panelType.value === "discharges") return "今日離院";
|
||||
if (panelType.value === "discharges") return "今日結案";
|
||||
return "";
|
||||
});
|
||||
function openPanel(type) {
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="flex-[2] grid grid-cols-3 gap-2 max-h-[150px]">
|
||||
<!-- 住民人數 Card -->
|
||||
<div
|
||||
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow-md px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
@click="openPanel('residents')"
|
||||
>
|
||||
<div class="flex justify-center items-center gap-4">
|
||||
@ -31,7 +31,7 @@
|
||||
|
||||
<!-- 住院人數 Card -->
|
||||
<div
|
||||
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow-md px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
@click="openPanel('inpatients')"
|
||||
>
|
||||
<div class="flex justify-center items-center gap-4">
|
||||
@ -56,13 +56,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日離院 Card -->
|
||||
<!-- 今日結案 Card -->
|
||||
<div
|
||||
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow-md px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
@click="openPanel('discharges')"
|
||||
>
|
||||
<div class="flex justify-center items-center gap-4">
|
||||
<p>今日離院/當月累積離院</p>
|
||||
<p>今日結案/當月累積結案</p>
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -99,18 +99,18 @@
|
||||
<div class="flex flex-col gap-2 w-full h-full">
|
||||
<div class="flex-[5] grid grid-cols-2 gap-2 min-h-0">
|
||||
<div
|
||||
class="col-span-1 bg-white/50 rounded-md shadow p-2 flex justify-center items-center"
|
||||
class="col-span-1 bg-white/30 rounded-md shadow-md p-2 flex justify-center items-center"
|
||||
>
|
||||
<div ref="chartARef" class="w-full h-[90%] min-h-[200px]"></div>
|
||||
</div>
|
||||
<div
|
||||
class="col-span-1 bg-white/50 rounded-md shadow p-2 flex justify-center items-center"
|
||||
class="col-span-1 bg-white/30 rounded-md shadow-md p-2 flex justify-center items-center"
|
||||
>
|
||||
<div ref="chartBRef" class="w-full h-[90%] min-h-[200px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-white/50 rounded-md shadow p-2 flex flex-col justify-center items-start flex-[5] min-h-0"
|
||||
class="bg-white/30 rounded-md shadow-md p-2 flex flex-col justify-center items-start flex-[5] min-h-0"
|
||||
>
|
||||
<div ref="chartCRef" class="w-full h-[90%] min-h-[200px]"></div>
|
||||
</div>
|
||||
@ -120,7 +120,7 @@
|
||||
<!-- B. Panel -->
|
||||
<div v-else key="panel" class="absolute inset-0">
|
||||
<section
|
||||
class="relative w-full h-full bg-white/50 rounded-md shadow overflow-hidden p-0"
|
||||
class="relative w-full h-full bg-white/30 rounded-md shadow-md overflow-hidden p-0"
|
||||
role="region"
|
||||
:aria-labelledby="'panel-title'"
|
||||
>
|
||||
@ -273,7 +273,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 內容:今日離院(表格用分頁資料) -->
|
||||
<!-- 內容:今日結案(表格用分頁資料) -->
|
||||
<div v-else-if="panelType === 'discharges'" class="space-y-8">
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
@ -609,7 +609,7 @@ function buildOptionA(collapsedStage = "median") {
|
||||
title: mkTitle("佔床率", "(近 6 個月)"),
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
axisPointer: { type: "shadow" },
|
||||
axisPointer: { type: "shadow-md" },
|
||||
formatter: boxTooltipTW,
|
||||
},
|
||||
grid: commonGrid,
|
||||
@ -657,7 +657,7 @@ function buildOptionB(collapsedStage = "median") {
|
||||
title: mkTitle("住院率", "(近 6 個月)"),
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
axisPointer: { type: "shadow" },
|
||||
axisPointer: { type: "shadow-md" },
|
||||
formatter: boxTooltipTW,
|
||||
},
|
||||
grid: commonGrid,
|
||||
@ -699,7 +699,7 @@ function buildOptionC() {
|
||||
Number((v + Math.sin(i / 3) * 1.5).toFixed(1))
|
||||
);
|
||||
return {
|
||||
title: mkTitle("每日佔床率比較", "(近 30 天)"),
|
||||
title: mkTitle("各單位每日佔床率", "(近 30 天)"),
|
||||
legend: { top: 28, data: ["機構 A", "本機構", "機構 B"] },
|
||||
tooltip: { trigger: "axis", valueFormatter: (v) => `${v}%` },
|
||||
grid: { ...commonGrid, top: 64 },
|
||||
@ -781,7 +781,7 @@ const panelType = ref(null);
|
||||
const panelTitle = computed(() => {
|
||||
if (panelType.value === "residents") return "住民資訊";
|
||||
if (panelType.value === "inpatients") return "住院資訊";
|
||||
if (panelType.value === "discharges") return "今日離院";
|
||||
if (panelType.value === "discharges") return "今日結案";
|
||||
return "";
|
||||
});
|
||||
function openPanel(type) {
|
||||
|
@ -12,8 +12,8 @@ export const brand = {
|
||||
yellowDark: "#C4E920",
|
||||
black: "#424242",
|
||||
gray: "#828282",
|
||||
grayLight: "#E9E9E9",
|
||||
grayLighter: "#F6F6F6",
|
||||
grayLight: "#CACACA",
|
||||
grayLighter: "#EEEEEE",
|
||||
grayDark: "#D2D2D2",
|
||||
white: "#ffffff"
|
||||
};
|
||||
|
@ -40,11 +40,17 @@ module.exports = {
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
green: { DEFAULT: "#34D5C8", light: "#C4FBE5", dark: "#0CA99C" },
|
||||
green: {
|
||||
DEFAULT: "#34D5C8",
|
||||
light: "#C4FBE5",
|
||||
lighter: "#F2F9F6",
|
||||
dark: "#0CA99C",
|
||||
},
|
||||
red: "#FF8678",
|
||||
purple: {
|
||||
DEFAULT: "#A5BEFF",
|
||||
light: "#D5E1FF",
|
||||
lighter: "#EFF1F6",
|
||||
dark: "#7089CA",
|
||||
},
|
||||
yellow: {
|
||||
@ -55,7 +61,7 @@ module.exports = {
|
||||
gray: {
|
||||
DEFAULT: "#828282",
|
||||
lighter: "#EEEEEE",
|
||||
light: "#E9E9E9",
|
||||
light: "#CACACA",
|
||||
dark: "#767676",
|
||||
},
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user