|
@@ -91,6 +91,10 @@
|
|
|
preset: '30',
|
|
preset: '30',
|
|
|
mode: 'detail',
|
|
mode: 'detail',
|
|
|
rawView: false,
|
|
rawView: false,
|
|
|
|
|
+ page: 1,
|
|
|
|
|
+ pageSize: 200,
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ pages: 1,
|
|
|
shopFilter: new Set(),
|
|
shopFilter: new Set(),
|
|
|
q: '',
|
|
q: '',
|
|
|
columns: [],
|
|
columns: [],
|
|
@@ -171,7 +175,7 @@
|
|
|
anaCache = null;
|
|
anaCache = null;
|
|
|
renderEntityTabs();
|
|
renderEntityTabs();
|
|
|
renderShopOptions();
|
|
renderShopOptions();
|
|
|
- loadReport();
|
|
|
|
|
|
|
+ reloadReport();
|
|
|
renderCharts();
|
|
renderCharts();
|
|
|
});
|
|
});
|
|
|
wrap.appendChild(btn);
|
|
wrap.appendChild(btn);
|
|
@@ -372,7 +376,7 @@
|
|
|
const { from, to } = currentRange();
|
|
const { from, to } = currentRange();
|
|
|
try {
|
|
try {
|
|
|
let url = state.mode === 'detail'
|
|
let url = state.mode === 'detail'
|
|
|
- ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}${state.rawView ? '' : '&scope=shop'}`
|
|
|
|
|
|
|
+ ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}${state.rawView ? '' : '&scope=shop'}&page=${state.page}&pageSize=${state.pageSize}`
|
|
|
: `/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=${state.mode}`;
|
|
: `/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=${state.mode}`;
|
|
|
if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
|
|
if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
|
|
|
if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
|
|
if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
|
|
@@ -380,14 +384,52 @@
|
|
|
if (state.entity !== entity) return;
|
|
if (state.entity !== entity) return;
|
|
|
state.rows = data.rows || [];
|
|
state.rows = data.rows || [];
|
|
|
state.summary = data.summary || null;
|
|
state.summary = data.summary || null;
|
|
|
|
|
+ state.total = state.mode === 'detail' ? (data.total ?? state.rows.length) : state.rows.length;
|
|
|
|
|
+ state.pages = state.mode === 'detail' ? (data.pages ?? 1) : 1;
|
|
|
renderKpis();
|
|
renderKpis();
|
|
|
renderTable();
|
|
renderTable();
|
|
|
|
|
+ renderPager();
|
|
|
|
|
+ loadTrend();
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
if (e.name === 'AbortError') return;
|
|
if (e.name === 'AbortError') return;
|
|
|
showToast('报表加载失败:' + e.message, 'error');
|
|
showToast('报表加载失败:' + e.message, 'error');
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 筛选/主体/视图等变化:回到第一页再加载
|
|
|
|
|
+ function reloadReport() {
|
|
|
|
|
+ state.page = 1;
|
|
|
|
|
+ loadReport();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 分页条:明细模式服务端分页(排序作用于当前页);聚合模式行数少不分页
|
|
|
|
|
+ function renderPager() {
|
|
|
|
|
+ const el = $('reportPager');
|
|
|
|
|
+ if (!el) return;
|
|
|
|
|
+ if (state.mode !== 'detail' || state.pages <= 1) {
|
|
|
|
|
+ el.innerHTML = state.mode === 'detail' ? `<span class="pager-info">共 ${state.total} 行</span>` : '';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const from = (state.page - 1) * state.pageSize + 1;
|
|
|
|
|
+ const to = Math.min(state.total, state.page * state.pageSize);
|
|
|
|
|
+ const btn = (label, target, disabled) =>
|
|
|
|
|
+ `<button type="button" class="pager-btn" data-page="${target}" ${disabled ? 'disabled' : ''}>${label}</button>`;
|
|
|
|
|
+ el.innerHTML = `
|
|
|
|
|
+ ${btn('‹ 上一页', state.page - 1, state.page <= 1)}
|
|
|
|
|
+ <span class="pager-info">第 ${state.page} / ${state.pages} 页 · ${from}-${to} 行 · 共 ${state.total} 行</span>
|
|
|
|
|
+ ${btn('下一页 ›', state.page + 1, state.page >= state.pages)}
|
|
|
|
|
+ <span class="pager-info pager-note">列排序作用于当前页</span>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function changePage(page) {
|
|
|
|
|
+ if (page < 1 || page > state.pages || page === state.page) return;
|
|
|
|
|
+ state.page = page;
|
|
|
|
|
+ loadReport();
|
|
|
|
|
+ const wrap = $('reportTableWrap');
|
|
|
|
|
+ if (wrap && wrap.scrollIntoView) wrap.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ---------- 连接状态 ----------
|
|
// ---------- 连接状态 ----------
|
|
|
|
|
|
|
|
async function refreshConn() {
|
|
async function refreshConn() {
|
|
@@ -957,6 +999,7 @@
|
|
|
$('reportToDate').value = ymdToDash(a.to);
|
|
$('reportToDate').value = ymdToDash(a.to);
|
|
|
state.shopFilter = new Set((a.shopIds || []).map(String));
|
|
state.shopFilter = new Set((a.shopIds || []).map(String));
|
|
|
renderShopOptions();
|
|
renderShopOptions();
|
|
|
|
|
+ state.page = 1;
|
|
|
loadReport();
|
|
loadReport();
|
|
|
// 明细浏览在数据中台页:先切到明细 tab,再导航过去
|
|
// 明细浏览在数据中台页:先切到明细 tab,再导航过去
|
|
|
if (window.shanLakeShowDetail) window.shanLakeShowDetail();
|
|
if (window.shanLakeShowDetail) window.shanLakeShowDetail();
|
|
@@ -1056,13 +1099,13 @@
|
|
|
$('reportRangePreset').addEventListener('change', () => {
|
|
$('reportRangePreset').addEventListener('change', () => {
|
|
|
state.preset = $('reportRangePreset').value;
|
|
state.preset = $('reportRangePreset').value;
|
|
|
$('reportCustomRange').hidden = state.preset !== 'custom';
|
|
$('reportCustomRange').hidden = state.preset !== 'custom';
|
|
|
- loadReport();
|
|
|
|
|
|
|
+ reloadReport();
|
|
|
});
|
|
});
|
|
|
$('reportFromDate').addEventListener('change', loadReport);
|
|
$('reportFromDate').addEventListener('change', loadReport);
|
|
|
$('reportToDate').addEventListener('change', loadReport);
|
|
$('reportToDate').addEventListener('change', loadReport);
|
|
|
|
|
|
|
|
$('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger'));
|
|
$('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger'));
|
|
|
- $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', loadReport));
|
|
|
|
|
|
|
+ $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', reloadReport));
|
|
|
document.addEventListener('click', (event) => {
|
|
document.addEventListener('click', (event) => {
|
|
|
if (!$('reportShopDropdown').contains(event.target)) toggleDropdown('reportShopMenu', 'reportShopTrigger', false);
|
|
if (!$('reportShopDropdown').contains(event.target)) toggleDropdown('reportShopMenu', 'reportShopTrigger', false);
|
|
|
});
|
|
});
|
|
@@ -1074,7 +1117,7 @@
|
|
|
$('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
|
|
$('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
|
|
|
state.sortKey = state.mode === 'detail' ? 'date' : (state.mode === 'shop' ? 'shopName' : 'date');
|
|
state.sortKey = state.mode === 'detail' ? 'date' : (state.mode === 'shop' ? 'shopName' : 'date');
|
|
|
state.sortDir = 'desc';
|
|
state.sortDir = 'desc';
|
|
|
- loadReport();
|
|
|
|
|
|
|
+ reloadReport();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 字段模式:报表列(语义列)/ 全部字段(明细 + 全部原始字段;聚合视图无 raw,自动回明细)
|
|
// 字段模式:报表列(语义列)/ 全部字段(明细 + 全部原始字段;聚合视图无 raw,自动回明细)
|
|
@@ -1100,7 +1143,7 @@
|
|
|
clearTimeout(searchTimer);
|
|
clearTimeout(searchTimer);
|
|
|
searchTimer = setTimeout(() => {
|
|
searchTimer = setTimeout(() => {
|
|
|
state.q = $('reportSearch').value.trim();
|
|
state.q = $('reportSearch').value.trim();
|
|
|
- loadReport();
|
|
|
|
|
|
|
+ reloadReport();
|
|
|
}, 300);
|
|
}, 300);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -1174,22 +1217,28 @@
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ---------- 同步历史(数据采集页) ----------
|
|
|
|
|
|
|
+ // ---------- 同步历史(数据采集页,含云端推送状态) ----------
|
|
|
|
|
|
|
|
function renderSyncHistory() {
|
|
function renderSyncHistory() {
|
|
|
const tbody = $('syncHistoryRows');
|
|
const tbody = $('syncHistoryRows');
|
|
|
if (!tbody) return;
|
|
if (!tbody) return;
|
|
|
if (!state.entity) {
|
|
if (!state.entity) {
|
|
|
- tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">暂无主体</td></tr>';
|
|
|
|
|
|
|
+ tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">暂无主体</td></tr>';
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- jsonFetch(`/api/reports/syncs?entity=${encodeURIComponent(state.entity)}`)
|
|
|
|
|
- .then((data) => {
|
|
|
|
|
|
|
+ const entityKey = encodeURIComponent(state.entity);
|
|
|
|
|
+ Promise.all([
|
|
|
|
|
+ jsonFetch(`/api/reports/syncs?entity=${entityKey}`),
|
|
|
|
|
+ jsonFetch(`/api/reports/aitable/status?entity=${entityKey}`).catch(() => null),
|
|
|
|
|
+ ])
|
|
|
|
|
+ .then(([data, cloud]) => {
|
|
|
const runs = (data.runs || []).slice(0, 8);
|
|
const runs = (data.runs || []).slice(0, 8);
|
|
|
if (!runs.length) {
|
|
if (!runs.length) {
|
|
|
- tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">暂无同步记录</td></tr>';
|
|
|
|
|
|
|
+ tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">暂无同步记录</td></tr>';
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+ const lastPush = cloud && cloud.lastPush ? cloud.lastPush : null;
|
|
|
|
|
+ const pushAt = lastPush ? new Date(lastPush.at || lastPush.lastPushedAt || 0).getTime() : 0;
|
|
|
const SCOPE_LABEL = { all: '全主体', chain: '按连锁', shop: '按门店' };
|
|
const SCOPE_LABEL = { all: '全主体', chain: '按连锁', shop: '按门店' };
|
|
|
tbody.innerHTML = runs.map((r) => {
|
|
tbody.innerHTML = runs.map((r) => {
|
|
|
const warn = (r.steps || []).filter((s) => !s.ok).length;
|
|
const warn = (r.steps || []).filter((s) => !s.ok).length;
|
|
@@ -1197,6 +1246,11 @@
|
|
|
const cls = r.cancelled || r.failed ? 'badge-fail' : (warn ? 'badge-warn' : 'badge-ok');
|
|
const cls = r.cancelled || r.failed ? 'badge-fail' : (warn ? 'badge-warn' : 'badge-ok');
|
|
|
const t = r.at ? new Date(r.at) : null;
|
|
const t = r.at ? new Date(r.at) : null;
|
|
|
const ts = t ? `${t.getMonth() + 1}/${t.getDate()} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '–';
|
|
const ts = t ? `${t.getMonth() + 1}/${t.getDate()} ${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}` : '–';
|
|
|
|
|
+ // 该次同步之后是否推送过云端(以最近一次推送时间为准;更早的同步被其覆盖归档)
|
|
|
|
|
+ const pushed = pushAt && t && pushAt >= t.getTime();
|
|
|
|
|
+ const cloudCell = !cloud || !cloud.enabled
|
|
|
|
|
+ ? '<span class="badge">未启用</span>'
|
|
|
|
|
+ : (pushed ? `<span class="badge badge-ok">已推 ${lastPush.pushedRows || 0} 行</span>` : '<span class="badge badge-info">未推送</span>');
|
|
|
return `<tr>
|
|
return `<tr>
|
|
|
<td>${ts}</td>
|
|
<td>${ts}</td>
|
|
|
<td>${(r.from || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')} ~ ${(r.to || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')}</td>
|
|
<td>${(r.from || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')} ~ ${(r.to || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')}</td>
|
|
@@ -1205,14 +1259,71 @@
|
|
|
<td class="num">${r.updated ?? 0}</td>
|
|
<td class="num">${r.updated ?? 0}</td>
|
|
|
<td class="num">${r.failed ?? 0}</td>
|
|
<td class="num">${r.failed ?? 0}</td>
|
|
|
<td><span class="badge ${cls}">${note}</span></td>
|
|
<td><span class="badge ${cls}">${note}</span></td>
|
|
|
|
|
+ <td>${cloudCell}</td>
|
|
|
</tr>`;
|
|
</tr>`;
|
|
|
}).join('');
|
|
}).join('');
|
|
|
})
|
|
})
|
|
|
.catch(() => {
|
|
.catch(() => {
|
|
|
- tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">同步历史加载失败</td></tr>';
|
|
|
|
|
|
|
+ tbody.innerHTML = '<tr><td colspan="8" class="empty-cell">同步历史加载失败</td></tr>';
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ---------- 主指标趋势(明细 tab 顶部,BI 图表层) ----------
|
|
|
|
|
+ // 数据独立于表格分页:按日聚合全区间(行数=天数,轻量);懒 init——容器隐藏时 ECharts 拿不到宽高
|
|
|
|
|
+
|
|
|
|
|
+ let trendChart = null;
|
|
|
|
|
+ let trendRows = [];
|
|
|
|
|
+
|
|
|
|
|
+ function buildTrendMetricOptions() {
|
|
|
|
|
+ const sel = $('reportTrendMetric');
|
|
|
|
|
+ if (!sel) return;
|
|
|
|
|
+ const current = sel.value || 'grossAmt';
|
|
|
|
|
+ const cols = metricColumns().filter((c) => ['money', 'int', 'num', 'pct'].includes(c.type));
|
|
|
|
|
+ sel.innerHTML = cols.map((c) => `<option value="${c.key}" ${c.key === current ? 'selected' : ''}>${c.label}</option>`).join('');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadTrend() {
|
|
|
|
|
+ const entity = state.entity;
|
|
|
|
|
+ const el = $('reportTrendChart');
|
|
|
|
|
+ if (!el || !entity) { trendRows = []; return; }
|
|
|
|
|
+ const { from, to } = currentRange();
|
|
|
|
|
+ let shops = '';
|
|
|
|
|
+ if (state.shopFilter.size) shops = `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await jsonFetch(`/api/reports/aggregate?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&group=date${shops}`);
|
|
|
|
|
+ if (state.entity !== entity) return;
|
|
|
|
|
+ trendRows = data.rows || [];
|
|
|
|
|
+ buildTrendMetricOptions();
|
|
|
|
|
+ renderTrend();
|
|
|
|
|
+ } catch { /* 趋势图失败不打扰表格 */ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderTrend() {
|
|
|
|
|
+ const el = $('reportTrendChart');
|
|
|
|
|
+ if (!el || !el.offsetWidth) return; // 容器隐藏(tab 未激活):等激活后再画
|
|
|
|
|
+ if (!trendRows.length) { if (trendChart) trendChart.clear(); return; }
|
|
|
|
|
+ if (!trendChart) {
|
|
|
|
|
+ trendChart = echarts.init(el);
|
|
|
|
|
+ window.addEventListener('resize', () => { if (trendChart) trendChart.resize(); });
|
|
|
|
|
+ }
|
|
|
|
|
+ const metric = $('reportTrendMetric').value || 'grossAmt';
|
|
|
|
|
+ const col = state.columns.find((c) => c.key === metric) || {};
|
|
|
|
|
+ const rows = trendRows.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
|
|
|
|
|
+ const isRate = col.type === 'pct';
|
|
|
|
|
+ trendChart.setOption({
|
|
|
|
|
+ grid: { left: 56, right: 20, top: 24, bottom: 36 },
|
|
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
|
|
+ xAxis: { type: 'category', data: rows.map((r) => String(r.date).replace(/^(\d{4})(\d{2})(\d{2})$/, '$2/$3')) },
|
|
|
|
|
+ yAxis: { type: 'value', axisLabel: { formatter: (v) => (isRate ? (v * 100).toFixed(0) + '%' : v >= 10000 ? (v / 10000) + '万' : v) } },
|
|
|
|
|
+ series: [{
|
|
|
|
|
+ type: 'line', smooth: true, symbolSize: 5, data: rows.map((r) => r[metric]),
|
|
|
|
|
+ lineStyle: { width: 2.2 }, areaStyle: { opacity: 0.08 },
|
|
|
|
|
+ tooltip: { valueFormatter: (v) => (v == null ? '—' : isRate ? (v * 100).toFixed(2) + '%' : String(v)) },
|
|
|
|
|
+ }],
|
|
|
|
|
+ }, { notMerge: true });
|
|
|
|
|
+ trendChart.resize();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ---------- 初始化 ----------
|
|
// ---------- 初始化 ----------
|
|
|
|
|
|
|
|
// 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)
|
|
// 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)
|
|
@@ -1254,7 +1365,15 @@
|
|
|
if (!state.inited) {
|
|
if (!state.inited) {
|
|
|
state.inited = true;
|
|
state.inited = true;
|
|
|
bindEvents();
|
|
bindEvents();
|
|
|
- $('btnReportPush').addEventListener('click', pushCurrentEntity);
|
|
|
|
|
|
|
+ $('btnReportPush').addEventListener('click', async () => {
|
|
|
|
|
+ await pushCurrentEntity();
|
|
|
|
|
+ renderSyncHistory(); // 云端推送状态列随推送结果刷新
|
|
|
|
|
+ });
|
|
|
|
|
+ $('reportPager').addEventListener('click', (event) => {
|
|
|
|
|
+ const btn = event.target.closest('button[data-page]');
|
|
|
|
|
+ if (btn) changePage(Number(btn.dataset.page));
|
|
|
|
|
+ });
|
|
|
|
|
+ $('reportTrendMetric').addEventListener('change', renderTrend);
|
|
|
try {
|
|
try {
|
|
|
const data = await jsonFetch('/api/reports/columns');
|
|
const data = await jsonFetch('/api/reports/columns');
|
|
|
state.columns = data.columns || [];
|
|
state.columns = data.columns || [];
|