fix: 修正首頁地圖展開卡頓

This commit is contained in:
MJM_2025_05\polly 2025-09-23 13:17:25 +08:00
parent 5cc0dcbf56
commit 538d00d070
15 changed files with 290 additions and 243 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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;
// 1xx1F2xx2F
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);

View File

@ -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"

View File

@ -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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', '&copy; <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

View File

@ -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">

View File

@ -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);
<!-- 行事曆 ModalTeleport body避免影響布局 --> <!-- 行事曆 ModalTeleport body避免影響布局 -->
<Teleport to="body"> <Teleport to="body">
<!-- 這裡若曾因 <transition> 影響佈局已用 Teleport 解耦不會壓縮原區塊 -->
<transition name="fade"> <transition name="fade">
<div <div
v-if="isCalendarOpen" v-if="isCalendarOpen"

View File

@ -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() {

View File

@ -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 固定為立案床數361a 最後一筆對齊 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 最後一筆對齊 totala 最後一筆對齊 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",

View File

@ -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]"

View File

@ -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>

View File

@ -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");

View File

@ -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"

View File

@ -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 */

View File

@ -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);
} }
} }