[FEAT] 新增 OTA APK 版本管理與更新部署功能

1. 新增 `ApkVersion` Model 與對應的資料表遷移檔 (`create_apk_versions_table`),用以記錄 APK 版本、代碼、風味、更新說明、下載連結與更新模式等欄位。
2. 實作 `ApkVersionController` 控制器,提供 APK 版本之 CRUD(列表、建立、刪除)以及 OTA 推送功能。
3. 移除了 `SpecialPermissionController` 中舊有的 `apkVersions` 占位路由方法,並於 `routes/web.php` 的基本設定 (`basic-settings`) 下新增完整的 APK 版本管理與推送路由。
4. 於 `RoleSeeder.php` 角色種子資料中為 Super Admin 與系統管理員新增 `menu.basic.apk-versions` 權限。
5. 於 `sidebar-menu.blade.php` 基本設定選單中,加入「APK版本管理」之子選單選項,並以對應權限進行防護。
6. 實作 APK 版本列表與上傳頁面 Blade 視圖,採用 Preline UI 與 Alpine.js 提供極簡奢華風的 UI 互動,包含檔案上傳拖曳與 OTA 設備選擇部署彈窗。
7. 更新繁體中文語系檔 `zh_TW.json`,對齊所有新增之 OTA 韌體與版本管理翻譯字詞。
This commit is contained in:
sky121113 2026-05-22 16:51:21 +08:00
parent 1d54aefcb5
commit 7dee52a00a
10 changed files with 932 additions and 12 deletions

View File

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Admin\BasicSettings;
use App\Http\Controllers\Admin\AdminController;
use App\Models\Machine\ApkVersion;
use App\Models\Machine\Machine;
use App\Services\Machine\MqttService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class ApkVersionController extends AdminController
{
public function index(): View
{
$versions = ApkVersion::orderBy('version_code', 'desc')->paginate(10);
// 用於 OTA 下發時讓使用者選擇的機台列表
$machines = Machine::all();
return view('admin.basic-settings.apk-versions.index', compact('versions', 'machines'));
}
public function create(): View
{
return view('admin.basic-settings.apk-versions.create');
}
public function store(Request $request)
{
$request->validate([
'version_name' => 'required|string|max:100',
'version_code' => 'required|integer|unique:apk_versions,version_code',
'flavor' => 'required|string|max:100',
'apk_file' => 'required_without:download_url|nullable|file|max:102400', // 上傳限制 100MB
'download_url' => 'required_without:apk_file|nullable|url|max:500',
'release_notes' => 'nullable|string',
'is_forced' => 'nullable|boolean',
]);
$filePath = null;
if ($request->hasFile('apk_file')) {
$filePath = $request->file('apk_file')->store('apks', 'public');
}
ApkVersion::create([
'version_name' => $request->version_name,
'version_code' => $request->version_code,
'flavor' => $request->flavor,
'file_path' => $filePath,
'download_url' => $request->download_url,
'release_notes' => $request->release_notes,
'is_forced' => $request->has('is_forced') || (bool) $request->input('is_forced', false),
]);
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', __('APK version uploaded successfully.'));
}
public function destroy(ApkVersion $apkVersion)
{
if ($apkVersion->file_path) {
Storage::disk('public')->delete($apkVersion->file_path);
}
$apkVersion->delete();
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', __('APK version deleted successfully.'));
}
/**
* 推送 OTA 更新指令給指定機台
*/
public function push(Request $request, ApkVersion $apkVersion, MqttService $mqttService)
{
$request->validate([
'machine_ids' => 'required|array',
'machine_ids.*' => 'exists:machines,id',
]);
$machines = Machine::whereIn('id', $request->machine_ids)->get();
$successCount = 0;
foreach ($machines as $machine) {
// 1. 先建立 RemoteCommand 紀錄,以取得自增 ID 作為 command_id與系統慣例一致
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id(),
'command_type' => 'update_app',
'status' => 'pending',
'payload' => [
'url' => $apkVersion->url,
'version' => $apkVersion->version_name,
'version_code' => $apkVersion->version_code,
'is_forced' => $apkVersion->is_forced,
],
]);
// 2. 推播 MQTTcommand_id 使用資料庫自增流水號
$pushed = $mqttService->pushCommand(
$machine->serial_no,
'update_app',
$command->payload,
(string) $command->id
);
if ($pushed) {
$successCount++;
} else {
// 推播失敗,將紀錄標記為失敗
$command->update(['status' => 'failed']);
}
}
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', sprintf(__('OTA command sent to %d devices successfully.'), $successCount));
}
}

