diff --git a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php index 5e2caab..1c68900 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php @@ -28,7 +28,12 @@ class MachineSettingController extends AdminController $tab = $request->input('tab', 'machines'); $per_page = $request->input('per_page', 10); $search = $request->input('search'); - $isAjax = $request->boolean('_ajax'); + // 只有「真正的 XHR 請求」才回無 layout 的裸片段。 + // 否則使用者若直接導航到帶 _ajax 的分頁連結(AJAX 重繪後 withQueryString 會把 _ajax 烙進 href), + // 會拿到沒套 CSS 的裸片段 → SVG 圖示放大/變形。_ajax 只由前端 searchInTab 產生且必帶 XHR 標頭。 + $isAjax = $request->ajax() && $request->boolean('_ajax'); + // 移除 _ajax,避免它經 withQueryString() 洩漏進分頁連結網址 + $request->query->remove('_ajax'); $currentUser = auth()->user(); $companyId = trim((string) $request->input('company_id', '')); diff --git a/app/Http/Controllers/Admin/PermissionController.php b/app/Http/Controllers/Admin/PermissionController.php index 1fe5d3a..68a1291 100644 --- a/app/Http/Controllers/Admin/PermissionController.php +++ b/app/Http/Controllers/Admin/PermissionController.php @@ -670,6 +670,7 @@ class PermissionController extends Controller $activeModules = [ 'menu.machines', + 'menu.app', 'menu.warehouses', 'menu.sales', 'menu.analysis', diff --git a/app/Http/Controllers/Admin/UiElementController.php b/app/Http/Controllers/Admin/UiElementController.php new file mode 100644 index 0000000..49f87d4 --- /dev/null +++ b/app/Http/Controllers/Admin/UiElementController.php @@ -0,0 +1,280 @@ + $p['active'] ?? false); + } + + // ================================================================== + // 頁籤一:UI 元素列表(素材庫) + // ================================================================== + + public function uiElements(Request $request) + { + $perPage = (int) $request->input('per_page', 10); + $perPage = in_array($perPage, [10, 25, 50, 100], true) ? $perPage : 10; + + $search = trim((string) $request->input('search', '')); + + $query = UiElement::orderByDesc('id'); + if ($search !== '') { + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('part_key', 'like', "%{$search}%"); + }); + } + + $elements = $query->paginate($perPage)->withQueryString(); + + return view('admin.app.ui-elements.index', [ + 'elements' => $elements, + 'parts' => $this->activeParts(), + 'perPage' => $perPage, + 'search' => $search, + ]); + } + + public function storeElement(Request $request) + { + $activeKeys = array_keys($this->activeParts()); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'part_key' => 'required|string|in:' . implode(',', $activeKeys), + 'file' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:10240', + ]); + + $part = config("ui_parts.parts.{$validated['part_key']}"); + + // crop=true(預設,滿版類)→ 依部位尺寸置中裁切;crop=false(按鈕/icon 類)→ 保留原比例不裁切 + $crop = $part['crop'] ?? true; + $targetW = $crop ? ($part['width'] ?? null) : null; + $targetH = $crop ? ($part['height'] ?? null) : null; + + $path = $this->storeAsWebp($request->file('file'), 'ui_elements', 80, $targetW, $targetH); + + UiElement::create([ + 'part_key' => $validated['part_key'], + 'name' => $validated['name'], + 'type' => 'image', + 'image_url' => Storage::disk('public')->url($path), + 'is_active' => true, + ]); + + return redirect()->route('admin.app.ui-elements')->with('success', __('UI element created')); + } + + public function destroyElement(UiElement $uiElement) + { + // 刪除實體檔(以 URL 反推相對路徑) + if ($uiElement->image_url) { + $relative = str_replace(Storage::disk('public')->url(''), '', $uiElement->image_url); + Storage::disk('public')->delete($relative); + } + + $uiElement->delete(); + + return redirect()->route('admin.app.ui-elements')->with('success', __('UI element deleted')); + } + + // ================================================================== + // 頁籤二:UI 組合編輯 + // ================================================================== + + public function bundles(Request $request) + { + $perPage = (int) $request->input('per_page', 10); + $perPage = in_array($perPage, [10, 25, 50, 100], true) ? $perPage : 10; + $search = trim((string) $request->input('search', '')); + + $query = UiBundle::orderBy('id'); + if ($search !== '') { + $query->where('name', 'like', "%{$search}%"); + } + $bundles = $query->paginate($perPage)->withQueryString(); + + return view('admin.app.ui-bundles.index', compact('bundles', 'perPage', 'search')); + } + + public function storeBundle(Request $request) + { + $validated = $request->validate(['name' => 'required|string|max:255']); + UiBundle::create(['name' => $validated['name']]); + + return redirect()->route('admin.app.ui-bundles')->with('success', __('UI bundle created')); + } + + public function updateBundle(Request $request, UiBundle $uiBundle) + { + $validated = $request->validate(['name' => 'required|string|max:255']); + $uiBundle->update(['name' => $validated['name']]); + + return redirect()->route('admin.app.ui-bundles')->with('success', __('Saved')); + } + + public function destroyBundle(UiBundle $uiBundle) + { + $uiBundle->delete(); + + return redirect()->route('admin.app.ui-bundles')->with('success', __('UI bundle deleted')); + } + + /** + * 組合內容設定頁:每個部位一個下拉,選對應類別的 UI 元素。 + */ + public function editBundleContent(UiBundle $uiBundle) + { + $parts = $this->activeParts(); + + // 每個部位可選的元素(依 part_key 過濾) + $elementsByPart = []; + foreach (array_keys($parts) as $key) { + $elementsByPart[$key] = UiElement::active()->where('part_key', $key)->orderBy('name')->get(); + } + + return view('admin.app.ui-bundles.content', [ + 'bundle' => $uiBundle, + 'parts' => $parts, + 'elementsByPart' => $elementsByPart, + 'itemsByPart' => $uiBundle->itemsByPart(), + ]); + } + + public function updateBundleContent(Request $request, UiBundle $uiBundle) + { + $parts = $this->activeParts(); + $input = $request->input('parts', []); // 圖片型:part_key => ui_element_id + $colorInput = $request->input('parts_color', []); // 色彩型:part_key => #RRGGBB + $colorEnabled = $request->input('parts_enabled', []); // 色彩型:part_key => "1"(有勾才套用) + + foreach ($parts as $key => $part) { + $type = $part['type'] ?? 'image'; + + // 色彩型(C 底色 / T 文字色) + if ($type === 'color') { + $isOn = !empty($colorEnabled[$key]); + $color = $colorInput[$key] ?? null; + $valid = is_string($color) && preg_match('/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color); + + if (!$isOn || !$valid) { + $uiBundle->items()->where('part_key', $key)->delete(); + continue; + } + + UiBundleItem::updateOrCreate( + ['ui_bundle_id' => $uiBundle->id, 'part_key' => $key], + ['ui_element_id' => null, 'color_value' => strtoupper($color)] + ); + continue; + } + + // 圖片型 + $elementId = $input[$key] ?? null; + $elementId = ($elementId === null || $elementId === '' || trim((string) $elementId) === '') ? null : (int) $elementId; + + if ($elementId === null) { + $uiBundle->items()->where('part_key', $key)->delete(); + continue; + } + + // 只接受屬於該部位的元素 + if (!UiElement::where('id', $elementId)->where('part_key', $key)->exists()) { + continue; + } + + UiBundleItem::updateOrCreate( + ['ui_bundle_id' => $uiBundle->id, 'part_key' => $key], + ['ui_element_id' => $elementId, 'color_value' => null] + ); + } + + return redirect()->route('admin.app.ui-bundles.content', $uiBundle)->with('success', __('Saved')); + } + + // ================================================================== + // 頁籤三:機台設定(綁定 UI 組合) + // ================================================================== + + public function machines(Request $request) + { + $perPage = (int) $request->input('per_page', 25); + $perPage = in_array($perPage, [10, 25, 50, 100], true) ? $perPage : 25; + $search = trim((string) $request->input('search', '')); + + $query = Machine::orderBy('name'); + if ($search !== '') { + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('serial_no', 'like', "%{$search}%"); + }); + } + $machines = $query->paginate($perPage)->withQueryString(); + + $bundles = UiBundle::orderBy('name')->get(); + $bundleNames = $bundles->pluck('name', 'id'); + + return view('admin.app.ui-machines.index', [ + 'machines' => $machines, + 'bundles' => $bundles, + 'bundleNames' => $bundleNames, + 'perPage' => $perPage, + 'search' => $search, + ]); + } + + public function updateMachineBundle(Request $request, Machine $machine, \App\Services\Machine\MqttService $mqttService) + { + $validated = $request->validate([ + 'ui_bundle_id' => 'nullable|integer|exists:ui_bundles,id', + ]); + + $settings = $machine->settings ?? []; + if (empty($validated['ui_bundle_id'])) { + unset($settings['ui_bundle_id']); + } else { + $settings['ui_bundle_id'] = (int) $validated['ui_bundle_id']; + } + $machine->settings = $settings; + $machine->save(); + + // 儲存後主動推送,讓機台立即重拉設定並套用 UI 換膚。 + // 沿用 app 端既有的 update_settings 處理(MqttService → downloadSetting → 解析 UISet),APK 不需改動。 + $command = \App\Models\Machine\RemoteCommand::create([ + 'machine_id' => $machine->id, + 'user_id' => auth()->id(), + 'command_type' => 'update_settings', + 'payload' => [], + 'status' => 'pending', + 'remark' => __('Sync UI bundle'), + ]); + + $pushed = $mqttService->pushCommand($machine->serial_no, 'update_settings', [], (string) $command->id); + if (!$pushed) { + $command->update(['status' => 'failed', 'note' => 'MQTT push failed']); + } + + $message = $pushed + ? __('Saved and update pushed to machine.') + : __('Saved. Machine will apply on next sync/reboot.'); + + return redirect()->route('admin.app.ui-machines')->with('success', $message); + } +} diff --git a/app/Http/Controllers/Api/V1/App/MachineController.php b/app/Http/Controllers/Api/V1/App/MachineController.php index 731e68e..073c3a4 100644 --- a/app/Http/Controllers/Api/V1/App/MachineController.php +++ b/app/Http/Controllers/Api/V1/App/MachineController.php @@ -546,6 +546,10 @@ class MachineController extends Controller 'SpringSlot51_60' => (bool) $machine->is_spring_slot_51_60, ]; + // 5-7 UISet:後台「APP UI 元素設定」下發(部位 part_key → 圖片 URL / 色值)。 + // 機台端依 part_key 對照本地 view 套用圖片;未綁定或未設定的部位不出現於此。 + $data['UISet'] = $this->buildUiSet($s); + return response()->json([ 'success' => true, 'code' => 200, @@ -553,6 +557,53 @@ class MachineController extends Controller ]); } + /** + * 組出機台綁定的 UI 組合下發內容(part_key => 圖片絕對 URL 或色值)。 + * 僅輸出 config/ui_parts.php 中 active=true 的部位。 + */ + private function buildUiSet(array $settings): object + { + $bundleId = $settings['ui_bundle_id'] ?? null; + if (!$bundleId) { + return (object) []; + } + + $bundle = \App\Models\System\UiBundle::withoutGlobalScopes() + ->with(['items.element']) + ->find($bundleId); + + if (!$bundle) { + return (object) []; + } + + $activeParts = array_filter(config('ui_parts.parts', []), fn($p) => $p['active'] ?? false); + $itemsByPart = $bundle->itemsByPart(); + + $map = []; + foreach ($activeParts as $key => $part) { + $item = $itemsByPart->get($key); + if (!$item) { + continue; + } + + $type = $part['type'] ?? 'image'; + if ($type === 'color') { + if ($item->color_value) { + $map[$key] = $item->color_value; + } + continue; + } + + // image 型:輸出絕對 URL(比照 B005 廣告 t070v04 補完) + $url = optional($item->element)->image_url; + if ($url) { + $map[$key] = str_starts_with($url, 'http') ? $url : asset($url); + } + } + + return (object) $map; + } + /** * B016: Update Machine System Settings (Write-back from machine console) * 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。 diff --git a/app/Models/System/UiBundle.php b/app/Models/System/UiBundle.php new file mode 100644 index 0000000..71f011f --- /dev/null +++ b/app/Models/System/UiBundle.php @@ -0,0 +1,33 @@ +hasMany(UiBundleItem::class); + } + + /** + * 以 part_key 為鍵的內容對照(方便下發與表單回填)。 + */ + public function itemsByPart() + { + return $this->items->keyBy('part_key'); + } +} diff --git a/app/Models/System/UiBundleItem.php b/app/Models/System/UiBundleItem.php new file mode 100644 index 0000000..091ea33 --- /dev/null +++ b/app/Models/System/UiBundleItem.php @@ -0,0 +1,28 @@ +belongsTo(UiBundle::class, 'ui_bundle_id'); + } + + public function element() + { + return $this->belongsTo(UiElement::class, 'ui_element_id'); + } +} diff --git a/app/Models/System/UiElement.php b/app/Models/System/UiElement.php new file mode 100644 index 0000000..3cd04a1 --- /dev/null +++ b/app/Models/System/UiElement.php @@ -0,0 +1,46 @@ + 'boolean', + ]; + + /** + * 部位定義(來自 config/ui_parts.php)。 + */ + public function getPartAttribute(): ?array + { + return config("ui_parts.parts.{$this->part_key}"); + } + + /** + * 部位顯示名稱。 + */ + public function getPartLabelAttribute(): string + { + return config("ui_parts.parts.{$this->part_key}.label", $this->part_key); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/config/ui_parts.php b/config/ui_parts.php new file mode 100644 index 0000000..d0f78e2 --- /dev/null +++ b/config/ui_parts.php @@ -0,0 +1,85 @@ + [ + 'fullscreen' => '滿版畫面(A 系列)', + 'transaction' => '交易結果(E 系列)', + 'payment' => '支付方式(P 系列)', + 'background' => '整頁背景', + 'icon' => '浮動鈕 Icon(B 系列)', + 'payment_illust' => '支付示意圖(M 系列)', + 'payment_physical' => '支付實體示意圖(R 系列)', + 'bgcolor' => '背景色(C 系列)', + 'textcolor' => '文字色(T 系列)', + ], + + 'parts' => [ + // === A 系列:滿版 / 大圖背景 === + 'A1' => ['label' => 'A1 - 頁面跳轉中', 'group' => 'fullscreen', 'type' => 'image', 'width' => 1080, 'height' => 1920, 'active' => true], + 'A2' => ['label' => 'A2 - 機台自檢中', 'group' => 'fullscreen', 'type' => 'image', 'width' => 1080, 'height' => 1920, 'active' => true], + 'A3' => ['label' => 'A3 - 機台異常鎖定', 'group' => 'fullscreen', 'type' => 'image', 'width' => 1080, 'height' => 1920, 'active' => true], + + // === E 系列:交易結果 === + 'E1' => ['label' => 'E1 - 交易失敗/客服圖', 'group' => 'transaction', 'type' => 'image', 'width' => 720, 'height' => 1280, 'active' => true], + + // === P 系列:支付方式按鈕圖(width/height=建議尺寸提示;crop=false 不裁切、保留原比例)=== + 'P1' => ['label' => 'P1 - 信用卡支付', 'group' => 'payment', 'type' => 'image', 'width' => 838, 'height' => 155, 'crop' => false, 'active' => true], + 'P2' => ['label' => 'P2 - 卡片/電子票證', 'group' => 'payment', 'type' => 'image', 'width' => 704, 'height' => 159, 'crop' => false, 'active' => true], + 'P3' => ['label' => 'P3 - 玉山掃碼支付', 'group' => 'payment', 'type' => 'image', 'width' => 579, 'height' => 282, 'crop' => false, 'active' => true], + 'P4' => ['label' => 'P4 - 手機支付', 'group' => 'payment', 'type' => 'image', 'width' => 684, 'height' => 169, 'crop' => false, 'active' => true], + 'P5' => ['label' => 'P5 - 現金支付', 'group' => 'payment', 'type' => 'image', 'width' => 846, 'height' => 266, 'crop' => false, 'active' => true], + 'P6' => ['label' => 'P6 - TapPay 掃碼', 'group' => 'payment', 'type' => 'image', 'width' => 579, 'height' => 282, 'crop' => false, 'active' => true], + + // === 整頁背景(image,滿版裁切)=== + 'BG0' => ['label' => 'BG0 - 整頁背景(待機/銷售頁)', 'group' => 'background', 'type' => 'image', 'width' => 1080, 'height' => 1920, 'active' => true], + + // === B 系列:浮動鈕 Icon(image,不裁切保留原比例)=== + 'B1' => ['label' => 'B1 - 取貨碼浮動鈕', 'group' => 'icon', 'type' => 'image', 'width' => 128, 'height' => 128, 'crop' => false, 'active' => true], + 'B4' => ['label' => 'B4 - 購物車浮動鈕', 'group' => 'icon', 'type' => 'image', 'width' => 128, 'height' => 128, 'crop' => false, 'active' => true], + + // === M 系列:各支付「進行中」畫面上方的支付方式示意圖(image,不裁切保留原比例)=== + 'M1' => ['label' => 'M1 - 信用卡支付示意圖', 'group' => 'payment_illust', 'type' => 'image', 'width' => 838, 'height' => 155, 'crop' => false, 'active' => true], + 'M2' => ['label' => 'M2 - 手機支付示意圖', 'group' => 'payment_illust', 'type' => 'image', 'width' => 684, 'height' => 169, 'crop' => false, 'active' => true], + 'M3' => ['label' => 'M3 - 掃碼支付示意圖', 'group' => 'payment_illust', 'type' => 'image', 'width' => 579, 'height' => 282, 'crop' => false, 'active' => true], + 'M4' => ['label' => 'M4 - 卡片/電子票證示意圖', 'group' => 'payment_illust', 'type' => 'image', 'width' => 704, 'height' => 159, 'crop' => false, 'active' => true], + 'M5' => ['label' => 'M5 - 現金支付示意圖', 'group' => 'payment_illust', 'type' => 'image', 'width' => 846, 'height' => 266, 'crop' => false, 'active' => true], + + // === R 系列:各支付「進行中」畫面下方的實體示意圖(刷卡機/掃碼區/投幣口,全部 1280x720)=== + 'R1' => ['label' => 'R1 - 信用卡實體示意圖', 'group' => 'payment_physical', 'type' => 'image', 'width' => 1280, 'height' => 720, 'crop' => false, 'active' => true], + 'R2' => ['label' => 'R2 - 手機支付實體示意圖', 'group' => 'payment_physical', 'type' => 'image', 'width' => 1280, 'height' => 720, 'crop' => false, 'active' => true], + 'R3' => ['label' => 'R3 - 掃碼支付實體示意圖', 'group' => 'payment_physical', 'type' => 'image', 'width' => 1280, 'height' => 720, 'crop' => false, 'active' => true], + 'R4' => ['label' => 'R4 - 卡片/電子票證實體示意圖', 'group' => 'payment_physical', 'type' => 'image', 'width' => 1280, 'height' => 720, 'crop' => false, 'active' => true], + 'R5' => ['label' => 'R5 - 現金支付實體示意圖', 'group' => 'payment_physical', 'type' => 'image', 'width' => 1280, 'height' => 720, 'crop' => false, 'active' => true], + + // === C 系列:背景色(color)=== + 'C0' => ['label' => 'C0 - 機台資訊列底色', 'group' => 'bgcolor', 'type' => 'color', 'active' => true], + + // === T 系列:文字色(color)=== + 'T0' => ['label' => 'T0 - 機台資訊列文字色', 'group' => 'textcolor', 'type' => 'color', 'active' => true], + + // --- 以下為舊後台既有部位,Phase 2+ 逐步啟用(active=false 時後台不顯示、不下發)--- + 'A4' => ['label' => 'A4 - 對話框標頭', 'group' => 'fullscreen', 'type' => 'image', 'width' => 1080, 'height' => 192, 'active' => false], + 'A7' => ['label' => 'A7 - 購買頁廣告底圖', 'group' => 'fullscreen', 'type' => 'image', 'width' => 1080, 'height' => 620, 'active' => false], + 'A8' => ['label' => 'A8 - 來店廣告底圖', 'group' => 'fullscreen', 'type' => 'image', 'width' => 864, 'height' => 648, 'active' => false], + ], +]; diff --git a/database/migrations/2026_07_15_120000_create_ui_elements_table.php b/database/migrations/2026_07_15_120000_create_ui_elements_table.php new file mode 100644 index 0000000..f10c21f --- /dev/null +++ b/database/migrations/2026_07_15_120000_create_ui_elements_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('company_id')->nullable()->constrained()->onDelete('cascade'); + $table->string('part_key')->index()->comment('部位代碼,對應 config/ui_parts.php,如 A1/A2/A3'); + $table->string('name'); + $table->string('type')->default('image')->comment('image / color'); + $table->string('image_url')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ui_elements'); + } +}; diff --git a/database/migrations/2026_07_15_120001_create_ui_bundles_table.php b/database/migrations/2026_07_15_120001_create_ui_bundles_table.php new file mode 100644 index 0000000..1e837b9 --- /dev/null +++ b/database/migrations/2026_07_15_120001_create_ui_bundles_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('company_id')->nullable()->constrained()->onDelete('cascade'); + $table->string('name'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ui_bundles'); + } +}; diff --git a/database/migrations/2026_07_15_120002_create_ui_bundle_items_table.php b/database/migrations/2026_07_15_120002_create_ui_bundle_items_table.php new file mode 100644 index 0000000..f2ded79 --- /dev/null +++ b/database/migrations/2026_07_15_120002_create_ui_bundle_items_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('ui_bundle_id')->constrained()->onDelete('cascade'); + $table->string('part_key'); + $table->foreignId('ui_element_id')->nullable()->constrained()->onDelete('set null'); + $table->string('color_value')->nullable()->comment('色彩型部位(C/T 系列)用,如 #RRGGBBAA'); + $table->timestamps(); + + $table->unique(['ui_bundle_id', 'part_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ui_bundle_items'); + } +}; diff --git a/lang/en.json b/lang/en.json index ebb8007..03a8b22 100644 --- a/lang/en.json +++ b/lang/en.json @@ -2337,6 +2337,31 @@ "Type": "Type", "Type to search or leave blank for system defaults.": "Type to search or leave blank for system defaults.", "UI Elements": "UI Elements", + "APP UI Elements": "APP UI Elements", + "UI Bundles": "UI Bundles", + "Machine Binding": "Machine Binding", + "UI element created": "UI element created", + "UI element deleted": "UI element deleted", + "UI bundle created": "UI bundle created", + "UI bundle deleted": "UI bundle deleted", + "Sync UI bundle": "Sync UI bundle", + "Saved and update pushed to machine.": "Saved and update pushed to machine.", + "Saved. Machine will apply on next sync/reboot.": "Saved. Machine will apply on next sync/reboot.", + "No data": "No data", + "entries": "entries", + "Search name or category...": "Search name or category...", + "Search name...": "Search name...", + "Search machine or serial...": "Search machine or serial...", + "Back": "Back", + "Rename": "Rename", + "Content Settings": "Content Settings", + "Not set": "Not set", + "No image selected": "No image selected", + "Apply": "Apply", + "Current Bundle": "Current Bundle", + "Bind UI Bundle": "Bind UI Bundle", + "Image will be center-cropped to the category size.": "Numbers show the recommended size per category (full-screen types are center-cropped; button types keep their aspect ratio).", + "(deleted)": "(deleted)", "Unable to read the image archive": "Unable to read the image archive", "Unassigned": "Unassigned", "Unassigned / No Change": "Unassigned / No Change", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 569580e..75c9985 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -2338,6 +2338,31 @@ "Type": "類型", "Type to search or leave blank for system defaults.": "輸入關鍵字搜尋,或留空以使用系統預設。", "UI Elements": "UI元素", + "APP UI Elements": "APP UI 元素設定", + "UI Bundles": "UI組合編輯", + "Machine Binding": "機台設定", + "UI element created": "UI 元素已新增", + "UI element deleted": "UI 元素已刪除", + "UI bundle created": "UI 組合已新增", + "UI bundle deleted": "UI 組合已刪除", + "Sync UI bundle": "同步 UI 組合", + "Saved and update pushed to machine.": "已儲存,並已推送更新到機台(機台將立即重拉並套用)。", + "Saved. Machine will apply on next sync/reboot.": "已儲存。推送未成功(機台可能離線),將於下次同步/重開機時套用。", + "No data": "無資料", + "entries": "筆", + "Search name or category...": "搜尋名稱或類別…", + "Search name...": "搜尋名稱…", + "Search machine or serial...": "搜尋機台名稱或序號…", + "Back": "返回", + "Rename": "重新命名", + "Content Settings": "內容設定", + "Not set": "未設定", + "No image selected": "尚未選擇圖片", + "Apply": "套用", + "Current Bundle": "當前組合", + "Bind UI Bundle": "綁定 UI 組合", + "Image will be center-cropped to the category size.": "括號內為各類別建議上傳尺寸(滿版類會置中裁切,按鈕類保留原比例)。", + "(deleted)": "(已刪除)", "Unable to read the image archive": "無法讀取圖片壓縮檔", "Unassigned": "未指派", "Unassigned / No Change": "未指派 / 不變動", diff --git a/resources/views/admin/app/ui-bundles/content.blade.php b/resources/views/admin/app/ui-bundles/content.blade.php new file mode 100644 index 0000000..5dead09 --- /dev/null +++ b/resources/views/admin/app/ui-bundles/content.blade.php @@ -0,0 +1,90 @@ +@extends('layouts.admin') + +@section('content') +
+ + +
+ + + {{ __('Back') }} + +

