Compare commits

...

18 Commits
main ... dev

Author SHA1 Message Date
twsystem1004
53b5d44fde fix(remote): ACK 的 message 寫入 note,否則失敗原因與查詢結果都被丟棄
ProcessCommandAck 的通用路徑原本只把 result/stock 併進 payload,未保存機台
回報的 message,導致:
- 指令失敗時 note 為空,後台看不到原因(Machine busy / 下位機無回應 /
  Unknown command…),無從診斷。
- query_jam 以 message 回傳查詢結果 JSON {"jam":[2,5],"belt":[]},
  在指令中心永遠顯示不出來。

改為一律將 message 寫入 note;機台未回 message 時保留既有 note,
避免覆蓋 Superseded 等既有提示。update_app 的 progress 分支不受影響。
2026-07-17 08:38:00 +00:00
twsystem1004
84ec95d95f feat(remote): 指令中心新增卡貨貨道查詢/清除 (query_jam / clear_jam)
微波機出貨卡貨後,下位機會記錄該貨道異常;未清除前該貨道再出貨仍會失敗,
原本只能到機台端手動排除。此版把機台「系統設定 → 貨道卡貨排除」的能力
搬到後台指令中心,可遠端對個別機台查詢/清除。

對應下位機協定 4.5.32(CMD 0x32):
  0x00 查詢貨道卡貨狀態 → 回 卡貨貨道N + 皮帶自檢失敗貨道N
  0x01 清除卡貨故障(不帶貨道參數=一次清全部)

- RemoteController: command_type 白名單加 query_jam,clear_jam
- index.blade.php: 新增兩顆按鈕;getCommandName 對照;translateNote 將
  機台回傳的 JSON {jam:[2,5],belt:[]} 渲染為可讀文字
- lang(en/zh_TW/ja): 補 5 個標籤

