Add Commission and GMV dashboard
This commit is contained in:
Executable
+454
@@ -0,0 +1,454 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>TTSHOP 销售看板</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f7fb;
|
||||
--surface: #ffffff;
|
||||
--text: #182230;
|
||||
--muted: #65748b;
|
||||
--border: #e4eaf2;
|
||||
--primary: #2563eb;
|
||||
--warning-bg: #fff7e6;
|
||||
--warning-border: #f4c46d;
|
||||
--warning-text: #8a5b00;
|
||||
--error-bg: #fff1f2;
|
||||
--error-border: #f5a6b1;
|
||||
--error-text: #a51d2d;
|
||||
--shadow: 0 8px 24px rgba(33, 52, 78, 0.07);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
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;
|
||||
}
|
||||
.subtitle { margin: 0; color: var(--muted); }
|
||||
.status {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 3px;
|
||||
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[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);
|
||||
}
|
||||
.metric { padding: 16px 18px; }
|
||||
.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;
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.page { width: min(100% - 20px, 1440px); padding-top: 18px; }
|
||||
.page-header { display: block; }
|
||||
.status { margin-top: 10px; 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; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 id="page-title">TTSHOP 销售看板</h1>
|
||||
<p class="subtitle" id="page-subtitle">Commission 与 GMV 趋势</p>
|
||||
</div>
|
||||
<div class="status" id="status" data-state="loading" role="status" aria-live="polite">正在加载数据…</div>
|
||||
</header>
|
||||
|
||||
<div class="alert" id="currency-warning" role="alert" hidden></div>
|
||||
|
||||
<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>
|
||||
<div class="metric"><span class="metric-label">记录数</span><strong class="metric-value" id="record-count">—</strong></div>
|
||||
<div class="metric"><span class="metric-label">LinkName 数</span><strong class="metric-value" id="link-count">—</strong></div>
|
||||
<div class="metric"><span class="metric-label">币种</span><strong class="metric-value" id="currency-count">—</strong></div>
|
||||
</section>
|
||||
|
||||
<div class="empty-state" id="empty-state" hidden>没有可展示的数据。</div>
|
||||
<div class="error-state" id="error-state" role="alert" hidden></div>
|
||||
|
||||
<section class="charts" id="charts" aria-label="数据图表" hidden>
|
||||
<article class="chart-card">
|
||||
<h2>总体趋势</h2>
|
||||
<p id="overview-description">按日期汇总</p>
|
||||
<div class="chart" id="overview-chart" role="img" aria-label="按日期汇总的 Commission 和 GMV 图表"></div>
|
||||
</article>
|
||||
<article class="chart-card">
|
||||
<h2>LinkName 趋势</h2>
|
||||
<p id="link-description">按日期和 LinkName 汇总</p>
|
||||
<div class="chart" id="link-chart" role="img" aria-label="按日期和 LinkName 汇总的 Commission 和 GMV 图表"></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var DATA_URL = 'data/merged.html';
|
||||
var REQUIRED_FIELDS = ['Date', 'Commission', 'Order', 'GMV', 'Currency', 'LinkName'];
|
||||
var charts = [];
|
||||
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'
|
||||
});
|
||||
|
||||
var elements = {
|
||||
title: document.getElementById('page-title'),
|
||||
subtitle: document.getElementById('page-subtitle'),
|
||||
status: document.getElementById('status'),
|
||||
warning: document.getElementById('currency-warning'),
|
||||
metrics: document.getElementById('metrics'),
|
||||
updatedAt: document.getElementById('updated-at'),
|
||||
recordCount: document.getElementById('record-count'),
|
||||
linkCount: document.getElementById('link-count'),
|
||||
currencyCount: document.getElementById('currency-count'),
|
||||
empty: document.getElementById('empty-state'),
|
||||
error: document.getElementById('error-state'),
|
||||
charts: document.getElementById('charts'),
|
||||
overviewDescription: document.getElementById('overview-description'),
|
||||
linkDescription: document.getElementById('link-description'),
|
||||
overview: document.getElementById('overview-chart'),
|
||||
link: document.getElementById('link-chart')
|
||||
};
|
||||
|
||||
function setStatus(message, state) {
|
||||
elements.status.textContent = message;
|
||||
elements.status.dataset.state = state || '';
|
||||
}
|
||||
|
||||
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;
|
||||
var parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
var text = String(value == null ? '' : value).trim();
|
||||
var match = /^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})$/.exec(text);
|
||||
if (!match) return null;
|
||||
var year = Number(match[1]);
|
||||
var month = Number(match[2]);
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
function readTable(htmlText) {
|
||||
var documentParser = new DOMParser();
|
||||
var parsedDocument = documentParser.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 [];
|
||||
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 cells = Array.from(row.children);
|
||||
function cell(field) {
|
||||
var index = positions[field];
|
||||
return index < cells.length ? cells[index].textContent.trim() : '';
|
||||
}
|
||||
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'
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
var dateTotal = byDate.get(record.date.key);
|
||||
dateTotal.commission += record.commission;
|
||||
dateTotal.gmv += 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 });
|
||||
}
|
||||
var linkTotal = linkDates.get(record.date.key);
|
||||
linkTotal.commission += record.commission;
|
||||
linkTotal.gmv += record.gmv;
|
||||
});
|
||||
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];
|
||||
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));
|
||||
});
|
||||
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 }
|
||||
};
|
||||
}
|
||||
|
||||
function renderCharts(records, currencyLabel) {
|
||||
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 links = Array.from(aggregated.byLink.keys()).sort(function (a, b) { return a.localeCompare(b); });
|
||||
var linkSeries = [];
|
||||
links.forEach(function (linkName, linkIndex) {
|
||||
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 + ' · 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(); });
|
||||
charts = [echarts.init(elements.overview), echarts.init(elements.link)];
|
||||
charts[0].setOption(overviewOption);
|
||||
charts[1].setOption(linkOption);
|
||||
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() {
|
||||
elements.metrics.hidden = false;
|
||||
elements.empty.hidden = false;
|
||||
elements.charts.hidden = true;
|
||||
setStatus('没有有效记录', 'empty');
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
elements.metrics.hidden = true;
|
||||
elements.empty.hidden = true;
|
||||
elements.charts.hidden = true;
|
||||
elements.error.textContent = '加载失败:' + (error && error.message ? error.message : '未知错误');
|
||||
elements.error.hidden = false;
|
||||
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);
|
||||
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;
|
||||
elements.empty.hidden = true;
|
||||
elements.charts.hidden = false;
|
||||
renderCharts(records, currencyLabel);
|
||||
setStatus('已加载 ' + formatNumber(records.length) + ' 条有效记录', 'ready');
|
||||
})
|
||||
.catch(showError);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
charts.forEach(function (chart) { chart.resize(); });
|
||||
});
|
||||
loadData();
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user