feat: 新增 Global modals
This commit is contained in:
parent
144bd66de7
commit
b83edb0c97
116
src/App.vue
116
src/App.vue
@ -1,115 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter">
|
<section
|
||||||
|
class="bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter"
|
||||||
|
>
|
||||||
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
|
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
|
||||||
<component :is="layoutComponent" />
|
<component :is="layoutComponent" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 全域通知 Modal:Teleport 到 body -->
|
<!-- 全域通知 Modal:Teleport 到 body -->
|
||||||
<Teleport to="body">
|
<GlobalNotifModal
|
||||||
<div
|
v-model:isOpen="notif.isOpen"
|
||||||
v-if="notif.isOpen"
|
:rows="rows"
|
||||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
:page-size="10"
|
||||||
role="dialog"
|
@row-click="onTodoRowClick"
|
||||||
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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -117,6 +20,7 @@ import { ref, computed, defineAsyncComponent } from "vue";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useNotifStore } from "@/stores/notif";
|
import { useNotifStore } from "@/stores/notif";
|
||||||
import { NURSING_TODOS_SEED } from "@/constants/mocks/facilityData";
|
import { NURSING_TODOS_SEED } from "@/constants/mocks/facilityData";
|
||||||
|
import GlobalNotifModal from "@/components/common/modals/GlobalNotifModal.vue";
|
||||||
|
|
||||||
// ===== Layout 動態載入 =====
|
// ===== Layout 動態載入 =====
|
||||||
const layouts = {
|
const layouts = {
|
||||||
|
286
src/components/common/modals/GlobalCalendarModal.vue
Normal file
286
src/components/common/modals/GlobalCalendarModal.vue
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
<script setup>
|
||||||
|
/** ===================== 匯入 ===================== */
|
||||||
|
import { computed, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import FullCalendar from "@fullcalendar/vue3";
|
||||||
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
|
import listPlugin from "@fullcalendar/list";
|
||||||
|
import zhTw from "@fullcalendar/core/locales/zh-tw";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
|
import weekOfYear from "dayjs/plugin/weekOfYear";
|
||||||
|
import { brand } from "@/styles/palette";
|
||||||
|
|
||||||
|
dayjs.extend(isBetween);
|
||||||
|
dayjs.extend(weekOfYear);
|
||||||
|
|
||||||
|
/** ===================== Props / Emits ===================== */
|
||||||
|
const props = defineProps({
|
||||||
|
/** v-model 開關 */
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
/** Modal 標題 */
|
||||||
|
title: { type: String, default: "今日活動行事曆" },
|
||||||
|
/** 今日活動 rows:[{ id, time, org, title }] */
|
||||||
|
todayRows: { type: Array, default: () => [] },
|
||||||
|
/** 初始日期(YYYY-MM-DD) */
|
||||||
|
initialDate: { type: String, default: () => dayjs().format("YYYY-MM-DD") },
|
||||||
|
/** 是否自動補齊當月每週事件 */
|
||||||
|
includeMonthEvents: { type: Boolean, default: true },
|
||||||
|
/** 額外覆寫 FullCalendar options(會 merge 到預設 options 上) */
|
||||||
|
extraOptions: { type: Object, default: () => ({}) },
|
||||||
|
/** 是否允許點擊遮罩關閉 / ESC 關閉 */
|
||||||
|
closeOnBackdrop: { type: Boolean, default: true },
|
||||||
|
closeOnEsc: { type: Boolean, default: true },
|
||||||
|
/** 主題 / 尺寸 / 按鈕 變數(不傳則用 brand 預設) */
|
||||||
|
themeVars: { type: Object, default: () => ({}) },
|
||||||
|
sizeVars: { type: Object, default: () => ({}) },
|
||||||
|
buttonVars: { type: Object, default: () => ({}) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:open", "close", "event-click"]);
|
||||||
|
|
||||||
|
/** ===================== 關閉行為 ===================== */
|
||||||
|
function close() {
|
||||||
|
emit("update:open", false);
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
function onBackdropClick() {
|
||||||
|
if (props.closeOnBackdrop) close();
|
||||||
|
}
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (props.closeOnEsc && e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("keydown", onKeydown);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("keydown", onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ===================== 事件產生器(搬自 index.vue) ===================== */
|
||||||
|
const FAKE_TITLES = [
|
||||||
|
"復健團體活動",
|
||||||
|
"長照照護法規研習",
|
||||||
|
"營養衛教與點心時間",
|
||||||
|
"消防夜間演練說明會",
|
||||||
|
"CPR 教育訓練",
|
||||||
|
"全人評估相關課程",
|
||||||
|
];
|
||||||
|
const ORGS = ["A 機構", "B 機構", "C 機構", "D 機構", "E 機構", "F 機構"];
|
||||||
|
const SLOT_TIMES = ["09:15", "10:30", "13:00", "14:30", "15:45"];
|
||||||
|
|
||||||
|
function startOfWeek(d) {
|
||||||
|
const dow = d.day();
|
||||||
|
return d.subtract(dow, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDayEvents(isoDate, seedOffset = 0) {
|
||||||
|
const count = 2 + ((dayjs(isoDate).date() + seedOffset) % 2); // 2~3 筆
|
||||||
|
const events = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const org = ORGS[(dayjs(isoDate).date() + i + seedOffset) % ORGS.length];
|
||||||
|
const title = FAKE_TITLES[(dayjs(isoDate).date() * 7 + i + seedOffset) % FAKE_TITLES.length];
|
||||||
|
const time = SLOT_TIMES[(i + seedOffset) % SLOT_TIMES.length];
|
||||||
|
events.push({ id: `${isoDate}-${i}-${org}`, title: `[${org}] ${title}`, start: `${isoDate}T${time}` });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今天 rows → 轉 Calendar 事件
|
||||||
|
const baseTodayEvents = computed(() =>
|
||||||
|
(props.todayRows || []).map((r) => ({ id: `today-${r.id}`, title: `[${r.org}] ${r.title}`, start: `${props.initialDate}T${r.time}` }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 當月:每週 2 天活動(本週包含「今天 + 另一天」)
|
||||||
|
const monthEvents = computed(() => {
|
||||||
|
if (!props.includeMonthEvents) return [];
|
||||||
|
const todayD = dayjs(props.initialDate);
|
||||||
|
const mStart = todayD.startOf("month");
|
||||||
|
const mEnd = todayD.endOf("month");
|
||||||
|
|
||||||
|
const weekStarts = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let cursor = mStart;
|
||||||
|
while (cursor.isBefore(mEnd) || cursor.isSame(mEnd, "day")) {
|
||||||
|
const ws = startOfWeek(cursor).format("YYYY-MM-DD");
|
||||||
|
if (!seen.has(ws)) { seen.add(ws); weekStarts.push(dayjs(ws)); }
|
||||||
|
cursor = cursor.add(1, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (const ws of weekStarts) {
|
||||||
|
const weekStart = ws;
|
||||||
|
const weekEnd = ws.add(6, "day");
|
||||||
|
const isCurrentWeek = todayD.isSame(weekStart, "day") || (todayD.isAfter(weekStart, "day") && todayD.isBefore(weekEnd, "day")) || todayD.isSame(weekEnd, "day");
|
||||||
|
|
||||||
|
const defaultDays = [weekStart.add(2, "day"), weekStart.add(4, "day")]; // 週二、週四
|
||||||
|
const inMonth = (d) => d.isBetween(mStart.subtract(1, "day"), mEnd.add(1, "day"), "day");
|
||||||
|
|
||||||
|
let targetDays;
|
||||||
|
if (isCurrentWeek) {
|
||||||
|
let other = defaultDays[1].isSame(todayD, "day") ? defaultDays[0] : defaultDays[1];
|
||||||
|
if (!inMonth(other)) other = defaultDays[0];
|
||||||
|
if (!inMonth(other)) other = weekStart.add(1, "day");
|
||||||
|
targetDays = [todayD, other].sort((a, b) => a.valueOf() - b.valueOf());
|
||||||
|
} else {
|
||||||
|
targetDays = defaultDays.filter((d) => inMonth(d));
|
||||||
|
let k = 1;
|
||||||
|
while (targetDays.length < 2 && k <= 5) {
|
||||||
|
const candidate = weekStart.add(k, "day");
|
||||||
|
if (inMonth(candidate) && !targetDays.some((d) => d.isSame(candidate, "day"))) targetDays.push(candidate);
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of targetDays) {
|
||||||
|
const iso = d.format("YYYY-MM-DD");
|
||||||
|
if (iso === props.initialDate) continue;
|
||||||
|
result.push(...buildDayEvents(iso, d.week()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 最終事件
|
||||||
|
const calendarEvents = computed(() => [ ...baseTodayEvents.value, ...monthEvents.value ]);
|
||||||
|
|
||||||
|
/** ===================== FullCalendar 樣式變數(預設 + 外部覆寫) ===================== */
|
||||||
|
const defaultThemeVars = computed(() => ({
|
||||||
|
"--fc-page-font-family": '"Noto Sans TC","Noto Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang TC","Microsoft JhengHei",sans-serif',
|
||||||
|
"--fc-event-bg-color": brand.purpleLight,
|
||||||
|
"--fc-event-border-color": brand.purple,
|
||||||
|
"--fc-event-text-color": brand.black,
|
||||||
|
"--fc-button-text-color": brand.white,
|
||||||
|
"--fc-button-bg-color": brand.purpleDark,
|
||||||
|
"--fc-button-border-color": brand.purpleDark,
|
||||||
|
"--fc-button-hover-bg-color": brand.purple,
|
||||||
|
"--fc-button-hover-border-color": brand.purple,
|
||||||
|
"--fc-button-active-bg-color": brand.purple,
|
||||||
|
"--fc-button-active-border-color": brand.purple,
|
||||||
|
"--fc-today-bg-color": `${brand.yellow}30`,
|
||||||
|
"--fc-neutral-bg-color": brand.grayLighter,
|
||||||
|
"--fc-neutral-text-color": brand.gray,
|
||||||
|
"--fc-border-color": brand.grayLight,
|
||||||
|
"--fc-list-event-hover-bg-color": brand.grayLighter,
|
||||||
|
"--fc-now-indicator-color": brand.red,
|
||||||
|
}));
|
||||||
|
const defaultSizeVars = computed(() => ({ fontSize: "14px" }));
|
||||||
|
const defaultButtonVars = computed(() => ({
|
||||||
|
"--btn-px": "12px",
|
||||||
|
"--btn-py": "8px",
|
||||||
|
"--btn-h": "32px",
|
||||||
|
"--btn-font": "14px",
|
||||||
|
"--btn-radius": "5px",
|
||||||
|
"--btn-minw": "40px",
|
||||||
|
"--btn-icon": "16px",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const styleVars = computed(() => ({
|
||||||
|
...defaultThemeVars.value,
|
||||||
|
...defaultSizeVars.value,
|
||||||
|
...defaultButtonVars.value,
|
||||||
|
...(props.themeVars || {}),
|
||||||
|
...(props.sizeVars || {}),
|
||||||
|
...(props.buttonVars || {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** ===================== FullCalendar options ===================== */
|
||||||
|
const baseOptions = computed(() => ({
|
||||||
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin],
|
||||||
|
locale: zhTw,
|
||||||
|
initialDate: props.initialDate,
|
||||||
|
initialView: "listWeek",
|
||||||
|
height: "100%",
|
||||||
|
expandRows: true,
|
||||||
|
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" },
|
||||||
|
selectable: false,
|
||||||
|
editable: false,
|
||||||
|
nowIndicator: true,
|
||||||
|
events: calendarEvents.value,
|
||||||
|
eventClick: (info) => emit("event-click", info),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mergedOptions = computed(() => ({
|
||||||
|
...baseOptions.value,
|
||||||
|
...(props.extraOptions || {}),
|
||||||
|
// 若外部也傳了 events,以外部為準
|
||||||
|
events: (props.extraOptions?.events ?? baseOptions.value.events) || [],
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="calendar-modal-title"
|
||||||
|
@keydown.stop
|
||||||
|
>
|
||||||
|
<!-- 遮罩 -->
|
||||||
|
<div class="absolute inset-0 bg-black/40" aria-hidden="true" @click="onBackdropClick" />
|
||||||
|
|
||||||
|
<!-- 內容框 -->
|
||||||
|
<div
|
||||||
|
class="relative z-[1] w-full max-w-4xl h-[85vh] rounded-xl bg-white shadow-xl border border-gray-200 p-4 md:p-6 flex flex-col gap-3"
|
||||||
|
:style="styleVars"
|
||||||
|
>
|
||||||
|
<!-- 標題列 -->
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<h3 id="calendar-modal-title" class="text-2xl font-bold">{{ title }}</h3>
|
||||||
|
<button type="button" class="px-3 py-1 rounded-md hover:bg-gray-100" aria-label="關閉" @click="close">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- FullCalendar -->
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<FullCalendar :options="mergedOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 去除 FullCalendar 按鈕預設外框陰影 */
|
||||||
|
:deep(.fc .fc-button:hover),
|
||||||
|
:deep(.fc .fc-button:focus),
|
||||||
|
:deep(.fc .fc-button:focus-visible) {
|
||||||
|
box-shadow: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
/* 小動畫更順眼 */
|
||||||
|
:deep(.fc .fc-button) {
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
/* 使用外部注入的變數控制按鈕尺寸 */
|
||||||
|
:deep(.fc .fc-button) {
|
||||||
|
padding: var(--btn-py);
|
||||||
|
height: var(--btn-h);
|
||||||
|
font-size: var(--btn-font);
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: var(--btn-radius);
|
||||||
|
}
|
||||||
|
:deep(.fc .fc-toolbar .fc-button-group .fc-button) {
|
||||||
|
min-width: var(--btn-minw);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
:deep(.fc .fc-prev-button),
|
||||||
|
:deep(.fc .fc-next-button) {
|
||||||
|
width: var(--btn-h);
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
:deep(.fc .fc-button .fc-icon) {
|
||||||
|
font-size: var(--btn-icon);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
137
src/components/common/modals/GlobalNotifModal.vue
Normal file
137
src/components/common/modals/GlobalNotifModal.vue
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<!-- src/components/common/modals/GlobalNotifModal.vue -->
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import Table from "@/components/common/table/Table.vue";
|
||||||
|
|
||||||
|
/** ===================== Props / Emits ===================== */
|
||||||
|
const props = defineProps({
|
||||||
|
/** Modal 開關(支援 v-model:isOpen) */
|
||||||
|
isOpen: { type: Boolean, required: true },
|
||||||
|
/** 標題 */
|
||||||
|
title: { type: String, default: "通知" },
|
||||||
|
/** 資料列 */
|
||||||
|
rows: { type: Array, default: () => [] },
|
||||||
|
/** 每頁筆數(傳給 Table 的 pageSize) */
|
||||||
|
pageSize: { type: Number, default: 10 },
|
||||||
|
|
||||||
|
/** 行為控制:點遮罩關閉 / ESC 關閉 */
|
||||||
|
closeOnBackdrop: { type: Boolean, default: true },
|
||||||
|
closeOnEsc: { type: Boolean, default: true },
|
||||||
|
|
||||||
|
/** 尺寸:'sm' | 'md' | 'lg' | 'xl' */
|
||||||
|
size: { type: String, default: "lg" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "row-click", "update:isOpen"]);
|
||||||
|
|
||||||
|
/** ===================== 關閉行為,對齊 Calendar Modal ===================== */
|
||||||
|
function close() {
|
||||||
|
emit("update:isOpen", false); // 支援 v-model:isOpen
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
function onBackdropClick() {
|
||||||
|
if (props.closeOnBackdrop) close();
|
||||||
|
}
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (props.closeOnEsc && e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||||
|
|
||||||
|
/** ===================== 欄位定義 ===================== */
|
||||||
|
const columns = [
|
||||||
|
{ key: "date", label: "日期", thClass: "px-3 py-2 text-left" },
|
||||||
|
{ key: "bed", label: "床位", thClass: "px-3 py-2 text-left font-mono" },
|
||||||
|
{ key: "name", label: "姓名", thClass: "px-3 py-2 text-left" },
|
||||||
|
{ key: "type", label: "類型", thClass: "px-3 py-2 text-left" },
|
||||||
|
{ key: "desc", label: "說明", thClass: "px-3 py-2 text-left" },
|
||||||
|
{ key: "overdueDays", label: "逾期天數", thClass: "px-3 py-2 text-left" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 每列樣式(hover 高亮、可點擊手感) */
|
||||||
|
const getRowClass = () =>
|
||||||
|
"transition-colors duration-150 hover:bg-brand-gray-lighter cursor-pointer";
|
||||||
|
|
||||||
|
/** 尺寸對應寬度(與 Calendar Modal 一致的寫法) */
|
||||||
|
const dialogClass = computed(() => {
|
||||||
|
const map = {
|
||||||
|
sm: "w-[92vw] sm:w-[88vw] md:w-[72vw] lg:w-[48vw] max-w-3xl",
|
||||||
|
md: "w-[92vw] sm:w-[86vw] md:w-[76vw] lg:w-[56vw] max-w-4xl",
|
||||||
|
lg: "w-[92vw] sm:w-[86vw] md:w-[80vw] lg:w-[64vw] max-w-5xl",
|
||||||
|
xl: "w-[92vw] sm:w-[86vw] md:w-[80vw] lg:w-[72vw] max-w-6xl",
|
||||||
|
};
|
||||||
|
return map[props.size] || map.lg;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="fixed inset-0 z-[2000] flex items-center justify-center p-4 overscroll-contain"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="notif-modal-title"
|
||||||
|
@keydown.stop
|
||||||
|
>
|
||||||
|
<!-- 背景遮罩(不改 body/html overflow,保留頁面捲軸) -->
|
||||||
|
<div class="absolute inset-0 bg-black/40" aria-hidden="true" @click="onBackdropClick" />
|
||||||
|
|
||||||
|
<!-- 內容框(自有滾動區域) -->
|
||||||
|
<div
|
||||||
|
class="relative z-[1] h-[80vh] rounded-xl bg-white shadow-xl border border-gray-200 p-6 flex flex-col gap-4"
|
||||||
|
:class="dialogClass"
|
||||||
|
>
|
||||||
|
<!-- 標題列 -->
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 id="notif-modal-title" class="text-2xl font-bold">{{ title }}</h3>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 rounded-md hover:bg-gray-100"
|
||||||
|
@click="close"
|
||||||
|
aria-label="關閉"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 副標題 -->
|
||||||
|
<h4 class="text-xl font-bold">未完成的知會事項與代辦事項</h4>
|
||||||
|
|
||||||
|
<!-- 表格區:自身滾動,不讓背景滾 -->
|
||||||
|
<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
|
||||||
|
:columns="columns"
|
||||||
|
:rows="rows"
|
||||||
|
row-key="id"
|
||||||
|
:tbody-row-class="getRowClass"
|
||||||
|
:pagination="true"
|
||||||
|
:page-size="pageSize"
|
||||||
|
@row-click="(row) => emit('row-click', row)"
|
||||||
|
>
|
||||||
|
<!-- 無資料 -->
|
||||||
|
<template #empty>
|
||||||
|
<tr>
|
||||||
|
<td :colspan="columns.length" class="py-6 text-center text-brand-gray">
|
||||||
|
目前沒有待辦。
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 避免滾動連鎖到背景(手勢在頂/底時不穿透) */
|
||||||
|
.overscroll-contain {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
Reference in New Issue
Block a user