[FEAT] 新增商品銷售排行欄位與尊榮徽章,並更新資料匯出引擎

1. 於商品成本分析報表(電腦版 Table)最左側新增「排行」欄位,完美對齊圓角卡片樣式。
2. 針對前三名(Top 3)銷售商品設計尊榮徽章:第 1 名黃金徽章、第 2 名白銀徽章、第 3 名青銅徽章,其餘名次採用精緻 Mono 灰字。
3. 行動版 Card 移除原先通用包裹圖示,替換為智能偵測之獎牌徽章(🥇🥈🥉)或排行數字。
4. 全面升級資料複製(TSV)、CSV、Excel 與 PDF 列印等四大導出引擎,將「排行」業務指標同步注入首欄,並處理 colspan 對齊。
5. 補強多語系翻譯,於 zh_TW.json、en.json 與 ja.json 中完美新增「Rank / 排行 / 順位」鍵值。
This commit is contained in:
sky121113 2026-05-18 14:55:23 +08:00
parent 1dc79f2cec
commit 90a77404fe
4 changed files with 53 additions and 19 deletions

View File

@ -1265,6 +1265,7 @@
"Quick Select": "Quick Select",
"Quick replenishment from this view": "Quick replenishment from this view",
"Quick search...": "Quick search...",
"Rank": "Rank",
"Real-time OEE analysis awaits": "Real-time OEE analysis awaits",
"Real-time Operation Logs (Last 50)": "Real-time Operation Logs (Last 50)",
"Real-time fleet efficiency and OEE metrics": "Real-time fleet efficiency and OEE metrics",

View File

@ -1265,6 +1265,7 @@
"Quick Select": "クイック選択",
"Quick replenishment from this view": "この画面からクイック補充",
"Quick search...": "クイック検索...",
"Rank": "順位",
"Real-time OEE analysis awaits": "リアルタイムOEE分析待機中",
"Real-time Operation Logs (Last 50)": "リアルタイム操作ログ (直近50件)",
"Real-time fleet efficiency and OEE metrics": "全機器のリアルタイム効率とOEE指標",

View File

@ -1284,6 +1284,7 @@
"Quick Select": "快速選取",
"Quick replenishment from this view": "從此畫面快速補貨",
"Quick search...": "快速搜尋...",
"Rank": "排行",
"Real-time OEE analysis awaits": "即時 OEE 分析預備中",
"Real-time Operation Logs (Last 50)": "即時操作日誌 (最後 50 筆)",
"Real-time fleet efficiency and OEE metrics": "全機台即時效率與 OEE 指標",

View File