機台端以 ACK message 回傳 JSON(App v40 JamSlotManager),走既有
ProcessCommandAck → note 的既有管道,故 ACK 處理與 migration 皆免改。
2026-07-17 08:29:34 +00:00
7647b87f26 feat(app-ui): M1~M5 補建議尺寸提示 + 新增 R1~R5 支付實體示意圖(1280x720)群組
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:45 +08:00
3ee7fad010 feat(app-ui): 啟用部位 B1/B4(浮動鈕 icon) + M1~M5(各支付示意圖) + icon/payment_illust 群組
皆 crop=false 保留原比例;B 系列建議 128x128,M 系列不限尺寸

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:45 +08:00
68c772b06b fix(app-ui): color 型部位無 width/height 鍵導致 Undefined array key — blade 讀尺寸改 null 安全($part['width'] ?? null)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:44 +08:00
a0553258f8 feat(app-ui): Phase3 後台 — 支援 color 型部位(色票選擇器)+ 新增 BG0整頁背景/C0底色/T0文字色
- config 加 background/bgcolor/textcolor 群組 + BG0(image)/C0/T0(color)
- 內容設定頁 color 型渲染勾選套用+色票(input color);updateBundleContent 存 color_value
- B014 下發 color 型已支援(不需改)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:44 +08:00
f0a6c80137 fix(machines): 換頁後 SVG 圖示放大/變形 — 只對真正 XHR 回裸片段,並移除 _ajax 避免洩漏進分頁連結
根因:控制器僅憑 query 參數 _ajax=1 回無 layout 片段;AJAX 重繪(如儲存系統設定後)經 withQueryString 把 _ajax 烙進分頁連結,直接導航該連結即拿到裸片段致圖示無 CSS。改為 $request->ajax() && boolean('_ajax') 並移除 _ajax。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:44 +08:00
81acddffca feat(app-ui): 組合內容設定頁依類別群組分塊+分隔線(A系列/E系列/P系列)
config 加 groups 定義(標題+順序);content 頁 groupBy group 渲染,塊間加 hr

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:44 +08:00
f0f8da07a7 style(app-ui): 每頁筆數下拉加寬右側 padding,避免數字與箭頭相疊
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:44 +08:00
f43772fa99 feat(app-ui): UI組合編輯/機台設定 也加每頁筆數+搜尋+分頁
抽共用元件 x-app-list-controls(3頁共用);bundles 依名稱、machines 依名稱/序號搜尋 paginate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:44 +08:00
4d5d42f4cb feat(app-ui): UI元素列表加每頁筆數下拉+搜尋+分頁
controller paginate(per_page 10/25/50/100)+名稱/類別 like 搜尋;view 控制列(luxury 樣式)+分頁連結

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:43 +08:00
0f2b5dfe44 feat(app-ui): P1~P6 加建議尺寸提示 + crop 旗標(按鈕類不裁切保留原比例)
P 系列填實際圖檔尺寸(838x155 等)供下拉提示;controller 依 part.crop 決定是否置中裁切;提示文字改中性

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:43 +08:00
32599b8da7 style(app-ui): APP管理側邊欄各子功能加小圖示(對齊其他選單群組)
UI元素/UI組合/機台設定/小幫手/問卷/遊戲/計時器 各配一個 heroicon

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:43 +08:00
ef62b0a2ef style(app-ui): 頁籤與「新增」按鈕同列靠左 — 改用 x-app-tabs 元件(帶 Action slot)
移除獨立的新增按鈕列與舊 _tabs partial

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:43 +08:00
a326283d17 style(app-ui): APP管理三頁改用站上設計系統(luxury按鈕/標準表格/膠囊tabs/標準modal/刪除確認)
- btn-luxury-primary/secondary + x-delete-confirm-modal
- 膠囊式頁籤(對齊 x-tab-nav 外觀)
- 「新增」改用 __('Create')=新增(不動全域 New=新值,那是 products 變更紀錄用)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:43 +08:00
f1c4a9a2b7 feat(app-ui): 啟用部位 E1(交易失敗/客服圖) + P1~P6(支付方式按鈕圖)
P 系列 width/height=null 不裁切保留原比例;blade 尺寸標示對 null 做防護

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:43 +08:00
7625799ecc feat(app-ui): 機台綁定儲存後主動推送 update_settings,機台立即套用 UI
沿用 app 既有 update_settings→downloadSetting 處理(APK 不需改);推送失敗(離線)則下次同步套用

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:06:43 +08:00
a0c2ab3207 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>
2026-07-17 11:06:43 +08:00
25 changed files with 1177 additions and 15 deletions

View File

@ -28,7 +28,12 @@ class MachineSettingController extends AdminController
$tab = $request->input('tab', 'machines'); $tab = $request->input('tab', 'machines');
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 10);
$search = $request->input('search'); $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(); $currentUser = auth()->user();
$companyId = trim((string) $request->input('company_id', '')); $companyId = trim((string) $request->input('company_id', ''));

View File

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

View File

@ -142,7 +142,7 @@ class RemoteController extends Controller
{ {
$validated = $request->validate([ $validated = $request->validate([
'machine_id' => 'required|exists:machines,id', 'machine_id' => 'required|exists:machines,id',
'command_type' => 'required|string|in:reboot,reboot_force,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto', 'command_type' => 'required|string|in:reboot,reboot_force,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto,query_jam,clear_jam',
'amount' => 'nullable|integer|min:0', 'amount' => 'nullable|integer|min:0',
'slot_no' => 'nullable|string', 'slot_no' => 'nullable|string',
'note' => 'nullable|string|max:255', 'note' => 'nullable|string|max:255',

View File

@ -0,0 +1,280 @@
<?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(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 → 解析 UISetAPK 不需改動。
$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);
}
}

View File

@ -546,6 +546,10 @@ class MachineController extends Controller
'SpringSlot51_60' => (bool) $machine->is_spring_slot_51_60, '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([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, '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) * B016: Update Machine System Settings (Write-back from machine console)
* 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。 * 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。

View File

@ -140,9 +140,16 @@ class ProcessCommandAck implements ShouldQueue
} }
} }
// 機台回報的 message 一律寫入 note
// - 失敗時是原因Machine busy / 下位機無回應 / Unknown command…沒存就無從診斷。
// - query_jam 的 message 是查詢結果 JSON {"jam":[2,5],"belt":[]},指令中心據此渲染。
// 機台沒回 message 時保留既有 note如 Superseded 提示),不覆蓋。
$ackMessage = $this->payload['message'] ?? null;
$command->update([ $command->update([
'status' => $status, 'status' => $status,
'executed_at' => now(), 'executed_at' => now(),
'note' => ($ackMessage !== null && $ackMessage !== '') ? $ackMessage : $command->note,
'payload' => array_merge($command->payload, $payloadUpdates), 'payload' => array_merge($command->payload, $payloadUpdates),
]); ]);

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);
}
}

