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>
|
||||
<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 -->
|
||||
<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>
|
||||
<GlobalNotifModal
|
||||
v-model:isOpen="notif.isOpen"
|
||||
:rows="rows"
|
||||
:page-size="10"
|
||||
@row-click="onTodoRowClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -117,6 +20,7 @@ import { ref, computed, defineAsyncComponent } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useNotifStore } from "@/stores/notif";
|
||||
import { NURSING_TODOS_SEED } from "@/constants/mocks/facilityData";
|
||||
import GlobalNotifModal from "@/components/common/modals/GlobalNotifModal.vue";
|
||||
|
||||
// ===== Layout 動態載入 =====
|
||||
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