fix: 修正首頁地圖展開卡頓
This commit is contained in:
parent
5cc0dcbf56
commit
538d00d070
@ -14,7 +14,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>諾亞克 U-ARK 戰情中心</title>
|
<title>諾亞克 U-ARK 戰情中心</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="w-screen h-[1200px] bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter font-noto text-brand-black">
|
<body class="font-noto text-brand-black">
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section>
|
<section class="bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter">
|
||||||
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
|
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
|
||||||
<component :is="layoutComponent" />
|
<component :is="layoutComponent" />
|
||||||
</section>
|
</section>
|
||||||
|
@ -258,7 +258,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 中央 Modal:病患資訊 -->
|
<!-- 中央 Modal:住民資訊 -->
|
||||||
<div
|
<div
|
||||||
v-if="modalOpen"
|
v-if="modalOpen"
|
||||||
class="fixed inset-0 z-[120] flex items-center justify-center"
|
class="fixed inset-0 z-[120] flex items-center justify-center"
|
||||||
@ -276,7 +276,7 @@
|
|||||||
<!-- 標題 -->
|
<!-- 標題 -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold tracking-widest">病患資訊</h3>
|
<h3 class="text-lg font-semibold tracking-widest">住民資訊</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end items-center gap-4">
|
<div class="flex justify-end items-center gap-4">
|
||||||
<a
|
<a
|
||||||
@ -342,7 +342,7 @@ import {
|
|||||||
} from "@/constants/mocks/facilityData";
|
} from "@/constants/mocks/facilityData";
|
||||||
|
|
||||||
let INITIAL_1F_STATE = null; // 儲存 1F 初始視角(含樓層隔離/相機/切面等)
|
let INITIAL_1F_STATE = null; // 儲存 1F 初始視角(含樓層隔離/相機/切面等)
|
||||||
let INITIAL_1F_CAMERA = null; // 相機詳細快照
|
let INITIAL_1F_CAMERA = null; // 相機詳細快照
|
||||||
|
|
||||||
/** ===================== Tabs:樓層/狀態/分區 ===================== */
|
/** ===================== Tabs:樓層/狀態/分區 ===================== */
|
||||||
const floorTabItems = [
|
const floorTabItems = [
|
||||||
@ -476,6 +476,7 @@ const activeFloor = ref("1F");
|
|||||||
|
|
||||||
let unbindSpriteClick = null;
|
let unbindSpriteClick = null;
|
||||||
let THREE = null;
|
let THREE = null;
|
||||||
|
let _resizeObserver = null; // 用於追蹤並釋放 ResizeObserver
|
||||||
|
|
||||||
const {
|
const {
|
||||||
updateDataVisualization: dvInit,
|
updateDataVisualization: dvInit,
|
||||||
@ -527,10 +528,11 @@ try {
|
|||||||
|
|
||||||
function loadResidentStore() {
|
function loadResidentStore() {
|
||||||
try {
|
try {
|
||||||
return new Map(
|
const raw = localStorage.getItem(RESIDENT_STORE_KEY);
|
||||||
JSON.parse(localStorage.getItem(RESIDENT_STORE_KEY) || "[]")
|
if (!raw) return new Map();
|
||||||
);
|
return new Map(JSON.parse(raw));
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn("[Forge] loadResidentStore failed:", err);
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -561,8 +563,6 @@ function bedKeyFromNodeName(name, fallbackCode3) {
|
|||||||
function ensureResidentFor(bedKey, genderHint) {
|
function ensureResidentFor(bedKey, genderHint) {
|
||||||
const key = String(bedKey || "").trim();
|
const key = String(bedKey || "").trim();
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
|
|
||||||
// 先看「寫死表」(有的話,直接用它並覆蓋 localStorage)
|
|
||||||
const fixed = RESIDENTS_BY_BED && RESIDENTS_BY_BED[key];
|
const fixed = RESIDENTS_BY_BED && RESIDENTS_BY_BED[key];
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
const rec = {
|
const rec = {
|
||||||
@ -580,15 +580,11 @@ function ensureResidentFor(bedKey, genderHint) {
|
|||||||
saveResidentStore(RESIDENT_BY_BED);
|
saveResidentStore(RESIDENT_BY_BED);
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再看 localStorage(沒有寫死表才用舊值;補齊性別)
|
|
||||||
const existed = RESIDENT_BY_BED.get(key);
|
const existed = RESIDENT_BY_BED.get(key);
|
||||||
if (existed) {
|
if (existed) {
|
||||||
if (!existed.residentsSex && genderHint) existed.residentsSex = genderHint;
|
if (!existed.residentsSex && genderHint) existed.residentsSex = genderHint;
|
||||||
return existed;
|
return existed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最後備援(真的沒定義到的床位)
|
|
||||||
const g = genderHint === "女" ? "女" : "男";
|
const g = genderHint === "女" ? "女" : "男";
|
||||||
const pool = g === "男" ? NAME_POOL_MALE : NAME_POOL_FEMALE;
|
const pool = g === "男" ? NAME_POOL_MALE : NAME_POOL_FEMALE;
|
||||||
const idx = hashFNV1a(key) % pool.length;
|
const idx = hashFNV1a(key) % pool.length;
|
||||||
@ -610,7 +606,6 @@ function ensureResidentFor(bedKey, genderHint) {
|
|||||||
|
|
||||||
/** ===================== 分區映射(房號 → 一般/氧氣) ===================== */
|
/** ===================== 分區映射(房號 → 一般/氧氣) ===================== */
|
||||||
const ROOM_ZONE_MAP = { "1F": new Map(), "2F": new Map() };
|
const ROOM_ZONE_MAP = { "1F": new Map(), "2F": new Map() };
|
||||||
// 固定分區持久化(依 3 位房號)—— 一次決定,之後沿用
|
|
||||||
const ZONE_STORE_KEY = "uark-room-zone-v1";
|
const ZONE_STORE_KEY = "uark-room-zone-v1";
|
||||||
function loadZoneStore() {
|
function loadZoneStore() {
|
||||||
try {
|
try {
|
||||||
@ -624,14 +619,11 @@ function saveZoneStore(obj) {
|
|||||||
localStorage.setItem(ZONE_STORE_KEY, JSON.stringify(obj));
|
localStorage.setItem(ZONE_STORE_KEY, JSON.stringify(obj));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
// 依本層房號清單,確保 ROOM_ZONE_MAP 有「寫死」分區;若沒存過就用交錯規則初始化並持久化
|
|
||||||
function ensureZoneMapForFloor(model, floorKey, floorRootId) {
|
function ensureZoneMapForFloor(model, floorKey, floorRootId) {
|
||||||
const store = loadZoneStore();
|
const store = loadZoneStore();
|
||||||
const floorStore = store[floorKey] || {};
|
const floorStore = store[floorKey] || {};
|
||||||
const tree = model.getInstanceTree?.();
|
const tree = model.getInstanceTree?.();
|
||||||
if (!tree || floorRootId == null) return ROOM_ZONE_MAP[floorKey];
|
if (!tree || floorRootId == null) return ROOM_ZONE_MAP[floorKey];
|
||||||
|
|
||||||
// 取得本層所有「有幾何的房號(3 位)」並排序
|
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const rooms = [];
|
const rooms = [];
|
||||||
const code3For = (name) => {
|
const code3For = (name) => {
|
||||||
@ -665,8 +657,6 @@ function ensureZoneMapForFloor(model, floorKey, floorRootId) {
|
|||||||
};
|
};
|
||||||
walk(floorRootId);
|
walk(floorRootId);
|
||||||
rooms.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
rooms.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||||||
|
|
||||||
// 填滿 floorStore:沒存過的用交錯(偶數索引→一般;奇數索引→氧氣)
|
|
||||||
rooms.forEach((code, idx) => {
|
rooms.forEach((code, idx) => {
|
||||||
if (floorStore[code] !== "一般" && floorStore[code] !== "氧氣") {
|
if (floorStore[code] !== "一般" && floorStore[code] !== "氧氣") {
|
||||||
floorStore[code] = idx % 2 === 0 ? "一般" : "氧氣";
|
floorStore[code] = idx % 2 === 0 ? "一般" : "氧氣";
|
||||||
@ -674,12 +664,9 @@ function ensureZoneMapForFloor(model, floorKey, floorRootId) {
|
|||||||
});
|
});
|
||||||
store[floorKey] = floorStore;
|
store[floorKey] = floorStore;
|
||||||
saveZoneStore(store);
|
saveZoneStore(store);
|
||||||
|
|
||||||
// 寫回記憶體的 MAP
|
|
||||||
ROOM_ZONE_MAP[floorKey] = new Map(Object.entries(floorStore));
|
ROOM_ZONE_MAP[floorKey] = new Map(Object.entries(floorStore));
|
||||||
return ROOM_ZONE_MAP[floorKey];
|
return ROOM_ZONE_MAP[floorKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getZoneForRoom(floorKey, code3) {
|
function getZoneForRoom(floorKey, code3) {
|
||||||
return ROOM_ZONE_MAP[floorKey]?.get(String(code3)) ?? null;
|
return ROOM_ZONE_MAP[floorKey]?.get(String(code3)) ?? null;
|
||||||
}
|
}
|
||||||
@ -744,10 +731,53 @@ function resolveInitialFloor() {
|
|||||||
return "1F";
|
return "1F";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ===================== Forge 初始化 / 模型載入 ===================== */
|
/** ===================== 等容器有尺寸再啟動 Viewer ===================== */
|
||||||
function initViewer(container) {
|
async function waitForContainerSize(
|
||||||
|
el,
|
||||||
|
{ minW = 2, minH = 2, timeout = 4000 } = {}
|
||||||
|
) {
|
||||||
|
if (!el) return false;
|
||||||
|
const ok = () => el.clientWidth >= minW && el.clientHeight >= minH;
|
||||||
|
if (ok()) return true;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
Autodesk.Viewing.Initializer({ env: "Local", language: "en" }, () => {
|
let done = false;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (!done) {
|
||||||
|
done = true;
|
||||||
|
ro.disconnect();
|
||||||
|
resolve(ok());
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (done) return;
|
||||||
|
if (ok()) {
|
||||||
|
done = true;
|
||||||
|
clearTimeout(t);
|
||||||
|
ro.disconnect();
|
||||||
|
requestAnimationFrame(() => resolve(true));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!done && ok()) {
|
||||||
|
done = true;
|
||||||
|
clearTimeout(t);
|
||||||
|
ro.disconnect();
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ===================== Forge 初始化 / 模型載入 ===================== */
|
||||||
|
async function initViewer(container) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const initOpts = { env: "Local", language: "en" };
|
||||||
|
try {
|
||||||
|
initOpts.useADP = false;
|
||||||
|
initOpts.experimental = { disableIndexedDb: true };
|
||||||
|
} catch {}
|
||||||
|
Autodesk.Viewing.Initializer(initOpts, () => {
|
||||||
const config = {
|
const config = {
|
||||||
extensions: ["Autodesk.DataVisualization", "Autodesk.DocumentBrowser"],
|
extensions: ["Autodesk.DataVisualization", "Autodesk.DocumentBrowser"],
|
||||||
};
|
};
|
||||||
@ -757,10 +787,23 @@ function initViewer(container) {
|
|||||||
window.v = viewer;
|
window.v = viewer;
|
||||||
getThree();
|
getThree();
|
||||||
viewer.setGroundShadow(true);
|
viewer.setGroundShadow(true);
|
||||||
const r = viewer.impl?.renderer?.();
|
const once = () => {
|
||||||
if (r?.setClearColor) r.setClearColor(0x000000, 0);
|
try {
|
||||||
if (r?.setClearAlpha) r.setClearAlpha(0);
|
const r = viewer.impl?.renderer?.();
|
||||||
viewer.impl.invalidate(true);
|
if (r?.setClearColor) r.setClearColor(0x000000, 0);
|
||||||
|
if (r?.setClearAlpha) r.setClearAlpha(0);
|
||||||
|
viewer.impl.invalidate(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[Forge] invalidate skipped:", err);
|
||||||
|
}
|
||||||
|
viewer.removeEventListener(
|
||||||
|
Autodesk.Viewing.RENDER_PRESENTED_EVENT,
|
||||||
|
once
|
||||||
|
);
|
||||||
|
};
|
||||||
|
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -979,19 +1022,19 @@ function clearOurTheming(model) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
lastThemedIds.clear();
|
lastThemedIds.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRoomTheming(id, rgba) {
|
function setRoomTheming(id, rgba) {
|
||||||
const THREE = getThree();
|
const THREE = getThree();
|
||||||
const c = new THREE.Vector4(rgba.r, rgba.g, rgba.b, rgba.a);
|
const c = new THREE.Vector4(rgba.r, rgba.g, rgba.b, rgba.a);
|
||||||
viewer.setThemingColor(id, c, viewer.model, true);
|
viewer.setThemingColor(id, c, viewer.model, true);
|
||||||
lastThemedIds.add(id);
|
lastThemedIds.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildZoneThemingForCurrentFloor() {
|
function rebuildZoneThemingForCurrentFloor() {
|
||||||
if (!viewer || !viewer.model) return;
|
if (!viewer || !viewer.model) return;
|
||||||
clearOurTheming(viewer.model);
|
clearOurTheming(viewer.model);
|
||||||
if (selectedZones.value.size === 0) {
|
if (selectedZones.value.size === 0) {
|
||||||
viewer.impl.invalidate(true);
|
try {
|
||||||
|
viewer.impl.invalidate(true);
|
||||||
|
} catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const floorKey = activeFloor.value;
|
const floorKey = activeFloor.value;
|
||||||
@ -1038,29 +1081,25 @@ function rebuildZoneThemingForCurrentFloor() {
|
|||||||
const sortedRooms = [...rooms].sort((a, b) => a.codeN - b.codeN);
|
const sortedRooms = [...rooms].sort((a, b) => a.codeN - b.codeN);
|
||||||
ensureZoneMapForFloor(viewer.model, floorKey, floorRootId);
|
ensureZoneMapForFloor(viewer.model, floorKey, floorRootId);
|
||||||
const zoneMap = ROOM_ZONE_MAP[floorKey];
|
const zoneMap = ROOM_ZONE_MAP[floorKey];
|
||||||
|
|
||||||
const chosen = selectedZones.value;
|
const chosen = selectedZones.value;
|
||||||
for (const r of sortedRooms) {
|
for (const r of sortedRooms) {
|
||||||
const z = zoneMap.get(r.code3);
|
const z = zoneMap.get(r.code3);
|
||||||
if (!z || !chosen.has(z)) continue;
|
if (!z || !chosen.has(z)) continue;
|
||||||
setRoomTheming(r.id, z === "一般" ? THEME_PURPLE : THEME_YELLOW);
|
setRoomTheming(r.id, z === "一般" ? THEME_PURPLE : THEME_YELLOW);
|
||||||
}
|
}
|
||||||
viewer.impl.invalidate(true, true, true);
|
try {
|
||||||
|
viewer.impl.invalidate(true, true, true);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ===================== 返回模型初始視角 ===================== */
|
/** ===================== 返回模型初始視角 ===================== */
|
||||||
async function goHome() {
|
async function goHome() {
|
||||||
if (!viewer) return;
|
if (!viewer) return;
|
||||||
|
|
||||||
// 確保樓層先回到 1F(會重建 sprites 與隔離)
|
|
||||||
await applyFloor("1F");
|
await applyFloor("1F");
|
||||||
|
|
||||||
// 套回初始快照(相機/隔離/等),第三參數 immediate=true 立即更新
|
|
||||||
if (INITIAL_1F_STATE) {
|
if (INITIAL_1F_STATE) {
|
||||||
try {
|
try {
|
||||||
viewer.restoreState(INITIAL_1F_STATE, undefined, true);
|
viewer.restoreState(INITIAL_1F_STATE, undefined, true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 萬一還原失敗就退回 fitToView 方案
|
|
||||||
const id = FLOOR_DBIDS["1F"];
|
const id = FLOOR_DBIDS["1F"];
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
const ids = collectLeafsUnder(viewer.model, id);
|
const ids = collectLeafsUnder(viewer.model, id);
|
||||||
@ -1070,8 +1109,6 @@ async function goHome() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 依舊重算 popover 與區塊上色(避免狀態還原與你自訂邏輯不同步)
|
|
||||||
rebuildLabelsAfterNextRender();
|
rebuildLabelsAfterNextRender();
|
||||||
rebuildZoneThemingForCurrentFloor();
|
rebuildZoneThemingForCurrentFloor();
|
||||||
}
|
}
|
||||||
@ -1106,11 +1143,10 @@ function publishForgeSnapshot() {
|
|||||||
window.dispatchEvent(new CustomEvent("forge:snapshot", { detail: rows }));
|
window.dispatchEvent(new CustomEvent("forge:snapshot", { detail: rows }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 產生指定樓層的快照(不改視圖)
|
/** ===================== 產生指定樓層的快照(不改視圖) ===================== */
|
||||||
async function snapshotRowsForFloor(floorKey) {
|
async function snapshotRowsForFloor(floorKey) {
|
||||||
const floorRootId = FLOOR_DBIDS[floorKey];
|
const floorRootId = FLOOR_DBIDS[floorKey];
|
||||||
if (!viewer || !viewer.model || floorRootId == null) return [];
|
if (!viewer || !viewer.model || floorRootId == null) return [];
|
||||||
|
|
||||||
const ROOMS = collectRoomsForFloor(viewer.model, floorKey, floorRootId);
|
const ROOMS = collectRoomsForFloor(viewer.model, floorKey, floorRootId);
|
||||||
const BED_GROUP_KEYWORD = "【崇恩護家】床位";
|
const BED_GROUP_KEYWORD = "【崇恩護家】床位";
|
||||||
const bedGroupIds = collectDbIdsByNameUnder(
|
const bedGroupIds = collectDbIdsByNameUnder(
|
||||||
@ -1118,7 +1154,6 @@ async function snapshotRowsForFloor(floorKey) {
|
|||||||
floorRootId,
|
floorRootId,
|
||||||
BED_GROUP_KEYWORD
|
BED_GROUP_KEYWORD
|
||||||
);
|
);
|
||||||
|
|
||||||
let bedNodes = [];
|
let bedNodes = [];
|
||||||
getDirectChildren(viewer.model, floorRootId); // 讓樹穩定
|
getDirectChildren(viewer.model, floorRootId); // 讓樹穩定
|
||||||
bedGroupIds.forEach((groupId) => {
|
bedGroupIds.forEach((groupId) => {
|
||||||
@ -1137,7 +1172,6 @@ async function snapshotRowsForFloor(floorKey) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
bedNodes = Array.from(new Set(bedNodes));
|
bedNodes = Array.from(new Set(bedNodes));
|
||||||
|
|
||||||
const THREE = getThree();
|
const THREE = getThree();
|
||||||
const floorBox = getNodeWorldBounds(viewer.model, floorRootId);
|
const floorBox = getNodeWorldBounds(viewer.model, floorRootId);
|
||||||
const sortedBeds = bedNodes
|
const sortedBeds = bedNodes
|
||||||
@ -1149,7 +1183,6 @@ async function snapshotRowsForFloor(floorKey) {
|
|||||||
return c.z >= floorBox.min.z - 0.5 && c.z <= floorBox.max.z + 0.5;
|
return c.z >= floorBox.min.z - 0.5 && c.z <= floorBox.max.z + 0.5;
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.id - b.id);
|
.sort((a, b) => a.id - b.id);
|
||||||
|
|
||||||
const plan = NON_OCC_BY_FLOOR[floorKey] || {
|
const plan = NON_OCC_BY_FLOOR[floorKey] || {
|
||||||
vacant: 0,
|
vacant: 0,
|
||||||
hospitalized: 0,
|
hospitalized: 0,
|
||||||
@ -1165,7 +1198,6 @@ async function snapshotRowsForFloor(floorKey) {
|
|||||||
const leaveIds = new Set(
|
const leaveIds = new Set(
|
||||||
nonOccIds.slice(plan.vacant + plan.hospitalized, nonOccCount)
|
nonOccIds.slice(plan.vacant + plan.hospitalized, nonOccCount)
|
||||||
);
|
);
|
||||||
|
|
||||||
const rows = [];
|
const rows = [];
|
||||||
let globalIndex = 0;
|
let globalIndex = 0;
|
||||||
for (const { id: bedId, box: bedBox } of sortedBeds) {
|
for (const { id: bedId, box: bedBox } of sortedBeds) {
|
||||||
@ -1174,16 +1206,13 @@ async function snapshotRowsForFloor(floorKey) {
|
|||||||
const roomGender = roomMatch?.gender ?? fallbackGen;
|
const roomGender = roomMatch?.gender ?? fallbackGen;
|
||||||
const roomLabel = roomMatch?.name ?? "?";
|
const roomLabel = roomMatch?.name ?? "?";
|
||||||
const roomCode = roomMatch?.code3 ?? roomCodeOf(roomLabel);
|
const roomCode = roomMatch?.code3 ?? roomCodeOf(roomLabel);
|
||||||
|
|
||||||
let status = "occupied";
|
let status = "occupied";
|
||||||
if (vacantIds.has(bedId)) status = "vacant";
|
if (vacantIds.has(bedId)) status = "vacant";
|
||||||
else if (hospitalizedIds.has(bedId)) status = "hospitalized";
|
else if (hospitalizedIds.has(bedId)) status = "hospitalized";
|
||||||
else if (leaveIds.has(bedId)) status = "leave";
|
else if (leaveIds.has(bedId)) status = "leave";
|
||||||
|
|
||||||
const bedNodeName =
|
const bedNodeName =
|
||||||
viewer.model.getInstanceTree()?.getNodeName(bedId) || "";
|
viewer.model.getInstanceTree()?.getNodeName(bedId) || "";
|
||||||
const bedKey = bedKeyFromNodeName(bedNodeName, roomCode) || "?";
|
const bedKey = bedKeyFromNodeName(bedNodeName, roomCode) || "?";
|
||||||
|
|
||||||
const base = ensureResidentFor(bedKey, roomGender) || {};
|
const base = ensureResidentFor(bedKey, roomGender) || {};
|
||||||
const resident =
|
const resident =
|
||||||
status === "vacant"
|
status === "vacant"
|
||||||
@ -1203,9 +1232,7 @@ async function snapshotRowsForFloor(floorKey) {
|
|||||||
: status === "leave"
|
: status === "leave"
|
||||||
? { status: "leave", specialEvent: "請假中", ...base }
|
? { status: "leave", specialEvent: "請假中", ...base }
|
||||||
: { status: "occupied", specialEvent: "-", ...base };
|
: { status: "occupied", specialEvent: "-", ...base };
|
||||||
|
|
||||||
const zone = roomCode ? getZoneForRoom(floorKey, roomCode) ?? null : null;
|
const zone = roomCode ? getZoneForRoom(floorKey, roomCode) ?? null : null;
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
bed: bedKey,
|
bed: bedKey,
|
||||||
status: resident.status,
|
status: resident.status,
|
||||||
@ -1225,7 +1252,7 @@ async function snapshotRowsForFloor(floorKey) {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露到 global,供 operation 調用
|
// 暴露 API
|
||||||
window.FORGE_API = {
|
window.FORGE_API = {
|
||||||
getSnapshotForFloor: snapshotRowsForFloor,
|
getSnapshotForFloor: snapshotRowsForFloor,
|
||||||
getActiveFloor: () => activeFloor.value,
|
getActiveFloor: () => activeFloor.value,
|
||||||
@ -1234,36 +1261,23 @@ window.FORGE_API = {
|
|||||||
await applyFloor(fk);
|
await applyFloor(fk);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// === 新增:聚焦指定床位並顯示 popover ===
|
|
||||||
window.FORGE_API.focusBed = async function focusBed(bedKey, opts = {}) {
|
window.FORGE_API.focusBed = async function focusBed(bedKey, opts = {}) {
|
||||||
try {
|
try {
|
||||||
if (!viewer || !__staticDevices?.length) return false;
|
if (!viewer || !__staticDevices?.length) return false;
|
||||||
|
|
||||||
const bed = String(bedKey || "").trim();
|
const bed = String(bedKey || "").trim();
|
||||||
if (!bed) return false;
|
if (!bed) return false;
|
||||||
|
|
||||||
// 確認樓層,必要時切換(1xx→1F,2xx→2F)
|
|
||||||
const targetFloor = bed.charAt(0) === "2" ? "2F" : "1F";
|
const targetFloor = bed.charAt(0) === "2" ? "2F" : "1F";
|
||||||
if (activeFloor.value !== targetFloor) {
|
if (activeFloor.value !== targetFloor) {
|
||||||
await applyFloor(targetFloor);
|
await applyFloor(targetFloor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 找到對應 sprite
|
|
||||||
const dev = __staticDevices.find(
|
const dev = __staticDevices.find(
|
||||||
(d) => d.bedKey === bed || d.name?.includes(bed)
|
(d) => d.bedKey === bed || d.name?.includes(bed)
|
||||||
);
|
);
|
||||||
if (!dev) return false;
|
if (!dev) return false;
|
||||||
|
selectedInfo.value = dev.status;
|
||||||
// 讓 popover 篩選顯示到它(selectedInfo 要對上該 sprite 的 status)
|
|
||||||
selectedInfo.value = dev.status; // 'occupied' | 'vacant' | 'hospitalized' | 'leave'
|
|
||||||
soloSpriteId.value = dev.spriteDbId;
|
soloSpriteId.value = dev.spriteDbId;
|
||||||
bringToFrontById(dev.spriteDbId);
|
bringToFrontById(dev.spriteDbId);
|
||||||
|
|
||||||
// 重新算 popover 位置
|
|
||||||
rebuildLabelsAfterNextRender();
|
rebuildLabelsAfterNextRender();
|
||||||
|
|
||||||
// 視角帶到該 sprite(可用 opts.fit=false 關掉)
|
|
||||||
if (opts.fit !== false) {
|
if (opts.fit !== false) {
|
||||||
await cardfitToView({
|
await cardfitToView({
|
||||||
forge_dbid: dev.forge_dbid,
|
forge_dbid: dev.forge_dbid,
|
||||||
@ -1282,7 +1296,7 @@ window.FORGE_API.focusBed = async function focusBed(bedKey, opts = {}) {
|
|||||||
function spriteColorBy(status, gender) {
|
function spriteColorBy(status, gender) {
|
||||||
if (status === "occupied") return gender === "男" ? BRAND_GREEN : BRAND_RED;
|
if (status === "occupied") return gender === "男" ? BRAND_GREEN : BRAND_RED;
|
||||||
if (status === "vacant") return BRAND_GRAY;
|
if (status === "vacant") return BRAND_GRAY;
|
||||||
return BRAND_WHITE; // 住院/請假
|
return BRAND_WHITE;
|
||||||
}
|
}
|
||||||
function labelDotColor(L) {
|
function labelDotColor(L) {
|
||||||
const s = L?.data?.status;
|
const s = L?.data?.status;
|
||||||
@ -1321,7 +1335,9 @@ async function showLevel(levelKey) {
|
|||||||
if (levelDbId == null) {
|
if (levelDbId == null) {
|
||||||
viewer.showAll();
|
viewer.showAll();
|
||||||
viewer.fitToView();
|
viewer.fitToView();
|
||||||
viewer.impl.invalidate(true);
|
try {
|
||||||
|
viewer.impl.invalidate(true);
|
||||||
|
} catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ids = collectLeafsUnder(viewer.model, levelDbId);
|
const ids = collectLeafsUnder(viewer.model, levelDbId);
|
||||||
@ -1335,7 +1351,9 @@ async function showLevel(levelKey) {
|
|||||||
viewer.fitToView?.(ids, viewer.model);
|
viewer.fitToView?.(ids, viewer.model);
|
||||||
}
|
}
|
||||||
viewer.setGhosting(false);
|
viewer.setGhosting(false);
|
||||||
viewer.impl.invalidate(true);
|
try {
|
||||||
|
viewer.impl.invalidate(true);
|
||||||
|
} catch {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const all = new Set(allLeafIds(viewer.model));
|
const all = new Set(allLeafIds(viewer.model));
|
||||||
ids.forEach((id) => all.delete(id));
|
ids.forEach((id) => all.delete(id));
|
||||||
@ -1344,7 +1362,9 @@ async function showLevel(levelKey) {
|
|||||||
toHide.forEach((id) => viewer.hide(id));
|
toHide.forEach((id) => viewer.hide(id));
|
||||||
viewer.fitToView?.(ids, viewer.model);
|
viewer.fitToView?.(ids, viewer.model);
|
||||||
viewer.setGhosting(false);
|
viewer.setGhosting(false);
|
||||||
viewer.impl.invalidate(true);
|
try {
|
||||||
|
viewer.impl.invalidate(true);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1363,11 +1383,7 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
await createSprites();
|
await createSprites();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 收集『房』資料(決定性別 & 分區)
|
|
||||||
const ROOMS = collectRoomsForFloor(viewer.model, floorKey, floorRootId);
|
const ROOMS = collectRoomsForFloor(viewer.model, floorKey, floorRootId);
|
||||||
|
|
||||||
// 2) 找『【崇恩護家】床位』群組 → 展開每一張床
|
|
||||||
const BED_GROUP_KEYWORD = "【崇恩護家】床位";
|
const BED_GROUP_KEYWORD = "【崇恩護家】床位";
|
||||||
const bedGroupIds = collectDbIdsByNameUnder(
|
const bedGroupIds = collectDbIdsByNameUnder(
|
||||||
viewer.model,
|
viewer.model,
|
||||||
@ -1390,8 +1406,6 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
bedNodes = Array.from(new Set(bedNodes));
|
bedNodes = Array.from(new Set(bedNodes));
|
||||||
|
|
||||||
// 3) 只留本層高度範圍 & 穩定排序
|
|
||||||
const floorBox = getNodeWorldBounds(viewer.model, floorRootId);
|
const floorBox = getNodeWorldBounds(viewer.model, floorRootId);
|
||||||
const sortedBeds = bedNodes
|
const sortedBeds = bedNodes
|
||||||
.map((id) => ({ id, box: getNodeWorldBounds(viewer.model, id) }))
|
.map((id) => ({ id, box: getNodeWorldBounds(viewer.model, id) }))
|
||||||
@ -1402,8 +1416,6 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
return c.z >= floorBox.min.z - 0.5 && c.z <= floorBox.max.z + 0.5;
|
return c.z >= floorBox.min.z - 0.5 && c.z <= floorBox.max.z + 0.5;
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.id - b.id);
|
.sort((a, b) => a.id - b.id);
|
||||||
|
|
||||||
// 4) 決定本層非佔床名單:vacant/hospitalized/leave
|
|
||||||
const plan = NON_OCC_BY_FLOOR[floorKey] || {
|
const plan = NON_OCC_BY_FLOOR[floorKey] || {
|
||||||
vacant: 0,
|
vacant: 0,
|
||||||
hospitalized: 0,
|
hospitalized: 0,
|
||||||
@ -1419,8 +1431,6 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
const leaveIds = new Set(
|
const leaveIds = new Set(
|
||||||
nonOccIds.slice(plan.vacant + plan.hospitalized, nonOccCount)
|
nonOccIds.slice(plan.vacant + plan.hospitalized, nonOccCount)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5) 建立 sprites(以 bedKey + ensureResidentFor 寫死對應)
|
|
||||||
__staticDevices.length = 0;
|
__staticDevices.length = 0;
|
||||||
let globalIndex = 0;
|
let globalIndex = 0;
|
||||||
for (const { id: bedId, box: bedBox } of sortedBeds) {
|
for (const { id: bedId, box: bedBox } of sortedBeds) {
|
||||||
@ -1428,26 +1438,22 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
bedBox.getCenter(c);
|
bedBox.getCenter(c);
|
||||||
const lift = Math.max(0.25, (bedBox.max.z - bedBox.min.z) * 0.2);
|
const lift = Math.max(0.25, (bedBox.max.z - bedBox.min.z) * 0.2);
|
||||||
const pos = new THREE.Vector3(c.x, c.y, bedBox.max.z + lift);
|
const pos = new THREE.Vector3(c.x, c.y, bedBox.max.z + lift);
|
||||||
|
|
||||||
const roomMatch = resolveRoomByBox(ROOMS, bedBox);
|
const roomMatch = resolveRoomByBox(ROOMS, bedBox);
|
||||||
const fallbackGen = globalIndex % 2 === 0 ? "男" : "女";
|
const fallbackGen = globalIndex % 2 === 0 ? "男" : "女";
|
||||||
const roomGender = roomMatch?.gender ?? fallbackGen;
|
const roomGender = roomMatch?.gender ?? fallbackGen;
|
||||||
const roomLabel = roomMatch?.name ?? "?";
|
const roomLabel = roomMatch?.name ?? "?";
|
||||||
const roomCode = roomMatch?.code3 ?? roomCodeOf(roomLabel);
|
const roomCode = roomMatch?.code3 ?? roomCodeOf(roomLabel);
|
||||||
|
|
||||||
let status = "occupied";
|
let status = "occupied";
|
||||||
if (vacantIds.has(bedId)) status = "vacant";
|
if (vacantIds.has(bedId)) status = "vacant";
|
||||||
else if (hospitalizedIds.has(bedId)) status = "hospitalized";
|
else if (hospitalizedIds.has(bedId)) status = "hospitalized";
|
||||||
else if (leaveIds.has(bedId)) status = "leave";
|
else if (leaveIds.has(bedId)) status = "leave";
|
||||||
|
|
||||||
const bedNodeName =
|
const bedNodeName =
|
||||||
viewer.model.getInstanceTree()?.getNodeName(bedId) || "";
|
viewer.model.getInstanceTree()?.getNodeName(bedId) || "";
|
||||||
const m = String(bedNodeName).match(/\b(\d{3})(?:-(\d))?\b/);
|
const m = String(bedNodeName).match(/\b(\d{3})(?:-(\d))?\b/);
|
||||||
const bedKey = m ? (m[2] ? `${m[1]}-${m[2]}` : m[1]) : roomCode || "?";
|
const bedKey = m ? (m[2] ? `${m[1]}-${m[2]}` : m[1]) : roomCode || "?";
|
||||||
|
|
||||||
const base = ensureResidentFor(bedKey, roomGender) || {};
|
const base = ensureResidentFor(bedKey, roomGender) || {};
|
||||||
let resident;
|
let resident;
|
||||||
if (status === "vacant") {
|
if (status === "vacant")
|
||||||
resident = {
|
resident = {
|
||||||
status: "vacant",
|
status: "vacant",
|
||||||
...base,
|
...base,
|
||||||
@ -1459,18 +1465,14 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
medicationStatus: "-",
|
medicationStatus: "-",
|
||||||
note: "-",
|
note: "-",
|
||||||
};
|
};
|
||||||
} else if (status === "hospitalized") {
|
else if (status === "hospitalized")
|
||||||
resident = { status: "hospitalized", specialEvent: "住院中", ...base };
|
resident = { status: "hospitalized", specialEvent: "住院中", ...base };
|
||||||
} else if (status === "leave") {
|
else if (status === "leave")
|
||||||
resident = { status: "leave", specialEvent: "請假中", ...base };
|
resident = { status: "leave", specialEvent: "請假中", ...base };
|
||||||
} else {
|
else resident = { status: "occupied", specialEvent: "-", ...base };
|
||||||
resident = { status: "occupied", specialEvent: "-", ...base };
|
|
||||||
}
|
|
||||||
|
|
||||||
const zone = roomCode ? getZoneForRoom(floorKey, roomCode) ?? null : null;
|
const zone = roomCode ? getZoneForRoom(floorKey, roomCode) ?? null : null;
|
||||||
const color = spriteColorBy(resident.status, roomGender);
|
const color = spriteColorBy(resident.status, roomGender);
|
||||||
const spriteDbId = (floorKey === "1F" ? 91100 : 92100) + globalIndex++;
|
const spriteDbId = (floorKey === "1F" ? 91100 : 92100) + globalIndex++;
|
||||||
|
|
||||||
__staticDevices.push({
|
__staticDevices.push({
|
||||||
name: `床號 ${bedKey}`,
|
name: `床號 ${bedKey}`,
|
||||||
forge_dbid: floorRootId,
|
forge_dbid: floorRootId,
|
||||||
@ -1492,7 +1494,6 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
zone,
|
zone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await createSprites();
|
await createSprites();
|
||||||
publishForgeSnapshot();
|
publishForgeSnapshot();
|
||||||
}
|
}
|
||||||
@ -1589,7 +1590,6 @@ const filteredLabels = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const isVacantWithoutSpecial = (L) => L?.data?.status === "vacant";
|
const isVacantWithoutSpecial = (L) => L?.data?.status === "vacant";
|
||||||
|
|
||||||
const emit = defineEmits(["change"]);
|
const emit = defineEmits(["change"]);
|
||||||
const selectInfo = (opt) => {
|
const selectInfo = (opt) => {
|
||||||
soloSpriteId.value = null;
|
soloSpriteId.value = null;
|
||||||
@ -1597,7 +1597,6 @@ const selectInfo = (opt) => {
|
|||||||
infoOpen.value = false;
|
infoOpen.value = false;
|
||||||
emit("change", opt.value);
|
emit("change", opt.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const infoTriggerRef = ref(null);
|
const infoTriggerRef = ref(null);
|
||||||
const infoPanelRef = ref(null);
|
const infoPanelRef = ref(null);
|
||||||
const onClickOutsideInfo = (e) => {
|
const onClickOutsideInfo = (e) => {
|
||||||
@ -1615,10 +1614,14 @@ const onKeydownInfo = (e) => {
|
|||||||
async function applyFloor(next) {
|
async function applyFloor(next) {
|
||||||
soloSpriteId.value = null;
|
soloSpriteId.value = null;
|
||||||
activeFloor.value = next;
|
activeFloor.value = next;
|
||||||
localStorage.setItem("uark-floor", next);
|
try {
|
||||||
|
localStorage.setItem("uark-floor", next);
|
||||||
|
} catch {}
|
||||||
await showLevel(next);
|
await showLevel(next);
|
||||||
await rebuildSpritesForFloor(next);
|
await rebuildSpritesForFloor(next);
|
||||||
viewer.impl.invalidate(true);
|
try {
|
||||||
|
viewer.impl.invalidate(true);
|
||||||
|
} catch {}
|
||||||
rebuildLabelsAfterNextRender();
|
rebuildLabelsAfterNextRender();
|
||||||
rebuildZoneThemingForCurrentFloor();
|
rebuildZoneThemingForCurrentFloor();
|
||||||
}
|
}
|
||||||
@ -1633,7 +1636,6 @@ onMounted(async () => {
|
|||||||
(function migrateResidentStore() {
|
(function migrateResidentStore() {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const [k, v] of RESIDENT_BY_BED.entries()) {
|
for (const [k, v] of RESIDENT_BY_BED.entries()) {
|
||||||
// 只清理舊備援資料或 startTime 落在 2023/01/15 的
|
|
||||||
if (!v || v.startTime === "2023/01/15") {
|
if (!v || v.startTime === "2023/01/15") {
|
||||||
const fixed = RESIDENTS_BY_BED?.[k];
|
const fixed = RESIDENTS_BY_BED?.[k];
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
@ -1649,7 +1651,6 @@ onMounted(async () => {
|
|||||||
note: fixed.note,
|
note: fixed.note,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 沒有寫死表就用新的 seededStartTime
|
|
||||||
const g = v?.residentsSex || "男";
|
const g = v?.residentsSex || "男";
|
||||||
RESIDENT_BY_BED.set(k, {
|
RESIDENT_BY_BED.set(k, {
|
||||||
...v,
|
...v,
|
||||||
@ -1662,10 +1663,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
if (changed) saveResidentStore(RESIDENT_BY_BED);
|
if (changed) saveResidentStore(RESIDENT_BY_BED);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
document.addEventListener("click", onClickOutsideInfo);
|
document.addEventListener("click", onClickOutsideInfo);
|
||||||
document.addEventListener("keydown", onKeydownInfo);
|
document.addEventListener("keydown", onKeydownInfo);
|
||||||
activeFloor.value = resolveInitialFloor();
|
activeFloor.value = resolveInitialFloor();
|
||||||
|
await nextTick();
|
||||||
|
await waitForContainerSize(forgeDom.value, { timeout: 4000 });
|
||||||
await initViewer(forgeDom.value);
|
await initViewer(forgeDom.value);
|
||||||
const model = await loadModel(MODEL_SVF_PATH);
|
const model = await loadModel(MODEL_SVF_PATH);
|
||||||
await waitObjectTree(model);
|
await waitObjectTree(model);
|
||||||
@ -1683,9 +1685,20 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
attachCameraEventsForLabels();
|
attachCameraEventsForLabels();
|
||||||
|
// 監聽容器 resize,避免 0x0 導致的 FBO incomplete
|
||||||
|
try {
|
||||||
|
_resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!viewer) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
viewer.resize();
|
||||||
|
viewer.impl.invalidate(true);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
_resizeObserver.observe(forgeDom.value);
|
||||||
|
} catch {}
|
||||||
await applyFloor("1F");
|
await applyFloor("1F");
|
||||||
|
|
||||||
// 等一禎,確保 fitToView/隔離/labels 都完成再抓狀態(更穩定)
|
|
||||||
await new Promise((r) => {
|
await new Promise((r) => {
|
||||||
const once = () => {
|
const once = () => {
|
||||||
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
||||||
@ -1693,9 +1706,7 @@ onMounted(async () => {
|
|||||||
};
|
};
|
||||||
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
||||||
});
|
});
|
||||||
|
INITIAL_1F_STATE = viewer.getState();
|
||||||
INITIAL_1F_STATE = viewer.getState(); // 記錄整體 viewer 狀態做為 Home
|
|
||||||
|
|
||||||
viewerReady.value = true;
|
viewerReady.value = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1711,7 +1722,7 @@ watch(
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
try {
|
try {
|
||||||
clearOurTheming(viewer.model);
|
clearOurTheming(viewer?.model);
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
unbindSpriteClick?.();
|
unbindSpriteClick?.();
|
||||||
@ -1720,14 +1731,24 @@ onUnmounted(() => {
|
|||||||
try {
|
try {
|
||||||
clearSprites();
|
clearSprites();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
_resizeObserver?.disconnect();
|
||||||
|
_resizeObserver = null;
|
||||||
|
} catch {}
|
||||||
if (viewer) {
|
if (viewer) {
|
||||||
try {
|
try {
|
||||||
const ext = viewer.getExtension?.("Autodesk.DataVisualization");
|
const ext = viewer.getExtension?.("Autodesk.DataVisualization");
|
||||||
ext?.removeAllViewables?.();
|
ext?.removeAllViewables?.();
|
||||||
viewer.unloadExtension?.("Autodesk.DataVisualization");
|
viewer.unloadExtension?.("Autodesk.DataVisualization");
|
||||||
} catch {}
|
} catch {}
|
||||||
viewer.tearDown?.();
|
try {
|
||||||
viewer.finish?.();
|
viewer.tearDown?.();
|
||||||
|
viewer.finish?.();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
viewer?.impl?.purgeVertexBufferPool?.();
|
||||||
|
viewer?.impl?.purgeCachedMaterials?.();
|
||||||
|
} catch {}
|
||||||
viewer = null;
|
viewer = null;
|
||||||
}
|
}
|
||||||
document.removeEventListener("click", onClickOutsideInfo);
|
document.removeEventListener("click", onClickOutsideInfo);
|
||||||
|
@ -145,25 +145,32 @@
|
|||||||
"
|
"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- 標題列 + Tabs -->
|
<!-- 標題列 + Tabs(置中) -->
|
||||||
<div class="flex items-center justify-between mb-4 gap-3">
|
<div class="relative flex items-center justify-between mb-4 gap-3">
|
||||||
<div class="flex justify-start items-center gap-6">
|
<!-- 左:標題 -->
|
||||||
<h3
|
<h3
|
||||||
:id="modalTitleId"
|
:id="modalTitleId"
|
||||||
class="text-2xl font-semibold tracking-widest"
|
class="text-2xl font-semibold tracking-widest"
|
||||||
>
|
>
|
||||||
{{ titleLabel }}
|
{{ titleLabel }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<Tabs
|
<!-- 中:Tabs 絕對置中 -->
|
||||||
v-model="menuTab"
|
<div
|
||||||
:items="tabItems"
|
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
:showPrefix="false"
|
>
|
||||||
:defaultSelectFirst="true"
|
<div class="pointer-events-auto">
|
||||||
aria-label="菜單篩選"
|
<Tabs
|
||||||
/>
|
v-model="menuTab"
|
||||||
|
:items="tabItems"
|
||||||
|
:showPrefix="false"
|
||||||
|
:defaultSelectFirst="true"
|
||||||
|
aria-label="菜單篩選"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 右:關閉按鈕 -->
|
||||||
<button
|
<button
|
||||||
ref="closeBtnRef"
|
ref="closeBtnRef"
|
||||||
class="px-3 py-1 rounded-md hover:bg-gray-100"
|
class="px-3 py-1 rounded-md hover:bg-gray-100"
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
<section
|
<section
|
||||||
class="relative bg-white/30 rounded-md shadow p-3 min-h-0 lg:h-full flex flex-col overscroll-contain"
|
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="fixed top-[100px] left-1/2 -translate-x-1/2 z-[50]">
|
||||||
<div class="relative pointer-events-none flex justify-center w-full">
|
<div class="relative pointer-events-none flex justify-center w-full">
|
||||||
<Tabs
|
<Tabs
|
||||||
:items="tabItems"
|
:items="tabItems"
|
||||||
@ -24,11 +24,12 @@
|
|||||||
<!-- 地圖本體 -->
|
<!-- 地圖本體 -->
|
||||||
<div
|
<div
|
||||||
ref="mapEl"
|
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"
|
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 contain-layout contain-paint"
|
||||||
></div>
|
></div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* MapPane.vue
|
* MapPane.vue
|
||||||
@ -141,6 +142,29 @@ let map = null;
|
|||||||
let markers = [];
|
let markers = [];
|
||||||
let ro = null; // ResizeObserver 實例
|
let ro = null; // ResizeObserver 實例
|
||||||
let rafId = 0; // rAF id(節流用)
|
let rafId = 0; // rAF id(節流用)
|
||||||
|
let coolId = 0;
|
||||||
|
|
||||||
|
function doInvalidate() {
|
||||||
|
if (map) {
|
||||||
|
map.invalidateSize();
|
||||||
|
refreshAllTooltips();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHostResize() {
|
||||||
|
// 粗暴一點:等 ~48ms 合併多次尺寸變化
|
||||||
|
if (coolId) clearTimeout(coolId);
|
||||||
|
coolId = setTimeout(() => {
|
||||||
|
coolId = 0;
|
||||||
|
// 單幀內再用 rAF,確保樣式已套用
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
doInvalidate();
|
||||||
|
});
|
||||||
|
}, 48);
|
||||||
|
}
|
||||||
|
defineExpose({ invalidate: doInvalidate });
|
||||||
|
|
||||||
// Leaflet icon(本元件作用域,不影響全域)
|
// Leaflet icon(本元件作用域,不影響全域)
|
||||||
const base = import.meta.env.BASE_URL ?? "/";
|
const base = import.meta.env.BASE_URL ?? "/";
|
||||||
@ -348,25 +372,6 @@ function syncTooltips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) 尺寸觀察與 invalidate(暴露給父層)
|
|
||||||
function doInvalidate() {
|
|
||||||
if (map) {
|
|
||||||
map.invalidateSize();
|
|
||||||
refreshAllTooltips();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 讓父層可手動呼叫(通常不需要,但保留)
|
|
||||||
defineExpose({ invalidate: doInvalidate });
|
|
||||||
|
|
||||||
function onHostResize() {
|
|
||||||
// 以 rAF 節流,避免 RO 在 transition 過程觸發過於頻繁
|
|
||||||
if (rafId) return;
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
rafId = 0;
|
|
||||||
doInvalidate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Lifecycle
|
// 7) Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!mapEl.value) return;
|
if (!mapEl.value) return;
|
||||||
@ -405,6 +410,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
maxZoom: 20,
|
maxZoom: 20,
|
||||||
|
updateWhenIdle: true, // 等使用者停止再更新
|
||||||
|
updateWhenZooming: false, // 放大縮小時不要邊動邊更新
|
||||||
|
keepBuffer: 4, // 保留周邊 tile,避免一收一畫
|
||||||
attribution:
|
attribution:
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
@ -437,12 +445,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 初始後多次 invalidate,避免白屏
|
// 初始後多次 invalidate,避免白屏
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const m = map;
|
map?.invalidateSize?.();
|
||||||
m?.invalidateSize?.();
|
refreshAllTooltips();
|
||||||
requestAnimationFrame(() => {
|
|
||||||
m?.invalidateSize?.();
|
|
||||||
refreshAllTooltips();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 互動後更新 tooltip 位置
|
// 互動後更新 tooltip 位置
|
||||||
@ -450,14 +454,6 @@ onMounted(() => {
|
|||||||
map.on("moveend", refreshAllTooltips);
|
map.on("moveend", refreshAllTooltips);
|
||||||
map.on("resize", refreshAllTooltips);
|
map.on("resize", refreshAllTooltips);
|
||||||
|
|
||||||
// 視窗尺寸改變
|
|
||||||
const onResize = () => {
|
|
||||||
map?.invalidateSize?.();
|
|
||||||
refreshAllTooltips();
|
|
||||||
};
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
map.__onResize = onResize;
|
|
||||||
|
|
||||||
// 觀察地圖容器尺寸(包含父層寬度 transition)
|
// 觀察地圖容器尺寸(包含父層寬度 transition)
|
||||||
if (mapEl.value && "ResizeObserver" in window) {
|
if (mapEl.value && "ResizeObserver" in window) {
|
||||||
ro = new ResizeObserver(onHostResize);
|
ro = new ResizeObserver(onHostResize);
|
||||||
@ -466,7 +462,18 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (map?.__onResize) window.removeEventListener("resize", map.__onResize);
|
if (coolId) {
|
||||||
|
clearTimeout(coolId);
|
||||||
|
coolId = 0;
|
||||||
|
}
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = 0;
|
||||||
|
}
|
||||||
|
if (ro) {
|
||||||
|
ro.disconnect();
|
||||||
|
ro = null;
|
||||||
|
}
|
||||||
if (map) {
|
if (map) {
|
||||||
map.off("zoomend", refreshAllTooltips);
|
map.off("zoomend", refreshAllTooltips);
|
||||||
map.off("moveend", refreshAllTooltips);
|
map.off("moveend", refreshAllTooltips);
|
||||||
@ -474,14 +481,6 @@ onBeforeUnmount(() => {
|
|||||||
map.remove();
|
map.remove();
|
||||||
map = null;
|
map = null;
|
||||||
}
|
}
|
||||||
if (ro) {
|
|
||||||
ro.disconnect();
|
|
||||||
ro = null;
|
|
||||||
}
|
|
||||||
if (rafId) {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
rafId = 0;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 8) Watchers(模式切換時重綁 tooltip 並刷新尺寸)
|
// 8) Watchers(模式切換時重綁 tooltip 並刷新尺寸)
|
||||||
|
@ -30,7 +30,7 @@
|
|||||||
></progress>
|
></progress>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="w-full absolute top-1 bottom-2 flex justify-between items-center text-[24px] font-nats pe-5 text-brand-gray group-hover:text-brand-purple-dark left-2"
|
class="w-full absolute top-1 bottom-2 flex justify-between items-center text-[22px] font-nats pe-5 text-brand-gray group-hover:text-brand-purple-dark left-2"
|
||||||
>
|
>
|
||||||
{{ animatedValueLocale }} / {{ totalLocale }}
|
{{ animatedValueLocale }} / {{ totalLocale }}
|
||||||
<span aria-hidden="true">
|
<span aria-hidden="true">
|
||||||
|
@ -100,7 +100,7 @@ const closeCalendar = () => (isCalendarOpen.value = false);
|
|||||||
>{{ currentPage }} / {{ totalPages }}</span
|
>{{ currentPage }} / {{ totalPages }}</span
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-brand-gray disabled:cursor-not-allowed"
|
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
|
||||||
:disabled="currentPage === totalPages"
|
:disabled="currentPage === totalPages"
|
||||||
@click="currentPage = Math.min(totalPages, currentPage + 1)"
|
@click="currentPage = Math.min(totalPages, currentPage + 1)"
|
||||||
>
|
>
|
||||||
@ -111,7 +111,6 @@ const closeCalendar = () => (isCalendarOpen.value = false);
|
|||||||
|
|
||||||
<!-- 行事曆 Modal:Teleport 到 body,避免影響布局 -->
|
<!-- 行事曆 Modal:Teleport 到 body,避免影響布局 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<!-- ✅ 這裡若曾因 <transition> 影響佈局,已用 Teleport 解耦,不會壓縮原區塊 -->
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="isCalendarOpen"
|
v-if="isCalendarOpen"
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 左中:圖表 -->
|
<!-- 左中:圖表 -->
|
||||||
<section class="row-span-4 bg-white/30 backdrop-blur-sm rounded-md shadow min-h-0 flex items-stretch">
|
<section
|
||||||
<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]">
|
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 min-h-[200px] p-8 sm:min-h-[240px] md:min-h-[300px]"
|
||||||
|
>
|
||||||
<div ref="chartEl" class="w-full h-full"></div>
|
<div ref="chartEl" class="w-full h-full"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -21,6 +25,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const chartEl = ref(null);
|
const chartEl = ref(null);
|
||||||
let chart = null;
|
let chart = null;
|
||||||
|
let ro = null; // ResizeObserver
|
||||||
|
|
||||||
// 工具:近 7 天標籤
|
// 工具:近 7 天標籤
|
||||||
function last7DaysLabels() {
|
function last7DaysLabels() {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
// ========= 型別註解 =========
|
||||||
/** @typedef {Object} ResidentFixed
|
/** @typedef {Object} ResidentFixed
|
||||||
* @property {string} name
|
* @property {string} name
|
||||||
* @property {('男'|'女')} sex
|
* @property {('男'|'女')} sex
|
||||||
@ -7,10 +8,9 @@
|
|||||||
* @property {string} medicationStatus
|
* @property {string} medicationStatus
|
||||||
* @property {string} specialEvent
|
* @property {string} specialEvent
|
||||||
* @property {string} note
|
* @property {string} note
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ========== 固定姓名池(A 機構床號 74 → 男/女各 37)==========
|
// ========= 固定姓名池(A 機構床號 74 → 男/女各 37) =========
|
||||||
export const NAME_POOL_MALE = Object.freeze([
|
export const NAME_POOL_MALE = Object.freeze([
|
||||||
"王建中",
|
"王建中",
|
||||||
"張家豪",
|
"張家豪",
|
||||||
@ -91,13 +91,8 @@ export const NAME_POOL_FEMALE = Object.freeze([
|
|||||||
"簡琬淳",
|
"簡琬淳",
|
||||||
]); // 長度必為 37
|
]); // 長度必為 37
|
||||||
|
|
||||||
// ========== 每床固定住民:設定區(請依模型實際床位調整) ==========
|
// ========= 每床固定住民:設定區(請依模型實際床位調整) =========
|
||||||
// 目標:男/女各 37 張床 → 總計 74 床。以下僅為範例配置:
|
// 目標:男/女各 37 張床 → 總計 74 床。
|
||||||
// - 1F 男:101/103/107/109/111/113/115(各 3 床 = 21)
|
|
||||||
// - 2F 男:201/203/205/207/209(各 3 床 = 15)+ 211-1(補 1 床)
|
|
||||||
// - 1F 女:102/105/112/114/116(各 3 床 = 15)
|
|
||||||
// - 2F 女:202/206/208/210/212/216/204(各 3 床 = 21)+ 214-1(補 1 床)
|
|
||||||
|
|
||||||
export const A_FACILITY_BEDS_MALE = Object.freeze([
|
export const A_FACILITY_BEDS_MALE = Object.freeze([
|
||||||
// 1F(男)
|
// 1F(男)
|
||||||
"101-1",
|
"101-1",
|
||||||
@ -182,7 +177,7 @@ export const A_FACILITY_BEDS_FEMALE = Object.freeze([
|
|||||||
"214-1",
|
"214-1",
|
||||||
]); // 37
|
]); // 37
|
||||||
|
|
||||||
// 可選:錯誤防護(開發期)
|
// ========= DEV 警告 =========
|
||||||
const __DEV__ =
|
const __DEV__ =
|
||||||
(typeof import.meta !== "undefined" &&
|
(typeof import.meta !== "undefined" &&
|
||||||
import.meta.env &&
|
import.meta.env &&
|
||||||
@ -213,7 +208,7 @@ if (__DEV__) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 多樣化健康狀況詞庫(循環取用) ==========
|
// ========= 多樣化健康狀況詞庫(循環取用) =========
|
||||||
export const HEALTH_STATUS_POOL = Object.freeze([
|
export const HEALTH_STATUS_POOL = Object.freeze([
|
||||||
"一般",
|
"一般",
|
||||||
"慢性病監測",
|
"慢性病監測",
|
||||||
@ -227,14 +222,13 @@ export const HEALTH_STATUS_POOL = Object.freeze([
|
|||||||
"營養補充",
|
"營養補充",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ========== 穩定年齡產生器(非隨機、可預測) ==========
|
// ========= 穩定年齡產生器(非隨機、可預測) =========
|
||||||
export function ageFromBedKey(key, base = 66) {
|
export function ageFromBedKey(key, base = 66) {
|
||||||
const n = parseInt(String(key).replace("-", ""), 10) || 0;
|
const n = parseInt(String(key).replace("-", ""), 10) || 0;
|
||||||
return base + (n % 30); // 66..95
|
return base + (n % 30); // 66..95
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 建立固定住民表 ==========
|
// ========= 小工具:日期 =========
|
||||||
// 工具:格式化成 YYYY/MM/DD
|
|
||||||
function fmtYMD(d) {
|
function fmtYMD(d) {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
@ -242,15 +236,14 @@ function fmtYMD(d) {
|
|||||||
return `${y}/${m}/${day}`;
|
return `${y}/${m}/${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工具:依床位與索引產生「穩定且分散」的入住日期
|
// 依床位與索引產生「穩定且分散」的入住日期
|
||||||
// 規則:以 2025/01/01 為起點,往後偏移 0~540 天(約 18 個月)
|
// 規則:以 2025/01/01 為起點,往後偏移 0~540 天(約 18 個月)
|
||||||
// 若超過今天,會往回扣 (n % 60 + 10) 天,確保不會是未來日期
|
// 若超過今天,往回扣 (n % 60 + 10) 天,避免未來日期
|
||||||
function seededStartTime(bedKey, idx) {
|
function seededStartTime(bedKey, idx) {
|
||||||
const n = parseInt(String(bedKey).replace("-", ""), 10) || 0;
|
const n = parseInt(String(bedKey).replace("-", ""), 10) || 0;
|
||||||
const base = new Date(2025, 0, 1); // 2025/01/01
|
const base = new Date(2025, 0, 1); // 2025/01/01
|
||||||
const OFFSET_DAYS = (n * 13 + idx * 17) % 540; // 分散 18 個月內
|
const OFFSET_DAYS = (n * 13 + idx * 17) % 540; // 分散 18 個月內
|
||||||
let d = new Date(base.getTime() + OFFSET_DAYS * 86400000);
|
let d = new Date(base.getTime() + OFFSET_DAYS * 86400000);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
if (d > today) {
|
if (d > today) {
|
||||||
const back = (n % 60) + 10; // 至少退 10 天,最多 ~70 天
|
const back = (n % 60) + 10; // 至少退 10 天,最多 ~70 天
|
||||||
@ -269,7 +262,7 @@ function buildFixedResidents(keys, names, sex) {
|
|||||||
sex,
|
sex,
|
||||||
age: ageFromBedKey(k, sex === "女" ? 65 : 66),
|
age: ageFromBedKey(k, sex === "女" ? 65 : 66),
|
||||||
healthStatus: HEALTH_STATUS_POOL[n % HEALTH_STATUS_POOL.length],
|
healthStatus: HEALTH_STATUS_POOL[n % HEALTH_STATUS_POOL.length],
|
||||||
startTime: seededStartTime(k, i), // ← 用分散演算法產生
|
startTime: seededStartTime(k, i), // 分散演算法產生
|
||||||
medicationStatus: "規律服藥",
|
medicationStatus: "規律服藥",
|
||||||
specialEvent: "-",
|
specialEvent: "-",
|
||||||
note: "-",
|
note: "-",
|
||||||
@ -287,42 +280,44 @@ export const RESIDENTS_BY_BED = Object.freeze({
|
|||||||
...buildFixedResidents(A_FACILITY_BEDS_FEMALE, NAME_POOL_FEMALE, "女"),
|
...buildFixedResidents(A_FACILITY_BEDS_FEMALE, NAME_POOL_FEMALE, "女"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== 總部與 A~F 機構資料(固定對齊 progress/charts)==========
|
// ========= 總部與 A~F 機構資料(對齊 progress/charts 最後一天) =========
|
||||||
export const BASE_FACILITY_DATA = {
|
export const BASE_FACILITY_DATA = {
|
||||||
ALL: {
|
ALL: {
|
||||||
desc: "OO長期照顧集團——在全台各地皆有眾多據點,整合醫護團隊與專業照護人員,提供高齡照護服務總部。",
|
desc: "OO長期照顧集團——在全台各地皆有眾多據點,整合醫護團隊與專業照護人員,提供高齡照護服務總部。",
|
||||||
progress: {
|
progress: {
|
||||||
residents: { current: 1216, total: 3600 },
|
residents: { current: 269, total: 361 },
|
||||||
vacancy: { current: 2384, total: 3600 },
|
vacancy: { current: 58, total: 361 },
|
||||||
hospitalized: { current: 8, total: 50 },
|
hospitalized: { current: 5, total: 89 },
|
||||||
movein: { current: 3, total: 50 },
|
movein: { current: 13, total: 63 },
|
||||||
moveout: { current: 8, total: 50 },
|
moveout: { current: 10, total: 64 },
|
||||||
},
|
},
|
||||||
charts: {
|
charts: {
|
||||||
|
// 住民與空床:b 固定為立案床數(361),a 最後一筆對齊 current
|
||||||
residents: {
|
residents: {
|
||||||
legends: ["現在住民", "全立案床數"],
|
legends: ["現在住民", "全立案床數"],
|
||||||
a: [1188, 1195, 1201, 1206, 1210, 1213, 1216],
|
a: [263, 264, 265, 266, 267, 268, 269],
|
||||||
b: Array(7).fill(3600),
|
b: Array(7).fill(361),
|
||||||
},
|
},
|
||||||
vacancy: {
|
vacancy: {
|
||||||
legends: ["目前空床數", "全立案床數"],
|
legends: ["目前空床數", "全立案床數"],
|
||||||
a: [2412, 2405, 2399, 2394, 2390, 2387, 2384],
|
a: [64, 63, 62, 61, 60, 59, 58],
|
||||||
b: Array(7).fill(3600),
|
b: Array(7).fill(361),
|
||||||
},
|
},
|
||||||
|
// 累積圖:b 最後一筆對齊 total,a 最後一筆對齊 current(解讀為「今日」)
|
||||||
hospitalized: {
|
hospitalized: {
|
||||||
legends: ["今日住院", "當月累積住院"],
|
legends: ["今日住院", "當月累積住院"],
|
||||||
a: [2, 1, 3, 2, 1, 3, 8],
|
a: [0, 1, 1, 2, 1, 2, 5],
|
||||||
b: [8, 14, 21, 27, 33, 42, 50],
|
b: [10, 20, 35, 45, 60, 75, 89],
|
||||||
},
|
},
|
||||||
movein: {
|
movein: {
|
||||||
legends: ["今日入住", "當月累積入住"],
|
legends: ["今日入住", "當月累積入住"],
|
||||||
a: [1, 0, 2, 1, 1, 2, 3],
|
a: [1, 1, 2, 2, 2, 3, 13],
|
||||||
b: [6, 10, 16, 21, 28, 41, 50],
|
b: [10, 20, 30, 40, 50, 55, 63],
|
||||||
},
|
},
|
||||||
moveout: {
|
moveout: {
|
||||||
legends: ["今日結案", "當月累積結案"],
|
legends: ["今日結案", "當月累積結案"],
|
||||||
a: [1, 2, 1, 2, 3, 3, 8],
|
a: [1, 1, 1, 2, 2, 3, 10],
|
||||||
b: [7, 12, 18, 23, 31, 42, 50],
|
b: [8, 15, 25, 35, 45, 55, 64],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diet: {
|
diet: {
|
||||||
@ -343,7 +338,6 @@ export const BASE_FACILITY_DATA = {
|
|||||||
moveout: { current: 3, total: 20 },
|
moveout: { current: 3, total: 20 },
|
||||||
},
|
},
|
||||||
charts: {
|
charts: {
|
||||||
// 圖表最後一天需對齊 progress:住民 64/空床 10
|
|
||||||
residents: {
|
residents: {
|
||||||
legends: ["現在住民", "立案床數"],
|
legends: ["現在住民", "立案床數"],
|
||||||
a: [60, 61, 62, 63, 63, 64, 64],
|
a: [60, 61, 62, 63, 63, 64, 64],
|
||||||
@ -370,8 +364,6 @@ export const BASE_FACILITY_DATA = {
|
|||||||
b: [4, 6, 9, 11, 13, 17, 20],
|
b: [4, 6, 9, 11, 13, 17, 20],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// diet 會在頁面端以 normalize 工具依 residents.current=64 切分(60/40 基準)
|
|
||||||
// 這裡先給一組可讀值以利右側對照
|
|
||||||
diet: {
|
diet: {
|
||||||
meat: { total: 38, normal: 33, soft: 2, tube: 3 },
|
meat: { total: 38, normal: 33, soft: 2, tube: 3 },
|
||||||
veg: { total: 26, normal: 22, soft: 2, tube: 2 },
|
veg: { total: 26, normal: 22, soft: 2, tube: 2 },
|
||||||
@ -419,6 +411,7 @@ export const BASE_FACILITY_DATA = {
|
|||||||
veg: { total: 61, normal: 53, soft: 5, tube: 3 },
|
veg: { total: 61, normal: 53, soft: 5, tube: 3 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
"C 機構": {
|
"C 機構": {
|
||||||
desc: "C 機構強調復健與社會參與,陪伴長者建立日常節奏,Lorem ipsum dolor sit amet consectetur adipisicingelit。",
|
desc: "C 機構強調復健與社會參與,陪伴長者建立日常節奏,Lorem ipsum dolor sit amet consectetur adipisicingelit。",
|
||||||
progress: {
|
progress: {
|
||||||
@ -460,6 +453,7 @@ export const BASE_FACILITY_DATA = {
|
|||||||
veg: { total: 71, normal: 62, soft: 5, tube: 4 },
|
veg: { total: 71, normal: 62, soft: 5, tube: 4 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
"D 機構": {
|
"D 機構": {
|
||||||
desc: "D 機構導入智慧照護設備,提升照護品質與效率,Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro, enim。",
|
desc: "D 機構導入智慧照護設備,提升照護品質與效率,Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro, enim。",
|
||||||
progress: {
|
progress: {
|
||||||
@ -501,6 +495,7 @@ export const BASE_FACILITY_DATA = {
|
|||||||
veg: { total: 51, normal: 44, soft: 3, tube: 5 },
|
veg: { total: 51, normal: 44, soft: 3, tube: 5 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
"E 機構": {
|
"E 機構": {
|
||||||
desc: "E 機構著重個別化照護計畫,維護長者尊嚴與生活品質,Lorem ipsum dolor sit amet, consectetur adipisicing elit。",
|
desc: "E 機構著重個別化照護計畫,維護長者尊嚴與生活品質,Lorem ipsum dolor sit amet, consectetur adipisicing elit。",
|
||||||
progress: {
|
progress: {
|
||||||
@ -542,6 +537,7 @@ export const BASE_FACILITY_DATA = {
|
|||||||
veg: { total: 57, normal: 49, soft: 5, tube: 3 },
|
veg: { total: 57, normal: 49, soft: 5, tube: 3 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
"F 機構": {
|
"F 機構": {
|
||||||
desc: "F 機構以社區融入為核心,串連日照與居家照護資源,Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro, enim。",
|
desc: "F 機構以社區融入為核心,串連日照與居家照護資源,Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro, enim。",
|
||||||
progress: {
|
progress: {
|
||||||
@ -587,14 +583,11 @@ export const BASE_FACILITY_DATA = {
|
|||||||
|
|
||||||
// ====== Nursing 待辦:與 Forge 對齊的示範假資料(用 RESIDENTS_BY_BED 反查姓名) ======
|
// ====== Nursing 待辦:與 Forge 對齊的示範假資料(用 RESIDENTS_BY_BED 反查姓名) ======
|
||||||
/** @typedef {{date:string, bed:string, name:string, type:string, desc:string}} NursingTodoSeed */
|
/** @typedef {{date:string, bed:string, name:string, type:string, desc:string}} NursingTodoSeed */
|
||||||
|
|
||||||
export const NURSING_TODOS_SEED = Object.freeze(
|
export const NURSING_TODOS_SEED = Object.freeze(
|
||||||
(() => {
|
(() => {
|
||||||
const getName = (bed) => RESIDENTS_BY_BED?.[bed]?.name || "-";
|
const getName = (bed) => RESIDENTS_BY_BED?.[bed]?.name || "-";
|
||||||
|
|
||||||
/** @type {NursingTodoSeed[]} */
|
/** @type {NursingTodoSeed[]} */
|
||||||
const seeds = [
|
const seeds = [
|
||||||
// (a) 你指定的三筆,姓名會由 RESIDENTS_BY_BED 對應床位自動帶入,確保與 Forge 一致
|
|
||||||
{
|
{
|
||||||
date: "2025/09/21",
|
date: "2025/09/21",
|
||||||
bed: "201-1",
|
bed: "201-1",
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section id="app" class="flex flex-col min-h-screen">
|
<section id="app" class="flex flex-col min-h-screen overflow-y-hidden">
|
||||||
<NavBar class="fixed" />
|
<NavBar class="fixed" />
|
||||||
<main
|
<main
|
||||||
class="w-full p-4 pt-[80px]"
|
class="w-full p-4 pt-[80px]"
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<section id="app" class="flex flex-col min-h-screen">
|
<section id="app" class="flex flex-col min-h-[1200px]">
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main class="w-full p-4 pt-[80px] overflow-x-hidden">
|
<main class="w-full p-4 pt-[80px] overflow-x-hidden flex-1 min-h-0">
|
||||||
<RouterView class="h-[calc(100vh-72px-40px)]" />
|
<!-- 這裡改成 min-h 而不是 h,避免固定死 -->
|
||||||
|
<RouterView class="block min-h-[calc(100vh-72px-40px)]" />
|
||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
@ -17,7 +17,6 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(createPinia());
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@
|
|||||||
<div class="h-full min-h-0 flex flex-col lg:flex-row gap-2">
|
<div class="h-full min-h-0 flex flex-col lg:flex-row gap-2">
|
||||||
<!-- 中:地圖(flex-basis 做寬度動畫) -->
|
<!-- 中:地圖(flex-basis 做寬度動畫) -->
|
||||||
<div
|
<div
|
||||||
class="relative h-[420px] lg:h-full min-w-0 will-change-[flex-basis] transition-[flex-basis] duration-400 ease-in-out basis-full lg:basis-1/2"
|
class="relative h-[420px] lg:h-full min-w-0 will-change-[flex-basis] transition-[flex-basis] duration-300 ease-out"
|
||||||
:class="isRightCollapsed ? 'lg:basis-full' : 'lg:basis-1/2'"
|
:class="isRightCollapsed ? 'lg:basis-full' : 'lg:basis-1/2'"
|
||||||
>
|
>
|
||||||
<!-- caret 按鈕 -->
|
<!-- caret 按鈕 -->
|
||||||
@ -134,13 +134,12 @@
|
|||||||
<transition name="slide-pane" appear>
|
<transition name="slide-pane" appear>
|
||||||
<aside
|
<aside
|
||||||
v-show="!isRightCollapsed"
|
v-show="!isRightCollapsed"
|
||||||
class="h-full min-h-0 overflow-hidden min-w-0 will-change-[flex-basis,opacity] transition-[flex-basis,opacity] duration-400 ease-in-out basis-full lg:basis-1/2"
|
class="h-full min-h-0 overflow-hidden min-w-0 will-change-[flex-basis,opacity] transition-[flex-basis,opacity] duration-300 ease-out"
|
||||||
:class="
|
:class="
|
||||||
isRightCollapsed
|
isRightCollapsed
|
||||||
? 'lg:basis-0 opacity-0 pointer-events-none'
|
? 'lg:basis-0 opacity-0 pointer-events-none'
|
||||||
: 'lg:basis-1/2 opacity-100 pointer-events-auto'
|
: 'lg:basis-1/2 opacity-100 pointer-events-auto'
|
||||||
"
|
"
|
||||||
:aria-hidden="isRightCollapsed ? 'true' : 'false'"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex-1 min-h-0 overflow-y-auto flex flex-col gap-2 h-full"
|
class="flex-1 min-h-0 overflow-y-auto flex flex-col gap-2 h-full"
|
||||||
|
@ -156,6 +156,10 @@ body,
|
|||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable both-edges;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%; /* 別用 100vw */
|
width: 100%; /* 別用 100vw */
|
||||||
|
@ -1,45 +1,65 @@
|
|||||||
// src/utils/versionGuard.js
|
const VERSION_URL = "/version.json";
|
||||||
const VERSION_URL = '/version.json';
|
const LS_KEY = "__app_version__";
|
||||||
const LS_KEY = '__app_version__';
|
const SESSION_REFRESH_FLAG = "__just_refreshed__";
|
||||||
const SESSION_REFRESH_FLAG = '__just_refreshed__';
|
|
||||||
|
|
||||||
async function fetchRemoteVersion() {
|
async function fetchRemoteVersion() {
|
||||||
const res = await fetch(VERSION_URL, { cache: 'no-store' }); // ★ 關鍵:不快取
|
// 關鍵:no-store,確保抓到最新
|
||||||
if (!res.ok) throw new Error('version.json fetch failed');
|
const res = await fetch(VERSION_URL, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error("version.json fetch failed");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unregisterAllSW() {
|
async function unregisterAllSW() {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!("serviceWorker" in navigator)) return;
|
||||||
const regs = await navigator.serviceWorker.getRegistrations();
|
const regs = await navigator.serviceWorker.getRegistrations();
|
||||||
await Promise.all(regs.map(r => r.unregister()));
|
await Promise.all(regs.map((r) => r.unregister()));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearCacheStorage() {
|
async function clearCacheStorage() {
|
||||||
if (!('caches' in window)) return;
|
if (!("caches" in window)) return;
|
||||||
const keys = await caches.keys();
|
const keys = await caches.keys();
|
||||||
await Promise.all(keys.map(k => caches.delete(k)));
|
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureFreshAssets() {
|
export async function ensureFreshAssets() {
|
||||||
try {
|
try {
|
||||||
|
// 若剛做過強制刷新,這次進來先把旗標清掉,避免反覆觸發
|
||||||
|
if (sessionStorage.getItem(SESSION_REFRESH_FLAG) === "1") {
|
||||||
|
sessionStorage.removeItem(SESSION_REFRESH_FLAG);
|
||||||
|
// 不直接 return,仍然讓它跑一次 fetch 以同步 LS 版本(更保險)
|
||||||
|
}
|
||||||
|
|
||||||
const prev = localStorage.getItem(LS_KEY);
|
const prev = localStorage.getItem(LS_KEY);
|
||||||
const remote = await fetchRemoteVersion();
|
const remote = await fetchRemoteVersion();
|
||||||
const ver = remote.version || remote.buildId || remote.commit;
|
const ver = remote.version || remote.buildId || remote.commit;
|
||||||
if (!ver) return;
|
if (!ver) return;
|
||||||
|
|
||||||
if (prev && prev !== ver) {
|
if (!prev) {
|
||||||
await unregisterAllSW();
|
// 首次載入:寫入版本後直接使用
|
||||||
await clearCacheStorage();
|
localStorage.setItem(LS_KEY, ver);
|
||||||
sessionStorage.setItem(SESSION_REFRESH_FLAG, '1');
|
|
||||||
// 用 replace 避免回上一頁又載到舊 index.html
|
|
||||||
location.replace(location.href.split('#')[0]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!prev) localStorage.setItem(LS_KEY, ver);
|
if (prev !== ver) {
|
||||||
|
// 在重整前,先把「新版本」寫回 localStorage
|
||||||
|
localStorage.setItem(LS_KEY, ver);
|
||||||
|
|
||||||
|
// 清 SW 與 Cache(若有)
|
||||||
|
await unregisterAllSW();
|
||||||
|
await clearCacheStorage();
|
||||||
|
|
||||||
|
// 打一個旗標避免連續重整
|
||||||
|
sessionStorage.setItem(SESSION_REFRESH_FLAG, "1");
|
||||||
|
|
||||||
|
// 用 replace 避免使用者回上一頁又遇到舊頁
|
||||||
|
location.replace(location.href.split("#")[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本相同 → 不做事
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[versionGuard] check failed:', e);
|
// 若抓不到 version.json,避免造成空白頁循環
|
||||||
|
console.warn("[versionGuard] check failed:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user