[FEAT] 優化商品操作紀錄清單與多語系支援,並修復全域快取錯誤

1. 修復 ProductCatalogService 在處理全域商品(company_id 為 null)時引發的型別錯誤
2. 在操作紀錄清單新增「操作人員」欄位,並優化手機版顯示
3. 將「MODULE / 目標」欄位更名為更直覺的「操作目標」
4. 於 status-badge 元件加入操作紀錄(CREATE, UPDATE, DELETE)的專屬顏色與多語系對應
5. 補齊 zh_TW.json 缺漏的機台權限、操作動作與商品相關欄位多語系翻譯
This commit is contained in:
sky121113 2026-05-14 11:52:01 +08:00
parent 256d996918
commit b87fae8eb1
4 changed files with 164 additions and 21 deletions

View File

@ -10,21 +10,21 @@ class ProductCatalogService
/**
* Get the Redis cache key for a company's product catalog.
*
* @param int $companyId
* @param int|null $companyId
* @return string
*/
public function getCacheKey(int $companyId): string
public function getCacheKey(?int $companyId): string
{
return "products_catalog:{$companyId}";
return "products_catalog:" . ($companyId ?? 'global');
}
/**
* Get the product catalog payload, using cache if available.
*
* @param int $companyId
* @param int|null $companyId
* @return array
*/
public function getPayload(int $companyId): array
public function getPayload(?int $companyId): array
{
$cacheKey = $this->getCacheKey($companyId);
$cached = Cache::get($cacheKey);
@ -39,10 +39,10 @@ class ProductCatalogService
/**
* Rebuild the product catalog cache for a company.
*
* @param int $companyId
* @param int|null $companyId
* @return array
*/
public function rebuildCache(int $companyId): array
public function rebuildCache(?int $companyId): array
{
$products = Product::where('company_id', $companyId)
->with(['translations'])
@ -64,10 +64,10 @@ class ProductCatalogService
/**
* Invalidate the product catalog cache for a company.
*
* @param int $companyId
* @param int|null $companyId
* @return void
*/
public function invalidate(int $companyId): void
public function invalidate(?int $companyId): void
{
Cache::forget($this->getCacheKey($companyId));
}

View File

@ -1988,5 +1988,29 @@
"Old": "舊值",
"New": "新值",
"No changes detected": "未偵測到資料變更",
"Log Details": "操作紀錄詳情"
"Log Details": "操作紀錄詳情",
"No detailed changes recorded": "沒有詳細的變更紀錄",
"Modified fields": "修改欄位",
"company_id": "公司",
"category_id": "分類",
"name": "商品名稱",
"name_dictionary_key": "多語系鍵值",
"barcode": "條碼",
"spec": "規格",
"manufacturer": "製造商",
"description": "商品描述",
"price": "售價",
"member_price": "會員價",
"cost": "成本",
"track_limit": "履帶容量",
"spring_limit": "彈簧容量",
"type": "商品類型",
"image_url": "商品圖片",
"status": "狀態",
"is_active": "啟用狀態",
"Operator": "操作人員",
"Create": "新增",
"Update": "編輯",
"Target Item": "操作目標",
"Manage machine access permissions": "管理機台存取權限"
}

View File

@ -96,11 +96,14 @@
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
{{ __('Time') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
{{ __('Operator') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Action') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
{{ __('Module') }} / {{ __('Target') }}
{{ __('Target Item') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
{{ __('Note') }}
@ -112,12 +115,39 @@
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($logs as $log)
@php
$oldValues = is_string($log->old_values) ? json_decode($log->old_values, true) : (array)($log->old_values ?? []);
$newValues = is_string($log->new_values) ? json_decode($log->new_values, true) : (array)($log->new_values ?? []);
$ignoredKeys = ['updated_at', 'created_at', 'deleted_at', 'localized_name'];
$allKeys = array_diff(array_unique(array_merge(array_keys($oldValues), array_keys($newValues))), $ignoredKeys);
$changedKeys = [];
foreach($allKeys as $k) {
if (($oldValues[$k] ?? null) !== ($newValues[$k] ?? null)) {
$changedKeys[] = $k;
}
}
$displayNote = $log->translated_note;
if ($log->action === 'update' && !empty($changedKeys)) {
$translatedKeys = array_map(function($k) { return __($k); }, $changedKeys);
$displayNote = __('Modified fields') . ': ' . implode(', ', $translatedKeys);
}
@endphp
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-5 whitespace-nowrap">
<span class="text-xs font-mono font-black text-slate-700 dark:text-slate-200 tracking-tighter">
{{ $log->created_at?->format('Y-m-d H:i:s') }}
</span>
</td>
<td class="px-6 py-5 whitespace-nowrap">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center border border-slate-200 dark:border-white/5 shrink-0">
<svg class="w-3 h-3 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
</div>
<span class="text-sm font-bold text-slate-700 dark:text-slate-200">{{ $log->user->name ?? 'System' }}</span>
</div>
</td>
<td class="px-6 py-5 text-center">
<x-status-badge :status="$log->action" size="sm" />
</td>
@ -128,21 +158,52 @@
</div>
</td>
<td class="px-6 py-5">
<p class="text-sm font-bold text-slate-600 dark:text-slate-400">{{ $log->translated_note }}</p>
<p class="text-sm font-bold text-slate-600 dark:text-slate-400">{{ $displayNote }}</p>
</td>
<td class="px-6 py-5 text-right whitespace-nowrap">
<button type="button" @click="viewLogDetails({{ $log }})"
class="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-500 hover:text-cyan-500 hover:bg-cyan-50 dark:hover:bg-cyan-500/10 transition-all active:scale-95">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
@if(!empty($changedKeys))
<button type="button" @click="toggleLog({{ $log->id }})"
class="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-500 hover:text-cyan-500 hover:bg-cyan-50 dark:hover:bg-cyan-500/10 transition-all active:scale-95"
:class="expandedLogs.includes({{ $log->id }}) ? 'bg-cyan-50 text-cyan-500 dark:bg-cyan-500/10' : ''">
<svg class="w-4 h-4 transition-transform duration-300" :class="expandedLogs.includes({{ $log->id }}) ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
@endif
</td>
</tr>
{{-- Desktop Expandable Row --}}
@if(!empty($changedKeys))
<tr x-show="expandedLogs.includes({{ $log->id }})" x-cloak>
<td colspan="6" class="px-6 py-4 bg-slate-50/50 dark:bg-slate-900/30 border-t-0 border-b border-slate-100 dark:border-slate-800">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@foreach($changedKeys as $key)
<div class="p-3 bg-white dark:bg-slate-800 rounded-xl border border-slate-100 dark:border-white/5">
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">{{ __($key) }}</div>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="text-[10px] text-slate-400">{{ __('Old Value') }}</div>
@php $oldVal = $oldValues[$key] ?? null; @endphp
<div class="text-sm font-bold text-slate-600 dark:text-slate-300 truncate" title="{{ is_array($oldVal) ? json_encode($oldVal) : ($oldVal ?? '-') }}">{{ is_array($oldVal) ? json_encode($oldVal) : ($oldVal ?? '-') }}</div>
</div>
<div class="shrink-0 text-slate-300 dark:text-slate-600">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
</div>
<div class="flex-1 min-w-0">
<div class="text-[10px] text-cyan-500">{{ __('New Value') }}</div>
@php $newVal = $newValues[$key] ?? null; @endphp
<div class="text-sm font-bold text-cyan-600 dark:text-cyan-400 truncate" title="{{ is_array($newVal) ? json_encode($newVal) : ($newVal ?? '-') }}">{{ is_array($newVal) ? json_encode($newVal) : ($newVal ?? '-') }}</div>
</div>
</div>
</div>
@endforeach
</div>
</td>
</tr>
@endif
@empty
<tr>
<td colspan="5" class="px-6 py-20 text-center">
<td colspan="6" class="px-6 py-20 text-center">
<x-empty-state :message="__('No operation logs found')" />
</td>
</tr>
@ -154,6 +215,25 @@
{{-- Card Mode (Mobile) --}}
<div class="xl:hidden space-y-4">
@forelse($logs as $log)
@php
$oldValuesMob = is_string($log->old_values) ? json_decode($log->old_values, true) : (array)($log->old_values ?? []);
$newValuesMob = is_string($log->new_values) ? json_decode($log->new_values, true) : (array)($log->new_values ?? []);
$ignoredKeysMob = ['updated_at', 'created_at', 'deleted_at', 'localized_name'];
$allKeysMob = array_diff(array_unique(array_merge(array_keys($oldValuesMob), array_keys($newValuesMob))), $ignoredKeysMob);
$changedKeysMob = [];
foreach($allKeysMob as $k) {
if (($oldValuesMob[$k] ?? null) !== ($newValuesMob[$k] ?? null)) {
$changedKeysMob[] = $k;
}
}
$displayNoteMob = $log->translated_note;
if ($log->action === 'update' && !empty($changedKeysMob)) {
$translatedKeysMob = array_map(function($k) { return __($k); }, $changedKeysMob);
$displayNoteMob = __('Modified fields') . ': ' . implode(', ', $translatedKeysMob);
}
@endphp
<div class="luxury-card p-5 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
@ -165,18 +245,49 @@
<span class="text-[10px] font-mono font-bold text-slate-400">{{ $log->created_at?->format('H:i:s') }}</span>
</div>
<p class="text-sm font-black text-slate-800 dark:text-slate-100 mb-2">{{ $log->translated_note }}</p>
<p class="text-sm font-black text-slate-800 dark:text-slate-100 mb-2">{{ $displayNoteMob }}</p>
<div class="flex items-center justify-between pt-3 border-t border-slate-100 dark:border-white/5">
<div class="flex flex-col">
<span class="text-xs font-bold text-slate-400">#{{ $log->target_id }}</span>
<span class="text-[10px] font-black text-slate-500 uppercase">{{ $log->user->name ?? 'System' }}</span>
</div>
<button type="button" @click="viewLogDetails({{ $log }})"
class="px-4 py-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-xs font-black hover:bg-cyan-500 hover:text-white transition-all">
@if(!empty($changedKeysMob))
<button type="button" @click="toggleLog({{ $log->id }})"
class="px-4 py-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-xs font-black hover:bg-cyan-500 hover:text-white transition-all flex items-center gap-2">
{{ __('Details') }}
<svg class="w-3 h-3 transition-transform duration-300" :class="expandedLogs.includes({{ $log->id }}) ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
@endif
</div>
{{-- Mobile Expandable Section --}}
@if(!empty($changedKeysMob))
<div x-show="expandedLogs.includes({{ $log->id }})" x-cloak class="mt-4 pt-4 border-t border-slate-100 dark:border-white/5 space-y-3">
@foreach($changedKeysMob as $key)
<div class="p-3 bg-slate-50 dark:bg-slate-800/50 rounded-xl">
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2">{{ __($key) }}</div>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="text-[10px] text-slate-400">{{ __('Old Value') }}</div>
@php $oldValMob = $oldValuesMob[$key] ?? null; @endphp
<div class="text-sm font-bold text-slate-600 dark:text-slate-300 truncate" title="{{ is_array($oldValMob) ? json_encode($oldValMob) : ($oldValMob ?? '-') }}">{{ is_array($oldValMob) ? json_encode($oldValMob) : ($oldValMob ?? '-') }}</div>
</div>
<div class="shrink-0 text-slate-300 dark:text-slate-600">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
</div>
<div class="flex-1 min-w-0">
<div class="text-[10px] text-cyan-500">{{ __('New Value') }}</div>
@php $newValMob = $newValuesMob[$key] ?? null; @endphp
<div class="text-sm font-bold text-cyan-600 dark:text-cyan-400 truncate" title="{{ is_array($newValMob) ? json_encode($newValMob) : ($newValMob ?? '-') }}">{{ is_array($newValMob) ? json_encode($newValMob) : ($newValMob ?? '-') }}</div>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@empty
<x-empty-state :message="__('No operation logs found')" />

View File

@ -65,6 +65,14 @@
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'timeout' => ['label' => __('Timeout'), 'color' => 'rose'],
'superseded' => ['label' => __('Superseded'),'color' => 'slate'],
// 操作紀錄 (Activity Log)
'create' => ['label' => __('Create'), 'color' => 'emerald'],
'update' => ['label' => __('Update'), 'color' => 'amber'],
'delete' => ['label' => __('Delete'), 'color' => 'rose'],
'created' => ['label' => __('Created'), 'color' => 'emerald'],
'updated' => ['label' => __('Updated'), 'color' => 'amber'],
'deleted' => ['label' => __('Deleted'), 'color' => 'rose'],
];
$preset = $presets[$status] ?? ['label' => $status, 'color' => 'slate'];