Files
ttshop/index.html
T

455 lines
18 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>