85
config/ui_parts.php Normal file
View File

@ -0,0 +1,85 @@
<?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 [
// 群組定義(決定內容設定頁的分塊標題與順序;未列到的群組排最後)
'groups' => [
'fullscreen' => '滿版畫面A 系列)',
'transaction' => '交易結果E 系列)',
'payment' => '支付方式P 系列)',
'background' => '整頁背景',
'icon' => '浮動鈕 IconB 系列)',
'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 系列:浮動鈕 Iconimage不裁切保留原比例===
'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],
],
];

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,31 @@
"Type": "Type", "Type": "Type",
"Type to search or leave blank for system defaults.": "Type to search or leave blank for system defaults.", "Type to search or leave blank for system defaults.": "Type to search or leave blank for system defaults.",
"UI Elements": "UI Elements", "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", "Unable to read the image archive": "Unable to read the image archive",
"Unassigned": "Unassigned", "Unassigned": "Unassigned",
"Unassigned / No Change": "Unassigned / No Change", "Unassigned / No Change": "Unassigned / No Change",
@ -2723,5 +2748,10 @@
"video": "Video", "video": "Video",
"visit_gift": "Visit Gift", "visit_gift": "Visit Gift",
"vs Yesterday": "vs Yesterday", "vs Yesterday": "vs Yesterday",
"warning": "warning" "warning": "warning",
"Query Jammed Slots": "Query Jammed Slots",
"Clear Jammed Slots": "Clear Jammed Slots",
"No jammed slots": "No jammed slots",
"Jammed slots:": "Jammed slots:",
"Belt self-test failed:": "Belt self-test failed:"
} }

View File

@ -2709,5 +2709,10 @@
"video": "動画", "video": "動画",
"visit_gift": "来店ギフト", "visit_gift": "来店ギフト",
"vs Yesterday": "昨日比", "vs Yesterday": "昨日比",
"warning": "警告" "warning": "警告",
"Query Jammed Slots": "詰まりレーン照会",
"Clear Jammed Slots": "詰まりレーン解除",
"No jammed slots": "詰まりレーンなし",
"Jammed slots:": "詰まりレーン:",
"Belt self-test failed:": "ベルト自己診断failed"
} }

View File

@ -2338,6 +2338,31 @@
"Type": "類型", "Type": "類型",
"Type to search or leave blank for system defaults.": "輸入關鍵字搜尋,或留空以使用系統預設。", "Type to search or leave blank for system defaults.": "輸入關鍵字搜尋,或留空以使用系統預設。",
"UI Elements": "UI元素", "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": "無法讀取圖片壓縮檔", "Unable to read the image archive": "無法讀取圖片壓縮檔",
"Unassigned": "未指派", "Unassigned": "未指派",
"Unassigned / No Change": "未指派 / 不變動", "Unassigned / No Change": "未指派 / 不變動",
@ -2724,5 +2749,10 @@
"video": "影片", "video": "影片",
"visit_gift": "來店禮", "visit_gift": "來店禮",
"vs Yesterday": "較昨日", "vs Yesterday": "較昨日",
"warning": "警告" "warning": "警告",
"Query Jammed Slots": "查詢卡貨貨道",
"Clear Jammed Slots": "清除卡貨貨道",
"No jammed slots": "無卡貨貨道",
"Jammed slots:": "卡貨貨道:",
"Belt self-test failed:": "皮帶自檢失敗貨道:"
} }

View File

