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 @@
-
+
-
+
+ @if(auth()->user()->isSystemAdmin())
+
+
+
+ @foreach($companies as $company)
+
+ @endforeach
+
+ @error('company_id')
{{ $message }}
@enderror
+
+ @endif
-
+