{{ __('Content Settings') }}:{{ $bundle->name }}

+
+ +
+ @csrf @method('PUT') + + @php + $grouped = collect($parts)->groupBy('group', true); + $groupDefs = config('ui_parts.groups', []); + // 群組順序:先照 config 定義,未定義者排最後,且只留實際有部位的群組 + $orderedGroups = collect($groupDefs)->keys() + ->merge($grouped->keys())->unique() + ->filter(fn($g) => $grouped->has($g))->values(); + $firstGroup = true; + @endphp + + @foreach($orderedGroups as $groupKey) + @if(!$firstGroup) +
+ @endif + @php $firstGroup = false; @endphp + +

{{ $groupDefs[$groupKey] ?? $groupKey }}

+
+ @foreach($grouped[$groupKey] as $key => $part) + @php $item = $itemsByPart->get($key); $partType = $part['type'] ?? 'image'; @endphp + + @if($partType === 'color') + {{-- 色彩型(C 底色 / T 文字色):勾選套用 + 色票 --}} + @php $currentColor = $item->color_value ?? ''; @endphp +
+ + +
+ + +
+
+ @else + {{-- 圖片型:選對應類別的 UI 元素 --}} + @php + $currentId = optional($item)->ui_element_id; + $currentThumb = optional(optional($item)->element)->image_url ?? ''; + @endphp +
+ + +
+ +

