From ea438d55d1126bb48258e2cf01e291b930fd71a6 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Sun, 17 May 2026 11:07:41 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E5=AF=A6=E4=BD=9C=E7=B3=BB=E7=B5=B1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=93=A1=E5=88=87=E6=8F=9B=E5=B8=B3=E8=99=9F?= =?UTF-8?q?=20(Impersonate)=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 `ImpersonateController` 處理帳號切換與退出邏輯,並提供公司與使用者的動態下拉選單 API (包含 username 支援)。 2. 於 `admin.blade.php` 的 Header 個人選單中加入「切換帳號」入口,並支援退出切換狀態的顯示。 3. 實作 `modal.blade.php` Alpine.js 彈窗,使用專案規範的 `x-searchable-select` 動態重建模式 (Dynamic Rebuild Pattern) 與 `focus-within:z-[60]` 防護。 4. 註冊相關 Web Routes。 5. 更新 `ui-minimal-luxury/SKILL.md`,新增 Alpine.js Modal 實作規範,並禁止使用 `hs-overlay` 避免層級堆疊問題。 --- .agents/skills/ui-minimal-luxury/SKILL.md | 77 ++++- .../Admin/ImpersonateController.php | 98 +++++++ .../views/admin/impersonate/modal.blade.php | 273 ++++++++++++++++++ resources/views/layouts/admin.blade.php | 33 +++ routes/web.php | 6 + 5 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/ImpersonateController.php create mode 100644 resources/views/admin/impersonate/modal.blade.php diff --git a/.agents/skills/ui-minimal-luxury/SKILL.md b/.agents/skills/ui-minimal-luxury/SKILL.md index cab0720..884425e 100644 --- a/.agents/skills/ui-minimal-luxury/SKILL.md +++ b/.agents/skills/ui-minimal-luxury/SKILL.md @@ -404,7 +404,82 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範 | 刪除確認 Modal | `` | | 頁面內警告(琥珀色)| `
` | ---- +### 7.1 Modal 實作規範(強制) + +> [!CAUTION] +> **嚴禁使用 Preline 的 `hs-overlay` 系統**來實作 Modal。 +> 本專案全部 Modal 統一使用 **Alpine.js** (`x-show` + `x-transition`) 實作。 +> 使用 `hs-overlay` class 會導致 Modal 出現但完全透明(`opacity: 0`),因為 Preline overlay 的動畫 class 在此專案中未被正確初始化。 + +**正確:Alpine.js Modal 標準結構(參考 `modal-replenishment-create.blade.php`)** + +```html +{{-- 1. 觸發按鈕:用 $dispatch 發送 window 事件 --}} + + +{{-- 2. Modal 主體:放在 layout 底部,x-data 宣告 Alpine state --}} +
+ +
+``` + +**跨元件開啟 Modal(dropdown 內的按鈕):** +- 按鈕在 Alpine.js dropdown 內時,需先關閉 dropdown 再發送事件: + ```html + + ``` +- Modal 透過 `@open-xxx-modal.window` 接收(`window` 層級才能跨 Alpine scope 傳遞) + + ## 8. AJAX fetchPage 標準實作 diff --git a/app/Http/Controllers/Admin/ImpersonateController.php b/app/Http/Controllers/Admin/ImpersonateController.php new file mode 100644 index 0000000..52dcd42 --- /dev/null +++ b/app/Http/Controllers/Admin/ImpersonateController.php @@ -0,0 +1,98 @@ +validate([ + 'user_id' => 'required|exists:users,id', + ]); + + $currentUser = $request->user(); + + // 1. Only system admins can impersonate + if (!$currentUser->isSystemAdmin()) { + abort(403, '只有系統管理員可以使用此功能。'); + } + + // 2. Prevent nested impersonation (cannot impersonate someone while already impersonating) + if (session()->has('impersonated_by')) { + return back()->with('error', '您目前已經處於切換狀態,無法再次切換。請先退出目前的切換狀態。'); + } + + $targetUserId = $request->input('user_id'); + + // 3. Cannot impersonate oneself + if ($currentUser->id == $targetUserId) { + return back()->with('error', '無法切換至自己的帳號。'); + } + + $targetUser = User::findOrFail($targetUserId); + + // 4. Set session and login + session()->put('impersonated_by', $currentUser->id); + Auth::loginUsingId($targetUser->id); + + return redirect()->route('admin.dashboard')->with('success', "已成功切換至 {$targetUser->name} 的帳號。"); + } + + /** + * Stop impersonating and return to the original user. + */ + public function leave(Request $request) + { + if (!session()->has('impersonated_by')) { + return back()->with('error', '您目前並未處於切換狀態。'); + } + + $originalUserId = session()->pull('impersonated_by'); + Auth::loginUsingId($originalUserId); + + return redirect()->route('admin.dashboard')->with('success', '已退出切換狀態,返回原帳號。'); + } + + /** + * Fetch companies for the impersonate modal. + */ + public function companies(Request $request) + { + if (!$request->user()->isSystemAdmin()) abort(403); + + $companies = \App\Models\System\Company::select('id', 'name')->orderBy('name')->get(); + return response()->json($companies); + } + + /** + * Fetch users for the impersonate modal, optionally filtered by company. + */ + public function users(Request $request) + { + if (!$request->user()->isSystemAdmin()) abort(403); + + $query = User::select('id', 'name', 'username', 'email', 'company_id'); + + if ($request->filled('company_id')) { + $query->where('company_id', $request->company_id); + } else { + // Include system accounts (company_id is null) when no company is selected? + // Actually let's just return all users if no company selected, or just system accounts. + // But if company_id is explicitly sent as 'system', we filter for null. + if ($request->company_id === 'system') { + $query->whereNull('company_id'); + } + } + + $users = $query->orderBy('name')->get(); + return response()->json($users); + } +} diff --git a/resources/views/admin/impersonate/modal.blade.php b/resources/views/admin/impersonate/modal.blade.php new file mode 100644 index 0000000..9b2328a --- /dev/null +++ b/resources/views/admin/impersonate/modal.blade.php @@ -0,0 +1,273 @@ +{{-- 切換帳號 Modal --}} +
+ +
+ + diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 8fe7c73..2585f25 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -306,6 +306,35 @@ {{ __('Account Settings') }} + + @if(session()->has('impersonated_by')) +
+
+ @csrf + +
+ @elseif(auth()->user()->isSystemAdmin()) +
+ + @endif +
@csrf @@ -420,6 +449,10 @@
+ @if(auth()->user() && auth()->user()->isSystemAdmin()) + @include('admin.impersonate.modal') + @endif + @yield('scripts') diff --git a/routes/web.php b/routes/web.php index 4015f4a..3a2d420 100644 --- a/routes/web.php +++ b/routes/web.php @@ -283,6 +283,12 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name( // 主題設定 Route::post('/theme', [App\Http\Controllers\Admin\ThemeController::class, 'update'])->name('theme.update'); + + // 16. 切換帳號 (Impersonation) + Route::post('/impersonate', [App\Http\Controllers\Admin\ImpersonateController::class, 'store'])->name('impersonate.store'); + Route::post('/impersonate/leave', [App\Http\Controllers\Admin\ImpersonateController::class, 'leave'])->name('impersonate.leave'); + Route::get('/impersonate/companies', [App\Http\Controllers\Admin\ImpersonateController::class, 'companies'])->name('impersonate.companies'); + Route::get('/impersonate/users', [App\Http\Controllers\Admin\ImpersonateController::class, 'users'])->name('impersonate.users'); }); Route::middleware('auth')->group(function () {