feat: 首頁地圖展開與收闔功能

This commit is contained in:
MJM_2025_05\polly 2025-09-23 11:18:16 +08:00
parent 3a6b61db1d
commit 5cc0dcbf56
15 changed files with 617 additions and 82 deletions

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/public/version.json

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-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter font-noto text-brand-black">
<body class="w-screen h-[1200px] bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter font-noto text-brand-black">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

View File

@ -4,9 +4,11 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/write-version.mjs",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"dev": "vite",
"version:write": "node scripts/write-version.mjs"
},
"dependencies": {
"@fullcalendar/core": "^6.1.19",

29
scripts/write-version.mjs Normal file
View File

@ -0,0 +1,29 @@
import { writeFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { resolve } from 'node:path';
function safe(cmd, fallback = '') {
try {
return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
} catch {
return fallback;
}
}
const now = new Date().toISOString();
const commit = safe('git rev-parse --short HEAD', 'local');
const branch = safe('git rev-parse --abbrev-ref HEAD', '');
const buildId = `${now.replace(/[-:TZ.]/g, '').slice(0, 14)}.${commit}`;
const payload = {
version: now, // 比對用(每次 build 都不同)
commit,
branch,
buildId
};
mkdirSync(resolve('public'), { recursive: true });
writeFileSync(resolve('public/version.json'), JSON.stringify(payload, null, 2), 'utf-8');
console.log('[write-version] public/version.json ->', payload);

View File

@ -3,13 +3,122 @@
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
<component :is="layoutComponent" />
</section>
<!-- 全域通知 ModalTeleport body -->
<Teleport to="body">
<div
v-if="notif.isOpen"
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="notif-modal-title"
@keydown.esc.prevent="notif.close()"
>
<!-- 背景遮罩 -->
<div
class="absolute inset-0 bg-black/40"
@click="notif.close()"
aria-hidden="true"
></div>
<!-- 內容框 -->
<div
class="relative z-[1] w-full max-w-5xl h-[80vh] rounded-xl bg-white shadow-xl border border-gray-200 p-6 flex flex-col gap-4"
>
<!-- 標題 + 關閉 -->
<div class="flex items-center justify-between gap-3">
<h3 id="notif-modal-title" class="text-2xl font-bold">通知</h3>
<button
class="px-3 py-1 rounded-md hover:bg-gray-100"
@click="notif.close()"
aria-label="關閉"
type="button"
>
</button>
</div>
<h4 class="text-xl font-bold">未完成的知會事項與代辦事項</h4>
<!-- Table -->
<div class="flex flex-col gap-4 mb-6 flex-1 min-h-0">
<div class="w-full flex-1 min-h-0 overflow-x-auto overflow-y-auto">
<table class="table w-full whitespace-nowrap">
<thead
class="bg-brand-gray-lighter text-brand-black sticky top-0 z-[1]"
>
<tr>
<th class="px-3 py-2 text-left">日期</th>
<th class="px-3 py-2 text-left">床位</th>
<th class="px-3 py-2 text-left">姓名</th>
<th class="px-3 py-2 text-left">類型</th>
<th class="px-3 py-2 text-left">說明</th>
<th class="px-3 py-2 text-left">逾期天數</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in pagedRows"
:key="row.id"
class="transition-colors duration-150 hover:bg-brand-gray-lighter"
tabindex="0"
@click="onTodoRowClick(row)"
@keydown.enter.prevent="onTodoRowClick(row)"
@keydown.space.prevent="onTodoRowClick(row)"
>
<td>{{ row.date }}</td>
<td class="font-mono">{{ row.bed }}</td>
<td class="truncate">{{ row.name }}</td>
<td>{{ row.type }}</td>
<td class="truncate">{{ row.desc }}</td>
<td class="text-brand-red">{{ row.overdueDays }} </td>
</tr>
<tr v-if="!pagedRows.length">
<td class="py-6 text-center text-brand-gray" colspan="6">
目前沒有待辦
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分頁 -->
<div class="mt-3 flex items-center justify-between px-3">
<span class="text-sm text-gray-500">
{{ total }} 每頁 {{ pageSize }}
</span>
<div class="inline-flex items-center gap-2">
<button
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === 1"
@click="goPrev()"
>
上一頁
</button>
<span class="px-2 text-sm tabular-nums text-brand-purple-dark">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === totalPages"
@click="goNext()"
>
下一頁
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, defineAsyncComponent } from "vue";
import { ref, computed, defineAsyncComponent } from "vue";
import { useRoute } from "vue-router";
import { useNotifStore } from "@/stores/notif";
import { NURSING_TODOS_SEED } from "@/constants/mocks/facilityData";
// Layout
// ===== Layout =====
const layouts = {
headquarter: defineAsyncComponent(() =>
import("@/layouts/HeadquarterLayout.vue")
@ -18,10 +127,68 @@ const layouts = {
};
const route = useRoute();
// 使 map meta.layout
const layoutComponent = computed(() => {
const key = route.meta?.layout ?? "map";
return layouts[key] ?? layouts.map;
});
// ===== store =====
const notif = useNotifStore();
// ===== NURSING_TODOS_SEED rows id =====
function parseYMDSlash(str) {
// "YYYY/MM/DD" null
const m = String(str).match(/^(\d{4})\/(\d{2})\/(\d{2})$/);
if (!m) return null;
const [_, y, mo, d] = m;
return new Date(Number(y), Number(mo) - 1, Number(d));
}
function calcOverdueDays(dateStr) {
const d = parseYMDSlash(dateStr);
if (!d) return 0;
const today = new Date();
//
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const diff = Math.floor((end - start) / 86400000);
return diff > 0 ? diff : 0;
}
const rows = ref(
(Array.isArray(NURSING_TODOS_SEED) ? NURSING_TODOS_SEED : []).map((r, i) => ({
id: i + 1,
date: r.date, // YYYY/MM/DD
bed: r.bed,
name: r.name || "-",
type: r.type || "-",
desc: r.desc || "-",
overdueDays: calcOverdueDays(r.date),
}))
);
// ===== =====
const pageSize = 10;
const currentPage = ref(1);
const total = computed(() => rows.value.length);
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / pageSize))
);
const pagedRows = computed(() => {
const start = (currentPage.value - 1) * pageSize;
return rows.value.slice(start, start + pageSize);
});
function goPrev() {
if (currentPage.value > 1) currentPage.value--;
}
function goNext() {
if (currentPage.value < totalPages.value) currentPage.value++;
}
function onTodoRowClick(row) {
// modal
console.log("點擊 row", row);
}
</script>

