|
@@ -129,58 +129,163 @@
|
|
|
if (from && to && from <= to) return { from, to };
|
|
if (from && to && from <= to) return { from, to };
|
|
|
return { from: daysAgoYmd(30), to: daysAgoYmd(0) };
|
|
return { from: daysAgoYmd(30), to: daysAgoYmd(0) };
|
|
|
}
|
|
}
|
|
|
|
|
+ if (String(state.preset) === '1') return { from: daysAgoYmd(1), to: daysAgoYmd(1) }; // 「昨日」:from=to=昨天
|
|
|
const days = Number(state.preset) || 30;
|
|
const days = Number(state.preset) || 30;
|
|
|
return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
|
|
return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function analysisRange() {
|
|
function analysisRange() {
|
|
|
const sel = $('analysisRangePreset');
|
|
const sel = $('analysisRangePreset');
|
|
|
|
|
+ if (sel && String(sel.value) === '1') return { from: daysAgoYmd(1), to: daysAgoYmd(1) }; // 「昨日」
|
|
|
const days = Number(sel && sel.value) || 30;
|
|
const days = Number(sel && sel.value) || 30;
|
|
|
return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
|
|
return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ---------- 主体 tabs(数据采集 / 趋势图表 共用选中) ----------
|
|
|
|
|
|
|
+ // ---------- 主体(账号)选择器:数据采集 / 数据湖 / 趋势图表 共用选中 ----------
|
|
|
|
|
+
|
|
|
|
|
+ const ENTITY_SELECT_CONTAINERS = ['entityTabs', 'analysisEntityTabs', 'lakeEntityTabs'];
|
|
|
|
|
+ const ENTITY_SEARCH_THRESHOLD = 8; // 账号超过该数量时下拉内显示搜索框
|
|
|
|
|
|
|
|
function activeEntity() {
|
|
function activeEntity() {
|
|
|
return state.entities.find((e) => e.key === state.entity) || null;
|
|
return state.entities.find((e) => e.key === state.entity) || null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function entityShopCount(e) {
|
|
|
|
|
+ return (e.shops || []).length || e.shopCount || 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function selectEntity(key) {
|
|
|
|
|
+ if (state.entity === key) return;
|
|
|
|
|
+ state.entity = key;
|
|
|
|
|
+ state.shopFilter = new Set();
|
|
|
|
|
+ state.analysisShopFilter = new Set();
|
|
|
|
|
+ state.sortKey = 'date'; state.sortDir = 'desc';
|
|
|
|
|
+ LS.set('shanReports.entity', key);
|
|
|
|
|
+ anaCache = null;
|
|
|
|
|
+ renderEntityTabs();
|
|
|
|
|
+ renderShopOptions();
|
|
|
|
|
+ reloadReport();
|
|
|
|
|
+ renderCharts();
|
|
|
|
|
+ refreshPushBadge(); // 待推计数按主体算,切主体要重取
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function closeEntitySelect(wrap) {
|
|
|
|
|
+ const menu = wrap.querySelector('.entity-select-menu');
|
|
|
|
|
+ const trigger = wrap.querySelector('.scan-dropdown-trigger');
|
|
|
|
|
+ if (menu) menu.hidden = true;
|
|
|
|
|
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ document.addEventListener('click', (event) => {
|
|
|
|
|
+ for (const id of ENTITY_SELECT_CONTAINERS) {
|
|
|
|
|
+ const wrap = $(id);
|
|
|
|
|
+ if (wrap && !wrap.contains(event.target)) closeEntitySelect(wrap);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ function buildEntitySelect() {
|
|
|
|
|
+ const current = activeEntity();
|
|
|
|
|
+ const box = document.createElement('div');
|
|
|
|
|
+ box.className = 'scan-dropdown entity-select';
|
|
|
|
|
+
|
|
|
|
|
+ const trigger = document.createElement('button');
|
|
|
|
|
+ trigger.type = 'button';
|
|
|
|
|
+ trigger.className = 'scan-dropdown-trigger';
|
|
|
|
|
+ trigger.setAttribute('aria-expanded', 'false');
|
|
|
|
|
+ trigger.setAttribute('aria-haspopup', 'listbox');
|
|
|
|
|
+ const label = document.createElement('span');
|
|
|
|
|
+ label.textContent = current
|
|
|
|
|
+ ? `${current.name || current.key}(${entityShopCount(current)} 家门店)`
|
|
|
|
|
+ : '选择主体';
|
|
|
|
|
+ trigger.appendChild(label);
|
|
|
|
|
+ trigger.insertAdjacentHTML('beforeend', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>');
|
|
|
|
|
+ box.appendChild(trigger);
|
|
|
|
|
+
|
|
|
|
|
+ const menu = document.createElement('div');
|
|
|
|
|
+ menu.className = 'scan-dropdown-menu entity-select-menu';
|
|
|
|
|
+ menu.setAttribute('role', 'listbox');
|
|
|
|
|
+ menu.hidden = true;
|
|
|
|
|
+ box.appendChild(menu);
|
|
|
|
|
+
|
|
|
|
|
+ const withSearch = state.entities.length > ENTITY_SEARCH_THRESHOLD;
|
|
|
|
|
+ let search = null;
|
|
|
|
|
+ let noMatch = null;
|
|
|
|
|
+ if (withSearch) {
|
|
|
|
|
+ search = document.createElement('input');
|
|
|
|
|
+ search.type = 'search';
|
|
|
|
|
+ search.className = 'entity-select-search';
|
|
|
|
|
+ search.placeholder = '搜索账号…';
|
|
|
|
|
+ menu.appendChild(search);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const items = state.entities.map((e) => {
|
|
|
|
|
+ const item = document.createElement('button');
|
|
|
|
|
+ item.type = 'button';
|
|
|
|
|
+ item.className = 'scan-dropdown-item';
|
|
|
|
|
+ item.dataset.value = e.key;
|
|
|
|
|
+ item.dataset.text = `${e.name || ''} ${e.key}`.toLowerCase();
|
|
|
|
|
+ item.setAttribute('role', 'option');
|
|
|
|
|
+ item.innerHTML = '<span class="scan-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></span><span></span><span class="entity-select-item-meta"></span>';
|
|
|
|
|
+ item.children[1].textContent = e.name || e.key;
|
|
|
|
|
+ const cnt = entityShopCount(e);
|
|
|
|
|
+ item.lastElementChild.textContent = cnt ? `${cnt} 家门店` : '';
|
|
|
|
|
+ const active = e.key === state.entity;
|
|
|
|
|
+ item.classList.toggle('selected', active);
|
|
|
|
|
+ item.setAttribute('aria-selected', String(active));
|
|
|
|
|
+ item.addEventListener('click', () => {
|
|
|
|
|
+ closeEntitySelect(box);
|
|
|
|
|
+ selectEntity(e.key);
|
|
|
|
|
+ });
|
|
|
|
|
+ menu.appendChild(item);
|
|
|
|
|
+ return item;
|
|
|
|
|
+ });
|
|
|
|
|
+ if (withSearch) {
|
|
|
|
|
+ noMatch = document.createElement('div');
|
|
|
|
|
+ noMatch.className = 'entity-select-no-match';
|
|
|
|
|
+ noMatch.textContent = '无匹配账号';
|
|
|
|
|
+ noMatch.hidden = true;
|
|
|
|
|
+ menu.appendChild(noMatch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const applyFilter = () => {
|
|
|
|
|
+ const kw = (search && search.value.trim().toLowerCase()) || '';
|
|
|
|
|
+ let visible = 0;
|
|
|
|
|
+ for (const it of items) {
|
|
|
|
|
+ const hit = !kw || it.dataset.text.includes(kw);
|
|
|
|
|
+ it.hidden = !hit;
|
|
|
|
|
+ if (hit) visible += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (noMatch) noMatch.hidden = visible > 0;
|
|
|
|
|
+ };
|
|
|
|
|
+ if (search) search.addEventListener('input', applyFilter);
|
|
|
|
|
+
|
|
|
|
|
+ trigger.addEventListener('click', () => {
|
|
|
|
|
+ const willOpen = menu.hidden;
|
|
|
|
|
+ closeEntitySelect(box);
|
|
|
|
|
+ menu.hidden = !willOpen;
|
|
|
|
|
+ trigger.setAttribute('aria-expanded', String(willOpen));
|
|
|
|
|
+ if (willOpen) {
|
|
|
|
|
+ applyFilter();
|
|
|
|
|
+ if (search) search.focus();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return box;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function renderEntityTabs() {
|
|
function renderEntityTabs() {
|
|
|
- for (const containerId of ['entityTabs', 'analysisEntityTabs', 'lakeEntityTabs']) {
|
|
|
|
|
|
|
+ for (const containerId of ENTITY_SELECT_CONTAINERS) {
|
|
|
const wrap = $(containerId);
|
|
const wrap = $(containerId);
|
|
|
if (!wrap) continue;
|
|
if (!wrap) continue;
|
|
|
wrap.innerHTML = '';
|
|
wrap.innerHTML = '';
|
|
|
if (!state.entities.length) {
|
|
if (!state.entities.length) {
|
|
|
const empty = document.createElement('div');
|
|
const empty = document.createElement('div');
|
|
|
- empty.className = 'entity-tabs-empty';
|
|
|
|
|
|
|
+ empty.className = 'entity-select-empty';
|
|
|
empty.textContent = '暂无主体 —— 请先在商家客户端登录并完成一次「同步数据」';
|
|
empty.textContent = '暂无主体 —— 请先在商家客户端登录并完成一次「同步数据」';
|
|
|
wrap.appendChild(empty);
|
|
wrap.appendChild(empty);
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- for (const e of state.entities) {
|
|
|
|
|
- const btn = document.createElement('button');
|
|
|
|
|
- btn.type = 'button';
|
|
|
|
|
- btn.className = 'entity-tab' + (e.key === state.entity ? ' active' : '');
|
|
|
|
|
- btn.innerHTML = `<span class="entity-name"></span><span class="entity-meta"></span>`;
|
|
|
|
|
- btn.querySelector('.entity-name').textContent = e.name || e.key;
|
|
|
|
|
- const shopCount = (e.shops || []).length || e.shopCount || 0;
|
|
|
|
|
- btn.querySelector('.entity-meta').textContent = shopCount ? `${shopCount} 家门店` : '主体';
|
|
|
|
|
- btn.addEventListener('click', () => {
|
|
|
|
|
- if (state.entity === e.key) return;
|
|
|
|
|
- state.entity = e.key;
|
|
|
|
|
- state.shopFilter = new Set();
|
|
|
|
|
- state.analysisShopFilter = new Set();
|
|
|
|
|
- state.sortKey = 'date'; state.sortDir = 'desc';
|
|
|
|
|
- LS.set('shanReports.entity', e.key);
|
|
|
|
|
- anaCache = null;
|
|
|
|
|
- renderEntityTabs();
|
|
|
|
|
- renderShopOptions();
|
|
|
|
|
- reloadReport();
|
|
|
|
|
- renderCharts();
|
|
|
|
|
- refreshPushBadge(); // 待推计数按主体算,切主体要重取
|
|
|
|
|
- });
|
|
|
|
|
- wrap.appendChild(btn);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ wrap.appendChild(buildEntitySelect());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -408,6 +513,85 @@
|
|
|
|
|
|
|
|
// ---------- 连接状态 ----------
|
|
// ---------- 连接状态 ----------
|
|
|
|
|
|
|
|
|
|
+ // 服务端 hint 已结构化({title, steps:[{text, code}], note});兜底兼容旧版纯文本
|
|
|
|
|
+ function connHintPlain(hint) {
|
|
|
|
|
+ if (!hint) return '';
|
|
|
|
|
+ if (typeof hint === 'string') return hint;
|
|
|
|
|
+ const parts = [];
|
|
|
|
|
+ if (hint.title) parts.push(hint.title);
|
|
|
|
|
+ (hint.steps || []).forEach((s, i) => parts.push(`${i + 1}. ${s.text || ''}${s.code ? ' ' + s.code : ''}`));
|
|
|
|
|
+ if (hint.note) parts.push('注意:' + hint.note);
|
|
|
|
|
+ return parts.join('\n');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 同步弹窗内渲染「以调试端口启动客户端」分步指引(命令用 code 胶囊,自检地址可点击)
|
|
|
|
|
+ function renderSyncConnHint(napos) {
|
|
|
|
|
+ const wrap = $('syncHint');
|
|
|
|
|
+ wrap.innerHTML = '';
|
|
|
|
|
+ const hint = napos && napos.hint;
|
|
|
|
|
+ if (!hint) return;
|
|
|
|
|
+ const box = document.createElement('div');
|
|
|
|
|
+ box.className = 'conn-steps';
|
|
|
|
|
+ if (Array.isArray(hint.steps)) {
|
|
|
|
|
+ const title = document.createElement('div');
|
|
|
|
|
+ title.className = 'conn-steps-title';
|
|
|
|
|
+ title.textContent = `未连接客户端 · ${hint.title || '以调试端口启动客户端'}`;
|
|
|
|
|
+ box.appendChild(title);
|
|
|
|
|
+ const list = document.createElement('div');
|
|
|
|
|
+ list.className = 'conn-steps-list';
|
|
|
|
|
+ const rows = hint.steps.slice();
|
|
|
|
|
+ if (napos.selfCheckUrl) {
|
|
|
|
|
+ rows.push({ text: '自检:用浏览器打开', link: napos.selfCheckUrl, tail: ',看到一段 JSON 即端口已开。' });
|
|
|
|
|
+ }
|
|
|
|
|
+ rows.forEach((s, i) => {
|
|
|
|
|
+ const row = document.createElement('div');
|
|
|
|
|
+ row.className = 'conn-step';
|
|
|
|
|
+ const idx = document.createElement('span');
|
|
|
|
|
+ idx.className = 'conn-step-idx';
|
|
|
|
|
+ idx.textContent = String(i + 1);
|
|
|
|
|
+ row.appendChild(idx);
|
|
|
|
|
+ const txt = document.createElement('span');
|
|
|
|
|
+ txt.className = 'conn-step-text';
|
|
|
|
|
+ txt.appendChild(document.createTextNode(s.text || ''));
|
|
|
|
|
+ if (s.code) {
|
|
|
|
|
+ const code = document.createElement('code');
|
|
|
|
|
+ code.textContent = s.code;
|
|
|
|
|
+ txt.appendChild(code);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (s.link) {
|
|
|
|
|
+ txt.appendChild(document.createTextNode(' '));
|
|
|
|
|
+ const a = document.createElement('a');
|
|
|
|
|
+ a.href = s.link;
|
|
|
|
|
+ a.target = '_blank';
|
|
|
|
|
+ a.rel = 'noopener';
|
|
|
|
|
+ a.textContent = s.link;
|
|
|
|
|
+ txt.appendChild(a);
|
|
|
|
|
+ if (s.tail) txt.appendChild(document.createTextNode(s.tail));
|
|
|
|
|
+ }
|
|
|
|
|
+ row.appendChild(txt);
|
|
|
|
|
+ list.appendChild(row);
|
|
|
|
|
+ });
|
|
|
|
|
+ box.appendChild(list);
|
|
|
|
|
+ if (hint.note) {
|
|
|
|
|
+ const note = document.createElement('div');
|
|
|
|
|
+ note.className = 'conn-steps-note';
|
|
|
|
|
+ note.textContent = hint.note;
|
|
|
|
|
+ box.appendChild(note);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 旧版纯文本兜底
|
|
|
|
|
+ const p = document.createElement('div');
|
|
|
|
|
+ p.textContent = typeof hint === 'string' ? hint : '';
|
|
|
|
|
+ box.appendChild(p);
|
|
|
|
|
+ if (napos.selfCheckUrl) {
|
|
|
|
|
+ const p2 = document.createElement('div');
|
|
|
|
|
+ p2.textContent = `自检:用浏览器打开 ${napos.selfCheckUrl},看到一段 JSON 即端口已开。`;
|
|
|
|
|
+ box.appendChild(p2);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ wrap.appendChild(box);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async function refreshConn() {
|
|
async function refreshConn() {
|
|
|
const badge = $('reportConnBadge');
|
|
const badge = $('reportConnBadge');
|
|
|
try {
|
|
try {
|
|
@@ -425,7 +609,7 @@
|
|
|
badge.className = 'badge ' + (ok ? 'badge-ok' : 'badge-fail');
|
|
badge.className = 'badge ' + (ok ? 'badge-ok' : 'badge-fail');
|
|
|
badge.title = ok
|
|
badge.title = ok
|
|
|
? `调试端口 ${state.conn.port}`
|
|
? `调试端口 ${state.conn.port}`
|
|
|
- : [state.conn && state.conn.error, state.conn && state.conn.hint].filter(Boolean).join('\n') || '未检测到调试端口';
|
|
|
|
|
|
|
+ : [state.conn && state.conn.error, connHintPlain(state.conn && state.conn.hint)].filter(Boolean).join('\n') || '未检测到调试端口';
|
|
|
} catch {
|
|
} catch {
|
|
|
badge.textContent = '状态检测失败';
|
|
badge.textContent = '状态检测失败';
|
|
|
badge.className = 'badge badge-fail';
|
|
badge.className = 'badge badge-fail';
|
|
@@ -545,6 +729,9 @@
|
|
|
$('syncEntityBadge').className = 'badge badge-info';
|
|
$('syncEntityBadge').className = 'badge badge-info';
|
|
|
$('syncShopPickWrap').hidden = true;
|
|
$('syncShopPickWrap').hidden = true;
|
|
|
$('syncProgressWrap').hidden = true;
|
|
$('syncProgressWrap').hidden = true;
|
|
|
|
|
+ // 首次打开时记住默认说明文案;连接失败会覆盖为分步指引,连上后要能恢复
|
|
|
|
|
+ if (!state.syncHintDefault) state.syncHintDefault = $('syncHint').textContent;
|
|
|
|
|
+ else $('syncHint').textContent = state.syncHintDefault;
|
|
|
loadAutoSyncPrefs();
|
|
loadAutoSyncPrefs();
|
|
|
try {
|
|
try {
|
|
|
const tree = await jsonFetch('/api/reports/tree');
|
|
const tree = await jsonFetch('/api/reports/tree');
|
|
@@ -563,9 +750,7 @@
|
|
|
badge.className = 'badge badge-fail';
|
|
badge.className = 'badge badge-fail';
|
|
|
try {
|
|
try {
|
|
|
const st = await jsonFetch('/api/reports/status');
|
|
const st = await jsonFetch('/api/reports/status');
|
|
|
- const napos = st.napos || {};
|
|
|
|
|
- $('syncHint').textContent = [napos.hint, napos.selfCheckUrl ? `自检:客户端带参启动后,用浏览器打开 ${napos.selfCheckUrl} 应看到一段 JSON。` : '']
|
|
|
|
|
- .filter(Boolean).join(' ');
|
|
|
|
|
|
|
+ renderSyncConnHint(st.napos || {});
|
|
|
} catch { /* 服务不可达时保留默认文案 */ }
|
|
} catch { /* 服务不可达时保留默认文案 */ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|