View File

@ -15,13 +15,4 @@ class SpecialPermissionController extends Controller
'description' => '特殊權限庫存清空功能',
]);
}
// APK版本管理
public function apkVersions()
{
return view('admin.placeholder', [
'title' => 'APK版本管理',
'description' => 'APP版本控制與更新',
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ApkVersion extends Model
{
protected $fillable = [
'version_name',
'version_code',
'flavor',
'file_path',
'download_url',
'release_notes',
'is_forced',
];
protected $casts = [
'is_forced' => 'boolean',
];
/**
* 取得最終下載 URL
*/
public function getUrlAttribute(): string
{
return $this->download_url ?: Storage::disk('public')->url($this->file_path);
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('apk_versions', function (Blueprint $table) {
$table->id();
$table->string('version_name'); // 例如 '10_08_6_R'
$table->integer('version_code')->unique(); // 例如 100806用於比對版本新舊必須遞增
$table->string('flavor'); // 機台風味,例如 'S_12_XY_U_Standard_'
$table->string('file_path')->nullable(); // 本地上傳的 APK 儲存路徑
$table->string('download_url')->nullable(); // 外連下載 URL (如果未使用本地上傳)
$table->text('release_notes')->nullable(); // 版本更新說明
$table->boolean('is_forced')->default(false); // 是否強制更新
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('apk_versions');
}
};

View File

@ -64,6 +64,7 @@ class RoleSeeder extends Seeder
'menu.basic.machines',
'menu.basic.payment-configs',
'menu.basic.discord-notifications',
'menu.basic.apk-versions',
'menu.permissions',
'menu.permissions.companies',
'menu.permissions.accounts',
@ -125,6 +126,7 @@ class RoleSeeder extends Seeder
'menu.special-permission.apk-versions',
'menu.special-permission.discord-notifications',
'menu.basic.discord-notifications',
'menu.basic.apk-versions',
]);
}
}

View File

@ -2151,6 +2151,7 @@
"menu.basic.discord-notifications": "Discord通知設定",
"menu.basic.machines": "機台設定",
"menu.basic.payment-configs": "客戶金流設定",
"menu.basic.apk-versions": "APK版本",
"menu.data-config": "資料設定",
"menu.data-config.admin-products": "商品狀態",
"menu.data-config.advertisements": "廣告管理",
@ -2252,5 +2253,51 @@
"visit_gift": "來店禮",
"vs Yesterday": "較昨日",
"warehouses": "倉庫管理",
"warning": "警告"
"warning": "警告",
"OTA firmware update and version control": "OTA 韌體更新與版本控制",
"Upload New APK": "上傳新 APK",
"No versions found": "未找到任何版本",
"Are you sure you want to delete this APK version? This action will permanently remove the record and file.": "您確定要刪除此 APK 版本嗎?此操作將永久移除該紀錄與檔案。",
"OTA Update Deployment": "OTA 更新部署",
"Deploy version": "部署版本",
"Search machine name, S/N, or location...": "搜尋機台名稱、序號 (S/N) 或位置...",
"Online Only": "僅限線上機台",
"Machine Detail": "機台詳情",
"App Version": "App 版本",
"No machines match your criteria": "沒有符合條件的機台",
"Devices Selected": "個設備已選擇",
"Deploy Update": "部署更新",
"Register a new firmware version for OTA deployment": "註冊新韌體版本以進行 OTA 部署",
"Save Version": "儲存版本",
"Please enter version name": "請輸入版本名稱",
"Version code must be a positive integer": "版本代碼必須是正整數",
"Please select an APK file to upload": "請選擇要上傳的 APK 檔案",
"Please provide a download URL": "請提供下載連結",
"Version Information": "版本資訊",
"Firmware identification details": "韌體識別詳情",
"Version Name": "版本名稱",
"Format: Major_Minor_Patch_R": "格式Major_Minor_Patch_R",
"Version Code": "版本代碼",
"Must be strictly greater than previous version": "必須嚴格大於上一個版本代碼",
"Machine Flavor": "機台風味",
"Select flavor...": "選擇風味...",
"Target hardware flavor for this APK": "此 APK 的目標硬體風味",
"Release Notes": "更新說明",
"Describe the changes in this version...": "描述此版本的變更...",
"APK Source": "APK 來源",
"Upload file or provide URL": "上傳檔案或提供連結",
"File Upload": "檔案上傳",
"External URL": "外連 URL",
"Drag & drop your APK here": "拖曳 APK 檔案至此",
"Maximum file size: 100MB": "檔案大小上限100MB",
"Download URL": "下載連結",
"Provide a publicly accessible direct download link": "提供公開且可直接下載的連結",
"Update Mode": "更新模式",
"Control deployment behavior": "控制部署行為",
"Device will install immediately without user confirmation": "設備將立即安裝,無需使用者確認",
"Important Notice": "重要通知",
"Version Code must be strictly greater than the previous release, otherwise the OTA silent installation on the device will fail with INSTALL_FAILED_ALREADY_EXISTS.": "版本代碼必須嚴格大於先前的版本,否則機台端的 OTA 靜默安裝將會失敗並出現 INSTALL_FAILED_ALREADY_EXISTS 錯誤。",
"APK version uploaded successfully.": "APK 版本上傳成功。",
"APK version deleted successfully.": "APK 版本刪除成功。",
"OTA command sent to %d devices successfully.": "OTA 指令已成功發送至 %d 台設備。"
}