{{ __('No image selected') }}

+
+
+ @endif + @endforeach +
+ @endforeach + +
+ +
+
+
+@endsection diff --git a/resources/views/admin/app/ui-bundles/index.blade.php b/resources/views/admin/app/ui-bundles/index.blade.php new file mode 100644 index 0000000..8970254 --- /dev/null +++ b/resources/views/admin/app/ui-bundles/index.blade.php @@ -0,0 +1,84 @@ +@extends('layouts.admin') + +@section('content') +
+ + + + + + +
+ + + + + + + + + + @forelse($bundles as $bundle) + + + + + + @empty + + @endforelse + +
{{ __('ID') }}{{ __('Name') }}{{ __('Actions') }}
{{ $bundle->id }} + {{ $bundle->name }} +
+ @csrf @method('PUT') + + + +
+
+
+ + {{ __('Content Settings') }} + +
+
{{ __('No data') }}
+
+ +
+ {{ $bundles->links() }} +
+ + {{-- 新增組合 Modal --}} +
+
+
+
+
+

{{ __('Create') }} — {{ __('UI Bundles') }}

+
+
+ @csrf +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
+@endsection diff --git a/resources/views/admin/app/ui-elements/index.blade.php b/resources/views/admin/app/ui-elements/index.blade.php new file mode 100644 index 0000000..af5922f --- /dev/null +++ b/resources/views/admin/app/ui-elements/index.blade.php @@ -0,0 +1,98 @@ +@extends('layouts.admin') + +@section('content') +
+ + + + + + +
+ + + + + + + + + + + + @forelse($elements as $element) + + + + + + + + @empty + + @endforelse + +
{{ __('Preview') }}{{ __('Name') }}{{ __('Category') }}{{ __('Created At') }}{{ __('Actions') }}
+ @if($element->image_url) + {{ $element->name }} + @else + + @endif + {{ $element->name }} + {{ $element->part_label }} + {{ $element->created_at?->format('Y-m-d H:i') }} + +
{{ __('No data') }}
+
+ +
+ {{ $elements->links() }} +
+ + {{-- 新增 UI 元素 Modal --}} +
+
+
+
+
+

