|
|
@@ -90,6 +90,7 @@
|
|
|
entity: null,
|
|
|
preset: '30',
|
|
|
mode: 'detail',
|
|
|
+ rawView: false,
|
|
|
shopFilter: new Set(),
|
|
|
q: '',
|
|
|
columns: [],
|
|
|
@@ -141,7 +142,7 @@
|
|
|
}
|
|
|
|
|
|
function renderEntityTabs() {
|
|
|
- for (const containerId of ['entityTabs', 'analysisEntityTabs']) {
|
|
|
+ for (const containerId of ['entityTabs', 'analysisEntityTabs', 'lakeEntityTabs']) {
|
|
|
const wrap = $(containerId);
|
|
|
if (!wrap) continue;
|
|
|
wrap.innerHTML = '';
|
|
|
@@ -280,6 +281,30 @@
|
|
|
}
|
|
|
|
|
|
function tableColumns() {
|
|
|
+ // 全部字段模式(数据中台):固定业务列 + 从首行 raw 发现的全部原始字段(原始 API 字段名)
|
|
|
+ if (state.rawView) {
|
|
|
+ const base = [
|
|
|
+ { key: 'account', label: '账号名', type: 'text' },
|
|
|
+ { key: 'date', label: '日期', type: 'date' },
|
|
|
+ { key: 'scope', label: '维度', type: 'text' },
|
|
|
+ { key: 'shopId', label: '门店ID', type: 'text' },
|
|
|
+ { key: 'shopName', label: '门店名称', type: 'text' },
|
|
|
+ ];
|
|
|
+ const rawCols = [];
|
|
|
+ const seen = new Set(base.map((c) => c.label));
|
|
|
+ for (const r of state.rows) {
|
|
|
+ const raw = r && r.raw;
|
|
|
+ if (!raw || typeof raw !== 'object') continue;
|
|
|
+ for (const k of Object.keys(raw)) {
|
|
|
+ if (seen.has(k)) continue;
|
|
|
+ seen.add(k);
|
|
|
+ const sample = raw[k];
|
|
|
+ const isNum = typeof sample === 'number' || (typeof sample === 'string' && sample !== '' && Number.isFinite(Number(sample)));
|
|
|
+ rawCols.push({ key: `raw.${k}`, label: k, type: isNum ? 'num' : 'text', raw: true });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return [...base, ...rawCols];
|
|
|
+ }
|
|
|
const metricCols = metricColumns();
|
|
|
const accountCol = { key: 'account', label: '账号名', type: 'text' };
|
|
|
if (state.mode === 'shop') return [accountCol, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
|
|
|
@@ -287,9 +312,17 @@
|
|
|
return [accountCol, { key: 'date', label: '日期', type: 'date' }, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
|
|
|
}
|
|
|
|
|
|
+ // 取单元格值:raw. 前缀深入原始字段对象;对象/数组序列化展示
|
|
|
+ function cellValue(r, key) {
|
|
|
+ if (!key.startsWith('raw.')) return r[key];
|
|
|
+ const raw = r && r.raw;
|
|
|
+ if (!raw || typeof raw !== 'object') return undefined;
|
|
|
+ return raw[key.slice(4)];
|
|
|
+ }
|
|
|
+
|
|
|
function sortedRows() {
|
|
|
return state.rows.slice().sort((a, b) => {
|
|
|
- const av = a[state.sortKey], bv = b[state.sortKey];
|
|
|
+ const av = cellValue(a, state.sortKey), bv = cellValue(b, state.sortKey);
|
|
|
if (av == null || av === '') return 1;
|
|
|
if (bv == null || bv === '') return -1;
|
|
|
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * (state.sortDir === 'asc' ? 1 : -1);
|
|
|
@@ -317,8 +350,10 @@
|
|
|
}).join('') + '</tr>';
|
|
|
tbody.innerHTML = sortedRows().map((r) => '<tr>' + cols.map((c) => {
|
|
|
const isNum = ['money', 'int', 'pct', 'num'].includes(c.type);
|
|
|
- const cls = `col-${c.type}${isNum ? ' col-num' : ''}${c.type === 'pct' ? ' ' + rateClass(c.key, r[c.key]) : ''}`;
|
|
|
- return `<td class="${cls}">${fmtCell(c, r[c.key])}</td>`;
|
|
|
+ const v = cellValue(r, c.key);
|
|
|
+ const display = (v != null && typeof v === 'object') ? JSON.stringify(v) : v;
|
|
|
+ const cls = `col-${c.type}${isNum ? ' col-num' : ''}${c.type === 'pct' ? ' ' + rateClass(c.key, v) : ''}`;
|
|
|
+ return `<td class="${cls}">${fmtCell(c, display)}</td>`;
|
|
|
}).join('') + '</tr>').join('');
|
|
|
}
|
|
|
|
|
|
@@ -337,7 +372,7 @@
|
|
|
const { from, to } = currentRange();
|
|
|
try {
|
|
|
let url = state.mode === 'detail'
|
|
|
- ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&scope=shop`
|
|
|
+ ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}${state.rawView ? '' : '&scope=shop'}`
|
|
|
: `/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.q) url += `&q=${encodeURIComponent(state.q)}`;
|
|
|
@@ -550,6 +585,23 @@
|
|
|
|
|
|
function exportCsv() {
|
|
|
if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
|
|
|
+ // 全部字段模式:行数据只在客户端(raw 列由前端发现),就地生成 CSV
|
|
|
+ if (state.rawView) {
|
|
|
+ const cols = tableColumns();
|
|
|
+ const cell = (v) => {
|
|
|
+ const s = (v == null || typeof v !== 'object') ? String(v ?? '') : JSON.stringify(v);
|
|
|
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
|
+ };
|
|
|
+ const lines = [cols.map((c) => cell(c.label)).join(',')];
|
|
|
+ for (const r of sortedRows()) lines.push(cols.map((c) => cell(cellValue(r, c.key))).join(','));
|
|
|
+ const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = URL.createObjectURL(blob);
|
|
|
+ a.download = `湖明细-全部字段-${state.entity}.csv`;
|
|
|
+ a.click();
|
|
|
+ URL.revokeObjectURL(a.href);
|
|
|
+ return;
|
|
|
+ }
|
|
|
const { from, to } = currentRange();
|
|
|
let url = `/api/reports/export?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`;
|
|
|
if (state.mode !== 'detail') url += `&group=${state.mode}`;
|
|
|
@@ -906,7 +958,8 @@
|
|
|
state.shopFilter = new Set((a.shopIds || []).map(String));
|
|
|
renderShopOptions();
|
|
|
loadReport();
|
|
|
- const nav = document.querySelector('.nav-item[data-view="reports"]');
|
|
|
+ // 明细浏览已挪到数据中台页,应用后跳到中台
|
|
|
+ const nav = document.querySelector('.nav-item[data-view="lake"]');
|
|
|
if (nav) nav.click();
|
|
|
}
|
|
|
|
|
|
@@ -1023,6 +1076,24 @@
|
|
|
loadReport();
|
|
|
});
|
|
|
|
|
|
+ // 字段模式:报表列(语义列)/ 全部字段(明细 + 全部原始字段;聚合视图无 raw,自动回明细)
|
|
|
+ $('reportFieldModes').addEventListener('click', (event) => {
|
|
|
+ const btn = event.target.closest('button[data-fields]');
|
|
|
+ if (!btn) return;
|
|
|
+ state.rawView = btn.dataset.fields === 'all';
|
|
|
+ $('reportFieldModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
|
|
|
+ if (state.rawView && state.mode !== 'detail') {
|
|
|
+ state.mode = 'detail';
|
|
|
+ state.sortKey = 'date';
|
|
|
+ state.sortDir = 'desc';
|
|
|
+ $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === 'detail'));
|
|
|
+ }
|
|
|
+ $('reportViewModes').querySelectorAll('button').forEach((b) => { b.disabled = state.rawView; });
|
|
|
+ $('btnColumnCfg').hidden = state.rawView; // 列设置只作用于报表列模式
|
|
|
+ state.sortKey = state.rawView ? 'date' : state.sortKey;
|
|
|
+ renderTable();
|
|
|
+ });
|
|
|
+
|
|
|
let searchTimer = null;
|
|
|
$('reportSearch').addEventListener('input', () => {
|
|
|
clearTimeout(searchTimer);
|
|
|
@@ -1102,6 +1173,45 @@
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+ // ---------- 同步历史(数据采集页) ----------
|
|
|
+
|
|
|
+ function renderSyncHistory() {
|
|
|
+ const tbody = $('syncHistoryRows');
|
|
|
+ if (!tbody) return;
|
|
|
+ if (!state.entity) {
|
|
|
+ tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">暂无主体</td></tr>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ jsonFetch(`/api/reports/syncs?entity=${encodeURIComponent(state.entity)}`)
|
|
|
+ .then((data) => {
|
|
|
+ const runs = (data.runs || []).slice(0, 8);
|
|
|
+ if (!runs.length) {
|
|
|
+ tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">暂无同步记录</td></tr>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const SCOPE_LABEL = { all: '全主体', chain: '按连锁', shop: '按门店' };
|
|
|
+ tbody.innerHTML = runs.map((r) => {
|
|
|
+ const warn = (r.steps || []).filter((s) => !s.ok).length;
|
|
|
+ const note = r.cancelled ? '已取消' : (r.failed ? `${r.failed} 失败` : (warn ? `${warn} 步骤告警(含全零)` : '成功'));
|
|
|
+ const cls = r.cancelled || r.failed ? 'badge-fail' : (warn ? 'badge-warn' : 'badge-ok');
|
|
|
+ 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')}` : '–';
|
|
|
+ return `<tr>
|
|
|
+ <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>${SCOPE_LABEL[r.scope] || r.scope || '–'}</td>
|
|
|
+ <td class="num">${r.added ?? 0}</td>
|
|
|
+ <td class="num">${r.updated ?? 0}</td>
|
|
|
+ <td class="num">${r.failed ?? 0}</td>
|
|
|
+ <td><span class="badge ${cls}">${note}</span></td>
|
|
|
+ </tr>`;
|
|
|
+ }).join('');
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">同步历史加载失败</td></tr>';
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
// ---------- 初始化 ----------
|
|
|
|
|
|
// 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)
|
|
|
@@ -1153,6 +1263,7 @@
|
|
|
}
|
|
|
await refreshConn();
|
|
|
refreshPushBadge();
|
|
|
+ renderSyncHistory();
|
|
|
await loadReport();
|
|
|
};
|
|
|
|