diff --git a/.agents/rules/skill-trigger.md b/.agents/rules/skill-trigger.md index acc9130..ca0d8ea 100644 --- a/.agents/rules/skill-trigger.md +++ b/.agents/rules/skill-trigger.md @@ -15,7 +15,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 | 觸發詞 / 情境 | 對應 Skill | 路徑 | |---|---|---| | 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` | -| 介面, UI, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` | +| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` | | 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` | | RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` | @@ -41,4 +41,4 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。 ### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時 必須讀取: 1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範 -2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用 +2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用 \ No newline at end of file diff --git a/.agents/skills/ui-minimal-luxury/SKILL.md b/.agents/skills/ui-minimal-luxury/SKILL.md index 34dea32..b204887 100644 --- a/.agents/skills/ui-minimal-luxury/SKILL.md +++ b/.agents/skills/ui-minimal-luxury/SKILL.md @@ -178,6 +178,25 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範 + +### 搜尋式下拉選單 (Searchable Select) - 【進階推薦】 +- **組件**: `` +- **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:所屬單位、機台編號)。 +- **奢華特徵**: + - **動態旋轉箭頭**: 透過 `::after` 偽元素實作,選單展開時箭頭執行 `300ms` 的 180 度旋轉動畫。 + - **即時過濾**: 輸入關鍵字即時隱藏不匹配項。 + - **選取標示**: 選取的項目右側帶有青色 (`Cyan`) 的勾選小圖標。 + - **全部選項修復 (Space Fix)**: 若用於篩選(如公司篩選),組件內部已實作「空格佔位符」機制。若選單中的「全部」選項在選取後消失,請確保該選項的值為單個空格 (`value=" "`)。這能繞過 Preline 對空標記的隱藏邏輯,並同步觸發 Laravel 的 `blank()` 判定。 + +```html + +``` ``` ## 8. 編輯與詳情頁規範 (Detail & Edit Views) diff --git a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php index 7b73c4b..d3620ab 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php @@ -45,7 +45,7 @@ class MachineSettingController extends AdminController // 3. 基礎下拉資料 (用於新增/編輯機台的彈窗) $models = MachineModel::select('id', 'name')->get(); $paymentConfigs = PaymentConfig::select('id', 'name')->get(); - $companies = \App\Models\System\Company::select('id', 'name')->get(); + $companies = \App\Models\System\Company::select('id', 'name', 'code')->get(); return view('admin.basic-settings.machines.index', compact( 'machines', @@ -101,7 +101,7 @@ class MachineSettingController extends AdminController { $models = MachineModel::select('id', 'name')->get(); $paymentConfigs = PaymentConfig::select('id', 'name')->get(); - $companies = \App\Models\System\Company::select('id', 'name')->get(); + $companies = \App\Models\System\Company::select('id', 'name', 'code')->get(); return view('admin.basic-settings.machines.edit', compact('machine', 'models', 'paymentConfigs', 'companies')); } @@ -137,6 +137,13 @@ class MachineSettingController extends AdminController 'payment_config_id' => 'nullable|exists:payment_configs,id', 'location' => 'nullable|string|max:255', ]); + + // 僅限系統管理員可修改公司 + if (auth()->user()->isSystemAdmin()) { + $companyRule = ['company_id' => 'nullable|exists:companies,id']; + $companyData = $request->validate($companyRule); + $validated = array_merge($validated, $companyData); + } Log::info('Machine Update Validated Data', ['data' => $validated]); } catch (\Illuminate\Validation\ValidationException $e) { diff --git a/app/Http/Controllers/Admin/BasicSettings/PaymentConfigController.php b/app/Http/Controllers/Admin/BasicSettings/PaymentConfigController.php index a4d41de..726cfb3 100644 --- a/app/Http/Controllers/Admin/BasicSettings/PaymentConfigController.php +++ b/app/Http/Controllers/Admin/BasicSettings/PaymentConfigController.php @@ -38,7 +38,7 @@ class PaymentConfigController extends AdminController */ public function create(): View { - $companies = \App\Models\System\Company::select('id', 'name')->get(); + $companies = \App\Models\System\Company::select('id', 'name', 'code')->get(); return view('admin.basic-settings.payment-configs.create', compact('companies')); } diff --git a/app/Http/Controllers/Admin/MachineController.php b/app/Http/Controllers/Admin/MachineController.php index 6d4a8b5..a1e0e6a 100644 --- a/app/Http/Controllers/Admin/MachineController.php +++ b/app/Http/Controllers/Admin/MachineController.php @@ -67,12 +67,68 @@ class MachineController extends AdminController return view('admin.machines.logs', compact('logs', 'machines')); } + /** - * 機台權限設定 (開發中) + * AJAX: 取得特定帳號的機台分配狀態 */ - public function permissions(Request $request): View + public function getAccountMachines(\App\Models\System\User $user) { - return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder + $currentUser = auth()->user(); + + // 安全檢查:只能操作自己公司的帳號(除非是系統管理員) + if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + // 取得該公司所有機台 (限定 company_id 以實作資料隔離) + $machines = Machine::where('company_id', $user->company_id) + ->get(['id', 'name', 'serial_no']); + + $assignedIds = $user->machines()->pluck('machines.id')->toArray(); + + return response()->json([ + 'user' => $user, + 'machines' => $machines, + 'assigned_ids' => $assignedIds + ]); + } + + /** + * AJAX: 儲存特定帳號的機台分配 + */ + public function syncAccountMachines(Request $request, \App\Models\System\User $user) + { + $currentUser = auth()->user(); + + // 安全檢查 + if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $request->validate([ + 'machine_ids' => 'nullable|array', + 'machine_ids.*' => 'exists:machines,id' + ]); + + // 加固驗證:確保所有機台 ID 都屬於該使用者的公司 + if ($request->has('machine_ids')) { + $machineIds = array_unique($request->machine_ids); + $validCount = Machine::where('company_id', $user->company_id) + ->whereIn('id', $machineIds) + ->count(); + + if ($validCount !== count($machineIds)) { + return response()->json(['error' => 'Invalid machine IDs provided.'], 422); + } + } + + $user->machines()->sync($request->machine_ids ?? []); + + return response()->json([ + 'success' => true, + 'message' => __('Permissions updated successfully.'), + 'assigned_machines' => $user->machines()->select('machines.id', 'machines.name', 'machines.serial_no')->get() + ]); } /** diff --git a/app/Http/Controllers/Admin/PermissionController.php b/app/Http/Controllers/Admin/PermissionController.php index 383846a..e99fab8 100644 --- a/app/Http/Controllers/Admin/PermissionController.php +++ b/app/Http/Controllers/Admin/PermissionController.php @@ -248,7 +248,7 @@ class PermissionController extends Controller // 帳號管理 public function accounts(Request $request) { - $query = \App\Models\System\User::query()->with(['company', 'roles']); + $query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']); // 租戶隔離:如果不是系統管理員,則只看自己公司的成員 if (!auth()->user()->isSystemAdmin()) { diff --git a/app/Models/Machine/Machine.php b/app/Models/Machine/Machine.php index c432086..d121f69 100644 --- a/app/Models/Machine/Machine.php +++ b/app/Models/Machine/Machine.php @@ -12,6 +12,26 @@ class Machine extends Model use HasFactory, TenantScoped; use \Illuminate\Database\Eloquent\SoftDeletes; + protected static function booted() + { + // 權限隔離:一般帳號登入時只能看到自己被分配的機台 + static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) { + $user = auth()->user(); + // 如果是在 Console、或是系統管理員、或是租戶的「管理員」角色,則不限制 (可看該公司所有機台) + if (app()->runningInConsole() || !$user || $user->isSystemAdmin() || $user->hasRole('管理員') || $user->hasRole('super-admin')) { + return; + } + + // 一般租戶帳號:限制只能看自己擁有的機台 + $builder->whereExists(function ($query) use ($user) { + $query->select(\Illuminate\Support\Facades\DB::raw(1)) + ->from('machine_user') + ->whereColumn('machine_user.machine_id', 'machines.id') + ->where('machine_user.user_id', $user->id); + }); + }); + } + protected $fillable = [ 'company_id', 'name', @@ -101,4 +121,8 @@ class Machine extends Model return $this->belongsTo(\App\Models\System\User::class, 'updater_id'); } + public function users() + { + return $this->belongsToMany(\App\Models\System\User::class); + } } diff --git a/app/Models/System/User.php b/app/Models/System/User.php index 2d2f571..26ac998 100644 --- a/app/Models/System/User.php +++ b/app/Models/System/User.php @@ -69,6 +69,14 @@ class User extends Authenticatable return $this->belongsTo(Company::class); } + /** + * Get the machines assigned to the user. + */ + public function machines() + { + return $this->belongsToMany(\App\Models\Machine\Machine::class); + } + /** * Check if the user is a system administrator. */ diff --git a/database/factories/Machine/MachineFactory.php b/database/factories/Machine/MachineFactory.php index 3116e9c..cb123a6 100644 --- a/database/factories/Machine/MachineFactory.php +++ b/database/factories/Machine/MachineFactory.php @@ -17,6 +17,7 @@ class MachineFactory extends Factory 'status' => fake()->randomElement(['online', 'offline', 'error']), 'temperature' => fake()->randomFloat(2, 2, 10), 'firmware_version' => 'v' . fake()->randomElement(['1.0.0', '1.1.2', '2.0.1']), + 'serial_no' => 'SN-' . strtoupper(fake()->unique()->bothify('??###?')), 'last_heartbeat_at' => fake()->dateTimeBetween('-1 day', 'now'), ]; } diff --git a/database/migrations/2026_03_23_093421_create_machine_user_table.php b/database/migrations/2026_03_23_093421_create_machine_user_table.php new file mode 100644 index 0000000..f4aba3a --- /dev/null +++ b/database/migrations/2026_03_23_093421_create_machine_user_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('machine_id')->constrained()->onDelete('cascade'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['machine_id', 'user_id']); // Ensure uniqueness + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('machine_user'); + } +}; diff --git a/lang/en.json b/lang/en.json index 61f0164..05e5c2a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -505,5 +505,15 @@ "Role Identification": "Role Identification", "LEVEL TYPE": "LEVEL TYPE", "Affiliated Unit": "Affiliated Unit", - "System Official": "System Official" + "System Official": "System Official", + "Full Access": "Full Access", + "System Default": "System Default", + "Authorized Machines": "Authorized Machines", + "Assign": "Assign", + "No machines available": "No machines available", + "Selected": "Selected", + "Failed to fetch machine data.": "Failed to fetch machine data.", + "Failed to save permissions.": "Failed to save permissions.", + "An error occurred while saving.": "An error occurred while saving.", + "Loading machines...": "Loading machines..." } \ No newline at end of file diff --git a/lang/zh_TW.json b/lang/zh_TW.json index cada3a9..e03c1c1 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -20,6 +20,17 @@ "admin": "管理員", "Admin display name": "管理員顯示名稱", "Admin Name": "管理員姓名", + "Account Name": "帳號姓名", + "Account": "帳號", + "Account:": "帳號:", + "Manage Account Access": "管理帳號存取", + "Assigned Machines": "授權機台", + "No machines assigned": "未分配機台", + "Available Machines": "可供分配的機台", + "Loading machines...": "正在載入機台...", + "No machines available in this company.": "此客戶目前沒有可供分配的機台。", + "Saving...": "儲存中...", + "Save Permissions": "儲存權限", "Admin Sellable Products": "管理者可賣", "Administrator": "管理員", "Advertisement Management": "廣告管理", @@ -230,7 +241,7 @@ "Machine Model Settings": "機台型號設定", "Machine model updated successfully.": "機台型號已成功更新。", "Machine Name": "機台名稱", - "Machine Permissions": "機台權限", + "Machine Permissions": "授權機台", "Machine Reports": "機台報表", "Machine Restart": "機台重啟", "Machine Settings": "機台設定", @@ -524,5 +535,26 @@ "Role Identification": "角色識別資訊", "LEVEL TYPE": "層級類型", "Affiliated Unit": "所屬單位", - "System Official": "系統層" + "System Official": "系統層", + "Other Permissions": "其他權限", + "General permissions not linked to a specific menu.": "未連結到特定選單的一般權限。", + "Assign Machines": "分配機台", + "data-config.sub-accounts": "子帳號管理", + "data-config.sub-account-roles": "子帳號角色", + "basic.machines": "機台設定", + "basic.payment-configs": "客戶金流設定", + "permissions.companies": "客戶管理", + "permissions.accounts": "帳號管理", + "permissions.roles": "角色權限管理", + "Edit Sub Account Role": "編輯子帳號角色", + "New Sub Account Role": "新增子帳號角色", + "Full Access": "全機台授權", + "System Default": "系統預設", + "Authorized Machines": "授權機台", + "Assign": "分配所屬機台", + "No machines available": "目前沒有可供分配的機台", + "Selected": "已選擇", + "Failed to fetch machine data.": "無法取得機台資料。", + "Failed to save permissions.": "無法儲存權限設定。", + "An error occurred while saving.": "儲存時發生錯誤。" } \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 3023d77..cbbee40 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -265,4 +265,48 @@ background-repeat: no-repeat; background-size: 1.25em 1.25em; } + + /* Preline Searchable Select Customizations */ + .hs-select-toggle.luxury-select-toggle { + @apply luxury-input pr-10 cursor-pointer text-start flex items-center justify-between relative; + } + + .hs-select-toggle.luxury-select-toggle::after { + content: ""; + @apply absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 transition-transform duration-300 pointer-events-none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m6 9 6 6 6-6'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-size: contain; + } + + /* 當選單開啟時旋轉箭頭 */ + .hs-select.active .hs-select-toggle::after, + .hs-select-toggle.active::after, + .hs-select-toggle[aria-expanded="true"]::after { + @apply rotate-180; + } + + .hs-select-menu { + @apply mt-2 max-h-72 p-2 space-y-0.5 z-50 bg-white border border-slate-200 rounded-2xl shadow-2xl overflow-y-auto; + @apply dark:bg-slate-900 dark:border-slate-800; + backdrop-filter: blur(12px); + } + + .hs-select-option { + @apply py-2.5 px-4 w-full text-sm font-bold text-slate-700 cursor-pointer hover:bg-slate-50 rounded-xl transition-colors; + @apply dark:text-slate-200 dark:hover:bg-slate-800; + } + + .hs-selected.hs-select-option { + @apply bg-cyan-50 text-cyan-600; + @apply dark:bg-cyan-500/10 dark:text-cyan-400; + } + + .hs-select-search-wrapper { + @apply p-2 sticky top-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md z-10 mb-1 border-b border-slate-100 dark:border-slate-800; + } + + .hs-select-search-input { + @apply luxury-input py-2 px-3 text-xs border-slate-100 dark:border-slate-800; + } } \ No newline at end of file diff --git a/resources/views/admin/basic-settings/machines/edit.blade.php b/resources/views/admin/basic-settings/machines/edit.blade.php index 02e7b10..341d10a 100644 --- a/resources/views/admin/basic-settings/machines/edit.blade.php +++ b/resources/views/admin/basic-settings/machines/edit.blade.php @@ -62,7 +62,7 @@
-
+
@@ -87,17 +87,39 @@
- +
+ @if(auth()->user()->isSystemAdmin()) +
+ + + @foreach($companies as $company) + + @endforeach + + @error('company_id')

{{ $message }}

@enderror +
+ @endif
-
+
@@ -176,7 +198,7 @@
-
+
@@ -189,26 +211,34 @@
- +
- +
-
+
diff --git a/resources/views/admin/basic-settings/machines/index.blade.php b/resources/views/admin/basic-settings/machines/index.blade.php index f9c7494..ab11c30 100644 --- a/resources/views/admin/basic-settings/machines/index.blade.php +++ b/resources/views/admin/basic-settings/machines/index.blade.php @@ -391,16 +391,17 @@
-
+
- +
-
+
- +