From 256d99691811b2a9359d7208356b0e890d4bdb99 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 14 May 2026 11:25:43 +0800 Subject: [PATCH] =?UTF-8?q?[STYLE]=20=E5=95=86=E5=93=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=BB=8B=E9=9D=A2=E5=A4=9A=E8=AA=9E=E7=B3=BB=E6=A8=99=E6=BA=96?= =?UTF-8?q?=E5=8C=96=E8=88=87=E5=81=B4=E9=82=8A=E6=AC=84=20UI=20=E5=84=AA?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 完成商品詳細資訊側邊欄 (Slide-over) 的全面多語系化,修復部分標題顯示英文的問題。 2. 重構 Alpine.js 的 getCategoryName 函式,優先顯示 localized_name。 3. 更新 zh_TW.json 補齊缺失的翻譯鍵值 (包含同步指令提示、側邊欄區塊標題等)。 4. 優化分頁與分頁大小切換邏輯,支援新增的日誌 (Logs) 分頁。 5. 調整同步指令錯誤訊息為通用語氣,提升不同角色下的使用者體驗。 --- .../Admin/ProductCategoryController.php | 44 +++- .../Controllers/Admin/ProductController.php | 97 ++++++- app/Models/System/SystemOperationLog.php | 7 +- lang/zh_TW.json | 27 +- .../views/admin/products/index.blade.php | 237 +++++++++++++++++- .../products/partials/tab-logs.blade.php | 189 ++++++++++++++ 6 files changed, 587 insertions(+), 14 deletions(-) create mode 100644 resources/views/admin/products/partials/tab-logs.blade.php diff --git a/app/Http/Controllers/Admin/ProductCategoryController.php b/app/Http/Controllers/Admin/ProductCategoryController.php index b864892..fb5e4a8 100644 --- a/app/Http/Controllers/Admin/ProductCategoryController.php +++ b/app/Http/Controllers/Admin/ProductCategoryController.php @@ -8,6 +8,7 @@ use App\Models\System\Translation; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use App\Models\System\SystemOperationLog; class ProductCategoryController extends Controller { @@ -73,6 +74,17 @@ class ProductCategoryController extends Controller 'name_dictionary_key' => $dictKey, ]); + SystemOperationLog::create([ + 'company_id' => $category->company_id, + 'user_id' => auth()->id(), + 'module' => 'category', + 'action' => 'create', + 'target_id' => $category->id, + 'target_type' => ProductCategory::class, + 'note' => 'category_created', + 'new_values' => $category->toArray(), + ]); + DB::commit(); return redirect()->back()->with('success', __('Category created successfully')); @@ -125,10 +137,24 @@ class ProductCategoryController extends Controller ); } + $oldValues = $category->toArray(); $category->update([ 'name' => $request->names['zh_TW'] ?? $category->name, 'name_dictionary_key' => $dictKey, ]); + $newValues = $category->fresh()->toArray(); + + SystemOperationLog::create([ + 'company_id' => $category->company_id, + 'user_id' => auth()->id(), + 'module' => 'category', + 'action' => 'update', + 'target_id' => $category->id, + 'target_type' => ProductCategory::class, + 'note' => 'category_updated', + 'old_values' => $oldValues, + 'new_values' => $newValues, + ]); DB::commit(); @@ -157,12 +183,22 @@ class ProductCategoryController extends Controller return redirect()->back()->with('error', $errorMsg); } - if ($category->name_dictionary_key) { - Translation::withoutGlobalScopes()->where('group', 'category')->where('key', $category->name_dictionary_key)->delete(); - } - + $oldValues = $category->toArray(); + $categoryId = $category->id; + $companyId = $category->company_id; $category->delete(); + SystemOperationLog::create([ + 'company_id' => $companyId, + 'user_id' => auth()->id(), + 'module' => 'category', + 'action' => 'delete', + 'target_id' => $categoryId, + 'target_type' => ProductCategory::class, + 'note' => 'category_deleted', + 'old_values' => $oldValues, + ]); + if (request()->ajax()) { return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index 26ce57e..645ca4f 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use App\Services\Product\ProductCatalogService; +use App\Models\System\SystemOperationLog; class ProductController extends Controller { @@ -91,7 +92,7 @@ class ProductController extends Controller 'routeName' => $routeName ])->render() ]); - } else { + } elseif ($tab === 'categories') { return response()->json([ 'success' => true, 'html' => view('admin.products.partials.tab-categories', [ @@ -100,6 +101,38 @@ class ProductController extends Controller 'routeName' => $routeName ])->render() ]); + } elseif ($tab === 'logs') { + $logQuery = SystemOperationLog::with(['user', 'company']) + ->whereIn('module', ['product', 'category']); + + if ($user->isSystemAdmin()) { + if ($request->filled('log_company_id')) { + $logQuery->where('company_id', $request->log_company_id); + } + } + + if ($request->filled('search_log')) { + $search = $request->search_log; + $logQuery->where(function($q) use ($search) { + $q->where('note', 'like', "%{$search}%") + ->orWhere('old_values', 'like', "%{$search}%") + ->orWhere('new_values', 'like', "%{$search}%"); + }); + } + + if ($request->filled('start_date') && $request->filled('end_date')) { + $logQuery->whereBetween('created_at', [$request->start_date, $request->end_date]); + } + + $logs = $logQuery->latest()->paginate($per_page, ['*'], 'log_page')->withQueryString(); + + return response()->json([ + 'success' => true, + 'html' => view('admin.products.partials.tab-logs', [ + 'logs' => $logs, + 'companies' => $companies + ])->render() + ]); } } @@ -223,6 +256,17 @@ class ProductController extends Controller 'is_active' => $request->boolean('is_active', true), ]); + SystemOperationLog::create([ + 'company_id' => $product->company_id, + 'user_id' => auth()->id(), + 'module' => 'product', + 'action' => 'create', + 'target_id' => $product->id, + 'target_type' => Product::class, + 'note' => 'product_created', + 'new_values' => $product->toArray(), + ]); + DB::commit(); // Rebuild catalog cache @@ -336,7 +380,21 @@ class ProductController extends Controller $data['image_url'] = null; } + $oldValues = $product->toArray(); $product->update($data); + $newValues = $product->fresh()->toArray(); + + SystemOperationLog::create([ + 'company_id' => $product->company_id, + 'user_id' => auth()->id(), + 'module' => 'product', + 'action' => 'update', + 'target_id' => $product->id, + 'target_type' => Product::class, + 'note' => 'product_updated', + 'old_values' => $oldValues, + 'new_values' => $newValues, + ]); DB::commit(); @@ -366,8 +424,22 @@ class ProductController extends Controller { try { $product = Product::findOrFail($id); + $oldValues = $product->toArray(); $product->is_active = !$product->is_active; $product->save(); + $newValues = $product->fresh()->toArray(); + + SystemOperationLog::create([ + 'company_id' => $product->company_id, + 'user_id' => auth()->id(), + 'module' => 'product', + 'action' => 'update', + 'target_id' => $product->id, + 'target_type' => Product::class, + 'note' => 'product_status_toggled', + 'old_values' => $oldValues, + 'new_values' => $newValues, + ]); // Rebuild catalog cache $this->catalogService->rebuildCache($product->company_id); @@ -411,8 +483,20 @@ class ProductController extends Controller } $companyId = $product->company_id; + $oldValues = $product->toArray(); $product->delete(); + SystemOperationLog::create([ + 'company_id' => $companyId, + 'user_id' => auth()->id(), + 'module' => 'product', + 'action' => 'delete', + 'target_id' => $id, + 'target_type' => Product::class, + 'note' => 'product_deleted', + 'old_values' => $oldValues, + ]); + // Rebuild catalog cache $this->catalogService->rebuildCache($companyId); @@ -478,6 +562,17 @@ class ProductController extends Controller $user->id ); + SystemOperationLog::create([ + 'company_id' => $companyId, + 'user_id' => $user->id, + 'module' => 'product', + 'action' => 'sync', + 'target_id' => $companyId, + 'target_type' => Company::class, + 'note' => 'product_catalog_synced_to_all_machines', + 'new_values' => ['remark' => $remark], + ]); + return response()->json([ 'success' => true, 'message' => __('Batch sync command has been queued. Machines will be updated sequentially.') diff --git a/app/Models/System/SystemOperationLog.php b/app/Models/System/SystemOperationLog.php index 42b1edb..bded4dc 100644 --- a/app/Models/System/SystemOperationLog.php +++ b/app/Models/System/SystemOperationLog.php @@ -24,10 +24,13 @@ class SystemOperationLog extends Model return null; } - return __($this->note, array_merge( + // 過濾掉非字串/數值的數值,避免 Laravel 翻譯引擎處理巢狀陣列時報錯 + $replacements = collect(array_merge( $this->old_values ?? [], $this->new_values ?? [] - )); + ))->filter(fn($val) => is_scalar($val))->toArray(); + + return __($this->note, $replacements); } diff --git a/lang/zh_TW.json b/lang/zh_TW.json index d5279f6..318bf14 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -240,6 +240,7 @@ "Change Stock": "零錢庫存", "Channel Limits": "貨道上限", "Channel Limits (Track/Spring)": "貨道上限 (履帶/彈簧)", + "Channel Limits Config": "貨道上限配置", "Channel Limits Configuration": "貨道上限配置", "ChannelId": "ChannelId", "ChannelSecret": "ChannelSecret", @@ -688,6 +689,7 @@ "ITEM": "ITEM", "Identification": "識別資訊", "Identity & Codes": "識別與代碼", + "Identity and Codes": "識別與代碼", "Image": "圖片", "Image Downloaded": "圖片已下載", "Immediate": "立即", @@ -782,6 +784,7 @@ "Low Stock Alerts": "低庫存警示", "Low stock and out-of-stock alerts": "低庫存與缺貨警示", "Loyalty & Features": "行銷與點數", + "Marketing and Loyalty": "行銷與點數", "Machine": "機台", "Machine / Date": "機台 / 日期", "Machine / Employee": "機台 / 員工", @@ -1963,5 +1966,27 @@ "Manual Sync Products": "手動同步商品", "Please select a company.": "請選擇公司", "Batch sync command has been queued. Machines will be updated sequentially.": "批次同步指令已進入隊列,機台將依序進行更新", - "A sync command was recently sent. Please wait 1 minute.": "近期已發送過同步指令,請等待 1 分鐘後再試" + "A sync command was recently sent. Please wait 1 minute.": "近期已發送過同步指令,請等待 1 分鐘後再試", + "Operation Logs": "操作紀錄", + "product": "商品", + "category": "分類", + "product_created": "商品已建立", + "product_updated": "商品資訊已更新", + "product_status_toggled": "商品狀態已切換", + "product_deleted": "商品已刪除", + "product_catalog_synced_to_all_machines": "商品目錄已同步至所有機台", + "category_created": "分類已建立", + "category_updated": "分類資訊已更新", + "category_deleted": "分類已刪除", + "Search notes or values...": "搜尋備註或數值...", + "Filter by Company": "依公司篩選", + "No operation logs found": "找不到操作紀錄", + "Old Value": "舊資料", + "New Value": "新資料", + "Data Changes": "資料異動詳情", + "Field": "欄位", + "Old": "舊值", + "New": "新值", + "No changes detected": "未偵測到資料變更", + "Log Details": "操作紀錄詳情" } \ No newline at end of file diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php index f292ae3..ce419be 100644 --- a/resources/views/admin/products/index.blade.php +++ b/resources/views/admin/products/index.blade.php @@ -57,6 +57,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be + {{-- Tab Contents --}} @@ -98,6 +99,25 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be + {{-- Logs Tab --}} +
+ + {{-- Loading Overlay (Logs) --}} + + + + + + +
+ {{-- Content will be loaded via AJAX --}} +
+
+
@@ -284,7 +304,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
-

