fix: 修改首頁葷素區,變成可 click 出現 modal 的卡片

This commit is contained in:
MJM_2025_05\polly 2025-09-17 17:27:29 +08:00
parent 3b87ee1c45
commit 4fee3a6e3a
18 changed files with 1992 additions and 2292 deletions

View File

@ -14,7 +14,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>諾亞克 U-ARK 戰情中心</title>
</head>
<body class="w-screen bg-brand-gray-light font-noto text-brand-black">
<body class="w-screen bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter font-noto text-brand-black">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

View File

@ -1,6 +1,8 @@
<template>
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
<component :is="layoutComponent" />
<section>
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
<component :is="layoutComponent" />
</section>
</template>
<script setup>
@ -9,7 +11,9 @@ import { useRoute } from "vue-router";
// Layout
const layouts = {
headquarter: defineAsyncComponent(() => import("@/layouts/HeadquarterLayout.vue")),
headquarter: defineAsyncComponent(() =>
import("@/layouts/HeadquarterLayout.vue")
),
map: defineAsyncComponent(() => import("@/layouts/ForgeLayout.vue")),
};

File diff suppressed because it is too large Load Diff

View File

@ -1,893 +0,0 @@
<template>
<div class="relative w-full h-full min-h-full">
<div id="forge-preview" ref="forgeDom" class="absolute inset-0"></div>
<!-- Popovers永遠顯示在每顆 sprite 上方跟著相機更新 -->
<div class="absolute inset-0 z-20 pointer-events-none opacity-90">
<div
v-for="L in filteredLabels"
:key="L.id"
class="absolute -translate-x-1/2 -translate-y-full cursor-pointer"
:style="{
left: L.x + 'px',
top: L.y - 10 + 'px',
zIndex: activeLabelId === L.id ? 40 : 30,
}"
@click="bringToFrontById(L.id)"
>
<div
class="pointer-events-auto relative bg-white/95 border border-gray-300 rounded-md shadow px-3 py-2 text-sm cursor-pointer"
style="will-change: transform"
@click.stop="openResidentModal(L, $event)"
role="button"
tabindex="0"
@keydown.enter.prevent.stop="openResidentModal(L, $event)"
@keydown.space.prevent.stop="openResidentModal(L, $event)"
aria-label="查看 {{ L.data.name }} 詳細資訊"
>
<!-- 箭頭下方置中指向 sprite -->
<!-- 外層邊框色 -->
<span
class="absolute left-1/2 -bottom-2 -translate-x-1/2 w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-white"
aria-hidden="true"
></span>
<ul class="list-none">
<!-- 第一行床號永遠顯示 -->
<li class="flex justify-between items-center gap-1">
<div>
<span
class="inline-block w-2 h-2 rounded-full mr-1 align-middle"
:style="{
backgroundColor:
L.data.state === 'offnormal' ? BRAND_RED : BRAND_GREEN,
}"
></span>
<!-- 住院中才顯示黃色 icon -->
<span
v-if="L.data.special"
class="w-2 h-2 text-brand-yellow-dark inline-block align-middle me-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<!-- 床號永遠顯示 -->
<span class="align-middle">{{ L.data.name }}</span>
</div>
<!-- 眼睛 icon -->
<div class="text-gray-400" title="查看詳細">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5"
/>
</svg>
</span>
</div>
</li>
<!-- 第二行住民基本資料 -->
<li>
{{
isVacantWithoutSpecial(L)
? "-"
: `${L.data.residentsName}${L.data.residentsSex}${L.data.residentsAge}`
}}
</li>
<!-- 第三行入住日期 -->
<li>
{{
isVacantWithoutSpecial(L)
? "-"
: `入住日期:${L.data.startTime}`
}}
</li>
</ul>
</div>
</div>
</div>
<!-- 樓層切換 -->
<div
class="absolute top-4 left-4 text-sm flex justify-center items-center gap-2 z-10 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">樓層</p>
<button
class="px-4 py-2 rounded-md"
:class="activeFloor === '9F' ? 'bg-brand-green-light' : 'bg-white'"
@click="onClickFloor('9F')"
aria-pressed="activeFloor==='9F'"
>
1F
</button>
<button
class="px-4 py-2 rounded-md hover:bg-gray-100"
:class="activeFloor === '8F' ? 'bg-brand-green-light' : 'bg-white'"
@click="onClickFloor('8F')"
aria-pressed="activeFloor==='8F'"
>
2F
</button>
</div>
<!-- 床位資訊-->
<div
class="absolute top-16 left-4 text-sm flex justify-center items-center z-10 bg-white border rounded-md shadow ps-4"
>
<p class="py-2">床位資訊</p>
<div class="flex justify-start items-center">
<!-- 無顯示 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'none' ? 'bg-brand-green-light bg-opacity-50' : ''
"
@click="selectInfo({ label: '無顯示', value: 'none' })"
:aria-pressed="selectedInfo === 'none'"
>
<span>不顯示</span>
</button>
<!-- 有住民 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'occupied'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '有住民', value: 'occupied' })"
:aria-pressed="selectedInfo === 'occupied'"
>
<span class="w-2 h-2 bg-brand-green rounded-full inline-block"></span>
<span>有住民</span>
</button>
<!-- 空床 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'vacant'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '空床', value: 'vacant' })"
:aria-pressed="selectedInfo === 'vacant'"
>
<span class="w-2 h-2 bg-brand-red rounded-full inline-block"></span>
<span>空床</span>
</button>
<!-- 住院中 + 黃色警示 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'hospitalized'
? 'bg-brand-green-light bg-opacity-50'
: ''
"
@click="selectInfo({ label: '住院中', value: 'hospitalized' })"
:aria-pressed="selectedInfo === 'hospitalized'"
>
<span class="w-2 h-2 text-brand-yellow-dark inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span>住院中</span>
</button>
<!-- 請假中 -->
<button
class="flex items-center gap-2 px-4 py-2 rounded-md hover:bg-gray-100"
:class="
selectedInfo === 'leave' ? 'bg-brand-green-light bg-opacity-50' : ''
"
@click="selectInfo({ label: '請假中', value: 'leave' })"
:aria-pressed="selectedInfo === 'leave'"
>
<span class="w-2 h-2 text-gray-400 inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span>請假中</span>
</button>
</div>
</div>
<!-- 分區切換多選預設不選顯示全部 -->
<div
class="absolute bottom-4 left-4 text-sm flex justify-center items-center gap-2 z-10 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">查看分區</p>
<button
v-for="zone in zones"
:key="zone"
class="px-4 py-2 rounded-md hover:bg-gray-100"
:class="
isZoneSelected(zone)
? 'bg-brand-green-light bg-opacity-50'
: 'bg-white'
"
@click="toggleZone(zone)"
:aria-pressed="isZoneSelected(zone)"
>
{{ zone }}
</button>
</div>
</div>
<!-- 中央 Modal病患資訊 -->
<div
v-if="modalOpen"
class="fixed inset-0 z-[120] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/40" @click="closeResidentModal"></div>
<!-- Modal 內容 -->
<div
class="relative z-[130] w-[min(92vw,500px)] max-h-[84vh] overflow-auto bg-white rounded-2xl shadow-xl p-6"
@click.stop
>
<!-- 標題 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold tracking-widest">病患資訊</h3>
<button
class="px-3 py-1 rounded-md hover:bg-gray-100"
@click="closeResidentModal"
aria-label="關閉"
>
</button>
</div>
<!-- 內容清單 -->
<ul class="list-none text-sm space-y-2">
<li><span class="text-gray-500">姓名</span>{{ modalData?.name }}</li>
<li><span class="text-gray-500">性別</span>{{ modalData?.sex }}</li>
<li><span class="text-gray-500">年齡</span>{{ modalData?.age }}</li>
<li>
<span class="text-gray-500">入住日期</span
>{{ modalData?.startTime }}
</li>
<li>
<span class="text-gray-500">身體狀況</span
>{{ modalData?.healthStatus }}
</li>
<li>
<span class="text-gray-500">服藥狀況</span
>{{ modalData?.medicationStatus }}
</li>
<li>
<span class="text-gray-500">特殊事件</span
>{{ modalData?.specialEvent }}
</li>
<li class="whitespace-pre-wrap break-words">
<span class="text-gray-500">備註</span>{{ modalData?.note }}
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute } from "vue-router";
import useForgeSprite from "@/hooks/forge/useForgeSprite";
// ====== / ======
const route = useRoute();
const forgeDom = ref(null);
let viewer = null;
const viewerReady = ref(false);
const activeFloor = ref("9F"); // 9F
let unbindSpriteClick = null;
let THREE = null;
const {
updateDataVisualization: dvInit,
createSprites,
forgeClickListener,
cardfitToView,
clear: clearSprites,
__staticDevices, //
} = useForgeSprite();
// ====== / / ======
const FLOOR_DBIDS = { "9F": 14973, "8F": 14262 };
const BRAND_GREEN = "#34D5C8";
const BRAND_RED = "#FF8678";
const MODEL_SVF_PATH = `/upload/forge/0.svf`;
// ====== THREE ======
function getThree() {
if (THREE) return THREE;
THREE =
(globalThis.Autodesk &&
globalThis.Autodesk.Viewing &&
(globalThis.Autodesk.Viewing.THREE ||
globalThis.Autodesk.Viewing.Private?.THREE)) ||
globalThis.THREE ||
null;
if (!THREE)
throw new Error("[Forge] THREE 尚未就緒Viewer 還沒載好或版本差異)");
return THREE;
}
// ====== Resident Modal ======
const modalOpen = ref(false);
const modalData = ref(null);
function buildResidentModalData(d) {
const vacant = d?.state === "offnormal" && !d?.special; //
return {
name: vacant ? "-" : d?.residentsName ?? "-",
sex: vacant ? "-" : d?.residentsSex ?? "-",
age: vacant ? "-" : d?.residentsAge ?? "-",
startTime: vacant ? "-" : d?.startTime ?? "-",
healthStatus: vacant ? "-" : d?.healthStatus ?? "一般",
medicationStatus: vacant ? "-" : d?.medicationStatus ?? "規律服藥",
specialEvent: d?.special
? d?.specialEvent ?? "住院中"
: d?.specialEvent ?? "-",
note: vacant ? "-" : d?.note ?? "-",
};
}
function openResidentModal(L, e) {
e?.stopPropagation?.();
bringToFrontById(L.id);
modalData.value = buildResidentModalData(L.data);
modalOpen.value = true;
}
function closeResidentModal() {
modalOpen.value = false;
}
// ====== ======
function resolveInitialFloor() {
const q = (route.query?.floor || "").toString().toUpperCase();
if (q === "8F" || q === "9F") return q;
const saved = localStorage.getItem("uark-floor");
if (saved === "8F" || saved === "9F") return saved;
return "9F";
}
// ====== Forge Viewer / ======
function initViewer(container) {
return new Promise((resolve) => {
Autodesk.Viewing.Initializer({ env: "Local", language: "en" }, () => {
const config = {
extensions: ["Autodesk.DataVisualization", "Autodesk.DocumentBrowser"],
};
viewer = new Autodesk.Viewing.GuiViewer3D(container, config);
Autodesk.Viewing.Private.InitParametersSetting.alpha = true;
viewer.start();
getThree();
viewer.setGroundShadow(true);
viewer.impl.renderer().setClearAlpha(0);
viewer.impl.glrenderer().setClearColor(0xffffff, 0);
viewer.impl.invalidate(true);
resolve();
});
});
}
function loadModel(filePath) {
return new Promise((resolve, reject) => {
viewer.loadModel(filePath, {}, (model) => resolve(model), reject);
});
}
function hideViewCubeAndHome() {
const tryHide = () => {
const ext = viewer?.getExtension?.("Autodesk.ViewCubeUi");
if (ext?.setVisible) ext.setVisible(false);
};
tryHide();
viewer.addEventListener(Autodesk.Viewing.EXTENSION_LOADED_EVENT, (e) => {
if (e.extensionId === "Autodesk.ViewCubeUi") tryHide();
});
}
function waitObjectTree(model) {
return new Promise((resolve) => {
if (model.getData().instanceTree) return resolve();
const onTree = () => {
viewer.removeEventListener(
Autodesk.Viewing.OBJECT_TREE_CREATED_EVENT,
onTree
);
resolve();
};
viewer.addEventListener(Autodesk.Viewing.OBJECT_TREE_CREATED_EVENT, onTree);
});
}
// ====== ======
function getNodeWorldBounds(model, dbId) {
const THREE = getThree();
const tree = model.getInstanceTree?.();
const fragList = model.getFragmentList?.();
const box = new THREE.Box3();
if (!tree || !fragList) return box;
tree.enumNodeFragments(
dbId,
(fragId) => {
const fb = new THREE.Box3();
fragList.getWorldBounds(fragId, fb);
box.union(fb);
},
true
);
return box;
}
function positionsFromRatios(box, ratios, zLiftRatio = 0.05) {
const THREE = getThree();
const { min, max } = box;
const w = max.x - min.x,
h = max.y - min.y,
zH = max.z - min.z;
const z = max.z + Math.max(0.2, zH * zLiftRatio); //
return ratios.map(
([rx, ry]) => new THREE.Vector3(min.x + rx * w, min.y + ry * h, z)
);
}
//
const LAYOUT_RATIOS = {
"9F": [
[0.18, 0.26],
[0.5, 0.24],
[0.78, 0.29],
[0.16, 0.48],
[0.23, 0.95],
[0.86, 0.53],
],
"8F": [
[0.89, 0.68],
[0.85, 0.23],
],
};
// ====== 4 ======
function hasDigit4(n) {
return n.toString().includes("4");
}
function generateBedNumbers(count, start = 1001) {
const out = [];
let n = start;
while (out.length < count) {
if (!hasDigit4(n)) out.push(n);
n++;
}
return out;
}
const BED_SERIES_ALL = generateBedNumbers(8, 1001);
const BED_SERIES_BY_FLOOR = {
"9F": BED_SERIES_ALL.slice(0, 6),
"8F": BED_SERIES_ALL.slice(6, 8),
};
// ====== / ======
function seededRand(seed) {
const s = Math.sin(seed * 9301 + 49297) * 233280;
return s - Math.floor(s);
}
function pick(arr, seed) {
return arr[Math.floor(seededRand(seed) * arr.length)];
}
function ageFromSeed(seed) {
return 65 + Math.floor(seededRand(seed + 7) * 31);
} // 65~95
function startDateFromSeed(seed) {
const now = new Date();
const daysAgo = 30 + Math.floor(seededRand(seed + 13) * 365); // 1~12
const d = new Date(now.getTime() - daysAgo * 86400000);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}/${mm}/${dd}`;
}
const NAME_POOL = [
"王雅雯",
"陳志明",
"林冠廷",
"張淑芬",
"黃子涵",
"李建宏",
"吳佩珊",
"周育賢",
"趙雅筑",
"許庭瑋",
"郭怡君",
"洪嘉文",
];
const HEALTH_POOL = ["一般", "良好", "需觀察", "虛弱"];
const MEDICATION_POOL = ["規律服藥", "偶爾忘記", "暫停服藥", "需家屬協助"];
const SPECIAL_POOL = ["-"];
const NOTE_POOL = ["-", "對花生過敏", "家屬每週三探視", "夜間需加護理巡房"];
const SEX_POOL = ["男", "女"];
function pickAreaLetter(seed) {
const r = seededRand(seed);
if (r < 0.4) return "A";
return "B";
}
// ====== / Sprites ======
async function showLevel(levelKey) {
const levelDbId = FLOOR_DBIDS[levelKey];
if (!viewer || !viewer.model || !levelDbId) return;
const ids = (() => {
const tree = viewer.model.getInstanceTree();
const leafs = [];
const walk = (id) => {
const childCount = tree.getChildCount(id);
if (childCount === 0) leafs.push(id);
else tree.enumNodeChildren(id, walk);
};
walk(levelDbId);
return leafs.length ? leafs : [levelDbId];
})();
viewer.isolate(ids);
viewer.fitToView(ids);
viewer.setGhosting(false);
viewer.impl.invalidate(true);
}
async function rebuildSpritesForFloor(floorKey) {
const box = getNodeWorldBounds(viewer.model, FLOOR_DBIDS[floorKey]);
const is9F = floorKey === "9F";
const ratios = LAYOUT_RATIOS[floorKey] || [];
const poses = positionsFromRatios(box, ratios);
const bedNos =
BED_SERIES_BY_FLOOR[floorKey] || generateBedNumbers(poses.length, 1001);
__staticDevices.length = 0;
const GREEN = BRAND_GREEN,
RED = BRAND_RED;
poses.forEach((p, i) => {
const bedNo = bedNos[i] ?? 1001 + i;
const spriteIdBase = is9F ? 90000 : 91000;
const spriteDbId = spriteIdBase + i + 1;
const area = pickAreaLetter(spriteDbId);
const isRed = is9F ? i >= poses.length - 2 : false;
__staticDevices.push({
name: `${area}${bedNo}`,
forge_dbid: FLOOR_DBIDS[floorKey],
spriteDbId,
device_coordinate_3d: { x: p.x, y: p.y, z: p.z },
device_normal_color: BRAND_GREEN,
device_close_color: BRAND_RED,
state: isRed ? "offnormal" : "normal",
special: isRed && i === poses.length - 2, // sprite
residentsName: pick(NAME_POOL, spriteDbId),
residentsSex: pick(SEX_POOL, spriteDbId + 1),
residentsAge: ageFromSeed(spriteDbId + 2),
startTime: startDateFromSeed(spriteDbId + 3),
avatarUrl: Math.random() > 0.4 ? pick(spriteDbId + 4) : null,
healthStatus: pick(HEALTH_POOL, spriteDbId + 5),
medicationStatus: pick(MEDICATION_POOL, spriteDbId + 6),
specialEvent:
isRed && i === poses.length - 2
? "住院中"
: pick(SPECIAL_POOL, spriteDbId + 7),
note: pick(NOTE_POOL, spriteDbId + 8),
});
});
await createSprites(); //
}
// ====== Popover sprite ======
const labels = ref([]); // [{ id, x, y, data }]
let camEvtCleanup = null;
const activeLabelId = ref(null);
function bringToFrontById(id) {
activeLabelId.value = id;
}
function worldToScreen(pos3) {
const p = viewer.worldToClient(pos3); // viewer
return { x: p.x, y: p.y };
}
function rebuildLabelsForCurrentFloor() {
labels.value = __staticDevices.map((d) => {
const { x, y } = worldToScreen(
new (getThree().Vector3)(
d.device_coordinate_3d.x,
d.device_coordinate_3d.y,
d.device_coordinate_3d.z
)
);
return {
id: d.spriteDbId,
x,
y,
data: d,
};
});
}
function updateLabelPositions() {
if (!labels.value.length) return;
labels.value = labels.value.map((L) => {
const { x, y } = worldToScreen(
new (getThree().Vector3)(
L.data.device_coordinate_3d.x,
L.data.device_coordinate_3d.y,
L.data.device_coordinate_3d.z
)
);
return { ...L, x, y };
});
}
function attachCameraEventsForLabels() {
camEvtCleanup?.(); //
const onUpdate = () => requestAnimationFrame(updateLabelPositions);
viewer.addEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, onUpdate);
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, onUpdate);
viewer.addEventListener(Autodesk.Viewing.VIEWER_RESIZE_EVENT, onUpdate);
camEvtCleanup = () => {
viewer.removeEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, onUpdate);
viewer.removeEventListener(
Autodesk.Viewing.RENDER_PRESENTED_EVENT,
onUpdate
);
viewer.removeEventListener(Autodesk.Viewing.VIEWER_RESIZE_EVENT, onUpdate);
};
}
function rebuildLabelsAfterNextRender() {
const once = () => {
rebuildLabelsForCurrentFloor();
updateLabelPositions();
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
};
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
}
// ====== ======
const zones = ["一般", "氧氣"];
const selectedZones = ref(new Set());
const toggleZone = (zone) => {
const set = new Set(selectedZones.value);
if (set.has(zone)) set.delete(zone);
else set.add(zone);
selectedZones.value = set;
};
const isZoneSelected = (zone) => selectedZones.value.has(zone);
// ====== / ======
const soloSpriteId = ref(null); // sprite idnull /
const infoOptions = [
{ label: "無顯示", value: "none" },
{ label: "有住民", value: "occupied" },
{ label: "空床", value: "vacant" },
{ label: "住院中", value: "hospitalized" },
{ label: "請假中", value: "leave" }, //
];
const infoOpen = ref(false);
const selectedInfo = ref(infoOptions[0].value); // 'none'
const filteredLabels = computed(() => {
//
if (soloSpriteId.value != null) {
if (selectedInfo.value === "none" || selectedInfo.value === "leave")
return [];
return labels.value.filter((L) => L.id === soloSpriteId.value);
}
// none / leave =
if (selectedInfo.value === "none" || selectedInfo.value === "leave") {
return [];
}
const info = selectedInfo.value; // 'occupied' | 'vacant' | 'hospitalized'
const selectedSet = selectedZones.value; // Set
return labels.value.filter((L) => {
// ===== =====
const inZone =
selectedSet.size === 0
? true
: L.data?.zone
? selectedSet.has(L.data.zone)
: true;
// ===== =====
const isRed = L.data.state === "offnormal";
const isHosp = isRed && !!L.data.special; // + special
const isGreen = !isRed && !L.data.special; //
let stateOk = true;
if (info === "occupied") stateOk = isGreen;
if (info === "vacant") stateOk = isRed; // =
if (info === "hospitalized") stateOk = isHosp;
return inZone && stateOk;
});
});
const isVacantWithoutSpecial = (L) =>
L?.data?.state === "offnormal" && !L?.data?.special;
const emit = defineEmits(["change"]);
const selectInfo = (opt) => {
//
soloSpriteId.value = null;
selectedInfo.value = opt.value;
infoOpen.value = false;
emit("change", opt.value);
};
const infoTriggerRef = ref(null);
const infoPanelRef = ref(null);
const onClickOutsideInfo = (e) => {
const t = e.target;
if (!infoTriggerRef.value || !infoPanelRef.value) return;
const insideTrigger = infoTriggerRef.value.contains(t);
const insidePanel = infoPanelRef.value.contains(t);
if (!insideTrigger && !insidePanel) infoOpen.value = false;
};
const onKeydownInfo = (e) => {
if (e.key === "Escape") infoOpen.value = false;
};
// ====== / ======
async function applyFloor(next) {
//
soloSpriteId.value = null;
activeFloor.value = next;
localStorage.setItem("uark-floor", next);
await showLevel(next);
await rebuildSpritesForFloor(next); // sprites
viewer.impl.invalidate(true); //
rebuildLabelsAfterNextRender(); // labels
}
async function onClickFloor(next) {
if (next === activeFloor.value) return;
if (!viewerReady.value) return;
await applyFloor(next);
}
// ====== ======
onMounted(async () => {
activeFloor.value = resolveInitialFloor();
await initViewer(forgeDom.value);
const model = await loadModel(MODEL_SVF_PATH);
await waitObjectTree(model);
hideViewCubeAndHome();
// DataVisualization viewer
await dvInit(viewer);
// sprite
unbindSpriteClick = forgeClickListener(async ({ data }) => {
// popover
soloSpriteId.value =
soloSpriteId.value === data.spriteDbId ? null : data.spriteDbId;
bringToFrontById(data.spriteDbId);
await cardfitToView({
forge_dbid: data.forge_dbid,
spriteDbId: data.spriteDbId,
back: 30,
});
});
attachCameraEventsForLabels();
await applyFloor("9F");
viewerReady.value = true; // applyFloor
});
onMounted(() => {
document.addEventListener("click", onClickOutsideInfo);
document.addEventListener("keydown", onKeydownInfo);
});
watch(
() => route.query?.floor,
async (v) => {
if (!viewerReady.value) return;
const q = (v || "").toString().toUpperCase();
if (q === "8F" || q === "9F") {
await applyFloor(q);
}
attachCameraEventsForLabels();
}
);
onUnmounted(() => {
try {
unbindSpriteClick?.();
camEvtCleanup?.();
} catch {}
try {
clearSprites();
} catch {}
if (viewer) {
try {
const ext = viewer.getExtension?.("Autodesk.DataVisualization");
ext?.removeAllViewables?.();
viewer.unloadExtension?.("Autodesk.DataVisualization");
} catch {}
viewer.tearDown?.();
viewer.finish?.();
viewer = null;
}
document.removeEventListener("click", onClickOutsideInfo);
document.removeEventListener("keydown", onKeydownInfo);
});
</script>
<style>
.adsk-viewing-viewer {
background-color: transparent !important;
}
#guiviewer3d-toolbar {
display: none;
}
.adsk-viewcube {
display: none !important;
}
</style>

View File

@ -1,17 +1,16 @@
<template>
<section id="app" class="flex flex-col min-h-screen">
<NavBar class="fixed" />
<main
class="w-full p-4 pt-[80px] from-brand-green-light/20 via-white/40 to-white/10"
class="w-full p-4 pt-[80px]"
>
<section class="grid grid-cols-7 gap-4 overflow-hidden">
<!-- 左側地圖 -->
<section
class="col-span-3 bg-gradient-to-br rounded-md h-full overflow-hidden"
class="col-span-3 rounded-md h-full overflow-hidden"
>
<div
class="w-full h-full flex items-center justify-center text-gray-500"
class="w-full h-full flex items-center justify-center text-brand-gray"
>
<Forge />
</div>

View File

@ -1,8 +1,5 @@
<template>
<section
id="app"
class="flex flex-col min-h-screen bg-gradient-to-r from-brand-gray-lighter/50 via-brand-purple-light/20 to-white/50"
>
<section id="app" class="flex flex-col min-h-screen">
<NavBar />
<main class="w-full p-4 pt-[80px] overflow-x-hidden">
<RouterView class="h-[calc(100vh-72px-40px)]" />

View File

@ -1,6 +1,6 @@
<template>
<nav
class="fixed top-0 w-full h-14 lg:h-16 bg-white/50 backdrop-blur-sm shadow-md grid grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6 lg:px-8 z-[80]"
class="fixed top-0 w-full h-14 lg:h-16 bg-white/20 backdrop-blur-sm border-b-2 grid grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6 lg:px-8 z-[80]"
>
<!-- 左側Logo + 機構切換 -->
<div
@ -13,7 +13,7 @@
<div class="relative" v-if="!isHome">
<div
ref="triggerRef"
class="btn bg-brand-green text-white hover:opacity-90 shadow-md border-none rounded-full px-6 lg:px-8 h-10 gap-2"
class="btn bg-brand-green text-white hover:opacity-90 shadow border-none rounded-full px-6 lg:px-8 h-10 gap-2"
role="button"
tabindex="0"
aria-haspopup="true"
@ -40,7 +40,7 @@
<div
v-show="isFacilityOpen"
ref="panelRef"
class="absolute top-12 left-0 z-50 w-56 md:w-64 rounded-md border border-gray-100 bg-white shadow-lg p-2"
class="absolute top-12 left-0 z-50 w-56 md:w-64 rounded-md border border-brand-gray-light bg-white shadow-lg p-2"
@click.stop
>
<ul class="max-h-48 overflow-y-auto text-brand-black">
@ -48,7 +48,7 @@
v-for="item in facilities"
:key="item"
@click="selectItem(item)"
class="px-3 py-2 rounded-md cursor-pointer hover:bg-gray-100 tracking-wider"
class="px-3 py-2 rounded-md cursor-pointer hover:bg-brand-gray-light tracking-wider"
:class="selectedItem === item ? 'bg-brand-green-light' : ''"
>
{{ item }}
@ -60,7 +60,7 @@
<!-- 中間導覽平板以上顯示置中 -->
<div
class="col-span-1 justify-self-center hidden lg:flex items-center rounded-full min-w-[300px] w-[300px] bg-white/60 shadow-md"
class="col-span-1 justify-self-center hidden lg:flex items-center rounded-full min-w-[300px] w-[300px] bg-white/60 shadow"
>
<nav class="w-full grid grid-cols-3 divide-x divide-transparent">
<RouterLink to="/" v-slot="{ href, navigate, isExactActive }">
@ -106,7 +106,7 @@
class="col-span-1 justify-self-end hidden lg:flex items-center gap-3 sm:gap-6"
>
<button
class="btn bg-white text-brand-black hover:opacity-90 shadow-md border-none rounded-full p-2"
class="btn bg-white text-brand-black hover:opacity-90 shadow border-none rounded-full p-2"
aria-label="通知"
>
<!-- 鈴鐺 -->
@ -124,7 +124,7 @@
</button>
<div
class="btn bg-white text-brand-black hover:opacity-90 shadow-md border-none rounded-full px-3 md:px-5 h-9 md:h-10 gap-2 md:gap-3"
class="btn bg-white text-brand-black hover:opacity-90 shadow border-none rounded-full px-3 md:px-5 h-9 md:h-10 gap-2 md:gap-3"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -158,7 +158,7 @@
<!-- 手機漢堡按鈕-->
<button
class="lg:hidden btn bg-white text-brand-black hover:opacity-90 shadow-md border-none rounded-md p-2 justify-self-end"
class="lg:hidden btn bg-white text-brand-black hover:opacity-90 shadow border-none rounded-md p-2 justify-self-end"
@click="toggleMobileMenu"
:aria-expanded="isMobileMenuOpen"
aria-controls="mobile-menu"
@ -354,7 +354,7 @@
<!-- 平板直式 modal 640px1023px 顯示位置在漢堡鈕下方 -->
<div
v-show="isMobileMenuOpen"
class="hidden sm:block lg:hidden absolute right-4 top-16 z-[1100] w-[88vw] sm:w-[240px] bg-white rounded-xl shadow-xl border border-gray-100 p-4"
class="hidden sm:block lg:hidden absolute right-4 top-16 z-[1100] w-[88vw] sm:w-[240px] bg-white rounded-xl shadow-md border border-gray-100 p-4"
role="dialog"
aria-modal="true"
aria-label="主選單(平板)"

View File

@ -0,0 +1,129 @@
<template>
<section class="grid grid-cols-2 gap-2">
<!-- 卡片/ -->
<div
v-for="item in items"
:key="item.key"
class="col-span-1 bg-white/30 rounded-md shadow p-4 cursor-pointer active:opacity-80 group"
>
<div
class="relative border border-brand-yellow group-hover:border-brand-green-light rounded-md w-full h-full flex flex-col justify-center items-center gap-1 transition-colors"
>
<!-- 中間圓形徽章 + ICON -->
<div
class="relative text-brand-yellow group-hover:text-brand-green-light transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="90"
height="90"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M13 2.05v3.03c3.39.49 6 3.39 6 6.92c0 .9-.18 1.75-.5 2.54l2.62 1.53c.56-1.24.88-2.62.88-4.07c0-5.18-3.95-9.45-9-9.95M12 19a7 7 0 0 1-7-7c0-3.53 2.61-6.43 6-6.92V2.05c-5.06.5-9 4.76-9 9.95a10 10 0 0 0 10 10c3.3 0 6.23-1.61 8.05-4.09l-2.6-1.53A6.89 6.89 0 0 1 12 19"
/>
</svg>
<p
class="w-9 h-9 flex justify-center items-center p-2 bg-brand-yellow group-hover:bg-brand-green-light text-brand-black absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors"
>
{{ item.label }}
</p>
</div>
<!-- 數據 -->
<div class="flex flex-col justify-center items-center gap-4">
<p class="text-lg text-center">總數{{ item.data.total }}</p>
<div class="px-3">
<table
class="table-fixed border border-brand-gray-light border-collapse mx-auto w-auto"
>
<thead
class="bg-brand-yellow group-hover:bg-brand-green-light text-brand-black border-b border-brand-gray/30 transition-colors"
>
<tr class="text-center">
<th
class="text-sm p-2 text-center border-r border-brand-gray/30"
>
一般
</th>
<th
class="text-sm p-2 text-center border-r border-brand-gray/30"
>
碎食
</th>
<th class="text-sm p-2 text-center">管灌</th>
</tr>
</thead>
<tbody>
<tr class="text-center">
<td
class="px-3 py-1 border-r border-t border-brand-gray-light w-[80px] text-center"
>
<span
class="font-nats text-xl text-brand-black block text-center"
>
{{ item.data.normal }}
</span>
</td>
<td
class="px-3 py-1 border-r border-t border-brand-gray-light w-[80px] text-center"
>
<span
class="font-nats text-xl text-brand-black block text-center"
>
{{ item.data.soft }}
</span>
</td>
<td
class="px-3 py-1 border-t border-brand-gray-light w-[80px] text-center"
>
<span
class="font-nats text-xl text-brand-black block text-center"
>
{{ item.data.tube }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<svg
class="text-brand-gray/50 absolute top-5 right-5"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5"
/>
</svg>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
// { meat: { total, normal, soft, tube }, veg: { ... } }
diet: {
type: Object,
required: true,
default: () => ({
meat: { total: 0, normal: 0, soft: 0, tube: 0 },
veg: { total: 0, normal: 0, soft: 0, tube: 0 },
}),
},
});
const items = computed(() => [
{ key: "meat", label: "葷", data: props.diet?.meat ?? {} },
{ key: "veg", label: "素", data: props.diet?.veg ?? {} },
]);
</script>

View File

@ -0,0 +1,82 @@
<template>
<div class="relative">
<!-- 頂部資訊條機構名稱 + 返回 -->
<div
class="absolute top-0 left-0 z-30 flex justify-between items-center w-full px-2 py-1 bg-brand-gray-lighter/80"
>
<div
class="w-full h-[24px] flex justify-start items-center gap-3 text-brand-black"
>
<span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 24">
<g fill="none" fill-rule="evenodd">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"/>
<path
fill="currentColor"
d="M13 3a2 2 0 0 1 1.995 1.85L15 5v14h1V9.5a.5.5 0 0 1 .41-.492L16.5 9H18a2 2 0 0 1 1.995 1.85L20 11v8h1a1 1 0 0 1 .117 1.993L21 21H3a1 1 0 0 1-.117-1.993L3 19h1V5a2 2 0 0 1 1.85-1.995L6 3zm-2 12H8v2h3zm0-4H8v2h3zm0-4H8v2h3z"
/>
</g>
</svg>
</span>
<span class="text-sm tracking-wider">{{ facilityDisplayName }}</span>
</div>
<span
v-if="selectedFacility !== 'ALL'"
class="inline-flex items-center justify-center rounded px-2 py-1 hover:bg-white/80 hover:text-brand-black cursor-pointer select-none"
role="button"
tabindex="0"
@click="emitBack"
@keyup.enter="emitBack"
@keyup.space.prevent="emitBack"
aria-label="返回總部"
title="返回"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 48 48">
<path
fill="currentColor"
fill-rule="evenodd"
stroke="currentColor"
stroke-linejoin="round"
stroke-width="4"
d="M44 40.836q-7.34-8.96-13.036-10.168t-10.846-.365V41L4 23.545L20.118 7v10.167q9.523.075 16.192 6.833q6.668 6.758 7.69 16.836Z"
clip-rule="evenodd"
/>
</svg>
</span>
</div>
<!-- 照片 + 說明手機隱藏平板/桌機顯示 -->
<div class="w-full grid grid-rows-12 justify-start items-start gap-4 hidden sm:grid">
<div class="h-[230px] row-span-7">
<img
:src="photo"
alt="機構照片"
class="w-full h-full rounded-sm object-cover"
/>
</div>
<div class="row-span-5">
<p class="text-brand-gray bg-white/30 text-sm border rounded-sm px-2 py-3 border-brand-gray-light">
{{ desc }}
</p>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
const props = defineProps({
facilityDisplayName: { type: String, required: true },
selectedFacility: { type: String, required: true }, //
photo: { type: String, required: true },
desc: { type: String, required: true },
});
const emit = defineEmits(["back"]);
function emitBack() {
emit("back");
}
</script>

View File

@ -0,0 +1,485 @@
<template>
<section
class="relative bg-white/30 rounded-md shadow p-3 min-h-0 lg:h-full flex flex-col overscroll-contain"
>
<!-- 顯示資訊切換條置頂置中 -->
<div class="sticky top-6 z-[50] h-0">
<div class="relative pointer-events-none flex justify-center w-full">
<div
class="pointer-events-auto inline-flex items-center text-sm text-brand-gray-dark bg-white bg-opacity-90 border border-gray-300 rounded-md shadow ps-3"
role="group"
aria-label="顯示資訊切換"
>
<div class="flex items-center">
<p class="py-2 pr-2">顯示資訊</p>
<!-- 不顯示 -->
<button
:class="[
'flex items-center gap-2 px-3 py-2 rounded-md hover:bg-brand-gray-lighter',
props.infoMode === 'none' &&
'bg-brand-purple-dark text-white hover:bg-brand-purple-dark hover:text-white',
]"
@click="setInfoMode('none')"
:aria-pressed="props.infoMode === 'none'"
>
<span class="inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 15 15"
>
<path
fill="currentColor"
d="M3.64 2.27L7.5 6.13l3.84-3.84A.92.92 0 0 1 12 2a1 1 0 0 1 1 1a.9.9 0 0 1-.27.66L8.84 7.5l3.89 3.89A.9.9 0 0 1 13 12a1 1 0 0 1-1 1a.92.92 0 0 1-.69-.27L7.5 8.87l-3.85 3.85A.92.92 0 0 1 3 13a1 1 0 0 1-1-1a.9.9 0 0 1 .27-.66L6.16 7.5L2.27 3.61A.9.9 0 0 1 2 3a1 1 0 0 1 1-1c.24.003.47.1.64.27"
/>
</svg>
</span>
<span>不顯示</span>
</button>
<!-- 入住情況 -->
<button
:class="[
'flex items-center gap-2 px-3 py-2 rounded-md hover:bg-brand-gray-lighter',
props.infoMode === 'residents' &&
'bg-brand-purple-dark text-white hover:bg-brand-purple-dark hover:text-white',
]"
@click="setInfoMode('residents')"
:aria-pressed="props.infoMode === 'residents'"
>
<span class="inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
>
<path
fill="currentColor"
d="M3 14s-1 0-1-1s1-4 6-4s6 3 6 4s-1 1-1 1zm5-6a3 3 0 1 0 0-6a3 3 0 0 0 0 6"
/>
</svg>
</span>
<span>入住情況</span>
</button>
<!-- 飲食情況 -->
<button
:class="[
'flex items-center gap-2 px-3 py-2 rounded-md hover:bg-brand-gray-lighter',
props.infoMode === 'diet' &&
'bg-brand-purple-dark text-white hover:bg-brand-purple-dark hover:text-white',
]"
@click="setInfoMode('diet')"
:aria-pressed="props.infoMode === 'diet'"
>
<span class="inline-block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M17.5 2.75a.75.75 0 0 0-1.5 0v4.015c-2.026-1.358-4.867-.881-6.293 1.215L2.353 18.786c-.556.818-.45 1.91.255 2.608a2.1 2.1 0 0 0 2.667.23l10.79-7.469A4.454 4.454 0 0 0 17.24 8h4.009a.75.75 0 0 0 0-1.5h-2.69l3.22-3.22a.75.75 0 0 0-1.06-1.06L17.5 5.439z"
/>
</svg>
</span>
<span>飲食情況</span>
</button>
</div>
</div>
</div>
</div>
<!-- 地圖本體 -->
<div
ref="mapEl"
class="relative z-[0] w-full flex-1 rounded-md overflow-hidden min-h-[300px] md:min-h-[360px] lg:min-h-[420px] overscroll-contain touch-none select-none"
></div>
</section>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
/** Props / Emits **/
const props = defineProps({
// v-model:infoMode
infoMode: { type: String, default: "none" }, // 'none' | 'residents' | 'diet'
// tooltip
dataset: { type: Object, required: true },
//
locations: {
type: Array,
default: () => [
{
name: "A 機構",
addr: "高雄市楠梓區立仁街131、133號",
lat: 22.7409648895,
lng: 120.3354644775,
},
{
name: "B 機構",
addr: "高雄市楠梓區常德路317巷9弄27號",
lat: 22.7366924286,
lng: 120.3363342285,
},
{
name: "C 機構",
addr: "高雄市楠梓區宏昌街135、137號",
lat: 22.717588,
lng: 120.29406,
},
{
name: "D 機構",
addr: "高雄市左營區民族一路980號",
lat: 22.678054,
lng: 120.3192,
},
{
name: "E 機構",
addr: "高雄市三民區黃興路336號",
lat: 22.651488,
lng: 120.33731,
},
{
name: "F 機構",
addr: "高雄市小港區沿海一路377號",
lat: 22.5644512177,
lng: 120.3544540405,
},
],
},
});
const emit = defineEmits(["update:infoMode", "select-facility"]);
function setInfoMode(mode) {
emit("update:infoMode", mode);
}
/** Leaflet base icons (scoped不改全域) **/
const base = import.meta.env.BASE_URL ?? "/";
const iconBase = `${base}img/leaflet/`;
const defaultIcon = L.icon({
iconUrl: `${iconBase}marker-icon.png`,
iconRetinaUrl: `${iconBase}marker-icon-2x.png`,
shadowUrl: `${iconBase}marker-shadow.png`,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
/** Map state **/
const mapEl = ref(null);
let map = null;
let markers = [];
/** Tooltip HTML helpers **/
function getResidentsHtml(name) {
const f = props.dataset[name];
const p = f?.progress || {};
const residents = p.residents
? [p.residents.current, p.residents.total]
: [0, 0];
const vacancy = p.vacancy ? [p.vacancy.current, p.vacancy.total] : [0, 0];
const hosp = p.hospitalized
? [p.hospitalized.current, p.hospitalized.total]
: [0, 0];
return `
<div class="tip p-2" style="cursor:pointer; touch-action:none; overscroll-behavior:contain" data-facility="${name}">
<div class="flex flex-col gap-2">
<div class="inline-flex justify-between items-center text-brand-purple-dark font-noto">
<div class="inline-flex justify-start items-center gap-1">
<span><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M5 19v-8.692q0-.384.172-.727t.474-.565l5.385-4.078q.423-.323.966-.323t.972.323l5.385 4.077q.303.222.474.566q.172.343.172.727V19q0 .402-.299.701T18 20h-3.384q-.344 0-.576-.232q-.232-.233-.232-.576v-4.769q0-.343-.232-.575q-.233-.233-.576-.233h-2q-.343 0-.575.233q-.233.232-.233.575v4.77q0 .343-.232.575T9.385 20H6q-.402 0-.701-.299T5 19"/></svg></span>
<strong class="text-[14px]">${name}</strong>
</div>
<div><span class="text-brand-gray/50"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5"/></svg></span></div>
</div>
<div class="inline-block rounded-md border border-brand-gray/30 overflow-hidden">
<table class="table-fixed min-w-[180px] border-collapse">
<thead class="bg-brand-purple-light text-brand-black">
<tr class="text-center">
<th class="text-[14px] p-2 text-center border-r border-brand-gray/30 w-[80px]">住民</th>
<th class="text-[14px] p-2 text-center border-r border-brand-gray/30 w-[80px]">空床</th>
<th class="text-[14px] p-2 text-center w-[80px]">住院</th>
</tr>
</thead>
<tbody>
<tr class="text-center">
<td class="px-3 py-1 border-r border-brand-gray/30 w-[80px]"><span class="font-nats text-xl text-brand-purple-dark">${residents[0]} / ${residents[1]}</span></td>
<td class="px-3 py-1 border-r border-brand-gray/30 w-[80px]"><span class="font-nats text-xl text-brand-purple-dark">${vacancy[0]} / ${vacancy[1]}</span></td>
<td class="px-3 py-1 w-[80px]"><span class="font-nats text-xl text-brand-purple-dark">${hosp[0]} / ${hosp[1]}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>`;
}
function getDietHtml(name) {
const d = props.dataset[name]?.diet || {
meat: { total: 0, normal: 0, soft: 0, tube: 0 },
veg: { total: 0, normal: 0, soft: 0, tube: 0 },
};
return `
<div class="tip p-2" style="cursor:pointer; touch-action:none; overscroll-behavior:contain" data-facility="${name}">
<div class="flex flex-col gap-2">
<div class="inline-flex justify-between items-center text-brand-purple-dark font-noto">
<div class="inline-flex justify-start items-center gap-1">
<span><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M5 19v-8.692q0-.384.172-.727t.474-.565l5.385-4.078q.423-.323.966-.323t.972.323l5.385 4.077q.303.222.474.566q.172.343.172.727V19q0 .402-.299.701T18 20h-3.384q-.344 0-.576-.232q-.232-.233-.232-.576v-4.769q0-.343-.232-.575q-.233-.233-.576-.233h-2q-.343 0-.575.233q-.233.232-.233.575v4.77q0 .343-.232.575T9.385 20H6q-.402 0-.701-.299T5 19"/></svg></span>
<strong class="text-[14px]">${name}</strong>
</div>
<div><span class="text-brand-gray/50"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5"/></svg></span></div>
</div>
<div class="inline-block rounded-md border border-brand-gray/30 overflow-hidden">
<table class="table-fixed w-[240px] min-w-[200px] border-collapse">
<thead class="bg-brand-purple-light text-brand-black">
<tr class="text-center">
<th class="p-2 text-center border-r border-brand-gray/30 w-[60px] text-[14px]">類別</th>
<th class="p-2 text-center border-r border-brand-gray/30 w-[60px] text-[14px]">總數</th>
<th class="p-2 text-center border-r border-brand-gray/30 w-[60px] text-[14px]">一般</th>
<th class="p-2 text-center border-r border-brand-gray/30 w-[60px] text-[14px]">碎食</th>
<th class="p-2 text-center w-[60px] text-[14px]">管灌</th>
</tr>
</thead>
<tbody>
<tr class="text-center border-b border-brand-gray/30">
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="text-[14px] font-noto text-brand-dark"></span></td>
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.meat.total}</span></td>
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.meat.normal}</span></td>
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.meat.soft}</span></td>
<td class="px-2 py-1 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.meat.tube}</span></td>
</tr>
<tr class="text-center">
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="text-[14px] font-noto text-brand-dark"></span></td>
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.veg.total}</span></td>
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.veg.normal}</span></td>
<td class="px-2 py-1 border-r border-brand-gray/30 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.veg.soft}</span></td>
<td class="px-2 py-1 w-[60px]"><span class="font-nats text-xl text-brand-purple-dark">${d.veg.tube}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>`;
}
function getTooltipHtml(mode, name) {
if (mode === "residents") return getResidentsHtml(name);
if (mode === "diet") return getDietHtml(name);
return "";
}
/** helpers **/
function refreshAllTooltips() {
if (!markers?.length) return;
for (const m of markers) m.getTooltip?.()?.update?.();
}
function syncTooltips() {
if (!Array.isArray(markers)) return;
const iconH =
(defaultIcon.options.iconSize && defaultIcon.options.iconSize[1]) || 41;
const offset = [0, -(iconH + 8)];
for (const m of markers) {
const name = m.__name || m.options?.title || "";
if (props.infoMode === "none") {
m.unbindTooltip?.();
continue;
}
const html = getTooltipHtml(props.infoMode, name);
const cur = m.getTooltip?.();
const needsRebind = !cur || cur.getContent?.() !== html;
if (needsRebind) {
m.unbindTooltip?.();
m.bindTooltip(html, {
permanent: true,
direction: "top",
offset,
opacity: 0.95,
interactive: true,
});
}
const t = m.getTooltip?.();
if (t && typeof t.getElement === "function") {
const el = t.getElement();
if (el && !el.__bound) {
el.__bound = true;
el.style.cursor = "pointer";
el.setAttribute("tabindex", "-1");
const block = (ev) => {
ev.preventDefault();
ev.stopPropagation();
};
[
"wheel",
"mousewheel",
"DOMMouseScroll",
"touchstart",
"touchmove",
"pointerdown",
"pointermove",
"mousedown",
"mousemove",
].forEach((type) =>
el.addEventListener(type, block, { passive: false })
);
el.addEventListener(
"click",
(e) => {
block(e);
const facility = el.getAttribute("data-facility") || name;
emit("select-facility", facility);
},
{ passive: false }
);
el.addEventListener("dblclick", block, { passive: false });
}
}
}
if (map?.invalidateSize) {
requestAnimationFrame(() => {
map.invalidateSize();
refreshAllTooltips();
});
}
}
/** Lifecycle **/
onMounted(() => {
if (!mapEl.value) return;
//
const BOUNDS_LOOSE = L.latLngBounds([22.35, 120.0], [23.05, 120.75]);
map = L.map(mapEl.value, {
center: [22.6273, 120.3014],
zoom: 13,
minZoom: 12,
maxZoom: 20,
zoomSnap: 0.25,
zoomDelta: 0.25,
maxBounds: BOUNDS_LOOSE,
maxBoundsViscosity: 0.8,
preferCanvas: true,
zoomControl: false,
keyboard: false, // focus
});
const container = map.getContainer();
container.removeAttribute("tabindex"); //
container.addEventListener("mousedown", () => container.blur(), {
passive: true,
}); // blur
// passive:false
const block = (e) => e.preventDefault();
["wheel", "mousewheel", "DOMMouseScroll", "touchmove"].forEach((ev) => {
container.addEventListener(ev, block, { passive: false });
});
["wheel", "mousewheel", "DOMMouseScroll", "touchmove"].forEach((ev) => {
container.addEventListener(ev, block, { passive: false });
});
L.DomEvent.disableScrollPropagation(container); //
L.DomEvent.disableClickPropagation(container); //
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 20,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const group = L.featureGroup();
markers = [];
props.locations.forEach((p) => {
const marker = L.marker([p.lat, p.lng], {
title: p.name,
icon: defaultIcon,
interactive: false, // pointer
});
marker.__name = p.name;
marker.addTo(group);
markers.push(marker);
});
group.addTo(map);
const bounds = group.getBounds().pad(0.06);
map.fitBounds(bounds, {
paddingTopLeft: [24, 140],
paddingBottomRight: [24, 24],
maxZoom: 15,
});
// tooltip
syncTooltips();
// invalidateSize
requestAnimationFrame(() => {
const m = map;
m?.invalidateSize?.();
requestAnimationFrame(() => {
m?.invalidateSize?.();
refreshAllTooltips();
});
});
// tooltip
map.on("zoomend", refreshAllTooltips);
map.on("moveend", refreshAllTooltips);
map.on("resize", refreshAllTooltips);
//
const onResize = () => {
map?.invalidateSize?.();
refreshAllTooltips();
};
window.addEventListener("resize", onResize);
map.__onResize = onResize;
});
onBeforeUnmount(() => {
if (map?.__onResize) window.removeEventListener("resize", map.__onResize);
if (map) {
map.off("zoomend", refreshAllTooltips);
map.off("moveend", refreshAllTooltips);
map.off("resize", refreshAllTooltips);
map.remove();
map = null;
}
});
/** Re-bind when mode changes **/
watch(
() => props.infoMode,
async () => {
await nextTick();
requestAnimationFrame(() => {
syncTooltips();
requestAnimationFrame(() => {
map?.invalidateSize?.();
refreshAllTooltips();
});
});
}
);
</script>

View File

@ -30,7 +30,7 @@
></progress>
<span
class="w-full absolute bottom-0 flex justify-between items-center text-[20px] font-nats pe-5 text-brand-black/80 group-hover:text-brand-purple-dark left-2"
class="w-full absolute bottom-0 flex justify-between items-center text-[20px] font-nats pe-5 text-brand-gray group-hover:text-brand-purple-dark left-2"
>
{{ animatedValueLocale }} / {{ totalLocale }}
<span aria-hidden="true">

View File

@ -0,0 +1,69 @@
<template>
<div class="w-full flex flex-col gap-5">
<!-- 逐條渲染 ProgressBar -->
<ProgressBar
v-for="it in items"
:key="`${it.key}-${selectedFacilityKey}`"
:label="it.label"
:current="it.current"
:total="it.total"
:chartKey="it.key"
:currentLegend="it.currentLegend"
:totalLegend="it.totalLegend"
@select="onSelect"
data-animate
data-animate-from="0"
data-animate-duration="700"
class="[&::-webkit-progress-value]:transition-all [&::-webkit-progress-value]:duration-700 [&::-moz-progress-bar]:transition-all [&::-moz-progress-bar]:duration-700"
/>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
import ProgressBar from "./ProgressBar.vue";
/**
* items 期待的結構
* [
* {
* key: 'residents' | 'vacancy' | 'hospitalized' | 'movein' | 'moveout',
* label: string,
* current: number,
* total: number,
* currentLegend: string,
* totalLegend: string
* },
* ...
* ]
*/
const props = defineProps({
items: {
type: Array,
required: true,
// key/label/current/total
validator(arr) {
return Array.isArray(arr) && arr.every(
(x) =>
x &&
typeof x.key === "string" &&
typeof x.label === "string" &&
typeof x.current === "number" &&
typeof x.total === "number"
);
},
},
// key
selectedFacilityKey: {
type: [String, Number],
default: "",
},
});
const emit = defineEmits(["select"]);
function onSelect(payload) {
// payload ProgressBar { key }
emit("select", payload);
}
</script>

View File

@ -0,0 +1,162 @@
<template>
<!-- 整個下半圖表卡片 -->
<section class="row-span-4 bg-white/30 backdrop-blur-sm rounded-md shadow min-h-0 flex items-stretch">
<div class="w-full h-full p-3 sm:p-4 md:p-6 min-h-[200px] sm:min-h-[240px] md:min-h-[300px]">
<div ref="chartEl" class="w-full h-full"></div>
</div>
</section>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import * as echarts from "echarts";
import { brand } from "@/styles/palette";
const props = defineProps({
activeKey: { type: String, default: "residents" },
// currentFacility.charts
// { [key]: { legends: [string, string], a: number[], b: number[] }, ... }
charts: { type: Object, required: true },
});
const chartEl = ref(null);
let chart = null;
// 7
function last7DaysLabels() {
const labels = [];
const now = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(now);
d.setDate(now.getDate() - i);
labels.push(`${d.getMonth() + 1}/${d.getDate()}`);
}
return labels;
}
//
function mkTitle(text, subtext = "", opts = {}) {
return {
text,
subtext,
left: "center",
top: 12,
itemGap: 8,
textStyle: {
color: brand.black,
fontSize: 20,
fontWeight: 600,
fontFamily: '"Noto Sans TC"',
},
subtextStyle: {
color: brand.gray,
fontSize: 14,
fontWeight: 400,
fontFamily: '"Noto Sans TC"',
lineHeight: 20,
},
...opts,
};
}
function applyChartOption() {
if (!chart) return;
const conf = props.charts?.[props.activeKey];
if (!conf) return;
const title = `${conf.legends?.[0] ?? ""} ${conf.legends?.[1] ?? ""}`;
const subTitle = "(近 7 天)";
chart.setOption({
color: [brand.green, brand.purple],
title: mkTitle(title, subTitle),
grid: { top: 84, left: 36, right: 16, bottom: 56, containLabel: true },
tooltip: { trigger: "axis", axisPointer: { type: "line" }, confine: true },
xAxis: {
type: "category",
boundaryGap: false,
data: last7DaysLabels(),
axisLine: { lineStyle: { color: brand.gray } },
axisTick: { show: false },
axisLabel: { color: brand.gray },
splitLine: { show: false },
},
legend: {
data: conf.legends || [],
bottom: 8,
icon: "circle",
itemWidth: 10,
itemHeight: 10,
itemGap: 24,
textStyle: { color: brand.gray },
},
yAxis: {
type: "value",
name: "數量",
nameLocation: "middle",
nameGap: 52,
nameTextStyle: { padding: [0, 8, 0, 8] },
axisLine: { show: true },
axisTick: { show: true },
axisLabel: { color: brand.gray },
splitLine: { show: true, lineStyle: { color: brand.grayLight } },
splitArea: {
show: true,
areaStyle: { color: [brand.white, brand.grayLighter] },
},
},
series: [
{
name: conf.legends?.[0] ?? "",
type: "line",
data: conf.a || [],
symbol: "circle",
symbolSize: 6,
lineStyle: { width: 2 },
},
{
name: conf.legends?.[1] ?? "",
type: "line",
data: conf.b || [],
symbol: "circle",
symbolSize: 6,
lineStyle: { width: 2 },
},
],
});
}
function initChart() {
if (!chartEl.value) return;
chart = echarts.init(chartEl.value);
applyChartOption();
}
function disposeChart() {
if (chart) {
chart.dispose();
chart = null;
}
}
function onWindowResize() {
chart?.resize?.();
}
onMounted(() => {
initChart();
window.addEventListener("resize", onWindowResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onWindowResize);
disposeChart();
});
// activeKey charts/
watch(
() => [props.activeKey, props.charts],
() => applyChartOption(),
{ deep: true }
);
</script>

File diff suppressed because it is too large Load Diff

View File

@ -56,13 +56,13 @@
</div>
</div>
<!-- 今日離院 Card -->
<!-- 今日結案 Card -->
<div
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openPanel('discharges')"
>
<div class="flex justify-center items-center gap-4">
<p>今日離院當月累積離院</p>
<p>今日結案當月累積結案</p>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -96,7 +96,7 @@
<!-- A. 未完成的知會事項與代辦事項-->
<div v-if="!showPanel" key="todos" class="absolute inset-0">
<section
class="bg-white/50 rounded-md shadow p-6 flex flex-col min-h-0 gap-4 h-full"
class="bg-white/30 rounded-md shadow p-6 flex flex-col min-h-0 gap-4 h-full"
>
<h3 class="text-2xl font-bold">未完成的知會事項與代辦事項</h3>
<div class="flex flex-col gap-4 mb-6 flex-1 min-h-0">
@ -169,7 +169,7 @@
<!-- B. Panel -->
<div v-else key="panel" class="absolute inset-0">
<section
class="relative w-full h-full bg-white/50 backdrop-blur-sm rounded-md shadow overflow-hidden p-0"
class="relative w-full h-full bg-white/30 backdrop-blur-sm rounded-md shadow overflow-hidden p-0"
role="region"
:aria-labelledby="'panel-title'"
>
@ -324,7 +324,7 @@
</div>
</div>
<!-- 內容今日離院底部固定分頁移出 scroll -->
<!-- 內容今日結案底部固定分頁移出 scroll -->
<div v-else-if="panelType === 'discharges'" class="space-y-8">
<div class="overflow-x-auto">
<table
@ -479,7 +479,7 @@ const panelType = ref(null);
const panelTitle = computed(() => {
if (panelType.value === "residents") return "住民資訊";
if (panelType.value === "inpatients") return "住院資訊";
if (panelType.value === "discharges") return "今日離院";
if (panelType.value === "discharges") return "今日結案";
return "";
});
function openPanel(type) {

View File

@ -4,7 +4,7 @@
<div class="flex-[2] grid grid-cols-3 gap-2 max-h-[150px]">
<!-- 住民人數 Card -->
<div
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow-md px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openPanel('residents')"
>
<div class="flex justify-center items-center gap-4">
@ -31,7 +31,7 @@
<!-- 住院人數 Card -->
<div
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow-md px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openPanel('inpatients')"
>
<div class="flex justify-center items-center gap-4">
@ -56,13 +56,13 @@
</div>
</div>
<!-- 今日離院 Card -->
<!-- 今日結案 Card -->
<div
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
class="col-span-1 cursor-pointer text-white bg-brand-green hover:bg-brand-green hover:opacity-80 active:opacity-70 rounded-md shadow-md px-4 pt-3 flex flex-col justify-center items-start tracking-widest"
@click="openPanel('discharges')"
>
<div class="flex justify-center items-center gap-4">
<p>今日離院當月累積離院</p>
<p>今日結案當月累積結案</p>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -99,18 +99,18 @@
<div class="flex flex-col gap-2 w-full h-full">
<div class="flex-[5] grid grid-cols-2 gap-2 min-h-0">
<div
class="col-span-1 bg-white/50 rounded-md shadow p-2 flex justify-center items-center"
class="col-span-1 bg-white/30 rounded-md shadow-md p-2 flex justify-center items-center"
>
<div ref="chartARef" class="w-full h-[90%] min-h-[200px]"></div>
</div>
<div
class="col-span-1 bg-white/50 rounded-md shadow p-2 flex justify-center items-center"
class="col-span-1 bg-white/30 rounded-md shadow-md p-2 flex justify-center items-center"
>
<div ref="chartBRef" class="w-full h-[90%] min-h-[200px]"></div>
</div>
</div>
<div
class="bg-white/50 rounded-md shadow p-2 flex flex-col justify-center items-start flex-[5] min-h-0"
class="bg-white/30 rounded-md shadow-md p-2 flex flex-col justify-center items-start flex-[5] min-h-0"
>
<div ref="chartCRef" class="w-full h-[90%] min-h-[200px]"></div>
</div>
@ -120,7 +120,7 @@
<!-- B. Panel -->
<div v-else key="panel" class="absolute inset-0">
<section
class="relative w-full h-full bg-white/50 rounded-md shadow overflow-hidden p-0"
class="relative w-full h-full bg-white/30 rounded-md shadow-md overflow-hidden p-0"
role="region"
:aria-labelledby="'panel-title'"
>
@ -273,7 +273,7 @@
</div>
</div>
<!-- 內容今日離院表格用分頁資料 -->
<!-- 內容今日結案表格用分頁資料 -->
<div v-else-if="panelType === 'discharges'" class="space-y-8">
<div class="overflow-x-auto">
<table
@ -609,7 +609,7 @@ function buildOptionA(collapsedStage = "median") {
title: mkTitle("佔床率", "(近 6 個月)"),
tooltip: {
trigger: "item",
axisPointer: { type: "shadow" },
axisPointer: { type: "shadow-md" },
formatter: boxTooltipTW,
},
grid: commonGrid,
@ -657,7 +657,7 @@ function buildOptionB(collapsedStage = "median") {
title: mkTitle("住院率", "(近 6 個月)"),
tooltip: {
trigger: "item",
axisPointer: { type: "shadow" },
axisPointer: { type: "shadow-md" },
formatter: boxTooltipTW,
},
grid: commonGrid,
@ -699,7 +699,7 @@ function buildOptionC() {
Number((v + Math.sin(i / 3) * 1.5).toFixed(1))
);
return {
title: mkTitle("每日佔床率比較", "(近 30 天)"),
title: mkTitle("各單位每日佔床率", "(近 30 天)"),
legend: { top: 28, data: ["機構 A", "本機構", "機構 B"] },
tooltip: { trigger: "axis", valueFormatter: (v) => `${v}%` },
grid: { ...commonGrid, top: 64 },
@ -781,7 +781,7 @@ const panelType = ref(null);
const panelTitle = computed(() => {
if (panelType.value === "residents") return "住民資訊";
if (panelType.value === "inpatients") return "住院資訊";
if (panelType.value === "discharges") return "今日離院";
if (panelType.value === "discharges") return "今日結案";
return "";
});
function openPanel(type) {

View File

@ -12,8 +12,8 @@ export const brand = {
yellowDark: "#C4E920",
black: "#424242",
gray: "#828282",
grayLight: "#E9E9E9",
grayLighter: "#F6F6F6",
grayLight: "#CACACA",
grayLighter: "#EEEEEE",
grayDark: "#D2D2D2",
white: "#ffffff"
};

View File

@ -40,11 +40,17 @@ module.exports = {
},
colors: {
brand: {
green: { DEFAULT: "#34D5C8", light: "#C4FBE5", dark: "#0CA99C" },
green: {
DEFAULT: "#34D5C8",
light: "#C4FBE5",
lighter: "#F2F9F6",
dark: "#0CA99C",
},
red: "#FF8678",
purple: {
DEFAULT: "#A5BEFF",
light: "#D5E1FF",
lighter: "#EFF1F6",
dark: "#7089CA",
},
yellow: {
@ -55,7 +61,7 @@ module.exports = {
gray: {
DEFAULT: "#828282",
lighter: "#EEEEEE",
light: "#E9E9E9",
light: "#CACACA",
dark: "#767676",
},
},