This commit is contained in:
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.133"
|
||||
SYSTEM_VERSION = "V10.134"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -284,6 +284,149 @@
|
||||
|
||||
/* ── 9. 響應式 ─────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.ms-page-head { grid-template-columns: 1fr; }
|
||||
.ms-filter-card { position: static; }
|
||||
.ms-page {
|
||||
gap: var(--momo-space-3);
|
||||
}
|
||||
|
||||
.ms-page-head {
|
||||
grid-template-columns: 1fr;
|
||||
padding: var(--momo-space-4);
|
||||
}
|
||||
|
||||
.ms-page-head__title {
|
||||
align-items: flex-start;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ms-page-head__sub,
|
||||
.ms-page-head__sub code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ms-filter-card {
|
||||
position: static;
|
||||
padding: var(--momo-space-3);
|
||||
}
|
||||
|
||||
.ms-filter-group::before {
|
||||
top: 30px;
|
||||
bottom: 4px;
|
||||
}
|
||||
|
||||
.ms-filter-card__refresh {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.ms-chart-card,
|
||||
.ms-special,
|
||||
.ms-data-table {
|
||||
margin-bottom: var(--momo-space-3) !important;
|
||||
}
|
||||
|
||||
.ms-chart-card__head,
|
||||
.ms-special__head,
|
||||
.ms-data-table__head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--momo-space-2);
|
||||
padding: var(--momo-space-3);
|
||||
}
|
||||
|
||||
.ms-chart-card__title,
|
||||
.ms-special__title,
|
||||
.ms-data-table__title {
|
||||
align-items: flex-start;
|
||||
font-size: var(--momo-text-base);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ms-chart-card .card-body,
|
||||
.ms-special .card-body {
|
||||
overflow: hidden;
|
||||
padding: var(--momo-space-3);
|
||||
}
|
||||
|
||||
.ms-chart-card__canvas {
|
||||
height: 300px !important;
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
#vendorRankingChart {
|
||||
height: 620px !important;
|
||||
}
|
||||
|
||||
#divisionDistChart,
|
||||
#priceRangeChart {
|
||||
height: 280px !important;
|
||||
}
|
||||
|
||||
#bcgMatrixChart,
|
||||
#priceVolumeScatterChart,
|
||||
#seasonalityHeatmapChart,
|
||||
#areaRankingChart,
|
||||
#specialChart,
|
||||
#bodyCareChart,
|
||||
#makeupFragranceChart,
|
||||
#privacyInfantChart {
|
||||
height: 320px !important;
|
||||
}
|
||||
|
||||
.ms-chart-card .table-responsive,
|
||||
.ms-chart-card__scroll,
|
||||
#compareChartDataTable,
|
||||
#specialChartDataTable,
|
||||
#bodyCareChartDataTable,
|
||||
#makeupFragranceChartDataTable,
|
||||
#privacyInfantChartDataTable {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ms-data-table__import {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.ms-data-table .card-body {
|
||||
padding: var(--momo-space-2);
|
||||
}
|
||||
|
||||
.ms-data-table .table-responsive {
|
||||
border: 1px solid var(--ms-card-border);
|
||||
border-radius: var(--momo-radius-md);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ms-data-table__table {
|
||||
min-width: 760px;
|
||||
font-size: var(--momo-text-xs);
|
||||
}
|
||||
|
||||
.ms-data-table__table thead th {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .row {
|
||||
gap: var(--momo-space-2);
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .row > [class*="col-"] {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_length,
|
||||
.dataTables_wrapper .dataTables_filter,
|
||||
.dataTables_wrapper .dataTables_info,
|
||||
.dataTables_wrapper .dataTables_paginate {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dataTables_filter label,
|
||||
.dataTables_wrapper .dataTables_filter input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
let specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart;
|
||||
|
||||
let currentFilters = { year: '', month: '', area: '', vendor: '', trade: '' };
|
||||
const compactMediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
|
||||
function isCompactViewport() {
|
||||
return compactMediaQuery.matches;
|
||||
}
|
||||
|
||||
$(function () {
|
||||
compareChart = echarts.init(document.getElementById('compareChart'));
|
||||
@@ -35,6 +40,8 @@
|
||||
|
||||
table = $('#summaryTable').DataTable({
|
||||
language: { url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' },
|
||||
autoWidth: false,
|
||||
deferRender: true,
|
||||
pageLength: 20,
|
||||
order: [[0, 'desc'], [7, 'desc']],
|
||||
columnDefs: [
|
||||
@@ -49,6 +56,12 @@
|
||||
specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart]
|
||||
.forEach(c => c && c.resize());
|
||||
});
|
||||
const refreshForViewport = () => fetchData();
|
||||
if (compactMediaQuery.addEventListener) {
|
||||
compactMediaQuery.addEventListener('change', refreshForViewport);
|
||||
} else if (compactMediaQuery.addListener) {
|
||||
compactMediaQuery.addListener(refreshForViewport);
|
||||
}
|
||||
|
||||
fetchData();
|
||||
});
|
||||
@@ -184,6 +197,7 @@
|
||||
// ── Excel-style 主對比圖 ─────────────────────────────
|
||||
function renderExcelChart(chart, tableBodyId, trend) {
|
||||
if (!trend) return;
|
||||
const compact = isCompactViewport();
|
||||
const years = [...new Set(trend.map(t => t.date.split('/')[0]))].sort().reverse();
|
||||
const currYear = years[0] || String(new Date().getFullYear());
|
||||
const prevYear = String(parseInt(currYear) - 1);
|
||||
@@ -214,20 +228,30 @@
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: [currYear, prevYear, 'YOY'], bottom: 0, show: false },
|
||||
grid: { left: '80', right: '50', top: '50', bottom: '20' },
|
||||
xAxis: { type: 'category', data: months, axisLabel: { show: false }, axisTick: { show: true } },
|
||||
grid: {
|
||||
left: compact ? '42' : '80',
|
||||
right: compact ? '16' : '50',
|
||||
top: compact ? '26' : '50',
|
||||
bottom: compact ? '28' : '20'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: months,
|
||||
axisLabel: { show: compact, interval: 0, fontSize: 10 },
|
||||
axisTick: { show: true }
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '業績', axisLabel: { formatter: v => v.toLocaleString() }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: 'YoY', axisLabel: { formatter: '{value}%' }, splitLine: { show: false } }
|
||||
{ type: 'value', name: compact ? '' : '業績', axisLabel: { formatter: v => compact ? (v/10000).toFixed(0) + '萬' : v.toLocaleString(), fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
{ type: 'value', name: compact ? '' : 'YoY', axisLabel: { formatter: '{value}%', fontSize: compact ? 10 : 12 }, splitLine: { show: false } }
|
||||
],
|
||||
series: [
|
||||
{ name: currYear, type: 'bar', data: currData, barMaxWidth: 20,
|
||||
label: { show: true, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } },
|
||||
{ name: prevYear, type: 'bar', data: prevData, barMaxWidth: 20,
|
||||
label: { show: true, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } },
|
||||
{ name: currYear, type: 'bar', data: currData, barMaxWidth: compact ? 12 : 20,
|
||||
label: { show: !compact, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } },
|
||||
{ name: prevYear, type: 'bar', data: prevData, barMaxWidth: compact ? 12 : 20,
|
||||
label: { show: !compact, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } },
|
||||
{ name: 'YOY', type: 'line', yAxisIndex: 1, data: yoyData, smooth: true,
|
||||
lineStyle: { width: 2 }, symbol: 'circle', symbolSize: 6,
|
||||
label: { show: true, position: 'bottom', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? p.value + '%' : '' } }
|
||||
lineStyle: { width: compact ? 1.5 : 2 }, symbol: 'circle', symbolSize: compact ? 4 : 6,
|
||||
label: { show: !compact, position: 'bottom', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? p.value + '%' : '' } }
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -235,6 +259,7 @@
|
||||
// ── YoY Trend ─────────────────────────────────────────
|
||||
function renderYoYTrendChart(chart, tableId, data) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const dates = data.map(d => d.date.split('/')[1] + '月');
|
||||
const currData = data.map(d => d.curr);
|
||||
const yoaData = data.map(d => d.yoa);
|
||||
@@ -249,10 +274,10 @@
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '60', right: '30', top: '40', bottom: '20' },
|
||||
legend: { data: [currYearStr, '去年同期'], bottom: 0 },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬' } },
|
||||
grid: { left: compact ? '42' : '60', right: compact ? '14' : '30', top: compact ? '24' : '40', bottom: compact ? '26' : '20' },
|
||||
legend: { data: [currYearStr, '去年同期'], bottom: 0, show: !compact },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: compact ? 10 : 12 } },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 12 } },
|
||||
series: [
|
||||
{ name: currYearStr, type: 'line', data: currData, lineStyle: { width: 3 }, showSymbol: false, areaStyle: { opacity: 0.12 } },
|
||||
{ name: '去年同期', type: 'line', data: yoaData, lineStyle: { type: 'dashed' }, showSymbol: false }
|
||||
@@ -263,19 +288,20 @@
|
||||
// ── 類別分佈 (雙圓餅) ─────────────────────────────────
|
||||
function renderDivisionDistChart(chart, _tableId, data) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const pie = key => data.map(d => ({ name: d.name, value: d[key] || 0 })).filter(d => d.value > 0);
|
||||
chart.setOption({
|
||||
title: [
|
||||
{ text: '2024 年', left: '23%', top: '5%', textAlign: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
|
||||
{ text: '2025 年', left: '73%', top: '5%', textAlign: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } }
|
||||
{ text: '2024 年', left: '23%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } },
|
||||
{ text: '2025 年', left: '73%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } }
|
||||
],
|
||||
tooltip: { trigger: 'item', formatter: p => `<strong>${p.name}</strong><br/>業績: ${(p.value/10000).toFixed(0)}萬<br/>佔比: ${p.percent.toFixed(1)}%` },
|
||||
legend: { type: 'scroll', orient: 'horizontal', bottom: 0, textStyle: { fontSize: 12, fontWeight: 'bold' } },
|
||||
legend: { type: 'scroll', orient: 'horizontal', bottom: 0, show: !compact, textStyle: { fontSize: 12, fontWeight: 'bold' } },
|
||||
series: [
|
||||
{ name: '2024 年', type: 'pie', radius: ['30%', '60%'], center: ['25%', '55%'], data: pie('sales_2024'),
|
||||
label: { show: true, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%` } },
|
||||
{ name: '2025 年', type: 'pie', radius: ['30%', '60%'], center: ['75%', '55%'], data: pie('sales_2025'),
|
||||
label: { show: true, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%` } }
|
||||
{ name: '2024 年', type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['25%', compact ? '53%' : '55%'], data: pie('sales_2024'),
|
||||
label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%` } },
|
||||
{ name: '2025 年', type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['75%', compact ? '53%' : '55%'], data: pie('sales_2025'),
|
||||
label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}萬\n${p.percent.toFixed(1)}%` } }
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -283,6 +309,7 @@
|
||||
// ── 價格帶 ────────────────────────────────────────────
|
||||
function renderPriceRangeChart(chart, tableId, data) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const order = ['0-499','500-999','1,000-1,999','2,000-4,999','5,000-9,999','10,000+'];
|
||||
data.sort((a,b) => order.indexOf(a.range) - order.indexOf(b.range));
|
||||
const t24 = data.reduce((s, x) => s + (x.sales_2024 || 0), 0);
|
||||
@@ -297,15 +324,15 @@
|
||||
return `${p.marker} ${p.seriesName}: ${v.toLocaleString()} (${pct})`;
|
||||
}).join('<br/>')
|
||||
},
|
||||
legend: { data: ['2024 銷售', '2025 銷售'], bottom: 0, textStyle: { fontSize: 13, fontWeight: 'bold' } },
|
||||
grid: { left: '3%', right: '4%', bottom: '12%', top: '15%', containLabel: true },
|
||||
xAxis: { type: 'category', data: data.map(d => d.range), axisLabel: { interval: 0, fontSize: 13, fontWeight: 'bold' } },
|
||||
yAxis: { type: 'value', name: '銷售額', nameTextStyle: { fontSize: 12, fontWeight: 'bold' },
|
||||
axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: 12 } },
|
||||
legend: { data: ['2024 銷售', '2025 銷售'], bottom: 0, show: !compact, textStyle: { fontSize: 13, fontWeight: 'bold' } },
|
||||
grid: { left: compact ? '2%' : '3%', right: compact ? '2%' : '4%', bottom: compact ? '18%' : '12%', top: compact ? '8%' : '15%', containLabel: true },
|
||||
xAxis: { type: 'category', data: data.map(d => d.range), axisLabel: { interval: 0, rotate: compact ? 32 : 0, fontSize: compact ? 10 : 13, fontWeight: 'bold' } },
|
||||
yAxis: { type: 'value', name: compact ? '' : '銷售額', nameTextStyle: { fontSize: 12, fontWeight: 'bold' },
|
||||
axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 12 } },
|
||||
series: ['2024','2025'].map((y) => ({
|
||||
name: y + ' 銷售', type: 'bar', barWidth: 30,
|
||||
name: y + ' 銷售', type: 'bar', barWidth: compact ? 13 : 30,
|
||||
data: data.map(d => d['sales_' + y] || 0),
|
||||
label: { show: true, position: 'top', fontSize: 12, fontWeight: 'bold',
|
||||
label: { show: !compact, position: 'top', fontSize: 12, fontWeight: 'bold',
|
||||
formatter: p => {
|
||||
const v = p.value || 0;
|
||||
const total = y === '2024' ? t24 : t25;
|
||||
@@ -321,19 +348,20 @@
|
||||
// ── BCG 矩陣(4 象限顯式配色) ───────────────────────
|
||||
function renderBCGMatrixChart(chart, data) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const C_STAR = 'var(--momo-warm-olive, #6f7a4a)';
|
||||
const C_COW = 'var(--momo-warm-honey, #c89043)';
|
||||
const C_QUESTION = 'var(--momo-warm-caramel, #c96442)';
|
||||
const C_DOG = 'var(--momo-warm-mahogany, #7a3b2c)';
|
||||
chart.setOption({
|
||||
tooltip: { formatter: p => `<div style="font-weight:bold">${p.value[3]}</div><div>毛利率: ${p.value[0]}%</div><div>銷售額: $${p.value[1].toLocaleString()}</div><div>銷量: ${p.value[2].toLocaleString()}</div>` },
|
||||
grid: { left: '80', right: '50', top: '30', bottom: '30' },
|
||||
xAxis: { name: '毛利率(%)', type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
yAxis: { name: '業績($)', type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
grid: { left: compact ? '46' : '80', right: compact ? '16' : '50', top: compact ? '18' : '30', bottom: compact ? '34' : '30' },
|
||||
xAxis: { name: compact ? '' : '毛利率(%)', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
yAxis: { name: compact ? '' : '業績($)', type: 'value', axisLabel: { formatter: v => compact ? (v/10000).toFixed(0) + '萬' : v.toLocaleString(), fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
series: [{
|
||||
type: 'scatter',
|
||||
data: data.map(d => [d.margin, d.sales, d.qty, d.name]),
|
||||
symbolSize: d => Math.sqrt(d[2]) * 1.5,
|
||||
symbolSize: d => Math.min(compact ? 52 : 96, Math.max(compact ? 8 : 10, Math.sqrt(Math.max(d[2], 1)) * (compact ? 0.35 : 0.75))),
|
||||
itemStyle: {
|
||||
color: p => {
|
||||
if (p.value[0] >= 30 && p.value[1] >= 500000) return C_STAR;
|
||||
@@ -344,8 +372,8 @@
|
||||
opacity: 0.78, borderColor: '#fff8ef', borderWidth: 1
|
||||
},
|
||||
markLine: { silent: true, data: [
|
||||
{ xAxis: 30, lineStyle: { color: '#999' }, label: { formatter: '高獲利線 (30%)' } },
|
||||
{ yAxis: 500000, lineStyle: { color: '#999' }, label: { formatter: '高營收線 ($50萬)' } }
|
||||
{ xAxis: 30, lineStyle: { color: '#999' }, label: { show: !compact, formatter: '高獲利線 (30%)' } },
|
||||
{ yAxis: 500000, lineStyle: { color: '#999' }, label: { show: !compact, formatter: '高營收線 ($50萬)' } }
|
||||
] }
|
||||
}]
|
||||
});
|
||||
@@ -354,35 +382,37 @@
|
||||
// ── 散佈圖(價/量) ──────────────────────────────────
|
||||
function renderScatterChart(chart, data) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const series = data.map(d => {
|
||||
const avg = d.qty > 0 ? Math.round(d.sales / d.qty) : 0;
|
||||
return [avg, d.qty, d.sales, d.name];
|
||||
});
|
||||
chart.setOption({
|
||||
tooltip: { formatter: p => `<div style="font-weight:bold">${p.value[3]}</div><div>均價: $${p.value[0]}</div><div>銷量: ${p.value[1].toLocaleString()}</div><div>業績: $${p.value[2].toLocaleString()}</div>` },
|
||||
grid: { left: '60', right: '50', top: '30', bottom: '30' },
|
||||
xAxis: { name: '均價($)', type: 'value', splitLine: { show: false } },
|
||||
yAxis: { name: '銷量', type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
series: [{ type: 'scatter', data: series, symbolSize: d => Math.log(Math.max(d[2],1)) * 3, itemStyle: { opacity: 0.65 } }]
|
||||
grid: { left: compact ? '42' : '60', right: compact ? '16' : '50', top: compact ? '18' : '30', bottom: compact ? '32' : '30' },
|
||||
xAxis: { name: compact ? '' : '均價($)', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { show: false } },
|
||||
yAxis: { name: compact ? '' : '銷量', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } },
|
||||
series: [{ type: 'scatter', data: series, symbolSize: d => Math.min(compact ? 36 : 64, Math.max(compact ? 6 : 8, Math.log(Math.max(d[2],1)) * (compact ? 1.35 : 2))), itemStyle: { opacity: 0.65 } }]
|
||||
});
|
||||
}
|
||||
|
||||
// ── 廠商排行 ──────────────────────────────────────────
|
||||
function renderVendorRankingChart(chart, tableId, data) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const cd = data.slice().reverse();
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { data: ['2024 銷售額', '2025 銷售額'], bottom: 0, textStyle: { fontSize: 15, fontWeight: 'bold' } },
|
||||
grid: { left: '160', right: '110', top: '20', bottom: '45' },
|
||||
xAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: 14, fontWeight: 'bold' } },
|
||||
yAxis: { type: 'category', data: cd.map(d => d.name), axisLabel: { width: 150, overflow: 'truncate', interval: 0, fontSize: 14, fontWeight: 'bold' } },
|
||||
legend: { data: ['2024 銷售額', '2025 銷售額'], bottom: 0, show: !compact, textStyle: { fontSize: 15, fontWeight: 'bold' } },
|
||||
grid: { left: compact ? '86' : '160', right: compact ? '18' : '110', top: compact ? '10' : '20', bottom: compact ? '28' : '45' },
|
||||
xAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
|
||||
yAxis: { type: 'category', data: cd.map(d => d.name), axisLabel: { width: compact ? 78 : 150, overflow: 'truncate', interval: 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
|
||||
series: [
|
||||
{ name: '2024 銷售額', type: 'bar', data: cd.map(d => d.sales_2024 || 0), barWidth: 18, barGap: '0%', barCategoryGap: '75%',
|
||||
label: { show: true, position: 'insideRight', fontSize: 13, fontWeight: 'bold', color: '#fff8ef', textShadowColor: 'rgba(42,37,32,0.5)', textShadowBlur: 2,
|
||||
{ name: '2024 銷售額', type: 'bar', data: cd.map(d => d.sales_2024 || 0), barWidth: compact ? 10 : 18, barGap: '0%', barCategoryGap: compact ? '70%' : '75%',
|
||||
label: { show: !compact, position: 'insideRight', fontSize: 13, fontWeight: 'bold', color: '#fff8ef', textShadowColor: 'rgba(42,37,32,0.5)', textShadowBlur: 2,
|
||||
formatter: p => p.value > 0 ? (p.value/10000).toFixed(0) + '萬' : '' } },
|
||||
{ name: '2025 銷售額', type: 'bar', data: cd.map(d => d.sales_2025 || 0), barWidth: 18,
|
||||
label: { show: true, position: 'right', fontSize: 14, fontWeight: 'bold',
|
||||
{ name: '2025 銷售額', type: 'bar', data: cd.map(d => d.sales_2025 || 0), barWidth: compact ? 10 : 18,
|
||||
label: { show: !compact, position: 'right', fontSize: 14, fontWeight: 'bold',
|
||||
formatter: p => p.value > 0 ? (p.value/10000).toFixed(0) + '萬' : '' } }
|
||||
]
|
||||
});
|
||||
@@ -417,6 +447,7 @@
|
||||
// ── 季節熱力圖 ────────────────────────────────────────
|
||||
function renderSeasonalityHeatmap(chart, data) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||||
const areaSales = {};
|
||||
data.forEach(d => { areaSales[d.category] = (areaSales[d.category] || 0) + d.sales; });
|
||||
@@ -432,13 +463,13 @@
|
||||
chart.setOption({
|
||||
tooltip: { position: 'top',
|
||||
formatter: p => `<strong>${yLabels[p.value[1]]}</strong><br/>${months[p.value[0]]}<br/>業績: <strong>${(p.value[2]/10000).toFixed(0)}萬</strong>` },
|
||||
grid: { height: '75%', top: '5%', bottom: '18%', left: '15%', right: '3%' },
|
||||
xAxis: { type: 'category', data: months, splitArea: { show: true }, axisLabel: { fontSize: 14, fontWeight: 'bold' } },
|
||||
yAxis: { type: 'category', data: yLabels, splitArea: { show: true }, axisLabel: { fontSize: 14, fontWeight: 'bold' } },
|
||||
visualMap: { min: 0, max, calculable: true, orient: 'horizontal', left: 'center', bottom: '0%', textStyle: { fontSize: 12 },
|
||||
grid: { height: compact ? '72%' : '75%', top: compact ? '3%' : '5%', bottom: compact ? '20%' : '18%', left: compact ? '72' : '15%', right: compact ? '5' : '3%' },
|
||||
xAxis: { type: 'category', data: months, splitArea: { show: true }, axisLabel: { fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
|
||||
yAxis: { type: 'category', data: yLabels, splitArea: { show: true }, axisLabel: { width: compact ? 66 : 120, overflow: 'truncate', fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
|
||||
visualMap: { min: 0, max, calculable: true, orient: 'horizontal', left: 'center', bottom: '0%', textStyle: { fontSize: compact ? 10 : 12 },
|
||||
inRange: { color: ['#fef3c7', '#f5c98a', '#e3a560', '#c96442', '#7a3b2c'] } },
|
||||
series: [{ type: 'heatmap', data: heatmap,
|
||||
label: { show: true, fontSize: 12, fontWeight: 'bold', formatter: p => (p.value[2]/10000).toFixed(0) + '萬' },
|
||||
label: { show: !compact, fontSize: 12, fontWeight: 'bold', formatter: p => (p.value[2]/10000).toFixed(0) + '萬' },
|
||||
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.45)' } } }]
|
||||
});
|
||||
}
|
||||
@@ -446,17 +477,18 @@
|
||||
// ── 區域排行 ──────────────────────────────────────────
|
||||
function renderAreaRankingChart(chart, tableId, data) {
|
||||
if (!data || !data.length) return;
|
||||
const compact = isCompactViewport();
|
||||
data.sort((a,b) => b.sales - a.sales);
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' },
|
||||
formatter: ps => ps[0].name + '<br/>' + ps.map(p => `${p.marker} ${p.seriesName}: ${(p.value || 0).toLocaleString()}`).join('<br/>') },
|
||||
legend: { data: ['2024 業績', '2025 業績'], bottom: 0, textStyle: { fontSize: 14 } },
|
||||
grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true },
|
||||
xAxis: { type: 'category', data: data.map(d => d.name), axisLabel: { interval: 0, fontSize: 14, fontWeight: 'bold' } },
|
||||
yAxis: { type: 'value', name: '業績', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: 13 } },
|
||||
legend: { data: ['2024 業績', '2025 業績'], bottom: 0, show: !compact, textStyle: { fontSize: 14 } },
|
||||
grid: { left: compact ? '2%' : '3%', right: compact ? '2%' : '4%', bottom: compact ? '20%' : '10%', top: compact ? '8%' : '10%', containLabel: true },
|
||||
xAxis: { type: 'category', data: data.map(d => d.name), axisLabel: { interval: 0, rotate: compact ? 28 : 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
|
||||
yAxis: { type: 'value', name: compact ? '' : '業績', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 13 } },
|
||||
series: ['2024','2025'].map(y => ({
|
||||
name: y + ' 業績', type: 'bar', data: data.map(d => d['sales_' + y] || 0),
|
||||
label: { show: true, position: 'top', fontSize: 14, fontWeight: 'bold', formatter: p => (p.value/10000).toFixed(0) + '萬' }
|
||||
label: { show: !compact, position: 'top', fontSize: 14, fontWeight: 'bold', formatter: p => (p.value/10000).toFixed(0) + '萬' }
|
||||
}))
|
||||
});
|
||||
let html = `<thead class="table-light"><tr><th>排名</th><th>區名稱</th><th>業績</th></tr></thead><tbody>`;
|
||||
|
||||
Reference in New Issue
Block a user