ab24f210a6
## 新增功能 - 會員與教練在 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>
192 lines
7.9 KiB
Vue
192 lines
7.9 KiB
Vue
<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>
|