838 lines
44 KiB
HTML
Executable File
838 lines
44 KiB
HTML
Executable File
<!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">
|
||
<link rel="icon" type="image/png" href="https://storage.googleapis.com/msgsndr/lW87lVmHdMr8CFmSlKXl/media/68402c8a28def01375942a3a.png">
|
||
<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); }
|
||
.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); white-space: pre-line; }
|
||
.alert[data-stale="true"] { border-color: var(--error-border); background: var(--error-bg); color: var(--error-text); }
|
||
.alert[hidden] { display: none; }
|
||
.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: flex-start; gap: 14px 20px; }
|
||
.filter-group { display: grid; gap: 5px; min-width: 160px; }
|
||
.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); }
|
||
.currency-group { min-width: 150px; }
|
||
.currency-help, .filter-help, .rate-info { color: var(--muted); font-size: 12px; }
|
||
.currency-help { line-height: 1.35; }
|
||
.date-fields { display: flex; flex-wrap: wrap; gap: 12px 14px; align-items: end; }
|
||
.date-fields[hidden] { display: none; }
|
||
.multi-select { position: relative; min-width: 250px; }
|
||
.multi-select-trigger { display: flex; width: 100%; min-height: 36px; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); cursor: pointer; font: inherit; text-align: left; }
|
||
.multi-select-trigger::after { content: '⌄'; color: var(--muted); }
|
||
.multi-select-trigger:hover, .multi-select-trigger:focus, .multi-select-trigger[aria-expanded="true"] { border-color: var(--primary); outline: 2px solid rgba(37, 99, 235, 0.12); }
|
||
.multi-select-trigger:disabled { background: #f1f4f8; color: var(--muted); cursor: not-allowed; }
|
||
.multi-select-panel { position: absolute; z-index: 10; top: calc(100% + 6px); left: 0; width: min(320px, calc(100vw - 40px)); padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
|
||
.multi-select-panel[hidden] { display: none; }
|
||
.multi-select-search { width: 100%; min-height: 34px; margin-bottom: 8px; }
|
||
.multi-select-list { display: grid; max-height: 220px; gap: 2px; overflow-y: auto; padding: 2px; }
|
||
.multi-select-option { display: flex; min-height: 32px; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px; cursor: pointer; }
|
||
.multi-select-option:hover, .multi-select-option:focus-within { background: #f1f5ff; }
|
||
.multi-select-option input { width: 16px; height: 16px; min-height: 0; margin: 0; }
|
||
.multi-select-empty { padding: 8px 6px; color: var(--muted); font-size: 13px; }
|
||
.multi-select-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||
.multi-select-action { min-height: 32px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 7px; background: #fff; color: var(--text); cursor: pointer; font: inherit; }
|
||
.multi-select-action:hover, .multi-select-action:focus { border-color: var(--primary); color: var(--primary); }
|
||
.multi-select-action[data-action="confirm"] { border-color: var(--primary); background: var(--primary); color: #fff; }
|
||
.rate-info { display: flex; flex-wrap: wrap; gap: 4px 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-value { display: block; font-size: 23px; font-weight: 700; }
|
||
.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], .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; }
|
||
.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; }
|
||
.multi-select { min-width: 0; }
|
||
.multi-select-panel { left: 50%; transform: translateX(-50%); max-width: calc(100vw - 32px); }
|
||
}
|
||
</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="header-actions">
|
||
<a class="raw-link" href="data/merged.html" target="_blank" rel="noopener">查看原始数据</a>
|
||
<a class="raw-link" href="https://docs.google.com/spreadsheets/d/1DPbdVsX3SG_kgDXs2j34mDAgplRSfcAWKKG9yqalFmw/edit?gid=0#gid=0" target="_blank" rel="noopener noreferrer">查看 Google Sheet 数据源</a>
|
||
<div class="status" id="status" data-state="loading" role="status" aria-live="polite">正在加载数据…</div>
|
||
</div>
|
||
</header>
|
||
|
||
<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 currency-group">
|
||
<label for="currency-select">显示币种</label>
|
||
<select id="currency-select"></select>
|
||
<span class="currency-help" id="currency-help"></span>
|
||
</div>
|
||
<div class="filter-group">
|
||
<label for="range-select">日期范围</label>
|
||
<select id="range-select">
|
||
<option value="month">当月(按数据最新月份)</option>
|
||
<option value="recent">最近30天</option>
|
||
<option value="all">全部</option>
|
||
<option value="custom">自定义</option>
|
||
</select>
|
||
</div>
|
||
<div class="date-fields" id="date-fields" hidden>
|
||
<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>
|
||
<div class="filter-group">
|
||
<label for="link-name-trigger">LinkName</label>
|
||
<div class="multi-select" id="link-name-picker">
|
||
<button class="multi-select-trigger" id="link-name-trigger" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="link-name-panel" disabled>无可选 LinkName</button>
|
||
<div class="multi-select-panel" id="link-name-panel" role="dialog" aria-label="选择 LinkName" hidden>
|
||
<input class="multi-select-search" id="link-name-search" type="search" placeholder="搜索 LinkName" aria-label="搜索 LinkName">
|
||
<div class="multi-select-list" id="link-name-list" role="group"></div>
|
||
<div class="multi-select-actions">
|
||
<button class="multi-select-action" type="button" data-action="select-all">全选</button>
|
||
<button class="multi-select-action" type="button" data-action="clear">清空</button>
|
||
<button class="multi-select-action" type="button" data-action="confirm">确定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="filter-help" id="link-name-summary" aria-live="polite">无可选 LinkName</span>
|
||
</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="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">Total Commission</span><strong class="metric-value" id="total-commission">—</strong></div>
|
||
<div class="metric"><span class="metric-label">Total GMV</span><strong class="metric-value" id="total-gmv">—</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">按日期汇总 Commission 与 GMV</p>
|
||
<div class="chart" id="overview-chart" role="img" aria-label="按日期汇总的 Commission 柱状图和 GMV 折线图"></div>
|
||
</article>
|
||
<article class="chart-card">
|
||
<h2>LinkName Commission 趋势</h2>
|
||
<p id="link-commission-description">按日期和 LinkName 堆积显示 Commission</p>
|
||
<div class="chart" id="link-commission-chart" role="img" aria-label="按日期和 LinkName 堆积的 Commission 柱状图"></div>
|
||
</article>
|
||
<article class="chart-card">
|
||
<h2>LinkName GMV 趋势</h2>
|
||
<p id="link-gmv-description">按日期和 LinkName 堆积显示 GMV</p>
|
||
<div class="chart" id="link-gmv-chart" role="img" aria-label="按日期和 LinkName 堆积的 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 SYNC_LOG_URL = 'data/sync-log.json';
|
||
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'
|
||
});
|
||
var elements = {
|
||
title: document.getElementById('page-title'),
|
||
subtitle: document.getElementById('page-subtitle'),
|
||
status: document.getElementById('status'),
|
||
warning: document.getElementById('data-warning'),
|
||
filters: document.getElementById('filters'),
|
||
currency: document.getElementById('currency-select'),
|
||
currencyHelp: document.getElementById('currency-help'),
|
||
rangeSelect: document.getElementById('range-select'),
|
||
dateFields: document.getElementById('date-fields'),
|
||
startDate: document.getElementById('start-date'),
|
||
endDate: document.getElementById('end-date'),
|
||
linkNamePicker: document.getElementById('link-name-picker'),
|
||
linkNameTrigger: document.getElementById('link-name-trigger'),
|
||
linkNamePanel: document.getElementById('link-name-panel'),
|
||
linkNameSearch: document.getElementById('link-name-search'),
|
||
linkNameList: document.getElementById('link-name-list'),
|
||
linkNameActions: Array.from(document.querySelectorAll('[data-action]')),
|
||
linkNameSummary: document.getElementById('link-name-summary'),
|
||
rateStatus: document.getElementById('rate-status'),
|
||
metrics: document.getElementById('metrics'),
|
||
recordCount: document.getElementById('record-count'),
|
||
linkCount: document.getElementById('link-count'),
|
||
totalCommission: document.getElementById('total-commission'),
|
||
totalGmv: document.getElementById('total-gmv'),
|
||
empty: document.getElementById('empty-state'),
|
||
error: document.getElementById('error-state'),
|
||
charts: document.getElementById('charts'),
|
||
overviewDescription: document.getElementById('overview-description'),
|
||
linkCommissionDescription: document.getElementById('link-commission-description'),
|
||
linkGmvDescription: document.getElementById('link-gmv-description'),
|
||
overview: document.getElementById('overview-chart'),
|
||
linkCommission: document.getElementById('link-commission-chart'),
|
||
linkGmv: document.getElementById('link-gmv-chart')
|
||
};
|
||
var state = {
|
||
rawRecords: [],
|
||
sourceCurrencies: [],
|
||
selectedCurrency: '',
|
||
latestDate: null,
|
||
dateOnlyRows: 0,
|
||
invalidCurrencyRows: 0,
|
||
syncLog: null,
|
||
syncLogUnavailable: false,
|
||
availableLinkNames: [],
|
||
selectedLinkNames: new Set(),
|
||
draftLinkNames: new Set()
|
||
};
|
||
|
||
function setStatus(message, status) {
|
||
elements.status.textContent = message;
|
||
elements.status.dataset.state = status || '';
|
||
}
|
||
|
||
function formatNumber(value) { return numberFormatter.format(Number(value) || 0); }
|
||
|
||
function parseNumber(value) {
|
||
var normalized = String(value == null ? '' : value).trim().replace(/,/g, '');
|
||
if (!normalized) return null;
|
||
var parsed = Number(normalized);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
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 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 { 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('、'));
|
||
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];
|
||
return index < cells.length ? cells[index].textContent.trim() : '';
|
||
}
|
||
var parsedDate = parseDate(cell('Date'));
|
||
if (!parsedDate) return null;
|
||
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 formatShanghaiTime(value) {
|
||
if (!value) return null;
|
||
var timestamp = Date.parse(value);
|
||
if (Number.isNaN(timestamp)) return null;
|
||
var parts = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: 'Asia/Shanghai',
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||
hourCycle: 'h23'
|
||
}).formatToParts(new Date(timestamp));
|
||
var values = {};
|
||
parts.forEach(function (part) { values[part.type] = part.value; });
|
||
return values.year + '-' + values.month + '-' + values.day + ' ' + values.hour + ':' + values.minute + ':' + values.second;
|
||
}
|
||
|
||
function isOlderThan(value, milliseconds) {
|
||
var timestamp = Date.parse(value);
|
||
return !Number.isNaN(timestamp) && Date.now() - timestamp > milliseconds;
|
||
}
|
||
|
||
function updateNotice() {
|
||
var messages = [];
|
||
var log = state.syncLog;
|
||
var lastSuccessAt = log && log.lastSuccessAt ? formatShanghaiTime(log.lastSuccessAt) : null;
|
||
var lastDataUpdateAt = log && log.lastDataUpdateAt ? formatShanghaiTime(log.lastDataUpdateAt) : null;
|
||
var dataIsStale = false;
|
||
messages.push('最后成功运行:' + (lastSuccessAt || '暂无记录'));
|
||
messages.push('数据最后更新:' + (lastDataUpdateAt || '暂无记录'));
|
||
if (state.syncLogUnavailable) messages.push('运行日志暂不可用。');
|
||
if (log && log.lastSuccessAt && isOlderThan(log.lastSuccessAt, 24 * 60 * 60 * 1000)) {
|
||
messages.push('最后成功运行已超过24小时,请刷新页面或检查自动任务。');
|
||
}
|
||
if (log && log.lastDataUpdateAt) {
|
||
dataIsStale = isOlderThan(log.lastDataUpdateAt, 48 * 60 * 60 * 1000);
|
||
if (dataIsStale) messages.push('数据已超过48小时未更新,请检查数据同步。');
|
||
} else {
|
||
messages.push('没有数据更新时间记录。');
|
||
}
|
||
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('\n');
|
||
elements.warning.dataset.stale = dataIsStale ? 'true' : 'false';
|
||
elements.warning.hidden = messages.length === 0;
|
||
}
|
||
|
||
function parseSyncLog(payload) {
|
||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||
throw new Error('运行日志格式无效。');
|
||
}
|
||
function parseTimestamp(field) {
|
||
var value = payload[field];
|
||
if (value === null || value === undefined || value === '') return null;
|
||
if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) {
|
||
throw new Error('运行日志缺少有效的 ' + field + '。');
|
||
}
|
||
return value;
|
||
}
|
||
return {
|
||
lastSuccessAt: parseTimestamp('last_success_at'),
|
||
lastDataUpdateAt: parseTimestamp('last_data_update_at')
|
||
};
|
||
}
|
||
|
||
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];
|
||
elements.rangeSelect.value = rangeName;
|
||
elements.dateFields.hidden = rangeName !== 'custom';
|
||
if (rangeName === 'custom') {
|
||
if (!elements.startDate.value) elements.startDate.value = first.key;
|
||
if (!elements.endDate.value) elements.endDate.value = latest.key;
|
||
return;
|
||
}
|
||
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 = rangeName === 'all' ? first.key : (startKey < first.key ? first.key : startKey);
|
||
elements.endDate.value = latest.key;
|
||
}
|
||
|
||
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 updateLinkNameSummary() {
|
||
var total = state.availableLinkNames.length;
|
||
var selected = state.selectedLinkNames.size;
|
||
var label;
|
||
if (!total) {
|
||
label = '无可选 LinkName';
|
||
} else if (selected === total) {
|
||
label = '全部 LinkName';
|
||
} else {
|
||
label = '已选 ' + formatNumber(selected) + ' 项';
|
||
}
|
||
elements.linkNameTrigger.textContent = label;
|
||
elements.linkNameSummary.textContent = label;
|
||
}
|
||
|
||
function renderLinkNameOptions() {
|
||
elements.linkNameList.replaceChildren();
|
||
var query = elements.linkNameSearch.value.trim().toLocaleLowerCase();
|
||
var visibleCount = 0;
|
||
state.availableLinkNames.forEach(function (linkName) {
|
||
if (query && linkName.toLocaleLowerCase().indexOf(query) === -1) return;
|
||
visibleCount += 1;
|
||
var label = document.createElement('label');
|
||
label.className = 'multi-select-option';
|
||
var checkbox = document.createElement('input');
|
||
checkbox.type = 'checkbox';
|
||
checkbox.value = linkName;
|
||
checkbox.checked = state.draftLinkNames.has(linkName);
|
||
checkbox.setAttribute('aria-label', linkName);
|
||
checkbox.addEventListener('change', function () {
|
||
if (checkbox.checked) state.draftLinkNames.add(linkName);
|
||
else state.draftLinkNames.delete(linkName);
|
||
});
|
||
var text = document.createElement('span');
|
||
text.textContent = linkName;
|
||
label.appendChild(checkbox);
|
||
label.appendChild(text);
|
||
elements.linkNameList.appendChild(label);
|
||
});
|
||
if (!visibleCount) {
|
||
var empty = document.createElement('div');
|
||
empty.className = 'multi-select-empty';
|
||
empty.textContent = '没有匹配的 LinkName';
|
||
elements.linkNameList.appendChild(empty);
|
||
}
|
||
}
|
||
|
||
function closeLinkNamePicker() {
|
||
elements.linkNamePanel.hidden = true;
|
||
elements.linkNameTrigger.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
function openLinkNamePicker() {
|
||
if (elements.linkNameTrigger.disabled) return;
|
||
state.draftLinkNames = new Set(state.selectedLinkNames);
|
||
elements.linkNameSearch.value = '';
|
||
renderLinkNameOptions();
|
||
elements.linkNamePanel.hidden = false;
|
||
elements.linkNameTrigger.setAttribute('aria-expanded', 'true');
|
||
elements.linkNameSearch.focus();
|
||
}
|
||
|
||
function updateLinkNameOptions() {
|
||
var available = [];
|
||
try {
|
||
var range = currentRange();
|
||
available = Array.from(new Set(state.rawRecords.filter(function (record) {
|
||
return record.date.time >= range.start && record.date.time <= range.end;
|
||
}).map(function (record) { return record.linkName; }))).sort(function (a, b) {
|
||
return a.localeCompare(b);
|
||
});
|
||
} catch (error) {
|
||
available = [];
|
||
}
|
||
state.availableLinkNames = available;
|
||
state.selectedLinkNames = new Set(available);
|
||
state.draftLinkNames = new Set(available);
|
||
elements.linkNameTrigger.disabled = available.length === 0;
|
||
closeLinkNamePicker();
|
||
renderLinkNameOptions();
|
||
updateLinkNameSummary();
|
||
}
|
||
|
||
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; }));
|
||
var totalCommission = records.reduce(function (total, record) {
|
||
return total + (record.commission === null ? 0 : record.commission);
|
||
}, 0);
|
||
var totalGmv = records.reduce(function (total, record) {
|
||
return total + (record.gmv === null ? 0 : record.gmv);
|
||
}, 0);
|
||
elements.title.textContent = 'TTSHOP 销售看板 · ' + state.selectedCurrency;
|
||
elements.subtitle.textContent = 'Commission 与 GMV 趋势(数据来自最近一次成功同步)';
|
||
elements.recordCount.textContent = formatNumber(records.length);
|
||
elements.linkCount.textContent = formatNumber(links.size);
|
||
elements.totalCommission.textContent = state.selectedCurrency + ' ' + formatNumber(totalCommission);
|
||
elements.totalGmv.textContent = state.selectedCurrency + ' ' + formatNumber(totalGmv);
|
||
}
|
||
|
||
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 === 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 });
|
||
var linkTotal = linkDates.get(record.date.key);
|
||
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 { '&': '&', '<': '<', '>': '>', '\"': '"', "'": ''' }[character];
|
||
});
|
||
}
|
||
|
||
function tooltipFormatter(params) {
|
||
var items = Array.isArray(params) ? params : [params];
|
||
var date = items.length && items[0].axisValue ? items[0].axisValue : '';
|
||
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 + ' ' + escapeHtml(item.seriesName) + ':' + metric + ' ' + formatNumber(value));
|
||
});
|
||
return lines.join('<br>');
|
||
}
|
||
|
||
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) {
|
||
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 (' + state.selectedCurrency + ')';
|
||
var gmvAxisName = 'GMV (' + state.selectedCurrency + ')';
|
||
var dualAxes = [
|
||
{ 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) };
|
||
var overviewSeries = [
|
||
{ name: 'Commission', type: 'bar', yAxisIndex: 0, barMaxWidth: 34, data: aggregated.dates.map(function (item) { return item.commission; }) },
|
||
{ name: 'GMV', type: 'line', yAxisIndex: 1, smooth: true, symbol: 'circle', symbolSize: 6, 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 linkCommissionSeries = [];
|
||
var linkGmvSeries = [];
|
||
links.forEach(function (linkName) {
|
||
var linkDates = aggregated.byLink.get(linkName);
|
||
var values = dates.map(function (_, index) {
|
||
var item = aggregated.dates[index];
|
||
var value = linkDates.get(item.date.key);
|
||
return value || { commission: 0, gmv: 0 };
|
||
});
|
||
linkCommissionSeries.push({ name: linkName + ' · Commission', type: 'bar', stack: 'commission-by-link', smooth: true, symbol: 'circle', symbolSize: 5, data: values.map(function (value) { return value.commission; }) });
|
||
linkGmvSeries.push({ name: linkName + ' · GMV', type: 'bar', stack: 'gmv-by-link', barMaxWidth: 34, data: values.map(function (value) { return value.gmv; }) });
|
||
});
|
||
var commissionChart = Object.assign({}, common, {
|
||
yAxis: [{ type: 'value', name: commissionAxisName, nameLocation: 'middle', nameGap: 52 }],
|
||
legend: { type: 'scroll', top: 2, data: linkCommissionSeries.map(function (series) { return series.name; }) },
|
||
series: linkCommissionSeries
|
||
});
|
||
var gmvChart = Object.assign({}, common, {
|
||
yAxis: [{ type: 'value', name: gmvAxisName, nameLocation: 'middle', nameGap: 52 }],
|
||
legend: { type: 'scroll', top: 2, data: linkGmvSeries.map(function (series) { return series.name; }) },
|
||
series: linkGmvSeries
|
||
});
|
||
disposeCharts();
|
||
charts = [echarts.init(elements.overview), echarts.init(elements.linkCommission), echarts.init(elements.linkGmv)];
|
||
charts[0].setOption(Object.assign({}, common, { yAxis: dualAxes, color: ['#2563eb', '#f59e0b'], legend: { type: 'scroll', top: 2, data: ['Commission', 'GMV'] }, series: overviewSeries }));
|
||
charts[1].setOption(commissionChart);
|
||
charts[2].setOption(gmvChart);
|
||
elements.overviewDescription.textContent = '按日期汇总 · Commission 柱状图使用主轴 · GMV 折线图使用副轴 · ' + state.selectedCurrency;
|
||
elements.linkCommissionDescription.textContent = '按日期和 LinkName 堆积 · Commission 柱状图 · 主轴 ' + commissionAxisName;
|
||
elements.linkGmvDescription.textContent = '按日期和 LinkName 堆积 · GMV 柱状图 · 主轴 ' + gmvAxisName;
|
||
}
|
||
|
||
function showEmpty(message) {
|
||
disposeCharts();
|
||
elements.metrics.hidden = false;
|
||
elements.empty.textContent = message || '没有可展示的数据。';
|
||
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 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 && state.selectedLinkNames.has(record.linkName);
|
||
});
|
||
updateSummary(filtered);
|
||
elements.error.hidden = true;
|
||
elements.metrics.hidden = false;
|
||
if (!filtered.length) { showEmpty(); return; }
|
||
elements.empty.hidden = true;
|
||
elements.charts.hidden = false;
|
||
renderCharts(filtered);
|
||
setStatus('已加载 ' + formatNumber(filtered.length) + ' 条有效记录', 'ready');
|
||
}).catch(function (error) {
|
||
if (token === renderToken) showError(error);
|
||
});
|
||
}
|
||
|
||
function loadData() {
|
||
var dataPromise = fetch(DATA_URL, { cache: 'no-store' }).then(function (response) {
|
||
if (!response.ok) throw new Error('无法读取 data/merged.html(HTTP ' + response.status + ')。');
|
||
return response.text();
|
||
});
|
||
var syncLogPromise = fetch(SYNC_LOG_URL, { cache: 'no-store' }).then(function (response) {
|
||
if (!response.ok) throw new Error('无法读取 data/sync-log.json(HTTP ' + response.status + ')。');
|
||
return response.json();
|
||
}).then(parseSyncLog).catch(function () {
|
||
state.syncLogUnavailable = true;
|
||
return null;
|
||
});
|
||
Promise.all([dataPromise, syncLogPromise]).then(function (results) {
|
||
var result = readTable(results[0]);
|
||
state.syncLog = results[1];
|
||
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();
|
||
updateLinkNameOptions();
|
||
if (!state.rawRecords.length) { showEmpty('没有可展示的有效数据。'); return; }
|
||
renderCurrentView();
|
||
}).catch(showError);
|
||
}
|
||
|
||
elements.currency.addEventListener('change', function () {
|
||
state.selectedCurrency = elements.currency.value;
|
||
renderCurrentView();
|
||
});
|
||
elements.rangeSelect.addEventListener('change', function () {
|
||
setRange(elements.rangeSelect.value);
|
||
updateLinkNameOptions();
|
||
renderCurrentView();
|
||
});
|
||
elements.startDate.addEventListener('change', function () {
|
||
updateLinkNameOptions();
|
||
renderCurrentView();
|
||
});
|
||
elements.endDate.addEventListener('change', function () {
|
||
updateLinkNameOptions();
|
||
renderCurrentView();
|
||
});
|
||
elements.linkNameTrigger.addEventListener('click', function () {
|
||
if (elements.linkNamePanel.hidden) openLinkNamePicker();
|
||
else closeLinkNamePicker();
|
||
});
|
||
elements.linkNameSearch.addEventListener('input', renderLinkNameOptions);
|
||
elements.linkNameActions.forEach(function (button) {
|
||
button.addEventListener('click', function () {
|
||
var action = button.dataset.action;
|
||
if (action === 'select-all') state.draftLinkNames = new Set(state.availableLinkNames);
|
||
if (action === 'clear') state.draftLinkNames = new Set();
|
||
if (action === 'confirm') {
|
||
state.selectedLinkNames = new Set(state.draftLinkNames);
|
||
updateLinkNameSummary();
|
||
closeLinkNamePicker();
|
||
renderCurrentView();
|
||
return;
|
||
}
|
||
renderLinkNameOptions();
|
||
});
|
||
});
|
||
document.addEventListener('click', function (event) {
|
||
if (!elements.linkNamePicker.contains(event.target)) closeLinkNamePicker();
|
||
});
|
||
document.addEventListener('keydown', function (event) {
|
||
if (event.key === 'Escape' && !elements.linkNamePanel.hidden) {
|
||
closeLinkNamePicker();
|
||
elements.linkNameTrigger.focus();
|
||
}
|
||
});
|
||
window.addEventListener('resize', function () { charts.forEach(function (chart) { chart.resize(); }); });
|
||
loadData();
|
||
}());
|
||
</script>
|
||
</body>
|
||
</html>
|