[FEAT] 商品匯入支援圖片:Excel 圖片檔名欄 + 圖片壓縮檔背景處理
1. ProductImportService 新增 deferImages 模式與第 18 欄「圖片檔名」,匯入時收集 pending_images 供背景處理,並保留同步轉檔路徑。 2. 新增 AttachProductImagesJob:背景逐筆將圖片轉為 WebP 入庫、更新時刪除舊圖、重建目錄快取、清除暫存資料夾並寫入操作日誌。 3. ProductController::import 新增圖片 zip 上傳(上限 60MB)與安全解壓 extractImageZip:副檔名白名單、getimagesize 內容驗證、basename 防路徑穿越、項目數與單檔及總量上限防 zip bomb,解壓後派發背景 Job。 4. 匯入 Modal 新增選填的圖片 .zip 上傳欄位與背景處理提示文字。 5. config/queue.php 的 redis retry_after 提高至 1830(可由 REDIS_QUEUE_RETRY_AFTER 覆寫),避免長時間圖片任務未完成即被重複派發造成重複處理。 6. 三語系新增圖片匯入相關文案並對齊排序。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
02e4e99308
commit
4a86be6cbf
@ -594,6 +594,7 @@ class ProductController extends Controller
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx|max:5120',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'images' => 'nullable|file|mimes:zip|max:61440', // 60MB;落在 php 預設 100M 與 Cloudflare 100M 之內,免改機器設定
|
||||
]);
|
||||
|
||||
$companyId = $request->get('company_id') ?: auth()->user()->company_id;
|
||||
@ -605,23 +606,128 @@ class ProductController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$results = $importService->import(
|
||||
$request->file('file')->getRealPath(),
|
||||
$companyId,
|
||||
auth()->id()
|
||||
);
|
||||
// 有圖片壓縮檔:解壓到暫存目錄,圖片轉檔交由背景 Job 處理 (避免大量同步轉檔逾時)
|
||||
$imageDir = $request->hasFile('images') ? $this->extractImageZip($request->file('images')) : null;
|
||||
|
||||
try {
|
||||
$results = $importService->import(
|
||||
$request->file('file')->getRealPath(),
|
||||
$companyId,
|
||||
auth()->id(),
|
||||
$imageDir,
|
||||
deferImages: $imageDir !== null
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if ($imageDir && is_dir($imageDir)) {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($imageDir);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
foreach ($results['company_ids'] as $importedCompanyId) {
|
||||
$this->catalogService->rebuildCache($importedCompanyId);
|
||||
}
|
||||
|
||||
// 派發背景圖片處理 Job (Job 負責轉檔 + 重建快取 + 刪暫存目錄)
|
||||
$queuedImages = 0;
|
||||
if ($imageDir) {
|
||||
if (!empty($results['pending_images'])) {
|
||||
$queuedImages = count($results['pending_images']);
|
||||
\App\Jobs\Product\AttachProductImagesJob::dispatch(
|
||||
$results['pending_images'],
|
||||
$imageDir,
|
||||
$companyId,
|
||||
auth()->id()
|
||||
);
|
||||
} else {
|
||||
// 沒有任何圖片需處理,立即清掉暫存目錄
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($imageDir);
|
||||
}
|
||||
}
|
||||
|
||||
$message = __(':count products imported successfully', ['count' => $results['success_count']]);
|
||||
if ($queuedImages > 0) {
|
||||
$message .= ' · ' . __(':count images are being processed in the background', ['count' => $queuedImages]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __(':count products imported successfully', ['count' => $results['success_count']]),
|
||||
'message' => $message,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 將上傳的圖片壓縮檔安全解壓到暫存目錄,回傳目錄路徑。
|
||||
* 防護:副檔名白名單、basename 防穿越、項目數/單檔/總量上限 (防 zip bomb)、內容驗證為圖片。
|
||||
*/
|
||||
private function extractImageZip(\Illuminate\Http\UploadedFile $zipFile): string
|
||||
{
|
||||
$allowedExt = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
$maxEntries = 2000;
|
||||
$maxFileBytes = 5 * 1024 * 1024; // 單檔解壓上限 5MB
|
||||
$maxTotalBytes = 200 * 1024 * 1024; // 總解壓上限 200MB
|
||||
|
||||
$dir = storage_path('app/tmp/product_import_' . Str::uuid()->toString());
|
||||
\Illuminate\Support\Facades\File::ensureDirectoryExists($dir);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFile->getRealPath()) !== true) {
|
||||
throw new \RuntimeException(__('Unable to read the image archive'));
|
||||
}
|
||||
|
||||
if ($zip->numFiles > $maxEntries) {
|
||||
$zip->close();
|
||||
throw new \RuntimeException(__('The image archive contains too many files'));
|
||||
}
|
||||
|
||||
$totalBytes = 0;
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$entryName = $stat['name'];
|
||||
|
||||
// 跳過目錄與含路徑穿越的項目,只取檔名
|
||||
if (str_ends_with($entryName, '/') || str_contains($entryName, '..')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$base = basename($entryName);
|
||||
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExt, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($stat['size'] > $maxFileBytes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalBytes += $stat['size'];
|
||||
if ($totalBytes > $maxTotalBytes) {
|
||||
$zip->close();
|
||||
throw new \RuntimeException(__('The image archive is too large when extracted'));
|
||||
}
|
||||
|
||||
$stream = $zip->getStream($entryName);
|
||||
if ($stream === false) {
|
||||
continue;
|
||||
}
|
||||
$contents = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
|
||||
// 內容驗證:確認真為圖片,擋掉偽裝檔
|
||||
if ($contents === false || getimagesizefromstring($contents) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($dir . '/' . $base, $contents);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步商品目錄至全公司機台 (Phase 2)
|
||||
*/
|
||||
|
||||
99
app/Jobs/Product/AttachProductImagesJob.php
Normal file
99
app/Jobs/Product/AttachProductImagesJob.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Product;
|
||||
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Services\Product\ProductCatalogService;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 背景處理產品匯入的圖片:將已解壓資料夾中的圖片逐筆轉 WebP 入庫並寫回 image_url。
|
||||
* 與商品文字匯入解耦,避免大量圖片同步轉檔導致請求逾時。
|
||||
* 完成後重建目錄快取並刪除暫存圖片資料夾。
|
||||
*/
|
||||
class AttachProductImagesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, ImageHandler;
|
||||
|
||||
public int $timeout = 1800; // 30 分鐘,容納大量圖片轉檔
|
||||
public int $tries = 1; // 圖片步驟不重試整批(避免重複轉檔),失敗以 log 呈現
|
||||
|
||||
/**
|
||||
* @param array<int, array{product_id:int, filename:string, row:int}> $pendingImages
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $pendingImages,
|
||||
protected string $imageDir,
|
||||
protected ?int $companyId = null,
|
||||
protected ?int $userId = null
|
||||
) {}
|
||||
|
||||
public function handle(ProductCatalogService $catalogService): void
|
||||
{
|
||||
$success = 0;
|
||||
$missing = [];
|
||||
|
||||
try {
|
||||
foreach ($this->pendingImages as $item) {
|
||||
$product = Product::withoutGlobalScopes()->find($item['product_id']);
|
||||
if (!$product) {
|
||||
$missing[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = rtrim($this->imageDir, '/') . '/' . basename($item['filename']);
|
||||
if (!is_file($path) || getimagesize($path) === false) {
|
||||
$missing[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$oldImage = $product->image_url;
|
||||
$file = new UploadedFile($path, basename($path), null, null, true);
|
||||
$storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320);
|
||||
$product->update(['image_url' => Storage::url($storedPath)]);
|
||||
|
||||
if ($oldImage) {
|
||||
Storage::disk('public')->delete(str_replace('/storage/', '', $oldImage));
|
||||
}
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$missing[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
// 圖片已更新,重建目錄快取讓機台拿到新圖
|
||||
if ($this->companyId) {
|
||||
$catalogService->rebuildCache($this->companyId);
|
||||
}
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $this->companyId,
|
||||
'user_id' => $this->userId,
|
||||
'module' => 'product',
|
||||
'action' => 'update',
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_import_images_processed',
|
||||
'new_values' => [
|
||||
'image_success' => $success,
|
||||
'image_missing' => count($missing),
|
||||
'missing_detail' => $missing,
|
||||
],
|
||||
]);
|
||||
} finally {
|
||||
// 無論成敗,清掉暫存圖片資料夾
|
||||
if (is_dir($this->imageDir)) {
|
||||
File::deleteDirectory($this->imageDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,10 @@ use App\Models\Product\Product;
|
||||
use App\Models\Product\ProductCategory;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Models\System\Translation;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenSpout\Reader\XLSX\Reader;
|
||||
@ -15,7 +18,14 @@ use OpenSpout\Common\Entity\Row;
|
||||
|
||||
class ProductImportService
|
||||
{
|
||||
public function import(string $filePath, ?int $companyId = null, ?int $userId = null): array
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* @param string|null $imageDir 已解壓的圖片資料夾路徑 (Excel「圖片檔名」欄對應此資料夾內檔案)
|
||||
* @param bool $deferImages true 時不在此同步轉檔,改將 (product_id, filename) 收集到 results['pending_images']
|
||||
* 供呼叫端丟背景 Job 處理,避免大量圖片同步轉檔逾時。
|
||||
*/
|
||||
public function import(string $filePath, ?int $companyId = null, ?int $userId = null, ?string $imageDir = null, bool $deferImages = false): array
|
||||
{
|
||||
$reader = new Reader();
|
||||
$reader->open($filePath);
|
||||
@ -27,6 +37,9 @@ class ProductImportService
|
||||
'error_count' => 0,
|
||||
'errors' => [],
|
||||
'company_ids' => [],
|
||||
'image_success' => 0,
|
||||
'image_missing' => [],
|
||||
'pending_images' => [],
|
||||
];
|
||||
|
||||
$rowCount = 0;
|
||||
@ -65,6 +78,7 @@ class ProductImportService
|
||||
'points_half' => 'nullable|integer|min:0',
|
||||
'points_half_amount' => 'nullable|integer|min:0',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'image_filename' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -73,7 +87,7 @@ class ProductImportService
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($data, $rowCount, $userId, &$results) {
|
||||
DB::transaction(function () use ($data, $rowCount, $userId, $imageDir, $deferImages, &$results) {
|
||||
$categoryId = $this->resolveCategoryId($data['category'], $data['company_id']);
|
||||
$lookup = $this->buildProductLookup($data);
|
||||
$product = Product::withoutGlobalScopes()->where($lookup)->first();
|
||||
@ -116,6 +130,18 @@ class ProductImportService
|
||||
$note = 'product_import_created';
|
||||
}
|
||||
|
||||
if ($deferImages) {
|
||||
if ($imageDir && $data['image_filename']) {
|
||||
$results['pending_images'][] = [
|
||||
'product_id' => $product->id,
|
||||
'filename' => $data['image_filename'],
|
||||
'row' => $rowCount,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$this->attachImage($product, $data['image_filename'], $imageDir, $rowCount, $results);
|
||||
}
|
||||
|
||||
$results['success_count']++;
|
||||
if ($product->company_id) {
|
||||
$results['company_ids'][$product->company_id] = $product->company_id;
|
||||
@ -168,6 +194,7 @@ class ProductImportService
|
||||
'半額點數(選填)',
|
||||
'半額點數折抵金額(選填)',
|
||||
'是否啟用(1/0,選填)',
|
||||
'圖片檔名(選填,需一併上傳圖片壓縮檔)',
|
||||
]));
|
||||
|
||||
$writer->addRow(Row::fromValues([
|
||||
@ -188,6 +215,7 @@ class ProductImportService
|
||||
400,
|
||||
40,
|
||||
1,
|
||||
'latte.png',
|
||||
]));
|
||||
|
||||
$writer->close();
|
||||
@ -214,9 +242,46 @@ class ProductImportService
|
||||
'points_half' => (int) $this->numericValue($cells[14] ?? null, 0),
|
||||
'points_half_amount' => (int) $this->numericValue($cells[15] ?? null, 0),
|
||||
'is_active' => $this->booleanValue($cells[16] ?? null),
|
||||
'image_filename' => $this->stringValue($cells[17] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 依「圖片檔名」欄將已解壓資料夾中的圖片轉 WebP 入庫並寫回 image_url。
|
||||
* 失敗 (找不到檔/非圖) 只記錄至 image_missing,不影響商品本身寫入。
|
||||
*/
|
||||
private function attachImage(Product $product, ?string $filename, ?string $imageDir, int $rowCount, array &$results): void
|
||||
{
|
||||
if (!$filename || !$imageDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$path = rtrim($imageDir, '/') . '/' . basename($filename);
|
||||
|
||||
if (!is_file($path) || getimagesize($path) === false) {
|
||||
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
|
||||
return;
|
||||
}
|
||||
|
||||
$oldImage = $product->image_url;
|
||||
|
||||
$file = new UploadedFile($path, basename($path), null, null, true);
|
||||
$storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320);
|
||||
|
||||
$product->update(['image_url' => Storage::url($storedPath)]);
|
||||
|
||||
if ($oldImage) {
|
||||
$oldPath = str_replace('/storage/', '', $oldImage);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
|
||||
$results['image_success']++;
|
||||
} catch (\Throwable $e) {
|
||||
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
|
||||
}
|
||||
}
|
||||
|
||||
private function isEmptyRow(array $data): bool
|
||||
{
|
||||
return empty($data['category'])
|
||||
|
||||
@ -66,7 +66,8 @@ return [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => 90,
|
||||
// 須大於最長 Job 的 timeout(AttachProductImagesJob 1800s),否則長任務未跑完即被重派造成重複處理
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 1830),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
"30s": "30s",
|
||||
"60 Seconds": "60 Seconds",
|
||||
"60s": "60s",
|
||||
":count image files not found": ":count image files not found",
|
||||
":count images are being processed in the background": ":count images are being processed in the background",
|
||||
":count images matched": ":count images matched",
|
||||
":count products imported successfully": ":count products imported successfully",
|
||||
":count staff cards imported successfully": ":count staff cards imported successfully",
|
||||
"A new verification link has been sent to your email address.": "A new verification link has been sent to your email address.",
|
||||
@ -1407,6 +1410,7 @@
|
||||
"Optional remarks": "Optional remarks",
|
||||
"Optional remarks...": "Optional remarks...",
|
||||
"Optional. If you fill in any field, all fields below become required.": "Optional. If you fill in any field, all fields below become required.",
|
||||
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)",
|
||||
"Or Choose Custom Date": "Or Choose Custom Date",
|
||||
"Order Details": "Order Details",
|
||||
"Order Info": "Order Info",
|
||||
@ -1594,6 +1598,7 @@
|
||||
"Product Details": "Product Details",
|
||||
"Product ID": "Product ID",
|
||||
"Product Image": "Product Image",
|
||||
"Product Images (ZIP)": "Product Images (ZIP)",
|
||||
"Product Info": "Product Info",
|
||||
"Product Items": "Product Items",
|
||||
"Product List": "Product List",
|
||||
@ -2092,6 +2097,8 @@
|
||||
"The Super Admin role cannot be deleted.": "The Super Admin role cannot be deleted.",
|
||||
"The Super Admin role is immutable.": "The Super Admin role is immutable.",
|
||||
"The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.",
|
||||
"The image archive contains too many files": "The image archive contains too many files",
|
||||
"The image archive is too large when extracted": "The image archive is too large when extracted",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.",
|
||||
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "The machine will be notified to fetch the latest system settings. It may take a moment to apply.",
|
||||
@ -2182,6 +2189,7 @@
|
||||
"Type": "Type",
|
||||
"Type to search or leave blank for system defaults.": "Type to search or leave blank for system defaults.",
|
||||
"UI Elements": "UI Elements",
|
||||
"Unable to read the image archive": "Unable to read the image archive",
|
||||
"Unassigned": "Unassigned",
|
||||
"Unassigned / No Change": "Unassigned / No Change",
|
||||
"Unauthorized": "Unauthorized",
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
"30s": "30秒",
|
||||
"60 Seconds": "60秒",
|
||||
"60s": "60秒",
|
||||
":count image files not found": ":count 個の画像ファイルが見つかりません",
|
||||
":count images are being processed in the background": ":count 枚の画像をバックグラウンドで処理しています",
|
||||
":count images matched": ":count 枚の画像を紐付けました",
|
||||
":count products imported successfully": ":count products imported successfully",
|
||||
":count staff cards imported successfully": ":count 枚のスタッフカードが正常にインポートされました",
|
||||
"A new verification link has been sent to your email address.": "新しい確認リンクがメールアドレスに送信されました。",
|
||||
@ -1407,6 +1410,7 @@
|
||||
"Optional remarks": "任意の備考",
|
||||
"Optional remarks...": "任意の備考...",
|
||||
"Optional. If you fill in any field, all fields below become required.": "任意。いずれかの項目を入力すると、以下のすべての項目が必須になります。",
|
||||
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "任意。画像のファイル名を「画像ファイル名」列に記入し、画像を .zip にまとめてアップロードしてください (最大 60MB)",
|
||||
"Or Choose Custom Date": "または日付を選択",
|
||||
"Order Details": "注文詳細",
|
||||
"Order Info": "注文情報",
|
||||
@ -1594,6 +1598,7 @@
|
||||
"Product Details": "商品詳細",
|
||||
"Product ID": "商品ID",
|
||||
"Product Image": "商品画像",
|
||||
"Product Images (ZIP)": "商品画像 (ZIP)",
|
||||
"Product Info": "商品情報",
|
||||
"Product Items": "Product Items",
|
||||
"Product List": "商品リスト",
|
||||
@ -2092,6 +2097,8 @@
|
||||
"The Super Admin role cannot be deleted.": "特権管理者ロールは削除できません。",
|
||||
"The Super Admin role is immutable.": "特権管理者ロールは変更できません。",
|
||||
"The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。",
|
||||
"The image archive contains too many files": "画像アーカイブのファイル数が多すぎます",
|
||||
"The image archive is too large when extracted": "画像アーカイブの展開後のサイズが大きすぎます",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。",
|
||||
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "最新のシステム設定を取得するよう機台に通知しました。反映まで少々時間がかかる場合があります。",
|
||||
@ -2182,6 +2189,7 @@
|
||||
"Type": "タイプ",
|
||||
"Type to search or leave blank for system defaults.": "入力して検索、または空白のままでシステムデフォルトを使用します。",
|
||||
"UI Elements": "UI要素",
|
||||
"Unable to read the image archive": "画像アーカイブを読み込めません",
|
||||
"Unassigned": "未割り当て",
|
||||
"Unassigned / No Change": "未割り当て / 変更なし",
|
||||
"Unauthorized": "未授権",
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
"30s": "30秒",
|
||||
"60 Seconds": "60 秒",
|
||||
"60s": "60秒",
|
||||
":count image files not found": ":count 個圖片檔找不到",
|
||||
":count images are being processed in the background": ":count 張圖片背景處理中",
|
||||
":count images matched": "已配對 :count 張圖片",
|
||||
":count products imported successfully": "成功匯入 :count 筆商品",
|
||||
":count staff cards imported successfully": "成功匯入 :count 張員工識別卡",
|
||||
"A new verification link has been sent to your email address.": "已將新的驗證連結發送至您的電子郵件地址。",
|
||||
@ -1407,6 +1410,7 @@
|
||||
"Optional remarks": "選填備註",
|
||||
"Optional remarks...": "選填備註...",
|
||||
"Optional. If you fill in any field, all fields below become required.": "選填。只要填寫任一欄位,以下欄位即全部必填。",
|
||||
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "選填。在「圖片檔名」欄填寫檔名,再將圖片打包成 .zip 一併上傳 (上限 60MB)",
|
||||
"Or Choose Custom Date": "或選擇自訂日期",
|
||||
"Order Details": "訂單詳情",
|
||||
"Order Info": "單據資訊",
|
||||
@ -1594,6 +1598,7 @@
|
||||
"Product Details": "商品明細",
|
||||
"Product ID": "商品 ID",
|
||||
"Product Image": "商品圖片",
|
||||
"Product Images (ZIP)": "商品圖片壓縮檔 (ZIP)",
|
||||
"Product Info": "商品資訊",
|
||||
"Product Items": "商品品項",
|
||||
"Product List": "商品清單",
|
||||
@ -2092,6 +2097,8 @@
|
||||
"The Super Admin role cannot be deleted.": "超級管理員角色不可刪除。",
|
||||
"The Super Admin role is immutable.": "超級管理員角色不可修改。",
|
||||
"The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。",
|
||||
"The image archive contains too many files": "圖片壓縮檔內的檔案數量過多",
|
||||
"The image archive is too large when extracted": "圖片壓縮檔解壓後的體積過大",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。",
|
||||
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "已通知機台回抓最新系統設定,套用可能需要一點時間。",
|
||||
@ -2182,6 +2189,7 @@
|
||||
"Type": "類型",
|
||||
"Type to search or leave blank for system defaults.": "輸入關鍵字搜尋,或留空以使用系統預設。",
|
||||
"UI Elements": "UI元素",
|
||||
"Unable to read the image archive": "無法讀取圖片壓縮檔",
|
||||
"Unassigned": "未指派",
|
||||
"Unassigned / No Change": "未指派 / 不變動",
|
||||
"Unauthorized": "未授權",
|
||||
|
||||
@ -188,6 +188,27 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
{{ __('Product Images (ZIP)') }}
|
||||
</label>
|
||||
<div class="relative group">
|
||||
<input type="file" id="product-import-images" @change="importFormFields.images = $event.target.files[0]"
|
||||
accept=".zip" class="luxury-input w-full pr-12 cursor-pointer">
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-2 flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ __('Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-white/5 rounded-2xl p-4 border border-slate-100 dark:border-white/5">
|
||||
<h4 class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
{{ __('Download Template') }}
|
||||
@ -827,7 +848,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
names: { zh_TW: '', en: '', ja: '' },
|
||||
company_id: ''
|
||||
},
|
||||
importFormFields: { company_id: '{{ auth()->user()->company_id ?? "" }}', file: null },
|
||||
importFormFields: { company_id: '{{ auth()->user()->company_id ?? "" }}', file: null, images: null },
|
||||
|
||||
init() {
|
||||
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
|
||||
@ -1265,6 +1286,9 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
if (this.importFormFields.company_id) {
|
||||
formData.append('company_id', this.importFormFields.company_id);
|
||||
}
|
||||
if (this.importFormFields.images) {
|
||||
formData.append('images', this.importFormFields.images);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ route($baseRoute . '.import') }}`, {
|
||||
@ -1280,8 +1304,15 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
if (data.success) {
|
||||
this.isImportModalOpen = false;
|
||||
this.importFormFields.file = null;
|
||||
this.importFormFields.images = null;
|
||||
const input = document.getElementById('product-import-file');
|
||||
if (input) input.value = '';
|
||||
const imagesInput = document.getElementById('product-import-images');
|
||||
if (imagesInput) imagesInput.value = '';
|
||||
|
||||
if (data.results.image_missing && data.results.image_missing.length > 0) {
|
||||
console.warn('Image files not found for some rows:', data.results.image_missing);
|
||||
}
|
||||
sessionStorage.setItem('productImportToast', JSON.stringify({
|
||||
message: data.message,
|
||||
type: 'success'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user