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:
+4
-14
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user