Files
wordpress-theme-aksrt/assets/js/admin-pay-overview.js
T
kate522andClaude Fable 5 9805a371d8 feat(admin): rebuild the payment center overview with ECharts analytics
- Overview page: realtime today vs. same time yesterday, 6 KPI cards with
  period-over-period deltas, trend (paid / net / refund curves + order count
  line), category curves, payment method donut, top 10 paid content,
  conversion / close / refund rates, todos and shortcuts
- Date presets, custom range and category filter via
  GET aksrt/v1/admin/pay-overview (manage_options + wp_rest nonce)
- Metrics: paid amount by paid date including later-refunded orders, refunds
  by refund date, net = paid - refund; CNY only, points and recharge excluded,
  foreign-currency orders listed separately; integer cents internally
- Daily records cached per month in transients (today/yesterday live),
  invalidated on order status change and on purge
- ECharts 6.1.0 subset bundled to assets/js/runtime/libs/echarts-pay.min.js
  (npm run build:pay-charts), loaded only on the overview page
- Split payment-attempt methods out of OrderRepositoryCore into
  OrderRepositoryAttempts; remove dead get_stats(), cash_decimal(),
  Order::get_stats() and the old .aksrt-native-links styles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-01 00:22:44 +08:00

408 lines
17 KiB
JavaScript
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.
/**
* 支付中心概览:区间筛选 + 指标卡 + ECharts 图表(成交趋势 / 分类成交 / 支付方式)+ 转化与退款 + 热销排行。
*
* 首屏报表来自页面内 #aksrt-pay-overview-data(PayOverview 输出),之后切换区间 / 分类请求
* GET aksrt/v1/admin/pay-overview(PayOverviewController)。ECharts 子集由
* assets/js/runtime/libs/echarts-pay.min.js 提供(window.echarts)。仅本页入队。
*/
(function () {
'use strict';
var root = document.querySelector('.aksrt-po-page');
var dataEl = document.getElementById('aksrt-pay-overview-data');
if (!root || !dataEl || !window.echarts) return;
var boot;
try { boot = JSON.parse(dataEl.textContent || '{}'); } catch (e) { return; }
var echarts = window.echarts;
var i18n = boot.i18n || {};
var currency = boot.currency || '';
var COLORS = { paid: '#18a058', net: '#2080f0', refund: '#d03050', count: '#f0a020' };
var PALETTE = ['#18a058', '#2080f0', '#f0a020', '#8a63d2', '#86909c', '#13c2c2', '#eb2f96'];
var TEXT = '#4e5969', MUTED = '#86909c', LINE = '#f0f1f4';
var state = { from: boot.initial.from, to: boot.initial.to, group: boot.initial.group || '', report: boot.initial.report };
var charts = {};
var reqSeq = 0;
/* ---------------- 格式化 ---------------- */
function money(v) {
return currency + Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function int(v) { return Number(v || 0).toLocaleString(); }
function compact(v) {
var n = Math.abs(v);
if (n >= 1e8) return (v / 1e8).toFixed(1).replace(/\.0$/, '') + '亿';
if (n >= 1e4) return (v / 1e4).toFixed(1).replace(/\.0$/, '') + '万';
return String(Math.round(v * 100) / 100);
}
function pct(v) { return v === null || v === undefined ? '—' : Number(v).toFixed(1) + '%'; }
function shortLabel(label) { return label.length === 10 ? label.slice(5) : label; }
/** 环比:基期为 0 时,本期有数为「新增」,否则不可比。 */
function deltaInfo(cur, prev) {
cur = Number(cur || 0); prev = Number(prev || 0);
if (prev <= 0) return cur > 0 ? { cls: 'is-up', text: i18n.new } : { cls: 'is-flat', text: '—' };
var d = (cur - prev) / prev * 100;
var cls = d > 0.05 ? 'is-up' : (d < -0.05 ? 'is-down' : 'is-flat');
return { cls: cls, text: (d > 0.05 ? '↑' : (d < -0.05 ? '↓' : '')) + Math.abs(d).toFixed(1) + '%' };
}
/* ---------------- 日期 ---------------- */
function parse(s) { var p = s.split('-'); return new Date(Date.UTC(+p[0], +p[1] - 1, +p[2])); }
function fmt(d) { return d.toISOString().slice(0, 10); }
function shift(s, days) { var d = parse(s); d.setUTCDate(d.getUTCDate() + days); return fmt(d); }
function spanDays(a, b) { return Math.round((parse(b) - parse(a)) / 864e5) + 1; }
function presetRange(key) {
var today = boot.today;
if (key === 'this_month') return [today.slice(0, 8) + '01', today];
if (key === 'last_month') {
var first = parse(today.slice(0, 8) + '01');
var lastEnd = new Date(first.getTime() - 864e5);
return [fmt(lastEnd).slice(0, 8) + '01', fmt(lastEnd)];
}
var n = parseInt(key.slice(1), 10);
return [shift(today, -(n - 1)), today];
}
/* ---------------- 数据加载 ---------------- */
function setBusy(busy) {
root.classList.toggle('is-loading', busy);
var body = root.querySelector('[data-aksrt-po-body]');
if (body) body.setAttribute('aria-busy', busy ? 'true' : 'false');
}
function showError(msg) {
var box = root.querySelector('[data-aksrt-po-error]');
if (!box) return;
box.hidden = !msg;
box.querySelector('p').textContent = msg || '';
}
function load(from, to, group) {
if (spanDays(from, to) < 1 || spanDays(from, to) > boot.maxDays || to > boot.today) {
showError(i18n.badRange);
return;
}
var seq = ++reqSeq;
var url = boot.rest.url + (boot.rest.url.indexOf('?') === -1 ? '?' : '&')
+ 'from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to) + '&group=' + encodeURIComponent(group);
setBusy(true);
fetch(url, { credentials: 'same-origin', headers: { 'X-WP-Nonce': boot.rest.nonce } })
.then(function (res) { return res.json().then(function (json) { return { ok: res.ok, json: json }; }); })
.then(function (r) {
if (seq !== reqSeq) return;
if (!r.ok || !r.json || !r.json.success) throw new Error((r.json && r.json.message) || i18n.loadError);
state = { from: from, to: to, group: group, report: r.json.data };
showError('');
renderAll();
})
.catch(function (err) { if (seq === reqSeq) showError(err && err.message ? err.message : i18n.loadError); })
.then(function () { if (seq === reqSeq) setBusy(false); });
}
/* ---------------- 指标卡 / 转化 / 排行 / 提示 ---------------- */
function renderKpis(rep) {
var t = rep.totals || {}, p = rep.previous || {};
var defs = {
paid_amount: [money, function () { return int(t.paid_count) + ' ' + i18n.unit; }],
net_amount: [money, function () { return i18n.refund + ' ' + money(t.refund_amount); }],
paid_count: [int, function () { return ''; }],
buyers: [int, function () { return ''; }],
avg_amount: [money, function () { return ''; }],
refund_amount: [money, function () { return int(t.refund_count) + ' ' + i18n.unit; }],
};
root.querySelectorAll('[data-kpi]').forEach(function (card) {
var key = card.getAttribute('data-kpi'), def = defs[key];
if (!def) return;
card.querySelector('[data-kpi-value]').textContent = def[0](t[key]);
card.querySelector('[data-kpi-meta]').textContent = def[1]();
var d = deltaInfo(t[key], p[key]);
var el = card.querySelector('[data-kpi-delta]');
// 退款上升是坏事:颜色反转。
var cls = key === 'refund_amount' ? (d.cls === 'is-up' ? 'is-down' : (d.cls === 'is-down' ? 'is-up' : d.cls)) : d.cls;
el.className = 'aksrt-po-delta ' + cls;
el.textContent = i18n.basis + ' ' + d.text;
});
var label = root.querySelector('[data-aksrt-po-range-label]');
if (label && rep.range) label.textContent = rep.range.from + ' ~ ' + rep.range.to + '(' + rep.range.days + ' 天)';
}
function renderFunnel(rep) {
var t = rep.totals || {}, p = rep.previous || {};
root.querySelectorAll('[data-aksrt-po-funnel] [data-metric]').forEach(function (li) {
var key = li.getAttribute('data-metric');
var isCount = key === 'created_count';
li.querySelector('[data-value]').textContent = isCount ? int(t[key]) : pct(t[key]);
var bar = li.querySelector('[data-bar]');
bar.style.width = isCount ? '100%' : Math.max(0, Math.min(100, Number(t[key] || 0))) + '%';
var deltaEl = li.querySelector('[data-delta]');
if (isCount) {
var d = deltaInfo(t[key], p[key]);
deltaEl.className = 'aksrt-po-delta ' + d.cls;
deltaEl.textContent = i18n.basis + ' ' + d.text;
} else if (p[key] !== null && p[key] !== undefined && t[key] !== null && t[key] !== undefined) {
var diff = Number(t[key]) - Number(p[key]);
var good = key === 'conversion' ? diff >= 0 : diff <= 0;
deltaEl.className = 'aksrt-po-delta ' + (Math.abs(diff) < 0.05 ? 'is-flat' : (good ? 'is-up' : 'is-down'));
deltaEl.textContent = i18n.basis + ' ' + (diff > 0 ? '+' : '') + diff.toFixed(1) + 'pt';
} else {
deltaEl.className = 'aksrt-po-delta is-flat';
deltaEl.textContent = '';
}
});
}
function renderTop(rep) {
var body = root.querySelector('[data-aksrt-po-top]');
if (!body) return;
body.textContent = '';
var rows = rep.top || [];
if (!rows.length) {
var tr = body.insertRow(), td = tr.insertCell();
td.colSpan = 5;
td.className = 'aksrt-po-top__empty';
td.textContent = i18n.noTop;
return;
}
var total = rows.reduce(function (s, r) { return s + Number(r.amount || 0); }, 0) || 1;
rows.forEach(function (r, i) {
var tr = body.insertRow();
var rank = tr.insertCell();
rank.className = 'column-rank';
rank.innerHTML = '<span class="aksrt-po-rank' + (i < 3 ? ' is-top' : '') + '"></span>';
rank.firstChild.textContent = String(i + 1);
var title = tr.insertCell();
if (r.url) {
var a = document.createElement('a');
a.href = r.url;
a.textContent = r.title;
title.appendChild(a);
} else {
title.textContent = r.title;
}
var count = tr.insertCell(); count.className = 'column-num'; count.textContent = int(r.count);
var amount = tr.insertCell(); amount.className = 'column-num'; amount.textContent = money(r.amount);
var share = tr.insertCell(); share.className = 'column-share';
var ratio = Number(r.amount || 0) / total * 100;
share.innerHTML = '<span class="aksrt-po-share"><i></i></span><span></span>';
share.querySelector('i').style.width = ratio.toFixed(1) + '%';
share.lastChild.textContent = ratio.toFixed(1) + '%';
});
}
function renderExcluded(rep) {
var box = root.querySelector('[data-aksrt-po-excluded]');
if (!box) return;
var list = rep.excluded || [];
box.hidden = !list.length;
box.textContent = list.length ? i18n.excluded + list.map(function (x) {
return x.currency + ' ' + Number(x.amount).toFixed(2) + '(' + x.count + ' ' + i18n.unit + ')';
}).join(';') : '';
}
/* ---------------- 图表 ---------------- */
function chart(key) {
var host = root.querySelector('[data-aksrt-po-chart="' + key + '"]');
if (!host) return null;
if (!charts[key]) charts[key] = echarts.init(host, null, { renderer: 'svg' });
return charts[key];
}
function baseAxis(labels) {
return {
type: 'category',
boundaryGap: false,
data: labels,
axisLine: { lineStyle: { color: '#e5e6eb' } },
axisTick: { show: false },
axisLabel: { color: MUTED, hideOverlap: true, formatter: shortLabel },
};
}
function valueAxis(formatter, show) {
return {
type: 'value',
show: show !== false,
splitLine: { lineStyle: { color: LINE } },
axisLabel: { color: MUTED, formatter: formatter },
minInterval: 1,
};
}
function tooltip(formatters) {
return {
trigger: 'axis',
confine: true,
axisPointer: { type: 'line', lineStyle: { color: '#c9cdd4', type: 'dashed' } },
backgroundColor: '#fff',
borderColor: '#e5e6eb',
textStyle: { color: TEXT, fontSize: 12 },
extraCssText: 'box-shadow:0 8px 24px rgba(15,23,42,.12);border-radius:8px;',
valueFormatter: formatters,
};
}
function area(color) {
return {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color + '38' },
{ offset: 1, color: color + '00' },
]),
};
}
function zoom(n) {
return n > 45 ? [{ type: 'inside', start: 0, end: 100 }, { type: 'slider', height: 18, bottom: 6, borderColor: 'transparent', fillerColor: 'rgba(24,160,88,.12)', handleSize: '80%', showDetail: false }] : [];
}
function emptyGraphic(isEmpty) {
return isEmpty ? [{ type: 'text', left: 'center', top: 'middle', silent: true, style: { text: i18n.empty, fill: MUTED, fontSize: 13 } }] : [];
}
function renderTrend(rep) {
var c = chart('trend');
if (!c) return;
var s = rep.series || { labels: [], paid: [], net: [], refund: [], count: [] };
var n = s.labels.length;
var isEmpty = !s.paid.some(function (v) { return v > 0; });
var smoothLine = function (name, data, color, extra) {
return Object.assign({ name: name, type: 'line', data: data, smooth: 0.35, showSymbol: false, symbolSize: 6, color: color, lineStyle: { width: 2.25 }, emphasis: { focus: 'series' } }, extra || {});
};
c.setOption({
aria: { enabled: true },
color: [COLORS.paid, COLORS.net, COLORS.refund, COLORS.count],
grid: { left: 8, right: 8, top: 40, bottom: n > 45 ? 36 : 8, containLabel: true },
legend: { top: 0, left: 0, icon: 'roundRect', itemWidth: 12, itemHeight: 4, textStyle: { color: TEXT }, selected: { [i18n.refund]: true } },
tooltip: tooltip(function (v) { return typeof v === 'number' && !Number.isInteger(v) ? money(v) : v; }),
xAxis: baseAxis(s.labels),
yAxis: [valueAxis(compact), valueAxis(function (v) { return v; })],
dataZoom: zoom(n),
graphic: emptyGraphic(isEmpty),
series: [
smoothLine(i18n.paid, s.paid, COLORS.paid, { areaStyle: area(COLORS.paid), tooltip: { valueFormatter: money } }),
smoothLine(i18n.net, s.net, COLORS.net, { tooltip: { valueFormatter: money } }),
smoothLine(i18n.refund, s.refund, COLORS.refund, { lineStyle: { width: 1.5, type: 'dashed' }, tooltip: { valueFormatter: money } }),
{
name: i18n.count, type: 'line', yAxisIndex: 1, data: s.count, smooth: false,
showSymbol: n <= 31, symbol: 'circle', symbolSize: 5, color: COLORS.count,
lineStyle: { width: 1.5 }, itemStyle: { borderColor: '#fff', borderWidth: 1.5 },
tooltip: { valueFormatter: function (v) { return int(v) + ' ' + i18n.unit; } },
},
],
}, true);
}
function renderGroups(rep) {
var c = chart('groups');
if (!c) return;
var s = rep.series || { labels: [], groups: [] };
var groups = s.groups || [];
var n = s.labels.length;
c.setOption({
aria: { enabled: true },
color: PALETTE,
grid: { left: 8, right: 8, top: 40, bottom: n > 45 ? 36 : 8, containLabel: true },
legend: { top: 0, left: 0, icon: 'roundRect', itemWidth: 12, itemHeight: 4, textStyle: { color: TEXT } },
tooltip: tooltip(money),
xAxis: baseAxis(s.labels),
yAxis: valueAxis(compact),
dataZoom: zoom(n),
graphic: emptyGraphic(!groups.length),
series: groups.map(function (g) {
return { name: g.name, type: 'line', data: g.data, smooth: 0.35, showSymbol: false, lineStyle: { width: 2 }, emphasis: { focus: 'series' } };
}),
}, true);
}
function renderMethods(rep) {
var c = chart('methods');
if (!c) return;
var list = rep.methods || [];
var total = list.reduce(function (s, m) { return s + Number(m.amount || 0); }, 0);
c.setOption({
aria: { enabled: true },
color: PALETTE,
tooltip: {
trigger: 'item', confine: true, backgroundColor: '#fff', borderColor: '#e5e6eb', textStyle: { color: TEXT, fontSize: 12 },
formatter: function (p) { return p.marker + p.name + '<br>' + money(p.value) + '(' + p.percent + '%,' + int(p.data.count) + ' ' + i18n.unit + ')'; },
},
legend: { bottom: 0, left: 'center', icon: 'circle', itemWidth: 8, itemHeight: 8, textStyle: { color: TEXT } },
graphic: list.length ? [{
type: 'group', left: 'center', top: '36%', silent: true,
children: [
{ type: 'text', style: { text: i18n.total, fill: MUTED, fontSize: 12, align: 'center' }, left: 'center', top: 0 },
{ type: 'text', style: { text: money(total), fill: '#1f2329', fontSize: 16, fontWeight: 600, align: 'center' }, left: 'center', top: 18 },
],
}] : emptyGraphic(true),
series: [{
type: 'pie', radius: ['52%', '72%'], center: ['50%', '44%'], avoidLabelOverlap: true,
itemStyle: { borderColor: '#fff', borderWidth: 2, borderRadius: 4 },
label: { show: false }, labelLine: { show: false },
emphasis: { scale: true, scaleSize: 4 },
data: list.map(function (m) { return { name: m.name, value: m.amount, count: m.count }; }),
}],
}, true);
}
function renderAll() {
var rep = state.report || {};
renderKpis(rep);
renderFunnel(rep);
renderTop(rep);
renderExcluded(rep);
renderTrend(rep);
renderGroups(rep);
renderMethods(rep);
}
/* ---------------- 交互 ---------------- */
var presets = root.querySelector('[data-aksrt-po-presets]');
var form = root.querySelector('[data-aksrt-po-range]');
function syncForm() {
if (!form) return;
form.elements.from.value = state.from;
form.elements.to.value = state.to;
form.elements.group.value = state.group;
}
function pressPreset(btn) {
if (!presets) return;
presets.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-pressed', b === btn ? 'true' : 'false'); });
}
if (presets) {
presets.addEventListener('click', function (e) {
var btn = e.target.closest('button[data-preset]');
if (!btn) return;
var r = presetRange(btn.getAttribute('data-preset'));
pressPreset(btn);
state.from = r[0]; state.to = r[1];
syncForm();
load(r[0], r[1], form ? form.elements.group.value : state.group);
});
}
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
var from = form.elements.from.value, to = form.elements.to.value;
if (!from || !to || from > to) { showError(i18n.badRange); return; }
pressPreset(null);
load(from, to, form.elements.group.value);
});
form.elements.group.addEventListener('change', function () { load(state.from, state.to, form.elements.group.value); });
}
var resizeTimer;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () { Object.keys(charts).forEach(function (k) { charts[k].resize(); }); }, 120);
});
syncForm();
renderAll();
})();