Selaa lähdekoodia

feat: lake console page + push buttons at collection page + conflict policy (newer-wins, all-zero guard)

KenZ1117 2 viikkoa sitten
vanhempi
sitoutus
c55934c90b
9 muutettua tiedostoa jossa 337 lisäystä ja 26 poistoa
  1. 19 15
      public/app.js
  2. 62 3
      public/index.html
  3. 144 0
      public/lake.js
  4. 37 0
      public/reports.js
  5. 8 0
      public/style.css
  6. 10 1
      scripts/smoke.js
  7. 34 0
      server/routes/reports.js
  8. 1 0
      server/routes/static.js
  9. 22 7
      server/services/aitable.js

+ 19 - 15
public/app.js

@@ -28,6 +28,7 @@
     tasks: document.getElementById('viewTasks'),
     history: document.getElementById('viewHistory'),
     reports: document.getElementById('viewReports'),
+    lake: document.getElementById('viewLake'),
     analysis: document.getElementById('viewAnalysis'),
     settings: document.getElementById('viewSettings'),
     logs: document.getElementById('viewLogs'),
@@ -226,32 +227,36 @@
     });
   }
 
-  async function aitableAction(kind) {
-    const status = document.getElementById('aitableStatus');
+  // 云端动作(测试/推/拉);badgeId 允许不同页面各自展示状态(设置页/数据采集页/数据中台页)
+  async function aitableAction(kind, badgeId = 'aitableStatus', body = {}) {
+    const status = document.getElementById(badgeId);
     const labels = { test: '测试连通中…', push: '推送中…', pull: '拉取中…' };
-    status.textContent = labels[kind];
-    status.className = 'badge badge-info';
+    if (status) {
+      status.textContent = labels[kind];
+      status.className = 'badge badge-info';
+    }
     try {
-      const res = await fetch(`/api/reports/aitable/${kind}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+      const res = await fetch(`/api/reports/aitable/${kind}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
       const data = await res.json();
       if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
       if (kind === 'test') {
-        status.textContent = '连通正常';
-        status.className = 'badge badge-ok';
-        return;
+        if (status) { status.textContent = '连通正常'; status.className = 'badge badge-ok'; }
+        return data;
       }
       if (kind === 'push') {
-        status.textContent = `已推送 ${data.pushed} 行(新增 ${data.created} / 更新 ${data.updated})`;
+        const stale = data.skippedStale ? `,跳过云端较新 ${data.skippedStale} 行` : '';
+        if (status) { status.textContent = `已推送 ${data.pushed} 行(新增 ${data.created} / 更新 ${data.updated}${stale})`; status.className = 'badge badge-ok'; }
       } else {
         const detail = (data.accounts || []).map((a) => `${a.account} ${a.rows}行`).join('、') || '无数据';
-        status.textContent = `已拉取 ${data.pulled} 行:${detail}`;
+        if (status) { status.textContent = `已拉取 ${data.pulled} 行:${detail}`; status.className = 'badge badge-ok'; }
       }
-      status.className = 'badge badge-ok';
+      return data;
     } catch (error) {
-      status.textContent = error.message;
-      status.className = 'badge badge-fail';
+      if (status) { status.textContent = error.message; status.className = 'badge badge-fail'; }
+      throw error;
     }
   }
+  window.shanAitableAction = aitableAction;
 
   async function initSettings() {
     if (settingsLoaded) return;
@@ -266,8 +271,6 @@
     document.getElementById('btnTestImage').addEventListener('click', testImageApi);
     document.getElementById('btnTestChat').addEventListener('click', testChatModel);
     document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
-    document.getElementById('btnAitablePush').addEventListener('click', () => aitableAction('push'));
-    document.getElementById('btnAitablePull').addEventListener('click', () => aitableAction('pull'));
     document.getElementById('btnClearLog').addEventListener('click', () => {
       clearLogs().catch(() => {});
     });
@@ -340,6 +343,7 @@
     if (name === 'tasks' && window.shanTasksInit) window.shanTasksInit();
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
     if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
+    if (name === 'lake' && window.shanLakeInit) window.shanLakeInit();
     if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate();
     if (name === 'settings') initSettings();
     // 系统日志:仅日志页激活时轮询

+ 62 - 3
public/index.html

@@ -40,6 +40,7 @@
 
     <div class="nav-section-label">经营分析</div>
     <button class="nav-item" data-view="reports"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 21h16"/></svg></span><span>数据采集</span></button>
+    <button class="nav-item" data-view="lake"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="7" ry="2.5"/><path d="M5 5v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5"/><path d="M5 11v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-6"/></svg></span><span>数据中台</span></button>
     <button class="nav-item" data-view="analysis"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17l5-6 4 3 5-8 4 5"/><path d="M3 21h18"/></svg></span><span>趋势图表</span></button>
 
     <div class="nav-section-label">设置</div>
@@ -293,6 +294,11 @@
               <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 21h16"/></svg>
               同步数据
             </button>
+            <button class="btn btn-ghost btn-small" id="btnReportPush" title="把本地湖增量推送到钉钉 AI 表格(云端主档案)">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21V9"/><path d="M7 14l5-5 5 5"/><path d="M4 3h16"/></svg>
+              推送云端
+            </button>
+            <span class="badge" id="reportPushBadge">云端未启用</span>
           </div>
         </div>
 
@@ -365,6 +371,60 @@
       </main>
     </div>
 
+    <!-- ==================== 数据中台 ==================== -->
+    <div id="viewLake" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>数据中台</h1>
+            <div class="sub">本地数据湖全景:主体覆盖、行数分布、云端同步状态</div>
+          </div>
+          <div class="toolbar">
+            <button class="btn btn-ghost btn-small" id="btnLakeRefresh">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
+              刷新
+            </button>
+            <button class="btn btn-ghost btn-small" id="btnLakePull" title="以钉钉 AI 表格(云端主档案)为准,拉回本地湖;未注册的账号自动创建主体">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 21h16"/></svg>
+              从云端拉取
+            </button>
+            <button class="btn btn-primary btn-small" id="btnLakePush" title="把本地湖全部主体的新增/变更行推送到钉钉 AI 表格">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21V9"/><path d="M7 14l5-5 5 5"/><path d="M4 3h16"/></svg>
+              推送云端
+            </button>
+            <span class="badge" id="lakeSyncBadge"></span>
+          </div>
+        </div>
+
+        <div class="stats-grid" id="lakeKpis">
+          <div class="metric"><span class="metric-body"><span class="metric-label">数据主体</span><span class="metric-value" id="lakeEntityCount">–</span><span class="metric-sub">已注册的商家账号</span></span></div>
+          <div class="metric"><span class="metric-body"><span class="metric-label">湖内总行数</span><span class="metric-value" id="lakeRowCount">–</span><span class="metric-sub">按 日期×维度×门店 幂等去重</span></span></div>
+          <div class="metric"><span class="metric-body"><span class="metric-label">日期覆盖</span><span class="metric-value" id="lakeCoverage">–</span><span class="metric-sub">最早 ~ 最晚(后台约 180 天)</span></span></div>
+          <div class="metric"><span class="metric-body"><span class="metric-label">最近同步</span><span class="metric-value" id="lakeLastSync">–</span><span class="metric-sub">取数写入本地湖的时间</span></span></div>
+        </div>
+
+        <div class="panel panel-fixed report-table-panel">
+          <div class="panel-title">
+            <span>主体明细</span>
+            <span class="badge badge-info" id="lakeRowsBadge">0 主体</span>
+          </div>
+          <div class="table-wrap">
+            <table class="data-table">
+              <thead>
+                <tr>
+                  <th>账号名</th><th>门店/连锁</th><th>总行数</th><th>公司</th><th>连锁</th><th>门店</th><th>汇总</th><th>覆盖区间</th><th>最近同步</th><th>上次推送云端</th>
+                </tr>
+              </thead>
+              <tbody id="lakeEntityRows">
+                <tr><td colspan="10" class="empty-cell">加载中…</td></tr>
+              </tbody>
+            </table>
+          </div>
+          <div class="help">冲突策略:同一行(日期×账号×维度×门店)多机写入时以取数时间新者为准;云端更新的行不会被本机旧数据覆盖。大批量全零数据会被拦截,防止取数异常污染云端主档案。</div>
+        </div>
+      </main>
+    </div>
+
     <!-- ==================== 趋势图表 ==================== -->
     <div id="viewAnalysis" class="view">
       <main class="content">
@@ -594,11 +654,9 @@
                 </div>
                 <div class="settings-links">
                   <button type="button" class="settings-link settings-link-btn" id="btnTestAitable"><span>测试连通</span></button>
-                  <button type="button" class="settings-link settings-link-btn" id="btnAitablePush"><span>推送云端</span></button>
-                  <button type="button" class="settings-link settings-link-btn" id="btnAitablePull"><span>从云端拉取</span></button>
                   <span id="aitableStatus" class="badge"></span>
                 </div>
-                <div class="help">架构:取数机「同步 → 自动推送」,其他电脑「从云端拉取」到本地后再看报表/图表。云表为在线主档案,一人取数、多人共享。</div>
+                <div class="help">架构:取数机「同步 → 自动推送」(数据采集页有手动推送按钮),其他电脑在「数据中台」页从云端拉取。推送/拉取入口分别在数据采集与数据中台页,本面板只管配置。</div>
                 <div class="panel-save-row">
                   <button class="btn btn-primary btn-small" id="btnSaveAitable">保存设置</button>
                   <span id="aitableSaveStatus" class="badge"></span>
@@ -804,6 +862,7 @@
 <script src="/history.js"></script>
 <script src="/vendor/echarts.min.js"></script>
 <script src="/reports.js"></script>
+<script src="/lake.js"></script>
 <script src="/app.js"></script>
 </body>
 </html>

+ 144 - 0
public/lake.js

@@ -0,0 +1,144 @@
+'use strict';
+
+// 数据中台页:本地湖全景 + 云端同步(推送/拉取)
+// 读取 GET /api/reports/lake;推送逐主体执行(POST /api/reports/aitable/push)。
+(function () {
+  const $ = (id) => document.getElementById(id);
+  let inited = false;
+  let loading = false;
+
+  function fmtDate(ymd) {
+    const s = String(ymd || '');
+    return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : (s || '–');
+  }
+
+  function fmtTime(iso) {
+    if (!iso) return '–';
+    const d = new Date(iso);
+    return Number.isNaN(d.getTime()) ? String(iso) : `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
+  }
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value ?? '');
+    return div.innerHTML;
+  }
+
+  function setBadge(id, text, cls) {
+    const badge = $(id);
+    if (!badge) return;
+    badge.textContent = text;
+    badge.className = 'badge ' + (cls || 'badge-info');
+  }
+
+  function renderKpis(data) {
+    $('lakeEntityCount').textContent = data.entities.length;
+    $('lakeRowCount').textContent = data.totalRows;
+    $('lakeCoverage').textContent = data.coverage ? `${fmtDate(data.coverage.from)} ~ ${fmtDate(data.coverage.to)}` : '–';
+    const lastSyncs = data.entities.map((e) => e.lastSync && e.lastSync.at).filter(Boolean).sort();
+    $('lakeLastSync').textContent = lastSyncs.length ? fmtTime(lastSyncs[lastSyncs.length - 1]) : '–';
+    $('lakeRowsBadge').textContent = `${data.entities.length} 主体`;
+  }
+
+  function renderEntities(entities) {
+    const tbody = $('lakeEntityRows');
+    if (!entities.length) {
+      tbody.innerHTML = '<tr><td colspan="10" class="empty-cell">本地湖为空 —— 取数机先「同步数据」,其他电脑点右上「从云端拉取」</td></tr>';
+      return;
+    }
+    tbody.innerHTML = entities.map((e) => {
+      const cov = e.coverage ? `${fmtDate(e.coverage.from)} ~ ${fmtDate(e.coverage.to)}` : '–';
+      const sync = e.lastSync ? `${fmtTime(e.lastSync.at)}(${fmtDate(e.lastSync.from)}~${fmtDate(e.lastSync.to)})${e.lastSync.ok ? '' : ' ⚠'}` : '–';
+      const push = e.lastPush ? `${fmtTime(e.lastPush.at)} / ${e.lastPush.pushedRows || 0} 行` : '未推送';
+      return `<tr>
+        <td>${esc(e.name || e.key)}</td>
+        <td>${e.shopCount || 0} / ${e.chainCount || 0}</td>
+        <td>${e.rows}</td>
+        <td>${e.byScope.company || 0}</td>
+        <td>${e.byScope.chain || 0}</td>
+        <td>${e.byScope.shop || 0}</td>
+        <td>${e.byScope.summary || 0}</td>
+        <td>${cov}</td>
+        <td>${esc(sync)}</td>
+        <td>${esc(push)}</td>
+      </tr>`;
+    }).join('');
+  }
+
+  async function loadLake() {
+    if (loading) return;
+    loading = true;
+    try {
+      const res = await fetch('/api/reports/lake');
+      const data = await res.json();
+      if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
+      renderKpis(data);
+      renderEntities(data.entities);
+      if (!data.aitable.enabled) setBadge('lakeSyncBadge', '云端同步未启用(系统设置)', 'badge-info');
+      else if (!data.aitable.operatorConfigured) setBadge('lakeSyncBadge', '云端配置不完整', 'badge-fail');
+      else setBadge('lakeSyncBadge', '云端就绪', 'badge-ok');
+    } catch (error) {
+      setBadge('lakeSyncBadge', `加载失败:${error.message}`, 'badge-fail');
+    } finally {
+      loading = false;
+    }
+  }
+
+  // 推送:逐主体执行,聚合结果(单主体失败不中断其余,最后统一汇报)
+  async function pushAll() {
+    const btn = $('btnLakePush');
+    btn.disabled = true;
+    setBadge('lakeSyncBadge', '推送中…');
+    try {
+      const res = await fetch('/api/reports/lake');
+      const data = await res.json();
+      const targets = (data.entities || []).map((e) => e.key);
+      if (!targets.length) throw new Error('本地湖为空,没有可推送的主体');
+      let created = 0;
+      let updated = 0;
+      let stale = 0;
+      const errors = [];
+      for (const key of targets) {
+        try {
+          const r = await window.shanAitableAction('push', 'lakeSyncBadge', { entity: key });
+          created += r.created || 0;
+          updated += r.updated || 0;
+          stale += r.skippedStale || 0;
+        } catch (e) {
+          errors.push(`${key}:${e.message}`);
+        }
+      }
+      if (errors.length) {
+        setBadge('lakeSyncBadge', `部分失败(成功 新增${created}/更新${updated}):${errors[0]}`, 'badge-fail');
+      } else {
+        setBadge('lakeSyncBadge', `已推送:新增 ${created} / 更新 ${updated}${stale ? ` / 跳过云端较新 ${stale}` : ''}`, 'badge-ok');
+      }
+      await loadLake();
+    } catch (error) {
+      setBadge('lakeSyncBadge', error.message, 'badge-fail');
+    } finally {
+      btn.disabled = false;
+    }
+  }
+
+  async function pullAll() {
+    const btn = $('btnLakePull');
+    btn.disabled = true;
+    try {
+      await window.shanAitableAction('pull', 'lakeSyncBadge', {});
+      await loadLake();
+    } catch { /* 徽标已由 aitableAction 写入 */ } finally {
+      btn.disabled = false;
+    }
+  }
+
+  window.shanLakeInit = function () {
+    if (!inited) {
+      inited = true;
+      $('btnLakeRefresh').addEventListener('click', loadLake);
+      $('btnLakePush').addEventListener('click', pushAll);
+      $('btnLakePull').addEventListener('click', pullAll);
+    }
+    loadLake();
+  };
+})();

+ 37 - 0
public/reports.js

@@ -1104,10 +1104,46 @@
 
   // ---------- 初始化 ----------
 
+  // 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)
+  async function pushCurrentEntity() {
+    const badge = $('reportPushBadge');
+    const entity = state.entity || (state.entities[0] || {}).key;
+    if (!entity) {
+      badge.textContent = '暂无主体';
+      badge.className = 'badge badge-fail';
+      return;
+    }
+    if (window.shanAitableAction) {
+      try {
+        await window.shanAitableAction('push', 'reportPushBadge', { entity });
+      } catch { /* 徽标已由 aitableAction 写入 */ }
+    }
+  }
+
+  async function refreshPushBadge() {
+    const badge = $('reportPushBadge');
+    if (!badge) return;
+    try {
+      const res = await fetch('/api/reports/aitable/status');
+      const data = await res.json();
+      if (!data.enabled) {
+        badge.textContent = '云端未启用';
+        badge.className = 'badge badge-info';
+      } else if (!data.configured) {
+        badge.textContent = '云端配置不完整';
+        badge.className = 'badge badge-fail';
+      } else {
+        badge.textContent = '云端就绪';
+        badge.className = 'badge badge-ok';
+      }
+    } catch { /* 状态刷新失败不打扰 */ }
+  }
+
   window.shanReportsInit = async function () {
     if (!state.inited) {
       state.inited = true;
       bindEvents();
+      $('btnReportPush').addEventListener('click', pushCurrentEntity);
       try {
         const data = await jsonFetch('/api/reports/columns');
         state.columns = data.columns || [];
@@ -1116,6 +1152,7 @@
       renderChartMetricSelect();
     }
     await refreshConn();
+    refreshPushBadge();
     await loadReport();
   };
 

+ 8 - 0
public/style.css

@@ -982,3 +982,11 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .panel-save-row { display: flex; align-items: center; gap: 10px; padding-top: 4px; border-top: 1px dashed var(--border-light); margin-top: 2px; }
 .panel-save-row .badge:empty { display: none; }
 @media (max-width: 1080px) { .settings-workspace { grid-template-columns: 1fr; } }
+
+/* ── 数据中台 ─────────────────────────────────────────────── */
+#lakeKpis { margin-bottom: 14px; }
+#lakeKpis .metric { min-height: 92px; padding: 14px 16px; }
+#lakeKpis .metric-body { gap: 6px; }
+#lakeKpis .metric-value { font-size: clamp(15px, 1.3vw, 24px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+#lakeKpis .metric-sub { font-size: var(--font-size-small); color: var(--text-3); line-height: 1.35; }
+.data-table .empty-cell { color: var(--text-3); text-align: center; padding: 28px 12px; }

+ 10 - 1
scripts/smoke.js

@@ -285,9 +285,18 @@ async function main() {
       const r = await request(port, 'POST', '/api/reports/ask', { q: 'test' });
       expect(r.status === 400 && r.json().code === 'ASK_NO_ENTITY', `status=${r.status}`);
     });
+    await check('GET /api/reports/lake → 湖全景', async () => {
+      const r = await request(port, 'GET', '/api/reports/lake');
+      const d = r.json();
+      expect(r.status === 200 && Array.isArray(d.entities) && typeof d.totalRows === 'number' && d.aitable && 'enabled' in d.aitable, `status=${r.status}`);
+    });
+    await check('POST /api/reports/aitable/push(无主体 → 400)', async () => {
+      const r = await request(port, 'POST', '/api/reports/aitable/push', {});
+      expect(r.status === 400 && r.json().code === 'AITABLE_NO_ENTITY', `status=${r.status} code=${r.json().code}`);
+    });
 
     // ---- 静态与路由行为 ----
-    for (const p of ['/', '/index.html', '/app.js', '/crawl.js', '/gen.js', '/history.js', '/tasks.js', '/reports.js', '/style.css', '/vendor/echarts.min.js']) {
+    for (const p of ['/', '/index.html', '/app.js', '/crawl.js', '/gen.js', '/history.js', '/tasks.js', '/reports.js', '/lake.js', '/style.css', '/vendor/echarts.min.js']) {
       await check(`静态 ${p}`, async () => {
         const r = await request(port, 'GET', p);
         expect(r.status === 200, `status=${r.status}`);

+ 34 - 0
server/routes/reports.js

@@ -50,6 +50,40 @@ function register(app, ctx) {
     sendJson(res, 200, { entities: naposService.store.listEntities() });
   });
 
+  // 数据中台:本地湖全景(主体覆盖、行数分布、最近同步/推送),供中台页渲染
+  app.get('/api/reports/lake', async ({ res }) => {
+    const store = naposService.store;
+    const entities = store.listEntities().map((e) => {
+      const rows = store.loadRows(e.key);
+      const byScope = { company: 0, chain: 0, shop: 0, summary: 0 };
+      let from = '';
+      let to = '';
+      for (const r of rows) {
+        if (byScope[r.scope] != null) byScope[r.scope] += 1;
+        if (r.date && (!from || r.date < from)) from = r.date;
+        if (r.date && r.date > to) to = r.date;
+      }
+      const lastSync = (store.loadSyncLogs(e.key)[0] || null);
+      return {
+        key: e.key, name: e.name, shopCount: e.shopCount || 0, chainCount: e.chainCount || 0,
+        rows: rows.length, byScope, coverage: rows.length ? { from, to } : null,
+        lastSync: lastSync ? { at: lastSync.at || null, from: lastSync.from || null, to: lastSync.to || null, ok: !lastSync.cancelled && !lastSync.failed } : null,
+        lastPush: ctx.aitable ? ctx.aitable.lastPushInfo(e.key) : null,
+      };
+    });
+    const totalRows = entities.reduce((s, e) => s + e.rows, 0);
+    const coverage = entities.filter((e) => e.coverage).map((e) => e.coverage);
+    sendJson(res, 200, {
+      lakeDir: store.LAKE_DIR,
+      entities, totalRows,
+      coverage: coverage.length ? { from: coverage.map((c) => c.from).sort()[0], to: coverage.map((c) => c.to).sort().pop() } : null,
+      aitable: {
+        enabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
+        operatorConfigured: !!(ctx.config.aitable && ctx.config.aitable.operatorId),
+      },
+    });
+  });
+
   app.get('/api/reports/tree', async ({ res, query }) => {
     try {
       const tree = await naposService.fetchTree({ refresh: query.refresh === '1' });

+ 1 - 0
server/routes/static.js

@@ -14,6 +14,7 @@ const STATIC_MAP = {
   '/history.js': ['history.js', 'application/javascript; charset=utf-8'],
   '/tasks.js': ['tasks.js', 'application/javascript; charset=utf-8'],
   '/reports.js': ['reports.js', 'application/javascript; charset=utf-8'],
+  '/lake.js': ['lake.js', 'application/javascript; charset=utf-8'],
   '/vendor/echarts.min.js': ['vendor/echarts.min.js', 'application/javascript; charset=utf-8'],
   '/style.css': ['style.css', 'text/css; charset=utf-8'],
 };

+ 22 - 7
server/services/aitable.js

@@ -216,7 +216,9 @@ function createAitableService(ctx) {
     } catch { /* 状态落盘失败不阻断推送 */ }
   }
 
-  /** 推送一个主体的数据。force=true 时忽略水位推全量。返回统计。 */
+  /** 推送一个主体的数据。force=true 时忽略水位推全量、并跳过冲突/全零保护。
+   *  冲突策略:同键行「数据新者胜」——云端记录的 lastModifiedTime 比 本地行 syncedAt
+   *  (取数时间)新则跳过(skippedStale),避免另一台机器后推的旧数据盖掉云端新数据。 */
   async function pushLake(entity, opts = {}) {
     const cfg = requireConfig();
     const naposService = require('../../napos-service.js');
@@ -235,8 +237,15 @@ function createAitableService(ctx) {
     }
     if (!rows.length) return { pushed: 0, created: 0, updated: 0, skipped: '无新增数据' };
 
+    // 全零保护:大批量行指标全零基本可断定取数异常(已知门店级 bug 的症状),
+    // 防止把坏数据灌进云端主档案;确认要推(force)可绕过。
+    const zeroRows = rows.filter((r) => METRIC_FIELDS.every((f) => !Number(r[f]))).length;
+    if (!opts.force && rows.length >= 20 && zeroRows / rows.length > 0.8) {
+      throw err(`本批 ${rows.length} 行中 ${zeroRows} 行指标全零,疑似取数异常,已拦截推送(确认无误可在数据中台强制推送)`, 'AITABLE_ALL_ZERO');
+    }
+
     const token = await getToken();
-    // 现有记录索引:业务键 → recordId(分页拉全量)
+    // 现有记录索引:业务键 → {recordId, mtime}(分页拉全量)
     const existing = new Map();
     for (const rec of await listAllRecords(cfg)) {
       const f = rec.fields || {};
@@ -246,19 +255,25 @@ function createAitableService(ctx) {
         [FIELD_COLUMNS.dimension]: f[FIELD_COLUMNS.dimension],
         [FIELD_COLUMNS.shopId]: f[FIELD_COLUMNS.shopId],
       });
-      if (rec.id) existing.set(k, rec.id);
+      if (rec.id) existing.set(k, { id: rec.id, mtime: Number(rec.lastModifiedTime) || 0 });
     }
 
     let created = 0;
     let updated = 0;
+    let skippedStale = 0;
     const toCreate = [];
     const toUpdate = []; // {id, fields}(更新接口的记录标识键是 id,不是 record_id)
     for (const row of rows) {
       const rec = rowToRecord(row, accountName);
       const k = recordKey(rec.fields);
-      const rid = existing.get(k);
-      if (rid) toUpdate.push({ id: rid, fields: rec.fields });
-      else toCreate.push(rec);
+      const hit = existing.get(k);
+      if (hit) {
+        const localAt = new Date(row.syncedAt || 0).getTime();
+        if (!opts.force && hit.mtime && localAt && hit.mtime > localAt) { skippedStale += 1; continue; }
+        toUpdate.push({ id: hit.id, fields: rec.fields });
+      } else {
+        toCreate.push(rec);
+      }
     }
     for (const part of chunk(toCreate, INSERT_CHUNK)) {
       const res = await transport('POST', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg), { records: part }, token);
@@ -274,7 +289,7 @@ function createAitableService(ctx) {
     const now = Date.now();
     state[key] = { lastPushedAt: now, pushedRows: rows.length, at: new Date().toISOString() };
     writePushState(state);
-    return { pushed: rows.length, created, updated, account: accountName };
+    return { pushed: rows.length, created, updated, skippedStale, account: accountName };
   }
 
   // ---------- 拉取:AI 表格 → 本地湖 ----------