Compare commits

...

2 Commits

Author SHA1 Message Date
6fab048461 [FEAT] 完善個人檔案功能:新增頭像即時上傳、麵包屑導覽、版面寬度優化與日期格式統一
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
2026-03-13 10:08:30 +08:00
ea460cf6d9 [FEAT] 完善個人資料頭像上傳功能與導覽列介面優化 2026-03-13 09:30:07 +08:00
27 changed files with 602 additions and 517 deletions

408
README.md
View File

@@ -1,388 +1,112 @@
# Star Cloud 智能販賣機管理平台
> 基於 Docker 的全方位智能販賣機後台管理系統
> 基於 Docker 的全方位智能販賣機後台管理系統 (Cloud 平台)
Star Cloud 是一個專為智能販賣機設計的後台管理系統,提供機台監控、庫存管理、銷售分析與會員管理等完整功能。本專案採用 Docker Compose 容器化架構,實現快速部署與環境一致性
Star Cloud 是一個專為智能販賣機設計的後台管理系統,負責管理機台、商品、銷售數據,並為硬體端點提供專用的 API
---
## 技術架構
## 🚀 技術架構
### 容器化架構
本專案完全運行在 Docker 容器中,包含以下服務:
### 核心架構
本專案採用 **傳統單體式架構 (Monolithic Architecture)**,結合 Laravel Blade 引擎進行伺服器端渲染 (SSR)。
| 服務 | 容器名稱 | 技術 | 用途 | 連接埠 |
| 服務 | 容器名稱 | 技術 | 用途 | 本地 Port |
|------|---------|------|------|--------|
| **應用程式** | star-cloud-laravel | Laravel 10 + PHP 8.5 | Web 應用與 API | 8090:80, 5175:5175 |
| **資料庫** | star-cloud-mysql | MySQL 8.0 | 關聯式資料庫 | 3306:3306 |
| **快取** | star-cloud-redis | Redis Alpine | 快取與 Session | 6380:6379 |
| **應用程式** | `star-cloud-laravel` | Laravel 12 + PHP 8.5 | 核心業務與渲染 | 8090 |
| **資料庫** | `star-cloud-mysql` | MySQL 8.0 | 數據持久化 | 3306 |
| **快取/隊列** | `star-cloud-redis` | Redis Alpine | IoT 高併發隊列 | 6380 |
### 後端技術棧
- **Framework**: Laravel 10.x
- **Language**: PHP 8.5+
- **Framework**: Laravel 12.x
- **Language**: PHP 8.5
- **Redis**: 用於 IoT 高併發隊列 (B010, B600 等)
- **Database**: MySQL 8.0
- **Cache/Session**: Redis
- **Authentication**: Laravel Sanctum (API Token)
- **Package Manager**: Composer 2.x
### 前端技術棧
- **Template Engine**: Blade Templates
- **UI Library**: Preline UI 3.x (Tailwind CSS 組件庫)
- **CSS Framework**: Tailwind CSS 3.x
- **JavaScript**: Alpine.js 3.x (輕量級互動框架)
- **Build Tool**: Vite 5.x
- **HTTP Client**: Axios
- **View**: Laravel Blade
- **CSS**: Tailwind CSS + **Preline UI**
- **JS**: Alpine.js (行為控制)
- **Build**: Vite
---
## 快速開始
## 🛠️ 開發環境 (Laravel Sail)
本專案建議使用 **Laravel Sail** 進行開發,避免直接在宿主機執行指令。
### 前置需求
- Docker Desktop (Windows/Mac) 或 Docker Engine (Linux)
- Git
確保您的系統已安裝以下軟體:
### 快速啟動
1. `clone` 專案並進入目錄
2. `cp .env.example .env`
3. `./vendor/bin/sail up -d`
4. `./vendor/bin/sail composer install`
5. `./vendor/bin/sail artisan key:generate`
6. `./vendor/bin/sail artisan migrate --seed`
7. `./vendor/bin/sail npm install`
8. `./vendor/bin/sail npm run dev`
- **Docker** 20.10+
- **Docker Compose** 2.0+
- **Git**
> **提示**Windows 使用者建議安裝 [Docker Desktop](https://www.docker.com/products/docker-desktop/)Linux 使用者可參考 [官方安裝文件](https://docs.docker.com/engine/install/)
### 安裝步驟
#### 1. Clone 專案
```bash
git clone <repository_url>
cd star-cloud
```
#### 2. 環境設定
複製環境變數範例檔案:
```bash
cp .env.example .env
```
**重要設定**`.env` 檔案):
```env
# 應用程式設定
APP_NAME=Star Cloud
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8090
# 資料庫設定(對應 Docker Compose 服務)
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=star_cloud
DB_USERNAME=sail
DB_PASSWORD=password
# Redis 設定(對應 Docker Compose 服務)
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
# Vite 開發伺服器
VITE_PORT=5175
```
#### 3. 啟動 Docker 容器
啟動所有服務應用程式、資料庫、Redis
```bash
docker compose up -d
```
> **說明**`-d` 參數表示背景執行
檢查容器狀態:
```bash
docker compose ps
```
預期輸出:
```
NAME STATUS PORTS
star-cloud-laravel Up X minutes 0.0.0.0:8090->80/tcp, 0.0.0.0:5175->5175/tcp
star-cloud-mysql Up X minutes 0.0.0.0:3306->3306/tcp
star-cloud-redis Up X minutes 0.0.0.0:6380->6379/tcp
```
#### 4. 初始化應用程式
**4.1 安裝後端依賴**
```bash
docker compose exec laravel.test composer install
```
**4.2 產生應用程式金鑰**
```bash
docker compose exec laravel.test php artisan key:generate
```
**4.3 執行資料庫遷移與種子**
```bash
docker compose exec laravel.test php artisan migrate --seed
```
> **預設管理員帳號**
> - Email: `admin`
> - Password: `password`
**4.4 安裝前端依賴**
```bash
docker compose exec laravel.test npm install
```
**4.5 編譯前端資源**
```bash
# 開發模式(支援 Hot Module Replacement
docker compose exec laravel.test npm run dev
# 或生產模式
docker compose exec laravel.test npm run build
```
#### 5. 訪問應用程式
- **應用程式**: http://localhost:8090
- **Vite Dev Server**: http://localhost:5175
### 常用開發指令
| 功能 | 指令 |
|------|------|
| 啟動環境 | `./vendor/bin/sail up -d` |
| 停止環境 | `./vendor/bin/sail down` |
| Artisan 指令 | `./vendor/bin/sail artisan <cmd>` |
| Composer 指令 | `./vendor/bin/sail composer <cmd>` |
| NPM 指令 | `./vendor/bin/sail npm <cmd>` |
| 執行測試 | `./vendor/bin/sail test` |
---
## Docker 常用指令
## 🔐 API 規範
### 容器管理
系統 API 分為兩大類,遵循不同的設計慣例:
```bash
# 啟動所有服務
docker compose up -d
### 1. Admin/Web API (`/api/v1/...`)
- **對象**: 後台管理介面、APP UI。
- **認證**: Laravel Sanctum (Session/Token)。
- **格式**: 標準 RESTful JSON。
# 停止所有服務
docker compose down
# 重啟服務
docker compose restart
# 查看容器日誌
docker compose logs -f laravel.test
# 進入應用程式容器
docker compose exec laravel.test bash
```
### Laravel 指令
所有 Laravel Artisan 指令需在容器內執行:
```bash
# 執行 Artisan 指令
docker compose exec laravel.test php artisan <command>
# 範例:清除快取
docker compose exec laravel.test php artisan cache:clear
# 範例:執行 Migration
docker compose exec laravel.test php artisan migrate
# 範例:建立新 Controller
docker compose exec laravel.test php artisan make:controller ExampleController
```
### 前端開發
```bash
# 安裝 npm 套件
docker compose exec laravel.test npm install
# 開發模式(即時編譯)
docker compose exec laravel.test npm run dev
# 生產編譯
docker compose exec laravel.test npm run build
```
### 資料庫操作
```bash
# 進入 MySQL 容器
docker compose exec mysql bash
# 直接執行 SQL
docker compose exec mysql mysql -u sail -ppassword star_cloud
# 備份資料庫
docker compose exec mysql mysqldump -u sail -ppassword star_cloud > backup.sql
# 還原資料庫
docker compose exec -T mysql mysql -u sail -ppassword star_cloud < backup.sql
```
### 2. Machine IoT API (`/api/app/...`)
- **對象**: 智能販賣機、計時器等硬體。
- **認證**: Header `Authorization: Bearer <api_token>`
- **高併發處理**: 核心日誌 (B010 心跳、B600 交易) **嚴禁直寫 DB**,必須進入 **Redis Queue** 背景異步處理。
---
## 主要功能模組
## 🌐 多語系支援 (I18n)
### 核心功能
| 模組 | 功能描述 |
|------|---------|
| **儀錶板** | 銷售數據總覽、機台狀態即時監控、營收統計圖表 |
| **機台管理** | 機台列表、遠端控制、日誌查詢、維修管理、效期控制 |
| **倉庫管理** | 倉庫列表、庫存管理、調撥單、採購單、補貨單 |
| **商品管理** | 商品資料、分類管理、商品報表分析 |
| **銷售管理** | 交易紀錄、金流管理、促銷設定、營收報表 |
| **會員系統** | 會員管理、點數系統、來店禮、Line 整合 |
| **權限控制** | 角色管理、權限分配、功能權限設定 |
| **遠端管理** | 機台重啟、遠端出貨、遠端結帳、庫存調整 |
所有 UI 顯示文字必須支援多語系,禁止 Hard-coded。
- **語系檔案**: `lang/zh_TW.json`, `lang/en.json`, `lang/ja.json`
- **呼叫方式**: 使用 `__('Phrases in English')``@lang('...')`
- **命名規範**: 優先使用「英文原始詞彙」作為 Key 名稱。
---
## Preline UI 組件庫
## 📂 目錄結構
本專案已整合 **Preline UI 3.x**,這是一個基於 Tailwind CSS 的開源 UI 組件庫,提供 50+ 預構建組件。
### 可用組件類別
- **Navigation**: 導航列、側邊欄、分頁、麵包屑、頁籤
- **Forms**: 輸入框、選擇器、開關、檔案上傳、日期選擇器
- **Overlays**: 模態框、抽屜、下拉選單、提示框、彈出框
- **Data Display**: 表格、卡片、時間軸、折疊面板、徽章
- **Feedback**: 通知、警告、載入狀態、進度條
### 使用範例
```html
<!-- 下拉選單 -->
<div class="hs-dropdown relative inline-flex">
<button type="button" class="hs-dropdown-toggle px-4 py-2 bg-blue-600 text-white rounded-lg">
選單 <svg class="w-4 h-4 inline ml-2">...</svg>
</button>
<div class="hs-dropdown-menu hidden bg-white shadow-lg rounded-lg p-2 mt-2">
<a class="block px-3 py-2 rounded hover:bg-gray-100" href="#">選項 1</a>
<a class="block px-3 py-2 rounded hover:bg-gray-100" href="#">選項 2</a>
</div>
</div>
<!-- 模態框 -->
<button type="button" data-hs-overlay="#my-modal" class="px-4 py-2 bg-blue-600 text-white rounded-lg">
開啟模態框
</button>
<div id="my-modal" class="hs-overlay hidden">
<!-- 模態框內容 -->
</div>
```
**更多資源**
- 官方文件: https://preline.co/docs/
- 組件範例: https://preline.co/examples.html
- GitHub: https://github.com/htmlstreamofficial/preline
- `app/Http/Controllers/`: 控制器
- `app/Models/{Domain}/`: 分領域的模型 (如 `Machine`, `Member`)
- `app/Services/{Domain}/`: 封裝商業邏輯與資料異動
- `app/Jobs/{Domain}/`: IoT 異步隊列處理任務 (重要)
- `resources/views/`: Blade 模板 (按功能分資料夾)
- `resources/views/components/`: 可重用的 UI 組件
- `routes/`: 路由定義 (`web.php``api.php`)
---
## 故障排除
## 🚢 CI/CD 與部署
### 容器無法啟動
```bash
# 檢查容器日誌
docker compose logs
# 重建容器
docker compose down
docker compose up -d --build
```
### 連接資料庫失敗
確認 `.env``DB_HOST` 設定為 `mysql`(容器服務名稱),而非 `127.0.0.1`
### 前端資源編譯失敗
```bash
# 清除 node_modules 重新安裝
docker compose exec laravel.test rm -rf node_modules
docker compose exec laravel.test npm install
docker compose exec laravel.test npm run build
```
### 權限問題
```bash
# 修正儲存目錄權限
docker compose exec laravel.test chmod -R 775 storage bootstrap/cache
```
---
## 部署至生產環境
### 1. 環境變數設定
`.env` 中的設定調整為生產環境:
```env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://your-domain.com
```
### 2. 編譯前端資源
```bash
docker compose exec laravel.test npm run build
```
### 3. 優化 Laravel
```bash
docker compose exec laravel.test php artisan config:cache
docker compose exec laravel.test php artisan route:cache
docker compose exec laravel.test php artisan view:cache
```
### 4. 設定 HTTPS
建議使用 Nginx Reverse Proxy + Let's Encrypt SSL 憑證。
---
## 開發團隊協作
### Git Workflow
```bash
# 拉取最新程式碼
git pull origin main
# 重建容器(若 Docker 設定有變更)
docker compose down
docker compose up -d
# 更新依賴
docker compose exec laravel.test composer install
docker compose exec laravel.test npm install
# 執行 Migration
docker compose exec laravel.test php artisan migrate
```
- **自動化工具**: Gitea Actions (`.gitea/workflows/`)。
- **Demo 環境**: 推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`
- **伺服器路徑**: `/var/www/star-cloud-demo` (透過 `ssh gitea_work` 登入)。
---
## 授權與版權
© Star Cloud. All Rights Reserved.
---

View File

@@ -7,6 +7,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class ProfileController extends Controller
@@ -18,16 +19,9 @@ class ProfileController extends Controller
{
$user = $request->user();
// 如果沒有歷史紀錄,注入一些模擬資料供預覽
if ($user->loginLogs()->count() === 0) {
$user->loginLogs()->createMany([
['ip_address' => '127.0.0.1', 'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', 'login_at' => now()->subHours(2)],
['ip_address' => '192.168.1.100', 'user_agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)', 'login_at' => now()->subDays(1)],
]);
}
return view('profile.edit', [
'user' => $user->load(['loginLogs' => fn($q) => $q->latest()]),
// 只取最新 10 筆登入紀錄
'user' => $user->load(['loginLogs' => fn($q) => $q->latest('login_at')->limit(10)]),
]);
}
@@ -36,35 +30,50 @@ class ProfileController extends Controller
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
$user = $request->user();
$user->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$request->user()->save();
$user->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
* Update the user's avatar via AJAX.
*/
public function destroy(Request $request): RedirectResponse
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
$request->validate([
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:1024'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
if ($request->hasFile('avatar')) {
// Delete old avatar if exists
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
$path = $request->file('avatar')->store('avatars', 'public');
$user->avatar = $path;
$user->save();
return response()->json([
'success' => true,
'avatar_url' => $user->avatar_url,
'message' => __('Avatar updated successfully.'),
]);
}
return response()->json([
'success' => false,
'message' => __('No file uploaded.'),
], 400);
}
}

View File

@@ -19,7 +19,7 @@ class ProfileUpdateRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
'phone' => ['nullable', 'string', 'max:20'],
'avatar' => ['nullable', 'string', 'max:255'],
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Listeners;
use App\Models\UserLoginLog;
use Illuminate\Auth\Events\Login;
use Illuminate\Http\Request;
class LogSuccessfulLogin
{
/**
* The request instance.
*
* @var \Illuminate\Http\Request
*/
protected $request;
/**
* Create the event listener.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Handle the event.
*
* @param \Illuminate\Auth\Events\Login $event
* @return void
*/
public function handle(Login $event)
{
UserLoginLog::create([
'user_id' => $event->user->id,
'ip_address' => $this->request->ip(),
'user_agent' => $this->request->userAgent(),
'login_at' => now(),
]);
}
}

View File

@@ -54,4 +54,16 @@ class User extends Authenticatable
{
return $this->hasMany(\App\Models\UserLoginLog::class);
}
/**
* Get the user's avatar URL.
*/
public function getAvatarUrlAttribute(): string
{
if ($this->avatar) {
return asset('storage/' . $this->avatar);
}
return "https://ui-avatars.com/api/?name=" . urlencode($this->name) . "&background=0D8ABC&color=fff";
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Providers;
use App\Listeners\LogSuccessfulLogin;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -22,5 +25,9 @@ class AppServiceProvider extends ServiceProvider
if (!$this->app->isLocal()) {
\Illuminate\Support\Facades\URL::forceScheme('https');
}
// 記錄使用者成功登入的歷史
Event::listen(Login::class, LogSuccessfulLogin::class);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Listeners\LogSuccessfulLogin;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
@@ -18,6 +20,9 @@ class EventServiceProvider extends ServiceProvider
Registered::class => [
SendEmailVerificationNotification::class,
],
Login::class => [
LogSuccessfulLogin::class,
],
];
/**

View File

@@ -146,5 +146,15 @@
"Sales": "販売",
"Others": "その他",
"AI Prediction": "AI予測",
"Roles": "ロール"
"Roles": "ロール",
"Yesterday": "昨日",
"Day Before": "一昨日",
"No login history yet": "ログイン履歴はまだありません",
"Signed in as": "ログイン中",
"Logout": "ログアウト",
"Joined": "入会日",
"Recent Login": "最近のログイン",
"Total Logins": "総ログイン数",
"Account Status": "アカウント状態",
"Active": "アクティブ"
}

View File

@@ -39,6 +39,8 @@
"Today's Transactions": "今日交易",
"Yesterday's Transactions": "昨日交易",
"Before Yesterday's Transactions": "前日交易",
"Yesterday": "昨日",
"Day Before": "前日",
"vs Yesterday": "比昨日",
"Machine Status List": "機台狀態列表",
"Total items": "共 :count 筆",
@@ -146,5 +148,13 @@
"Sales": "銷售管理",
"Others": "其他功能",
"AI Prediction": "AI智能預測",
"Roles": "角色設定"
"Roles": "角色設定",
"No login history yet": "尚無登入紀錄",
"Signed in as": "登入身份",
"Logout": "登出",
"Joined": "加入日期",
"Recent Login": "最近登入",
"Total Logins": "總登入次數",
"Account Status": "帳號狀態",
"Active": "使用中"
}

View File

@@ -29,7 +29,7 @@
/* Luxury Cards */
.luxury-card {
@apply bg-white dark:bg-[#1e293b] border-0;
@apply bg-white dark:bg-[#1e293b] border-0 rounded-2xl;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

View File

@@ -8,8 +8,8 @@
<div class="luxury-card rounded-2xl p-8 animate-luxury-in flex flex-col">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-xl font-extrabold text-slate-800 dark:text-white font-display tracking-tight">{{ __('Connectivity Status') }}</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-400 uppercase tracking-widest mt-1">{{ __('Real-time status monitoring') }}</p>
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Connectivity Status') }}</h3>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time status monitoring') }}</p>
</div>
<div class="flex items-center gap-x-1.5 px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 border border-cyan-500/20">
<span class="relative flex h-2 w-2">
@@ -26,21 +26,21 @@
<div class="flex items-center justify-between pr-10">
<div class="flex items-center gap-x-4">
<div class="w-2 h-2 rounded-full bg-cyan-500 shadow-[0_0_10px_rgba(6,182,212,0.6)]"></div>
<span class="text-xs font-black text-slate-400 dark:text-slate-400 uppercase tracking-widest">{{ __('Online Machines') }}</span>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Online Machines') }}</span>
</div>
<span class="text-2xl font-black text-slate-900 dark:text-white">{{ $activeMachines }}</span>
</div>
<div class="flex items-center justify-between pr-10">
<div class="flex items-center gap-x-4">
<div class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.6)]"></div>
<span class="text-xs font-black text-slate-400 dark:text-slate-400 uppercase tracking-widest">{{ __('Offline Machines') }}</span>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Offline Machines') }}</span>
</div>
<span class="text-2xl font-black text-rose-500">{{ $alertsPending }}</span>
</div>
<div class="flex items-center justify-between pr-10">
<div class="flex items-center gap-x-4">
<div class="w-2 h-2 rounded-full bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.6)]"></div>
<span class="text-xs font-black text-slate-400 dark:text-slate-400 uppercase tracking-widest">{{ __('Alerts Pending') }}</span>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Alerts Pending') }}</span>
</div>
<span class="text-2xl font-black text-slate-900 dark:text-white">0</span>
</div>
@@ -52,7 +52,7 @@
<!-- Right: Big Total -->
<div class="w-40 text-center">
<p class="text-7xl font-black text-cyan-500 drop-shadow-[0_0_20px_rgba(6,182,212,0.3)] leading-none">{{ $activeMachines }}</p>
<p class="text-[10px] font-black text-cyan-500/80 dark:text-cyan-400 uppercase tracking-[0.3em] mt-4">{{ __('Total Connected') }}</p>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] mt-4">{{ __('Total Connected') }}</p>
</div>
</div>
</div>
@@ -61,8 +61,8 @@
<div class="luxury-card rounded-2xl p-8 animate-luxury-in flex flex-col" style="animation-delay: 100ms">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-xl font-extrabold text-slate-800 dark:text-white font-display tracking-tight">{{ __('Monthly Transactions') }}</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-400 uppercase tracking-widest mt-1">{{ __('Monthly cumulative revenue overview') }}</p>
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Monthly Transactions') }}</h3>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Monthly cumulative revenue overview') }}</p>
</div>
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800/80 text-slate-400 dark:text-slate-500 border border-transparent dark:border-slate-700/50">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@@ -77,8 +77,8 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.5 4.5L21.75 7.5M21.75 7.5V12m0-4.5H17.25"/></svg>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __("Today's Transactions") }}</p>
<p class="text-2xl font-black text-slate-900 dark:text-white mt-0.5 tracking-tight">${{ number_format($totalRevenue / 30, 0) }}</p>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Today's Transactions") }}</p>
<p class="text-4xl font-black text-slate-900 dark:text-white mt-1 tracking-tight drop-shadow-sm">${{ number_format($totalRevenue / 30, 0) }}</p>
</div>
</div>
<div class="flex flex-col items-end gap-y-1">
@@ -92,7 +92,7 @@
<!-- Yesterday Card -->
<div class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
<div class="flex justify-between items-start mb-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __("Yesterday's Transactions") }}</p>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Yesterday") }}</p>
<div class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
@@ -103,7 +103,7 @@
<!-- Before Yesterday Card -->
<div class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
<div class="flex justify-between items-start mb-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __("Before Yesterday's Transactions") }}</p>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Day Before") }}</p>
<div class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
</div>
@@ -120,12 +120,12 @@
<div class="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-6">
<div>
<div class="flex items-center gap-x-3">
<h2 class="text-2xl font-extrabold text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Status List') }}</h2>
<h2 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Status List') }}</h2>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-tighter">
{{ __('Total items', ['count' => count($latestActivities)]) }}
</span>
</div>
<p class="text-sm font-bold text-slate-400 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Real-time monitoring across all machines') }}</p>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time monitoring across all machines') }}</p>
</div>
<div class="flex items-center gap-x-4">

View File

@@ -24,7 +24,7 @@
</div>
@endif
<div class="container mx-auto px-6 py-8">
<div class="px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">儲值回饋設定</h3>
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">

View File

@@ -39,7 +39,7 @@
</div>
@endif
<div class="container mx-auto px-6 py-8">
<div class="px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">禮品設定</h3>
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="btn-luxury-primary">

View File

@@ -8,7 +8,7 @@
@section('content')
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg border border-gray-200">
<div class="p-6 text-gray-900">
<div class="flex justify-between items-center mb-6">

View File

@@ -10,7 +10,7 @@
@section('content')
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="sm:px-6 lg:px-8 space-y-6">
<!-- 篩選器 -->
<div class="luxury-card rounded-2xl p-6 animate-luxury-in">

View File

@@ -11,7 +11,7 @@
@section('content')
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="sm:px-6 lg:px-8 space-y-6">
<!-- 基本資訊卡片 -->
<div class="bg-white shadow sm:rounded-lg p-6 border border-gray-200">
<h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">基本資訊</h3>

View File

@@ -3,7 +3,7 @@
@section('content')
@php
@endphp
<div class="container mx-auto px-6 py-8">
<div class="px-6 py-8">
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">會員列表</h3>
<div class="mt-8">

View File

@@ -24,7 +24,7 @@
</div>
@endif
<div class="container mx-auto px-6 py-8">
<div class="px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">會員等級設定</h3>
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="btn-luxury-primary">

View File

@@ -1,7 +1,7 @@
@extends('layouts.admin')
@section('content')
<div class="max-w-7xl mx-auto">
<div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 text-center border border-gray-200 dark:border-gray-700">
<div class="mb-6">
<svg class="mx-auto h-24 w-24 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@@ -32,7 +32,7 @@
</div>
@endif
<div class="container mx-auto px-6 py-8">
<div class="px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">點數規則設定</h3>
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="btn-luxury-primary">

View File

@@ -0,0 +1,176 @@
@props(['links' => null])
@php
if (is_null($links)) {
$routeName = Route::currentRouteName();
$links = [];
// 預設首頁 (Dashboard)
$links[] = [
'label' => __('Dashboard'),
'url' => route('admin.dashboard'),
'active' => $routeName === 'admin.dashboard'
];
if ($routeName && $routeName !== 'admin.dashboard') {
// 定義大模組映射表 (路由前綴 => 大模組名稱)
$moduleMap = [
'profile' => __('Profile Settings'),
'admin.members' => __('Member Management'),
'admin.membership-tiers' => __('Member Management'),
'admin.deposit-bonus-rules' => __('Member Management'),
'admin.point-rules' => __('Member Management'),
'admin.gift-definitions' => __('Member Management'),
'admin.machines' => __('Machine Management'),
'admin.app' => __('APP Management'),
'admin.warehouses' => __('Warehouse Management'),
'admin.sales' => __('Sales Management'),
'admin.analysis' => __('Analysis Management'),
'admin.audit' => __('Audit Management'),
'admin.data-config' => __('Data Configuration'),
'admin.remote' => __('Remote Management'),
'admin.line' => __('Line Management'),
'admin.reservation' => __('Reservation System'),
'admin.special-permission' => __('Special Permission'),
'admin.permission' => __('Permission Settings'),
];
// 1. 找出所屬大模組
$foundModule = null;
foreach ($moduleMap as $prefix => $label) {
if (str_starts_with($routeName, $prefix)) {
$foundModule = [
'label' => $label,
'url' => '#',
'active' => false
];
break;
}
}
if ($foundModule) {
$links[] = $foundModule;
}
// 2. 處理具體頁面
$segments = explode('.', $routeName);
$lastSegment = end($segments);
// 嘗試翻譯最後一個片段作為頁面名稱
$pageLabel = match($lastSegment) {
'edit' => __('Profile'), // 專門處理 profile.edit
'index' => null, // 通常 index 代表列表,如果在大模組下則不重複顯示
'logs' => __('Machine Logs'),
'permissions' => __('Machine Permissions'),
'utilization' => __('Utilization Rate'),
'expiry' => __('Expiry Management'),
'maintenance' => __('Maintenance Records'),
'ui-elements' => __('UI Elements'),
'helper' => __('Helper'),
'questionnaire' => __('Questionnaire'),
'games' => __('Games'),
'timer' => __('Timer'),
'personal' => __('Warehouse List (Individual)'),
'stock-management' => __('Stock Management'),
'transfers' => __('Transfers'),
'purchases' => __('Purchases'),
'replenishments' => __('Replenishments'),
'replenishment-records' => __('Replenishment Records'),
'machine-stock' => __('Machine Stock'),
'staff-stock' => __('Staff Stock'),
'returns' => __('Returns'),
'pickup-codes' => __('Pickup Codes'),
'orders' => __('Orders'),
'promotions' => __('Promotions'),
'pass-codes' => __('Pass Codes'),
'store-gifts' => __('Store Gifts'),
'change-stock' => __('Change Stock'),
'machine-reports' => __('Machine Reports'),
'product-reports' => __('Product Reports'),
'survey-analysis' => __('Survey Analysis'),
'products' => __('Product Management'),
'advertisements' => __('Advertisement Management'),
'admin-products' => __('Admin Sellable Products'),
'accounts' => __('Account Management'),
'sub-accounts' => __('Sub Accounts'),
'sub-account-roles' => __('Sub Account Roles'),
'points' => __('Point Settings'),
'badges' => __('Badge Settings'),
'restart' => __('Machine Restart'),
'restart-card-reader' => __('Card Reader Restart'),
'checkout' => __('Remote Checkout'),
'lock' => __('Remote Lock'),
'change' => __('Remote Change'),
'dispense' => __('Remote Dispense'),
'official-account' => __('Line Official Account'),
'coupons' => __('Line Coupons'),
'stores' => __('Store Management'),
'time-slots' => __('Time Slots'),
'venues' => __('Venue Management'),
'reservations' => __('Reservations'),
'clear-stock' => __('Clear Stock'),
'apk-versions' => __('APK Versions'),
'discord-notifications' => __('Discord Notifications'),
'app-features' => __('APP Features'),
'roles' => __('Roles'),
'others' => __('Others'),
'ai-prediction' => __('AI Prediction'),
'create' => __('Create'),
'show' => __('Show'),
'members' => __('Member List'), // 處理 admin.members.index 這種情況
default => null,
};
// 如果匹配不到,嘗試處理一些特殊的 index 標籤
if (!$pageLabel && $lastSegment === 'index') {
$pageLabel = match($segments[count($segments)-2] ?? '') {
'members' => __('Member List'),
'machines' => __('Machine List'),
'warehouses' => __('Warehouse List'),
'sales' => __('Sales Records'),
default => null,
};
}
if ($pageLabel) {
$links[] = [
'label' => $pageLabel,
'url' => route($routeName),
'active' => true
];
}
// 確保最後一個 link 是 active 的
if (!empty($links)) {
$links[count($links) - 1]['active'] = true;
// 如果倒數第二個也是同個頁面(例如 Dashboard > Dashboard則移除重複
if (count($links) > 1 && $links[count($links)-1]['label'] === $links[count($links)-2]['label']) {
array_pop($links);
$links[count($links)-1]['active'] = true;
}
}
}
}
@endphp
<nav {{ $attributes->merge(['class' => 'flex', 'aria-label' => 'Breadcrumb']) }}>
<ol class="flex items-center whitespace-nowrap min-w-0 w-full">
@foreach($links as $link)
<li class="flex items-center text-sm {{ $link['active'] ? 'font-semibold text-slate-800 dark:text-slate-200' : 'text-slate-500 dark:text-slate-400' }} {{ !$loop->last ? 'shrink-0' : 'truncate' }}">
@if(!$link['active'] && $link['url'] !== '#')
<a class="hover:text-cyan-600 transition-colors" href="{{ $link['url'] }}">
{{ $link['label'] }}
</a>
@else
{{ $link['label'] }}
@endif
@if(!$loop->last)
<svg class="flex-shrink-0 mx-3 overflow-visible h-2.5 w-2.5 text-slate-400 dark:text-slate-600" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 1L10.6869 7.16086C10.8637 7.35239 10.8637 7.64761 10.6869 7.83914L5 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
@endif
</li>
@endforeach
</ol>
</nav>

View File

@@ -127,9 +127,12 @@
</button>
<!-- Profile Dropdown -->
<div class="relative inline-flex" x-data="{ open: false }">
<div class="relative inline-flex" x-data="{
open: false,
avatarUrl: '{{ Auth::user()->avatar_url }}'
}" @avatar-updated.window="avatarUrl = $event.detail.url">
<button type="button" @click="open = !open" @click.away="open = false" class="inline-flex flex-shrink-0 justify-center items-center gap-2 h-[2.375rem] w-[2.375rem] rounded-full font-medium bg-white text-gray-700 align-middle hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-white transition-all text-xs dark:bg-gray-800 dark:hover:bg-slate-800 dark:text-gray-400 dark:hover:text-white dark:focus:ring-gray-700 dark:focus:ring-offset-gray-800">
<img class="inline-block h-[2.375rem] w-[2.375rem] rounded-full ring-2 ring-white dark:ring-gray-800" src="https://ui-avatars.com/api/?name={{ Auth::user()->name }}&background=0D8ABC&color=fff" alt="Image Description">
<img class="inline-block h-[2.375rem] w-[2.375rem] rounded-full ring-2 ring-white dark:ring-gray-800 object-cover" :src="avatarUrl" alt="{{ Auth::user()->name }}">
</button>
<div x-show="open"
@@ -142,8 +145,9 @@
class="absolute right-0 top-full mt-2 min-w-[15rem] bg-white shadow-xl rounded-2xl p-2 dark:bg-gray-800 dark:border dark:border-gray-700 z-50 border border-slate-100"
x-cloak>
<div class="py-3 px-5 -m-2 bg-slate-50 rounded-t-2xl dark:bg-slate-900/50 border-b border-slate-100 dark:border-slate-700">
<p class="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">Signed in as</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-200 truncate">{{ Auth::user()->email }}</p>
<p class="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">{{ __('Signed in as') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-200 truncate">{{ Auth::user()->name }}</p>
<p class="text-[10px] font-medium text-slate-400 truncate">{{ Auth::user()->email }}</p>
</div>
<div class="mt-2 py-2">
<a class="flex items-center gap-x-3.5 py-2.5 px-3 rounded-xl text-sm font-bold text-slate-700 hover:bg-gray-100 focus:ring-2 focus:ring-blue-500 dark:text-slate-300 dark:hover:bg-gray-700 dark:hover:text-white transition-colors" href="{{ route('profile.edit') }}">
@@ -181,17 +185,7 @@
<!-- End Navigation Toggle -->
<!-- Breadcrumb -->
<ol class="ms-3 flex items-center whitespace-nowrap" aria-label="Breadcrumb">
<li class="flex items-center text-sm text-gray-800 dark:text-gray-400">
Star Cloud
<svg class="flex-shrink-0 mx-3 overflow-visible h-2.5 w-2.5 text-gray-400 dark:text-gray-600" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 1L10.6869 7.16086C10.8637 7.35239 10.8637 7.64761 10.6869 7.83914L5 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</li>
<li class="text-sm font-semibold text-gray-800 truncate dark:text-gray-200" aria-current="page">
儀表板
</li>
</ol>
<x-breadcrumbs class="ms-3" />
<!-- End Breadcrumb -->
</div>
</div>
@@ -227,7 +221,8 @@
<!-- End Sidebar -->
<!-- Content -->
<div class="w-full pt-10 px-4 sm:px-6 md:px-8 lg:pl-72">
<div class="w-full pt-6 lg:pt-10 px-4 sm:px-6 md:px-8 lg:pl-72">
<x-breadcrumbs class="mb-4 hidden lg:flex" />
<main class="animate-fade-up">
@yield('content')
</main>

View File

@@ -1,32 +1,163 @@
@extends('layouts.admin')
@section('content')
<div class="max-w-5xl mx-auto space-y-8 animate-luxury-in">
<!-- Header -->
<div class="mb-8">
<h2 class="text-3xl font-extrabold text-slate-800 dark:text-white font-display tracking-tight">{{ __('Account Settings') }}</h2>
<p class="text-sm font-bold text-slate-400 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Manage your profile information, security settings, and login history') }}</p>
<div x-data="{
photoPreview: null,
isUploading: false,
uploadAvatar(event) {
const file = event.target.files[0];
if (!file) return;
// Size Check (1MB = 1024 * 1024 bytes)
if (file.size > 1024 * 1024) {
alert('{{ __('The image is too large. Please upload an image smaller than 1MB.') }}');
return;
}
this.isUploading = true;
// Show local preview immediately
const reader = new FileReader();
reader.onload = (e) => {
this.photoPreview = e.target.result;
};
reader.readAsDataURL(file);
const formData = new FormData();
formData.append('avatar', file);
formData.append('_token', '{{ csrf_token() }}');
fetch('{{ route('profile.avatar') }}', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.photoPreview = data.avatar_url;
// Dispatch global event for other components (like header)
window.dispatchEvent(new CustomEvent('avatar-updated', {
detail: { url: data.avatar_url }
}));
} else {
alert(data.message || 'Upload failed');
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred during upload');
})
.finally(() => {
this.isUploading = false;
});
}
}"
class="space-y-8 animate-luxury-in text-slate-800 dark:text-white">
<!-- Luxury Profile Banner -->
<div class="luxury-card rounded-[2.5rem] p-1 border-luxury-accent/10 relative overflow-hidden group/banner">
<div class="relative p-8 md:p-12 flex flex-col lg:flex-row items-center justify-between gap-12 z-10">
<!-- Left: Profile Core Info -->
<div class="flex flex-col md:flex-row items-center gap-8 flex-1">
<!-- Profile Avatar Area -->
<div class="relative cursor-pointer group/avatar"
@click="document.querySelector('#profile-update-form input[name=avatar]').click()">
<div class="size-32 md:size-40 rounded-full overflow-hidden ring-4 ring-white dark:ring-slate-800 shadow-2xl transition-transform duration-500 group-hover/avatar:scale-105 relative">
<template x-if="!photoPreview">
<img src="{{ $user->avatar_url }}" class="size-full object-cover" alt="{{ $user->name }}">
</template>
<template x-if="photoPreview">
<img :src="photoPreview" class="size-full object-cover">
</template>
<!-- Loading Overlay -->
<div x-show="isUploading"
class="absolute inset-0 bg-black/60 flex items-center justify-center z-10"
x-transition:enter="transition opacity-0"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100">
<svg class="animate-spin size-8 text-cyan-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<div class="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover/avatar:opacity-100 transition-opacity">
<svg class="size-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<div class="absolute -bottom-2 -right-2 size-10 rounded-xl bg-cyan-500 flex items-center justify-center text-white shadow-lg shadow-cyan-500/40 border-2 border-white dark:border-slate-800">
<svg x-show="!isUploading" class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/></svg>
<svg x-show="isUploading" class="animate-spin size-5" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left Side: Basic Info & Security -->
<div class="lg:col-span-2 space-y-8">
<div class="luxury-card p-8">
<div class="text-center md:text-left">
<div class="flex flex-col md:flex-row md:items-center gap-x-4 mb-2">
<h2 class="text-4xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ $user->name }}</h2>
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border border-cyan-500/20 uppercase tracking-widest self-center md:self-auto mt-2 md:mt-0">
{{ __('Administrator') }}
</span>
</div>
<p class="text-slate-400 font-bold tracking-wide flex items-center justify-center md:justify-start gap-x-2">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2-2v10a2 2 0 002 2z"/></svg>
{{ $user->email }}
</p>
<div class="mt-6 flex flex-wrap justify-center md:justify-start gap-4">
<div class="px-5 py-2.5 rounded-2xl bg-white/50 dark:bg-slate-900/50 border border-white dark:border-slate-800 backdrop-blur-sm transition-all hover:bg-white dark:hover:bg-slate-900 text-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{ __('Joined') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $user->created_at->format('Y/m/d') }}</p>
</div>
<div class="px-5 py-2.5 rounded-2xl bg-white/50 dark:bg-slate-900/50 border border-white dark:border-slate-800 backdrop-blur-sm transition-all hover:bg-white dark:hover:bg-slate-900 text-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{ __('Recent Login') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $user->loginLogs->first() ? $user->loginLogs->first()->login_at->diffForHumans() : 'N/A' }}</p>
</div>
</div>
</div>
</div>
<!-- Right: Stats Summary -->
<div class="flex items-center gap-12 pr-4 lg:border-l lg:border-slate-200/50 dark:lg:border-slate-700/50 lg:pl-12">
<div class="text-center group/stat">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2 group-hover/stat:text-cyan-500 transition-colors">{{ __('Total Logins') }}</p>
<div class="relative inline-block">
<span class="text-4xl font-black text-slate-800 dark:text-white">{{ $user->loginLogs()->count() }}</span>
<div class="absolute -bottom-1 left-0 w-full h-1 bg-cyan-500/20 rounded-full scale-x-0 group-hover/stat:scale-x-100 transition-transform origin-left"></div>
</div>
</div>
<div class="h-12 w-px bg-slate-100 dark:bg-slate-800 hidden sm:block"></div>
<div class="text-center group/stat">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2 group-hover/stat:text-cyan-500 transition-colors">{{ __('Account Status') }}</p>
<div class="flex items-center gap-3">
<span class="relative flex size-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full size-3 bg-emerald-500"></span>
</span>
<span class="text-2xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{ __('Active') }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<!-- Left Side: Profile & Password -->
<div class="space-y-8">
<div class="luxury-card rounded-[2rem] p-8 shadow-xl shadow-slate-200/50 dark:shadow-none">
@include('profile.partials.update-profile-information-form')
</div>
<div class="luxury-card p-8">
<div class="luxury-card rounded-[2rem] p-8 shadow-xl shadow-slate-200/50 dark:shadow-none">
@include('profile.partials.update-password-form')
</div>
<div class="luxury-card p-8 border-rose-500/20 dark:border-rose-500/10">
@include('profile.partials.delete-user-form')
</div>
</div>
<!-- Right Side: Login History -->
<div class="lg:col-span-1">
<div class="luxury-card p-6 sticky top-24">
<!-- Right Side: Login History (Wider) -->
<div class="space-y-8">
<div class="luxury-card rounded-[2rem] p-8 min-h-[600px] shadow-xl shadow-slate-200/50 dark:shadow-none">
@include('profile.partials.login-history')
</div>
</div>

View File

@@ -1,58 +0,0 @@
<section class="space-y-6">
<header>
<h2 class="text-xl font-black text-rose-600 dark:text-rose-500 tracking-tight">
{{ __('Danger Zone: Delete Account') }}
</h2>
<p class="mt-1 text-sm font-bold text-slate-400 dark:text-slate-400 uppercase tracking-widest">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
</p>
</header>
<button
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
class="btn-luxury-rose px-8"
>
<span>{{ __('Delete Account') }}</span>
</button>
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
<form method="post" action="{{ route('profile.destroy') }}" class="p-8">
@csrf
@method('delete')
<h2 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-3 text-sm font-bold text-slate-500 dark:text-slate-400 leading-relaxed">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<div class="mt-8">
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
<input
id="password"
name="password"
type="password"
class="py-3 px-4 block w-full border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 rounded-2xl text-sm font-bold text-slate-700 dark:text-slate-200 focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500 transition-all outline-none"
placeholder="{{ __('Enter your password to confirm') }}"
/>
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
</div>
<div class="mt-8 flex justify-end gap-x-3">
<button type="button" x-on:click="$dispatch('close')" class="py-3 px-6 inline-flex items-center gap-x-2 text-sm font-black rounded-2xl border border-slate-200 bg-white text-slate-600 shadow-sm hover:bg-slate-50 focus:outline-none focus:bg-slate-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:focus:bg-slate-800 transition-all">
{{ __('Cancel') }}
</button>
<button type="submit" class="btn-luxury-rose px-8">
<span>{{ __('Permanently Delete Account') }}</span>
</button>
</div>
</form>
</x-modal>
</section>

View File

@@ -1,46 +1,62 @@
<div class="space-y-6">
<div class="flex items-center gap-x-3 mb-6">
<div class="size-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-600 dark:text-cyan-400">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-x-4">
<div class="size-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-600 dark:text-cyan-400 border border-cyan-500/20 shadow-lg shadow-cyan-500/5">
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Login History') }}</h3>
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Login History') }}</h3>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">{{ __('Your recent account activity') }}</p>
</div>
</div>
</div>
<div class="flow-root">
<ul role="list" class="-mb-8">
@forelse($user->loginLogs as $log)
<li>
<div class="relative pb-8">
@if(!$loop->last)
<span class="absolute left-4 top-4 -ml-px h-full w-0.5 bg-slate-100 dark:bg-slate-800" aria-hidden="true"></span>
@endif
<div class="relative flex space-x-3 mt-1">
<div>
<span class="h-8 w-8 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center ring-8 ring-white dark:ring-slate-900">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<div class="relative">
<!-- Vertical Line -->
<div class="absolute left-6 top-2 bottom-2 w-px bg-gradient-to-b from-slate-200 via-slate-200 to-transparent dark:from-slate-800 dark:via-slate-800 dark:to-transparent"></div>
<div class="space-y-6">
@forelse($user->loginLogs()->latest()->take(10)->get() as $log)
<div class="relative pl-14 group">
<!-- Dot -->
<div class="absolute left-[21px] top-3 size-2.5 rounded-full border-2 border-white dark:border-slate-900 bg-slate-300 dark:bg-slate-700 group-hover:bg-cyan-500 group-hover:scale-125 transition-all duration-300 z-10"></div>
<div class="luxury-card p-5 rounded-2xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 hover:bg-white dark:hover:bg-slate-800/80 transition-all duration-300">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-x-3 mb-2">
<span class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono tracking-tight">{{ $log->ip_address }}</span>
<span class="size-1 rounded-full bg-slate-300 dark:bg-slate-600"></span>
<span class="text-[10px] font-black text-cyan-500 dark:text-cyan-400 uppercase tracking-widest bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10">
{{ __('Success') }}
</span>
</div>
<div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>
<p class="text-[11px] font-black text-slate-700 dark:text-slate-300 uppercase tracking-widest">{{ $log->ip_address }}</p>
<p class="mt-1 text-xs text-slate-400 truncate max-w-[120px]" title="{{ $log->user_agent }}">
{{ Str::limit($log->user_agent, 20) }}
<p class="text-xs font-medium text-slate-400 dark:text-slate-500 break-all leading-relaxed" title="{{ $log->user_agent }}">
<span class="font-bold text-slate-500 dark:text-slate-400 mr-1 italic">{{ __('Device:') }}</span>
{{ $log->user_agent }}
</p>
</div>
<div class="whitespace-nowrap text-right text-[10px] font-bold text-slate-400">
<time datetime="{{ $log->login_at }}">{{ $log->login_at->diffForHumans() }}</time>
<div class="shrink-0 text-right">
<p class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-1">
{{ $log->login_at->format('Y/m/d') }}
</p>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500">
{{ $log->login_at->diffForHumans() }}
</p>
</div>
</div>
</div>
</div>
</li>
@empty
<li class="py-4 text-center text-xs text-slate-400">尚無登入紀錄</li>
<div class="luxury-card p-12 text-center rounded-[2rem] border-dashed border-2 border-slate-100 dark:border-slate-800">
<div class="size-16 rounded-full bg-slate-50 dark:bg-slate-900 flex items-center justify-center mx-auto mb-4">
<svg class="size-8 text-slate-300 dark:text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<p class="text-sm font-bold text-slate-400">{{ __('No login history yet') }}</p>
</div>
@endforelse
</ul>
</div>
</div>
</div>

View File

@@ -13,10 +13,14 @@
@csrf
</form>
<form method="post" action="{{ route('profile.update') }}" class="mt-8 space-y-6">
<form id="profile-update-form" method="post" action="{{ route('profile.update') }}" class="mt-8 space-y-6" enctype="multipart/form-data">
@csrf
@method('patch')
<!-- Hidden Avatar Input -->
<input type="file" name="avatar" class="hidden" accept="image/*"
@change="uploadAvatar($event)">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<x-input-label for="name" :value="__('Name')" class="text-xs font-black text-slate-500 uppercase tracking-widest mb-2 ml-1" />

View File

@@ -171,7 +171,7 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::post("/profile/avatar", [ProfileController::class, "updateAvatar"])->name("profile.avatar");
});
require __DIR__.'/auth.php';