feat: 首頁地圖展開與收闔功能
This commit is contained in:
parent
3a6b61db1d
commit
5cc0dcbf56
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,3 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/public/version.json
|
||||
|
@ -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>
|
||||
|
@ -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
29
scripts/write-version.mjs
Normal 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);
|
175
src/App.vue
175
src/App.vue
@ -3,13 +3,122 @@
|
||||
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
|
||||
<component :is="layoutComponent" />
|
||||
</section>
|
||||
|
||||
<!-- 全域通知 Modal:Teleport 到 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>
|
||||
|
@ -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;
|
||||
});
|
||||
|
||||
|
@ -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) Lifecycle(mounted/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 **/
|
||||
// --- helpers:tooltip 更新/綁定 ---
|
||||
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(() => {
|
||||
'© <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 () => {
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
37
src/main.js
37
src/main.js
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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
27
src/stores/notif.js
Normal 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
52
src/utils/versionGuard.js
Normal 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 {}
|
||||
}
|
@ -46,7 +46,7 @@ module.exports = {
|
||||
lighter: "#F2F9F6",
|
||||
dark: "#0CA99C",
|
||||
},
|
||||
red: "#FF8678",
|
||||
red: { DEFAULT: "#FF8678", dark: "#F26252" },
|
||||
purple: {
|
||||
DEFAULT: "#A5BEFF",
|
||||
light: "#D5E1FF",
|
||||
|
Loading…
Reference in New Issue
Block a user