Compare commits
16 Commits
f984d0bb82
...
78bf145f37
| Author | SHA1 | Date | |
|---|---|---|---|
| 78bf145f37 | |||
| bda6533105 | |||
| efb1f22be3 | |||
| 915e404dfc | |||
| 0bce40a5bf | |||
| ca2ed14de3 | |||
| 8ef293332a | |||
| 88c0c09ccb | |||
| 24145419b0 | |||
| 3827223be3 | |||
| e44d69a973 | |||
| e12c91db7c | |||
| 611d6f29f6 | |||
| 57b9ee2d12 | |||
| 6e7ec350a3 | |||
| 83aa770c7e |
+6
-4
@@ -4,6 +4,7 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -25,6 +26,7 @@ DB_CONNECTION=sqlite
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
# MYSQL_ROOT_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
@@ -36,17 +38,17 @@ BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=redis
|
||||
CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_CLIENT=predis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/.phpunit.cache
|
||||
docker-compose.override.yml
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
@@ -17,3 +18,11 @@ yarn-error.log
|
||||
/.fleet
|
||||
/.idea
|
||||
/.vscode
|
||||
/.claude
|
||||
/.opensp
|
||||
|
||||
# Frontend
|
||||
/frontend/node_modules
|
||||
/frontend/dist
|
||||
/frontend/.env
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "CFDivePlatform API Tests",
|
||||
"description": "CFDivePlatform API 測試集合 - 包含會員、服務提供者、管理員等完整測試案例",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "基礎測試",
|
||||
"item": [
|
||||
{
|
||||
"name": "Ping Test",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/ping",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "ping"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Test POST",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"測試用戶\",\n \"value\": \"123\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/testpost",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "testpost"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "會員認證",
|
||||
"item": [
|
||||
{
|
||||
"name": "會員註冊",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"測試會員\",\n \"email\": \"member@test.com\",\n \"password\": \"password123\",\n \"password_confirmation\": \"password123\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/member/register",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "member", "register"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "會員登入",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"email\": \"member@test.com\",\n \"password\": \"password123\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/member/login",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "member", "login"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "取得會員資料",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{member_token}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/member/profile",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "member", "profile"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "會員專屬 API",
|
||||
"item": [
|
||||
{
|
||||
"name": "會員登出",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{member_token}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/member/logout",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "member", "logout"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "更新會員個人資料",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{member_token}}"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"更新後的會員名稱\",\n \"phone\": \"0912345678\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/member/profile",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "member", "profile"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "修改會員密碼",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{member_token}}"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"current_password\": \"password123\",\n \"new_password\": \"newpassword123\",\n \"new_password_confirmation\": \"newpassword123\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/member/change-password",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "member", "change-password"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "服務提供者認證",
|
||||
"item": [
|
||||
{
|
||||
"name": "服務提供者註冊",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"測試教練\",\n \"email\": \"{{test_provider_email}}\",\n \"password\": \"{{test_password}}\",\n \"password_confirmation\": \"{{test_password}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/provider/register",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "provider", "register"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "服務提供者登入",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"email\": \"{{test_provider_email}}\",\n \"password\": \"{{test_password}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/provider/login",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "provider", "login"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "服務提供者專屬 API",
|
||||
"item": [
|
||||
{
|
||||
"name": "服務提供者登出",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{provider_token}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/provider/logout",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "provider", "logout"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "取得服務提供者資料",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{provider_token}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/provider/profile",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "provider", "profile"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "管理員認證",
|
||||
"item": [
|
||||
{
|
||||
"name": "管理員註冊",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"測試管理員\",\n \"email\": \"{{test_admin_email}}\",\n \"password\": \"{{test_password}}\",\n \"password_confirmation\": \"{{test_password}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/admin/register",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "admin", "register"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "管理員登入",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"email\": \"{{test_admin_email}}\",\n \"password\": \"{{test_password}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/admin/login",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "admin", "login"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "管理員專屬 API",
|
||||
"item": [
|
||||
{
|
||||
"name": "查詢會員資料",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{admin_token}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/admin/check-member/1",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "admin", "check-member", "1"],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "查詢服務提供者資料",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{admin_token}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "http://localhost:8080/api/admin/check-provider/1",
|
||||
"protocol": "http",
|
||||
"host": ["localhost"],
|
||||
"port": "8080",
|
||||
"path": ["api", "admin", "check-provider", "1"],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
"value": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "http://localhost:8080/api"
|
||||
},
|
||||
{
|
||||
"key": "member_token",
|
||||
"value": "",
|
||||
"description": "會員登入後設定"
|
||||
},
|
||||
{
|
||||
"key": "provider_token",
|
||||
"value": "",
|
||||
"description": "服務提供者登入後設定"
|
||||
},
|
||||
{
|
||||
"key": "admin_token",
|
||||
"value": "",
|
||||
"description": "管理員登入後設定"
|
||||
},
|
||||
{
|
||||
"key": "test_member_email",
|
||||
"value": "member@test.com"
|
||||
},
|
||||
{
|
||||
"key": "test_provider_email",
|
||||
"value": "provider@test.com"
|
||||
},
|
||||
{
|
||||
"key": "test_admin_email",
|
||||
"value": "admin@test.com"
|
||||
},
|
||||
{
|
||||
"key": "test_password",
|
||||
"value": "password123"
|
||||
}
|
||||
]
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# 使用官方 PHP 8.2 FPM 鏡像作為基礎
|
||||
FROM php:8.2-fpm
|
||||
|
||||
# 安裝系統依賴
|
||||
# 1. 更新套件列表並安裝必要的套件
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
libpng-dev \
|
||||
libonig-dev \
|
||||
libxml2-dev \
|
||||
zip \
|
||||
unzip \
|
||||
libzip-dev \
|
||||
default-mysql-client \
|
||||
netcat-openbsd \
|
||||
grep \
|
||||
cron
|
||||
|
||||
# 清理 apt 快取以減小鏡像大小
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安裝 PHP 擴展
|
||||
# 這些擴展是 Laravel 和一般 PHP 開發所需的
|
||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
||||
|
||||
# 從官方 Composer 鏡像複製 Composer 執行文件
|
||||
# 這樣可以確保我們使用最新版本的 Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# 設置工作目錄
|
||||
WORKDIR /var/www
|
||||
|
||||
# 設置目錄權限
|
||||
# 將 /var/www 目錄的所有權更改為 www-data 用戶和組
|
||||
RUN chown -R www-data:www-data /var/www
|
||||
|
||||
# 創建必要的目錄並設置權限
|
||||
# Laravel 需要這些目錄來存儲日誌、緩存等
|
||||
RUN mkdir -p /var/www/storage /var/www/bootstrap/cache
|
||||
RUN chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||
|
||||
# 複製自定義的 PHP 配置文件
|
||||
# 這個文件包含 PHP 運行時的配置選項
|
||||
COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini
|
||||
|
||||
# 複製並設置入口點腳本
|
||||
# 這個腳本將在容器啟動時執行
|
||||
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# 加入 Laravel Scheduler cron job
|
||||
RUN echo "* * * * * www-data php /var/www/artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1" \
|
||||
> /etc/cron.d/laravel-scheduler \
|
||||
&& chmod 0644 /etc/cron.d/laravel-scheduler \
|
||||
&& crontab /etc/cron.d/laravel-scheduler
|
||||
|
||||
# 設置容器啟動時執行的入口點
|
||||
# 這將在 CMD 指令之前執行
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# 設置默認的容器命令
|
||||
# 這將在 ENTRYPOINT 執行後運行
|
||||
CMD ["php-fpm"]
|
||||
@@ -1,66 +1,4 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||

|
||||

|
||||

|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com/)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[WebReinvent](https://webreinvent.com/)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
|
||||
- **[Cyber-Duck](https://cyber-duck.co.uk)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Jump24](https://jump24.co.uk)**
|
||||
- **[Redberry](https://redberry.international/laravel/)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
- **[byte5](https://byte5.de)**
|
||||
- **[OP.GG](https://op.gg)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Models\Booking;
|
||||
use App\Notifications\BookingCompletedNotification;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CompleteFinishedBookings extends Command
|
||||
{
|
||||
protected $signature = 'app:complete-finished-bookings';
|
||||
protected $description = '將課程日期已過的 confirmed 預約標記為 completed';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$bookings = Booking::with(['member', 'schedule.divingOffer'])
|
||||
->where('status', BookingStatus::Confirmed->value)
|
||||
->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString()))
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($bookings as $booking) {
|
||||
$booking->update(['status' => BookingStatus::Completed]);
|
||||
$count++;
|
||||
|
||||
try {
|
||||
$booking->member->notify(new BookingCompletedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("BookingCompletedNotification failed for booking #{$booking->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("CompleteFinishedBookings: {$count} completed");
|
||||
$this->info("CompleteFinishedBookings: {$count} bookings completed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ExpirePendingBookings extends Command
|
||||
{
|
||||
protected $signature = 'app:expire-pending-bookings';
|
||||
protected $description = '將超過 48 小時未確認的 pending 預約標記為 expired';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$count = Booking::where('status', BookingStatus::Pending->value)
|
||||
->where('created_at', '<=', now()->subHours(48))
|
||||
->update(['status' => BookingStatus::Expired->value]);
|
||||
|
||||
Log::info("ExpirePendingBookings: {$count} expired");
|
||||
$this->info("ExpirePendingBookings: {$count} bookings expired.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BookingStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Confirmed = 'confirmed';
|
||||
case Completed = 'completed';
|
||||
case Rejected = 'rejected';
|
||||
case Expired = 'expired';
|
||||
case MemberCancelled = 'member_cancelled';
|
||||
case ProviderCancelled = 'provider_cancelled';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ScheduleStatus: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case Full = 'full';
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
|
||||
class AdminBookingController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$bookings = Booking::with(['member', 'schedule.divingOffer'])
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => [
|
||||
'id' => $b->id,
|
||||
'member_name' => $b->member?->name,
|
||||
'member_email' => $b->member?->email,
|
||||
'offer_title' => $b->schedule?->divingOffer?->title,
|
||||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $bookings]);
|
||||
}
|
||||
|
||||
public function complete(int $id)
|
||||
{
|
||||
$booking = Booking::findOrFail($id);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
|
||||
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Completed]);
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminOfferController extends Controller
|
||||
{
|
||||
private function checkAdmin()
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$query = DivingOffer::query();
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('title', 'like', "%{$q}%")
|
||||
->orWhere('location', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$paginated = $query->latest('id')->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $paginated->items(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$offer = DivingOffer::find($id);
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
$offer->delete();
|
||||
return response()->json(['status' => true, 'message' => '課程已刪除']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\Review;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdminReviewController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = min((int) $request->query('per_page', 20), 100);
|
||||
$paginator = Review::with(['divingOffer', 'member'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage);
|
||||
|
||||
$reviews = $paginator->getCollection()->map(fn($r) => [
|
||||
'id' => $r->id,
|
||||
'offer_title' => $r->divingOffer?->title,
|
||||
'member_email' => $r->member?->email,
|
||||
'rating' => $r->rating,
|
||||
'comment' => mb_strimwidth($r->comment, 0, 50, '...'),
|
||||
'is_edited' => $r->is_edited,
|
||||
'helpful_count'=> $r->helpful_count,
|
||||
'created_at' => $r->created_at?->toISOString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $reviews,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$review = Review::findOrFail($id);
|
||||
$offerId = $review->diving_offer_id;
|
||||
|
||||
DB::transaction(function () use ($review, $offerId) {
|
||||
$review->delete();
|
||||
$this->recalculateOfferRating($offerId);
|
||||
});
|
||||
|
||||
return response()->json(['status' => true, 'message' => '評價已刪除']);
|
||||
}
|
||||
|
||||
private function recalculateOfferRating(int $offerId): void
|
||||
{
|
||||
$stats = Review::where('diving_offer_id', $offerId)
|
||||
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
|
||||
->first();
|
||||
|
||||
DivingOffer::where('id', $offerId)->update([
|
||||
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
|
||||
'reviews' => $stats->total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AdminStatsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
|
||||
$stats = Cache::remember('admin_stats', 300, fn() => [
|
||||
'total_members' => User::where('role', 'member')->count(),
|
||||
'total_providers' => User::where('role', 'provider')->count(),
|
||||
'total_offers' => DivingOffer::count(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $stats]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminUserController extends Controller
|
||||
{
|
||||
private function checkAdmin()
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findUser(int $id, string $role)
|
||||
{
|
||||
return User::where('id', $id)->where('role', $role)->first();
|
||||
}
|
||||
|
||||
public function members(Request $request)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$query = User::where('role', 'member')->with('memberProfile');
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('name', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$paginated = $query->latest()->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $paginated->items(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function member(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'member');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $user->load('memberProfile')]);
|
||||
}
|
||||
|
||||
public function toggleMemberActive(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'member');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
$user->is_active = !$user->is_active;
|
||||
$user->save();
|
||||
|
||||
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
|
||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||
}
|
||||
|
||||
public function providers(Request $request)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$query = User::where('role', 'provider')->with('providerProfile');
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('name', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$paginated = $query->latest()->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $paginated->items(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function provider(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'provider');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $user->load('providerProfile')]);
|
||||
}
|
||||
|
||||
public function toggleProviderActive(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'provider');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
$user->is_active = !$user->is_active;
|
||||
$user->save();
|
||||
|
||||
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
|
||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||
}
|
||||
|
||||
public function toggleProviderVerified(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'provider');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
$profile = $user->providerProfile;
|
||||
$profile->is_verified = !$profile->is_verified;
|
||||
$profile->save();
|
||||
|
||||
$msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
|
||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
|
||||
}
|
||||
}
|
||||
@@ -474,7 +474,7 @@ class AuthController extends Controller
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'business_name' => 'required|string|max:255',
|
||||
'business_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'contact_person' => 'nullable|string|max:100',
|
||||
'contact_phone' => 'nullable|string|max:20',
|
||||
@@ -655,6 +655,12 @@ class AuthController extends Controller
|
||||
'contact_email' => 'nullable|string|email|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'business_hours' => 'nullable|string|max:100',
|
||||
'certifications' => 'nullable|string',
|
||||
'dive_sites' => 'nullable|string',
|
||||
'services' => 'nullable|string',
|
||||
'facilities' => 'nullable|string',
|
||||
'website' => 'nullable|string|max:255',
|
||||
'social_media' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -701,6 +707,24 @@ class AuthController extends Controller
|
||||
if ($request->has('business_hours')) {
|
||||
$providerProfile->business_hours = $request->business_hours;
|
||||
}
|
||||
if ($request->has('certifications')) {
|
||||
$providerProfile->certifications = $request->certifications;
|
||||
}
|
||||
if ($request->has('dive_sites')) {
|
||||
$providerProfile->dive_sites = $request->dive_sites;
|
||||
}
|
||||
if ($request->has('services')) {
|
||||
$providerProfile->services = $request->services;
|
||||
}
|
||||
if ($request->has('facilities')) {
|
||||
$providerProfile->facilities = $request->facilities;
|
||||
}
|
||||
if ($request->has('website')) {
|
||||
$providerProfile->website = $request->website;
|
||||
}
|
||||
if ($request->has('social_media')) {
|
||||
$providerProfile->social_media = $request->social_media;
|
||||
}
|
||||
|
||||
$providerProfile->save();
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CourseImage;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CourseImageController extends Controller
|
||||
{
|
||||
private function validateImage(Request $request): void
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeOffer(Request $request, DivingOffer $offer): void
|
||||
{
|
||||
if ($offer->provider_id !== $request->user()->id) {
|
||||
abort(403, '無權操作此課程');
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadCover(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
$this->validateImage($request);
|
||||
|
||||
if ($offer->cover_image) {
|
||||
Storage::disk('public')->delete($offer->cover_image);
|
||||
}
|
||||
|
||||
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public');
|
||||
$offer->update(['cover_image' => $path]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '封面已上傳',
|
||||
'cover_image_url' => $offer->cover_image_url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteCover(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
|
||||
if ($offer->cover_image) {
|
||||
Storage::disk('public')->delete($offer->cover_image);
|
||||
$offer->update(['cover_image' => null]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '封面已刪除']);
|
||||
}
|
||||
|
||||
public function uploadImage(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
$this->validateImage($request);
|
||||
|
||||
if ($offer->courseImages()->count() >= 3) {
|
||||
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
|
||||
}
|
||||
|
||||
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
|
||||
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
|
||||
|
||||
$image = CourseImage::create([
|
||||
'diving_offer_id' => $offerId,
|
||||
'image_path' => $path,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '圖片已上傳',
|
||||
'data' => ['id' => $image->id, 'url' => $image->url, 'sort_order' => $image->sort_order],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function deleteImage(Request $request, int $imageId)
|
||||
{
|
||||
$image = CourseImage::with('divingOffer')->findOrFail($imageId);
|
||||
|
||||
if ($image->divingOffer->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權刪除此圖片'], 403);
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($image->image_path);
|
||||
$image->delete();
|
||||
|
||||
return response()->json(['status' => true, 'message' => '圖片已刪除']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class DivingOfferController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = min((int) $request->query('per_page', 12), 50);
|
||||
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
|
||||
|
||||
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
|
||||
$query = DivingOffer::query();
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('title', 'like', "%{$q}%")
|
||||
->orWhere('location', 'like', "%{$q}%")
|
||||
->orWhere('spot', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($region = $request->query('region')) {
|
||||
$query->where('region', $region);
|
||||
}
|
||||
|
||||
if ($tag = $request->query('tag')) {
|
||||
$query->where('tag', 'like', "%{$tag}%");
|
||||
}
|
||||
|
||||
$paginated = $query->paginate($perPage);
|
||||
|
||||
return [
|
||||
'items' => collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false))->values(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $result['items'],
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
$offer = DivingOffer::with('courseImages')->find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
|
||||
}
|
||||
|
||||
private function formatOffer(DivingOffer $offer, bool $withImages): array
|
||||
{
|
||||
$data = array_merge($offer->toArray(), [
|
||||
'cover_image_url' => $offer->cover_image_url,
|
||||
]);
|
||||
|
||||
if ($withImages) {
|
||||
$data['images'] = $offer->courseImages->map(fn($img) => [
|
||||
'id' => $img->id,
|
||||
'url' => $img->url,
|
||||
'sort_order' => $img->sort_order,
|
||||
])->values();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Notifications\BookingCreatedNotification;
|
||||
use App\Notifications\BookingCancelledNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MemberBookingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$bookings = Booking::with(['schedule.divingOffer'])
|
||||
->where('member_id', $request->user()->id)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => $this->formatBooking($b));
|
||||
|
||||
return response()->json(['status' => true, 'data' => $bookings]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with(['schedule.divingOffer'])->findOrFail($id);
|
||||
if ($booking->member_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權查看此預約'], 403);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $this->formatBooking($booking)]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'schedule_id' => 'required|integer|exists:course_schedules,id',
|
||||
'participants' => 'required|integer|min:1',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']);
|
||||
|
||||
// Layer 1:快速失敗
|
||||
if ($schedule->status !== ScheduleStatus::Open) {
|
||||
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
|
||||
}
|
||||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||||
return response()->json(['status' => false, 'message' => '人數超過剩餘名額'], 422);
|
||||
}
|
||||
|
||||
$memberId = $request->user()->id;
|
||||
|
||||
try {
|
||||
$booking = DB::transaction(function () use ($data, $schedule, $memberId) {
|
||||
// Layer 2:lockForUpdate 後二次驗證
|
||||
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
|
||||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||||
throw new \RuntimeException('名額不足,請重新選擇');
|
||||
}
|
||||
|
||||
// 重複預約檢查
|
||||
$duplicate = Booking::where('member_id', $memberId)
|
||||
->where('schedule_id', $schedule->id)
|
||||
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
throw new \RuntimeException('您已預約此時段');
|
||||
}
|
||||
|
||||
return Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $memberId,
|
||||
'participants' => $data['participants'],
|
||||
'total_price' => $schedule->divingOffer->price * $data['participants'],
|
||||
'status' => BookingStatus::Pending,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
});
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$booking->load('schedule.divingOffer.provider');
|
||||
$provider = $booking->schedule->divingOffer->provider;
|
||||
$provider->notify(new BookingCreatedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCreatedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '預約已送出,等待教練確認',
|
||||
'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
if ($booking->member_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權操作此預約'], 403);
|
||||
}
|
||||
|
||||
$canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed];
|
||||
if (!in_array($booking->status, $canCancelFrom)) {
|
||||
return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422);
|
||||
}
|
||||
|
||||
// 24h 截止驗證
|
||||
$schedule = $booking->schedule;
|
||||
$courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time);
|
||||
if (now()->diffInHours($courseStart, false) < 24) {
|
||||
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($booking, $schedule) {
|
||||
$wasConfirmed = $booking->status === BookingStatus::Confirmed;
|
||||
$booking->update(['status' => BookingStatus::MemberCancelled]);
|
||||
|
||||
if ($wasConfirmed) {
|
||||
$schedule = $booking->schedule()->lockForUpdate()->first();
|
||||
$schedule->decrement('current_participants', $booking->participants);
|
||||
$schedule->refresh();
|
||||
|
||||
if ($schedule->current_participants < $schedule->max_participants
|
||||
&& $schedule->status === ScheduleStatus::Full) {
|
||||
$schedule->update(['status' => ScheduleStatus::Open]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$booking->load('schedule.divingOffer.provider');
|
||||
$provider = $booking->schedule->divingOffer->provider;
|
||||
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已取消']);
|
||||
}
|
||||
|
||||
private function formatBooking(Booking $b): array
|
||||
{
|
||||
$offer = $b->schedule?->divingOffer;
|
||||
return [
|
||||
'id' => $b->id,
|
||||
'offer_id' => $offer?->id,
|
||||
'offer_title' => $offer?->title,
|
||||
'offer_location' => $offer?->location,
|
||||
'offer_region' => $offer?->region,
|
||||
'offer_price' => $offer?->price,
|
||||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'notes' => $b->notes,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$user = $request->user();
|
||||
$unreadCount = $user->unreadNotifications()->count();
|
||||
$notifications = $user->notifications()
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$items = $notifications->map(fn($n) => array_merge($n->data, [
|
||||
'id' => $n->id,
|
||||
'read_at' => $n->read_at?->toISOString(),
|
||||
'created_at' => $n->created_at->toISOString(),
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $items,
|
||||
'unread_count' => $unreadCount,
|
||||
'meta' => [
|
||||
'current_page' => $notifications->currentPage(),
|
||||
'last_page' => $notifications->lastPage(),
|
||||
'total' => $notifications->total(),
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['status' => true, 'data' => [], 'unread_count' => 0, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0]]);
|
||||
}
|
||||
}
|
||||
|
||||
public function unreadCount(Request $request)
|
||||
{
|
||||
try {
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => ['count' => $request->user()->unreadNotifications()->count()],
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['status' => true, 'data' => ['count' => 0]]);
|
||||
}
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $id)
|
||||
{
|
||||
$notification = $request->user()->notifications()->findOrFail($id);
|
||||
$notification->markAsRead();
|
||||
|
||||
return response()->json(['status' => true]);
|
||||
}
|
||||
|
||||
public function markAllRead(Request $request)
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return response()->json(['status' => true]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id)
|
||||
{
|
||||
$notification = $request->user()->notifications()->findOrFail($id);
|
||||
$notification->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Notifications\BookingCancelledNotification;
|
||||
use App\Notifications\BookingCompletedNotification;
|
||||
use App\Notifications\BookingConfirmedNotification;
|
||||
use App\Notifications\BookingRejectedNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProviderBookingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$provider = $request->user();
|
||||
$bookings = Booking::with(['member', 'schedule.divingOffer'])
|
||||
->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => $this->formatBooking($b));
|
||||
|
||||
return response()->json(['status' => true, 'data' => $bookings]);
|
||||
}
|
||||
|
||||
public function confirm(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Confirmed)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($booking) {
|
||||
$schedule = $booking->schedule()->lockForUpdate()->first();
|
||||
$remaining = $schedule->max_participants - $schedule->current_participants;
|
||||
|
||||
if ($booking->participants > $remaining) {
|
||||
throw new \RuntimeException('名額不足,無法確認此預約');
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Confirmed]);
|
||||
$schedule->increment('current_participants', $booking->participants);
|
||||
$schedule->refresh();
|
||||
|
||||
if ($schedule->current_participants >= $schedule->max_participants) {
|
||||
$schedule->update(['status' => ScheduleStatus::Full]);
|
||||
}
|
||||
});
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingConfirmedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingConfirmedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]);
|
||||
}
|
||||
|
||||
public function reject(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Rejected)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Rejected]);
|
||||
|
||||
try {
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingRejectedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingRejectedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已拒絕']);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($booking) {
|
||||
$schedule = $booking->schedule()->lockForUpdate()->first();
|
||||
$booking->update(['status' => BookingStatus::ProviderCancelled]);
|
||||
$schedule->decrement('current_participants', $booking->participants);
|
||||
$schedule->refresh();
|
||||
|
||||
if ($schedule->current_participants < $schedule->max_participants
|
||||
&& $schedule->status === ScheduleStatus::Full) {
|
||||
$schedule->update(['status' => ScheduleStatus::Open]);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCancelledNotification(provider) failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已取消']);
|
||||
}
|
||||
|
||||
public function complete(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
|
||||
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Completed]);
|
||||
|
||||
try {
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingCompletedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCompletedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
|
||||
}
|
||||
|
||||
private function authorizeProvider(Request $request, Booking $booking): void
|
||||
{
|
||||
if ($booking->schedule->provider_id !== $request->user()->id) {
|
||||
abort(403, '無權操作此預約');
|
||||
}
|
||||
}
|
||||
|
||||
private function formatBooking(Booking $b): array
|
||||
{
|
||||
return [
|
||||
'id' => $b->id,
|
||||
'member_name' => $b->member?->name,
|
||||
'member_email' => $b->member?->email,
|
||||
'offer_title' => $b->schedule?->divingOffer?->title,
|
||||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'notes' => $b->notes,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ProviderOfferController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$offers = DivingOffer::where('provider_id', auth()->id())
|
||||
->paginate(12);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $offers->items(),
|
||||
'meta' => [
|
||||
'total' => $offers->total(),
|
||||
'per_page' => $offers->perPage(),
|
||||
'current_page' => $offers->currentPage(),
|
||||
'last_page' => $offers->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
$offer = DivingOffer::find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
if ($offer->provider_id !== auth()->id()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限查看此課程'], 403);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $offer]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'location' => 'required|string|max:255',
|
||||
'spot' => 'nullable|string|max:255',
|
||||
'price' => 'required|integer|min:0',
|
||||
'region' => 'required|string|max:100',
|
||||
'tag' => 'nullable|string|max:100',
|
||||
'badges' => 'nullable|array',
|
||||
'badges.*' => 'string|max:50',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['provider_id'] = auth()->id();
|
||||
$validated['rating'] = 0;
|
||||
$validated['reviews'] = 0;
|
||||
|
||||
$offer = DivingOffer::create($validated);
|
||||
|
||||
Cache::tags(['diving_offers'])->flush();
|
||||
|
||||
return response()->json(['status' => true, 'data' => $offer], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$offer = DivingOffer::find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
if ($offer->provider_id !== auth()->id()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限修改此課程'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'nullable|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'spot' => 'nullable|string|max:255',
|
||||
'price' => 'nullable|integer|min:0',
|
||||
'region' => 'nullable|string|max:100',
|
||||
'tag' => 'nullable|string|max:100',
|
||||
'badges' => 'nullable|array',
|
||||
'badges.*' => 'string|max:50',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$offer->fill($validated)->save();
|
||||
|
||||
Cache::tags(['diving_offers'])->flush();
|
||||
|
||||
return response()->json(['status' => true, 'data' => $offer]);
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$offer = DivingOffer::find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
if ($offer->provider_id !== auth()->id()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限刪除此課程'], 403);
|
||||
}
|
||||
|
||||
$offer->delete();
|
||||
|
||||
Cache::tags(['diving_offers'])->flush();
|
||||
|
||||
return response()->json(['status' => true, 'message' => '課程已刪除']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\Review;
|
||||
use App\Models\ReviewEdit;
|
||||
use App\Models\ReviewVote;
|
||||
use App\Notifications\ReviewReceivedNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ReviewController extends Controller
|
||||
{
|
||||
// ── 公開列表 ──────────────────────────────────────────────
|
||||
|
||||
public function publicList(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$user = $request->user();
|
||||
$perPage = min((int) $request->query('per_page', 20), 50);
|
||||
$sort = $request->query('sort', 'helpful');
|
||||
|
||||
$query = Review::with('votes')->where('diving_offer_id', $offer->id);
|
||||
|
||||
match ($sort) {
|
||||
'rating' => $query->orderByDesc('rating')->orderByDesc('created_at'),
|
||||
'newest' => $query->orderByDesc('created_at'),
|
||||
default => $query->orderByDesc('helpful_count')->orderByDesc('created_at'),
|
||||
};
|
||||
|
||||
$paginator = $query->paginate($perPage);
|
||||
$reviews = $paginator->getCollection();
|
||||
|
||||
$memberId = $user?->id;
|
||||
|
||||
$distribution = Cache::remember("offer_review_distribution_{$offerId}", 600, function () use ($offerId) {
|
||||
$distRaw = Review::where('diving_offer_id', $offerId)
|
||||
->selectRaw('rating, COUNT(*) as cnt')
|
||||
->groupBy('rating')
|
||||
->pluck('cnt', 'rating');
|
||||
return collect([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0])->merge($distRaw);
|
||||
});
|
||||
|
||||
$total = $paginator->total();
|
||||
$average = $total > 0 ? round(
|
||||
Review::where('diving_offer_id', $offerId)->avg('rating'), 1
|
||||
) : 0;
|
||||
|
||||
$formatted = $reviews->map(function ($r) use ($user, $memberId) {
|
||||
$item = [
|
||||
'id' => $r->id,
|
||||
'reviewer_name' => '匿名潛水者',
|
||||
'rating' => $r->rating,
|
||||
'comment' => $r->comment,
|
||||
'helpful_count' => $r->helpful_count,
|
||||
'is_edited' => $r->is_edited,
|
||||
'created_at' => $r->created_at?->toISOString(),
|
||||
'has_voted' => $memberId
|
||||
? $r->votes->contains('member_id', $memberId)
|
||||
: false,
|
||||
];
|
||||
if ($user) {
|
||||
$item['is_mine'] = $r->member_id === $memberId;
|
||||
}
|
||||
return $item;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => [
|
||||
'summary' => [
|
||||
'average' => $average,
|
||||
'total' => $total,
|
||||
'distribution' => $distribution,
|
||||
],
|
||||
'reviews' => $formatted,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Member CRUD ───────────────────────────────────────────
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
|
||||
'rating' => 'required|integer|min:1|max:5',
|
||||
'comment' => 'required|string|min:1',
|
||||
]);
|
||||
|
||||
$memberId = $request->user()->id;
|
||||
$offerId = $data['diving_offer_id'];
|
||||
|
||||
// 資格驗證:有 completed booking
|
||||
$eligible = Booking::where('member_id', $memberId)
|
||||
->whereHas('schedule', fn($q) => $q->where('diving_offer_id', $offerId))
|
||||
->where('status', BookingStatus::Completed->value)
|
||||
->exists();
|
||||
|
||||
if (!$eligible) {
|
||||
return response()->json(['status' => false, 'message' => '須完成此課程後才能評價'], 403);
|
||||
}
|
||||
|
||||
// 重複評價檢查
|
||||
if (Review::where('member_id', $memberId)->where('diving_offer_id', $offerId)->exists()) {
|
||||
return response()->json(['status' => false, 'message' => '已評價,如需修改請使用編輯功能'], 422);
|
||||
}
|
||||
|
||||
$review = DB::transaction(function () use ($data, $memberId, $offerId) {
|
||||
$review = Review::create([
|
||||
'diving_offer_id' => $offerId,
|
||||
'member_id' => $memberId,
|
||||
'rating' => $data['rating'],
|
||||
'comment' => $data['comment'],
|
||||
]);
|
||||
$this->recalculateOfferRating($offerId);
|
||||
return $review;
|
||||
});
|
||||
|
||||
Cache::forget("offer_review_distribution_{$offerId}");
|
||||
|
||||
try {
|
||||
$offer = DivingOffer::with('provider')->findOrFail($offerId);
|
||||
$provider = $offer->provider;
|
||||
if ($provider) {
|
||||
$provider->notify(new ReviewReceivedNotification($review));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ReviewReceivedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '評價已送出', 'data' => $this->formatReview($review)], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$review = Review::findOrFail($id);
|
||||
if ($review->member_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權修改此評價'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'rating' => 'sometimes|integer|min:1|max:5',
|
||||
'comment' => 'sometimes|string|min:1',
|
||||
]);
|
||||
|
||||
$offerId = $review->diving_offer_id;
|
||||
|
||||
DB::transaction(function () use ($review, $data) {
|
||||
ReviewEdit::updateOrCreate(
|
||||
['review_id' => $review->id],
|
||||
['old_rating' => $review->rating, 'old_comment' => $review->comment, 'edited_at' => now()]
|
||||
);
|
||||
$review->update(array_merge($data, ['is_edited' => true]));
|
||||
$this->recalculateOfferRating($review->diving_offer_id);
|
||||
});
|
||||
|
||||
Cache::forget("offer_review_distribution_{$offerId}");
|
||||
|
||||
return response()->json(['status' => true, 'message' => '評價已更新', 'data' => $this->formatReview($review->fresh())]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$review = Review::findOrFail($id);
|
||||
if ($review->member_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權刪除此評價'], 403);
|
||||
}
|
||||
|
||||
$offerId = $review->diving_offer_id;
|
||||
DB::transaction(function () use ($review, $offerId) {
|
||||
$review->delete();
|
||||
$this->recalculateOfferRating($offerId);
|
||||
});
|
||||
|
||||
Cache::forget("offer_review_distribution_{$offerId}");
|
||||
|
||||
return response()->json(['status' => true, 'message' => '評價已刪除']);
|
||||
}
|
||||
|
||||
// ── 有幫助投票 ────────────────────────────────────────────
|
||||
|
||||
public function toggleHelpful(Request $request, int $id)
|
||||
{
|
||||
if (!$request->user()->isMember()) {
|
||||
return response()->json(['status' => false, 'message' => '只有會員可以投票'], 403);
|
||||
}
|
||||
|
||||
$review = Review::findOrFail($id);
|
||||
$memberId = $request->user()->id;
|
||||
|
||||
if ($review->member_id === $memberId) {
|
||||
return response()->json(['status' => false, 'message' => '不可對自己的評價投票'], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($review, $memberId) {
|
||||
$vote = ReviewVote::where('review_id', $review->id)
|
||||
->where('member_id', $memberId)
|
||||
->first();
|
||||
|
||||
if ($vote) {
|
||||
$vote->delete();
|
||||
DB::table('reviews')
|
||||
->where('id', $review->id)
|
||||
->where('helpful_count', '>', 0)
|
||||
->decrement('helpful_count');
|
||||
} else {
|
||||
ReviewVote::create(['review_id' => $review->id, 'member_id' => $memberId, 'created_at' => now()]);
|
||||
$review->increment('helpful_count');
|
||||
}
|
||||
});
|
||||
|
||||
$review->refresh();
|
||||
$hasVoted = ReviewVote::where('review_id', $review->id)->where('member_id', $memberId)->exists();
|
||||
|
||||
return response()->json(['status' => true, 'data' => ['helpful_count' => $review->helpful_count, 'has_voted' => $hasVoted]]);
|
||||
}
|
||||
|
||||
// ── 私有方法 ──────────────────────────────────────────────
|
||||
|
||||
private function recalculateOfferRating(int $offerId): void
|
||||
{
|
||||
$stats = Review::where('diving_offer_id', $offerId)
|
||||
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
|
||||
->first();
|
||||
|
||||
DivingOffer::where('id', $offerId)->update([
|
||||
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
|
||||
'reviews' => $stats->total,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatReview(Review $r): array
|
||||
{
|
||||
return [
|
||||
'id' => $r->id,
|
||||
'reviewer_name' => '匿名潛水者',
|
||||
'rating' => $r->rating,
|
||||
'comment' => $r->comment,
|
||||
'helpful_count' => $r->helpful_count,
|
||||
'is_edited' => $r->is_edited,
|
||||
'created_at' => $r->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ScheduleController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$provider = $request->user();
|
||||
$schedules = CourseSchedule::with('divingOffer')
|
||||
->where('provider_id', $provider->id)
|
||||
->orderBy('scheduled_date')
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->map(fn($s) => $this->formatSchedule($s));
|
||||
|
||||
return response()->json(['status' => true, 'data' => $schedules]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
|
||||
'scheduled_date' => 'required|date|after_or_equal:today',
|
||||
'start_time' => 'required|date_format:H:i',
|
||||
'max_participants' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$offer = DivingOffer::findOrFail($data['diving_offer_id']);
|
||||
if ($offer->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權操作此課程'], 403);
|
||||
}
|
||||
|
||||
$schedule = CourseSchedule::create([
|
||||
'diving_offer_id' => $data['diving_offer_id'],
|
||||
'provider_id' => $request->user()->id,
|
||||
'scheduled_date' => $data['scheduled_date'],
|
||||
'start_time' => $data['start_time'],
|
||||
'max_participants' => $data['max_participants'],
|
||||
'current_participants'=> 0,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule)], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$schedule = CourseSchedule::findOrFail($id);
|
||||
if ($schedule->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'start_time' => 'sometimes|date_format:H:i',
|
||||
'max_participants' => 'sometimes|integer|min:1',
|
||||
]);
|
||||
|
||||
if (isset($data['max_participants']) && $data['max_participants'] < $schedule->current_participants) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => '人數上限不可低於目前已確認人數(' . $schedule->current_participants . ')',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$schedule->update($data);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule->fresh())]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$schedule = CourseSchedule::findOrFail($id);
|
||||
if ($schedule->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($schedule) {
|
||||
$schedule->update(['status' => ScheduleStatus::Cancelled]);
|
||||
$schedule->bookings()
|
||||
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
|
||||
->update(['status' => BookingStatus::ProviderCancelled->value]);
|
||||
});
|
||||
|
||||
return response()->json(['status' => true, 'message' => '時段已取消']);
|
||||
}
|
||||
|
||||
public function publicList(int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$schedules = CourseSchedule::where('diving_offer_id', $offer->id)
|
||||
->where('status', ScheduleStatus::Open->value)
|
||||
->whereDate('scheduled_date', '>=', now()->toDateString())
|
||||
->orderBy('scheduled_date')
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->map(fn($s) => [
|
||||
'id' => $s->id,
|
||||
'scheduled_date' => $s->scheduled_date->toDateString(),
|
||||
'start_time' => $s->start_time,
|
||||
'max_participants' => $s->max_participants,
|
||||
'remaining_spots' => $s->remainingSpots(),
|
||||
'status' => $s->status->value,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $schedules]);
|
||||
}
|
||||
|
||||
private function formatSchedule(CourseSchedule $s): array
|
||||
{
|
||||
return [
|
||||
'id' => $s->id,
|
||||
'diving_offer_id' => $s->diving_offer_id,
|
||||
'offer_title' => $s->divingOffer?->title,
|
||||
'scheduled_date' => $s->scheduled_date?->toDateString(),
|
||||
'start_time' => $s->start_time,
|
||||
'max_participants' => $s->max_participants,
|
||||
'current_participants' => $s->current_participants,
|
||||
'remaining_spots' => $s->remainingSpots(),
|
||||
'status' => $s->status->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -106,23 +106,9 @@ class SocialAuthController extends Controller
|
||||
// 生成 Sanctum token
|
||||
$token = $user->createToken('google-auth')->plainTextToken;
|
||||
|
||||
// 載入會員資料
|
||||
$user->load('memberProfile');
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => 'Google 登入成功',
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
]
|
||||
]);
|
||||
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Google 登入失敗:' . $e->getMessage()
|
||||
], 500);
|
||||
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$request->user() || !$request->user()->isAdmin()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Booking extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'schedule_id',
|
||||
'member_id',
|
||||
'participants',
|
||||
'total_price',
|
||||
'status',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'participants' => 'integer',
|
||||
'total_price' => 'integer',
|
||||
'status' => BookingStatus::class,
|
||||
];
|
||||
|
||||
const VALID_TRANSITIONS = [
|
||||
'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
|
||||
'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'],
|
||||
'completed' => [],
|
||||
'rejected' => [],
|
||||
'expired' => [],
|
||||
'member_cancelled' => [],
|
||||
'provider_cancelled' => [],
|
||||
];
|
||||
|
||||
public function canTransitionTo(BookingStatus $newStatus): bool
|
||||
{
|
||||
$current = $this->status->value;
|
||||
$allowed = self::VALID_TRANSITIONS[$current] ?? [];
|
||||
return in_array($newStatus->value, $allowed);
|
||||
}
|
||||
|
||||
public function schedule()
|
||||
{
|
||||
return $this->belongsTo(CourseSchedule::class, 'schedule_id');
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'member_id');
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="CoachProfile",
|
||||
* title="教練個人資料",
|
||||
* description="教練的詳細個人資料",
|
||||
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
|
||||
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
|
||||
* @OA\Property(property="bio", type="string", example="專業潛水教練,擁有10年教學經驗", description="個人簡介"),
|
||||
* @OA\Property(property="expertise", type="string", example="自由潛水,水肺潛水", description="專長領域"),
|
||||
* @OA\Property(property="certification", type="string", example="PADI專業潛水教練", description="證照資訊"),
|
||||
* @OA\Property(property="experience", type="string", example="10年教學經驗,帶領超過500名學員", description="教學經驗"),
|
||||
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
|
||||
* @OA\Property(property="availability", type="string", example="週一至週五 09:00-18:00", description="可授課時間"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2023-01-01T00:00:00.000000Z", description="創建時間"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2023-01-01T00:00:00.000000Z", description="更新時間")
|
||||
* )
|
||||
*/
|
||||
// 該模型已棄用,請使用ProviderProfile模型
|
||||
class CoachProfile extends Model
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CourseImage extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
const CREATED_AT = 'created_at';
|
||||
|
||||
protected $fillable = [
|
||||
'diving_offer_id',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function divingOffer()
|
||||
{
|
||||
return $this->belongsTo(DivingOffer::class);
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return Storage::disk('public')->url($this->image_path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ScheduleStatus;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CourseSchedule extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'diving_offer_id',
|
||||
'provider_id',
|
||||
'scheduled_date',
|
||||
'start_time',
|
||||
'max_participants',
|
||||
'current_participants',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_date' => 'date',
|
||||
'max_participants' => 'integer',
|
||||
'current_participants' => 'integer',
|
||||
'status' => ScheduleStatus::class,
|
||||
];
|
||||
|
||||
public function divingOffer()
|
||||
{
|
||||
return $this->belongsTo(DivingOffer::class);
|
||||
}
|
||||
|
||||
public function provider()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'provider_id');
|
||||
}
|
||||
|
||||
public function bookings()
|
||||
{
|
||||
return $this->hasMany(Booking::class, 'schedule_id');
|
||||
}
|
||||
|
||||
public function remainingSpots(): int
|
||||
{
|
||||
return max(0, $this->max_participants - $this->current_participants);
|
||||
}
|
||||
|
||||
protected function startTime(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn($value) => $value ? substr($value, 0, 5) : $value,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DivingOffer extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'diving_offers';
|
||||
|
||||
protected $fillable = [
|
||||
'provider_id',
|
||||
'title',
|
||||
'location',
|
||||
'spot',
|
||||
'rating',
|
||||
'reviews',
|
||||
'price',
|
||||
'badges',
|
||||
'description',
|
||||
'tag',
|
||||
'region',
|
||||
'cover_image',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'badges' => 'array',
|
||||
'rating' => 'float',
|
||||
'price' => 'integer',
|
||||
'reviews'=> 'integer',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function ($offer) {
|
||||
Storage::disk('public')->deleteDirectory("offers/{$offer->id}");
|
||||
});
|
||||
}
|
||||
|
||||
public function getCoverImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->cover_image
|
||||
? Storage::disk('public')->url($this->cover_image)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function provider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'provider_id');
|
||||
}
|
||||
|
||||
public function schedules()
|
||||
{
|
||||
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
|
||||
}
|
||||
|
||||
public function courseImages()
|
||||
{
|
||||
return $this->hasMany(CourseImage::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function reviews()
|
||||
{
|
||||
return $this->hasMany(Review::class);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="ProviderProfile",
|
||||
* title="潛水業者資料",
|
||||
* description="潛水業者的詳細資料",
|
||||
* title="服務提供者資料",
|
||||
* description="服務提供者的詳細資料",
|
||||
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
|
||||
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
|
||||
* @OA\Property(property="business_name", type="string", example="藍海潛水中心", description="業者名稱"),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Review extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'diving_offer_id',
|
||||
'member_id',
|
||||
'rating',
|
||||
'comment',
|
||||
'helpful_count',
|
||||
'is_edited',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'rating' => 'integer',
|
||||
'helpful_count' => 'integer',
|
||||
'is_edited' => 'boolean',
|
||||
];
|
||||
|
||||
public function divingOffer()
|
||||
{
|
||||
return $this->belongsTo(DivingOffer::class);
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'member_id');
|
||||
}
|
||||
|
||||
public function edit()
|
||||
{
|
||||
return $this->hasOne(ReviewEdit::class);
|
||||
}
|
||||
|
||||
public function votes()
|
||||
{
|
||||
return $this->hasMany(ReviewVote::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReviewEdit extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'review_id',
|
||||
'old_rating',
|
||||
'old_comment',
|
||||
'edited_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'old_rating' => 'integer',
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function review()
|
||||
{
|
||||
return $this->belongsTo(Review::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReviewVote extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'review_id',
|
||||
'member_id',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
public function review()
|
||||
{
|
||||
return $this->belongsTo(Review::class);
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'member_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingCancelledNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
public readonly Booking $booking,
|
||||
public readonly string $cancelledBy,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
if ($this->cancelledBy === 'member') {
|
||||
return [
|
||||
'type' => 'booking_cancelled',
|
||||
'title' => '學員取消了預約',
|
||||
'body' => "學員已取消《{$offer->title}》的預約(時段:{$date})",
|
||||
'action_url' => config('app.frontend_url') . '/coach/bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'booking_cancelled',
|
||||
'title' => '教練取消了你的預約',
|
||||
'body' => "教練已取消你的《{$offer->title}》預約(時段:{$date}),如有疑問請聯繫教練",
|
||||
'action_url' => config('app.frontend_url') . '/my-bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
if ($this->cancelledBy === 'member') {
|
||||
return (new MailMessage)
|
||||
->subject('預約已取消 — CFDivePlatform')
|
||||
->greeting('通知')
|
||||
->line("學員已取消《{$offer->title}》的預約(時段:{$date})。")
|
||||
->action('查看所有預約', config('app.frontend_url') . '/coach/bookings')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('你的預約已取消 — CFDivePlatform')
|
||||
->greeting('通知')
|
||||
->line("教練已取消你的《{$offer->title}》預約(時段:{$date})。如有疑問,請聯繫課程教練。")
|
||||
->action('查看預約', config('app.frontend_url') . '/my-bookings')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingCompletedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Booking $booking) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
|
||||
return [
|
||||
'type' => 'booking_completed',
|
||||
'title' => '課程已完成,歡迎留下評價',
|
||||
'body' => "你的《{$offer->title}》課程已完成,歡迎分享你的學習心得!",
|
||||
'action_url' => config('app.frontend_url') . '/courses/' . $offer->id,
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$url = config('app.frontend_url') . '/courses/' . $offer->id;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('課程完成,歡迎留下評價 — CFDivePlatform')
|
||||
->greeting('恭喜完成課程!')
|
||||
->line("你的《{$offer->title}》課程已完成。")
|
||||
->action('前往評價', $url)
|
||||
->line('你的評價對其他學員非常有幫助,感謝你的分享!')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingConfirmedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Booking $booking) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
return [
|
||||
'type' => 'booking_confirmed',
|
||||
'title' => '預約已確認',
|
||||
'body' => "你的《{$offer->title}》課程預約已由教練確認(時段:{$date})",
|
||||
'action_url' => config('app.frontend_url') . '/my-bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
$url = config('app.frontend_url') . '/my-bookings';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('你的預約已確認 — CFDivePlatform')
|
||||
->greeting('好消息!')
|
||||
->line("你的《{$offer->title}》課程預約已由教練確認(時段:{$date})。")
|
||||
->action('查看預約', $url)
|
||||
->line('請準時出席,如需取消請至少提前 24 小時操作。')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingCreatedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Booking $booking) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
return [
|
||||
'type' => 'booking_created',
|
||||
'title' => '你有新的預約申請',
|
||||
'body' => "學員申請了《{$offer->title}》的預約(時段:{$date})",
|
||||
'action_url' => config('app.frontend_url') . '/coach/bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
$url = config('app.frontend_url') . '/coach/bookings';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('你有新的預約申請 — CFDivePlatform')
|
||||
->greeting('你好!')
|
||||
->line("學員申請了《{$offer->title}》的預約(時段:{$date})。")
|
||||
->action('查看預約', $url)
|
||||
->line('請盡快確認或拒絕此預約申請。')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingRejectedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Booking $booking) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
return [
|
||||
'type' => 'booking_rejected',
|
||||
'title' => '預約申請未通過',
|
||||
'body' => "你的《{$offer->title}》預約申請(時段:{$date})未通過,請選擇其他時段",
|
||||
'action_url' => config('app.frontend_url') . '/my-bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
$url = config('app.frontend_url') . '/courses';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('你的預約申請未通過 — CFDivePlatform')
|
||||
->greeting('通知')
|
||||
->line("很抱歉,你的《{$offer->title}》預約申請(時段:{$date})未通過審核。")
|
||||
->action('瀏覽其他課程', $url)
|
||||
->line('如有疑問,請聯繫課程教練。')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Review;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ReviewReceivedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Review $review) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->review->divingOffer;
|
||||
|
||||
return [
|
||||
'type' => 'review_received',
|
||||
'title' => '你收到了一則新評價',
|
||||
'body' => "《{$offer->title}》收到了 {$this->review->rating} 星評價",
|
||||
'action_url' => config('app.frontend_url') . '/coach/reviews',
|
||||
'related_id' => $this->review->id,
|
||||
'related_type' => 'review',
|
||||
];
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
//
|
||||
$middleware->alias([
|
||||
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/sanctum": "^4.1",
|
||||
"laravel/socialite": "^5.20",
|
||||
"laravel/tinker": "^2.9"
|
||||
"laravel/tinker": "^2.9",
|
||||
"predis/predis": "^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
Generated
+67
-4
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "cd7975455a8ac2a316ac819eaed700e8",
|
||||
"content-hash": "c7d653b10f8ee748e5662242029651ce",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -3242,6 +3242,69 @@
|
||||
],
|
||||
"time": "2024-12-14T21:12:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "predis/predis",
|
||||
"version": "v3.4.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/predis/predis.git",
|
||||
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075",
|
||||
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0",
|
||||
"psr/http-message": "^1.0|^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.3",
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpunit/phpcov": "^6.0 || ^8.0",
|
||||
"phpunit/phpunit": "^8.0 || ~9.4.4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Predis\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Till Krüss",
|
||||
"homepage": "https://till.im",
|
||||
"role": "Maintainer"
|
||||
}
|
||||
],
|
||||
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
|
||||
"homepage": "http://github.com/predis/predis",
|
||||
"keywords": [
|
||||
"nosql",
|
||||
"predis",
|
||||
"redis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/predis/predis/issues",
|
||||
"source": "https://github.com/predis/predis/tree/v3.4.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/tillkruss",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-09T20:33:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
@@ -9166,12 +9229,12 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ return [
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your settings for cross-origin resource sharing
|
||||
| or "CORS". This determines what cross-origin operations may execute
|
||||
| in web browsers. You are free to adjust these settings as needed.
|
||||
|
|
||||
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||
|
||||
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
|
||||
'allowed_origins' => [
|
||||
env('FRONTEND_URL', 'http://localhost:5173'),
|
||||
'http://127.0.0.1:5173',
|
||||
'http://localhost:5173',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => false,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('provider_id')->nullable()->after('id');
|
||||
$table->foreign('provider_id')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->dropForeign(['provider_id']);
|
||||
$table->dropColumn('provider_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->string('spot')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->string('spot')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('course_schedules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
|
||||
$table->foreignId('provider_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->date('scheduled_date');
|
||||
$table->time('start_time');
|
||||
$table->unsignedInteger('max_participants');
|
||||
$table->unsignedInteger('current_participants')->default(0);
|
||||
$table->string('status')->default('open');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['diving_offer_id', 'status', 'scheduled_date'], 'idx_offer_status_date');
|
||||
$table->index('provider_id', 'idx_provider_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('course_schedules');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('bookings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('schedule_id')->constrained('course_schedules')->cascadeOnDelete();
|
||||
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->unsignedInteger('participants')->default(1);
|
||||
$table->unsignedInteger('total_price');
|
||||
$table->string('status')->default('pending');
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['member_id', 'status'], 'idx_member_status');
|
||||
$table->index(['schedule_id', 'status'], 'idx_schedule_status');
|
||||
$table->index(['status', 'created_at'], 'idx_status_created_at');
|
||||
$table->index(['status', 'schedule_id'], 'idx_status_sched');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bookings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('reviews', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
|
||||
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->tinyInteger('rating')->unsigned();
|
||||
$table->text('comment');
|
||||
$table->unsignedInteger('helpful_count')->default(0);
|
||||
$table->boolean('is_edited')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['member_id', 'diving_offer_id']);
|
||||
$table->index(['diving_offer_id', 'helpful_count'], 'idx_reviews_helpful');
|
||||
$table->index(['diving_offer_id', 'rating'], 'idx_reviews_rating');
|
||||
$table->index(['diving_offer_id', 'created_at'], 'idx_reviews_newest');
|
||||
$table->index(['member_id', 'diving_offer_id'], 'idx_reviews_member_offer');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('reviews');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('review_edits', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('review_id')->unique()->constrained('reviews')->cascadeOnDelete();
|
||||
$table->tinyInteger('old_rating')->unsigned();
|
||||
$table->text('old_comment');
|
||||
$table->timestamp('edited_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('review_edits');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('review_votes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('review_id')->constrained('reviews')->cascadeOnDelete();
|
||||
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->unique(['review_id', 'member_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('review_votes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->string('cover_image', 500)->nullable()->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->dropColumn('cover_image');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('course_images', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
|
||||
$table->string('image_path', 500);
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['diving_offer_id', 'sort_order'], 'idx_course_images_offer_sort');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('course_images');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('notifications', function (Blueprint $table) {
|
||||
$table->index(['notifiable_type', 'notifiable_id', 'read_at'], 'notifications_notifiable_read_at_index');
|
||||
});
|
||||
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->index('provider_id', 'diving_offers_provider_id_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('notifications', function (Blueprint $table) {
|
||||
$table->dropIndex('notifications_notifiable_read_at_index');
|
||||
});
|
||||
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->dropIndex('diving_offers_provider_id_index');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Models\AdminProfile;
|
||||
use App\Models\Booking;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\MemberProfile;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DevelopmentSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Admin
|
||||
$admin = User::firstOrCreate(['email' => 'admin@cfdive.com'], [
|
||||
'name' => '平台管理員', 'password' => Hash::make('password123'),
|
||||
'role' => 'admin', 'is_active' => true,
|
||||
]);
|
||||
AdminProfile::firstOrCreate(['user_id' => $admin->id], ['department' => '營運']);
|
||||
|
||||
// Coach
|
||||
$coach = User::firstOrCreate(['email' => 'coach@cfdive.com'], [
|
||||
'name' => '蔡教練', 'password' => Hash::make('password123'),
|
||||
'role' => 'provider', 'is_active' => true,
|
||||
]);
|
||||
ProviderProfile::firstOrCreate(['user_id' => $coach->id], [
|
||||
'business_name' => '藍海潛水工作室',
|
||||
'description' => '專業 PADI 認證教練,10 年教學經驗',
|
||||
'contact_phone' => '0912345678',
|
||||
'contact_email' => 'coach@cfdive.com',
|
||||
'is_verified' => true,
|
||||
]);
|
||||
|
||||
// Member
|
||||
$member = User::firstOrCreate(['email' => 'member@cfdive.com'], [
|
||||
'name' => '測試會員', 'password' => Hash::make('password123'),
|
||||
'role' => 'member', 'is_active' => true,
|
||||
]);
|
||||
MemberProfile::firstOrCreate(['user_id' => $member->id], ['gender' => 'male']);
|
||||
|
||||
// Offers
|
||||
$offer = DivingOffer::firstOrCreate(
|
||||
['title' => '潛入海底 — 入門體驗', 'provider_id' => $coach->id],
|
||||
[
|
||||
'location' => '墾丁', 'spot' => '南灣', 'price' => 6000,
|
||||
'region' => '南部', 'tag' => '初學者',
|
||||
'badges' => ['PADI認證', '含裝備'],
|
||||
'description' => '適合零基礎的水肺潛水入門課程,由專業教練全程陪同。',
|
||||
'rating' => 0, 'reviews' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
DivingOffer::firstOrCreate(
|
||||
['title' => '進階深潛探索', 'provider_id' => $coach->id],
|
||||
[
|
||||
'location' => '小琉球', 'spot' => '美人洞', 'price' => 9800,
|
||||
'region' => '南部', 'tag' => '進階',
|
||||
'badges' => ['AOW認證', '含住宿'],
|
||||
'description' => '探索 30 米深海,適合已有 OW 認證的潛水愛好者。',
|
||||
'rating' => 0, 'reviews' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
// 未來時段(開放預約)
|
||||
$futureSchedule = CourseSchedule::firstOrCreate(
|
||||
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->addDays(14)->toDateString()],
|
||||
[
|
||||
'provider_id' => $coach->id, 'start_time' => '09:00',
|
||||
'max_participants' => 5, 'current_participants' => 0,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]
|
||||
);
|
||||
|
||||
// 過去時段(供測試 completed booking)
|
||||
$pastSchedule = CourseSchedule::firstOrCreate(
|
||||
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->subDays(7)->toDateString()],
|
||||
[
|
||||
'provider_id' => $coach->id, 'start_time' => '09:00',
|
||||
'max_participants' => 5, 'current_participants' => 1,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]
|
||||
);
|
||||
|
||||
// Pending booking(未來)
|
||||
Booking::firstOrCreate(
|
||||
['schedule_id' => $futureSchedule->id, 'member_id' => $member->id],
|
||||
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Pending]
|
||||
);
|
||||
|
||||
// Completed booking(可評價)
|
||||
Booking::firstOrCreate(
|
||||
['schedule_id' => $pastSchedule->id, 'member_id' => $member->id],
|
||||
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Completed]
|
||||
);
|
||||
|
||||
$this->command->info('✅ Seed 完成');
|
||||
$this->command->info(' Admin: admin@cfdive.com / password123');
|
||||
$this->command->info(' Coach: coach@cfdive.com / password123');
|
||||
$this->command->info(' Member: member@cfdive.com / password123');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
|
||||
services:
|
||||
# PHP-FPM 服務
|
||||
app:
|
||||
# 構建配置
|
||||
build:
|
||||
context: . # 使用當前目錄作為構建上下文
|
||||
dockerfile: Dockerfile # 指定 Dockerfile 路徑
|
||||
image: cfdive-platform # 構建的鏡像名稱
|
||||
container_name: cfdive-app # 容器名稱
|
||||
restart: unless-stopped # 自動重啟策略:除非手動停止,否則自動重啟
|
||||
working_dir: /var/www/ # 工作目錄
|
||||
|
||||
# 卷掛載:將本地代碼掛載到容器中
|
||||
volumes:
|
||||
- ./:/var/www # 本地代碼目錄掛載到容器中的 /var/www
|
||||
|
||||
# 環境變數:設置 Laravel 數據庫連接
|
||||
environment:
|
||||
- DB_CONNECTION=mysql # 數據庫類型
|
||||
- DB_HOST=db # 數據庫主機名
|
||||
- DB_PORT=3306 # 數據庫端口
|
||||
- DB_DATABASE=${DB_DATABASE:-CFDivePlatform} # 數據庫名稱
|
||||
- DB_USERNAME=${DB_USERNAME:-cfdiveuser} # 數據庫用戶名
|
||||
- DB_PASSWORD=${DB_PASSWORD} # 數據庫密碼
|
||||
|
||||
# 網絡配置
|
||||
networks:
|
||||
- cfdive-network # 連接到自定義網絡
|
||||
- proxy_net
|
||||
|
||||
# 依賴關係:確保 db 和 redis 服務先啟動
|
||||
depends_on:
|
||||
db: # 依賴 db 服務
|
||||
condition: service_healthy # 等待數據庫健康檢查通過
|
||||
redis: # 依賴 redis 服務
|
||||
condition: service_started # 等待服務啟動
|
||||
|
||||
# 健康檢查配置
|
||||
healthcheck:
|
||||
test: ["CMD", "php", "-v"] # 檢查 PHP 版本確認服務正常
|
||||
interval: 30s # 每30秒檢查一次
|
||||
timeout: 10s # 超時時間10秒
|
||||
retries: 3 # 重試3次
|
||||
start_period: 40s # 容器啟動後40秒開始檢查
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: cfdive-nginx
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||
networks:
|
||||
- cfdive-network
|
||||
- proxy_net
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
|
||||
image: cfdive-frontend
|
||||
container_name: cfdive-frontend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cfdive-network
|
||||
- proxy_net
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: cfdive-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-CFDivePlatform}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_USER: ${DB_USERNAME:-cfdiveuser}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
SERVICE_TAGS: dev
|
||||
SERVICE_NAME: mysql
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
networks:
|
||||
- cfdive-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin/phpmyadmin
|
||||
container_name: cfdive-phpmyadmin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
PMA_USER: ${DB_USERNAME:-cfdiveuser}
|
||||
PMA_PASSWORD: ${DB_PASSWORD}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
networks:
|
||||
- cfdive-network
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
queue-worker:
|
||||
image: cfdive-platform
|
||||
container_name: cfdive-queue
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www/
|
||||
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
environment:
|
||||
- DB_CONNECTION=mysql
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_DATABASE=${DB_DATABASE:-CFDivePlatform}
|
||||
- DB_USERNAME=${DB_USERNAME:-cfdiveuser}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
networks:
|
||||
- cfdive-network
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit
|
||||
container_name: cfdive-mailpit
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cfdive-network
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: cfdive-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cfdive-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
cfdive-network:
|
||||
driver: bridge
|
||||
proxy_net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,58 @@
|
||||
# 定義一個 HTTP 服務器塊
|
||||
server {
|
||||
listen 80;
|
||||
server_name cfdive.local localhost;
|
||||
|
||||
# 默認索引文件,按順序嘗試
|
||||
index index.php index.html;
|
||||
|
||||
# 錯誤日誌和訪問日誌的路徑
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
# 網站根目錄,指向 Laravel 的 public 目錄
|
||||
root /var/www/public;
|
||||
|
||||
# 客戶端上傳文件大小限制
|
||||
client_max_body_size 100M;
|
||||
|
||||
# 處理所有請求
|
||||
location / {
|
||||
# 嘗試以 URI 作為文件查找,然後作為目錄,最後轉發到 index.php
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
|
||||
# 啟用靜態 gzip 文件服務(如果存在 .gz 文件)
|
||||
gzip_static on;
|
||||
}
|
||||
|
||||
# 處理 PHP 文件請求
|
||||
location ~ \.php$ {
|
||||
# 如果文件不存在,返回 404
|
||||
try_files $uri =404;
|
||||
|
||||
# 分割路徑信息,用於處理 PATH_INFO
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
|
||||
# 轉發 PHP 請求到 PHP-FPM 服務
|
||||
fastcgi_pass app:9000;
|
||||
|
||||
# 設置 FastCGI 緩衝區大小
|
||||
fastcgi_buffers 16 16k;
|
||||
fastcgi_buffer_size 32k;
|
||||
|
||||
# 默認索引文件
|
||||
fastcgi_index index.php;
|
||||
|
||||
# 設置 FastCGI 讀取超時時間(秒)
|
||||
fastcgi_read_timeout 600;
|
||||
|
||||
# 包含 FastCGI 參數
|
||||
include fastcgi_params;
|
||||
|
||||
# 設置 SCRIPT_FILENAME 參數,告訴 PHP-FPM 要執行的文件
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
|
||||
# 設置 PATH_INFO 參數,用於獲取路徑信息
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== CFDivePlatform 容器初始化開始 ==="
|
||||
|
||||
# 檢查目錄結構
|
||||
if [ ! -d "/var/www/storage" ]; then
|
||||
echo "創建 storage 目錄..."
|
||||
mkdir -p /var/www/storage
|
||||
fi
|
||||
|
||||
if [ ! -d "/var/www/bootstrap/cache" ]; then
|
||||
echo "創建 bootstrap/cache 目錄..."
|
||||
mkdir -p /var/www/bootstrap/cache
|
||||
fi
|
||||
|
||||
# 設置權限
|
||||
echo "設置目錄權限..."
|
||||
chown -R www-data:www-data /var/www
|
||||
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||
|
||||
# 等待 MySQL 服務啟動
|
||||
echo "等待 MySQL 服務啟動..."
|
||||
|
||||
# 使用更穩定的方法檢查 MySQL 連接
|
||||
MAX_TRIES=60
|
||||
COUNT=0
|
||||
|
||||
wait_for_mysql() {
|
||||
while [ $COUNT -lt $MAX_TRIES ]; do
|
||||
if mysqladmin ping -h"db" -u"cfdiveuser" -p"**REMOVED**" --silent 2>/dev/null; then
|
||||
echo "✅ MySQL 服務已準備就緒"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 備用檢查方法
|
||||
if php -r "
|
||||
try {
|
||||
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', '**REMOVED**');
|
||||
echo 'PHP-PDO-OK';
|
||||
exit(0);
|
||||
} catch(Exception \$e) {
|
||||
exit(1);
|
||||
}
|
||||
" 2>/dev/null; then
|
||||
echo "✅ MySQL 連接成功 (通過 PHP PDO)"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)"
|
||||
sleep 2
|
||||
COUNT=$((COUNT+1))
|
||||
done
|
||||
|
||||
if [ $COUNT -eq $MAX_TRIES ]; then
|
||||
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_mysql
|
||||
|
||||
# 檢查並安裝 Composer 依賴
|
||||
echo "📦 檢查 Composer 依賴..."
|
||||
if [ -f "composer.json" ]; then
|
||||
if [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; then
|
||||
echo "安裝 Composer 依賴..."
|
||||
composer install --no-scripts --no-autoloader --optimize-autoloader
|
||||
composer dump-autoload --optimize
|
||||
else
|
||||
echo "✅ Composer 依賴已是最新"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 設置 Laravel 環境
|
||||
if [ ! -f .env ]; then
|
||||
echo "🔧 創建 .env 檔案..."
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
else
|
||||
echo "✅ .env 檔案已存在"
|
||||
fi
|
||||
|
||||
# 更新環境變數以確保正確配置
|
||||
echo "🔧 更新資料庫配置..."
|
||||
sed -i "s/DB_HOST=.*/DB_HOST=db/g" .env
|
||||
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=**REMOVED**/g" .env
|
||||
sed -i "s/DB_USERNAME=.*/DB_USERNAME=cfdiveuser/g" .env
|
||||
sed -i "s/DB_DATABASE=.*/DB_DATABASE=CFDivePlatform/g" .env
|
||||
|
||||
# 執行遷移(如果數據庫已準備好)
|
||||
echo "🗄️ 執行數據庫遷移..."
|
||||
if php artisan migrate:status 2>/dev/null; then
|
||||
php artisan migrate --force || echo "⚠️ 遷移執行遇到問題,但繼續執行"
|
||||
else
|
||||
echo "⚠️ 無法檢查遷移狀態,跳過遷移"
|
||||
fi
|
||||
|
||||
# 清除與優化 Laravel 緩存
|
||||
echo "🧹 清除 Laravel 緩存..."
|
||||
php artisan config:clear || true
|
||||
php artisan cache:clear || true
|
||||
php artisan route:clear || true
|
||||
php artisan view:clear || true
|
||||
|
||||
# 生成 Swagger 文檔(如果可能)
|
||||
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
||||
echo "📖 生成 API 文檔..."
|
||||
php artisan l5-swagger:generate || echo "⚠️ API 文檔生成失敗"
|
||||
fi
|
||||
|
||||
echo "✅ CFDivePlatform 初始化完成!"
|
||||
|
||||
# 建立 storage symlink
|
||||
echo "🔗 建立 storage symlink..."
|
||||
php artisan storage:link --force || true
|
||||
|
||||
# 啟動 cron daemon(Laravel Scheduler)
|
||||
echo "⏰ 啟動 Laravel Scheduler cron..."
|
||||
service cron start || cron || true
|
||||
|
||||
# 執行傳入的命令
|
||||
exec "$@"
|
||||
@@ -0,0 +1,5 @@
|
||||
upload_max_filesize=40M
|
||||
post_max_size=40M
|
||||
memory_limit=512M
|
||||
max_execution_time=600
|
||||
max_input_vars=10000
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL=http://localhost:8080
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cf-dive-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
}
|
||||
Generated
+3855
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "cf-dive-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
import NotificationDrawer from './components/NotificationDrawer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isBackofficePage = computed(() =>
|
||||
route.path.startsWith('/coach') || route.path.startsWith('/admin')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<NavBar v-if="!isBackofficePage" />
|
||||
<RouterView />
|
||||
<NotificationDrawer />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const adminApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
adminApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default adminApi
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,17 @@
|
||||
import api from './axios'
|
||||
|
||||
export function getMyBookings() {
|
||||
return api.get('/member/bookings')
|
||||
}
|
||||
|
||||
export function getBooking(id) {
|
||||
return api.get(`/member/bookings/${id}`)
|
||||
}
|
||||
|
||||
export function createBooking(payload) {
|
||||
return api.post('/member/bookings', payload)
|
||||
}
|
||||
|
||||
export function cancelBooking(id) {
|
||||
return api.delete(`/member/bookings/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const coachApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
coachApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('coach_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default coachApi
|
||||
@@ -0,0 +1,21 @@
|
||||
import coachApi from './coachAxios'
|
||||
|
||||
export function getProviderBookings() {
|
||||
return coachApi.get('/provider/bookings')
|
||||
}
|
||||
|
||||
export function confirmBooking(id) {
|
||||
return coachApi.put(`/provider/bookings/${id}/confirm`)
|
||||
}
|
||||
|
||||
export function rejectBooking(id) {
|
||||
return coachApi.put(`/provider/bookings/${id}/reject`)
|
||||
}
|
||||
|
||||
export function cancelBooking(id) {
|
||||
return coachApi.put(`/provider/bookings/${id}/cancel`)
|
||||
}
|
||||
|
||||
export function completeBooking(id) {
|
||||
return coachApi.put(`/provider/bookings/${id}/complete`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import coachApi from './coachAxios'
|
||||
|
||||
export function getSchedules() {
|
||||
return coachApi.get('/provider/schedules')
|
||||
}
|
||||
|
||||
export function createSchedule(payload) {
|
||||
return coachApi.post('/provider/schedules', payload)
|
||||
}
|
||||
|
||||
export function updateSchedule(id, payload) {
|
||||
return coachApi.put(`/provider/schedules/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteSchedule(id) {
|
||||
return coachApi.delete(`/provider/schedules/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import coachApi from './coachAxios'
|
||||
|
||||
function toFormData(file) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return fd
|
||||
}
|
||||
|
||||
export function uploadCover(offerId, file) {
|
||||
return coachApi.post(`/provider/offers/${offerId}/cover`, toFormData(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCover(offerId) {
|
||||
return coachApi.delete(`/provider/offers/${offerId}/cover`)
|
||||
}
|
||||
|
||||
export function uploadImage(offerId, file) {
|
||||
return coachApi.post(`/provider/offers/${offerId}/images`, toFormData(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteImage(imageId) {
|
||||
return coachApi.delete(`/provider/images/${imageId}`)
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
import api from './axios'
|
||||
import axios from 'axios'
|
||||
|
||||
const publicApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
export function getReviews(offerId, sort = 'helpful') {
|
||||
return publicApi.get(`/diving-offers/${offerId}/reviews`, { params: { sort } })
|
||||
}
|
||||
|
||||
export function createReview(payload) {
|
||||
return api.post('/member/reviews', payload)
|
||||
}
|
||||
|
||||
export function updateReview(id, payload) {
|
||||
return api.put(`/member/reviews/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteReview(id) {
|
||||
return api.delete(`/member/reviews/${id}`)
|
||||
}
|
||||
|
||||
export function toggleHelpful(reviewId) {
|
||||
return api.post(`/reviews/${reviewId}/helpful`)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const publicApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
export function getSchedulesByOffer(offerId) {
|
||||
return publicApi.get(`/diving-offers/${offerId}/schedules`)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { useAdminAuthStore } from '../stores/adminAuth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const adminAuth = useAdminAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await adminAuth.logout()
|
||||
router.push('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-slate-800 text-white shadow-md">
|
||||
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="text-lg font-bold tracking-wide">⚙️ Admin Panel</span>
|
||||
<RouterLink to="/admin/dashboard" class="text-sm hover:text-slate-300 transition">儀表板</RouterLink>
|
||||
<RouterLink to="/admin/members" class="text-sm hover:text-slate-300 transition">會員管理</RouterLink>
|
||||
<RouterLink to="/admin/providers" class="text-sm hover:text-slate-300 transition">教練管理</RouterLink>
|
||||
<RouterLink to="/admin/offers" class="text-sm hover:text-slate-300 transition">課程管理</RouterLink>
|
||||
<RouterLink to="/admin/bookings" class="text-sm hover:text-slate-300 transition">預約管理</RouterLink>
|
||||
<RouterLink to="/admin/reviews" class="text-sm hover:text-slate-300 transition">評價管理</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="text-slate-400">{{ adminAuth.user?.name }}</span>
|
||||
<button @click="handleLogout"
|
||||
class="bg-slate-600 hover:bg-slate-500 px-4 py-1.5 rounded-full transition">
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { useCoachAuthStore } from '../stores/coachAuth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NotificationBell from './NotificationBell.vue'
|
||||
|
||||
const coachAuth = useCoachAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await coachAuth.logout()
|
||||
router.push('/coach/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-gray-900 text-white shadow-md">
|
||||
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-6">
|
||||
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
|
||||
🤿 Coach Portal
|
||||
</RouterLink>
|
||||
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
|
||||
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
|
||||
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
|
||||
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
|
||||
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
offer: { type: Object, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/courses/${offer.id}`"
|
||||
class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col"
|
||||
>
|
||||
<div class="h-40 overflow-hidden">
|
||||
<img
|
||||
v-if="offer.cover_image_url"
|
||||
:src="offer.cover_image_url"
|
||||
:alt="offer.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
|
||||
🤿
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex flex-col gap-2 flex-1">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<span
|
||||
v-for="badge in (offer.badges || [])"
|
||||
:key="badge"
|
||||
class="text-xs bg-ocean-100 text-ocean-700 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{{ badge }}
|
||||
</span>
|
||||
<span v-if="offer.tag" class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{{ offer.tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold text-gray-800 line-clamp-2 leading-snug">{{ offer.title }}</h3>
|
||||
|
||||
<p class="text-sm text-gray-500 flex items-center gap-1">
|
||||
📍 {{ offer.location }}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<span class="text-sm text-amber-500 font-medium">
|
||||
★ {{ offer.rating }} <span class="text-gray-400">({{ offer.reviews }})</span>
|
||||
</span>
|
||||
<span class="text-ocean-700 font-bold">NT$ {{ offer.price.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NotificationBell from './NotificationBell.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-ocean-800 text-white shadow-md">
|
||||
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<RouterLink to="/" class="text-xl font-bold tracking-wide hover:text-ocean-100 transition">
|
||||
🤿 CFDive
|
||||
</RouterLink>
|
||||
|
||||
<div class="flex items-center gap-6 text-sm font-medium">
|
||||
<RouterLink to="/courses" class="hover:text-ocean-100 transition">探索課程</RouterLink>
|
||||
|
||||
<template v-if="auth.isLoggedIn">
|
||||
<span class="text-ocean-200 hidden sm:inline">
|
||||
👤 {{ auth.user?.name }}
|
||||
</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"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/login" class="hover:text-ocean-100 transition">登入</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition"
|
||||
>
|
||||
註冊
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup>
|
||||
import AdminNavBar from '../components/AdminNavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-50">
|
||||
<AdminNavBar />
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup>
|
||||
import CoachNavBar from '../components/CoachNavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<CoachNavBar />
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue'
|
||||
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)
|
||||
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')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user