Improve dashboard filters and currency views

This commit is contained in:
liuji
2026-10-10 16:19:13 +08:00
parent d8bda50560
commit 7b089eccc6
+341 -237
View File
@@ -22,7 +22,6 @@
--error-text: #a51d2d;
--shadow: 0 8px 24px rgba(33, 52, 78, 0.07);
}
* { box-sizing: border-box; }
body {
margin: 0;
@@ -31,104 +30,55 @@
color: var(--text);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.page {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 36px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
}
h1 {
margin: 0 0 6px;
font-size: clamp(24px, 3vw, 34px);
letter-spacing: -0.02em;
line-height: 1.2;
}
.page { width: min(1440px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 36px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
h1 { margin: 0 0 6px; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.02em; line-height: 1.2; }
.subtitle { margin: 0; color: var(--muted); }
.status {
flex: 0 0 auto;
margin-top: 3px;
color: var(--muted);
text-align: right;
}
.header-actions { display: grid; gap: 8px; justify-items: end; }
.raw-link { color: var(--primary); font-weight: 600; text-decoration: none; }
.raw-link:hover, .raw-link:focus { text-decoration: underline; }
.status { color: var(--muted); text-align: right; }
.status[data-state="error"] { color: var(--error-text); }
.status[data-state="empty"] { color: var(--muted); }
.alert {
margin: 0 0 18px;
padding: 11px 14px;
border: 1px solid var(--warning-border);
border-radius: 10px;
background: var(--warning-bg);
color: var(--warning-text);
}
.alert { margin: 0 0 18px; padding: 11px 14px; border: 1px solid var(--warning-border); border-radius: 10px; background: var(--warning-bg); color: var(--warning-text); }
.alert[hidden] { display: none; }
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.metric, .chart-card {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
box-shadow: var(--shadow);
}
.filters { display: grid; gap: 12px; margin: 0 0 20px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
.filter-row { display: flex; flex-wrap: wrap; align-items: end; gap: 14px 20px; }
.filter-group { display: grid; gap: 5px; }
.filter-group > span, .filter-group > label { color: var(--muted); font-size: 13px; }
.filter-group input, .filter-group select { min-height: 36px; padding: 6px 9px; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); font: inherit; }
.filter-group select:disabled { background: #f1f4f8; color: var(--muted); }
.range-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.range-button { min-height: 36px; padding: 6px 11px; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); cursor: pointer; font: inherit; }
.range-button:hover, .range-button:focus, .range-button[data-active="true"] { border-color: var(--primary); color: var(--primary); }
.currency-help, .rate-info { color: var(--muted); font-size: 12px; }
.rate-info { display: flex; flex-wrap: wrap; gap: 5px 10px; align-items: center; }
.rate-info a { color: var(--primary); }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
.metric, .chart-card { border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
.metric { padding: 16px 18px; }
.metric-label {
display: block;
margin-bottom: 5px;
color: var(--muted);
font-size: 13px;
}
.metric-label { display: block; margin-bottom: 5px; color: var(--muted); font-size: 13px; }
.metric-value { display: block; font-size: 23px; font-weight: 700; }
.charts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.charts { display: grid; grid-template-columns: 1fr; gap: 20px; }
.chart-card { min-width: 0; padding: 18px 18px 10px; }
.chart-card h2 { margin: 0 0 2px; font-size: 18px; }
.chart-card p { margin: 0; color: var(--muted); font-size: 13px; }
.chart { width: 100%; height: 450px; margin-top: 8px; }
.empty-state {
display: grid;
min-height: 260px;
place-items: center;
padding: 28px;
border: 1px dashed var(--border);
border-radius: 14px;
background: var(--surface);
color: var(--muted);
text-align: center;
}
.error-state {
margin-top: 20px;
padding: 16px 18px;
border: 1px solid var(--error-border);
border-radius: 12px;
background: var(--error-bg);
color: var(--error-text);
}
.error-state[hidden], .metrics[hidden], .charts[hidden], .empty-state[hidden] { display: none; }
@media (max-width: 980px) {
.charts { grid-template-columns: 1fr; }
.chart { height: 400px; }
}
.empty-state { display: grid; min-height: 260px; place-items: center; padding: 28px; border: 1px dashed var(--border); border-radius: 14px; background: var(--surface); color: var(--muted); text-align: center; }
.error-state { margin-top: 20px; padding: 16px 18px; border: 1px solid var(--error-border); border-radius: 12px; background: var(--error-bg); color: var(--error-text); }
.error-state[hidden], .filters[hidden], .metrics[hidden], .charts[hidden], .empty-state[hidden] { display: none; }
@media (max-width: 640px) {
.page { width: min(100% - 20px, 1440px); padding-top: 18px; }
.page-header { display: block; }
.status { margin-top: 10px; text-align: left; }
.header-actions { justify-items: start; margin-top: 10px; }
.status { text-align: left; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.metric { padding: 13px 14px; }
.metric-value { font-size: 20px; }
.chart-card { padding: 14px 10px 8px; }
.chart { height: 360px; }
.filter-row { align-items: stretch; }
.filter-group { flex: 1 1 145px; }
}
</style>
</head>
@@ -139,10 +89,46 @@
<h1 id="page-title">TTSHOP 销售看板</h1>
<p class="subtitle" id="page-subtitle">Commission 与 GMV 趋势</p>
</div>
<div class="header-actions">
<a class="raw-link" href="data/merged.html" target="_blank" rel="noopener">查看原始数据</a>
<div class="status" id="status" data-state="loading" role="status" aria-live="polite">正在加载数据…</div>
</div>
</header>
<div class="alert" id="currency-warning" role="alert" hidden></div>
<div class="alert" id="data-warning" role="alert" hidden></div>
<section class="filters" id="filters" aria-label="筛选与换算" hidden>
<div class="filter-row">
<div class="filter-group">
<label for="currency-select">显示币种</label>
<select id="currency-select"></select>
</div>
<div class="filter-group">
<span>日期范围</span>
<div class="range-actions">
<button class="range-button" type="button" data-range="month">当月(按数据最新月份)</button>
<button class="range-button" type="button" data-range="recent">最近30天(按数据最新日期)</button>
<button class="range-button" type="button" data-range="all">全部</button>
</div>
</div>
</div>
<div class="filter-row">
<div class="filter-group">
<label for="start-date">开始日期</label>
<input id="start-date" type="date">
</div>
<div class="filter-group">
<label for="end-date">结束日期</label>
<input id="end-date" type="date">
</div>
<div class="currency-help" id="currency-help"></div>
</div>
<div class="rate-info" id="rate-info">
<span id="rate-status">尚未需要汇率换算</span>
<a href="https://www.exchangerate-api.com" target="_blank" rel="noopener">汇率来源</a>
<span>公开接口通常每日更新,不承诺分钟级实时。</span>
</div>
</section>
<section class="metrics" id="metrics" aria-label="数据概览" hidden>
<div class="metric"><span class="metric-label">数据更新时间</span><strong class="metric-value" id="updated-at">—</strong></div>
@@ -174,19 +160,28 @@
'use strict';
var DATA_URL = 'data/merged.html';
var RATES_URL = 'https://open.er-api.com/v6/latest/USD';
var REQUIRED_FIELDS = ['Date', 'Commission', 'Order', 'GMV', 'Currency', 'LinkName'];
var charts = [];
var ratesData = null;
var ratesPromise = null;
var renderToken = 0;
var numberFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
var dateFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
});
var elements = {
title: document.getElementById('page-title'),
subtitle: document.getElementById('page-subtitle'),
status: document.getElementById('status'),
warning: document.getElementById('currency-warning'),
warning: document.getElementById('data-warning'),
filters: document.getElementById('filters'),
currency: document.getElementById('currency-select'),
currencyHelp: document.getElementById('currency-help'),
startDate: document.getElementById('start-date'),
endDate: document.getElementById('end-date'),
rangeButtons: Array.from(document.querySelectorAll('[data-range]')),
rateStatus: document.getElementById('rate-status'),
metrics: document.getElementById('metrics'),
updatedAt: document.getElementById('updated-at'),
recordCount: document.getElementById('record-count'),
@@ -200,21 +195,20 @@
overview: document.getElementById('overview-chart'),
link: document.getElementById('link-chart')
};
var state = { rawRecords: [], sourceCurrencies: [], selectedCurrency: '', latestDate: null, dateOnlyRows: 0, invalidCurrencyRows: 0 };
function setStatus(message, state) {
function setStatus(message, status) {
elements.status.textContent = message;
elements.status.dataset.state = state || '';
elements.status.dataset.state = status || '';
}
function formatNumber(value) {
return numberFormatter.format(Number(value) || 0);
}
function formatNumber(value) { return numberFormatter.format(Number(value) || 0); }
function parseNumber(value) {
var normalized = String(value == null ? '' : value).trim().replace(/,/g, '');
if (!normalized) return 0;
if (!normalized) return null;
var parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
return Number.isFinite(parsed) ? parsed : null;
}
function parseDate(value) {
@@ -226,28 +220,23 @@
var day = Number(match[3]);
var date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
return {
key: year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0'),
label: year + '/' + month + '/' + day,
time: date.getTime()
};
return { key: year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0'), label: year + '/' + month + '/' + day, time: date.getTime() };
}
function readTable(htmlText) {
var documentParser = new DOMParser();
var parsedDocument = documentParser.parseFromString(htmlText, 'text/html');
var parsedDocument = new DOMParser().parseFromString(htmlText, 'text/html');
var tables = parsedDocument.querySelectorAll('table');
if (tables.length !== 1) throw new Error('数据文件必须包含唯一 table。');
var rows = Array.from(tables[0].querySelectorAll('tr'));
if (!rows.length) return [];
if (!rows.length) return { records: [], dateOnlyRows: 0, invalidCurrencyRows: 0 };
var headers = Array.from(rows[0].children).map(function (cell) { return cell.textContent.trim(); });
var positions = {};
headers.forEach(function (header, index) { positions[header] = index; });
var missing = REQUIRED_FIELDS.filter(function (field) { return positions[field] === undefined; });
if (missing.length) throw new Error('数据缺少字段:' + missing.join('、'));
return rows.slice(1).map(function (row) {
var dateOnlyRows = 0;
var invalidCurrencyRows = 0;
var records = rows.slice(1).map(function (row) {
var cells = Array.from(row.children);
function cell(field) {
var index = positions[field];
@@ -255,160 +244,245 @@
}
var parsedDate = parseDate(cell('Date'));
if (!parsedDate) return null;
return {
date: parsedDate,
commission: parseNumber(cell('Commission')),
gmv: parseNumber(cell('GMV')),
currency: cell('Currency') || 'Unknown',
linkName: cell('LinkName') || 'Unknown'
};
var commission = parseNumber(cell('Commission'));
var gmv = parseNumber(cell('GMV'));
if (commission === null && gmv === null) {
dateOnlyRows += 1;
return null;
}
var currency = cell('Currency').toUpperCase();
if (!/^[A-Z]{3}$/.test(currency) || currency === 'UNK' || currency === 'UNKNOWN') {
invalidCurrencyRows += 1;
return null;
}
return { date: parsedDate, commission: commission, gmv: gmv, currency: currency, linkName: cell('LinkName') || 'Unknown' };
}).filter(Boolean);
return { records: records, dateOnlyRows: dateOnlyRows, invalidCurrencyRows: invalidCurrencyRows };
}
function disposeCharts() {
charts.forEach(function (chart) { chart.dispose(); });
charts = [];
}
function updateNotice() {
var messages = [];
if (state.dateOnlyRows) messages.push('已过滤 ' + formatNumber(state.dateOnlyRows) + ' 条只有 Date、Commission 和 GMV 均为空的日期行。');
if (state.invalidCurrencyRows) messages.push('已排除 ' + formatNumber(state.invalidCurrencyRows) + ' 条币种为空、Unknown 或格式无效且包含金额的记录,请检查原始数据。');
if (state.sourceCurrencies.length > 1) messages.push('检测到多个已知源币种(' + state.sourceCurrencies.join('、') + '),已统一换算为 USD,不能切换到 IDR。');
elements.warning.textContent = messages.join(' ');
elements.warning.hidden = messages.length === 0;
}
function setRateStatus(message) { elements.rateStatus.textContent = message; }
function updateCurrencyOptions() {
elements.currency.replaceChildren();
var sourceCurrencies = state.sourceCurrencies;
var options = [];
if (sourceCurrencies.length > 1) {
options = ['USD'];
state.selectedCurrency = 'USD';
elements.currency.disabled = true;
elements.currencyHelp.textContent = '多个源币种已自动统一为 USD。';
} else if (sourceCurrencies.length === 1) {
var source = sourceCurrencies[0];
options = source === 'USD' ? ['USD'] : [source, 'USD'];
state.selectedCurrency = source === 'IDR' ? 'IDR' : source;
elements.currency.disabled = options.length === 1;
elements.currencyHelp.textContent = options.length > 1 ? '可在源币种与 USD 之间切换。' : '当前数据已经是 USD。';
} else {
options = ['USD'];
state.selectedCurrency = 'USD';
elements.currency.disabled = true;
elements.currencyHelp.textContent = '';
}
options.forEach(function (currency) {
var option = document.createElement('option');
option.value = currency;
option.textContent = currency;
elements.currency.appendChild(option);
});
elements.currency.value = state.selectedCurrency;
}
function updateRangeInputs() {
var dates = state.rawRecords.map(function (record) { return record.date; }).sort(function (a, b) { return a.time - b.time; });
if (!dates.length) return;
var first = dates[0];
var latest = dates[dates.length - 1];
state.latestDate = latest;
elements.startDate.min = first.key;
elements.startDate.max = latest.key;
elements.endDate.min = first.key;
elements.endDate.max = latest.key;
setRange('month');
}
function setRange(rangeName) {
if (!state.latestDate || !state.rawRecords.length) return;
var dates = state.rawRecords.map(function (record) { return record.date; }).sort(function (a, b) { return a.time - b.time; });
var first = dates[0];
var latest = dates[dates.length - 1];
var start = first;
if (rangeName === 'month') start = parseDate(latest.key.slice(0, 7) + '-01');
if (rangeName === 'recent') start = new Date(latest.time - 29 * 24 * 60 * 60 * 1000);
var startKey = rangeName === 'recent' ? new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())).toISOString().slice(0, 10) : start.key;
elements.startDate.value = startKey < first.key ? first.key : startKey;
elements.endDate.value = latest.key;
elements.rangeButtons.forEach(function (button) { button.dataset.active = button.dataset.range === rangeName ? 'true' : 'false'; });
}
function currentRange() {
var start = parseDate(elements.startDate.value);
var end = parseDate(elements.endDate.value);
if (!start || !end || start.time > end.time) throw new Error('开始日期必须早于或等于结束日期。');
return { start: start.time, end: end.time };
}
function needsRates() {
return state.rawRecords.some(function (record) { return record.currency !== state.selectedCurrency; });
}
function rateTimestamp(data) {
if (data.time_last_update_utc) return new Date(data.time_last_update_utc);
if (data.time_last_update_unix) return new Date(data.time_last_update_unix * 1000);
return null;
}
function fetchRates() {
if (ratesData) return Promise.resolve(ratesData);
if (ratesPromise) return ratesPromise;
setRateStatus('正在加载公开汇率…');
ratesPromise = fetch(RATES_URL, { cache: 'no-store' }).then(function (response) {
if (!response.ok) throw new Error('汇率接口 HTTP ' + response.status + '。');
return response.json();
}).then(function (data) {
if (!data || data.result !== 'success' || !data.rates) throw new Error('汇率接口返回无效数据。');
ratesData = data;
var updated = rateTimestamp(data);
setRateStatus('汇率已更新:' + (updated && !Number.isNaN(updated.getTime()) ? dateFormatter.format(updated) : '接口未提供时间'));
return data;
}).catch(function (error) {
ratesPromise = null;
setRateStatus('汇率加载失败');
throw error;
});
return ratesPromise;
}
function convertValue(value, source, target, data) {
if (value === null || source === target) return value;
var sourceRate = Number(data.rates[source]);
var targetRate = Number(data.rates[target]);
if (source !== 'USD' && (!Number.isFinite(sourceRate) || sourceRate <= 0)) throw new Error('汇率接口不支持源币种 ' + source + '。');
if (target !== 'USD' && (!Number.isFinite(targetRate) || targetRate <= 0)) throw new Error('汇率接口不支持目标币种 ' + target + '。');
if (target === 'USD') return value / sourceRate;
if (source === 'USD') return value * targetRate;
return value / sourceRate * targetRate;
}
function convertedRecords() {
var conversion = needsRates() ? fetchRates() : Promise.resolve(null);
return conversion.then(function (data) {
return state.rawRecords.map(function (record) {
return {
date: record.date,
commission: convertValue(record.commission, record.currency, state.selectedCurrency, data),
gmv: convertValue(record.gmv, record.currency, state.selectedCurrency, data),
linkName: record.linkName
};
});
});
}
function updateSummary(records) {
var links = new Set(records.map(function (record) { return record.linkName; }));
elements.title.textContent = 'TTSHOP 销售看板 · ' + state.selectedCurrency;
elements.subtitle.textContent = 'Commission 与 GMV 趋势(当前加载时间:' + dateFormatter.format(new Date()) + ')';
elements.updatedAt.textContent = dateFormatter.format(new Date());
elements.recordCount.textContent = formatNumber(records.length);
elements.linkCount.textContent = formatNumber(links.size);
elements.currencyCount.textContent = state.sourceCurrencies.join('、') || '—';
}
function aggregate(records) {
var byDate = new Map();
var byLink = new Map();
records.forEach(function (record) {
if (!byDate.has(record.date.key)) {
byDate.set(record.date.key, { date: record.date, commission: 0, gmv: 0 });
}
if (!byDate.has(record.date.key)) byDate.set(record.date.key, { date: record.date, commission: 0, gmv: 0 });
var dateTotal = byDate.get(record.date.key);
dateTotal.commission += record.commission;
dateTotal.gmv += record.gmv;
dateTotal.commission += record.commission === null ? 0 : record.commission;
dateTotal.gmv += record.gmv === null ? 0 : record.gmv;
if (!byLink.has(record.linkName)) byLink.set(record.linkName, new Map());
var linkDates = byLink.get(record.linkName);
if (!linkDates.has(record.date.key)) {
linkDates.set(record.date.key, { date: record.date, commission: 0, gmv: 0 });
}
if (!linkDates.has(record.date.key)) linkDates.set(record.date.key, { date: record.date, commission: 0, gmv: 0 });
var linkTotal = linkDates.get(record.date.key);
linkTotal.commission += record.commission;
linkTotal.gmv += record.gmv;
linkTotal.commission += record.commission === null ? 0 : record.commission;
linkTotal.gmv += record.gmv === null ? 0 : record.gmv;
});
return { dates: Array.from(byDate.values()).sort(function (a, b) { return a.date.time - b.date.time; }), byLink: byLink };
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>\"']/g, function (character) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', "'": '&#39;' }[character];
});
var dates = Array.from(byDate.values()).sort(function (a, b) { return a.date.time - b.date.time; });
return { dates: dates, byLink: byLink };
}
function tooltipFormatter(params) {
var items = Array.isArray(params) ? params : [params];
var date = items.length && items[0].axisValue ? items[0].axisValue : '';
var lines = [date];
var lines = [escapeHtml(date)];
items.forEach(function (item) {
var value = Array.isArray(item.value) ? item.value[item.value.length - 1] : item.value;
var metric = /GMV$/.test(item.seriesName) ? 'GMV' : 'Commission';
lines.push(item.marker + ' ' + item.seriesName + ':' + metric + ' ' + formatNumber(value));
lines.push(item.marker + ' ' + escapeHtml(item.seriesName) + ':' + metric + ' ' + formatNumber(value));
});
return lines.join('<br>');
}
function axisLabelFormatter(value) { return value; }
function makeCommonXAxis(categories) {
return {
type: 'category',
data: categories,
boundaryGap: true,
axisLabel: { interval: categories.length > 30 ? Math.ceil(categories.length / 15) - 1 : 0, rotate: categories.length > 14 ? 30 : 0 }
};
return { type: 'category', data: categories, boundaryGap: true, axisLabel: { interval: categories.length > 30 ? Math.ceil(categories.length / 15) - 1 : 0, rotate: categories.length > 14 ? 30 : 0 } };
}
function renderCharts(records, currencyLabel) {
function renderCharts(records) {
if (!window.echarts) throw new Error('ECharts 未加载。');
var aggregated = aggregate(records);
var dates = aggregated.dates.map(function (item) { return item.date.label; });
var commissionAxisName = 'Commission (' + currencyLabel + ')';
var gmvAxisName = 'GMV (' + currencyLabel + ')';
var overviewOption = {
animation: false,
color: ['#2563eb', '#f59e0b'],
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, formatter: tooltipFormatter },
legend: { type: 'scroll', top: 2, data: ['Commission', 'GMV'] },
grid: { left: 70, right: 76, top: 52, bottom: 62 },
xAxis: makeCommonXAxis(dates),
yAxis: [
{ type: 'value', name: commissionAxisName, nameLocation: 'middle', nameGap: 52, axisLabel: { formatter: axisLabelFormatter } },
{ type: 'value', name: gmvAxisName, nameLocation: 'middle', nameGap: 58, axisLabel: { formatter: axisLabelFormatter } }
],
series: [
{
name: 'Commission', type: 'line', yAxisIndex: 0, stack: 'commission-total', smooth: true,
symbol: 'circle', symbolSize: 6,
data: aggregated.dates.map(function (item) { return item.commission; })
},
{
name: 'GMV', type: 'bar', yAxisIndex: 1, stack: 'gmv-total', barMaxWidth: 34,
data: aggregated.dates.map(function (item) { return item.gmv; })
}
]
};
var commissionAxisName = 'Commission (' + state.selectedCurrency + ')';
var gmvAxisName = 'GMV (' + state.selectedCurrency + ')';
var yAxes = [
{ type: 'value', name: commissionAxisName, nameLocation: 'middle', nameGap: 52 },
{ type: 'value', name: gmvAxisName, nameLocation: 'middle', nameGap: 58 }
];
var common = { animation: false, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, formatter: tooltipFormatter }, grid: { left: 70, right: 76, top: 52, bottom: 62 }, xAxis: makeCommonXAxis(dates), yAxis: yAxes };
var overviewSeries = [
{ name: 'Commission', type: 'line', yAxisIndex: 0, smooth: true, symbol: 'circle', symbolSize: 6, data: aggregated.dates.map(function (item) { return item.commission; }) },
{ name: 'GMV', type: 'bar', yAxisIndex: 1, stack: 'gmv-total', barMaxWidth: 34, data: aggregated.dates.map(function (item) { return item.gmv; }) }
];
var links = Array.from(aggregated.byLink.keys()).sort(function (a, b) { return a.localeCompare(b); });
var linkSeries = [];
links.forEach(function (linkName, linkIndex) {
links.forEach(function (linkName) {
var linkDates = aggregated.byLink.get(linkName);
linkSeries.push({
name: linkName + ' · Commission', type: 'line', yAxisIndex: 0, stack: 'commission-by-link', smooth: true,
symbol: 'circle', symbolSize: 5,
data: dates.map(function (_, index) {
var item = aggregated.dates[index];
var value = linkDates.get(item.date.key);
return value ? value.commission : 0;
})
linkSeries.push({ name: linkName + ' · Commission', type: 'line', yAxisIndex: 0, stack: 'commission-by-link', smooth: true, symbol: 'circle', symbolSize: 5, data: dates.map(function (_, index) { var item = aggregated.dates[index]; var value = linkDates.get(item.date.key); return value ? value.commission : 0; }) });
linkSeries.push({ name: linkName + ' · GMV', type: 'bar', yAxisIndex: 1, stack: 'gmv-by-link', barMaxWidth: 34, data: dates.map(function (_, index) { var item = aggregated.dates[index]; var value = linkDates.get(item.date.key); return value ? value.gmv : 0; }) });
});
linkSeries.push({
name: linkName + ' · GMV', type: 'bar', yAxisIndex: 1, stack: 'gmv-by-link', barMaxWidth: 34,
data: dates.map(function (_, index) {
var item = aggregated.dates[index];
var value = linkDates.get(item.date.key);
return value ? value.gmv : 0;
})
});
});
var linkOption = {
animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, formatter: tooltipFormatter },
legend: { type: 'scroll', top: 2, data: linkSeries.map(function (series) { return series.name; }) },
grid: { left: 70, right: 76, top: 52, bottom: 62 },
xAxis: makeCommonXAxis(dates),
yAxis: [
{ type: 'value', name: commissionAxisName, nameLocation: 'middle', nameGap: 52, axisLabel: { formatter: axisLabelFormatter } },
{ type: 'value', name: gmvAxisName, nameLocation: 'middle', nameGap: 58, axisLabel: { formatter: axisLabelFormatter } }
],
series: linkSeries
};
charts.forEach(function (chart) { chart.dispose(); });
disposeCharts();
charts = [echarts.init(elements.overview), echarts.init(elements.link)];
charts[0].setOption(overviewOption);
charts[1].setOption(linkOption);
charts[0].setOption(Object.assign({}, common, { color: ['#2563eb', '#f59e0b'], legend: { type: 'scroll', top: 2, data: ['Commission', 'GMV'] }, series: overviewSeries }));
charts[1].setOption(Object.assign({}, common, { legend: { type: 'scroll', top: 2, data: linkSeries.map(function (series) { return series.name; }) }, series: linkSeries }));
elements.overviewDescription.textContent = '按日期汇总 · 主轴 ' + commissionAxisName + ' · 副轴 ' + gmvAxisName;
elements.linkDescription.textContent = '按日期和 LinkName 堆积 · 主轴 ' + commissionAxisName + ' · 副轴 ' + gmvAxisName;
}
function updateSummary(records, currencies) {
var links = new Set(records.map(function (record) { return record.linkName; }));
var currencyList = Array.from(currencies).sort();
var currencyLabel = currencyList.length === 1 ? currencyList[0] : 'Mixed';
elements.title.textContent = 'TTSHOP 销售看板 · ' + currencyLabel;
elements.subtitle.textContent = 'Commission 与 GMV 趋势(当前加载时间:' + dateFormatter.format(new Date()) + ')';
elements.updatedAt.textContent = dateFormatter.format(new Date());
elements.recordCount.textContent = formatNumber(records.length);
elements.linkCount.textContent = formatNumber(links.size);
elements.currencyCount.textContent = currencyList.join('、');
if (currencyList.length > 1) {
elements.warning.textContent = '检测到多个币种:' + currencyList.join('、') + '。页面不会静默合并币种,图表仅按原始数值展示,请结合币种核对数据。';
elements.warning.hidden = false;
} else {
elements.warning.hidden = true;
}
return currencyLabel;
}
function showEmpty() {
function showEmpty(message) {
disposeCharts();
elements.metrics.hidden = false;
elements.empty.textContent = message || '没有可展示的数据。';
elements.empty.hidden = false;
elements.charts.hidden = true;
setStatus('没有有效记录', 'empty');
setStatus('当前范围没有有效记录', 'empty');
}
function showError(error) {
@@ -420,33 +494,63 @@
setStatus('数据加载失败', 'error');
}
function loadData() {
fetch(DATA_URL, { cache: 'no-store' })
.then(function (response) {
if (!response.ok) throw new Error('无法读取 data/merged.html(HTTP ' + response.status + ')。');
return response.text();
})
.then(function (htmlText) {
var records = readTable(htmlText);
function renderCurrentView() {
var token = ++renderToken;
var range;
try { range = currentRange(); } catch (error) { showError(error); return; }
setStatus(needsRates() ? '正在准备汇率与图表…' : '正在准备图表…', 'loading');
convertedRecords().then(function (records) {
if (token !== renderToken) return;
var filtered = records.filter(function (record) { return record.date.time >= range.start && record.date.time <= range.end; });
updateSummary(filtered);
elements.error.hidden = true;
if (!records.length) {
showEmpty();
return;
}
var currencies = new Set(records.map(function (record) { return record.currency; }));
var currencyLabel = updateSummary(records, currencies);
elements.metrics.hidden = false;
if (!filtered.length) { showEmpty(); return; }
elements.empty.hidden = true;
elements.charts.hidden = false;
renderCharts(records, currencyLabel);
setStatus('已加载 ' + formatNumber(records.length) + ' 条有效记录', 'ready');
})
.catch(showError);
renderCharts(filtered);
setStatus('已加载 ' + formatNumber(filtered.length) + ' 条有效记录', 'ready');
}).catch(function (error) {
if (token === renderToken) showError(error);
});
}
window.addEventListener('resize', function () {
charts.forEach(function (chart) { chart.resize(); });
function loadData() {
fetch(DATA_URL, { cache: 'no-store' }).then(function (response) {
if (!response.ok) throw new Error('无法读取 data/merged.html(HTTP ' + response.status + ')。');
return response.text();
}).then(function (htmlText) {
var result = readTable(htmlText);
state.rawRecords = result.records;
state.dateOnlyRows = result.dateOnlyRows;
state.invalidCurrencyRows = result.invalidCurrencyRows;
state.sourceCurrencies = Array.from(new Set(result.records.map(function (record) { return record.currency; }))).sort();
updateNotice();
elements.error.hidden = true;
elements.filters.hidden = false;
updateCurrencyOptions();
updateRangeInputs();
if (!state.rawRecords.length) { showEmpty('没有可展示的有效数据。'); return; }
renderCurrentView();
}).catch(showError);
}
elements.currency.addEventListener('change', function () {
state.selectedCurrency = elements.currency.value;
renderCurrentView();
});
elements.startDate.addEventListener('change', function () {
elements.rangeButtons.forEach(function (button) { button.dataset.active = 'false'; });
renderCurrentView();
});
elements.endDate.addEventListener('change', function () {
elements.rangeButtons.forEach(function (button) { button.dataset.active = 'false'; });
renderCurrentView();
});
elements.rangeButtons.forEach(function (button) {
button.addEventListener('click', function () { setRange(button.dataset.range); renderCurrentView(); });
});
window.addEventListener('resize', function () { charts.forEach(function (chart) { chart.resize(); }); });
loadData();
}());
</script>