feat: 製作 demo 用 modals

This commit is contained in:
MJM_2025_05\polly 2025-08-21 15:31:36 +08:00
parent a6beaae3e3
commit c4d78bd5b6
7 changed files with 955 additions and 443 deletions

21
public/img/hotSpot.svg Normal file
View File

@ -0,0 +1,21 @@
<svg width="164" height="164" viewBox="0 0 164 164" fill="none" xmlns="http://www.w3.org/2000/svg">
<g>
<circle cx="81.7363" cy="81.8212" r="40" stroke-width="60" style="stroke:grey" />
</g>
<g filter="url(#filter0_d)">
<circle cx="81.7363" cy="81.8212" r="20" stroke="white" stroke-width="60"/>
</g>
<defs>
<filter id="filter0_d" x="0.174763" y="0.259602" width="163.123" height="163.123" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="5.61135"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 938 B

View File

@ -38,8 +38,6 @@ const FLOOR_DBIDS = { "9F": 14973, "8F": 14262 };
// ====== / Sprite調 ======
const BRAND_GREEN = "#34D5C8";
const BRAND_RED = "#FF8678";
const SPRITES_PER_FLOOR = 6;
const RED_PER_FLOOR = 2;
// 調 SVF public
const MODEL_SVF_PATH = `/upload/forge/0.svf`;
@ -50,6 +48,37 @@ let viewer = null;
const viewerReady = ref(false);
const activeFloor = ref("9F"); // 9F
// ---- Resident Modal ----
const modalOpen = ref(false);
const modalData = ref(null);
function buildResidentModalData(d) {
const vacant = d?.state === "offnormal" && !d?.special; //
return {
name: vacant ? "-" : d?.residentsName ?? "-",
sex: vacant ? "-" : d?.residentsSex ?? "-",
age: vacant ? "-" : d?.residentsAge ?? "-",
startTime: vacant ? "-" : d?.startTime ?? "-",
healthStatus: vacant ? "-" : d?.healthStatus ?? "一般",
medicationStatus: vacant ? "-" : d?.medicationStatus ?? "規律服藥",
specialEvent: d?.special
? d?.specialEvent ?? "住院中"
: d?.specialEvent ?? "-",
note: vacant ? "-" : d?.note ?? "-",
};
}
function openResidentModal(L, e) {
e?.stopPropagation?.(); //
bringToFrontById(L.id);
modalData.value = buildResidentModalData(L.data);
modalOpen.value = true;
}
function closeResidentModal() {
modalOpen.value = false;
}
// ?floor=8F|9F localStorage
function resolveInitialFloor() {
const q = (route.query?.floor || "").toString().toUpperCase();
@ -132,27 +161,6 @@ function getNodeWorldBounds(model, dbId) {
return box;
}
// 3x2 6
function gridPositions(box, count = 6) {
const THREE = getThree();
const xs = [0.2, 0.5, 0.8];
const ys = [0.3, 0.7];
const res = [];
const min = box.min,
max = box.max;
const w = max.x - min.x,
h = max.y - min.y,
zH = max.z - min.z;
const z = max.z + Math.max(0.2, zH * 0.05);
for (let r = 0; r < ys.length; r++) {
for (let c = 0; c < xs.length; c++) {
if (res.length >= count) break;
res.push(new THREE.Vector3(min.x + xs[c] * w, min.y + ys[r] * h, z));
}
}
return res;
}
// [0~1] 3D
function positionsFromRatios(box, ratios, zLiftRatio = 0.05) {
const THREE = getThree();
@ -242,6 +250,15 @@ async function rebuildSpritesForFloor(floorKey) {
residentsSex: pick(SEX_POOL, spriteDbId + 1),
residentsAge: ageFromSeed(spriteDbId + 2),
startTime: startDateFromSeed(spriteDbId + 3),
avatarUrl: Math.random() > 0.4 ? pick(AVATAR_POOL, spriteDbId + 4) : null,
healthStatus: pick(HEALTH_POOL, spriteDbId + 5),
medicationStatus: pick(MEDICATION_POOL, spriteDbId + 6),
specialEvent:
isRed && i === poses.length - 2
? "住院中"
: pick(SPECIAL_POOL, spriteDbId + 7),
note: pick(NOTE_POOL, spriteDbId + 8),
});
});
@ -296,6 +313,7 @@ function updateLabelPositions() {
}
function attachCameraEventsForLabels() {
camEvtCleanup?.(); //
const onUpdate = () => requestAnimationFrame(updateLabelPositions);
viewer.addEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, onUpdate);
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, onUpdate);
@ -342,8 +360,7 @@ async function onClickFloor(next) {
}
onMounted(async () => {
// 9F localStorage/
activeFloor.value = "9F";
activeFloor.value = resolveInitialFloor();
await initViewer(forgeDom.value);
const model = await loadModel(MODEL_SVF_PATH);
@ -406,7 +423,21 @@ onUnmounted(() => {
}
});
const zones = ["全部", "VIP", "換管", "失能", "失智"];
const zones = ["VIP", "換管", "失能", "失智"];
// Set
const selectedZones = ref(new Set());
//
const toggleZone = (zone) => {
const set = new Set(selectedZones.value);
if (set.has(zone)) set.delete(zone);
else set.add(zone);
selectedZones.value = set;
};
const isZoneSelected = (zone) => selectedZones.value.has(zone);
const activeZone = ref("全部");
const onClickZone = (zone) => {
activeZone.value = zone;
@ -455,6 +486,19 @@ const NAME_POOL = [
"郭怡君",
"洪嘉文",
];
const AVATAR_POOL = [
"/img/avatars/m1.png",
"/img/avatars/m2.png",
"/img/avatars/f1.png",
"/img/avatars/f2.png",
]; // fallback
const HEALTH_POOL = ["一般", "良好", "需觀察", "虛弱"];
const MEDICATION_POOL = ["規律服藥", "偶爾忘記", "暫停服藥", "需家屬協助"];
const SPECIAL_POOL = ["住院中", "跌倒觀察", "復健中", "門診追蹤"];
const NOTE_POOL = ["-", "對花生過敏", "家屬每週三探視", "夜間需加護理巡房"];
const SEX_POOL = ["男", "女"];
function seededRand(seed) {
const s = Math.sin(seed * 9301 + 49297) * 233280;
@ -475,37 +519,51 @@ function startDateFromSeed(seed) {
return `${d.getFullYear()}/${mm}/${dd}`;
}
//
//
const infoOptions = [
{ label: "無顯示", value: "none" },
{ label: "有住民", value: "occupied" },
{ label: "空床", value: "vacant" },
{ label: "住院中", value: "hospitalized" },
{ label: "請假中", value: "leave" }, //
];
const filteredLabels = computed(() => {
//
//
if (soloSpriteId.value != null) {
if (selectedInfo.value === "none" || selectedInfo.value === "leave")
return [];
return labels.value.filter((L) => L.id === soloSpriteId.value);
}
// ---- infoOptions + ----
const info = selectedInfo.value; // none / occupied / vacant / hospitalized
const zone = activeZone.value; // / VIP / / / zone
// none / leave =
if (selectedInfo.value === "none" || selectedInfo.value === "leave") {
return [];
}
const info = selectedInfo.value; // 'occupied' | 'vacant' | 'hospitalized'
const selectedSet = selectedZones.value; // Set
return labels.value.filter((L) => {
// "A ...", L.data.zone `zone === '' || L.data.zone === zone`
const inZone = zone === "全部" || L.data.name?.startsWith(`${zone}`);
// ===== =====
//
//
const inZone =
selectedSet.size === 0
? true
: L.data?.zone
? selectedSet.has(L.data.zone)
: true;
// ===== 調=====
const isRed = L.data.state === "offnormal";
const isHosp = isRed && !!L.data.special; // + special
const isGreen = !isRed && !L.data.special; //
const isGreen = !isRed && !L.data.special; //
let stateOk = true;
if (info === "none") stateOk = false; // 2
if (info === "occupied") stateOk = isGreen; //
if (info === "vacant") stateOk = isRed; // special
if (info === "hospitalized") stateOk = isHosp; // +special
if (info === "occupied") stateOk = isGreen;
if (info === "vacant") stateOk = isRed; // =
if (info === "hospitalized") stateOk = isHosp;
return inZone && stateOk;
});
@ -588,8 +646,21 @@ onUnmounted(() => {
@click="bringToFrontById(L.id)"
>
<div
class="pointer-events-auto bg-white/95 border rounded-md shadow px-2 py-1 text-xs"
class="pointer-events-auto relative bg-white/95 border border-gray-300 rounded-lg shadow px-3 py-2 text-sm cursor-pointer"
style="will-change: transform"
@click.stop="openResidentModal(L, $event)"
role="button"
tabindex="0"
@keydown.enter.prevent.stop="openResidentModal(L, $event)"
@keydown.space.prevent.stop="openResidentModal(L, $event)"
aria-label="查看 {{ L.data.name }} 詳細資訊"
>
<!-- 箭頭下方置中指向 sprite -->
<!-- 外層邊框色 -->
<span
class="absolute left-1/2 -bottom-2 -translate-x-1/2 w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-white"
aria-hidden="true"
></span>
<ul class="list-none">
<!-- 第一行床號永遠顯示 -->
<li class="flex justify-between items-center gap-1">
@ -623,8 +694,8 @@ onUnmounted(() => {
<!-- 床號永遠顯示 -->
<span class="align-middle">{{ L.data.name }}</span>
</div>
<div class="text-gray-400 hover:text-gray-600">
<!-- 眼睛 icon -->
<div class="text-gray-400" title="查看詳細">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -687,96 +758,88 @@ onUnmounted(() => {
</button>
</div>
<!-- 過濾 popover 顯示什麼狀態-->
<div
class="absolute top-4 right-4 text-sm flex justify-center items-center gap-2 z-50"
>
<div class="relative">
<div
ref="infoTriggerRef"
class="btn text-brand-purple-dark bg-brand-purple-light hover:opacity-90 shadow-md border-none rounded-full px-6 tracking-widest cursor-pointer"
role="button"
tabindex="0"
aria-haspopup="true"
:aria-expanded="infoOpen ? 'true' : 'false'"
@click="toggleInfo"
@keydown.enter.prevent="toggleInfo"
@keydown.space.prevent="toggleInfo"
>
{{ infoDisplay }}
<span class="ml-2 inline-block align-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 512 512"
>
<path
fill="currentColor"
d="m98 190.06l139.78 163.12a24 24 0 0 0 36.44 0L414 190.06c13.34-15.57 2.28-39.62-18.22-39.62h-279.6c-20.5 0-31.56 24.05-18.18 39.62"
/>
</svg>
</span>
</div>
<!-- 下拉選單使用 <ul> <li> -->
<div
v-show="infoOpen"
ref="infoPanelRef"
class="absolute top-12 right-0 z-50 w-48 rounded-xl border border-gray-100 bg-white shadow-lg p-2"
@click.stop
>
<ul class="max-h-48 overflow-y-auto">
<li
v-for="opt in infoOptions"
:key="opt.value"
@click="selectInfo(opt)"
class="px-3 py-2 rounded-md cursor-pointer hover:bg-gray-100"
:class="selectedInfo === opt.value ? 'bg-brand-green-light' : ''"
>
{{ opt.label }}
</li>
</ul>
</div>
</div>
</div>
<!-- 分區切換 -->
<!-- 分區切換多選預設不選顯示全部 -->
<div
class="absolute top-16 left-4 text-sm flex justify-center items-center gap-2 z-10 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">分區</p>
<button
v-for="zone in zones"
:key="zone"
class="px-4 py-2 rounded-md hover:bg-gray-100"
:class="
activeZone === zone
isZoneSelected(zone)
? 'bg-brand-green-light bg-opacity-50'
: 'bg-white'
"
@click="onClickZone(zone)"
:aria-pressed="activeZone === zone"
@click="toggleZone(zone)"
:aria-pressed="isZoneSelected(zone)"
>
{{ zone }}
</button>
</div>
<!-- 圖例 -->
<!-- 床位資訊-->
<div
class="absolute bottom-4 left-4 text-sm flex justify-center gap-2 items-center z-10 bg-white border rounded-md shadow px-4"
class="absolute bottom-4 left-4 text-sm flex justify-center items-center z-10 bg-white border rounded-md shadow ps-4"
>
<p class="py-2">圖例</p>
<div class="flex justify-start items-center gap-4">
<div class="flex justify-start items-center gap-2">
<p class="py-2">床位資訊</p>
<div class="flex justify-start items-center">
<!-- 無顯示 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'none' ? 'bg-brand-green-light bg-opacity-50' : ''
"
@click="selectInfo({ label: '無顯示', value: 'none' })"
:aria-pressed="selectedInfo === 'none'"
>
<span>不顯示</span>
</button>
<!-- 有住民 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'occupied'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '有住民', value: 'occupied' })"
:aria-pressed="selectedInfo === 'occupied'"
>
<span class="w-2 h-2 bg-brand-green rounded-full inline-block"></span>
<p>有住民</p>
</div>
<div class="flex justify-start items-center gap-2">
<span>有住民</span>
</button>
<!-- 空床 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'vacant'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '空床', value: 'vacant' })"
:aria-pressed="selectedInfo === 'vacant'"
>
<span class="w-2 h-2 bg-brand-red rounded-full inline-block"></span>
<p>空床</p>
</div>
<div class="flex justify-start items-center gap-2">
<span>空床</span>
</button>
<!-- 住院中 + 黃色警示 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'hospitalized'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '住院中', value: 'hospitalized' })"
:aria-pressed="selectedInfo === 'hospitalized'"
>
<span class="w-2 h-2 text-brand-yellow-dark inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -790,11 +853,91 @@ onUnmounted(() => {
/>
</svg>
</span>
<p>住院中</p>
</div>
<span>住院中</span>
</button>
<!-- 請假中 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'leave' ? 'bg-brand-green-light bg-opacity-50' : ''
"
@click="selectInfo({ label: '請假中', value: 'leave' })"
:aria-pressed="selectedInfo === 'leave'"
>
<span class="w-2 h-2 text-gray-400 inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span>請假中</span>
</button>
</div>
</div>
</div>
<!-- 中央 Modal病患資訊 -->
<div
v-if="modalOpen"
class="fixed inset-0 z-[120] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/40" @click="closeResidentModal"></div>
<!-- Modal 內容 -->
<div
class="relative z-[130] w-[min(92vw,500px)] max-h-[84vh] overflow-auto bg-white rounded-2xl shadow-xl p-6"
@click.stop
>
<!-- 標題 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold tracking-widest">病患資訊</h3>
<button
class="px-3 py-1 rounded-md hover:bg-gray-100"
@click="closeResidentModal"
aria-label="關閉"
>
</button>
</div>
<!-- 內容清單 -->
<ul class="list-none text-sm space-y-2">
<li><span class="text-gray-500">姓名</span>{{ modalData?.name }}</li>
<li><span class="text-gray-500">性別</span>{{ modalData?.sex }}</li>
<li><span class="text-gray-500">年齡</span>{{ modalData?.age }}</li>
<li>
<span class="text-gray-500">入住日期</span
>{{ modalData?.startTime }}
</li>
<li>
<span class="text-gray-500">身體狀況</span
>{{ modalData?.healthStatus }}
</li>
<li>
<span class="text-gray-500">服藥狀況</span
>{{ modalData?.medicationStatus }}
</li>
<li>
<span class="text-gray-500">特殊事件</span
>{{ modalData?.specialEvent }}
</li>
<li class="whitespace-pre-wrap break-words">
<span class="text-gray-500">備註</span>{{ modalData?.note }}
</li>
</ul>
</div>
</div>
</template>
<style>

View File

@ -1,282 +0,0 @@
<script setup>
import {
ref,
onMounted,
defineProps,
computed,
onUnmounted,
watch,
provide,
} from "vue";
import { getUrn, getAccessToken } from "@/apis/forge";
// import { twMerge } from "tailwind-merge";
// import hexToRgb from "@/util/hexToRgb";
// import getModalPosition from "@/util/getModalPosition";
// import useSystemStatusByBaja from "@/hooks/baja/useSystemStatusByBaja";
// import ForgeInfoModal from "@/components/forge/ForgeInfoModal.vue";
// import useAlarmStore from "@/stores/useAlarmStore";
import useForgeSprite from "@/hooks/forge/useForgeSprite";
import useHeatmapBarStore from "@/stores/useHeatmapBarStore";
const props = defineProps({
initialData: Object,
cubeStyle: {
type: Object,
default: {
right: 0,
top: 0,
},
},
});
const store = useHeatmapBarStore();
const { updateDataVisualization, createSprites, showSubSystemObjects, forgeClickListener, clear, setCameraPosition } = useForgeSprite()
const forgeDom = ref(null);
const initViewer = (container) => {
return new Promise(function (resolve, reject) {
// Autodesk.Viewing.Initializer({ getAccessToken }, function () {
// const config = {
// extensions: ["Autodesk.DataVisualization", "Autodesk.DocumentBrowser"],
// };
// let viewer = new Autodesk.Viewing.GuiViewer3D(container, config);
// Autodesk.Viewing.Private.InitParametersSetting.alpha = true;
// viewer.start();
// resolve(viewer);
// });
Autodesk.Viewing.Initializer(
{
env: "Local",
language: "en",
},
function () {
const config = {
extensions: [
"Autodesk.DataVisualization",
"Autodesk.DocumentBrowser",
],
};
let viewer = new Autodesk.Viewing.GuiViewer3D(container, config);
Autodesk.Viewing.Private.InitParametersSetting.alpha = true;
viewer.start();
viewer.setGroundShadow(false);
viewer.impl.renderer().setClearAlpha(0);
viewer.impl.glrenderer().setClearColor(0xffffff, 0);
viewer.impl.invalidate(true);
resolve(viewer);
}
);
});
};
// 使 .svf
const loadModel = (viewer, filePath) => {
return new Promise((resolve, reject) => {
viewer.loadModel(
filePath,
{},
(model) => {
viewer.impl.invalidate(true);
viewer.fitToView();
setTimeout(() => {
setCameraPosition(
{
x: 241.40975707867645,
y: -260.4481491801548,
z: 129.5719879121458,
}, //
{
x: -183.36302786348594,
y: 194.05657710941966,
z: -149.3902249981004,
} //
);
}, 500);
updateDataVisualization(viewer)
resolve(model);
console.log("模型加載完成");
},
reject
);
});
};
// const loadModel = (viewer) => {
// return new Promise(function (resolve, reject) {
// async function onDocumentLoadSuccess(doc) {
// console.log("");
// viewer.setGroundShadow(false);
// viewer.impl.renderer().setClearAlpha(0); //clear alpha channel
// viewer.impl.glrenderer().setClearColor(0xffffff, 0); //set transparent background, color code does not matter
// viewer.impl.invalidate(true); //trigger rendering
// const documentNode = await viewer.loadDocumentNode(
// doc,
// doc.getRoot().getDefaultGeometry()
// );
// updateDataVisualization(viewer)
// resolve(documentNode);
// }
// function onDocumentLoadFailure(code, message, errors) {
// reject({ code, message, errors });
// }
// Autodesk.Viewing.Document.load(
// "urn:" + urn,
// onDocumentLoadSuccess,
// onDocumentLoadFailure
// );
// });
// };
const initForge = async () => {
// getUrn().then((res) => {
// if (!res.isSuccess) return;
// initViewer(forgeDom.value).then((viewer) => {
// loadModel(viewer, res.data[0].urn_3D).then(() => {
// viewer.addEventListener(
// Autodesk.Viewing.GEOMETRY_LOADED_EVENT,
// async function () {
// console.log(
// "Autodesk.Viewing.GEOMETRY_LOADED_EVENT",
// viewer.isLoadDone()
// );
// // updateForgeViewer(viewer);
// createSprites()
// hideAllObjects();
// }
// );
// viewer.addEventListener(
// Autodesk.Viewing.CAMERA_CHANGE_EVENT,
// function (e) {
// // viewer.isLoadDone() &&
// // updateDbidPosition(this, subscribeData.value);
// console.log(
// "camera position changed: ",
// NOP_VIEWER.navigation.getTarget(),
// e.camera.position
// );
// }
// );
// });
// });
// });
const FILE_BASEURL = import.meta.env.VITE_FILE_API_BASEURL;
const viewer = await initViewer(forgeDom.value)
const filePath = `${FILE_BASEURL}/upload/forge/0.svf`;
await loadModel(viewer, filePath)
viewer.addEventListener(Autodesk.Viewing.GEOMETRY_LOADED_EVENT,
async function () {
console.log(
"Autodesk.Viewing.GEOMETRY_LOADED_EVENT",
viewer.isLoadDone()
);
showSubSystemObjects();
createSprites();
forgeClickListener();
})
};
onMounted(() => {
console.log("Forge 加載");
initForge();
});
//
const currentInfoModalData = ref(null);
const isMobile = (pointerType) => {
return pointerType !== "mouse"; // is desktop
};
const getCurrentInfoModalData = (e, position, value) => {
const mobile = isMobile(e.pointerType);
currentInfoModalData.value = {
initPos: mobile
? { left: `50%`, top: `50%` }
: { left: `${position.left}px`, top: `${position.top}px` },
value,
isMobile: mobile,
};
forge_info_modal.showModal();
};
onUnmounted(() => {
console.log("Forge 銷毀");
clear();
});
</script>
<template>
<ForgeInfoModal :data="currentInfoModalData" />
<div id="forge-preview" ref="forgeDom" class="relative w-full h-full min-h-full">
<div v-show="store.heat_bar_isShow" class="absolute z-10 heatbar">
<div class="w-40 flex justify-between text-[10px] mb-1">
<span v-for="value in store.heatmapConfig?.range" class="text-gradient-1">{{ value }} {{
store.heatmapConfig?.unit }}</span>
</div>
<div class="w-40 h-3" :style="{
background: `linear-gradient(
to right,
${store.heatmapConfig?.color[0]} 0%,
${store.heatmapConfig?.color[1]} 100%
)`
}"></div>
</div>
<!-- label -->
<!-- https://github.com/augustogoncalves/forge-plant-operation/blob/master/forgeSample/wwwroot/js/iconExtension.js -->
<!-- https://github.com/dukedhx/viewer-iot-react-feathersjs/blob/master/src/client/iconExtension.js -->
<label v-for="value in subscribeData" :key="key" :data-dbid="value.forge_dbid" :class="twMerge(
`after:border-t-[${value.currentColor}]`,
'flex items-center justify-center h-12 -translate-x-1/2 -translate-y-1/5 absolute z-50 px-5 py-4 text-center rounded-md text-lg border-2 border-white',
'after:absolute after:border-t-[10px] after:border-x-[12px] after:border-x-transparent after:-bottom-[8px] after:left-1/2 after:-translate-x-1/2 ',
'before:absolute before:border-t-[12px] before:border-x-[14px] before:border-x-transparent before:-bottom-[12px] before:left-1/2 before:-translate-x-1/2 before:border-white'
)
" :style="{
left: `${Math.floor(value.device_coordinate_3d.x)}px`,
top: `${Math.floor(value.device_coordinate_3d.y) - 100}px`,
display: value.is_show,
backgroundColor: value.currentColor,
}" @click.prevent="(e) =>
getCurrentInfoModalData(
e,
{ left: e.clientX, top: e.clientY },
value
)
">
<span class="mr-2">{{ value.full_name }}</span>
<span v-if="value.alarmMsg">{{ value.alarmMsg }}</span>
<span v-else>{{ value.show_value }}</span>
</label>
</div>
</template>
<style lang="css">
.adsk-viewing-viewer {
background-color: transparent !important;
}
#guiviewer3d-toolbar {
display: none;
bottom: 200px;
}
.viewcubeWrapper {
right: v-bind("`${props.cubeStyle.right}%`") !important;
top: v-bind("`${props.cubeStyle.top}%`") !important;
}
.homeViewWrapper {
transform: scale(2) !important;
}
.heatbar {
/* right: v-bind("`${props.cubeStyle.right + 2}%`") !important; */
top: 0% !important;
}
</style>

View File

@ -1,42 +0,0 @@
<script setup>
import { inject, ref, watch } from "vue";
import SystemInfoModalContent from "./SystemInfoModalContent.vue";
const { selectedDevice: data } = inject("system_selectedDevice")
const position = ref({
left: "0px",
top: "0px",
display: "none",
});
watch(
data,
(newValue) => {
if (!newValue.value) {
position.value = {
display: "none"
};
} else {
position.value = {
...data.value.initPos,
display: "block"
};
}
}, {
deep: true,
}
);
</script>
<template>
<Modal id="system_info_modal" :width="600" :draggable="!data?.isMobile" :backdrop="false">
<template #modalContent>
<SystemInfoModalContent />
</template>
</Modal>
</template>
<style lang="scss" scoped></style>

View File

@ -70,7 +70,7 @@ export default function useForgeSprite() {
viewableData.spriteSize = 24;
// 圖示
const spriteIconUrl = "/dist/img/hotSpot.svg";
const spriteIconUrl = "/img/hotSpot.svg";
STATIC_SUB_DEVICES.forEach((d) => {
if (!d.device_coordinate_3d) return;

View File

@ -4,12 +4,14 @@
>
<!-- 高度比重2 -->
<div class="flex-[2] grid grid-cols-4 gap-2">
<!-- 住民人數 Card -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openModal('residents')"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">住民人數</p>
<span>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -28,12 +30,15 @@
<p class="text-[12px]"></p>
</div>
</div>
<!-- 住院人數 Card -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openModal('inpatients')"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">住院人數</p>
<span>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -52,6 +57,147 @@
<p class="text-[12px]"></p>
</div>
</div>
<!-- ===== Modal放在同一個 <template> 建議貼在最底部===== -->
<div
v-if="showModal"
class="fixed inset-0 z-[100] flex items-center justify-center"
@keydown.esc="closeModal"
tabindex="0"
>
<!-- backdrop -->
<div class="absolute inset-0 bg-black/40" @click="closeModal"></div>
<!-- dialog -->
<div
class="relative bg-white w-[92vw] max-w-5xl max-h-[86vh] overflow-auto rounded-2xl shadow-xl px-12 py-8"
role="dialog"
aria-modal="true"
>
<!-- header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-2xl font-bold text-gray-800">
{{ modalTitle }}
</h3>
<button
class="p-2 rounded hover:bg-gray-100"
@click="closeModal"
aria-label="關閉"
>
</button>
</div>
<!-- body住民資訊A/B + 表格 -->
<div v-if="modalType === 'residents'" class="space-y-8">
<section>
<h4 class="text-lg font-semibold text-gray-700 mb-3">A </h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">性別</th>
<th class="px-3 py-2">年齡</th>
<th class="px-3 py-2">身體狀況</th>
<th class="px-3 py-2">備註</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in residentsA"
:key="r.bed"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ r.bed }}</td>
<td class="px-3 py-2">{{ r.name }}</td>
<td class="px-3 py-2">{{ r.gender }}</td>
<td class="px-3 py-2">{{ r.age }}</td>
<td class="px-3 py-2">{{ r.condition }}</td>
<td class="px-3 py-2">{{ r.note }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section>
<h4 class="text-lg font-semibold text-gray-700 mb-3">B </h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">性別</th>
<th class="px-3 py-2">年齡</th>
<th class="px-3 py-2">身體狀況</th>
<th class="px-3 py-2">備註</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in residentsB"
:key="r.bed"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ r.bed }}</td>
<td class="px-3 py-2">{{ r.name }}</td>
<td class="px-3 py-2">{{ r.gender }}</td>
<td class="px-3 py-2">{{ r.age }}</td>
<td class="px-3 py-2">{{ r.condition }}</td>
<td class="px-3 py-2">{{ r.note }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<!-- body住院資訊 -->
<div v-else-if="modalType === 'inpatients'">
<h4 class="text-lg font-semibold text-gray-700 mb-3">清單</h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">醫院與科別</th>
<th class="px-3 py-2">病歷號</th>
<th class="px-3 py-2">狀況</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in inpatients"
:key="p.recordNo"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ p.bed }}</td>
<td class="px-3 py-2">{{ p.name }}</td>
<td class="px-3 py-2">{{ p.hospitalDept }}</td>
<td class="px-3 py-2 font-mono">{{ p.recordNo }}</td>
<td class="px-3 py-2">{{ p.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- footer -->
<div class="mt-6 text-right">
<button
class="btn bg-brand-green text-white px-4 py-2 rounded-lg"
@click="closeModal"
>
關閉
</button>
</div>
</div>
</div>
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
@ -129,7 +275,7 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from "vue";
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
import * as echarts from "echarts";
import { brand } from "@/styles/palette";
@ -292,4 +438,194 @@ onMounted(async () => {
onUnmounted(() => {
disposeCharts();
});
/* ===== Modal 狀態 ===== */
const showModal = ref(false);
const modalType = ref(null);
const modalTitle = computed(() => {
if (modalType.value === "residents") return "住民資訊";
if (modalType.value === "inpatients") return "住院資訊";
return "";
});
function openModal(type) {
modalType.value = type;
showModal.value = true;
nextTick(() => {
const dialog = document.querySelector('[role="dialog"]');
if (dialog) dialog.focus();
});
}
function closeModal() {
showModal.value = false;
modalType.value = null;
}
/* ===== 工具產生不含「4」的床位號 ===== */
function generateBeds(count, start = 1001) {
const arr = [];
let n = start;
while (arr.length < count) {
if (!String(n).includes("4")) arr.push(n);
n++;
}
return arr;
}
/* ===== 假資料:住民 ===== */
const bedsResidents = generateBeds(12, 1001);
const residentsAll = [
{
bed: bedsResidents[0],
name: "王小明",
gender: "男",
age: 78,
condition: "穩定",
note: "喜歡下棋",
area: "A",
},
{
bed: bedsResidents[1],
name: "陳美麗",
gender: "女",
age: 82,
condition: "需輕度協助",
note: "糖尿病控制中",
area: "A",
},
{
bed: bedsResidents[2],
name: "林大志",
gender: "男",
age: 74,
condition: "穩定",
note: "行動慢",
area: "A",
},
{
bed: bedsResidents[3],
name: "張翠華",
gender: "女",
age: 80,
condition: "復健中",
note: "膝關節置換術後",
area: "A",
},
{
bed: bedsResidents[4],
name: "黃國榮",
gender: "男",
age: 85,
condition: "需中度協助",
note: "夜間易醒",
area: "A",
},
{
bed: bedsResidents[5],
name: "李佩珊",
gender: "女",
age: 76,
condition: "穩定",
note: "對花粉過敏",
area: "A",
},
{
bed: bedsResidents[6],
name: "吳大同",
gender: "男",
age: 79,
condition: "穩定",
note: "喜歡園藝",
area: "B",
},
{
bed: bedsResidents[7],
name: "周怡君",
gender: "女",
age: 81,
condition: "需輕度協助",
note: "高血壓",
area: "B",
},
{
bed: bedsResidents[8],
name: "曾文龍",
gender: "男",
age: 77,
condition: "復健中",
note: "髖關節手術後",
area: "B",
},
{
bed: bedsResidents[9],
name: "蔡淑芬",
gender: "女",
age: 83,
condition: "穩定",
note: "喜歡編織",
area: "B",
},
{
bed: bedsResidents[10],
name: "許建宏",
gender: "男",
age: 75,
condition: "需中度協助",
note: "睡眠品質不佳",
area: "B",
},
{
bed: bedsResidents[11],
name: "簡婉婷",
gender: "女",
age: 78,
condition: "穩定",
note: "對海鮮過敏",
area: "B",
},
];
const residentsA = residentsAll.filter((r) => r.area === "A");
const residentsB = residentsAll.filter((r) => r.area === "B");
/* ===== 假資料:住院 ===== */
const bedsInpatient = generateBeds(4, 1020);
const inpatients = [
{
bed: bedsInpatient[0],
name: "劉書豪",
hospitalDept: "台大醫院/心臟內科",
recordNo: "A1023001",
status: "加護中",
},
{
bed: bedsInpatient[1],
name: "高雅筑",
hospitalDept: "榮總/新陳代謝科",
recordNo: "B1023007",
status: "住院觀察",
},
{
bed: bedsInpatient[2],
name: "方志明",
hospitalDept: "長庚/骨科",
recordNo: "C1023011",
status: "術後恢復",
},
{
bed: bedsInpatient[3],
name: "鄭于庭",
hospitalDept: "國泰/神經內科",
recordNo: "D1023020",
status: "檢查中",
},
];
/* ESC 關閉 */
function onKeydown(e) {
if (e.key === "Escape") closeModal();
}
onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
</script>

View File

@ -4,12 +4,14 @@
>
<!-- 高度比重2 -->
<div class="flex-[2] grid grid-cols-4 gap-2">
<!-- 住民人數 Card -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openModal('residents')"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">住民人數</p>
<span>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -28,12 +30,15 @@
<p class="text-[12px]"></p>
</div>
</div>
<!-- 住院人數 Card -->
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openModal('inpatients')"
>
<div class="flex justify-center items-center gap-4">
<p class="text-sm">住院人數</p>
<span>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@ -52,6 +57,147 @@
<p class="text-[12px]"></p>
</div>
</div>
<!-- ===== Modal放在同一個 <template> 建議貼在最底部===== -->
<div
v-if="showModal"
class="fixed inset-0 z-[100] flex items-center justify-center"
@keydown.esc="closeModal"
tabindex="0"
>
<!-- backdrop -->
<div class="absolute inset-0 bg-black/40" @click="closeModal"></div>
<!-- dialog -->
<div
class="relative bg-white w-[92vw] max-w-5xl max-h-[86vh] overflow-auto rounded-2xl shadow-xl px-12 py-8"
role="dialog"
aria-modal="true"
>
<!-- header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-2xl font-bold text-gray-800">
{{ modalTitle }}
</h3>
<button
class="p-2 rounded hover:bg-gray-100"
@click="closeModal"
aria-label="關閉"
>
</button>
</div>
<!-- body住民資訊A/B + 表格 -->
<div v-if="modalType === 'residents'" class="space-y-8">
<section>
<h4 class="text-lg font-semibold text-gray-700 mb-3">A </h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">性別</th>
<th class="px-3 py-2">年齡</th>
<th class="px-3 py-2">身體狀況</th>
<th class="px-3 py-2">備註</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in residentsA"
:key="r.bed"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ r.bed }}</td>
<td class="px-3 py-2">{{ r.name }}</td>
<td class="px-3 py-2">{{ r.gender }}</td>
<td class="px-3 py-2">{{ r.age }}</td>
<td class="px-3 py-2">{{ r.condition }}</td>
<td class="px-3 py-2">{{ r.note }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section>
<h4 class="text-lg font-semibold text-gray-700 mb-3">B </h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">性別</th>
<th class="px-3 py-2">年齡</th>
<th class="px-3 py-2">身體狀況</th>
<th class="px-3 py-2">備註</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in residentsB"
:key="r.bed"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ r.bed }}</td>
<td class="px-3 py-2">{{ r.name }}</td>
<td class="px-3 py-2">{{ r.gender }}</td>
<td class="px-3 py-2">{{ r.age }}</td>
<td class="px-3 py-2">{{ r.condition }}</td>
<td class="px-3 py-2">{{ r.note }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<!-- body住院資訊 -->
<div v-else-if="modalType === 'inpatients'">
<h4 class="text-lg font-semibold text-gray-700 mb-3">清單</h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left bg-gray-50">
<th class="px-3 py-2">床位</th>
<th class="px-3 py-2">姓名</th>
<th class="px-3 py-2">醫院與科別</th>
<th class="px-3 py-2">病歷號</th>
<th class="px-3 py-2">狀況</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in inpatients"
:key="p.recordNo"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td class="px-3 py-2 font-mono">{{ p.bed }}</td>
<td class="px-3 py-2">{{ p.name }}</td>
<td class="px-3 py-2">{{ p.hospitalDept }}</td>
<td class="px-3 py-2 font-mono">{{ p.recordNo }}</td>
<td class="px-3 py-2">{{ p.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- footer -->
<div class="mt-6 text-right">
<button
class="btn bg-brand-green text-white px-4 py-2 rounded-lg"
@click="closeModal"
>
關閉
</button>
</div>
</div>
</div>
<div
class="col-span-1 cursor-pointer active:opacity-80 text-white bg-brand-green rounded-xl shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
>
@ -129,7 +275,7 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from "vue";
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
import * as echarts from "echarts";
import { brand } from "@/styles/palette";
@ -292,4 +438,194 @@ onMounted(async () => {
onUnmounted(() => {
disposeCharts();
});
/* ===== Modal 狀態 ===== */
const showModal = ref(false);
const modalType = ref(null);
const modalTitle = computed(() => {
if (modalType.value === "residents") return "住民資訊";
if (modalType.value === "inpatients") return "住院資訊";
return "";
});
function openModal(type) {
modalType.value = type;
showModal.value = true;
nextTick(() => {
const dialog = document.querySelector('[role="dialog"]');
if (dialog) dialog.focus();
});
}
function closeModal() {
showModal.value = false;
modalType.value = null;
}
/* ===== 工具產生不含「4」的床位號 ===== */
function generateBeds(count, start = 1001) {
const arr = [];
let n = start;
while (arr.length < count) {
if (!String(n).includes("4")) arr.push(n);
n++;
}
return arr;
}
/* ===== 假資料:住民 ===== */
const bedsResidents = generateBeds(12, 1001);
const residentsAll = [
{
bed: bedsResidents[0],
name: "王小明",
gender: "男",
age: 78,
condition: "穩定",
note: "喜歡下棋",
area: "A",
},
{
bed: bedsResidents[1],
name: "陳美麗",
gender: "女",
age: 82,
condition: "需輕度協助",
note: "糖尿病控制中",
area: "A",
},
{
bed: bedsResidents[2],
name: "林大志",
gender: "男",
age: 74,
condition: "穩定",
note: "行動慢",
area: "A",
},
{
bed: bedsResidents[3],
name: "張翠華",
gender: "女",
age: 80,
condition: "復健中",
note: "膝關節置換術後",
area: "A",
},
{
bed: bedsResidents[4],
name: "黃國榮",
gender: "男",
age: 85,
condition: "需中度協助",
note: "夜間易醒",
area: "A",
},
{
bed: bedsResidents[5],
name: "李佩珊",
gender: "女",
age: 76,
condition: "穩定",
note: "對花粉過敏",
area: "A",
},
{
bed: bedsResidents[6],
name: "吳大同",
gender: "男",
age: 79,
condition: "穩定",
note: "喜歡園藝",
area: "B",
},
{
bed: bedsResidents[7],
name: "周怡君",
gender: "女",
age: 81,
condition: "需輕度協助",
note: "高血壓",
area: "B",
},
{
bed: bedsResidents[8],
name: "曾文龍",
gender: "男",
age: 77,
condition: "復健中",
note: "髖關節手術後",
area: "B",
},
{
bed: bedsResidents[9],
name: "蔡淑芬",
gender: "女",
age: 83,
condition: "穩定",
note: "喜歡編織",
area: "B",
},
{
bed: bedsResidents[10],
name: "許建宏",
gender: "男",
age: 75,
condition: "需中度協助",
note: "睡眠品質不佳",
area: "B",
},
{
bed: bedsResidents[11],
name: "簡婉婷",
gender: "女",
age: 78,
condition: "穩定",
note: "對海鮮過敏",
area: "B",
},
];
const residentsA = residentsAll.filter((r) => r.area === "A");
const residentsB = residentsAll.filter((r) => r.area === "B");
/* ===== 假資料:住院 ===== */
const bedsInpatient = generateBeds(4, 1020);
const inpatients = [
{
bed: bedsInpatient[0],
name: "劉書豪",
hospitalDept: "台大醫院/心臟內科",
recordNo: "A1023001",
status: "加護中",
},
{
bed: bedsInpatient[1],
name: "高雅筑",
hospitalDept: "榮總/新陳代謝科",
recordNo: "B1023007",
status: "住院觀察",
},
{
bed: bedsInpatient[2],
name: "方志明",
hospitalDept: "長庚/骨科",
recordNo: "C1023011",
status: "術後恢復",
},
{
bed: bedsInpatient[3],
name: "鄭于庭",
hospitalDept: "國泰/神經內科",
recordNo: "D1023020",
status: "檢查中",
},
];
/* ESC 關閉 */
function onKeydown(e) {
if (e.key === "Escape") closeModal();
}
onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
</script>