[PROMOTE] dev -> demo: 商品管理匯出(CSV/Excel)

1. feat(商品管理): 資料設定→商品管理新增「匯出」下拉(CSV/Excel),套當前篩選、欄位對齊匯入範本;匯入/匯出移至查詢/重設右邊;匯出連結加 download 屬性修正下載後畫面卡霧面
This commit is contained in:
twsystem1004 2026-07-10 02:04:33 +00:00
commit 650e2412da
6 changed files with 172 additions and 7 deletions

View File

@ -584,6 +584,136 @@ class ProductController extends Controller
}
}
/**
* 匯出商品資料CSV / Excel套用與 index 相同的篩選(搜尋/分類/公司)。
* 參考 SalesController::handleExport 的串流下載模式;欄位順序對齊匯入範本,方便編輯後回匯入。
*/
public function export(Request $request)
{
$user = auth()->user();
$type = $request->input('export', 'csv');
$isExcel = ($type === 'excel');
$ext = $isExcel ? 'xls' : 'csv';
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
// 與 index 相同的商品查詢與篩選(租戶隔離沿用 Product 全域 scope
$query = Product::with(['category.translations', 'translations', 'company']);
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('spec', 'like', "%{$search}%");
});
}
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
if ($user->isSystemAdmin() && $request->filled('product_company_id')) {
$query->where('company_id', $request->product_company_id);
}
// 欄位順序對齊匯入範本ProductImportService::downloadTemplate
$headers = [
'分類',
'商品名稱(zh_TW)',
'商品名稱(en)',
'商品名稱(ja)',
'條碼',
'規格',
'生產公司',
'售價',
'會員價',
'成本',
'履帶貨道上限',
'彈簧貨道上限',
'物料代碼',
'全額點數',
'半額點數',
'半額折抵金額',
'是否啟用',
'圖片檔名',
];
$filename = '商品資料_' . now()->format('YmdHis') . '.' . $ext;
$callback = function () use ($query, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOMExcel 開啟不亂碼
fputcsv($file, $headers);
}
$locale = app()->getLocale();
$query->orderBy('id')->chunk(500, function ($products) use ($file, $isExcel, $locale) {
foreach ($products as $p) {
$meta = is_array($p->metadata) ? $p->metadata : [];
$nameEn = optional($p->translations->firstWhere('locale', 'en'))->value;
$nameJa = optional($p->translations->firstWhere('locale', 'ja'))->value;
$categoryName = '';
if ($p->category) {
$catTrans = $p->category->translations->firstWhere('locale', $locale)
?? $p->category->translations->firstWhere('locale', 'zh_TW');
$categoryName = $catTrans->value ?? ($p->category->name ?? '');
}
$imageFilename = $p->image_url ? basename($p->image_url) : '';
$row = [
$categoryName,
$p->name,
$nameEn,
$nameJa,
$p->barcode,
$p->spec,
$p->manufacturer,
$p->price,
$p->member_price,
$p->cost,
$p->track_limit,
$p->spring_limit,
$meta['material_code'] ?? '',
$meta['points_full'] ?? 0,
$meta['points_half'] ?? 0,
$meta['points_half_amount'] ?? 0,
$p->is_active ? 1 : 0,
$imageFilename,
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $cell) {
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
}
public function downloadTemplate(ProductImportService $importService)
{
return $importService->downloadTemplate();

View File

@ -777,6 +777,7 @@
"Expiry Time": "Expiry Time",
"Expiry date tracking and warnings": "Expiry date tracking and warnings",
"Export Report": "Export Report",
"Export": "Export",
"Export to CSV": "Export to CSV",
"Export to Excel": "Export to Excel",
"External URL": "External URL",
@ -965,6 +966,8 @@
"Image Downloaded": "Image Downloaded",
"Immediate": "Immediate",
"Import Excel": "Import Excel",
"Export CSV": "Export CSV",
"Export Excel": "Export Excel",
"Import Products": "Import Products",
"Import Staff Cards": "Import Staff Cards",
"Import failed": "Import failed",

View File

@ -778,6 +778,7 @@
"Expiry Time": "到期時間",
"Expiry date tracking and warnings": "有效期限追蹤與預警",
"Export Report": "匯出報表",
"Export": "匯出",
"Export to CSV": "匯出成 CSV",
"Export to Excel": "匯出成 Excel",
"External URL": "外連 URL",
@ -966,6 +967,8 @@
"Image Downloaded": "圖片已下載",
"Immediate": "立即",
"Import Excel": "Excel 匯入",
"Export CSV": "匯出 CSV",
"Export Excel": "匯出 Excel",
"Import Products": "匯入商品",
"Import Staff Cards": "匯入員工識別卡",
"Import failed": "匯入失敗",

View File

@ -36,13 +36,6 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Add Product') }}</span>
</a>
<div class="order-2 w-full flex items-center gap-2 md:contents">
<button @click="isImportModalOpen = true" type="button"
class="btn-luxury-secondary whitespace-nowrap flex items-center gap-2 transition-all duration-300 flex-1 md:flex-none justify-center min-w-[126px] md:order-1">
<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.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Import Excel') }}</span>
</button>
<button @click="syncToAllMachines()"
class="btn-luxury-ghost group whitespace-nowrap flex items-center justify-center gap-2 transition-all duration-300 py-2 sm:py-2.5 px-4 sm:px-6 rounded-2xl border-slate-200 dark:border-white/10 hover:border-cyan-500/50 hover:bg-cyan-500/5 flex-1 md:flex-none min-w-[150px] md:order-2">
<svg class="w-4 h-4 text-cyan-500 group-hover:rotate-180 transition-transform duration-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">

View File

@ -62,6 +62,41 @@ $baseRoute = 'admin.data-config.products';
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button @click="isImportModalOpen = true" type="button"
class="px-4 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
title="{{ __('Import Excel') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="hidden sm:inline">{{ __('Import Excel') }}</span>
</button>
<div class="relative" x-data="{ exportOpen: false }">
<button type="button" @click="exportOpen = !exportOpen" @click.away="exportOpen = false"
class="px-4 py-2.5 rounded-xl bg-emerald-500 text-white hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
title="{{ __('Export') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span class="hidden sm:inline">{{ __('Export') }}</span>
<svg class="w-3 h-3 transition-transform duration-200" :class="exportOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div x-show="exportOpen" x-transition
class="absolute right-0 top-full mt-2 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl rounded-2xl p-2 z-30 min-w-[160px]"
x-cloak>
<a href="{{ route($baseRoute . '.export', array_merge(request()->only(['search','category_id','product_company_id']), ['export' => 'csv'])) }}"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📄 {{ __('Export to CSV') }}
</a>
<a href="{{ route($baseRoute . '.export', array_merge(request()->only(['search','category_id','product_company_id']), ['export' => 'excel'])) }}"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📊 {{ __('Export to Excel') }}
</a>
</div>
</div>
</div>
<input type="hidden" name="tab" value="products">

View File

@ -213,6 +213,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::prefix('data-config')->name('data-config.')->group(function () {
Route::middleware('can:menu.data-config.products')->group(function () {
Route::get('/products/template', [App\Http\Controllers\Admin\ProductController::class, 'downloadTemplate'])->name('products.template');
Route::get('/products/export', [App\Http\Controllers\Admin\ProductController::class, 'export'])->name('products.export');
Route::post('/products/import', [App\Http\Controllers\Admin\ProductController::class, 'import'])->name('products.import');
Route::resource('products', App\Http\Controllers\Admin\ProductController::class)->except(['show']);
Route::patch('/products/{id}/toggle-status', [App\Http\Controllers\Admin\ProductController::class, 'toggleStatus'])->name('products.status.toggle');