This commit is contained in:
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.166"
|
||||
SYSTEM_VERSION = "V10.167"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -393,32 +393,26 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.11.5/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.11.5/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
|
||||
{% if not error %}
|
||||
<script>
|
||||
window.__DAILY_SALES__ = {
|
||||
chartData: {{ chart_data | tojson | safe }},
|
||||
marketing: {
|
||||
{% if marketing_data and marketing_data.discount %}
|
||||
discount: {
|
||||
labels: {{ marketing_data.discount | map(attribute='name') | list | tojson | safe }},
|
||||
values: {{ marketing_data.discount | map(attribute='revenue') | list | tojson | safe }}
|
||||
},
|
||||
{% endif %}
|
||||
{% if marketing_data and marketing_data.coupon %}
|
||||
coupon: {
|
||||
labels: {{ marketing_data.coupon | map(attribute='name') | list | tojson | safe }},
|
||||
values: {{ marketing_data.coupon | map(attribute='revenue') | list | tojson | safe }}
|
||||
}
|
||||
{% endif %}
|
||||
},
|
||||
isMonthView: {{ 'true' if is_month_view else 'false' }}
|
||||
};
|
||||
</script>
|
||||
{% set daily_sales_payload = {
|
||||
'chartData': chart_data,
|
||||
'marketing': {
|
||||
'discount': {
|
||||
'labels': marketing_data.discount | map(attribute='name') | list,
|
||||
'values': marketing_data.discount | map(attribute='revenue') | list
|
||||
} if marketing_data and marketing_data.discount else none,
|
||||
'coupon': {
|
||||
'labels': marketing_data.coupon | map(attribute='name') | list,
|
||||
'values': marketing_data.coupon | map(attribute='revenue') | list
|
||||
} if marketing_data and marketing_data.coupon else none
|
||||
},
|
||||
'isMonthView': is_month_view
|
||||
} %}
|
||||
<template id="daily-sales-data">{{ daily_sales_payload | tojson }}</template>
|
||||
<script src="{{ url_for('static', filename='js/page-daily-sales.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -107,8 +107,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js"></script>
|
||||
<script id="chart-data" type="application/json">{{ chart_data | tojson }}</script>
|
||||
<template id="chart-data">{{ chart_data | tojson }}</template>
|
||||
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/page-growth.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* system → terra primary,clay/olive/greige/honey/apricot
|
||||
*/
|
||||
(function () {
|
||||
let chartJsLoader = null;
|
||||
const root = document.documentElement;
|
||||
const css = (name, fallback) =>
|
||||
getComputedStyle(root).getPropertyValue(name).trim() || fallback;
|
||||
@@ -225,6 +226,27 @@
|
||||
window.Chart = ThemedChart;
|
||||
}
|
||||
|
||||
function loadChartJs() {
|
||||
if (window.Chart) {
|
||||
installChartJsTheme();
|
||||
return Promise.resolve(window.Chart);
|
||||
}
|
||||
if (chartJsLoader) return chartJsLoader;
|
||||
|
||||
chartJsLoader = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js';
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
installChartJsTheme();
|
||||
resolve(window.Chart);
|
||||
};
|
||||
script.onerror = () => reject(new Error('Chart.js 載入失敗'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return chartJsLoader;
|
||||
}
|
||||
|
||||
// ECharts 走精簡版(v2 完整 mergeAxis/tuneSeries 保留邏輯但用群組色)
|
||||
function installEchartsTheme() {
|
||||
if (!window.echarts || window.echarts.__ewooocThemed) return;
|
||||
@@ -257,6 +279,7 @@
|
||||
readPalette,
|
||||
theme,
|
||||
tuneChartConfig,
|
||||
loadChartJs,
|
||||
installChartJsTheme,
|
||||
installEchartsTheme
|
||||
};
|
||||
|
||||
@@ -6,13 +6,23 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.__DAILY_SALES__) return;
|
||||
|
||||
if (typeof Chart === 'undefined') {
|
||||
console.error('[daily_sales] Chart.js 未載入');
|
||||
return;
|
||||
function readDailySalesData() {
|
||||
const node = document.getElementById('daily-sales-data');
|
||||
if (!node && window.__DAILY_SALES__) return window.__DAILY_SALES__;
|
||||
if (!node) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent || '{}');
|
||||
} catch (error) {
|
||||
console.error('[daily_sales] chart data parse failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const dailySalesData = readDailySalesData();
|
||||
if (!dailySalesData) return;
|
||||
window.__DAILY_SALES__ = dailySalesData;
|
||||
let chartsRendered = false;
|
||||
|
||||
// -- Palette 讀自 CSS variable (analysis-chart-theme.js 公開) ----------
|
||||
function cssVar(name, fallback) {
|
||||
const v = getComputedStyle(document.documentElement)
|
||||
@@ -39,12 +49,22 @@
|
||||
muted: cssVar('--momo-text-muted', '#7e6f5c')
|
||||
};
|
||||
|
||||
Chart.defaults.color = palette.muted;
|
||||
Chart.defaults.borderColor = rgba(palette.muted, 0.18);
|
||||
Chart.defaults.font.family = "'Noto Sans TC', 'Inter', system-ui, sans-serif";
|
||||
function loadChartJs() {
|
||||
if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) {
|
||||
return window.EwoooCChartTheme.loadChartJs();
|
||||
}
|
||||
if (window.Chart) return Promise.resolve(window.Chart);
|
||||
return Promise.reject(new Error('Chart.js loader unavailable'));
|
||||
}
|
||||
|
||||
function applyChartDefaults() {
|
||||
Chart.defaults.color = palette.muted;
|
||||
Chart.defaults.borderColor = rgba(palette.muted, 0.18);
|
||||
Chart.defaults.font.family = "'Noto Sans TC', 'Inter', system-ui, sans-serif";
|
||||
}
|
||||
|
||||
// -- Safe data extraction ---------------------------------------------
|
||||
const cd = window.__DAILY_SALES__.chartData || {};
|
||||
const cd = dailySalesData.chartData || {};
|
||||
const safe = {
|
||||
labels: cd.labels || [],
|
||||
revenue: cd.revenue || [],
|
||||
@@ -291,7 +311,7 @@
|
||||
order: [[2, 'desc']],
|
||||
pageLength: 25,
|
||||
language: {
|
||||
url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json'
|
||||
url: 'https://cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -355,16 +375,45 @@
|
||||
};
|
||||
|
||||
// -- Boot -----------------------------------------------------------
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
function renderAllCharts() {
|
||||
if (chartsRendered) return;
|
||||
chartsRendered = true;
|
||||
applyChartDefaults();
|
||||
renderTrend();
|
||||
renderDod();
|
||||
renderWow();
|
||||
renderTop10();
|
||||
|
||||
const mk = window.__DAILY_SALES__.marketing || {};
|
||||
const mk = dailySalesData.marketing || {};
|
||||
if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel);
|
||||
if (mk.coupon) renderMarketingBar('couponChart', mk.coupon, palette.olive);
|
||||
}
|
||||
|
||||
function bootCharts() {
|
||||
loadChartJs()
|
||||
.then(renderAllCharts)
|
||||
.catch(error => console.error('[daily_sales] Chart.js 載入失敗:', error));
|
||||
}
|
||||
|
||||
function observeCharts() {
|
||||
const targets = Array.from(document.querySelectorAll('.chart-card'));
|
||||
if (!targets.length) return;
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
bootCharts();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) return;
|
||||
observer.disconnect();
|
||||
bootCharts();
|
||||
}, { rootMargin: '260px 0px' });
|
||||
|
||||
targets.forEach(target => observer.observe(target));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initDataTable();
|
||||
observeCharts();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,104 +1,182 @@
|
||||
/* ════════════════════════════════════════════════════════
|
||||
* page-growth.js — Turn C
|
||||
* page-growth.js
|
||||
* growth_analysis.html 的 Chart.js 圖表
|
||||
* 依賴 analysis-chart-theme.js 統一字型/border/暖色 palette
|
||||
* Chart.js 由 analysis-chart-theme.js 懶載入,避免首屏阻塞。
|
||||
* ════════════════════════════════════════════════════════ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const data = JSON.parse(document.getElementById('chart-data').textContent);
|
||||
let chartsRendered = false;
|
||||
|
||||
function readGrowthData() {
|
||||
const node = document.getElementById('chart-data');
|
||||
if (!node) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent || '{}');
|
||||
} catch (error) {
|
||||
console.error('[growth_analysis] chart data parse failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const data = readGrowthData();
|
||||
if (!data) return;
|
||||
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const token = (name, fallback) => rootStyle.getPropertyValue(name).trim() || fallback;
|
||||
|
||||
// 與 design system page-group=analytics 對齊的暖色 palette
|
||||
const chartPalette = {
|
||||
caramel: token('--momo-page-chart-2', '#c96442'),
|
||||
caramelSoft: token('--momo-warm-caramel-soft', 'rgba(201, 100, 66, 0.58)'),
|
||||
honey: token('--momo-page-accent', '#c89043'),
|
||||
honeySoft: token('--momo-page-accent-soft', 'rgba(200, 144, 67, 0.14)'),
|
||||
rust: token('--momo-danger-text', '#7a3210'),
|
||||
rustSoft: token('--momo-danger-bg', '#efd3c4')
|
||||
caramel: token('--momo-page-chart-2', '#c96442'),
|
||||
caramelSoft: token('--momo-warm-caramel-soft', 'rgba(201, 100, 66, 0.58)'),
|
||||
honey: token('--momo-page-accent', '#c89043'),
|
||||
honeySoft: token('--momo-page-accent-soft', 'rgba(200, 144, 67, 0.14)'),
|
||||
rust: token('--momo-danger-text', '#7a3210'),
|
||||
rustSoft: token('--momo-danger-bg', '#efd3c4')
|
||||
};
|
||||
|
||||
Chart.defaults.color = token('--momo-text-secondary', '#6b6155');
|
||||
Chart.defaults.borderColor = token('--momo-border-light', 'rgba(42, 37, 32, 0.10)');
|
||||
Chart.defaults.font.family = token('--momo-font-family', "'Inter', system-ui, sans-serif");
|
||||
function loadChartJs() {
|
||||
if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) {
|
||||
return window.EwoooCChartTheme.loadChartJs();
|
||||
}
|
||||
if (window.Chart) return Promise.resolve(window.Chart);
|
||||
return Promise.reject(new Error('Chart.js loader unavailable'));
|
||||
}
|
||||
|
||||
// 1) Revenue + YoY
|
||||
new Chart(document.getElementById('revenueChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{ label: '月營收 ($)', data: data.revenue,
|
||||
backgroundColor: chartPalette.caramelSoft, order: 2 },
|
||||
{ label: 'YoY 年增率 (%)', data: data.yoy, type: 'line',
|
||||
borderColor: chartPalette.rust, borderWidth: 2,
|
||||
yAxisID: 'y1', order: 1, tension: 0.3 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: '金額 ($)' } },
|
||||
y1: { position: 'right', grid: { drawOnChartArea: false },
|
||||
title: { display: true, text: '成長率 (%)' } }
|
||||
function renderCharts() {
|
||||
if (chartsRendered) return;
|
||||
chartsRendered = true;
|
||||
|
||||
Chart.defaults.color = token('--momo-text-secondary', '#6b6155');
|
||||
Chart.defaults.borderColor = token('--momo-border-light', 'rgba(42, 37, 32, 0.10)');
|
||||
Chart.defaults.font.family = token('--momo-font-family', "'Inter', system-ui, sans-serif");
|
||||
|
||||
const revenueEl = document.getElementById('revenueChart');
|
||||
const momEl = document.getElementById('momChart');
|
||||
const aovEl = document.getElementById('aovChart');
|
||||
const marginEl = document.getElementById('marginChart');
|
||||
if (!revenueEl || !momEl || !aovEl || !marginEl) return;
|
||||
|
||||
new Chart(revenueEl, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '月營收 ($)',
|
||||
data: data.revenue,
|
||||
backgroundColor: chartPalette.caramelSoft,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: 'YoY 年增率 (%)',
|
||||
data: data.yoy,
|
||||
type: 'line',
|
||||
borderColor: chartPalette.rust,
|
||||
borderWidth: 2,
|
||||
yAxisID: 'y1',
|
||||
order: 1,
|
||||
tension: 0.3
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: '金額 ($)' } },
|
||||
y1: {
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
title: { display: true, text: '成長率 (%)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2) MoM
|
||||
new Chart(document.getElementById('momChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'MoM 月增率 (%)',
|
||||
data: data.mom,
|
||||
backgroundColor: ctx => ctx.raw >= 0 ? chartPalette.honeySoft : chartPalette.rustSoft
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
new Chart(momEl, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'MoM 月增率 (%)',
|
||||
data: data.mom,
|
||||
backgroundColor: ctx => ctx.raw >= 0 ? chartPalette.honeySoft : chartPalette.rustSoft
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
|
||||
// 3) AOV
|
||||
new Chart(document.getElementById('aovChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: '平均單價 ($)',
|
||||
data: data.aov,
|
||||
borderColor: chartPalette.caramel,
|
||||
backgroundColor: chartPalette.caramelSoft,
|
||||
fill: true, tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
new Chart(aovEl, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: '平均單價 ($)',
|
||||
data: data.aov,
|
||||
borderColor: chartPalette.caramel,
|
||||
backgroundColor: chartPalette.caramelSoft,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// 4) Margin
|
||||
new Chart(document.getElementById('marginChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: '毛利率 (%)',
|
||||
data: data.margin_rate,
|
||||
borderColor: chartPalette.honey,
|
||||
backgroundColor: chartPalette.honeySoft,
|
||||
fill: true, tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
new Chart(marginEl, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: '毛利率 (%)',
|
||||
data: data.margin_rate,
|
||||
borderColor: chartPalette.honey,
|
||||
backgroundColor: chartPalette.honeySoft,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bootCharts() {
|
||||
loadChartJs()
|
||||
.then(renderCharts)
|
||||
.catch(error => console.error('[growth_analysis] Chart.js 載入失敗:', error));
|
||||
}
|
||||
|
||||
function observeCharts() {
|
||||
const targets = Array.from(document.querySelectorAll('.ga-chart-card'));
|
||||
if (!targets.length) return;
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
bootCharts();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) return;
|
||||
observer.disconnect();
|
||||
bootCharts();
|
||||
}, { rootMargin: '220px 0px' });
|
||||
|
||||
targets.forEach(target => observer.observe(target));
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', observeCharts, { once: true });
|
||||
} else {
|
||||
observeCharts();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user