Files
ttshop/index.html
T
2026-10-10 18:28:18 +08:00

837 lines
43 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">
<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>
<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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', "'": '&#39;' }[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>