{{ __('Create') }} — {{ __('UI Elements') }}

+
+
+ @csrf +
+
+ + +
+
+ + +

{{ __('Image will be center-cropped to the category size.') }}

+
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
+@endsection diff --git a/resources/views/admin/app/ui-machines/index.blade.php b/resources/views/admin/app/ui-machines/index.blade.php new file mode 100644 index 0000000..e71c224 --- /dev/null +++ b/resources/views/admin/app/ui-machines/index.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.admin') + +@section('content') +
+ + + + +
+ + + + + + + + + + @forelse($machines as $machine) + @php + $currentId = data_get($machine->settings, 'ui_bundle_id'); + $currentName = $currentId ? ($bundleNames[$currentId] ?? __('(deleted)')) : '—'; + @endphp + + + + + + @empty + + @endforelse + +
{{ __('Machine') }}{{ __('Current Bundle') }}{{ __('Bind UI Bundle') }}
+
{{ $machine->name }}
+
{{ $machine->serial_no }}
+
{{ $currentName }} +
+ @csrf @method('PUT') + + +
+
{{ __('No data') }}
+
+ +
+ {{ $machines->links() }} +
+
+@endsection diff --git a/resources/views/components/app-list-controls.blade.php b/resources/views/components/app-list-controls.blade.php new file mode 100644 index 0000000..fe764dd --- /dev/null +++ b/resources/views/components/app-list-controls.blade.php @@ -0,0 +1,29 @@ +{{-- APP UI元素設定 列表控制列:每頁筆數下拉(左)+ 搜尋欄/重置(右)。一般 GET 表單(非 AJAX)。 + 用法: --}} +@props([ + 'route' => '', + 'perPage' => 10, + 'search' => '', + 'placeholder' => '', +]) + +
+
+ {{ __('Show') }} + + {{ __('entries') }} +
+
+ + + + + +
+
diff --git a/resources/views/components/app-tabs.blade.php b/resources/views/components/app-tabs.blade.php new file mode 100644 index 0000000..d094876 --- /dev/null +++ b/resources/views/components/app-tabs.blade.php @@ -0,0 +1,43 @@ +{{-- APP UI元素設定 共用標題 + 頁籤列(+ 選填右側 Action slot,與頁籤同列靠左)。 + 用法: --}} +@props(['active' => '']) +@php + $tabs = [ + 'elements' => ['label' => __('UI Elements'), 'route' => 'admin.app.ui-elements'], + 'bundles' => ['label' => __('UI Bundles'), 'route' => 'admin.app.ui-bundles'], + 'machines' => ['label' => __('Machine Binding'), 'route' => 'admin.app.ui-machines'], + ]; +@endphp + +

