Files
CFDivePlatform/frontend/src/views/MyBookingsView.vue
T
a620906209 ab24f210a6 feat: 即時預約聊天室(realtime booking chat)
## 新增功能
- 會員與教練在 confirmed 預約期間可互傳文字與圖片訊息
- Presence Channel 顯示對方在線狀態(即時加入/離線)
- 已讀回執:對方讀取訊息後即時更新
- 預約完成(completed)後訊息封存唯讀
- 圖片上傳使用 intervention/image 處理(移除 EXIF、限制尺寸、強制重新編碼)

## 通知系統
- 收到新訊息時 Bell Icon 即時更新(NotificationCreated via private channel)
- 預約列表卡片顯示未讀角標(GET /api/bookings/messages/unread-counts 批次查詢)
- 瀏覽器分頁在背景時推送 Web Notification

## 基礎建設
- 引入 Laravel Reverb 作為自架 WebSocket 伺服器
- docker-compose 新增 reverb service(連接 proxy_net,供 NPM 代理)
- 前端安裝 laravel-echo + pusher-js,初始化 Echo plugin
- Dockerfile 補 GD JPEG/WebP/FreeType 支援

## 清理
- 移除 test_broadcast.php 與 resources/js/echo.js(Blade 殘留)
- 移除 /ping、/testpost 測試路由
- channels.php 改用 class-based 授權語法,移除 debug log
- BookingChat.vue otherType 提取為 computed 消除重複
- docker-entrypoint.sh 健康檢查改用 env var 取代硬編碼密碼

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 03:59:14 +08:00

192 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted } from 'vue'
import { getMyBookings, cancelBooking } from '../api/bookingApi'
import BookingChat from '../components/BookingChat.vue'
import { useBookingUnreadCounts } from '../composables/useBookingUnreadCounts'
import api from '../api/axios'
const bookings = ref([])
const loading = ref(true)
const error = ref('')
const expanded = ref(new Set())
const { counts: unreadCounts, clearCount, startPolling } = useBookingUnreadCounts(api)
const STATUS_LABEL = {
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600', hint: '' },
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600', hint: '教練無法接受此預約' },
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400', hint: '超過 48 小時未獲確認,預約自動取消' },
member_cancelled: { text: '已取消', color: 'bg-gray-100 text-gray-500', hint: '' },
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600', hint: '教練因故取消此預約' },
}
onMounted(async () => {
try {
const res = await getMyBookings()
bookings.value = res.data.data
} catch {
error.value = '無法載入預約記錄'
} finally {
loading.value = false
}
startPolling()
})
function toggle(id) {
if (expanded.value.has(id)) expanded.value.delete(id)
else expanded.value.add(id)
}
async function doCancel(booking) {
if (!confirm('確定要取消此預約?')) return
try {
await cancelBooking(booking.id)
booking.status = 'member_cancelled'
} catch (e) {
alert(e.response?.data?.message || '取消失敗')
}
}
function canCancel(status) {
return status === 'pending' || status === 'confirmed'
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${String(d.getDate()).padStart(2,'0')}`
}
</script>
<template>
<main class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-gray-800 mb-6">我的預約</h1>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="error" class="text-center text-red-500 py-10">{{ error }}</div>
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">
目前沒有預約記錄<RouterLink to="/courses" class="text-ocean-600 underline">瀏覽課程</RouterLink>
</div>
<div v-else class="space-y-3">
<div
v-for="b in bookings"
:key="b.id"
class="bg-white rounded-2xl shadow border border-gray-100 overflow-hidden"
>
<!-- 摘要列點擊展開 -->
<button
class="w-full text-left px-5 py-4 flex items-center justify-between gap-4 hover:bg-gray-50 transition"
@click="toggle(b.id)"
>
<div class="flex-1 min-w-0">
<p class="font-semibold text-gray-800 truncate">{{ b.offer_title }}</p>
<p class="text-sm text-gray-500 mt-0.5">
{{ b.scheduled_date }} {{ b.start_time }}
{{ b.participants }}
NT$ {{ b.total_price?.toLocaleString() }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<!-- 未讀訊息角標 -->
<span
v-if="(unreadCounts[b.id] ?? 0) > 0"
class="flex items-center gap-1 bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full leading-none"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
{{ unreadCounts[b.id] }}
</span>
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
{{ STATUS_LABEL[b.status]?.text || b.status }}
</span>
<span class="text-gray-400 text-sm">{{ expanded.has(b.id) ? '▲' : '▼' }}</span>
</div>
</button>
<!-- 展開詳情 -->
<div v-if="expanded.has(b.id)" class="border-t border-gray-100 px-5 py-4 space-y-4 bg-gray-50">
<!-- 狀態說明 -->
<div v-if="STATUS_LABEL[b.status]?.hint"
class="flex items-center gap-2 text-sm rounded-lg px-3 py-2"
:class="b.status === 'pending' ? 'bg-yellow-50 text-yellow-700' : b.status === 'confirmed' ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
>
<span>{{ b.status === 'pending' ? '⏳' : b.status === 'confirmed' ? '✅' : '️' }}</span>
<span>{{ STATUS_LABEL[b.status].hint }}</span>
</div>
<!-- 課程與時段資訊 -->
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div>
<p class="text-gray-400 text-xs mb-0.5">課程名稱</p>
<p class="text-gray-700 font-medium">{{ b.offer_title }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">地點</p>
<p class="text-gray-700">{{ b.offer_location || '—' }}
<span v-if="b.offer_region" class="text-gray-400">{{ b.offer_region }}</span>
</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">上課日期</p>
<p class="text-gray-700">{{ b.scheduled_date }} {{ b.start_time }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">預約人數</p>
<p class="text-gray-700">{{ b.participants }} </p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">課程單價</p>
<p class="text-gray-700">NT$ {{ b.offer_price?.toLocaleString() }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">總金額</p>
<p class="text-gray-800 font-semibold">NT$ {{ b.total_price?.toLocaleString() }}</p>
</div>
<div v-if="b.notes" class="col-span-2">
<p class="text-gray-400 text-xs mb-0.5">備注</p>
<p class="text-gray-600">{{ b.notes }}</p>
</div>
<div class="col-span-2">
<p class="text-gray-400 text-xs mb-0.5">預約時間</p>
<p class="text-gray-500 text-xs">{{ b.created_at ? new Date(b.created_at).toLocaleString('zh-TW') : '—' }}</p>
</div>
</div>
<!-- 即時訊息confirmed / completed -->
<BookingChat
v-if="b.status === 'confirmed' || b.status === 'completed'"
:bookingId="b.id"
:bookingStatus="b.status"
currentUserType="member"
@read="clearCount(b.id)"
/>
<!-- 操作按鈕列 -->
<div class="flex items-center justify-between pt-1">
<RouterLink
v-if="b.offer_id"
:to="`/courses/${b.offer_id}`"
class="text-sm text-ocean-600 hover:text-ocean-800 hover:underline"
>
查看課程介紹
</RouterLink>
<span v-else></span>
<button
v-if="canCancel(b.status)"
@click="doCancel(b)"
class="text-sm text-red-500 hover:text-red-700 underline"
>
取消預約
</button>
</div>
</div>
</div>
</div>
</main>
</template>