[FEAT] 實作智慧型機台登入異常告警與自動自我修復機制

1. [Models] 修改 Machine.php,增加 has_login_warning 動態屬性,設定未解決之登入失敗日誌須達 3 筆以上才亮起「登入異常」警報。
2. [Models] 重構 scopeHasAnyAlert 查詢,確保當未解決之登入失敗日誌未達 3 筆時,不會計入儀表板的「待處理告警 (Alerts Pending)」統計。
3. [API] 修改 MachineAuthController.php,在 B000 登入成功時加入自動自我修復 (Self-healing) 邏輯,一鍵將先前該機台的所有未解決登入失敗日誌標記為已解決 (is_resolved = true)。
4. [Views] 修改 admin.dashboard 及 admin.machines.index,新增登入異常警告 (Login Alert) 的琥珀色徽章與動態提示,並將儀表板上的機台卡片標題改為點擊可跳轉至對應機台之篩選列表。
5. [Lang] 更新 zh_TW.json、en.json 與 ja.json,新增多語系翻譯對照詞彙。
This commit is contained in:
sky121113 2026-05-18 09:50:51 +08:00
parent 536d10234d
commit 86e895a4ae
7 changed files with 97 additions and 27 deletions

View File

@ -100,6 +100,12 @@ class MachineAuthController extends Controller
'machine' => $machine->serial_no
]);
// 自我修復:自動將先前所有未解決的登入失敗警告標記為已解決
$machine->logs()
->where('type', 'login')
->where('is_resolved', false)
->update(['is_resolved' => true]);
// 寫入成功登入日誌
ProcessStateLog::dispatch(
$machine->id,

View File

@ -92,7 +92,7 @@ class Machine extends Model
'cash_module_enabled',
];
protected $appends = ['image_urls', 'calculated_status', 'hardware_status', 'latest_status_log_time', 'latest_hardware_log_time', 'is_unstable', 'recent_disconnection_count'];
protected $appends = ['image_urls', 'calculated_status', 'hardware_status', 'latest_status_log_time', 'latest_hardware_log_time', 'is_unstable', 'recent_disconnection_count', 'has_login_warning'];
/**
* 動態計算機台當前狀態 (僅限連線與系統層級)
@ -133,6 +133,18 @@ class Machine extends Model
return $this->status ?? 'online';
}
/**
* 判定機台目前是否有未解決的登入失敗警告 (需達 3 次未解決警告才亮起)
*/
public function getHasLoginWarningAttribute(): bool
{
return $this->logs()
->where('type', 'login')
->where('level', 'warning')
->where('is_resolved', false)
->count() >= 3;
}
/**
* 下位機/硬體健康狀態 (基於 type = 'submachine' 日誌)
*/
@ -220,17 +232,27 @@ class Machine extends Model
/**
* Scope: 判定是否有任何待處理的異常 (排除掉單純的連線中斷)
* 待處理告警應專注於「功能性異常」如硬體錯誤、APP 報錯),而非連線狀態
* 只有當登入失敗次數達 3 次以上時,才列入待處理告警。
*/
public function scopeHasAnyAlert($query)
{
return $query->whereHas('logs', function ($q) {
$q->where(function($sub) {
$sub->where('is_resolved', false)
->orWhereNull('is_resolved');
return $query->where(function($outer) {
// 1. 有任何非登入類型的待處理異常
$outer->whereHas('logs', function ($q) {
$q->where(function($sub) {
$sub->where('is_resolved', false)
->orWhereNull('is_resolved');
})
->whereIn('level', ['error', 'warning'])
->where('type', '!=', 'login')
->where('message', '!=', 'Connection lost (LWT)');
})
->whereIn('level', ['error', 'warning'])
->where('message', '!=', 'Connection lost (LWT)');
// 2. 或者有至少 3 筆未解決的登入異常
->orWhereHas('logs', function ($q) {
$q->where('is_resolved', false)
->where('type', 'login')
->where('level', 'warning');
}, '>=', 3);
});
}

View File

@ -780,8 +780,10 @@
"Lock Page Unlock": "Lock Page Unlock",
"Locked Page": "Locked Page",
"Log Time": "Log Time",
"Login Alert": "Login Alert",
"Login History": "Login History",
"Login failed: :account": "Login failed: :account",
"Login warning alert!": "Login warning alert!",
"Logout": "Logout",
"Logs": "Logs",
"Longitude": "Longitude",

View File

@ -780,8 +780,10 @@
"Lock Page Unlock": "機器アプリロック解除",
"Locked Page": "ロックページ",
"Log Time": "記録時間",
"Login Alert": "ログイン警告",
"Login History": "ログイン履歴",
"Login failed: :account": "ログイン失敗: :account",
"Login warning alert!": "自販機ログイン警告!",
"Logout": "ログアウト",
"Logs": "ログ",
"Longitude": "経度",

View File

@ -788,8 +788,10 @@
"Locked Page": "鎖定頁",
"Log Details": "操作紀錄詳情",
"Log Time": "記錄時間",
"Login Alert": "登入異常",
"Login History": "登入歷史",
"Login failed: :account": "登入失敗::account",
"Login warning alert!": "機台登入異常告警!",
"Logout": "登出",
"Logs": "日誌",
"Longitude": "經度",

View File

@ -203,17 +203,29 @@
@forelse($machines as $machine)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6">
<div class="flex items-center gap-x-5">
<div class="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-300 group-hover:bg-cyan-500 group-hover:text-white transition-all shadow-sm">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path 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>
<a href="{{ route('admin.machines.index', ['search' => $machine->serial_no]) }}" class="block group/info hover:scale-105 transition-transform">
<div class="flex items-center gap-x-5 text-left">
<div class="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-300 group-hover/info:bg-cyan-500 group-hover/info:text-white transition-all shadow-sm">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path 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>
</div>
<div class="flex flex-col">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover/info:text-cyan-600 dark:group-hover/info:text-cyan-400 transition-colors flex items-center gap-2">
<span>{{ $machine->name }}</span>
@if($machine->has_login_warning)
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[10px] font-black bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 uppercase tracking-wider tooltip shrink-0" title="{{ __('Login warning alert!') }}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
{{ __('Login Alert') }}
</span>
@endif
</div>
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN: {{ $machine->serial_no }})</span>
</div>
</div>
<div class="flex flex-col">
<span 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">{{ $machine->name }}</span>
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN: {{ $machine->serial_no }})</span>
</div>
</div>
</a>
</td>
<td class="px-6 py-6 text-center">
<a href="{{ route('admin.machines.index', ['search' => $machine->serial_no]) }}" class="block hover:scale-105 transition-transform">
@ -320,8 +332,8 @@
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group relative overflow-hidden">
<!-- Card Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 rounded-2xl 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 overflow-hidden shadow-sm shrink-0">
<a href="{{ route('admin.machines.index', ['search' => $machine->serial_no]) }}" class="flex items-center gap-4 min-w-0 group/info hover:scale-105 transition-transform origin-left">
<div class="w-14 h-14 rounded-2xl 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/info:bg-cyan-500 group-hover/info:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
@if(isset($machine->image_urls[0]))
<img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover">
@else
@ -331,14 +343,22 @@
@endif
</div>
<div class="min-w-0">
<h3 class="text-lg font-black text-slate-800 dark:text-slate-100 truncate group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight">
{{ $machine->name }}
<h3 class="text-lg font-black text-slate-800 dark:text-slate-100 group-hover/info:text-cyan-600 dark:group-hover/info:text-cyan-400 transition-colors tracking-tight flex items-center gap-2 min-w-0">
<span class="truncate">{{ $machine->name }}</span>
@if($machine->has_login_warning)
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[10px] font-black bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 uppercase tracking-wider tooltip shrink-0" title="{{ __('Login warning alert!') }}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
{{ __('Login Alert') }}
</span>
@endif
</h3>
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
SN: {{ $machine->serial_no ?: '--' }}
</p>
</div>
</div>
</a>
@php $cStatus = $machine->calculated_status; @endphp
<div class="flex items-center">

View File

@ -371,8 +371,16 @@
</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 tracking-tight">
{{ $machine->name }}
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 tracking-tight flex items-center gap-2">
<span>{{ $machine->name }}</span>
@if($machine->has_login_warning)
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[10px] font-black bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 uppercase tracking-wider tooltip shrink-0" title="{{ __('Login warning alert!') }}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
{{ __('Login Alert') }}
</span>
@endif
</div>
<div class="flex items-center gap-2 mt-0.5">
<span
@ -535,8 +543,16 @@
</div>
<div class="min-w-0">
<h3 @click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $machine->name }}
class="text-base font-extrabold text-slate-800 dark:text-slate-100 hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer flex items-center gap-2 min-w-0">
<span class="truncate">{{ $machine->name }}</span>
@if($machine->has_login_warning)
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[10px] font-black bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 uppercase tracking-wider tooltip shrink-0" title="{{ __('Login warning alert!') }}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
{{ __('Login Alert') }}
</span>
@endif
</h3>
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">