Pārlūkot izejas kodu

feat: 主体搜索式选择器(账号多时不换行)+ 客户端启动指引结构化分步渲染

上次会话遗留的未提交改动:entity-tabs 换为可搜索下拉选择器三页共用;
cdpLaunchHint 返回结构化步骤由前端分步渲染。
KenZ1117 2 nedēļas atpakaļ
vecāks
revīzija
f2b19ed796
4 mainītis faili ar 276 papildinājumiem un 55 dzēšanām
  1. 19 8
      napos-service.js
  2. 6 3
      public/index.html
  3. 216 31
      public/reports.js
  4. 35 13
      public/style.css

+ 19 - 8
napos-service.js

@@ -146,17 +146,28 @@ function normalizeSummaryRow(allRow, ctx) {
 
 // ---------- 连接状态 ----------
 
-/** 按平台给出「以调试端口启动商家客户端」的可执行指引 */
+/** 按平台给出「以调试端口启动商家客户端」的可执行指引(结构化数据,前端分步渲染) */
 function cdpLaunchHint(port = DEFAULT_CDP_PORT) {
   if (process.platform === 'win32') {
-    return `Windows 启动方法:① 任务栏右键完全退出淘宝闪购客户端(任务管理器确认进程已退出);`
-      + `② 右键桌面快捷方式 → 属性 → 在「目标」末尾追加一个空格和 --remote-debugging-port=${port} → 确定后用该快捷方式启动。`
-      + `注意:客户端每次重启都要用这个快捷方式,否则端口不会开启。`;
+    return {
+      title: 'Windows 带参启动客户端',
+      steps: [
+        { text: '任务栏右键完全退出客户端,任务管理器确认进程已退出' },
+        { text: '右键桌面快捷方式 → 属性 →「目标」末尾追加一个空格和', code: `--remote-debugging-port=${port}` },
+        { text: '确定后,用该快捷方式启动客户端' },
+      ],
+      note: '客户端每次重启都要用这个快捷方式,否则端口不会开启。',
+    };
   }
-  return `macOS 启动方法:① 完全退出客户端(⌘Q,或活动监视器确认进程退出);`
-    + `② 终端执行:open -a "淘宝闪购商家版" --args --remote-debugging-port=${port}`
-    + `(应用名以实际为准;也可直接运行 "/Applications/客户端.app/Contents/MacOS/可执行文件" --remote-debugging-port=${port})。`
-    + `注意:客户端每次重启都要带此参数。`;
+  return {
+    title: 'macOS 带参启动客户端',
+    steps: [
+      { text: '⌘Q 完全退出客户端(活动监视器确认进程已退出)' },
+      { text: '终端执行:', code: `open -a "淘宝闪购商家版" --args --remote-debugging-port=${port}` },
+      { text: '应用名以实际为准;也可直接运行', code: `"<客户端.app>/Contents/MacOS/<可执行文件>" --remote-debugging-port=${port}` },
+    ],
+    note: '客户端每次重启都要带此参数,否则端口不会开启。',
+  };
 }
 
 async function naposStatus() {

+ 6 - 3
public/index.html

@@ -297,7 +297,7 @@
           </div>
         </div>
 
-        <div class="entity-tabs" id="entityTabs"></div>
+        <div id="entityTabs"></div>
 
         <div class="panel panel-fixed" id="syncHistoryPanel">
           <div class="panel-title">
@@ -393,12 +393,13 @@
         </div>
 
         <div id="lakeTabDetail" hidden>
-        <div class="entity-tabs" id="lakeEntityTabs"></div>
+        <div id="lakeEntityTabs"></div>
 
         <div class="report-toolbar panel">
           <div class="field report-field-sm">
             <label>日期范围</label>
             <select id="reportRangePreset">
+              <option value="1">昨日</option>
               <option value="7">近 7 天</option>
               <option value="30" selected>近 30 天</option>
               <option value="90">近 90 天</option>
@@ -487,12 +488,13 @@
           </div>
         </div>
 
-        <div class="entity-tabs" id="analysisEntityTabs"></div>
+        <div id="analysisEntityTabs"></div>
 
         <div class="report-toolbar panel">
           <div class="field report-field-sm">
             <label>日期范围</label>
             <select id="analysisRangePreset">
+              <option value="1">昨日</option>
               <option value="7">近 7 天</option>
               <option value="30" selected>近 30 天</option>
               <option value="90">近 90 天</option>
@@ -863,6 +865,7 @@
       <div class="field">
         <label>日期范围</label>
         <select id="syncRange">
+          <option value="1">昨日</option>
           <option value="7">近 7 天</option>
           <option value="30" selected>近 30 天</option>
           <option value="90">近 90 天</option>

+ 216 - 31
public/reports.js

@@ -129,58 +129,163 @@
       if (from && to && from <= to) return { from, to };
       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;
     return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
   }
 
   function analysisRange() {
     const sel = $('analysisRangePreset');
+    if (sel && String(sel.value) === '1') return { from: daysAgoYmd(1), to: daysAgoYmd(1) }; // 「昨日」
     const days = Number(sel && sel.value) || 30;
     return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
   }
 
-  // ---------- 主体 tabs(数据采集 / 趋势图表 共用选中) ----------
+  // ---------- 主体(账号)选择器:数据采集 / 数据湖 / 趋势图表 共用选中 ----------
+
+  const ENTITY_SELECT_CONTAINERS = ['entityTabs', 'analysisEntityTabs', 'lakeEntityTabs'];
+  const ENTITY_SEARCH_THRESHOLD = 8; // 账号超过该数量时下拉内显示搜索框
 
   function activeEntity() {
     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() {
-    for (const containerId of ['entityTabs', 'analysisEntityTabs', 'lakeEntityTabs']) {
+    for (const containerId of ENTITY_SELECT_CONTAINERS) {
       const wrap = $(containerId);
       if (!wrap) continue;
       wrap.innerHTML = '';
       if (!state.entities.length) {
         const empty = document.createElement('div');
-        empty.className = 'entity-tabs-empty';
+        empty.className = 'entity-select-empty';
         empty.textContent = '暂无主体 —— 请先在商家客户端登录并完成一次「同步数据」';
         wrap.appendChild(empty);
         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() {
     const badge = $('reportConnBadge');
     try {
@@ -425,7 +609,7 @@
       badge.className = 'badge ' + (ok ? 'badge-ok' : 'badge-fail');
       badge.title = ok
         ? `调试端口 ${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 {
       badge.textContent = '状态检测失败';
       badge.className = 'badge badge-fail';
@@ -545,6 +729,9 @@
     $('syncEntityBadge').className = 'badge badge-info';
     $('syncShopPickWrap').hidden = true;
     $('syncProgressWrap').hidden = true;
+    // 首次打开时记住默认说明文案;连接失败会覆盖为分步指引,连上后要能恢复
+    if (!state.syncHintDefault) state.syncHintDefault = $('syncHint').textContent;
+    else $('syncHint').textContent = state.syncHintDefault;
     loadAutoSyncPrefs();
     try {
       const tree = await jsonFetch('/api/reports/tree');
@@ -563,9 +750,7 @@
       badge.className = 'badge badge-fail';
       try {
         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 { /* 服务不可达时保留默认文案 */ }
     }
   }

+ 35 - 13
public/style.css

@@ -716,19 +716,20 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
    数据报表 · 淘宝闪购经营数据中台
    ═══════════════════════════════════════════════════════════════ */
 
-.entity-tabs { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
-.entity-tabs-empty { font-size: var(--font-size-body); color: var(--text-3); padding: 10px 0; }
-.entity-tab {
-  display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px;
-  background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-pill);
-  color: var(--text-2); font-size: var(--font-size-body); cursor: pointer;
-  transition: border-color var(--duration-base) var(--ease-out), color var(--duration-base) var(--ease-out), background var(--duration-base) var(--ease-out);
-}
-.entity-tab:hover { border-color: var(--accent); color: var(--text); }
-.entity-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
-.entity-tab.active .entity-meta { color: rgba(255, 255, 255, 0.8); }
-.entity-tab .entity-name { font-weight: 600; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.entity-tab .entity-meta { font-size: var(--font-size-small); color: var(--text-3); }
+/* 主体(账号)搜索式选择器:账号多时不再无限换行,三处页面共用 */
+.entity-select { width: min(340px, 100%); margin-bottom: 14px; }
+.entity-select-menu { left: 0; right: auto; width: 100%; max-height: 300px; }
+.entity-select-search {
+  width: 100%; margin-bottom: 6px; padding: 7px 10px;
+  border: 1px solid var(--border); border-radius: 7px; background: #fff;
+  font-size: var(--font-size-body); color: var(--text); outline: none;
+}
+.entity-select-search:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--focus-ring); }
+.entity-select-item-meta { margin-left: auto; flex: 0 0 auto; font-size: 11.5px; color: var(--text-3); }
+.entity-select-menu .scan-dropdown-item > span:nth-child(2) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.entity-select-menu .scan-dropdown-item > span:last-child { flex: 0 0 auto; overflow: visible; text-overflow: clip; }
+.entity-select-no-match { padding: 10px; font-size: var(--font-size-small); color: var(--text-3); text-align: center; }
+.entity-select-empty { font-size: var(--font-size-body); color: var(--text-3); padding: 10px 0; }
 
 .report-toolbar { display: flex; align-items: flex-end; gap: 14px; flex-wrap: wrap; padding: 14px 16px; margin-bottom: 14px; }
 .report-field-sm { width: 130px; flex: 0 0 auto; }
@@ -856,6 +857,27 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
   background: var(--warn-bg); color: var(--warn); font-size: var(--font-size-small);
 }
 
+/* 同步弹窗 · 连接失败分步指引(与 help-popover 同一设计语言) */
+.conn-steps-title { font-weight: 700; font-size: 12.5px; margin-bottom: 8px; }
+.conn-steps-list { display: flex; flex-direction: column; gap: 7px; }
+.conn-step { display: flex; align-items: flex-start; gap: 8px; font-size: 12px; line-height: 1.6; color: var(--text-2); }
+.conn-step-idx {
+  flex: 0 0 auto; width: 17px; height: 17px; margin-top: 2px;
+  border: 1px solid var(--border-light); border-radius: 50%;
+  background: #f6f4f2; color: var(--text-3); font-size: 10.5px; font-weight: 600;
+  display: inline-flex; align-items: center; justify-content: center;
+}
+.conn-step-text { min-width: 0; }
+.conn-step-text code {
+  background: #f6f4f2; border: 1px solid var(--border-light); border-radius: 4px;
+  padding: 1px 5px; font-size: 11px; word-break: break-all;
+}
+.conn-step-text a { color: var(--accent); word-break: break-all; }
+.conn-steps-note {
+  margin-top: 9px; padding: 7px 10px; border-radius: 8px;
+  background: var(--warn-bg); color: var(--warn); font-size: 11px; line-height: 1.5;
+}
+
 /* ── KPI 卡 · 数值自适应不截断 ──────────────────────────── */
 #reportKpis .metric { padding: 12px 14px; }
 #reportKpis .metric-body { gap: 6px; }