View File

@ -200,6 +200,29 @@
/>
</div>
<!-- 回到 Home 視角 -->
<div class="absolute top-4 right-4 z-30">
<button
class="btn btn-sm text-brand-gray bg-brand-gray-lighter border border-brand-gray-light shadow rounded p-4 hover:bg-brand-green-light active:opacity-80"
@click="goHome"
aria-label="回到初始視角"
title="回到初始視角"
>
<!-- Home Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="m21.743 12.331l-9-10c-.379-.422-1.107-.422-1.486 0l-9 10a1 1 0 0 0-.17 1.076c.16.361.518.593.913.593h2v7a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-4h4v4a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-7h2a.998.998 0 0 0 .743-1.669"
/>
</svg>
</button>
</div>
<!-- 床位資訊篩選 -->
<div class="absolute top-16 left-4 z-30">
<Tabs
@ -308,7 +331,7 @@
<script setup>
/** ===================== Imports ===================== */
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useRoute } from "vue-router";
import useForgeSprite from "@/hooks/forge/useForgeSprite";
import Tabs from "@/components/common/tabs/Tabs.vue";
@ -318,6 +341,9 @@ import {
RESIDENTS_BY_BED,
} from "@/constants/mocks/facilityData";
let INITIAL_1F_STATE = null; // 1F //
let INITIAL_1F_CAMERA = null; //
/** ===================== Tabs樓層/狀態/分區 ===================== */
const floorTabItems = [
{ value: "1F", label: "1F" },
@ -953,12 +979,14 @@ function clearOurTheming(model) {
} catch {}
lastThemedIds.clear();
}
function setRoomTheming(id, rgba) {
const THREE = getThree();
const c = new THREE.Vector4(rgba.r, rgba.g, rgba.b, rgba.a);
viewer.setThemingColor(id, c, viewer.model, true);
lastThemedIds.add(id);
}
function rebuildZoneThemingForCurrentFloor() {
if (!viewer || !viewer.model) return;
clearOurTheming(viewer.model);
@ -1020,6 +1048,34 @@ function rebuildZoneThemingForCurrentFloor() {
viewer.impl.invalidate(true, true, true);
}
/** ===================== 返回模型初始視角 ===================== */
async function goHome() {
if (!viewer) return;
// 1F sprites
await applyFloor("1F");
// // immediate=true
if (INITIAL_1F_STATE) {
try {
viewer.restoreState(INITIAL_1F_STATE, undefined, true);
} catch (e) {
// 退 fitToView
const id = FLOOR_DBIDS["1F"];
if (id != null) {
const ids = collectLeafsUnder(viewer.model, id);
viewer.fitToView?.(ids, viewer.model);
} else {
viewer.fitToView?.();
}
}
}
// popover
rebuildLabelsAfterNextRender();
rebuildZoneThemingForCurrentFloor();
}
/** ===================== 快照輸出(供 operation 使用) ===================== */
function parseBedCodeFromLabel(label) {
const m = String(label || "").match(/床號\s*([0-9]{3}(?:-[1-3])?)/);
@ -1628,6 +1684,18 @@ onMounted(async () => {
});
attachCameraEventsForLabels();
await applyFloor("1F");
// fitToView//labels
await new Promise((r) => {
const once = () => {
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
r();
};
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
});
INITIAL_1F_STATE = viewer.getState(); // viewer Home
viewerReady.value = true;
});

