feat:實作預約系統 — 時段管理、預約生命週期與前端整合

後端:
- 新增 course_schedules / bookings migration(含索引)
- BookingStatus / ScheduleStatus PHP BackedEnum
- CourseSchedule / Booking Model(七狀態機 VALID_TRANSITIONS)
- ScheduleController、ProviderBookingController、MemberBookingController
- 雙層名額驗證(API 層快速失敗 + DB lockForUpdate 防超賣)
- 24h 取消截止、pending 不佔位設計
- ExpirePendingBookings(每小時)/ CompleteFinishedBookings(每日)Scheduler
- Docker cron 配置、CACHE_STORE 改為 file 修正 502

前端:
- 課程詳情頁加入時段選擇與預約流程
- 我的預約頁(展開式卡片、狀態說明、連結課程詳情)
- Coach 時段管理(上午/下午時間選擇器、新課程引導)
- Coach 預約管理(依課程分組、待確認徽章)
- Navbar 新增「我的預約」與「時段/預約管理」入口
- 密碼格式提示與即時比對

OpenSpec:
- booking-system change 歸檔至 archive/2026-05-12-booking-system
- 新增 specs/course-scheduling 與 specs/booking-lifecycle 主規格

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:24:51 +08:00
parent 24145419b0
commit 88c0c09ccb
40 changed files with 2202 additions and 18 deletions
+87 -7
View File
@@ -2,24 +2,47 @@
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../api/axios'
import { getSchedulesByOffer } from '../api/scheduleApi'
import { createBooking } from '../api/bookingApi'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const offer = ref(null)
const loading = ref(true)
const notFound = ref(false)
const offer = ref(null)
const loading = ref(true)
const notFound = ref(false)
const schedules = ref([])
const selected = ref(null)
const participants = ref(1)
const booking = ref({ loading: false, success: false, error: '' })
onMounted(async () => {
try {
const res = await api.get(`/diving-offers/${route.params.id}`)
offer.value = res.data.data
const sRes = await getSchedulesByOffer(route.params.id)
schedules.value = sRes.data.data
} catch (e) {
notFound.value = true
} finally {
loading.value = false
}
})
async function submitBooking() {
if (!selected.value) return
booking.value = { loading: true, success: false, error: '' }
try {
await createBooking({ schedule_id: selected.value.id, participants: participants.value })
booking.value.success = true
} catch (e) {
booking.value.error = e.response?.data?.message || '預約失敗,請稍後再試'
} finally {
booking.value.loading = false
}
}
</script>
<template>
@@ -65,14 +88,71 @@ onMounted(async () => {
<p class="text-gray-700 leading-relaxed whitespace-pre-wrap">{{ offer.description || '暫無課程說明。' }}</p>
</div>
<div class="flex items-center justify-between bg-ocean-50 rounded-2xl p-6">
<div class="flex items-center justify-between bg-ocean-50 rounded-2xl p-6 mb-6">
<div>
<p class="text-sm text-gray-500">課程費用</p>
<p class="text-3xl font-bold text-ocean-800">NT$ {{ offer.price.toLocaleString() }}</p>
</div>
<button class="bg-ocean-700 hover:bg-ocean-600 text-white font-semibold px-8 py-3 rounded-full transition">
立即洽詢
</button>
</div>
<!-- 可用時段 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<div class="flex items-start gap-2 mb-4">
<h2 class="text-lg font-semibold text-gray-800">可預約時段</h2>
</div>
<div class="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4 text-sm text-amber-700">
<span></span>
<span>送出預約後需等待教練確認確認後才算預約成功</span>
</div>
<div v-if="schedules.length === 0" class="text-gray-400 text-sm">目前沒有開放時段</div>
<div v-else class="space-y-3">
<label
v-for="s in schedules"
:key="s.id"
class="flex items-center justify-between border rounded-xl px-4 py-3 cursor-pointer transition"
:class="selected?.id === s.id ? 'border-ocean-600 bg-ocean-50' : 'border-gray-200 hover:border-ocean-400'"
>
<div class="flex items-center gap-3">
<input type="radio" :value="s" v-model="selected" class="accent-ocean-600" />
<div>
<p class="font-medium text-gray-800">{{ s.scheduled_date }} {{ s.start_time }}</p>
<p class="text-sm text-gray-500">剩餘名額{{ s.remaining_spots }} </p>
</div>
</div>
<p class="text-ocean-700 font-semibold">NT$ {{ (offer.price * participants).toLocaleString() }}</p>
</label>
</div>
<!-- 人數選擇與預約按鈕 -->
<div v-if="selected" class="mt-5 border-t pt-4">
<div class="flex items-center gap-4 mb-4">
<label class="text-sm text-gray-600">預約人數</label>
<input
v-model.number="participants"
type="number"
min="1"
:max="selected.remaining_spots"
class="border rounded-lg px-3 py-1 w-20 text-center"
/>
</div>
<div v-if="booking.success" class="text-green-600 text-sm mb-3">✓ 預約已送出!請等待教練確認。前往 <RouterLink to="/my-bookings" class="underline">我的預約</RouterLink> 查看</div>
<div v-if="booking.error" class="text-red-500 text-sm mb-3">{{ booking.error }}</div>
<div v-if="!auth.isLoggedIn" class="text-sm text-gray-500">
請先 <RouterLink to="/login" class="text-ocean-600 underline">登入</RouterLink> 才能預約
</div>
<button
v-else
@click="submitBooking"
:disabled="booking.loading || booking.success"
class="w-full bg-ocean-700 hover:bg-ocean-600 disabled:opacity-50 text-white font-semibold py-3 rounded-full transition"
>
{{ booking.loading ? '送出中...' : booking.success ? '已送出預約' : '立即預約' }}
</button>
</div>
</div>
</template>