@ -262,7 +262,12 @@
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<!-- Sortable columns -->
<th @click="sortBy('barcode')" class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 cursor-pointer hover:bg-slate-100/50 dark:hover:bg-slate-800/20 transition-colors rounded-l-2xl">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 rounded-l-2xl w-24">
<div class="flex items-center gap-1.5">
<span>{{ __('Rank') }}</span>
</div>
</th>
<th @click="sortBy('barcode')" class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 cursor-pointer hover:bg-slate-100/50 dark:hover:bg-slate-800/20 transition-colors">
<div class="flex items-center gap-1.5">
<span>{{ __('Product Barcode') }}</span>
<template x-if="sortField === 'barcode'">
@ -321,8 +326,22 @@
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
<template x-for="row in paginatedTableData" :key="row.product_id || row.barcode || row.name">
<template x-for="(row, index) in paginatedTableData" :key="row.product_id || row.barcode || row.name">
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-4 text-xs font-mono font-black text-slate-400 dark:text-slate-500">
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 1">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20 font-black shadow-sm">1</span>
</template>
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-400/10 text-slate-400 border border-slate-400/20 font-black shadow-sm">2</span>
</template>
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-700/10 text-amber-700 border border-amber-700/20 font-black shadow-sm">3</span>
</template>
<template x-if="((currentPage - 1) * pageSize) + index + 1 > 3">
<span x-text="((currentPage - 1) * pageSize) + index + 1" class="pl-2"></span>
</template>
</td>
<td class="px-6 py-4 text-xs font-black font-mono text-slate-400 dark:text-slate-500 tracking-widest" x-text="row.barcode || '-'"></td>
<td class="px-6 py-4 text-sm font-extrabold text-slate-700 dark:text-slate-200" x-text="row.name"></td>
<td class="px-6 py-4 text-sm font-black text-right text-slate-600 dark:text-slate-300 font-mono" x-text="Number(row.total_quantity).toLocaleString() + ' 件'"></td>
@ -340,7 +359,7 @@
<!-- Empty State Table -->
<template x-if="paginatedTableData.length === 0">
<tr>
<td colspan="7" class="py-12 text-center">
<td colspan="8" class="py-12 text-center">
<div class="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center text-slate-300 mb-4 scale-animation">
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M15 15l6 6m-6-6l-6-6m6 6l6-6m-6 6l-6 6" />
@ -356,14 +375,25 @@
<!-- Mobile List (xl:hidden) -->
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
<template x-for="row in paginatedTableData" :key="row.product_id || row.barcode || row.name">
<template x-for="(row, index) in paginatedTableData" :key="row.product_id || row.barcode || row.name">
<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">
<!-- 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">
📦
<div class="w-14 h-14 rounded-2xl flex items-center justify-center border transition-all duration-300 overflow-hidden shadow-sm shrink-0 font-black text-base"
:class="
((currentPage - 1) * pageSize) + index + 1 === 1 ? 'bg-amber-500/10 text-amber-500 border-amber-500/20' :
(((currentPage - 1) * pageSize) + index + 1 === 2 ? 'bg-slate-400/10 text-slate-400 border-slate-400/20' :
(((currentPage - 1) * pageSize) + index + 1 === 3 ? 'bg-amber-700/10 text-amber-700 border-amber-700/20' :
'bg-slate-100 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500'))
">
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 1"><span>🥇</span></template>
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 2"><span>🥈</span></template>
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 3"><span>🥉</span></template>
<template x-if="((currentPage - 1) * pageSize) + index + 1 > 3">
<span x-text="((currentPage - 1) * pageSize) + index + 1" class="text-sm font-black font-mono"></span>
</template>
</div>
<div class="min-w-0">
<h3 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" x-text="row.name"></h3>
@ -750,10 +780,10 @@
// Exporter Module: Copy formatted TSV data into system Clipboard
copyTableData() {
let tsv = '商品條碼\t商品名稱\t銷售數量\t銷售金額\t商品成本\t銷售利潤\t毛利率\n';
this.filteredTableData.forEach(row => {
let tsv = '排行\t商品條碼\t商品名稱\t銷售數量\t銷售金額\t商品成本\t銷售利潤\t毛利率\n';
this.filteredTableData.forEach((row, idx) => {
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
tsv += `${row.barcode || '-'}\t${row.name}\t${row.total_quantity}\t${row.total_sales}\t${row.total_cost}\t${row.total_profit}\t${margin}\n`;
tsv += `${idx + 1}\t${row.barcode || '-'}\t${row.name}\t${row.total_quantity}\t${row.total_sales}\t${row.total_cost}\t${row.total_profit}\t${margin}\n`;
});
navigator.clipboard.writeText(tsv).then(() => {
@ -766,11 +796,11 @@
// Exporter Module: Export UTF-8 BOM CSV for direct MS Excel compatibility
exportCSV() {
let csv = '商品條碼,商品名稱,銷售數量,銷售金額,商品成本,銷售利潤,毛利率\n';
this.filteredTableData.forEach(row => {
let csv = '排行,商品條碼,商品名稱,銷售數量,銷售金額,商品成本,銷售利潤,毛利率\n';
this.filteredTableData.forEach((row, idx) => {
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
const escapedName = `"${(row.name || '').replace(/"/g, '""')}"`;
csv += `${row.barcode || '-'},${escapedName},${row.total_quantity},${row.total_sales},${row.total_cost},${row.total_profit},${margin}\n`;
csv += `${idx + 1},${row.barcode || '-'},${escapedName},${row.total_quantity},${row.total_sales},${row.total_cost},${row.total_profit},${margin}\n`;
});
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
@ -791,16 +821,16 @@
html += `<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>`;
html += `<h2>商品成本分析報表</h2>`;
html += `<p>統計期間:${this.dateRange}</p>`;
html += `<table><thead><tr><th>商品條碼</th><th>商品名稱</th><th>銷售數量</th><th>銷售金額</th><th>商品成本</th><th>銷售利潤</th><th>毛利率</th></tr></thead><tbody>`;
html += `<table><thead><tr><th>排行</th><th>商品條碼</th><th>商品名稱</th><th>銷售數量</th><th>銷售金額</th><th>商品成本</th><th>銷售利潤</th><th>毛利率</th></tr></thead><tbody>`;
this.filteredTableData.forEach(row => {
this.filteredTableData.forEach((row, idx) => {
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
html += `<tr><td>${row.barcode || '-'}</td><td>${row.name}</td><td>${row.total_quantity}</td><td>${row.total_sales}</td><td>${row.total_cost}</td><td>${row.total_profit}</td><td>${margin}</td></tr>`;
html += `<tr><td>${idx + 1}</td><td>${row.barcode || '-'}</td><td>${row.name}</td><td>${row.total_quantity}</td><td>${row.total_sales}</td><td>${row.total_cost}</td><td>${row.total_profit}</td><td>${margin}</td></tr>`;
});
const totalMargin = this.summary.total_sales > 0 ? ((this.summary.total_profit / this.summary.total_sales) * 100).toFixed(1) + '%' : '0.0%';
html += `<tr style="font-weight: bold; background-color: #f8fafc;">
<td colspan="2">總計 (Total)</td>
<td colspan="3">總計 (Total)</td>
<td>${this.summary.total_quantity}</td>
<td>${this.summary.total_sales}</td>
<td>${this.summary.total_cost}</td>
@ -842,12 +872,13 @@
</style></head><body>`;
html += `<h1>商品成本分析報表</h1>`;
html += `<div class="subtitle">統計區間:${this.dateRange}</div>`;
html += `<table><thead><tr><th>商品條碼</th><th>商品名稱</th><th class="text-right">銷售數量</th><th class="text-right">銷售金額</th><th class="text-right">商品成本</th><th class="text-right">銷售利潤</th><th class="text-right">毛利率</th></tr></thead><tbody>`;
html += `<table><thead><tr><th>排行</th><th>商品條碼</th><th>商品名稱</th><th class="text-right">銷售數量</th><th class="text-right">銷售金額</th><th class="text-right">商品成本</th><th class="text-right">銷售利潤</th><th class="text-right">毛利率</th></tr></thead><tbody>`;
this.filteredTableData.forEach(row => {
this.filteredTableData.forEach((row, idx) => {
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
const profitClass = row.total_profit >= 0 ? 'text-success' : 'text-danger';
html += `<tr>
<td>${idx + 1}</td>
<td>${row.barcode || '-'}</td>
<td>${row.name}</td>
<td class="text-right">${row.total_quantity} </td>
@ -860,7 +891,7 @@
const totalMargin = this.summary.total_sales > 0 ? ((this.summary.total_profit / this.summary.total_sales) * 100).toFixed(1) + '%' : '0.0%';
html += `<tr style="font-weight: bold; background-color: #f8fafc;">
<td colspan="2">總計 (Total)</td>
<td colspan="3">總計 (Total)</td>
<td class="text-right">${this.summary.total_quantity} </td>
<td class="text-right">NT$ ${Number(this.summary.total_sales).toLocaleString()}</td>
<td class="text-right">NT$ ${Number(this.summary.total_cost).toLocaleString()}</td>