feat: 製作 demo 用 modals
This commit is contained in:
parent
a6beaae3e3
commit
c4d78bd5b6
21
public/img/hotSpot.svg
Normal file
21
public/img/hotSpot.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<svg width="164" height="164" viewBox="0 0 164 164" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<g>
|
||||
<circle cx="81.7363" cy="81.8212" r="40" stroke-width="60" style="stroke:grey" />
|
||||
</g>
|
||||
<g filter="url(#filter0_d)">
|
||||
<circle cx="81.7363" cy="81.8212" r="20" stroke="white" stroke-width="60"/>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<filter id="filter0_d" x="0.174763" y="0.259602" width="163.123" height="163.123" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="5.61135"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 938 B |
@ -38,8 +38,6 @@ const FLOOR_DBIDS = { "9F": 14973, "8F": 14262 };
|
||||
// ====== 顏色 / Sprite數量(可調) ======
|
||||
const BRAND_GREEN = "#34D5C8";
|
||||
const BRAND_RED = "#FF8678";
|
||||
const SPRITES_PER_FLOOR = 6;
|
||||
const RED_PER_FLOOR = 2;
|
||||
|
||||
// (可調)本地 SVF 路徑(相對於 public)
|
||||
const MODEL_SVF_PATH = `/upload/forge/0.svf`;
|
||||
@ -50,6 +48,37 @@ let viewer = null;
|
||||
const viewerReady = ref(false);
|
||||
const activeFloor = ref("9F"); // 預設 9F
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
|
||||
// 嘗試從 ?floor=8F|9F 或 localStorage 還原
|
||||
function resolveInitialFloor() {
|
||||
const q = (route.query?.floor || "").toString().toUpperCase();
|
||||
@ -132,27 +161,6 @@ function getNodeWorldBounds(model, dbId) {
|
||||
return box;
|
||||
}
|
||||
|
||||
// 以 3x2 固定網格分佈 6 個點
|
||||
function gridPositions(box, count = 6) {
|
||||
const THREE = getThree();
|
||||
const xs = [0.2, 0.5, 0.8];
|
||||
const ys = [0.3, 0.7];
|
||||
const res = [];
|
||||
const min = box.min,
|
||||
max = box.max;
|
||||
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 * 0.05);
|
||||
for (let r = 0; r < ys.length; r++) {
|
||||
for (let c = 0; c < xs.length; c++) {
|
||||
if (res.length >= count) break;
|
||||
res.push(new THREE.Vector3(min.x + xs[c] * w, min.y + ys[r] * h, z));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// 把 [0~1] 比例座標轉成實際 3D 位置(固定佈局用)
|
||||
function positionsFromRatios(box, ratios, zLiftRatio = 0.05) {
|
||||
const THREE = getThree();
|
||||
@ -242,6 +250,15 @@ async function rebuildSpritesForFloor(floorKey) {
|
||||
residentsSex: pick(SEX_POOL, spriteDbId + 1),
|
||||
residentsAge: ageFromSeed(spriteDbId + 2),
|
||||
startTime: startDateFromSeed(spriteDbId + 3),
|
||||
|
||||
avatarUrl: Math.random() > 0.4 ? pick(AVATAR_POOL, 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),
|
||||
});
|
||||
});
|
||||
|
||||
@ -296,6 +313,7 @@ function updateLabelPositions() {
|
||||
}
|
||||
|
||||
function attachCameraEventsForLabels() {
|
||||
camEvtCleanup?.(); // 先清掉舊的
|
||||
const onUpdate = () => requestAnimationFrame(updateLabelPositions);
|
||||
viewer.addEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, onUpdate);
|
||||
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, onUpdate);
|
||||
@ -342,8 +360,7 @@ async function onClickFloor(next) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 強制首頁預設 9F(避免舊的 localStorage/路由殘值影響)
|
||||
activeFloor.value = "9F";
|
||||
activeFloor.value = resolveInitialFloor();
|
||||
|
||||
await initViewer(forgeDom.value);
|
||||
const model = await loadModel(MODEL_SVF_PATH);
|
||||
@ -406,7 +423,21 @@ onUnmounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const zones = ["全部", "VIP", "換管", "失能", "失智"];
|
||||
const zones = ["VIP", "換管", "失能", "失智"];
|
||||
|
||||
// 用 Set 做好操作與效能
|
||||
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 activeZone = ref("全部");
|
||||
const onClickZone = (zone) => {
|
||||
activeZone.value = zone;
|
||||
@ -455,6 +486,19 @@ const NAME_POOL = [
|
||||
"郭怡君",
|
||||
"洪嘉文",
|
||||
];
|
||||
|
||||
const AVATAR_POOL = [
|
||||
"/img/avatars/m1.png",
|
||||
"/img/avatars/m2.png",
|
||||
"/img/avatars/f1.png",
|
||||
"/img/avatars/f2.png",
|
||||
]; // 可自行準備,若沒有檔案也沒關係,模板有 fallback
|
||||
|
||||
const HEALTH_POOL = ["一般", "良好", "需觀察", "虛弱"];
|
||||
const MEDICATION_POOL = ["規律服藥", "偶爾忘記", "暫停服藥", "需家屬協助"];
|
||||
const SPECIAL_POOL = ["住院中", "跌倒觀察", "復健中", "門診追蹤"];
|
||||
const NOTE_POOL = ["-", "對花生過敏", "家屬每週三探視", "夜間需加護理巡房"];
|
||||
|
||||
const SEX_POOL = ["男", "女"];
|
||||
function seededRand(seed) {
|
||||
const s = Math.sin(seed * 9301 + 49297) * 233280;
|
||||
@ -475,37 +519,51 @@ function startDateFromSeed(seed) {
|
||||
return `${d.getFullYear()}/${mm}/${dd}`;
|
||||
}
|
||||
|
||||
// 選單選項(可自行增減)
|
||||
// 選單選項
|
||||
const infoOptions = [
|
||||
{ label: "無顯示", value: "none" },
|
||||
{ label: "有住民", value: "occupied" },
|
||||
{ label: "空床", value: "vacant" },
|
||||
{ label: "住院中", value: "hospitalized" },
|
||||
{ label: "請假中", value: "leave" }, // ← 新增
|
||||
];
|
||||
|
||||
const filteredLabels = computed(() => {
|
||||
// 單獨顯示模式:只回傳被點擊的那一顆
|
||||
// 單獨顯示模式(保留你原本的行為)
|
||||
if (soloSpriteId.value != null) {
|
||||
if (selectedInfo.value === "none" || selectedInfo.value === "leave")
|
||||
return [];
|
||||
return labels.value.filter((L) => L.id === soloSpriteId.value);
|
||||
}
|
||||
|
||||
// ---- 以下為全域篩選(依 infoOptions + 分區)----
|
||||
const info = selectedInfo.value; // none / occupied / vacant / hospitalized
|
||||
const zone = activeZone.value; // 全部 / VIP / 換管 / 失能 / 失智(若你有真的 zone 欄位可改用)
|
||||
// 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) => {
|
||||
// 分區(示例:用名稱前綴 "A 區 ...", 若你已在資料有 L.data.zone,改成 `zone === '全部' || L.data.zone === zone`)
|
||||
const inZone = zone === "全部" || L.data.name?.startsWith(`${zone} 區`);
|
||||
// ===== 分區多選判斷 =====
|
||||
// 有選任何分區 → 必須命中其中之一
|
||||
// 沒選任何分區 → 不過濾(視為全部)
|
||||
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; // 有住民:綠(依你資料邏輯)
|
||||
const isGreen = !isRed && !L.data.special; // 有住民:綠
|
||||
|
||||
let stateOk = true;
|
||||
if (info === "none") stateOk = false; // 無顯示 → 全隱(配合需求 2)
|
||||
if (info === "occupied") stateOk = isGreen; // 有住民 → 綠
|
||||
if (info === "vacant") stateOk = isRed; // 空床 → 紅(無論 special 與否)
|
||||
if (info === "hospitalized") stateOk = isHosp; // 住院中 → 紅+special
|
||||
if (info === "occupied") stateOk = isGreen;
|
||||
if (info === "vacant") stateOk = isRed; // 空床 = 只要是紅色,都包含(含住院)
|
||||
if (info === "hospitalized") stateOk = isHosp;
|
||||
|
||||
return inZone && stateOk;
|
||||
});
|
||||
@ -588,8 +646,21 @@ onUnmounted(() => {
|
||||
@click="bringToFrontById(L.id)"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto bg-white/95 border rounded-md shadow px-2 py-1 text-xs"
|
||||
class="pointer-events-auto relative bg-white/95 border border-gray-300 rounded-lg 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">
|
||||
@ -623,8 +694,8 @@ onUnmounted(() => {
|
||||
<!-- 床號:永遠顯示 -->
|
||||
<span class="align-middle">{{ L.data.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 hover:text-gray-600">
|
||||
<!-- 眼睛 icon -->
|
||||
<div class="text-gray-400" title="查看詳細">
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -687,96 +758,88 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 過濾 popover 顯示什麼狀態-->
|
||||
<div
|
||||
class="absolute top-4 right-4 text-sm flex justify-center items-center gap-2 z-50"
|
||||
>
|
||||
<div class="relative">
|
||||
<div
|
||||
ref="infoTriggerRef"
|
||||
class="btn text-brand-purple-dark bg-brand-purple-light hover:opacity-90 shadow-md border-none rounded-full px-6 tracking-widest cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="infoOpen ? 'true' : 'false'"
|
||||
@click="toggleInfo"
|
||||
@keydown.enter.prevent="toggleInfo"
|
||||
@keydown.space.prevent="toggleInfo"
|
||||
>
|
||||
{{ infoDisplay }}
|
||||
<span class="ml-2 inline-block align-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m98 190.06l139.78 163.12a24 24 0 0 0 36.44 0L414 190.06c13.34-15.57 2.28-39.62-18.22-39.62h-279.6c-20.5 0-31.56 24.05-18.18 39.62"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 下拉選單(使用 <ul> 與 <li>) -->
|
||||
<div
|
||||
v-show="infoOpen"
|
||||
ref="infoPanelRef"
|
||||
class="absolute top-12 right-0 z-50 w-48 rounded-xl border border-gray-100 bg-white shadow-lg p-2"
|
||||
@click.stop
|
||||
>
|
||||
<ul class="max-h-48 overflow-y-auto">
|
||||
<li
|
||||
v-for="opt in infoOptions"
|
||||
:key="opt.value"
|
||||
@click="selectInfo(opt)"
|
||||
class="px-3 py-2 rounded-md cursor-pointer hover:bg-gray-100"
|
||||
:class="selectedInfo === opt.value ? 'bg-brand-green-light' : ''"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</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="
|
||||
activeZone === zone
|
||||
isZoneSelected(zone)
|
||||
? 'bg-brand-green-light bg-opacity-50'
|
||||
: 'bg-white'
|
||||
"
|
||||
@click="onClickZone(zone)"
|
||||
:aria-pressed="activeZone === zone"
|
||||
@click="toggleZone(zone)"
|
||||
:aria-pressed="isZoneSelected(zone)"
|
||||
>
|
||||
{{ zone }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 圖例 -->
|
||||
<!-- 床位資訊-->
|
||||
<div
|
||||
class="absolute bottom-4 left-4 text-sm flex justify-center gap-2 items-center z-10 bg-white border rounded-md shadow px-4"
|
||||
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 gap-4">
|
||||
<div class="flex justify-start items-center gap-2">
|
||||
<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>
|
||||
<p>有住民</p>
|
||||
</div>
|
||||
<div class="flex justify-start items-center gap-2">
|
||||
<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>
|
||||
<p>空床</p>
|
||||
</div>
|
||||
<div class="flex justify-start items-center gap-2">
|
||||
<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"
|
||||
@ -790,11 +853,91 @@ onUnmounted(() => {
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<p>住院中</p>
|
||||
</div>
|
||||
<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>
|
||||
|
@ -1,282 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
defineProps,
|
||||
computed,
|
||||
onUnmounted,
|
||||
watch,
|
||||
provide,
|
||||
} from "vue";
|
||||
import { getUrn, getAccessToken } from "@/apis/forge";
|
||||
// import { twMerge } from "tailwind-merge";
|
||||
// import hexToRgb from "@/util/hexToRgb";
|
||||
// import getModalPosition from "@/util/getModalPosition";
|
||||
// import useSystemStatusByBaja from "@/hooks/baja/useSystemStatusByBaja";
|
||||
// import ForgeInfoModal from "@/components/forge/ForgeInfoModal.vue";
|
||||
// import useAlarmStore from "@/stores/useAlarmStore";
|
||||
import useForgeSprite from "@/hooks/forge/useForgeSprite";
|
||||
import useHeatmapBarStore from "@/stores/useHeatmapBarStore";
|
||||
|
||||
const props = defineProps({
|
||||
initialData: Object,
|
||||
cubeStyle: {
|
||||
type: Object,
|
||||
default: {
|
||||
right: 0,
|
||||
top: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const store = useHeatmapBarStore();
|
||||
|
||||
const { updateDataVisualization, createSprites, showSubSystemObjects, forgeClickListener, clear, setCameraPosition } = useForgeSprite()
|
||||
|
||||
const forgeDom = ref(null);
|
||||
|
||||
const initViewer = (container) => {
|
||||
return new Promise(function (resolve, reject) {
|
||||
// Autodesk.Viewing.Initializer({ getAccessToken }, function () {
|
||||
// const config = {
|
||||
// extensions: ["Autodesk.DataVisualization", "Autodesk.DocumentBrowser"],
|
||||
// };
|
||||
// let viewer = new Autodesk.Viewing.GuiViewer3D(container, config);
|
||||
// Autodesk.Viewing.Private.InitParametersSetting.alpha = true;
|
||||
// viewer.start();
|
||||
// resolve(viewer);
|
||||
// });
|
||||
|
||||
Autodesk.Viewing.Initializer(
|
||||
{
|
||||
env: "Local",
|
||||
language: "en",
|
||||
},
|
||||
function () {
|
||||
const config = {
|
||||
extensions: [
|
||||
"Autodesk.DataVisualization",
|
||||
"Autodesk.DocumentBrowser",
|
||||
],
|
||||
};
|
||||
let viewer = new Autodesk.Viewing.GuiViewer3D(container, config);
|
||||
Autodesk.Viewing.Private.InitParametersSetting.alpha = true;
|
||||
viewer.start();
|
||||
viewer.setGroundShadow(false);
|
||||
viewer.impl.renderer().setClearAlpha(0);
|
||||
viewer.impl.glrenderer().setClearColor(0xffffff, 0);
|
||||
viewer.impl.invalidate(true);
|
||||
resolve(viewer);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 使用本地 .svf 文件加載模型
|
||||
const loadModel = (viewer, filePath) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
viewer.loadModel(
|
||||
filePath,
|
||||
{},
|
||||
(model) => {
|
||||
viewer.impl.invalidate(true);
|
||||
viewer.fitToView();
|
||||
setTimeout(() => {
|
||||
setCameraPosition(
|
||||
{
|
||||
x: 241.40975707867645,
|
||||
y: -260.4481491801548,
|
||||
z: 129.5719879121458,
|
||||
}, // 攝影機的新位置
|
||||
{
|
||||
x: -183.36302786348594,
|
||||
y: 194.05657710941966,
|
||||
z: -149.3902249981004,
|
||||
} // 攝影機的焦點
|
||||
);
|
||||
}, 500);
|
||||
updateDataVisualization(viewer)
|
||||
resolve(model);
|
||||
console.log("模型加載完成");
|
||||
},
|
||||
reject
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// const loadModel = (viewer) => {
|
||||
// return new Promise(function (resolve, reject) {
|
||||
// async function onDocumentLoadSuccess(doc) {
|
||||
// console.log("模型加載完成");
|
||||
// viewer.setGroundShadow(false);
|
||||
// viewer.impl.renderer().setClearAlpha(0); //clear alpha channel
|
||||
// viewer.impl.glrenderer().setClearColor(0xffffff, 0); //set transparent background, color code does not matter
|
||||
// viewer.impl.invalidate(true); //trigger rendering
|
||||
|
||||
// const documentNode = await viewer.loadDocumentNode(
|
||||
// doc,
|
||||
// doc.getRoot().getDefaultGeometry()
|
||||
// );
|
||||
// updateDataVisualization(viewer)
|
||||
// resolve(documentNode);
|
||||
// }
|
||||
// function onDocumentLoadFailure(code, message, errors) {
|
||||
// reject({ code, message, errors });
|
||||
// }
|
||||
// Autodesk.Viewing.Document.load(
|
||||
// "urn:" + urn,
|
||||
// onDocumentLoadSuccess,
|
||||
// onDocumentLoadFailure
|
||||
// );
|
||||
// });
|
||||
// };
|
||||
|
||||
const initForge = async () => {
|
||||
// getUrn().then((res) => {
|
||||
// if (!res.isSuccess) return;
|
||||
|
||||
// initViewer(forgeDom.value).then((viewer) => {
|
||||
// loadModel(viewer, res.data[0].urn_3D).then(() => {
|
||||
// viewer.addEventListener(
|
||||
// Autodesk.Viewing.GEOMETRY_LOADED_EVENT,
|
||||
// async function () {
|
||||
// console.log(
|
||||
// "Autodesk.Viewing.GEOMETRY_LOADED_EVENT",
|
||||
// viewer.isLoadDone()
|
||||
// );
|
||||
// // updateForgeViewer(viewer);
|
||||
// createSprites()
|
||||
// hideAllObjects();
|
||||
// }
|
||||
// );
|
||||
// viewer.addEventListener(
|
||||
// Autodesk.Viewing.CAMERA_CHANGE_EVENT,
|
||||
// function (e) {
|
||||
// // viewer.isLoadDone() &&
|
||||
// // updateDbidPosition(this, subscribeData.value);
|
||||
// console.log(
|
||||
// "camera position changed: ",
|
||||
// NOP_VIEWER.navigation.getTarget(),
|
||||
// e.camera.position
|
||||
// );
|
||||
// }
|
||||
// );
|
||||
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
const FILE_BASEURL = import.meta.env.VITE_FILE_API_BASEURL;
|
||||
const viewer = await initViewer(forgeDom.value)
|
||||
const filePath = `${FILE_BASEURL}/upload/forge/0.svf`;
|
||||
await loadModel(viewer, filePath)
|
||||
viewer.addEventListener(Autodesk.Viewing.GEOMETRY_LOADED_EVENT,
|
||||
async function () {
|
||||
console.log(
|
||||
"Autodesk.Viewing.GEOMETRY_LOADED_EVENT",
|
||||
viewer.isLoadDone()
|
||||
);
|
||||
|
||||
showSubSystemObjects();
|
||||
createSprites();
|
||||
forgeClickListener();
|
||||
})
|
||||
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
console.log("Forge 加載");
|
||||
initForge();
|
||||
});
|
||||
|
||||
// 傳遞目前點擊資訊
|
||||
const currentInfoModalData = ref(null);
|
||||
const isMobile = (pointerType) => {
|
||||
return pointerType !== "mouse"; // is desktop
|
||||
};
|
||||
const getCurrentInfoModalData = (e, position, value) => {
|
||||
const mobile = isMobile(e.pointerType);
|
||||
currentInfoModalData.value = {
|
||||
initPos: mobile
|
||||
? { left: `50%`, top: `50%` }
|
||||
: { left: `${position.left}px`, top: `${position.top}px` },
|
||||
value,
|
||||
isMobile: mobile,
|
||||
};
|
||||
forge_info_modal.showModal();
|
||||
};
|
||||
onUnmounted(() => {
|
||||
console.log("Forge 銷毀");
|
||||
|
||||
clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ForgeInfoModal :data="currentInfoModalData" />
|
||||
<div id="forge-preview" ref="forgeDom" class="relative w-full h-full min-h-full">
|
||||
<div v-show="store.heat_bar_isShow" class="absolute z-10 heatbar">
|
||||
<div class="w-40 flex justify-between text-[10px] mb-1">
|
||||
<span v-for="value in store.heatmapConfig?.range" class="text-gradient-1">{{ value }} {{
|
||||
store.heatmapConfig?.unit }}</span>
|
||||
</div>
|
||||
<div class="w-40 h-3" :style="{
|
||||
background: `linear-gradient(
|
||||
to right,
|
||||
${store.heatmapConfig?.color[0]} 0%,
|
||||
${store.heatmapConfig?.color[1]} 100%
|
||||
)`
|
||||
}"></div>
|
||||
</div>
|
||||
<!-- label -->
|
||||
<!-- https://github.com/augustogoncalves/forge-plant-operation/blob/master/forgeSample/wwwroot/js/iconExtension.js -->
|
||||
<!-- https://github.com/dukedhx/viewer-iot-react-feathersjs/blob/master/src/client/iconExtension.js -->
|
||||
|
||||
<label v-for="value in subscribeData" :key="key" :data-dbid="value.forge_dbid" :class="twMerge(
|
||||
`after:border-t-[${value.currentColor}]`,
|
||||
'flex items-center justify-center h-12 -translate-x-1/2 -translate-y-1/5 absolute z-50 px-5 py-4 text-center rounded-md text-lg border-2 border-white',
|
||||
'after:absolute after:border-t-[10px] after:border-x-[12px] after:border-x-transparent after:-bottom-[8px] after:left-1/2 after:-translate-x-1/2 ',
|
||||
'before:absolute before:border-t-[12px] before:border-x-[14px] before:border-x-transparent before:-bottom-[12px] before:left-1/2 before:-translate-x-1/2 before:border-white'
|
||||
)
|
||||
" :style="{
|
||||
left: `${Math.floor(value.device_coordinate_3d.x)}px`,
|
||||
top: `${Math.floor(value.device_coordinate_3d.y) - 100}px`,
|
||||
display: value.is_show,
|
||||
backgroundColor: value.currentColor,
|
||||
}" @click.prevent="(e) =>
|
||||
getCurrentInfoModalData(
|
||||
e,
|
||||
{ left: e.clientX, top: e.clientY },
|
||||
value
|
||||
)
|
||||
">
|
||||
<span class="mr-2">{{ value.full_name }}</span>
|
||||
<span v-if="value.alarmMsg">{{ value.alarmMsg }}</span>
|
||||
<span v-else>{{ value.show_value }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="css">
|
||||
.adsk-viewing-viewer {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
#guiviewer3d-toolbar {
|
||||
display: none;
|
||||
bottom: 200px;
|
||||
}
|
||||
|
||||
.viewcubeWrapper {
|
||||
right: v-bind("`${props.cubeStyle.right}%`") !important;
|
||||
top: v-bind("`${props.cubeStyle.top}%`") !important;
|
||||
}
|
||||
|
||||
.homeViewWrapper {
|
||||
transform: scale(2) !important;
|
||||
}
|
||||
|
||||
.heatbar {
|
||||
/* right: v-bind("`${props.cubeStyle.right + 2}%`") !important; */
|
||||
top: 0% !important;
|
||||
}
|
||||
</style>
|
@ -1,42 +0,0 @@
|
||||
<script setup>
|
||||
import { inject, ref, watch } from "vue";
|
||||
import SystemInfoModalContent from "./SystemInfoModalContent.vue";
|
||||
|
||||
|
||||
const { selectedDevice: data } = inject("system_selectedDevice")
|
||||
|
||||
const position = ref({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
display: "none",
|
||||
});
|
||||
|
||||
watch(
|
||||
data,
|
||||
(newValue) => {
|
||||
if (!newValue.value) {
|
||||
position.value = {
|
||||
display: "none"
|
||||
};
|
||||
} else {
|
||||
position.value = {
|
||||
...data.value.initPos,
|
||||
display: "block"
|
||||
};
|
||||
}
|
||||
|
||||
}, {
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal id="system_info_modal" :width="600" :draggable="!data?.isMobile" :backdrop="false">
|
||||
<template #modalContent>
|
||||
<SystemInfoModalContent />
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
@ -70,7 +70,7 @@ export default function useForgeSprite() {
|
||||
viewableData.spriteSize = 24;
|
||||
|
||||
// 圖示
|
||||
const spriteIconUrl = "/dist/img/hotSpot.svg";
|
||||
const spriteIconUrl = "/img/hotSpot.svg";
|
||||
|
||||
STATIC_SUB_DEVICES.forEach((d) => {
|
||||
if (!d.device_coordinate_3d) return;
|
||||
|
@ -4,12 +4,14 @@
|
||||
>
|
||||
<!-- 高度比重:2 -->
|
||||
<div class="flex-[2] grid grid-cols-4 gap-2">
|
||||
<!-- 住民人數 Card -->
|
||||
<div
|
||||
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl 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>
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@ -28,12 +30,15 @@
|
||||
<p class="text-[12px]">人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 住院人數 Card -->
|
||||
<div
|
||||
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl 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>
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@ -52,6 +57,147 @@
|
||||
<p class="text-[12px]">人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Modal(放在同一個 <template> 中,建議貼在最底部)===== -->
|
||||
<div
|
||||
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">
|
||||
<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">
|
||||
<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">
|
||||
<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-lg"
|
||||
@click="closeModal"
|
||||
>
|
||||
關閉
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
>
|
||||
@ -129,7 +275,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
import { brand } from "@/styles/palette";
|
||||
|
||||
@ -292,4 +438,194 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
disposeCharts();
|
||||
});
|
||||
|
||||
/* ===== 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 n = start;
|
||||
while (arr.length < count) {
|
||||
if (!String(n).includes("4")) arr.push(n);
|
||||
n++;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/* ===== 假資料:住民 ===== */
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
const residentsA = residentsAll.filter((r) => r.area === "A");
|
||||
const residentsB = residentsAll.filter((r) => r.area === "B");
|
||||
|
||||
/* ===== 假資料:住院 ===== */
|
||||
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();
|
||||
}
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
</script>
|
||||
|
@ -4,12 +4,14 @@
|
||||
>
|
||||
<!-- 高度比重:2 -->
|
||||
<div class="flex-[2] grid grid-cols-4 gap-2">
|
||||
<!-- 住民人數 Card -->
|
||||
<div
|
||||
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl 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>
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@ -28,12 +30,15 @@
|
||||
<p class="text-[12px]">人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 住院人數 Card -->
|
||||
<div
|
||||
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl 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>
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@ -52,6 +57,147 @@
|
||||
<p class="text-[12px]">人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Modal(放在同一個 <template> 中,建議貼在最底部)===== -->
|
||||
<div
|
||||
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">
|
||||
<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">
|
||||
<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">
|
||||
<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-lg"
|
||||
@click="closeModal"
|
||||
>
|
||||
關閉
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
|
||||
>
|
||||
@ -129,7 +275,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
import { brand } from "@/styles/palette";
|
||||
|
||||
@ -292,4 +438,194 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
disposeCharts();
|
||||
});
|
||||
|
||||
/* ===== 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 n = start;
|
||||
while (arr.length < count) {
|
||||
if (!String(n).includes("4")) arr.push(n);
|
||||
n++;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/* ===== 假資料:住民 ===== */
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
const residentsA = residentsAll.filter((r) => r.area === "A");
|
||||
const residentsB = residentsAll.filter((r) => r.area === "B");
|
||||
|
||||
/* ===== 假資料:住院 ===== */
|
||||
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();
|
||||
}
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
</script>
|
||||
|
Loading…
Reference in New Issue
Block a user