feat:實作通知系統 — 站內通知、Email 通知、Polling 機制

後端
- 新增 6 個 Notification class(預約建立/確認/拒絕/取消/完成、收到評價),database + mail 雙 channel
- 新增 NotificationController(list / unread-count / markRead / markAllRead / destroy)
- 整合通知觸發至 MemberBookingController、ProviderBookingController、CompleteFinishedBookings、ReviewController
- 新增 notifications / jobs / failed_jobs migration
- Docker Compose 加入 queue-worker、mailpit service
- DivingOffer 補上 provider() 關聯

前端
- 新增 notificationStore(Polling 30s/60s 自適應 + Page Visibility API)
- 新增 NotificationBell(未讀 Badge)、NotificationDrawer(側邊通知中心)
- main.js:auth store init 前置於 router.use(),修正 beforeEach guard 時序問題
- notificationAxios:依路徑動態選擇 member/coach token
- NotificationDrawer:改用 new URL().pathname 提取 action_url 路徑

OpenSpec
- 歸檔 notification-system change
- 同步 notification-core / notification-email / notification-triggers specs 至主規格
- 更新 booking-lifecycle / review-lifecycle spec(補充通知觸發 requirement)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:26:14 +08:00
parent 4baa4cb52b
commit 03f8caf3e9
46 changed files with 2709 additions and 21 deletions
+4 -14
View File
@@ -1,21 +1,10 @@
<script setup>
import { computed, onMounted } from 'vue'
import { useAuthStore } from './stores/auth'
import { useCoachAuthStore } from './stores/coachAuth'
import { useAdminAuthStore } from './stores/adminAuth'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import NavBar from './components/NavBar.vue'
import NotificationDrawer from './components/NotificationDrawer.vue'
const auth = useAuthStore()
const coachAuth = useCoachAuthStore()
const adminAuth = useAdminAuthStore()
const route = useRoute()
onMounted(() => {
auth.init()
coachAuth.init()
adminAuth.init()
})
const route = useRoute()
const isBackofficePage = computed(() =>
route.path.startsWith('/coach') || route.path.startsWith('/admin')
@@ -26,5 +15,6 @@ const isBackofficePage = computed(() =>
<div class="min-h-screen bg-gray-50">
<NavBar v-if="!isBackofficePage" />
<RouterView />
<NotificationDrawer />
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios'
const notificationApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
notificationApi.interceptors.request.use((config) => {
// 優先用 coach_token,因為 coach 身份通知優先;member 也可用自己的 token
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
const isCoachPage = window.location.pathname.startsWith('/coach')
const token = isCoachPage
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default notificationApi
+2
View File
@@ -1,6 +1,7 @@
<script setup>
import { useCoachAuthStore } from '../stores/coachAuth'
import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const coachAuth = useCoachAuthStore()
const router = useRouter()
@@ -27,6 +28,7 @@ async function handleLogout() {
<div class="flex items-center gap-4 text-sm">
<span class="text-gray-400">{{ coachAuth.user?.name }}</span>
<NotificationBell />
<button
@click="handleLogout"
class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition"
+2
View File
@@ -1,6 +1,7 @@
<script setup>
import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const auth = useAuthStore()
const router = useRouter()
@@ -27,6 +28,7 @@ async function handleLogout() {
</span>
<RouterLink to="/my-bookings" class="hover:text-ocean-100 transition">我的預約</RouterLink>
<RouterLink to="/profile" class="hover:text-ocean-100 transition">個人資料</RouterLink>
<NotificationBell />
<button
@click="handleLogout"
class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition"
@@ -0,0 +1,32 @@
<script setup>
import { useNotificationStore } from '../stores/notifications'
const store = useNotificationStore()
function toggle() {
if (!store.isOpen) {
store.fetchNotifications()
}
store.isOpen = !store.isOpen
}
</script>
<template>
<button
@click="toggle"
class="relative p-2 rounded-full hover:bg-white/10 transition"
aria-label="通知"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0a3 3 0 11-6 0m6 0H9" />
</svg>
<span
v-if="store.unreadCount > 0"
class="absolute -top-0.5 -right-0.5 min-w-[1.1rem] h-[1.1rem] flex items-center justify-center
bg-red-500 text-white text-[10px] font-bold rounded-full px-0.5 leading-none"
>
{{ store.unreadCount > 99 ? '99+' : store.unreadCount }}
</span>
</button>
</template>
@@ -0,0 +1,115 @@
<script setup>
import { useNotificationStore } from '../stores/notifications'
import { useRouter } from 'vue-router'
const store = useNotificationStore()
const router = useRouter()
function formatTime(iso) {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
if (m < 1) return '剛剛'
if (m < 60) return `${m} 分鐘前`
const h = Math.floor(m / 60)
if (h < 24) return `${h} 小時前`
return `${Math.floor(h / 24)} 天前`
}
function truncate(text, max = 80) {
return text && text.length > max ? text.slice(0, max) + '…' : text
}
async function clickItem(item) {
await store.markRead(item.id)
store.isOpen = false
if (item.action_url) {
try {
const path = new URL(item.action_url).pathname
await router.push(path)
} catch (e) {
console.error('[NotificationDrawer] navigation failed:', item.action_url, e)
}
}
}
</script>
<template>
<Teleport to="body">
<Transition name="drawer">
<div v-if="store.isOpen" class="fixed inset-0 z-50 flex justify-end">
<div class="absolute inset-0 bg-black/30" @click="store.isOpen = false" />
<div class="relative w-80 sm:w-96 h-full bg-white shadow-2xl flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b">
<h2 class="font-semibold text-gray-800">通知</h2>
<div class="flex items-center gap-2">
<button
v-if="store.unreadCount > 0"
@click="store.markAllRead()"
class="text-xs text-blue-600 hover:underline"
>
全部標為已讀
</button>
<button @click="store.isOpen = false" class="text-gray-400 hover:text-gray-600 p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<p v-if="store.notifications.length === 0" class="text-center text-gray-400 text-sm py-12">
目前沒有通知
</p>
<ul v-else>
<li
v-for="item in store.notifications"
:key="item.id"
class="flex items-start gap-3 px-4 py-3 border-b hover:bg-gray-50 transition cursor-pointer"
:class="{ 'bg-blue-50': !item.read_at }"
@click="clickItem(item)"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ item.title }}</p>
<p class="text-xs text-gray-500 mt-0.5 line-clamp-2">{{ truncate(item.body) }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ formatTime(item.created_at) }}</p>
</div>
<button
@click.stop="store.remove(item.id)"
class="shrink-0 text-gray-300 hover:text-gray-500 p-1 mt-0.5"
title="刪除"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
</ul>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active > div:last-child,
.drawer-leave-active > div:last-child {
transition: transform 0.2s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from > div:last-child,
.drawer-leave-to > div:last-child {
transform: translateX(100%);
}
</style>
+13 -1
View File
@@ -3,8 +3,20 @@ import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import { useCoachAuthStore } from './stores/coachAuth'
import { useAdminAuthStore } from './stores/adminAuth'
const app = createApp(App)
app.use(createPinia())
const pinia = createPinia()
app.use(pinia)
// 在 router 安裝前同步初始化所有 auth store,
// 確保 beforeEach guard 跑時 isLoggedIn 已反映 localStorage 的實際狀態
useAuthStore().init()
useCoachAuthStore().init()
useAdminAuthStore().init()
app.use(router)
app.mount('#app')
+4
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api/axios'
import { useNotificationStore } from './notifications'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
@@ -14,6 +15,7 @@ export const useAuthStore = defineStore('auth', () => {
if (saved) {
token.value = saved
user.value = savedUser ? JSON.parse(savedUser) : null
useNotificationStore().startPolling()
}
}
@@ -22,12 +24,14 @@ export const useAuthStore = defineStore('auth', () => {
token.value = tokenValue
localStorage.setItem('token', tokenValue)
localStorage.setItem('user', JSON.stringify(userData))
useNotificationStore().startPolling()
}
async function logout() {
try {
await api.post('/member/logout')
} catch {}
useNotificationStore().stopPolling()
user.value = null
token.value = null
localStorage.removeItem('token')
+4
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import coachApi from '../api/coachAxios'
import { useNotificationStore } from './notifications'
export const useCoachAuthStore = defineStore('coachAuth', () => {
const user = ref(null)
@@ -14,6 +15,7 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
if (savedToken) {
token.value = savedToken
user.value = savedUser ? JSON.parse(savedUser) : null
useNotificationStore().startPolling()
}
}
@@ -22,12 +24,14 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
token.value = tokenValue
localStorage.setItem('coach_token', tokenValue)
localStorage.setItem('coach_user', JSON.stringify(userData))
useNotificationStore().startPolling()
}
async function logout() {
try {
await coachApi.post('/provider/logout')
} catch {}
useNotificationStore().stopPolling()
user.value = null
token.value = null
localStorage.removeItem('coach_token')
+115
View File
@@ -0,0 +1,115 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/notificationAxios'
export const useNotificationStore = defineStore('notifications', () => {
const unreadCount = ref(0)
const notifications = ref([])
const isOpen = ref(false)
let intervalId = null
let currentInterval = null
let visibilityHandler = null
async function fetchUnreadCount() {
try {
const res = await api.get('/notifications/unread-count')
const newCount = res.data?.data?.count ?? 0
if (newCount !== unreadCount.value) {
const wasZero = unreadCount.value === 0
unreadCount.value = newCount
if ((wasZero && newCount > 0) || (!wasZero && newCount === 0)) {
restartInterval()
}
}
} catch (e) {
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
}
}
async function fetchNotifications() {
try {
const res = await api.get('/notifications')
notifications.value = res.data.data
unreadCount.value = res.data.unread_count
} catch (e) {
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
}
}
function getInterval() {
return unreadCount.value > 0 ? 30000 : 60000
}
function restartInterval() {
if (intervalId) clearInterval(intervalId)
const ms = getInterval()
currentInterval = ms
intervalId = setInterval(fetchUnreadCount, ms)
}
function startPolling() {
fetchUnreadCount()
restartInterval()
visibilityHandler = () => {
if (document.visibilityState === 'hidden') {
if (intervalId) clearInterval(intervalId)
intervalId = null
} else {
fetchUnreadCount()
restartInterval()
}
}
document.addEventListener('visibilitychange', visibilityHandler)
}
function stopPolling() {
if (intervalId) clearInterval(intervalId)
intervalId = null
if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler)
visibilityHandler = null
}
unreadCount.value = 0
notifications.value = []
isOpen.value = false
}
async function markRead(id) {
const n = notifications.value.find(n => n.id === id)
if (n && !n.read_at) {
n.read_at = new Date().toISOString()
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
try {
await api.patch(`/notifications/${id}/read`)
} catch {}
}
async function markAllRead() {
notifications.value.forEach(n => {
if (!n.read_at) n.read_at = new Date().toISOString()
})
unreadCount.value = 0
try {
await api.patch('/notifications/read-all')
} catch {}
}
async function remove(id) {
const n = notifications.value.find(n => n.id === id)
if (n && !n.read_at) unreadCount.value = Math.max(0, unreadCount.value - 1)
notifications.value = notifications.value.filter(n => n.id !== id)
try {
await api.delete(`/notifications/${id}`)
} catch {}
}
return {
unreadCount, notifications, isOpen,
fetchNotifications, fetchUnreadCount,
startPolling, stopPolling,
markRead, markAllRead, remove,
}
})