View File

@ -30,11 +30,28 @@
</template>
<script setup>
/**
* MapPane.vue
* - 使用 Leaflet 顯示地圖與各機構 tooltip
* - 透過 ResizeObserver + rAF在父層做寬度 transition 時即時 invalidateSize避免展開/收合時的卡頓或灰帶
* - 最佳化撰寫順序與中文註解如下
* 1) Imports
* 2) 常數 / tab 項目
* 3) Props / Emits
* 4) 狀態refs/變數
* 5) 公用方法setInfoMode / HTML builders / helpers
* 6) 尺寸觀察與 invalidate對外暴露
* 7) Lifecyclemounted/unmounted
* 8) Watchers
*/
// 1) Imports
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import Tabs from "@/components/common/tabs/Tabs.vue";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// 2) / Tabs
const tabItems = [
{
value: "none",
@ -65,13 +82,13 @@ const tabItems = [
},
];
/** Props / Emits **/
// 3) Props / Emits
const props = defineProps({
// v-model:infoMode
infoMode: { type: String, default: "none" }, // 'none' | 'residents' | 'diet'
// tooltip
/** 切換顯示模式v-model: 'none' | 'residents' | 'diet' */
infoMode: { type: String, default: "none" },
/** 所有機構資料(提供 tooltip 文本) */
dataset: { type: Object, required: true },
//
/** 座標清單(可覆蓋) */
locations: {
type: Array,
default: () => [
@ -113,14 +130,19 @@ const props = defineProps({
},
],
},
/** layout 版本(若父層需要強制重建,可遞增此值) */
layoutVersion: { type: Number, default: 0 },
});
const emit = defineEmits(["update:infoMode", "select-facility"]);
function setInfoMode(mode) {
emit("update:infoMode", mode);
}
// 4) refs/
const mapEl = ref(null);
let map = null;
let markers = [];
let ro = null; // ResizeObserver
let rafId = 0; // rAF id
/** Leaflet base icons (scoped不改全域) **/
// Leaflet icon
const base = import.meta.env.BASE_URL ?? "/";
const iconBase = `${base}img/leaflet/`;
const defaultIcon = L.icon({
@ -133,12 +155,12 @@ const defaultIcon = L.icon({
shadowSize: [41, 41],
});
/** Map state **/
const mapEl = ref(null);
let map = null;
let markers = [];
// 5)
function setInfoMode(mode) {
emit("update:infoMode", mode);
}
/** Tooltip HTML helpers **/
// --- tooltip HTML ---
function getResidentsHtml(name) {
const f = props.dataset[name];
const p = f?.progress || {};
@ -157,7 +179,7 @@ function getResidentsHtml(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>
<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>
@ -197,7 +219,7 @@ function getDietHtml(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>
<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>
@ -241,7 +263,7 @@ function getTooltipHtml(mode, name) {
return "";
}
/** helpers **/
// --- helperstooltip / ---
function refreshAllTooltips() {
if (!markers?.length) return;
for (const m of markers) m.getTooltip?.()?.update?.();
@ -326,11 +348,30 @@ function syncTooltips() {
}
}
/** Lifecycle **/
// 6) invalidate
function doInvalidate() {
if (map) {
map.invalidateSize();
refreshAllTooltips();
}
}
//
defineExpose({ invalidate: doInvalidate });
function onHostResize() {
// rAF RO transition
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
doInvalidate();
});
}
// 7) Lifecycle
onMounted(() => {
if (!mapEl.value) return;
//
//
const BOUNDS_LOOSE = L.latLngBounds([22.35, 120.0], [23.05, 120.75]);
map = L.map(mapEl.value, {
@ -348,23 +389,19 @@ onMounted(() => {
});
const container = map.getContainer();
container.removeAttribute("tabindex"); //
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 })
);
["wheel", "mousewheel", "DOMMouseScroll", "touchmove"].forEach((ev) => {
container.addEventListener(ev, block, { passive: false });
});
L.DomEvent.disableScrollPropagation(container); //
L.DomEvent.disableClickPropagation(container); //
L.DomEvent.disableScrollPropagation(container);
L.DomEvent.disableClickPropagation(container);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 20,
@ -372,13 +409,14 @@ onMounted(() => {
'&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
interactive: false,
});
marker.__name = p.name;
marker.addTo(group);
@ -386,6 +424,7 @@ onMounted(() => {
});
group.addTo(map);
//
const bounds = group.getBounds().pad(0.06);
map.fitBounds(bounds, {
paddingTopLeft: [24, 140],
@ -393,10 +432,10 @@ onMounted(() => {
maxZoom: 15,
});
// tooltip
// tooltip
syncTooltips();
// invalidateSize
// invalidate
requestAnimationFrame(() => {
const m = map;
m?.invalidateSize?.();
@ -418,6 +457,12 @@ onMounted(() => {
};
window.addEventListener("resize", onResize);
map.__onResize = onResize;
// transition
if (mapEl.value && "ResizeObserver" in window) {
ro = new ResizeObserver(onHostResize);
ro.observe(mapEl.value);
}
});
onBeforeUnmount(() => {
@ -429,9 +474,17 @@ onBeforeUnmount(() => {
map.remove();
map = null;
}
if (ro) {
ro.disconnect();
ro = null;
}
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
});
/** Re-bind when mode changes **/
// 8) Watchers tooltip
watch(
() => props.infoMode,
async () => {

View File

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

View File

@ -106,8 +106,9 @@
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 border-none rounded-full p-2"
class="relative btn bg-white text-brand-black hover:opacity-90 shadow border-none rounded-full p-2"
aria-label="通知"
@click="notif.open()"
>
<!-- 鈴鐺 -->
<svg
@ -121,6 +122,13 @@
d="M8 2a4.5 4.5 0 0 0-4.5 4.5v2.401l-.964 2.414A.5.5 0 0 0 3 12h3c0 1.108.892 2 2 2s2-.892 2-2h3a.5.5 0 0 0 .464-.685L12.5 8.9V6.5A4.5 4.5 0 0 0 8 2m1 10c0 .556-.444 1-1 1s-1-.444-1-1zM4.5 6.5a3.5 3.5 0 1 1 7 0v2.498a.5.5 0 0 0 .036.185L12.262 11H3.738l.726-1.817a.5.5 0 0 0 .036-.185z"
/>
</svg>
<!-- 紅色圓圈徽章 -->
<span
class="absolute top-0 -right-3 w-5 h-5 rounded-full bg-brand-red-dark text-white text-xs font-bold flex items-center justify-center leading-none shadow"
aria-label="未讀通知 3 則"
>3</span
>
</button>
<div
@ -495,6 +503,28 @@ import {
watch,
} from "vue";
import { useRoute } from "vue-router";
import { useNotifStore } from "@/stores/notif";
const notif = useNotifStore();
// ====== Modal ======
const isNotifOpen = ref(false);
const notifBtnRef = ref(null);
const notifPanelRef = ref(null);
const notifCloseRef = ref(null);
const openNotif = async () => {
isNotifOpen.value = true;
await nextTick();
//
notifCloseRef.value?.focus?.();
};
const closeNotif = async () => {
isNotifOpen.value = false;
await nextTick();
//
notifBtnRef.value?.focus?.();
};
// ====== ======
const route = useRoute();
@ -558,6 +588,7 @@ const handleKeydown = (e) => {
if (e.key === "Escape") {
isFacilityOpen.value = false; // dropdown
isMobileMenuOpen.value = false; // ham menu
isNotifOpen.value = false; // Modal
}
};

View File

@ -1,13 +1,30 @@
import { createApp } from 'vue'
import { createApp } from "vue";
import { createPinia } from "pinia";
import './style.css'
import App from './App.vue'
import router from './router'
import 'leaflet/dist/leaflet.css';
import 'animate.css'
import "./style.css";
import App from "./App.vue";
import router from "./router";
import "leaflet/dist/leaflet.css";
import "animate.css";
const app = createApp(App)
// 引入版本守門員
import { ensureFreshAssets, syncLocalVersion } from "./utils/versionGuard";
app.use(createPinia());
app.use(router)
app.mount('#app')
async function bootstrap() {
// 只在正式環境檢查版本,避免 dev 被卡住
if (import.meta.env.PROD) {
await ensureFreshAssets();
}
const app = createApp(App);
app.use(createPinia());
app.use(createPinia());
app.use(router);
app.mount("#app");
// 掛載後同步一次版本(把最新版寫進 localStorage
if (import.meta.env.PROD) {
syncLocalVersion();
}
}
bootstrap();

View File

@ -1,11 +1,9 @@
<template>
<section
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 justify-center"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 justify-center h-full min-h-0"
>
<!-- 左側照片/說明 + 進度條 + 圖表 + 葷素資料 -->
<section
class="grid grid-rows-12 gap-2 order-2 lg:order-1 min-h-0 lg:h-full"
>
<section class="grid grid-rows-12 gap-2 order-2 lg:order-1 h-full min-h-0">
<!-- 左上照片/說明 + 進度條 -->
<section
class="relative row-span-5 bg-white/30 backdrop-blur-sm rounded-md shadow p-6 grid grid-cols-1 md:grid-cols-2 items-start gap-6 min-h-0"
@ -79,15 +77,74 @@
<DietSummary class="row-span-3" :diet="currentFacility.diet" />
</section>
<!-- 地圖-->
<!-- + 放在同一個 2 欄容器內 -->
<section class="order-1 lg:order-2 lg:col-span-2 h-full min-h-0">
<div class="h-full min-h-0 flex flex-col lg:flex-row gap-2">
<!-- 地圖flex-basis 做寬度動畫 -->
<div
class="relative h-[420px] lg:h-full min-w-0 will-change-[flex-basis] transition-[flex-basis] duration-400 ease-in-out basis-full lg:basis-1/2"
:class="isRightCollapsed ? 'lg:basis-full' : 'lg:basis-1/2'"
>
<!-- caret 按鈕 -->
<button
type="button"
class="absolute right-2 top-40 -translate-y-1/2 z-10 bg-white/80 border text-brand-gray/80 border-brand-gray-light rounded-md shadow w-9 h-20 grid place-items-center hover:bg-brand-purple-light active:opacity-70 transition-colors"
:aria-pressed="isRightCollapsed ? 'true' : 'false'"
:aria-label="isRightCollapsed ? '展開右側面板' : '收合右側面板'"
@click="toggleRightPane"
>
<svg
v-if="!isRightCollapsed"
xmlns="http://www.w3.org/2000/svg"
width="10"
height="14"
viewBox="0 0 576 1280"
>
<g transform="translate(576 0) scale(-1 1)">
<path
fill="currentColor"
d="M576 192v896q0 26-19 45t-45 19t-45-19L19 685Q0 666 0 640t19-45l448-448q19-19 45-19t45 19t19 45"
/>
</g>
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="10"
height="14"
viewBox="0 0 576 1280"
>
<path
fill="currentColor"
d="M576 192v896q0 26-19 45t-45 19t-45-19L19 685Q0 666 0 640t19-45l448-448q19-19 45-19t45 19t19 45"
/>
</svg>
</button>
<MapPane
class="order-1 lg:order-2"
class="h-full w-full"
ref="mapPaneRef"
v-model:infoMode="infoMode"
:dataset="FACILITY_DATA"
@select-facility="selectFacility"
/>
<!-- 三個表格區今日活動 / 今日異常事件 / 今日派車總表 -->
<section class="flex flex-col gap-2 order-4 lg:order-3">
</div>
<!-- 三表格flex-basis + opacity 過渡 <transition> 滑出/滑入 -->
<transition name="slide-pane" appear>
<aside
v-show="!isRightCollapsed"
class="h-full min-h-0 overflow-hidden min-w-0 will-change-[flex-basis,opacity] transition-[flex-basis,opacity] duration-400 ease-in-out basis-full lg:basis-1/2"
:class="
isRightCollapsed
? 'lg:basis-0 opacity-0 pointer-events-none'
: 'lg:basis-1/2 opacity-100 pointer-events-auto'
"
:aria-hidden="isRightCollapsed ? 'true' : 'false'"
>
<div
class="flex-1 min-h-0 overflow-y-auto flex flex-col gap-2 h-full"
>
<TodayActivitiesSection
:rows="rows"
:page-size="10"
@ -98,6 +155,10 @@
/>
<TodayIncidentsSection :rows="incidentRows" :page-size="10" />
<TodayDispatchSection :rows="dispatchRows" :page-size="10" />
</div>
</aside>
</transition>
</div>
</section>
</section>
</template>
@ -147,8 +208,6 @@ const FACILITY_ASSETS = {
"F 機構": `${base}img/home/building/building_7.jpg`,
};
// ===== Diet residents.current =====
function roundToIntArray(values) {
//
@ -243,6 +302,11 @@ const activeKey = ref("residents"); // 圖表目前指標
const selectedFacility = ref("ALL"); //
const infoMode = ref("none"); // 'none' | 'residents' | 'diet'
const isRightCollapsed = ref(false);
const toggleRightPane = () => {
isRightCollapsed.value = !isRightCollapsed.value;
};
// ===== Computed =====
const currentFacility = computed(() => {
const data = FACILITY_DATA[selectedFacility.value] || FACILITY_DATA.ALL;
@ -546,6 +610,9 @@ const pagedDispatchRows = computed(() =>
(dispatchPage.value - 1) * dispatchPageSize + dispatchPageSize
)
);
// map MapPane.vue defineExpose
const mapPaneRef = ref(null);
</script>
<style scoped>
@ -626,4 +693,25 @@ const pagedDispatchRows = computed(() =>
box-shadow: none !important;
outline: none !important;
}
/* 右側面板滑入/滑出(配合 v-show 的 <transition> */
.slide-pane-enter-active,
.slide-pane-leave-active {
transition: transform 260ms ease-in-out, opacity 260ms ease-in-out;
}
.slide-pane-enter-from,
.slide-pane-leave-to {
transform: translateX(16px);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.slide-pane-enter-active,
.slide-pane-leave-active {
transition: opacity 120ms linear;
}
.slide-pane-enter-from,
.slide-pane-leave-to {
transform: none;
}
}
</style>

View File

@ -74,7 +74,7 @@
<td class="truncate">{{ row.name }}</td>
<td>{{ row.type }}</td>
<td class="truncate">{{ row.desc }}</td>
<td class="text-brand-red">
<td class="text-brand-red-dark">
{{ row.overdueDays ?? 0 }}
</td>
</tr>

27
src/stores/notif.js Normal file
View File

@ -0,0 +1,27 @@
import { defineStore } from "pinia";
export const useNotifStore = defineStore("notif", {
state: () => ({
isOpen: false,
_lastActiveEl: null, // 可選:關閉後把焦點還回觸發鈴鐺
}),
actions: {
open() {
this._lastActiveEl = document.activeElement;
this.isOpen = true;
document.documentElement.style.overflow = "hidden"; // 鎖背景捲動
},
close() {
this.isOpen = false;
document.documentElement.style.overflow = "";
// 還原焦點a11y
if (this._lastActiveEl && this._lastActiveEl.focus) {
this._lastActiveEl.focus();
}
this._lastActiveEl = null;
},
toggle() {
this.isOpen ? this.close() : this.open();
},
},
});

52
src/utils/versionGuard.js Normal file
View File

@ -0,0 +1,52 @@
// src/utils/versionGuard.js
const VERSION_URL = '/version.json';
const LS_KEY = '__app_version__';
const SESSION_REFRESH_FLAG = '__just_refreshed__';
async function fetchRemoteVersion() {
const res = await fetch(VERSION_URL, { cache: 'no-store' }); // ★ 關鍵:不快取
if (!res.ok) throw new Error('version.json fetch failed');
return res.json();
}
async function unregisterAllSW() {
if (!('serviceWorker' in navigator)) return;
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map(r => r.unregister()));
}
async function clearCacheStorage() {
if (!('caches' in window)) return;
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
}
export async function ensureFreshAssets() {
try {
const prev = localStorage.getItem(LS_KEY);
const remote = await fetchRemoteVersion();
const ver = remote.version || remote.buildId || remote.commit;
if (!ver) return;
if (prev && prev !== ver) {
await unregisterAllSW();
await clearCacheStorage();
sessionStorage.setItem(SESSION_REFRESH_FLAG, '1');
// 用 replace 避免回上一頁又載到舊 index.html
location.replace(location.href.split('#')[0]);
return;
}
if (!prev) localStorage.setItem(LS_KEY, ver);
} catch (e) {
console.warn('[versionGuard] check failed:', e);
}
}
export async function syncLocalVersion() {
try {
const remote = await fetchRemoteVersion();
const ver = remote.version || remote.buildId || remote.commit;
if (ver) localStorage.setItem(LS_KEY, ver);
} catch {}
}

View File

@ -46,7 +46,7 @@ module.exports = {
lighter: "#F2F9F6",
dark: "#0CA99C",
},
red: "#FF8678",
red: { DEFAULT: "#FF8678", dark: "#F26252" },
purple: {
DEFAULT: "#A5BEFF",
light: "#D5E1FF",