View File

@ -0,0 +1,286 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-6 pb-20">
{{-- Header --}}
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div class="flex items-center gap-4">
<a href="{{ route('admin.basic-settings.apk-versions.index') }}" class="p-2.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 text-slate-500 hover:text-cyan-500 transition-all shadow-sm">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/></svg>
</a>
<div>
<h1 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Upload New APK') }}</h1>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Register a new firmware version for OTA deployment') }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<button type="submit" form="create-apk-form" class="btn-luxury-primary px-8 flex items-center gap-2">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
<span>{{ __('Save Version') }}</span>
</button>
</div>
</div>
<form id="create-apk-form"
x-data="{
uploadMode: 'file',
fileName: '',
isDragging: false,
isForced: false,
submitForm() {
const form = this.$el;
const versionName = form.querySelector('[name=version_name]').value.trim();
const versionCode = form.querySelector('[name=version_code]').value.trim();
if (!versionName) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Please enter version name') }}', type: 'error' }
}));
return;
}
if (!versionCode || isNaN(versionCode) || parseInt(versionCode) <= 0) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Version code must be a positive integer') }}', type: 'error' }
}));
return;
}
if (this.uploadMode === 'file') {
const fileInput = form.querySelector('[name=apk_file]');
if (!fileInput.files.length) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Please select an APK file to upload') }}', type: 'error' }
}));
return;
}
} else {
const urlInput = form.querySelector('[name=download_url]').value.trim();
if (!urlInput) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Please provide a download URL') }}', type: 'error' }
}));
return;
}
}
form.submit();
},
handleFileDrop(event) {
this.isDragging = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
const fileInput = this.$refs.apkFileInput;
const dt = new DataTransfer();
dt.items.add(files[0]);
fileInput.files = dt.files;
this.fileName = files[0].name;
}
},
handleFileSelect(event) {
if (event.target.files.length > 0) {
this.fileName = event.target.files[0].name;
}
}
}"
@submit.prevent="submitForm"
action="{{ route('admin.basic-settings.apk-versions.store') }}"
method="POST"
enctype="multipart/form-data"
class="space-y-6"
novalidate>
@csrf
{{-- 錯誤提示 --}}
@if ($errors->any())
<div class="p-5 bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 rounded-2xl flex items-start gap-4 animate-luxury-in font-bold">
<div class="w-8 h-8 bg-rose-500/20 rounded-xl flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</div>
<div>
<p class="text-sm tracking-wide">{{ __('Please check the following errors:') }}</p>
<ul class="mt-1 text-xs list-disc list-inside opacity-80">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
{{-- 左欄:版本基本資訊 --}}
<div class="lg:col-span-7 space-y-6">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in group/card">
<div class="flex items-center gap-5 mb-8">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500/10 to-teal-500/10 flex items-center justify-center text-cyan-500 border border-cyan-500/20 shadow-lg shadow-cyan-500/5 group-hover/card:scale-110 transition-transform duration-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/></svg>
</div>
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight font-display uppercase">{{ __('Version Information') }}</h3>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ __('Firmware identification details') }}</p>
</div>
</div>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{{-- 版本名稱 --}}
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Version Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="version_name" value="{{ old('version_name') }}" required class="luxury-input w-full" placeholder="{{ __('e.g., 10_08_6_R') }}">
<p class="mt-1.5 text-[10px] font-bold text-slate-400/70 dark:text-slate-500/70 tracking-wide">{{ __('Format: Major_Minor_Patch_R') }}</p>
</div>
{{-- 版本代碼 --}}
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Version Code') }} <span class="text-rose-500">*</span></label>
<input type="number" name="version_code" value="{{ old('version_code') }}" required min="1" class="luxury-input w-full" placeholder="{{ __('e.g., 100806') }}">
<p class="mt-1.5 text-[10px] font-bold text-slate-400/70 dark:text-slate-500/70 tracking-wide">{{ __('Must be strictly greater than previous version') }}</p>
</div>
</div>
{{-- 機台風味 --}}
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Machine Flavor') }} <span class="text-rose-500">*</span></label>
<select name="flavor" required class="luxury-input w-full">
<option value="" disabled {{ old('flavor') ? '' : 'selected' }}>{{ __('Select flavor...') }}</option>
<option value="S_12_XY_U_Standard_" {{ old('flavor') == 'S_12_XY_U_Standard_' ? 'selected' : '' }}>S_12_XY_U_Standard_ (S號 / Android 12 / XY主板)</option>
</select>
<p class="mt-1.5 text-[10px] font-bold text-slate-400/70 dark:text-slate-500/70 tracking-wide">{{ __('Target hardware flavor for this APK') }}</p>
</div>
{{-- 更新說明 --}}
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Release Notes') }}</label>
<textarea name="release_notes" rows="4" class="luxury-input w-full resize-none" placeholder="{{ __('Describe the changes in this version...') }}">{{ old('release_notes') }}</textarea>
</div>
</div>
</div>
</div>
{{-- 右欄:檔案上傳與更新模式 --}}
<div class="lg:col-span-5 space-y-6">
{{-- 上傳模式卡片 --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in group/card" style="animation-delay: 50ms">
<div class="flex items-center gap-5 mb-8">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500/10 to-indigo-500/10 flex items-center justify-center text-violet-500 border border-violet-500/20 shadow-lg shadow-violet-500/5 group-hover/card:scale-110 transition-transform duration-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>
</div>
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight font-display uppercase">{{ __('APK Source') }}</h3>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ __('Upload file or provide URL') }}</p>
</div>
</div>
{{-- 上傳模式切換 --}}
<div class="flex rounded-xl bg-slate-100 dark:bg-slate-800/80 p-1 mb-6">
<button type="button"
@click="uploadMode = 'file'"
:class="uploadMode === 'file' ? 'bg-white dark:bg-slate-700 text-cyan-600 dark:text-cyan-400 shadow-sm' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'"
class="flex-1 py-2.5 text-xs font-black uppercase tracking-widest rounded-lg transition-all duration-300 flex items-center justify-center gap-2">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>
{{ __('File Upload') }}
</button>
<button type="button"
@click="uploadMode = 'url'"
:class="uploadMode === 'url' ? 'bg-white dark:bg-slate-700 text-cyan-600 dark:text-cyan-400 shadow-sm' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'"
class="flex-1 py-2.5 text-xs font-black uppercase tracking-widest rounded-lg transition-all duration-300 flex items-center justify-center gap-2">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-1.637a4.5 4.5 0 00-1.242-7.244l-4.5-4.5a4.5 4.5 0 00-6.364 6.364L4.343 8.25"/></svg>
{{ __('External URL') }}
</button>
</div>
{{-- 檔案上傳區域 --}}
<div x-show="uploadMode === 'file'" x-transition>
<div class="relative border-2 border-dashed rounded-2xl p-8 text-center transition-all duration-300 cursor-pointer"
:class="isDragging ? 'border-cyan-500 bg-cyan-500/5 dark:bg-cyan-500/10 scale-[1.02]' : 'border-slate-200 dark:border-slate-700 hover:border-cyan-400 dark:hover:border-cyan-600 hover:bg-slate-50/50 dark:hover:bg-slate-800/50'"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop($event)"
@click="$refs.apkFileInput.click()">
<input type="file"
name="apk_file"
x-ref="apkFileInput"
accept=".apk"
class="hidden"
@change="handleFileSelect($event)">
<div class="flex flex-col items-center gap-4">
<div class="w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-300"
:class="isDragging ? 'bg-cyan-500 text-white scale-110' : fileName ? 'bg-emerald-500 text-white' : 'bg-slate-100 dark:bg-slate-800 text-slate-400'">
<svg x-show="!fileName" class="w-8 h-8 stroke-[1.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5h10.5a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0017.25 4.5H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z"/></svg>
<svg x-show="fileName" class="w-8 h-8 stroke-[1.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div>
<p x-show="!fileName" class="text-sm font-extrabold text-slate-600 dark:text-slate-300">
{{ __('Drag & drop your APK here') }}
</p>
<p x-show="fileName" class="text-sm font-extrabold text-emerald-600 dark:text-emerald-400" x-text="fileName"></p>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-2">
{{ __('Maximum file size: 100MB') }} · .apk
</p>
</div>
</div>
</div>
</div>
{{-- 外連 URL 輸入區域 --}}
<div x-show="uploadMode === 'url'" x-transition>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Download URL') }}</label>
<input type="url" name="download_url" value="{{ old('download_url') }}" class="luxury-input w-full" placeholder="https://example.com/release/app-v10.8.6.apk">
<p class="mt-1.5 text-[10px] font-bold text-slate-400/70 dark:text-slate-500/70 tracking-wide">{{ __('Provide a publicly accessible direct download link') }}</p>
</div>
</div>
{{-- 更新模式卡片 --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in group/card" style="animation-delay: 100ms">
<div class="flex items-center gap-5 mb-8">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500/10 to-orange-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20 shadow-lg shadow-amber-500/5 group-hover/card:scale-110 transition-transform duration-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
</div>
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight font-display uppercase">{{ __('Update Mode') }}</h3>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ __('Control deployment behavior') }}</p>
</div>
</div>
{{-- 強制更新開關 --}}
<div class="flex items-center justify-between p-5 rounded-2xl bg-slate-50/80 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800/80 transition-all duration-300"
:class="isForced ? 'border-rose-200 dark:border-rose-900/30 bg-rose-50/50 dark:bg-rose-900/10' : ''">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300"
:class="isForced ? 'bg-rose-500/10 text-rose-500' : 'bg-slate-100 dark:bg-slate-800 text-slate-400'">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"/></svg>
</div>
<div>
<p class="text-sm font-extrabold transition-colors"
:class="isForced ? 'text-rose-600 dark:text-rose-400' : 'text-slate-700 dark:text-slate-200'">{{ __('Forced Update') }}</p>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 tracking-wide mt-0.5">{{ __('Device will install immediately without user confirmation') }}</p>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer shrink-0">
<input type="hidden" name="is_forced" value="0">
<input type="checkbox" name="is_forced" value="1" x-model="isForced" class="sr-only peer" {{ old('is_forced') ? 'checked' : '' }}>
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-slate-600 peer-checked:bg-rose-500"></div>
</label>
</div>
{{-- 注意事項 --}}
<div class="mt-6 p-4 rounded-xl bg-amber-500/5 border border-amber-500/10">
<div class="flex items-start gap-3">
<svg class="w-4 h-4 stroke-[2.5] text-amber-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
<div class="text-[10px] font-bold text-amber-700/80 dark:text-amber-400/80 tracking-wide leading-relaxed">
<p class="font-black uppercase tracking-widest mb-1">{{ __('Important Notice') }}</p>
<p>{{ __('Version Code must be strictly greater than the previous release, otherwise the OTA silent installation on the device will fail with INSTALL_FAILED_ALREADY_EXISTS.') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,401 @@
@extends('layouts.admin')
@php
$machinesJson = $machines->map(function($m) {
return [
'id' => $m->id,
'name' => $m->name,
'serial_no' => $m->serial_no,
'status' => $m->status, // online / offline
'firmware_version' => $m->firmware_version ?? '', // 設備目前的版本或風味
'location' => $m->location ?? '',
];
});
@endphp
@section('content')
<script>
window.apkVersionApp = function() {
return {
isDeleteConfirmOpen: false,
deleteFormAction: '',
confirmDelete(action) {
this.deleteFormAction = action;
this.isDeleteConfirmOpen = true;
},
// OTA 派發 Modal 相關狀態
isPushModalOpen: false,
pushActionUrl: '',
selectedVersionName: '',
selectedFlavor: '',
searchMachineQuery: '',
onlyOnlineMachines: false,
selectedMachineIds: [],
machines: @json($machinesJson),
openPushModal(versionName, flavor, actionUrl) {
this.selectedVersionName = versionName;
this.selectedFlavor = flavor;
this.pushActionUrl = actionUrl;
this.selectedMachineIds = [];
this.searchMachineQuery = '';
this.onlyOnlineMachines = false;
this.isPushModalOpen = true;
},
get filteredMachines() {
return this.machines.filter(m => {
const matchesSearch = m.name.toLowerCase().includes(this.searchMachineQuery.toLowerCase()) ||
m.serial_no.toLowerCase().includes(this.searchMachineQuery.toLowerCase()) ||
m.location.toLowerCase().includes(this.searchMachineQuery.toLowerCase());
const matchesOnline = !this.onlyOnlineMachines || m.status === 'online';
// 可以根據 flavor 進行模糊匹配或若機台 firmware_version 包含特定字串
const matchesFlavor = !this.selectedFlavor ||
m.firmware_version.toLowerCase().includes(this.selectedFlavor.toLowerCase()) ||
m.name.toLowerCase().includes(this.selectedFlavor.toLowerCase());
return matchesSearch && matchesOnline;
});
},
toggleAllMachines(checked) {
if (checked) {
this.selectedMachineIds = this.filteredMachines.map(m => m.id);
} else {
this.selectedMachineIds = [];
}
},
isLoadingTable: false,
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) throw new Error('Network response was not ok');
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
history.pushState(null, '', url);
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit();
}
}
} catch (error) {
console.error('Fetch error:', error);
window.location.href = url;
} finally {
this.isLoadingTable = false;
}
}
};
};
</script>
<div class="space-y-6 pb-20" x-data="apkVersionApp()">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('APK Versions') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('OTA firmware update and version control') }}</p>
</div>
<div>
<a href="{{ route('admin.basic-settings.apk-versions.create') }}" class="btn-luxury-primary flex items-center gap-2">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<span>{{ __('Upload New APK') }}</span>
</a>
</div>
</div>
<!-- Content Container with Loading State -->
<div id="ajax-content-container"
class="relative transition-all duration-500 min-h-[400px]"
:class="isLoadingTable ? 'opacity-40 blur-[2px] pointer-events-none' : 'opacity-100 blur-0'"
@click="if ($event.target.closest('a') && $event.target.closest('a').href && ($event.target.closest('a').href.includes('page=') || $event.target.closest('a').href.includes('per_page='))) { $event.preventDefault(); fetchPage($event.target.closest('a').href); }">
<!-- Loading Spinner Overlay -->
<div x-show="isLoadingTable"
class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] rounded-3xl" x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
</div>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">{{ __('Loading Data') }}...</p>
</div>
@if(session('success'))
<div class="p-4 mb-6 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 font-extrabold flex items-center gap-3">
<svg class="w-5 h-5 shrink-0 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>{{ session('success') }}</span>
</div>
@endif
<!-- Main Card -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Version Info') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Flavor') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Update Mode') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Release Notes') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($versions as $version)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 font-extrabold text-slate-800 dark:text-slate-100">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300">
<svg class="w-5 h-5 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/></svg>
</div>
<div>
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors whitespace-nowrap">
{{ $version->version_name }}
</div>
<div class="text-xs font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest mt-0.5">
Code: {{ $version->version_code }}
</div>
</div>
</div>
</td>
<td class="px-6 py-6">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
{{ $version->flavor }}
</span>
</td>
<td class="px-6 py-6">
@if($version->is_forced)
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-rose-100 dark:border-rose-900/30 bg-rose-50 dark:bg-rose-900/20 text-rose-600 dark:text-rose-400 tracking-widest uppercase">
{{ __('Forced Update') }}
</span>
@else
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 tracking-widest uppercase">
{{ __('Optional Update') }}
</span>
@endif
</td>
<td class="px-6 py-6 max-w-xs truncate">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 leading-relaxed">
{{ $version->release_notes ?: '-' }}
</span>
</td>
<td class="px-6 py-6 text-right space-x-2 whitespace-nowrap">
<!-- OTA 派發按鈕 -->
<button type="button"
@click="openPushModal('{{ $version->version_name }}', '{{ $version->flavor }}', '{{ route('admin.basic-settings.apk-versions.push', $version) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 border border-transparent hover:border-cyan-500/20 transition-all inline-flex"
title="{{ __('OTA Deploy') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5h10.5a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0017.25 4.5H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z"/></svg>
</button>
<!-- 下載連結 -->
<a href="{{ $version->url }}"
target="_blank"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 border border-transparent hover:border-emerald-500/20 transition-all inline-flex"
title="{{ __('Download APK') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
</a>
<!-- 刪除按鈕 -->
<form action="{{ route('admin.basic-settings.apk-versions.destroy', $version) }}" method="POST" class="inline-block" @submit.prevent="confirmDelete($el.getAttribute('action'))">
@csrf
@method('DELETE')
<button type="submit"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 border border-transparent hover:border-rose-500/20 transition-all"
title="{{ __('Delete') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-20 text-center">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-slate-50 dark:bg-slate-800/50 mb-6 border border-slate-100 dark:border-slate-800 shadow-sm">
<svg class="w-10 h-10 text-slate-300 dark:text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5h10.5a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0017.25 4.5H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z"/></svg>
</div>
<p class="text-base font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('No versions found') }}</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $versions->links('vendor.pagination.luxury') }}
</div>
</div>
</div>
<!-- Global Delete Confirm Modal -->
<x-delete-confirm-modal :message="__('Are you sure you want to delete this APK version? This action will permanently remove the record and file.')" />
<!-- OTA 派發模態彈窗 (Custom Luxury Modal using Alpine.js) -->
<div x-show="isPushModalOpen"
class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" x-cloak>
<!-- 背景遮罩 -->
<div class="fixed inset-0 bg-slate-900/60 dark:bg-slate-950/80 backdrop-blur-sm" @click="isPushModalOpen = false"></div>
<!-- 彈窗主體 -->
<div class="relative w-full max-w-2xl mx-auto my-6 px-4 z-50">
<div class="relative flex flex-col w-full bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 rounded-3xl shadow-2xl outline-none focus:outline-none overflow-hidden animate-luxury-in">
<!-- 彈窗 Header -->
<div class="flex items-center justify-between p-6 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white flex items-center gap-2 font-display">
<span class="p-1.5 rounded-lg bg-cyan-500 text-white leading-none">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5h10.5a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0017.25 4.5H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z"/></svg>
</span>
<span>{{ __('OTA Update Deployment') }}</span>
</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 tracking-wider uppercase mt-1">
{{ __('Deploy version') }}: <span class="text-cyan-500" x-text="selectedVersionName"></span> (<span x-text="selectedFlavor"></span>)
</p>
</div>
<button type="button"
@click="isPushModalOpen = false"
class="p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-white transition-colors">
<svg class="w-6 h-6 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 彈窗內容 -->
<div class="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
<!-- 搜尋與篩選列 -->
<div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
<div class="relative flex-1 group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors stroke-[2.5]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text"
x-model="searchMachineQuery"
placeholder="{{ __('Search machine name, S/N, or location...') }}"
class="luxury-input py-2 pl-12 pr-6 block w-full text-sm">
</div>
<div class="flex items-center gap-2">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="onlyOnlineMachines" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-cyan-500"></div>
<span class="ml-2 text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{ __('Online Only') }}</span>
</label>
</div>
</div>
<!-- 表單與機台選擇清單 -->
<form :action="pushActionUrl" method="POST" id="ota-push-form">
@csrf
<div class="border border-slate-100 dark:border-slate-800/80 rounded-2xl overflow-hidden">
<table class="w-full text-left">
<thead class="bg-slate-50 dark:bg-slate-850/50">
<tr>
<th class="px-6 py-3 w-12 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">
<input type="checkbox"
@change="toggleAllMachines($el.checked)"
:checked="selectedMachineIds.length === filteredMachines.length && filteredMachines.length > 0"
class="rounded border-slate-300 dark:border-slate-700 text-cyan-500 focus:ring-cyan-500 dark:bg-slate-900">
</th>
<th class="px-6 py-3 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Machine Detail') }}</th>
<th class="px-6 py-3 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Status') }}</th>
<th class="px-6 py-3 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('App Version') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800">
<template x-for="machine in filteredMachines" :key="machine.id">
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/20 transition-all duration-200">
<td class="px-6 py-3">
<input type="checkbox"
name="machine_ids[]"
:value="machine.id"
x-model="selectedMachineIds"
class="rounded border-slate-300 dark:border-slate-700 text-cyan-500 focus:ring-cyan-500 dark:bg-slate-900">
</td>
<td class="px-6 py-3">
<div class="text-sm font-extrabold text-slate-800 dark:text-slate-100" x-text="machine.name"></div>
<div class="text-[10px] font-mono text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="'S/N: ' + machine.serial_no"></div>
</td>
<td class="px-6 py-3">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full"
:class="machine.status === 'online' ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600'"></span>
<span class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400"
x-text="machine.status === 'online' ? 'Online' : 'Offline'"></span>
</div>
</td>
<td class="px-6 py-3">
<span class="px-2 py-0.5 rounded text-[10px] font-mono font-bold"
:class="machine.firmware_version.toLowerCase().includes(selectedFlavor.toLowerCase()) && selectedFlavor !== '' ? 'bg-cyan-500/10 text-cyan-500 dark:bg-cyan-500/20 border border-cyan-500/20' : 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400 border border-transparent'"
x-text="machine.firmware_version ?: '-'"></span>
</td>
</tr>
</template>
<tr x-show="filteredMachines.length === 0">
<td colspan="4" class="px-6 py-12 text-center text-slate-400 dark:text-slate-500 font-bold">
{{ __('No machines match your criteria') }}
</td>
</tr>
</tbody>
</table>
</div>
</form>
</div>
<!-- 彈窗 Footer -->
<div class="flex items-center justify-between p-6 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<div class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
<span x-text="selectedMachineIds.length"></span> {{ __('Devices Selected') }}
</div>
<div class="flex items-center gap-3">
<button type="button"
@click="isPushModalOpen = false"
class="px-5 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 text-sm font-extrabold text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all">
{{ __('Cancel') }}
</button>
<button type="submit"
form="ota-push-form"
:disabled="selectedMachineIds.length === 0"
class="px-5 py-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 text-sm font-extrabold shadow-lg shadow-cyan-500/20 transition-all disabled:opacity-55 disabled:pointer-events-none">
{{ __('Deploy Update') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -448,7 +448,7 @@
@endcan
--}}
@if(auth()->user()->isSystemAdmin() || (auth()->user()->isTenant() && (auth()->user()->can('menu.basic.machines') || auth()->user()->can('menu.basic.payment-configs') || auth()->user()->can('menu.basic.discord-notifications'))))
@if(auth()->user()->isSystemAdmin() || (auth()->user()->isTenant() && (auth()->user()->can('menu.basic.machines') || auth()->user()->can('menu.basic.payment-configs') || auth()->user()->can('menu.basic.discord-notifications') || auth()->user()->can('menu.basic.apk-versions'))))
{{-- 14.5. 基本設定 --}}
<li x-data="{ open: localStorage.getItem('menu_basic_settings') === 'true' || {{ request()->routeIs('admin.basic-settings.*') ? 'true' : 'false' }} }">
<button type="button" @click="if(sidebarCollapsed) { sidebarCollapsed = false; open = true; } else { open = !open; } localStorage.setItem('menu_basic_settings', open)" class="luxury-nav-item w-full text-start group">
@ -460,6 +460,12 @@
</button>
<div x-show="open && !sidebarCollapsed" x-collapse>
<ul class="luxury-submenu" data-sidebar-sub>
@can('menu.basic.apk-versions')
<li><a class="flex items-center gap-x-3 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.basic-settings.apk-versions.*') ? '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.basic-settings.apk-versions.index') }}">
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('APK Versions') }}</span>
</a></li>
@endcan
@can('menu.basic.machines')
<li><a class="flex items-center gap-x-3 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.basic-settings.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.basic-settings.machines.index') }}">
<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 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /></svg>

View File

@ -236,11 +236,22 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 13. 特殊權限管理
Route::prefix('special-permission')->name('special-permission.')->group(function () {
Route::get('/clear-stock', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'clearStock'])->name('clear-stock');
Route::get('/apk-versions', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'apkVersions'])->name('apk-versions');
});
// 14. 基本設定
Route::prefix('basic-settings')->name('basic-settings.')->group(function () {
// APK 版本管理 (OTA)
Route::prefix('apk-versions')
->name('apk-versions.')
->middleware('can:menu.basic.apk-versions')
->group(function () {
Route::get('/', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'index'])->name('index');
Route::get('/create', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'create'])->name('create');
Route::post('/', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'store'])->name('store');
Route::delete('/{apkVersion}', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'destroy'])->name('destroy');
Route::post('/{apkVersion}/push', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'push'])->name('push');
});
// 機台設定
Route::prefix('machines')->name('machines.')->middleware('can:menu.basic.machines')->group(function () {
// 機台照片獨立更新