@ -0,0 +1,90 @@
@extends('layouts.admin')
@section('content')
<div class="px-6 py-8">
<x-app-tabs active="bundles" />
<div class="flex items-center gap-3 mb-6">
<a href="{{ route('admin.app.ui-bundles') }}" class="btn-luxury-secondary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg>
{{ __('Back') }}
</a>
<h4 class="text-xl font-semibold text-gray-900 dark:text-gray-200">{{ __('Content Settings') }}{{ $bundle->name }}</h4>
</div>
<form action="{{ route('admin.app.ui-bundles.content.update', $bundle) }}" method="POST">
@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)
<hr class="my-8 border-t border-gray-200 dark:border-gray-700">
@endif
@php $firstGroup = false; @endphp
<h4 class="text-base font-semibold text-gray-800 dark:text-gray-200 mb-3">{{ $groupDefs[$groupKey] ?? $groupKey }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
@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
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 p-4"
x-data="{ enabled: {{ $currentColor ? 'true' : 'false' }}, color: '{{ $currentColor ?: '#000000' }}' }">
<label class="block text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">{{ $part['label'] }}</label>
<label class="inline-flex items-center gap-2 mb-3 text-sm text-gray-600 dark:text-gray-300">
<input type="checkbox" name="parts_enabled[{{ $key }}]" value="1" x-model="enabled" class="rounded text-cyan-600 focus:ring-cyan-500">
{{ __('Apply') }}
</label>
<div class="flex items-center gap-3" :class="enabled ? '' : 'opacity-40 pointer-events-none'">
<input type="color" name="parts_color[{{ $key }}]" x-model="color" class="h-10 w-14 rounded border border-gray-300 dark:border-gray-600 bg-transparent cursor-pointer">
<span class="text-sm font-mono text-gray-500 dark:text-gray-400" x-text="color.toUpperCase()"></span>
</div>
</div>
@else
{{-- 圖片型:選對應類別的 UI 元素 --}}
@php
$currentId = optional($item)->ui_element_id;
$currentThumb = optional(optional($item)->element)->image_url ?? '';
@endphp
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 p-4" x-data="{ thumb: @js($currentThumb) }">
<label class="block text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">
{{ $part['label'] }}
@if($part['width'] ?? null)<span class="text-xs font-normal text-gray-400">{{ $part['width'] }}x{{ $part['height'] }}</span>@endif
</label>
<select name="parts[{{ $key }}]"
@change="thumb = ($event.target.selectedOptions[0]?.dataset.thumb) || ''"
class="w-full px-3 py-2 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 border rounded-md text-gray-900 dark:text-gray-200 text-sm focus:ring-2 focus:ring-cyan-500">
<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>
@endif
@endforeach
</div>
@endforeach
<div class="mt-6 flex justify-end">
<button type="submit" class="btn-luxury-primary">{{ __('Save') }}</button>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,84 @@
@extends('layouts.admin')
@section('content')
<div class="px-6 py-8" x-data="{ showCreate: false, isDeleteConfirmOpen: false, deleteFormAction: '' }">
<x-app-tabs active="bundles">
<button type="button" @click="showCreate = true" class="btn-luxury-primary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
{{ __('Create') }}
</button>
</x-app-tabs>
<x-app-list-controls :route="route('admin.app.ui-bundles')" :perPage="$perPage" :search="$search" :placeholder="__('Search name...')" />
<div class="overflow-x-auto">
<table class="min-w-full bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase w-20">{{ __('ID') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y border-gray-200 dark:border-gray-700">
@forelse($bundles as $bundle)
<tr x-data="{ editing: false }">
<td class="px-6 py-4 text-gray-500 dark:text-gray-400">{{ $bundle->id }}</td>
<td class="px-6 py-4 text-gray-900 dark:text-gray-200">
<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 items-center">
@csrf @method('PUT')
<input type="text" name="name" value="{{ $bundle->name }}" class="px-3 py-1.5 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 border rounded-md text-gray-900 dark:text-gray-200 text-sm focus:ring-2 focus:ring-cyan-500">
<button type="submit" class="text-emerald-600 hover:text-emerald-800 text-sm font-medium">{{ __('Save') }}</button>
<button type="button" @click="editing = false" class="text-gray-400 hover:text-gray-600 text-sm">{{ __('Cancel') }}</button>
</form>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-4">
<button type="button" @click="editing = !editing" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">{{ __('Rename') }}</button>
<a href="{{ route('admin.app.ui-bundles.content', $bundle) }}" class="text-cyan-600 hover:text-cyan-800 font-medium">{{ __('Content Settings') }}</a>
<button type="button"
@click="deleteFormAction = '{{ route('admin.app.ui-bundles.destroy', $bundle) }}'; isDeleteConfirmOpen = true"
class="text-red-600 hover:text-red-800">{{ __('Delete') }}</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="3" class="px-6 py-4 text-center text-gray-600 dark:text-gray-400">{{ __('No data') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $bundles->links() }}
</div>
{{-- 新增組合 Modal --}}
<div x-show="showCreate" x-cloak class="fixed inset-0 z-50 overflow-y-auto" @keydown.escape.window="showCreate = false">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreate = false"></div>
<div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-xl transform transition-all sm:max-w-md sm:w-full mx-4 text-left">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-gray-900 dark:text-gray-200 text-lg font-semibold">{{ __('Create') }} {{ __('UI Bundles') }}</h3>
</div>
<form action="{{ route('admin.app.ui-bundles.store') }}" method="POST">
@csrf
<div class="px-6 py-4 space-y-4">
<div>
<label class="text-gray-600 dark:text-gray-400 text-sm block mb-1">{{ __('Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="name" required class="w-full px-3 py-2 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 border rounded-md text-gray-900 dark:text-gray-200 focus:ring-2 focus:ring-cyan-500 focus:border-transparent">
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
<button type="button" @click="showCreate = false" class="btn-luxury-secondary">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary">{{ __('Create') }}</button>
</div>
</form>
</div>
</div>
</div>
<x-delete-confirm-modal :message="__('Are you sure you want to delete this item? This action cannot be undone.')" />
</div>
@endsection

View File

@ -0,0 +1,98 @@
@extends('layouts.admin')
@section('content')
<div class="px-6 py-8" x-data="{ showCreate: false, isDeleteConfirmOpen: false, deleteFormAction: '' }">
<x-app-tabs active="elements">
<button type="button" @click="showCreate = true" class="btn-luxury-primary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
{{ __('Create') }}
</button>
</x-app-tabs>
<x-app-list-controls :route="route('admin.app.ui-elements')" :perPage="$perPage" :search="$search" :placeholder="__('Search name or category...')" />
<div class="overflow-x-auto">
<table class="min-w-full bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Preview') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Category') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Created At') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y border-gray-200 dark:border-gray-700">
@forelse($elements as $element)
<tr>
<td class="px-6 py-4">
@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-6 py-4 text-gray-900 dark:text-gray-200">{{ $element->name }}</td>
<td class="px-6 py-4">
<span class="px-2 py-1 rounded-full bg-cyan-100 dark:bg-cyan-500/10 text-cyan-700 dark:text-cyan-400 text-xs font-semibold">{{ $element->part_label }}</span>
</td>
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm">{{ $element->created_at?->format('Y-m-d H:i') }}</td>
<td class="px-6 py-4">
<button type="button"
@click="deleteFormAction = '{{ route('admin.app.ui-elements.destroy', $element) }}'; isDeleteConfirmOpen = true"
class="text-red-600 hover:text-red-800">{{ __('Delete') }}</button>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-6 py-4 text-center text-gray-600 dark:text-gray-400">{{ __('No data') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $elements->links() }}
</div>
{{-- 新增 UI 元素 Modal --}}
<div x-show="showCreate" x-cloak class="fixed inset-0 z-50 overflow-y-auto" @keydown.escape.window="showCreate = false">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreate = false"></div>
<div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-xl transform transition-all sm:max-w-lg sm:w-full mx-4 text-left">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-gray-900 dark:text-gray-200 text-lg font-semibold">{{ __('Create') }} {{ __('UI Elements') }}</h3>
</div>
<form action="{{ route('admin.app.ui-elements.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="px-6 py-4 space-y-4">
<div>
<label class="text-gray-600 dark:text-gray-400 text-sm block mb-1">{{ __('Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="name" required class="w-full px-3 py-2 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 border rounded-md text-gray-900 dark:text-gray-200 focus:ring-2 focus:ring-cyan-500 focus:border-transparent">
</div>
<div>
<label class="text-gray-600 dark:text-gray-400 text-sm block mb-1">{{ __('Category') }} <span class="text-rose-500">*</span></label>
<select name="part_key" required class="w-full px-3 py-2 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 border rounded-md text-gray-900 dark:text-gray-200 focus:ring-2 focus:ring-cyan-500">
@foreach($parts as $key => $part)
<option value="{{ $key }}">{{ $part['label'] }}{{ ($part['width'] ?? null) ? ' — '.$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="text-gray-600 dark:text-gray-400 text-sm block 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-cyan-600 file:text-white hover:file:bg-cyan-700">
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
<button type="button" @click="showCreate = false" class="btn-luxury-secondary">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary">{{ __('Create') }}</button>
</div>
</form>
</div>
</div>
</div>
<x-delete-confirm-modal :message="__('Are you sure you want to delete this item? This action cannot be undone.')" />
</div>
@endsection

View File

@ -0,0 +1,54 @@
@extends('layouts.admin')
@section('content')
<div class="px-6 py-8">
<x-app-tabs active="machines" />
<x-app-list-controls :route="route('admin.app.ui-machines')" :perPage="$perPage" :search="$search" :placeholder="__('Search machine or serial...')" />
<div class="overflow-x-auto">
<table class="min-w-full bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Machine') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Current Bundle') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">{{ __('Bind UI Bundle') }}</th>
</tr>
</thead>
<tbody class="divide-y border-gray-200 dark:border-gray-700">
@forelse($machines as $machine)
@php
$currentId = data_get($machine->settings, 'ui_bundle_id');
$currentName = $currentId ? ($bundleNames[$currentId] ?? __('(deleted)')) : '—';
@endphp
<tr>
<td class="px-6 py-4">
<div class="font-medium text-gray-900 dark:text-gray-200">{{ $machine->name }}</div>
<div class="text-xs text-gray-400">{{ $machine->serial_no }}</div>
</td>
<td class="px-6 py-4 text-gray-900 dark:text-gray-200">{{ $currentName }}</td>
<td class="px-6 py-4">
<form action="{{ route('admin.app.ui-machines.update', $machine) }}" method="POST" class="flex gap-2 items-center max-w-md">
@csrf @method('PUT')
<select name="ui_bundle_id" class="flex-1 px-3 py-2 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 border rounded-md text-gray-900 dark:text-gray-200 text-sm focus:ring-2 focus:ring-cyan-500">
<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="btn-luxury-primary">{{ __('Save') }}</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="3" class="px-6 py-4 text-center text-gray-600 dark:text-gray-400">{{ __('No data') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $machines->links() }}
</div>
</div>
@endsection

View File

@ -46,6 +46,11 @@
'Ambient Temperature Upper Limit' => __('Ambient Temperature Upper Limit'), 'Ambient Temperature Upper Limit' => __('Ambient Temperature Upper Limit'),
'Remote Change' => __('Remote Change'), 'Remote Change' => __('Remote Change'),
'Remote Dispense' => __('Remote Dispense'), 'Remote Dispense' => __('Remote Dispense'),
'Query Jammed Slots' => __('Query Jammed Slots'),
'Clear Jammed Slots' => __('Clear Jammed Slots'),
'No jammed slots' => __('No jammed slots'),
'Jammed slots:' => __('Jammed slots:'),
'Belt self-test failed:' => __('Belt self-test failed:'),
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'), 'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
'Sync Ads' => __('Sync Ads'), 'Sync Ads' => __('Sync Ads'),
'Sync Products' => __('Sync Products'), 'Sync Products' => __('Sync Products'),
@ -456,6 +461,8 @@
'ambient_temp_limit': this.translations['Ambient Temperature Upper Limit'], 'ambient_temp_limit': this.translations['Ambient Temperature Upper Limit'],
'change': this.translations['Remote Change'], 'change': this.translations['Remote Change'],
'dispense': this.translations['Remote Dispense'], 'dispense': this.translations['Remote Dispense'],
'query_jam': this.translations['Query Jammed Slots'],
'clear_jam': this.translations['Clear Jammed Slots'],
'reload_stock': this.translations['Adjust Stock & Expiry'], 'reload_stock': this.translations['Adjust Stock & Expiry'],
'update_ads': this.translations['Sync Ads'], 'update_ads': this.translations['Sync Ads'],
'update_products': this.translations['Sync Products'], 'update_products': this.translations['Sync Products'],
@ -483,6 +490,21 @@
translateNote(note) { translateNote(note) {
if (!note) return ''; if (!note) return '';
// query_jam 的 ACK message 是 JSON{"jam":[2,5],"belt":[]}(機台 JamSlotManager.toJson
// 這裡渲染成可讀文字;非此格式則走原本的字典/原樣顯示。
if (note.startsWith('{') && note.includes('"jam"')) {
try {
const d = JSON.parse(note);
const parts = [];
parts.push(d.jam && d.jam.length
? this.translations['Jammed slots:'] + ' ' + d.jam.join('、')
: this.translations['No jammed slots']);
if (d.belt && d.belt.length) {
parts.push(this.translations['Belt self-test failed:'] + ' ' + d.belt.join('、'));
}
return parts.join(' / ');
} catch (e) { /* 解析失敗就原樣顯示 */ }
}
const translations = { const translations = {
'Superseded by new adjustment': this.translations['Superseded by new adjustment'], 'Superseded by new adjustment': this.translations['Superseded by new adjustment'],
'Superseded by new command': this.translations['Superseded by new command'], 'Superseded by new command': this.translations['Superseded by new command'],
@ -703,6 +725,44 @@
</div> </div>
</button> </button>
<!-- Query Jammed Slots (下位機 CMD 0x32 0x00) -->
<button @click="sendCommand('query_jam')"
class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div
class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 dark:text-cyan-400 group-hover:scale-110 transition-transform duration-500 border border-cyan-500/20 dark:border-cyan-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<div class="text-left">
<div
class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
{{ __('Query Jammed Slots') }}</div>
</div>
</button>
<!-- Clear Jammed Slots (下位機 CMD 0x32 0x01;一次清全部) -->
<button @click="sendCommand('clear_jam')"
class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-amber-500/50 dark:hover:border-amber-400/60 hover:bg-amber-500/5 dark:hover:bg-amber-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div
class="w-12 h-12 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 dark:text-amber-400 group-hover:scale-110 transition-transform duration-500 border border-amber-500/20 dark:border-amber-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div class="text-left">
<div
class="text-sm font-black text-slate-800 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">
{{ __('Clear Jammed Slots') }}</div>
</div>
</button>
<!-- Remote Settlement --> <!-- Remote Settlement -->
<button @click="sendCommand('checkout')" <button @click="sendCommand('checkout')"
class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-emerald-500/50 dark:hover:border-emerald-400/60 hover:bg-emerald-500/5 dark:hover:bg-emerald-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm"> class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-emerald-500/50 dark:hover:border-emerald-400/60 hover:bg-emerald-500/5 dark:hover:bg-emerald-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">

View File

@ -0,0 +1,29 @@
{{-- APP UI元素設定 列表控制列:每頁筆數下拉(左)+ 搜尋欄/重置(右)。一般 GET 表單(非 AJAX
用法:<x-app-list-controls :route="route('admin.app.ui-elements')" :perPage="$perPage" :search="$search" :placeholder="__('...')" /> --}}
@props([
'route' => '',
'perPage' => 10,
'search' => '',
'placeholder' => '',
])
<form method="GET" action="{{ $route }}" class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>{{ __('Show') }}</span>
<select name="per_page" onchange="this.form.submit()" class="luxury-input py-1.5 pl-3 pr-8 text-sm w-auto cursor-pointer">
@foreach([10, 25, 50, 100] as $n)
<option value="{{ $n }}" {{ (int)$perPage === $n ? 'selected' : '' }}>{{ $n }}</option>
@endforeach
</select>
<span>{{ __('entries') }}</span>
</div>
<div class="flex items-center gap-2">
<input type="text" name="search" value="{{ $search }}" placeholder="{{ $placeholder ?: __('Search...') }}" class="luxury-input py-2 px-3 text-sm sm:w-72">
<button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</button>
<a href="{{ $route }}" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</a>
</div>
</form>

View File

@ -0,0 +1,43 @@
{{-- APP UI元素設定 共用標題 + 頁籤列(+ 選填右側 Action slot與頁籤同列靠左
用法:<x-app-tabs active="elements"><button class="btn-luxury-primary">新增</button></x-app-tabs> --}}
@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
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium mb-4">{{ __('APP UI Elements') }}</h3>
<div class="flex flex-wrap items-center gap-3 mb-6">
<div class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl border border-slate-200/50 dark:border-slate-800/50 overflow-x-auto w-fit max-w-full [&::-webkit-scrollbar]:hidden">
@foreach($tabs as $key => $tab)
<a href="{{ route($tab['route']) }}"
class="px-6 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300 whitespace-nowrap
{{ $active === $key
? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10'
: 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
{{ $tab['label'] }}
</a>
@endforeach
</div>
@if($slot->isNotEmpty())
{{ $slot }}
@endif
</div>
@if(session('success'))
<div class="mb-4 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200/60 dark:border-emerald-800/50 px-4 py-3 text-sm font-medium">
{{ session('success') }}
</div>
@endif
@if($errors->any())
<div class="mb-4 rounded-xl bg-rose-50 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300 border border-rose-200/60 dark:border-rose-800/50 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

@ -97,9 +97,8 @@
@endcan @endcan
{{--
@can('menu.app') @can('menu.app')
5. APP管理 {{-- 5. APP管理 --}}
<li x-data="{ open: localStorage.getItem('menu_app') === 'true' || {{ request()->routeIs('admin.app.*') ? 'true' : 'false' }} }"> <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"> <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> <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>
@ -110,16 +109,17 @@
</button> </button>
<div x-show="open && !sidebarCollapsed" x-collapse> <div x-show="open && !sidebarCollapsed" x-collapse>
<ul class="luxury-submenu" data-sidebar-sub> <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-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') }}"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg><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.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.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') }}"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg><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.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.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') }}"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg><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.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> <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') }}"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg><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.timer') ? '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.timer') }}"><span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Timer') }}</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') }}"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01m-.01 4h.01" /></svg><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') }}"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" /></svg><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>
<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.timer') ? '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.timer') }}"><svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Timer') }}</span></a></li>
</ul> </ul>
</div> </div>
</li> </li>
@endcan @endcan
--}}
@can('menu.warehouses') @can('menu.warehouses')

View File

@ -87,8 +87,25 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
}); });
// 4. APP管理 // 4. APP管理
Route::prefix('app')->name('app.')->group(function () { Route::prefix('app')->name('app.')->middleware('can:menu.app')->group(function () {
Route::get('/ui-elements', [App\Http\Controllers\Admin\AppConfigController::class, 'uiElements'])->name('ui-elements'); // 頁籤一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('/helper', [App\Http\Controllers\Admin\AppConfigController::class, 'helper'])->name('helper');
Route::get('/questionnaire', [App\Http\Controllers\Admin\AppConfigController::class, 'questionnaire'])->name('questionnaire'); Route::get('/questionnaire', [App\Http\Controllers\Admin\AppConfigController::class, 'questionnaire'])->name('questionnaire');
Route::get('/games', [App\Http\Controllers\Admin\AppConfigController::class, 'games'])->name('games'); Route::get('/games', [App\Http\Controllers\Admin\AppConfigController::class, 'games'])->name('games');