From 81f4a0d18a333b5acf1f870885034460c44a99b5 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 18 May 2026 08:51:09 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=BB=B6=E5=BE=8C=E8=BC=89=E5=85=A5?= =?UTF-8?q?=E6=A5=AD=E7=B8=BE=E5=9C=96=E8=A1=A8=E8=B3=87=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 2 +- templates/daily_sales.html | 36 ++-- templates/growth_analysis.html | 3 +- web/static/js/analysis-chart-theme.js | 23 +++ web/static/js/page-daily-sales.js | 73 ++++++-- web/static/js/page-growth.js | 248 +++++++++++++++++--------- 6 files changed, 264 insertions(+), 121 deletions(-) diff --git a/config.py b/config.py index d47f640..782aca6 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/templates/daily_sales.html b/templates/daily_sales.html index 800abc4..540ceba 100644 --- a/templates/daily_sales.html +++ b/templates/daily_sales.html @@ -393,32 +393,26 @@ {% endblock %} {% block extra_js %} - {% if not error %} - + {% 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 + } %} + {% endif %} {% endblock %} diff --git a/templates/growth_analysis.html b/templates/growth_analysis.html index 70210e9..7137b05 100644 --- a/templates/growth_analysis.html +++ b/templates/growth_analysis.html @@ -107,8 +107,7 @@ {% endblock %} {% block extra_js %} - - + {% endblock %} diff --git a/web/static/js/analysis-chart-theme.js b/web/static/js/analysis-chart-theme.js index c07bebb..b8c3be8 100644 --- a/web/static/js/analysis-chart-theme.js +++ b/web/static/js/analysis-chart-theme.js @@ -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 }; diff --git a/web/static/js/page-daily-sales.js b/web/static/js/page-daily-sales.js index 759a905..e8c773f 100644 --- a/web/static/js/page-daily-sales.js +++ b/web/static/js/page-daily-sales.js @@ -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(); }); })(); diff --git a/web/static/js/page-growth.js b/web/static/js/page-growth.js index 2dc48f1..3a02ef2 100644 --- a/web/static/js/page-growth.js +++ b/web/static/js/page-growth.js @@ -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(); + } })();