[FEAT] 新增 OTA 預約排程與部署狀態展示,並優化版本排序與 UI

1. 建立 `ota_schedules` 資料表與 `OtaSchedule` Model,提供 OTA 預約排程基礎儲存結構。
2. 建立 `ota:process-schedules` 背景 Artisan Command 並註冊至 Console Kernel,每分鐘自動執行到期的預約並透過 MQTT 發送。
3. 擴充 `ApkVersionController` index 方法:加入加載列表時自動觸發排程的防呆機制,並在記憶體中快速統計 pending 與 completed 的機台。
4. 擴充 `ApkVersionController` store 與 push 方法,移除已用不到的 `is_forced` (更新模式) 限制,並支援 `scheduled_at` 預約時間的寫入。
5. 調整 `create.blade.php` 頁面,移除「強制更新」的 Switch 欄位,僅保留注意事項防錯提示。
6. 調整 `index.blade.php` 頁面,將「更新模式」改為「部署狀態」一欄,整合系統內建的 `.tooltip` 與原生 `title` 屬性,在加載與刷新時 100% 穩定展示 Hover 機台名單。
7. 優化 `index.blade.php` 彈窗,將原生時間輸入框改為 Flatpickr 樣式,加入日曆圖標並支援動態 locale 語言切換(中/英/日),將標籤統一命名為「選擇更新時間」。
8. 同步更新並對齊中、英、日三語系 `lang/*.json` 語系檔,並完成字母排序與反逸出斜線處理。
This commit is contained in:
sky121113 2026-05-29 10:55:23 +08:00
parent d9bf2bbe98
commit fb1bcf49e1
10 changed files with 352 additions and 57 deletions

View File

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands\Machine;
use Illuminate\Console\Command;
use App\Models\Machine\OtaSchedule;
use App\Models\Machine\Machine;
use App\Models\Machine\RemoteCommand;
use App\Services\Machine\MqttService;
use Exception;
use Illuminate\Support\Facades\Log;
class ProcessOtaSchedules extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ota:process-schedules';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process scheduled OTA firmware updates and send MQTT commands to target machines';
/**
* Execute the console command.
*/
public function handle(MqttService $mqttService)
{
$this->info('[' . now()->toDateTimeString() . '] Starting to process scheduled OTA updates...');
// 撈取狀態為待發送且時間已到的排程
$schedules = OtaSchedule::where('status', 'pending')
->where('scheduled_at', '<=', now())
->with('apkVersion')
->get();
if ($schedules->isEmpty()) {
$this->info('No pending OTA schedules to process.');
return 0;
}
$this->info("Found {$schedules->count()} pending OTA schedule(s) to process.");
foreach ($schedules as $schedule) {
$this->info("Processing OTA Schedule ID: {$schedule->id} (APK: {$schedule->apkVersion->version_name})");
// 標記為處理中,防止重複執行
$schedule->update(['status' => 'processing']);
$apkVersion = $schedule->apkVersion;
try {
// 根據 target_type 撈取目標機台
if ($schedule->target_type === 'all') {
$machines = Machine::all();
} else {
$machineIds = $schedule->target_value ?? [];
$machines = Machine::whereIn('id', $machineIds)->get();
}
if ($machines->isEmpty()) {
$this->warn("No target machines found for OTA Schedule ID: {$schedule->id}. Skipping.");
$schedule->update(['status' => 'completed']);
continue;
}
$successCount = 0;
foreach ($machines as $machine) {
// 1. 先建立 RemoteCommand 紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $schedule->user_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. 推播 MQTT 更新指令
$pushed = $mqttService->pushCommand(
$machine->serial_no,
'update_app',
$command->payload,
(string) $command->id
);
if ($pushed) {
$successCount++;
} else {
$command->update(['status' => 'failed']);
$this->error("Failed to push MQTT OTA command to machine: {$machine->serial_no}");
}
}
$schedule->update(['status' => 'completed']);
$this->info("Successfully processed OTA Schedule ID: {$schedule->id}. MQTT deployed to {$successCount}/{$machines->count()} machines.");
} catch (Exception $e) {
Log::error("Error processing OTA Schedule ID {$schedule->id}: " . $e->getMessage(), [
'exception' => $e
]);
$schedule->update(['status' => 'failed']);
$this->error("Error processing OTA Schedule ID {$schedule->id}: " . $e->getMessage());
}
}
$this->info('OTA schedules processing completed.');
return 0;
}
}