{{ __('APP UI Elements') }}

+ +
+
+ @foreach($tabs as $key => $tab) + + {{ $tab['label'] }} + + @endforeach +
+ + @if($slot->isNotEmpty()) + {{ $slot }} + @endif +
+ +@if(session('success')) +
+ {{ session('success') }} +
+@endif +@if($errors->any()) +
+ +
+@endif diff --git a/resources/views/layouts/partials/sidebar-menu.blade.php b/resources/views/layouts/partials/sidebar-menu.blade.php index 8109f6c..c77386e 100644 --- a/resources/views/layouts/partials/sidebar-menu.blade.php +++ b/resources/views/layouts/partials/sidebar-menu.blade.php @@ -97,9 +97,8 @@ @endcan -{{-- @can('menu.app') -5. APP管理 +{{-- 5. APP管理 --}}
  • @endcan ---}} @can('menu.warehouses') diff --git a/routes/web.php b/routes/web.php index 11f2c97..cec6181 100644 --- a/routes/web.php +++ b/routes/web.php @@ -87,8 +87,25 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix }); // 4. APP管理 - Route::prefix('app')->name('app.')->group(function () { - Route::get('/ui-elements', [App\Http\Controllers\Admin\AppConfigController::class, 'uiElements'])->name('ui-elements'); + Route::prefix('app')->name('app.')->middleware('can:menu.app')->group(function () { + // 頁籤一:UI 元素列表(素材庫) + Route::get('/ui-elements', [App\Http\Controllers\Admin\UiElementController::class, 'uiElements'])->name('ui-elements'); + Route::post('/ui-elements', [App\Http\Controllers\Admin\UiElementController::class, 'storeElement'])->name('ui-elements.store'); + Route::delete('/ui-elements/{uiElement}', [App\Http\Controllers\Admin\UiElementController::class, 'destroyElement'])->name('ui-elements.destroy'); + + // 頁籤二:UI 組合編輯 + Route::get('/ui-bundles', [App\Http\Controllers\Admin\UiElementController::class, 'bundles'])->name('ui-bundles'); + Route::post('/ui-bundles', [App\Http\Controllers\Admin\UiElementController::class, 'storeBundle'])->name('ui-bundles.store'); + Route::put('/ui-bundles/{uiBundle}', [App\Http\Controllers\Admin\UiElementController::class, 'updateBundle'])->name('ui-bundles.update'); + Route::delete('/ui-bundles/{uiBundle}', [App\Http\Controllers\Admin\UiElementController::class, 'destroyBundle'])->name('ui-bundles.destroy'); + Route::get('/ui-bundles/{uiBundle}/content', [App\Http\Controllers\Admin\UiElementController::class, 'editBundleContent'])->name('ui-bundles.content'); + Route::put('/ui-bundles/{uiBundle}/content', [App\Http\Controllers\Admin\UiElementController::class, 'updateBundleContent'])->name('ui-bundles.content.update'); + + // 頁籤三:機台設定(綁定 UI 組合) + Route::get('/ui-machines', [App\Http\Controllers\Admin\UiElementController::class, 'machines'])->name('ui-machines'); + Route::put('/ui-machines/{machine}', [App\Http\Controllers\Admin\UiElementController::class, 'updateMachineBundle'])->name('ui-machines.update'); + + // 其餘 APP 管理佔位頁(沿用既有) Route::get('/helper', [App\Http\Controllers\Admin\AppConfigController::class, 'helper'])->name('helper'); Route::get('/questionnaire', [App\Http\Controllers\Admin\AppConfigController::class, 'questionnaire'])->name('questionnaire'); Route::get('/games', [App\Http\Controllers\Admin\AppConfigController::class, 'games'])->name('games');