feat(app-ui): APP UI元素設定 Phase 1 — 後台三頁(元素/組合/機台綁定)+ B014 UISet 下發

- ui_elements/ui_bundles/ui_bundle_items 三表 + Models(TenantScoped)
- config/ui_parts.php 部位定義(Phase 1 啟用 A1/A2/A3 滿版)
- UiElementController 三頁 CRUD + 圖片上傳(ImageHandler storeAsWebp)
- 機台綁定存 machines.settings['ui_bundle_id']
- B014 getSettings 新增 UISet(部位→絕對圖URL,mirror B005)
- 解除 sidebar APP管理 註解 + can:menu.app + 權限白名單
- zh_TW/en 翻譯

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
terrylee 2026-07-15 12:37:35 +08:00
parent 42ec18188e
commit a0c2ab3207
19 changed files with 855 additions and 5 deletions

View File

@ -670,6 +670,7 @@ class PermissionController extends Controller
$activeModules = [
'menu.machines',
'menu.app',
'menu.warehouses',
'menu.sales',
'menu.analysis',

View File

@ -0,0 +1,206 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Machine\Machine;
use App\Models\System\UiBundle;
use App\Models\System\UiBundleItem;
use App\Models\System\UiElement;
use App\Traits\ImageHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class UiElementController extends Controller
{
use ImageHandler;
/**
* 本階段啟用的部位config/ui_parts.php active=true)。
*/
private function activeParts(): array
{
return array_filter(config('ui_parts.parts', []), fn($p) => $p['active'] ?? false);
}
// ==================================================================
// 頁籤一UI 元素列表(素材庫)
// ==================================================================
public function uiElements()
{
$elements = UiElement::orderByDesc('id')->get();
return view('admin.app.ui-elements.index', [
'elements' => $elements,
'parts' => $this->activeParts(),
]);
}
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']}");
// 依部位建議尺寸做居中裁切,存為 webp
$path = $this->storeAsWebp(
$request->file('file'),
'ui_elements',
80,
$part['width'] ?? null,
$part['height'] ?? null
);
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()
{
$bundles = UiBundle::orderBy('id')->get();
return view('admin.app.ui-bundles.index', compact('bundles'));
}
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', []);
foreach (array_keys($parts) as $key) {
$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;
}
// 只接受屬於該部位的元素
$valid = UiElement::where('id', $elementId)->where('part_key', $key)->exists();
if (!$valid) {
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)
{
$machines = Machine::orderBy('name')->paginate(20);
$bundles = UiBundle::orderBy('name')->get();
// 現有綁定名稱對照
$bundleNames = $bundles->pluck('name', 'id');
return view('admin.app.ui-machines.index', [
'machines' => $machines,
'bundles' => $bundles,
'bundleNames' => $bundleNames,
]);
}
public function updateMachineBundle(Request $request, Machine $machine)
{
$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();
return redirect()->route('admin.app.ui-machines')->with('success', __('Saved'));
}
}

View File

@ -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) 編輯後回寫雲端。

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models\System;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UiBundle extends Model
{
use HasFactory, TenantScoped;
protected $fillable = [
'company_id',
'name',
];
/**
* 組合內容:各部位 元素/色值。
*/
public function items()
{
return $this->hasMany(UiBundleItem::class);
}
/**
* part_key 為鍵的內容對照(方便下發與表單回填)。
*/
public function itemsByPart()
{
return $this->items->keyBy('part_key');
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UiBundleItem extends Model
{
use HasFactory;
protected $fillable = [
'ui_bundle_id',
'part_key',
'ui_element_id',
'color_value',
];
public function bundle()
{
return $this->belongsTo(UiBundle::class, 'ui_bundle_id');
}
public function element()
{
return $this->belongsTo(UiElement::class, 'ui_element_id');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Models\System;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UiElement extends Model
{
use HasFactory, TenantScoped;
protected $fillable = [
'company_id',
'part_key',
'name',
'type',
'image_url',
'is_active',
];
protected $casts = [
'is_active' => '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);
}
}

34
config/ui_parts.php Normal file
View File

@ -0,0 +1,34 @@
<?php
/*
|--------------------------------------------------------------------------
| APP UI 部位定義(比照舊後台 unibuy 的「類別」代碼)
|--------------------------------------------------------------------------
|
| 每個部位 = 機台 App 上一個可由後台換膚的位置。
| key 部位代碼(沿用舊後台,如 A1/A2/A3= ui_elements.part_key
| label 後台顯示名稱
| group 分類(僅用於後台排版分區)
| type image = 選一張 UI 元素圖片color = 選一個色值C/T 系列Phase 3
| width 建議上傳寬度px
| height 建議上傳高度px
| active 是否已在本階段啟用Phase 1 僅開 A1/A2/A3 滿版狀態頁)
|
| 擴充方式:後續階段把對應部位 active true 並在 App 端對照表補上 view 即可,
| 後台/下發邏輯不需改動(一律以本表 active=true 的部位為準)。
|
*/
return [
'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],
// --- 以下為舊後台既有部位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],
],
];

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* APP UI 元素素材庫:每張圖掛一個「部位代碼」(part_key A1/A2/A3)
*/
public function up(): void
{
Schema::create('ui_elements', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* APP UI 組合(皮膚):依客戶(company)建立多組具名組合。
*/
public function up(): void
{
Schema::create('ui_bundles', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained()->onDelete('cascade');
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ui_bundles');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* UI 組合內容:每個部位(part_key) 指定一張 UI 元素(圖片型)或一個色值(色彩型)
*/
public function up(): void
{
Schema::create('ui_bundle_items', function (Blueprint $table) {
$table->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');
}
};

View File

@ -2337,6 +2337,22 @@
"Type": "Type",
"Type to search or leave blank for system defaults.": "Type to search or leave blank for system defaults.",
"UI Elements": "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",
"No data": "No data",
"Back": "Back",
"Rename": "Rename",
"Content Settings": "Content Settings",
"Not set": "Not set",
"No image selected": "No image selected",
"Current Bundle": "Current Bundle",
"Bind UI Bundle": "Bind UI Bundle",
"Image will be center-cropped to the category size.": "Image will be center-cropped to the category size.",
"(deleted)": "(deleted)",
"Unable to read the image archive": "Unable to read the image archive",
"Unassigned": "Unassigned",
"Unassigned / No Change": "Unassigned / No Change",

View File

@ -2338,6 +2338,22 @@
"Type": "類型",
"Type to search or leave blank for system defaults.": "輸入關鍵字搜尋,或留空以使用系統預設。",
"UI Elements": "UI元素",
"UI Bundles": "UI組合編輯",
"Machine Binding": "機台設定",
"UI element created": "UI 元素已新增",
"UI element deleted": "UI 元素已刪除",
"UI bundle created": "UI 組合已新增",
"UI bundle deleted": "UI 組合已刪除",
"No data": "無資料",
"Back": "返回",
"Rename": "重新命名",
"Content Settings": "內容設定",
"Not set": "未設定",
"No image selected": "尚未選擇圖片",
"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": "未指派 / 不變動",

View File

@ -0,0 +1,40 @@
{{-- APP UI元素設定 共用頁籤列。傳入 $active = elements | bundles | machines --}}
@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
<div class="mb-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-gray-900 dark:text-gray-100 text-2xl font-semibold">{{ __('APP Management') }}</h3>
<p class="text-sm text-rose-500 mt-1">{{ __('UI Elements') }}</p>
</div>
</div>
<div class="mt-4 inline-flex rounded-lg bg-gray-100 dark:bg-gray-800 p-1 gap-1">
@foreach($tabs as $key => $tab)
<a href="{{ route($tab['route']) }}"
class="px-4 py-2 text-sm font-medium rounded-md transition-colors
{{ ($active ?? '') === $key
? 'bg-emerald-600 text-white shadow'
: 'text-gray-600 dark:text-gray-300 hover:bg-white/60 dark:hover:bg-gray-700' }}">
{{ $tab['label'] }}
</a>
@endforeach
</div>
</div>
@if(session('success'))
<div class="mb-4 rounded-lg bg-emerald-100 dark:bg-emerald-900/40 text-emerald-800 dark:text-emerald-200 px-4 py-3 text-sm">
{{ session('success') }}
</div>
@endif
@if($errors->any())
<div class="mb-4 rounded-lg bg-rose-100 dark:bg-rose-900/40 text-rose-800 dark:text-rose-200 px-4 py-3 text-sm">
<ul class="list-disc ps-5">
@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach
</ul>
</div>
@endif

View File

@ -0,0 +1,52 @@
@extends('layouts.admin')
@section('content')
<div class="container mx-auto px-4 py-6">
@include('admin.app._tabs', ['active' => 'bundles'])
<div class="mb-4 flex items-center gap-2">
<a href="{{ route('admin.app.ui-bundles') }}" class="text-sm text-gray-500 hover:text-gray-700">&larr; {{ __('Back') }}</a>
<span class="text-gray-300">/</span>
<h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ __('Content Settings') }}{{ $bundle->name }}</h4>
</div>
<form action="{{ route('admin.app.ui-bundles.content.update', $bundle) }}" method="POST">
@csrf @method('PUT')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach($parts as $key => $part)
@php
$currentId = optional($itemsByPart->get($key))->ui_element_id;
$currentThumb = optional(optional($itemsByPart->get($key))->element)->image_url ?? '';
@endphp
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4" x-data="{ thumb: @js($currentThumb) }">
<label class="block text-sm font-semibold text-gray-800 dark:text-gray-100 mb-1">
{{ $part['label'] }}
<span class="text-xs font-normal text-gray-400">{{ $part['width'] }}x{{ $part['height'] }}</span>
</label>
<select name="parts[{{ $key }}]"
@change="thumb = ($event.target.selectedOptions[0]?.dataset.thumb) || ''"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 py-2 px-3 text-sm">
<option value="">{{ __('Not set') }}</option>
@foreach($elementsByPart[$key] as $element)
<option value="{{ $element->id }}" data-thumb="{{ $element->image_url }}"
{{ (string)$currentId === (string)$element->id ? 'selected' : '' }}>
{{ $element->name }}
</option>
@endforeach
</select>
<div class="mt-3">
<img x-show="thumb" x-cloak :src="thumb"
class="h-28 w-auto max-w-full object-contain rounded border border-gray-200 dark:border-gray-700 bg-gray-50">
<p x-show="!thumb" x-cloak class="text-xs text-gray-400">{{ __('No image selected') }}</p>
</div>
</div>
@endforeach
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-2 px-6 rounded-lg">{{ __('Save') }}</button>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,75 @@
@extends('layouts.admin')
@section('content')
<div class="container mx-auto px-4 py-6" x-data="{ showCreate: false }">
@include('admin.app._tabs', ['active' => 'bundles'])
<div class="mb-4">
<button type="button" @click="showCreate = true"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg">
+ {{ __('New') }}
</button>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
<th class="px-4 py-3 w-20">{{ __('ID') }}</th>
<th class="px-4 py-3">{{ __('Name') }}</th>
<th class="px-4 py-3 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/60 text-sm text-gray-700 dark:text-gray-200">
@forelse($bundles as $bundle)
<tr x-data="{ editing: false }">
<td class="px-4 py-3 text-gray-500">{{ $bundle->id }}</td>
<td class="px-4 py-3">
<span x-show="!editing">{{ $bundle->name }}</span>
<form x-show="editing" x-cloak method="POST" action="{{ route('admin.app.ui-bundles.update', $bundle) }}" class="flex gap-2">
@csrf @method('PUT')
<input type="text" name="name" value="{{ $bundle->name }}"
class="rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-1 px-2 text-sm">
<button type="submit" class="text-emerald-600 text-sm">{{ __('Save') }}</button>
<button type="button" @click="editing=false" class="text-gray-400 text-sm">{{ __('Cancel') }}</button>
</form>
</td>
<td class="px-4 py-3 text-right space-x-3">
<button type="button" @click="editing=!editing" class="text-gray-500 hover:text-gray-700 text-sm">{{ __('Rename') }}</button>
<a href="{{ route('admin.app.ui-bundles.content', $bundle) }}" class="text-blue-600 hover:text-blue-800 text-sm">{{ __('Content Settings') }}</a>
<form action="{{ route('admin.app.ui-bundles.destroy', $bundle) }}" method="POST"
onsubmit="return confirm('{{ __('Are you sure?') }}')" class="inline">
@csrf @method('DELETE')
<button type="submit" class="text-rose-600 hover:text-rose-800 text-sm">{{ __('Delete') }}</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="3" class="px-4 py-10 text-center text-gray-400">{{ __('No data') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- 新增組合 Modal --}}
<div x-show="showCreate" x-cloak style="display:none"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md p-6" @click.outside="showCreate = false">
<h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{{ __('New') }} {{ __('UI Bundles') }}</h4>
<form action="{{ route('admin.app.ui-bundles.store') }}" method="POST" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-gray-600 dark:text-gray-300 mb-1">{{ __('Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="name" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 py-2 px-3 text-sm">
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" @click="showCreate = false"
class="px-4 py-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">{{ __('Cancel') }}</button>
<button type="submit" class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-semibold">{{ __('Save') }}</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,94 @@
@extends('layouts.admin')
@section('content')
<div class="container mx-auto px-4 py-6" x-data="{ showCreate: false }">
@include('admin.app._tabs', ['active' => 'elements'])
<div class="mb-4">
<button type="button" @click="showCreate = true"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg">
+ {{ __('New') }}
</button>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
<th class="px-4 py-3">{{ __('Preview') }}</th>
<th class="px-4 py-3">{{ __('Name') }}</th>
<th class="px-4 py-3">{{ __('Category') }}</th>
<th class="px-4 py-3">{{ __('Created At') }}</th>
<th class="px-4 py-3 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/60 text-sm text-gray-700 dark:text-gray-200">
@forelse($elements as $element)
<tr>
<td class="px-4 py-3">
@if($element->image_url)
<img src="{{ $element->image_url }}" alt="{{ $element->name }}"
class="h-12 w-auto max-w-[140px] object-contain rounded border border-gray-200 dark:border-gray-700 bg-gray-50">
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3">{{ $element->name }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-xs">
{{ $element->part_label }}
</span>
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{{ $element->created_at?->format('Y-m-d H:i') }}</td>
<td class="px-4 py-3 text-right">
<form action="{{ route('admin.app.ui-elements.destroy', $element) }}" method="POST"
onsubmit="return confirm('{{ __('Are you sure?') }}')" class="inline">
@csrf @method('DELETE')
<button type="submit" class="text-rose-600 hover:text-rose-800 text-sm">{{ __('Delete') }}</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-10 text-center text-gray-400">{{ __('No data') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- 新增 UI 元素 Modal --}}
<div x-show="showCreate" x-cloak style="display:none"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg p-6" @click.outside="showCreate = false">
<h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{{ __('New') }} {{ __('UI Elements') }}</h4>
<form action="{{ route('admin.app.ui-elements.store') }}" method="POST" enctype="multipart/form-data" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-gray-600 dark:text-gray-300 mb-1">{{ __('Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="name" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 py-2 px-3 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 dark:text-gray-300 mb-1">{{ __('Category') }} <span class="text-rose-500">*</span></label>
<select name="part_key" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 py-2 px-3 text-sm">
@foreach($parts as $key => $part)
<option value="{{ $key }}">{{ $part['label'] }} {{ $part['width'] }}x{{ $part['height'] }}</option>
@endforeach
</select>
<p class="text-xs text-gray-400 mt-1">{{ __('Image will be center-cropped to the category size.') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 dark:text-gray-300 mb-1">{{ __('Upload Image') }} <span class="text-rose-500">*</span></label>
<input type="file" name="file" accept="image/*" required
class="block w-full text-sm text-gray-600 dark:text-gray-300 file:mr-3 file:py-2 file:px-4 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" @click="showCreate = false"
class="px-4 py-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">{{ __('Cancel') }}</button>
<button type="submit" class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-semibold">{{ __('Save') }}</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,55 @@
@extends('layouts.admin')
@section('content')
<div class="container mx-auto px-4 py-6">
@include('admin.app._tabs', ['active' => 'machines'])
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/40">
<tr class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
<th class="px-4 py-3">{{ __('Machine') }}</th>
<th class="px-4 py-3">{{ __('Current Bundle') }}</th>
<th class="px-4 py-3 w-96">{{ __('Bind UI Bundle') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/60 text-sm text-gray-700 dark:text-gray-200">
@forelse($machines as $machine)
@php
$currentId = data_get($machine->settings, 'ui_bundle_id');
$currentName = $currentId ? ($bundleNames[$currentId] ?? __('(deleted)')) : '—';
@endphp
<tr>
<td class="px-4 py-3">
<div class="font-medium">{{ $machine->name }}</div>
<div class="text-xs text-gray-400">{{ $machine->serial_no }}</div>
</td>
<td class="px-4 py-3">{{ $currentName }}</td>
<td class="px-4 py-3">
<form action="{{ route('admin.app.ui-machines.update', $machine) }}" method="POST" class="flex gap-2">
@csrf @method('PUT')
<select name="ui_bundle_id"
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 py-1.5 px-2 text-sm">
<option value="">{{ __('Not set') }}</option>
@foreach($bundles as $bundle)
<option value="{{ $bundle->id }}" {{ (string)$currentId === (string)$bundle->id ? 'selected' : '' }}>
{{ $bundle->name }}
</option>
@endforeach
</select>
<button type="submit" class="px-3 py-1.5 rounded-md bg-emerald-600 hover:bg-emerald-700 text-white text-sm">{{ __('Save') }}</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="3" class="px-4 py-10 text-center text-gray-400">{{ __('No data') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $machines->links() }}
</div>
</div>
@endsection

View File

@ -97,9 +97,8 @@
@endcan
{{--
@can('menu.app')
5. APP管理
{{-- 5. APP管理 --}}
<li x-data="{ open: localStorage.getItem('menu_app') === 'true' || {{ request()->routeIs('admin.app.*') ? 'true' : 'false' }} }">
<button type="button" @click="if(sidebarCollapsed) { sidebarCollapsed = false; open = true; } else { open = !open; } localStorage.setItem('menu_app', open)" class="luxury-nav-item w-full text-start group">
<svg class="w-4 h-4 shrink-0 transition-colors {{ request()->routeIs('admin.app.*') ? 'text-cyan-500' : 'text-slate-600 group-hover:text-cyan-500 dark:text-slate-300 dark:group-hover:text-cyan-400' }}" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
@ -111,6 +110,8 @@
<div x-show="open && !sidebarCollapsed" x-collapse>
<ul class="luxury-submenu" data-sidebar-sub>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.app.ui-elements') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.app.ui-elements') }}"><span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('UI Elements') }}</span></a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.app.ui-bundles*') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.app.ui-bundles') }}"><span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('UI Bundles') }}</span></a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.app.ui-machines*') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.app.ui-machines') }}"><span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Machine Binding') }}</span></a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.app.helper') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.app.helper') }}"><span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Helper') }}</span></a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.app.questionnaire') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.app.questionnaire') }}"><span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Questionnaire') }}</span></a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.app.games') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.app.games') }}"><span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Games') }}</span></a></li>
@ -119,7 +120,6 @@
</div>
</li>
@endcan
--}}
@can('menu.warehouses')

View File

@ -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');