View File

@ -13,6 +13,7 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
$schedule->command('ota:process-schedules')->everyMinute();
}
/**

View File

@ -15,7 +15,39 @@ class ApkVersionController extends AdminController
{
public function index(): View
{
$versions = ApkVersion::orderBy('version_code', 'desc')->paginate(10);
// 在進入 APK 版本清單時,同步觸發已到期排程的執行(確保無 cron 的開發/測試環境也能 100% 執行)
try {
\Illuminate\Support\Facades\Artisan::call('ota:process-schedules');
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Auto-trigger scheduled OTA failed: ' . $e->getMessage());
}
$versions = ApkVersion::latest()->paginate(10);
// 統計並附屬機台部署狀態
$commands = \App\Models\Machine\RemoteCommand::where('command_type', 'update_app')
->with('machine')
->get();
foreach ($versions as $version) {
$pending = [];
$completed = [];
foreach ($commands as $cmd) {
$cmdVersionCode = $cmd->payload['version_code'] ?? null;
if ($cmdVersionCode == $version->version_code) {
if ($cmd->status === 'completed' || $cmd->status === 'success') {
$completed[] = $cmd->machine;
} elseif ($cmd->status === 'pending' || $cmd->status === 'processing') {
$pending[] = $cmd->machine;
}
}
}
$version->pending_machines = collect($pending)->filter()->unique('id');
$version->completed_machines = collect($completed)->filter()->unique('id');
}
// 用於 OTA 下發時讓使用者選擇的機台列表
$machines = Machine::all();
return view('admin.basic-settings.apk-versions.index', compact('versions', 'machines'));
@ -35,7 +67,6 @@ class ApkVersionController extends AdminController
'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;
@ -50,7 +81,7 @@ class ApkVersionController extends AdminController
'file_path' => $filePath,
'download_url' => $request->download_url,
'release_notes' => $request->release_notes,
'is_forced' => $request->boolean('is_forced'),
'is_forced' => false,
]);
return redirect()->route('admin.basic-settings.apk-versions.index')
@ -76,8 +107,24 @@ class ApkVersionController extends AdminController
$request->validate([
'machine_ids' => 'required|array',
'machine_ids.*' => 'exists:machines,id',
'scheduled_at' => 'nullable|date|after:now',
]);
// 如果設定了預約發送時間
if ($request->filled('scheduled_at')) {
\App\Models\Machine\OtaSchedule::create([
'apk_version_id' => $apkVersion->id,
'scheduled_at' => $request->scheduled_at,
'target_type' => 'custom',
'target_value' => $request->machine_ids,
'status' => 'pending',
'user_id' => auth()->id(),
]);
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', __('OTA update scheduled successfully.'));
}
$machines = Machine::whereIn('id', $request->machine_ids)->get();
$successCount = 0;

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OtaSchedule extends Model
{
use HasFactory;
protected $fillable = [
'apk_version_id',
'scheduled_at',
'target_type',
'target_value',
'status',
'user_id',
];
protected $casts = [
'scheduled_at' => 'datetime',
'target_value' => 'array',
];
public function apkVersion()
{
return $this->belongsTo(ApkVersion::class);
}
public function user()
{
return $this->belongsTo(\App\Models\System\User::class);
}
}

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 {
public function up(): void
{
Schema::create('ota_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('apk_version_id')->constrained('apk_versions')->onDelete('cascade');
$table->dateTime('scheduled_at');
$table->string('target_type')->default('custom'); // 'all', 'custom'
$table->json('target_value')->nullable(); // 當 target_type = 'custom' 時,儲存 [1, 2, 3] 機台 ID 的 JSON 陣列
$table->string('status')->default('pending'); // 'pending', 'processing', 'completed', 'failed'
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null'); // 建立排程的使用者
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ota_schedules');
}
};

View File

@ -476,6 +476,7 @@
"Delta": "Delta",
"Deploy Update": "Deploy Update",
"Deploy version": "Deploy version",
"Deployment Status": "Deployment Status",
"Deposit Bonus": "Deposit Bonus",
"Describe the changes in this version...": "Describe the changes in this version...",
"Describe the repair or maintenance status...": "Describe the repair or maintenance status...",
@ -527,6 +528,7 @@
"Dispensing in progress": "Dispensing in progress",
"Display Material Code": "Display Material Code",
"Displaying": "Displaying",
"Done": "Done",
"Door Closed": "Door Closed",
"Door Opened": "Door Opened",
"Download Image": "Download Image",
@ -887,6 +889,7 @@
"Last Week": "Last Week",
"Latitude": "Latitude",
"Lease": "Lease",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "Leave blank to deploy immediately, or set a future time to schedule a background release.",
"Leave empty for permanent code": "Leave empty for permanent code",
"Leave empty or 0 for permanent code": "Leave empty or 0 for permanent code",
"Leave empty to inherit company settings": "Leave empty to inherit company settings",
@ -1174,6 +1177,7 @@
"No machines available": "No machines available",
"No machines available in this company.": "No machines available in this company.",
"No machines found": "No machines found",
"No machines in this status": "No machines in this status",
"No machines match your criteria": "No machines match your criteria",
"No maintenance records found": "No maintenance records found",
"No matching logs found": "No matching logs found",
@ -1231,6 +1235,7 @@
"OTA Update Deployment": "OTA Update Deployment",
"OTA command sent to %d devices successfully.": "OTA command sent to %d devices successfully.",
"OTA firmware update and version control": "OTA firmware update and version control",
"OTA update scheduled successfully.": "OTA update scheduled successfully.",
"Off": "Off",
"Offline": "Offline",
"Offline + 1": "Offline + 1",
@ -1503,6 +1508,8 @@
"Quick replenishment from this view": "Quick replenishment from this view",
"Quick search...": "Quick search...",
"Rank": "Rank",
"Ready": "Ready",
"Ready to Execute": "Ready to Execute",
"Ready to print": "Ready to print",
"Real-time OEE analysis awaits": "Real-time OEE analysis awaits",
"Real-time Operation Logs (Last 50)": "Real-time Operation Logs (Last 50)",
@ -1630,6 +1637,7 @@
"Scan to authorize testing or maintenance": "Scan to authorize testing or maintenance",
"Scan to pick up your product": "Scan to pick up your product",
"Schedule": "Schedule",
"Schedule Release Time (Optional)": "Schedule Release Time (Optional)",
"Search": "Search",
"Search Company Title...": "Search Company Title...",
"Search Company...": "Search Company...",
@ -1695,6 +1703,7 @@
"Select Slot...": "Select Slot...",
"Select Target Company": "Select Target Company",
"Select Target Slot": "Select Target Slot",
"Select Update Time (Optional)": "Select Update Time (Optional)",
"Select Warehouse": "Select Warehouse",
"Select a machine to copy settings from...": "Select a machine to copy settings from...",
"Select a machine to deep dive": "Select a machine to deep dive",
@ -1704,6 +1713,7 @@
"Select an asset from the left to start analysis": "Select an asset from the left to start analysis",
"Select date to sync data": "Select date to sync data",
"Select flavor...": "Select flavor...",
"Select future time...": "Select update time...",
"Select...": "Select...",
"Selected": "Selected",
"Selected Date": "Selected Date",
@ -2015,6 +2025,7 @@
"Unpublish": "Unpublish",
"Update": "Update",
"Update Authorization": "Update Authorization",
"Update Completed": "Update Completed",
"Update Customer": "Update Customer",
"Update Mode": "Update Mode",
"Update Pass Code": "Update Pass Code",

View File

@ -476,6 +476,7 @@
"Delta": "変動量",
"Deploy Update": "Deploy Update",
"Deploy version": "Deploy version",
"Deployment Status": "配信ステータス",
"Deposit Bonus": "チャージボーナス",
"Describe the changes in this version...": "Describe the changes in this version...",
"Describe the repair or maintenance status...": "修理やメンテナンスの状況を説明してください...",
@ -527,6 +528,7 @@
"Dispensing in progress": "搬送中",
"Display Material Code": "資材コードを表示",
"Displaying": "表示中",
"Done": "完了",
"Door Closed": "扉が閉まりました",
"Door Opened": "扉が開きました",
"Download Image": "画像をダウンロード",
@ -887,6 +889,7 @@
"Last Week": "先週",
"Latitude": "緯度",
"Lease": "リース",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "空欄のままにすると即時配信されます。未来の日時を指定すると背景で自動予約配信されます。",
"Leave empty for permanent code": "空欄で無期限コードになります",
"Leave empty or 0 for permanent code": "空欄または0で無期限コードになります",
"Leave empty to inherit company settings": "空白のままにすると会社のグローバル設定を継承します",
@ -1174,6 +1177,7 @@
"No machines available": "利用可能な機器がありません",
"No machines available in this company.": "この会社で利用可能な機器がありません。",
"No machines found": "機器が見つかりません",
"No machines in this status": "このステータスのデバイスはありません",
"No machines match your criteria": "No machines match your criteria",
"No maintenance records found": "メンテナンス記録が見つかりません",
"No matching logs found": "一致するログが見つかりません",
@ -1231,6 +1235,7 @@
"OTA Update Deployment": "OTA Update Deployment",
"OTA command sent to %d devices successfully.": "OTA command sent to %d devices successfully.",
"OTA firmware update and version control": "OTA firmware update and version control",
"OTA update scheduled successfully.": "OTA予約更新スケジュールが正常に作成されました。",
"Off": "オフ",
"Offline": "オフライン",
"Offline + 1": "オフライン + 1",
@ -1503,6 +1508,8 @@
"Quick replenishment from this view": "この画面からクイック補充",
"Quick search...": "クイック検索...",
"Rank": "順位",
"Ready": "準備中",
"Ready to Execute": "コマンド実行待ちデバイス",
"Ready to print": "印刷準備完了",
"Real-time OEE analysis awaits": "リアルタイムOEE分析待機中",
"Real-time Operation Logs (Last 50)": "リアルタイム操作ログ (直近50件)",
@ -1630,6 +1637,7 @@
"Scan to authorize testing or maintenance": "スキャンしてテストまたはメンテナンスを承認します",
"Scan to pick up your product": "スキャンして商品をお受け取りください",
"Schedule": "スケジュール",
"Schedule Release Time (Optional)": "予約配信日時(任意)",
"Search": "検索",
"Search Company Title...": "会社名を検索...",
"Search Company...": "会社を検索...",
@ -1695,6 +1703,7 @@
"Select Slot...": "スロットを選択...",
"Select Target Company": "対象の会社を選択",
"Select Target Slot": "対象のスロットを選択",
"Select Update Time (Optional)": "更新時間を選択してください(任意)",
"Select Warehouse": "倉庫を選択",
"Select a machine to copy settings from...": "設定のコピー元機器を選択...",
"Select a machine to deep dive": "詳細分析する機器を選択",
@ -1704,6 +1713,7 @@
"Select an asset from the left to start analysis": "左側から機器を選択して分析を開始",
"Select date to sync data": "データを同期する日付を選択",
"Select flavor...": "Select flavor...",
"Select future time...": "配信日時を選択してください...",
"Select...": "選択してください...",
"Selected": "選択中",
"Selected Date": "選択日",
@ -2015,6 +2025,7 @@
"Unpublish": "非公開",
"Update": "更新",
"Update Authorization": "権限更新",
"Update Completed": "アップデート完了デバイス",
"Update Customer": "顧客更新",
"Update Mode": "Update Mode",
"Update Pass Code": "パスコード更新",

View File

@ -476,6 +476,7 @@
"Delta": "變化量",
"Deploy Update": "部署更新",
"Deploy version": "部署版本",
"Deployment Status": "部署狀態",
"Deposit Bonus": "儲值回饋",
"Describe the changes in this version...": "描述此版本的變更...",
"Describe the repair or maintenance status...": "請描述維修或保養狀況...",
@ -527,6 +528,7 @@
"Dispensing in progress": "正在出貨中",
"Display Material Code": "顯示物料代碼",
"Displaying": "目前顯示",
"Done": "已完成",
"Door Closed": "機門已關閉",
"Door Opened": "機門已開啟",
"Download Image": "下載圖片",
@ -887,6 +889,7 @@
"Last Week": "上週",
"Latitude": "緯度",
"Lease": "租賃",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "留空將立即部署更新,或設定未來時間由背景自動排程發布。",
"Leave empty for permanent code": "留空表示永久有效",
"Leave empty or 0 for permanent code": "留空或輸入 0 則為永久代碼",
"Leave empty to inherit company settings": "留空則繼承公司全域設定",
@ -1174,6 +1177,7 @@
"No machines available": "目前沒有可供分配的機台",
"No machines available in this company.": "此客戶目前沒有可供分配的機台。",
"No machines found": "未找到機台",
"No machines in this status": "此狀態下無設備",
"No machines match your criteria": "沒有符合條件的機台",
"No maintenance records found": "找不到維修紀錄",
"No matching logs found": "找不到符合條件的日誌",
@ -1231,6 +1235,7 @@
"OTA Update Deployment": "OTA 更新部署",
"OTA command sent to %d devices successfully.": "OTA 指令已成功發送至 %d 台設備。",
"OTA firmware update and version control": "OTA 韌體更新與版本控制",
"OTA update scheduled successfully.": "OTA 預約更新排程已建立成功。",
"Off": "關閉",
"Offline": "離線",
"Offline + 1": "離線 + 1",
@ -1503,6 +1508,8 @@
"Quick replenishment from this view": "從此畫面快速補貨",
"Quick search...": "快速搜尋...",
"Rank": "排行",
"Ready": "準備中",
"Ready to Execute": "準備要執行指令的設備",
"Ready to print": "準備列印",
"Real-time OEE analysis awaits": "即時 OEE 分析預備中",
"Real-time Operation Logs (Last 50)": "即時操作日誌 (最後 50 筆)",
@ -1630,6 +1637,7 @@
"Scan to authorize testing or maintenance": "掃描以進行測試或維護授權",
"Scan to pick up your product": "掃描以領取您的商品",
"Schedule": "排程區間",
"Schedule Release Time (Optional)": "預約發布時間(選填)",
"Search": "搜尋",
"Search Company Title...": "搜尋公司名稱...",
"Search Company...": "搜尋公司...",
@ -1695,6 +1703,7 @@
"Select Slot...": "選擇貨道...",
"Select Target Company": "選擇目標公司",
"Select Target Slot": "選擇目標貨道",
"Select Update Time (Optional)": "選擇更新時間(選填)",
"Select Warehouse": "選擇倉庫",
"Select a machine to copy settings from...": "選擇要複製設定的來源機台...",
"Select a machine to deep dive": "請選擇機台以開始深度分析",
@ -1704,6 +1713,7 @@
"Select an asset from the left to start analysis": "選擇左側機台以開始分析數據",
"Select date to sync data": "選擇日期以同步數據",
"Select flavor...": "選擇風味...",
"Select future time...": "選擇更新時間",
"Select...": "請選擇...",
"Selected": "已選擇",
"Selected Date": "查詢日期",
@ -2015,6 +2025,7 @@
"Unpublish": "下架",
"Update": "編輯",
"Update Authorization": "更新授權",
"Update Completed": "已經完成更新指令的設備",
"Update Customer": "更新客戶",
"Update Mode": "更新模式",
"Update Pass Code": "更新通行碼",

View File

@ -173,49 +173,15 @@
</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 border transition-all duration-300"
:class="isForced
? 'bg-rose-500/10 dark:bg-rose-500/10 border-rose-500/20 dark:border-rose-500/30 text-rose-600 dark:text-rose-400 shadow-sm shadow-rose-500/5'
: 'bg-slate-50/80 dark:bg-slate-800/40 border-slate-100 dark:border-slate-800/80 text-slate-700 dark:text-slate-200'">
<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">{{ __('During OTA deployment, the device will install directly in the background 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-5 rounded-2xl bg-amber-500/10 border border-amber-500/20 shadow-sm shadow-amber-500/5">
{{-- 注意事項卡片 --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in group/card bg-amber-500/5 dark:bg-amber-500/5 border border-amber-500/15" style="animation-delay: 100ms">
<div class="flex items-start gap-4">
<svg class="w-5 h-5 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-xs font-bold text-amber-800 dark:text-amber-300 tracking-wide leading-relaxed">
<p class="text-sm font-black uppercase tracking-widest mb-1.5 flex items-center gap-1">{{ __('Important Notice') }}</p>
<p class="font-bold opacity-90 leading-relaxed">{{ __('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 class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20 shadow-lg shadow-amber-500/5 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 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
</div>
<div class="text-xs font-bold text-amber-800 dark:text-amber-300 tracking-wide leading-relaxed">
<p class="text-sm font-black uppercase tracking-widest mb-1.5 flex items-center gap-1 font-display">{{ __('Important Notice') }}</p>
<p class="font-bold opacity-90 leading-relaxed">{{ __('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>
@ -230,7 +196,6 @@
uploadMode: 'file',
fileName: '',
isDragging: false,
isForced: false,
versionName: '{{ old('version_name', '') }}',
versionCode: '{{ old('version_code', '') }}',

View File

@ -43,6 +43,9 @@
this.searchMachineQuery = '';
this.onlyOnlineMachines = false;
this.isPushModalOpen = true;
// 清空預約時間
const scheduleInput = document.getElementById('scheduled_at');
if (scheduleInput) scheduleInput.value = '';
},
get filteredMachines() {
@ -143,7 +146,7 @@
<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">{{ __('Deployment Status') }}</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>
@ -171,16 +174,46 @@
{{ $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') }}
<td class="px-6 py-6 whitespace-nowrap">
<div class="flex items-center gap-3">
<!-- 準備中 -->
@php
$pendingTooltip = __('Ready to Execute') . ":\n";
if ($version->pending_machines->isEmpty()) {
$pendingTooltip .= "" . __('No machines in this status');
} else {
foreach($version->pending_machines as $m) {
$pendingTooltip .= "{$m->name} ({$m->serial_no})\n";
}
}
$pendingTooltip = trim($pendingTooltip);
@endphp
<div class="inline-block tooltip cursor-help" title="{{ $pendingTooltip }}">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-amber-100 dark:border-amber-900/30 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 tracking-widest inline-flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{{ __('Ready') }}: {{ $version->pending_machines->count() }}
</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') }}
</div>
<!-- 已完成 -->
@php
$completedTooltip = __('Update Completed') . ":\n";
if ($version->completed_machines->isEmpty()) {
$completedTooltip .= "" . __('No machines in this status');
} else {
foreach($version->completed_machines as $m) {
$completedTooltip .= "{$m->name} ({$m->serial_no})\n";
}
}
$completedTooltip = trim($completedTooltip);
@endphp
<div class="inline-block tooltip cursor-help" title="{{ $completedTooltip }}">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-emerald-100 dark:border-emerald-900/30 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 tracking-widest inline-flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
{{ __('Done') }}: {{ $version->completed_machines->count() }}
</span>
@endif
</div>
</div>
</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">
@ -307,6 +340,42 @@
<!-- 表單與機台選擇清單 -->
<form :action="pushActionUrl" method="POST" id="ota-push-form">
@csrf
<!-- 預約排程時間設定 -->
<div class="mb-6 p-4 rounded-2xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/20">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex-1">
<label for="scheduled_at" class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-1.5">
{{ __('Select Update Time (Optional)') }}
</label>
<p class="text-[10px] text-slate-400 dark:text-slate-500 leading-normal">
{{ __('Leave blank to deploy immediately, or set a future time to schedule a background release.') }}
</p>
</div>
<div class="w-full md:w-64 group">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</span>
<input type="text"
id="scheduled_at"
name="scheduled_at"
x-init="flatpickr($el, {
enableTime: true,
dateFormat: 'Y-m-d H:i',
time_24hr: true,
locale: '{{ app()->getLocale() === 'zh_TW' ? 'zh_tw' : (app()->getLocale() === 'ja' ? 'ja' : 'en') }}',
disableMobile: true
})"
class="luxury-input py-2.5 pl-12 pr-4 block w-full text-sm font-bold tracking-tight cursor-pointer"
placeholder="{{ __('Select future time...') }}">
</div>
</div>
</div>
</div>
<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/50 dark:bg-slate-950/40">