{{ __('Identity & Codes') }}

+

{{ __('Identity and Codes') }}

@@ -356,7 +376,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
-

{{ __('Channel Limits Configuration') }}

+

{{ __('Channel Limits Config') }}

@@ -377,7 +397,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
-

{{ __('Loyalty & Features') }}

+

{{ __('Marketing and Loyalty') }}

@@ -451,6 +471,120 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
+ + +
+ +
+ + +
+
+
+ + +
+
+

{{ __('Log Details') }}

+

+

+
+ +
+ + +
+ + +
+
+ {{ __('Time') }} +
+
+
+ {{ __('Operator') }} +
+
+
+ + +
+ {{ __('Target') }} +
+
+ + +
+

{{ __('Data Changes') }}

+ +
+ + + +
+
+
+ + +
+ +
+ +
+
+
@@ -553,7 +687,6 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be isDetailOpen: false, isImageZoomed: false, isStatusConfirmOpen: false, - isDeleteConfirmOpen: false, isCategoryModalOpen: false, confirmModal: { show: false, @@ -569,8 +702,12 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be deleteFormAction: '', toggleFormAction: '', selectedProduct: null, + selectedLog: null, + isLogPanelOpen: false, + expandedLogs: [], categories: [], companies: [], + translations: {}, categoryFormFields: { names: { zh_TW: '', en: '', ja: '' }, company_id: '' @@ -585,11 +722,22 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be 'Failed to send command' => __('Failed to send command'), 'Select Target Company' => __('Select Target Company'), 'Search Company...' => __('Search Company...'), + 'Field' => __('Field'), + 'Old' => __('Old'), + 'New' => __('New'), + 'No changes detected' => __('No changes detected'), + 'Close' => __('Close'), + 'Log Details' => __('Log Details'), ]); // Initial binding this.bindPaginationLinks('#tab-products-container', 'products'); this.bindPaginationLinks('#tab-categories-container', 'categories'); + this.bindPaginationLinks('#tab-logs-container', 'logs'); + + if (this.activeTab === 'logs') { + this.fetchTabData('logs'); + } this.$watch('confirmModal.show', (value) => { if (value && document.getElementById('sync_company_select_wrapper')) { @@ -627,15 +775,91 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be else bar.classList.remove('loading'); } }); - // Sync tab to URL (Clean up parameters from other tabs when switching) this.$watch('activeTab', (val) => { const url = new URL(window.location.origin + window.location.pathname); url.searchParams.set('tab', val); window.history.pushState({}, '', url); + + // Auto fetch logs if empty + if (val === 'logs') { + const container = document.getElementById('tab-logs-container'); + if (container && container.innerHTML.trim() === '') { + this.fetchTabData('logs'); + } + } }); }, + toggleLog(id) { + const idx = this.expandedLogs.indexOf(id); + if (idx > -1) { + this.expandedLogs.splice(idx, 1); + } else { + this.expandedLogs.push(id); + } + }, + + viewLogDetails(log) { + console.log('Viewing log details:', log); + this.selectedLog = log; + this.isLogPanelOpen = true; + }, + + getLogChanges(log) { + if (!log) return []; + const oldVals = log.old_values || {}; + const newVals = log.new_values || {}; + const changes = []; + const keys = new Set([...Object.keys(oldVals), ...Object.keys(newVals)]); + + // 排除不需顯示的欄位 + const excludeKeys = ['id', 'created_at', 'updated_at', 'deleted_at', 'name_dictionary_key', 'company_id', 'user_id', 'image_url']; + + keys.forEach(key => { + if (excludeKeys.includes(key)) return; + + const ov = oldVals[key]; + const nv = newVals[key]; + + if (JSON.stringify(ov) !== JSON.stringify(nv)) { + changes.push({ + key: key, + label: this.getFieldLabel(key), + old: this.formatValue(ov), + new: this.formatValue(nv) + }); + } + }); + + return changes; + }, + + getFieldLabel(key) { + const labels = { + 'name': '{{ __("Name") }}', + 'price': '{{ __("Price") }}', + 'cost': '{{ __("Cost") }}', + 'member_price': '{{ __("Member Price") }}', + 'barcode': '{{ __("Barcode") }}', + 'spec': '{{ __("Spec") }}', + 'manufacturer': '{{ __("Manufacturer") }}', + 'track_limit': '{{ __("Track Limit") }}', + 'spring_limit': '{{ __("Spring Limit") }}', + 'is_active': '{{ __("Is Active") }}', + 'category_id': '{{ __("Category") }}', + 'metadata': '{{ __("Metadata") }}' + }; + return labels[key] || key; + }, + + formatValue(val) { + if (val === null || val === undefined) return '-'; + if (typeof val === 'boolean') return val ? '{{ __("Yes") }}' : '{{ __("No") }}'; + if (typeof val === 'object') return JSON.stringify(val); + return val; + }, + async fetchTabData(tab, url = null) { this.tabLoading = tab; @@ -722,6 +946,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be // Explicitly remove page parameters to reset to page 1 params.delete('product_page'); params.delete('category_page'); + params.delete('log_page'); const url = window.location.pathname + '?' + params.toString(); this.fetchTabData(tab, url); @@ -764,7 +989,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be // Per page limit change: needs to build URL const params = new URLSearchParams(window.location.search); params.set('per_page', sel.value); - const pageKey = tab === 'products' ? 'product_page' : 'category_page'; + const pageKey = tab === 'products' ? 'product_page' : (tab === 'categories' ? 'category_page' : 'log_page'); params.delete(pageKey); // Reset to page 1 newUrl = window.location.pathname + '?' + params.toString(); } diff --git a/resources/views/admin/products/partials/tab-logs.blade.php b/resources/views/admin/products/partials/tab-logs.blade.php new file mode 100644 index 0000000..e0a2583 --- /dev/null +++ b/resources/views/admin/products/partials/tab-logs.blade.php @@ -0,0 +1,189 @@ +{{-- Filter Form --}} +
+ + + {{-- Company Filter (If Admin) --}} + @if(auth()->user()->isSystemAdmin()) +
+ +
+ @endif + + {{-- Search Keyword --}} +
+ + + + + + + +
+ + {{-- Date Range --}} +
+ + + + + + +
+ + {{-- Buttons --}} +
+ + +
+
+ +{{-- Desktop Table --}} + + +{{-- Card Mode (Mobile) --}} +
+ @forelse($logs as $log) +
+
+
+ + {{ __($log->module) }} + + +
+ {{ $log->created_at?->format('H:i:s') }} +
+ +

{{ $log->translated_note }}

+ +
+
+ #{{ $log->target_id }} + {{ $log->user->name ?? 'System' }} +
+ +
+
+ @empty + + @endforelse +
+ +{{-- Pagination --}} +
+ {{ $logs->links('vendor.pagination.luxury') }} +