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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user