feat: 新增 Global modals

This commit is contained in:
MJM_2025_05\polly 2025-09-24 14:12:52 +08:00
parent 144bd66de7
commit b83edb0c97
3 changed files with 433 additions and 106 deletions

View File

@ -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>
<!-- 全域通知 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>
<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 = {

View 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>

View 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>