瀏覽代碼

feat: http log, unified task center, chat history persistence

- httpLog config: per-request console log when enabled; 5xx and >1s
  requests always logged
- task center: crawl jobs appear alongside generation tasks with
  per-shop progress and interruption notices (/api/tasks type=crawl)
- chat history per entity persisted to localStorage (last 20 messages,
  quota-safe fallback drops chart rows); restore on open, clear wipes
  storage too
- reserved columns (bizHours/abnormalCloseHours/goodsSaleAmt): confirmed
  no upstream data source in the 140-endpoint catalog (businessHours hit
  is a static order-setting string) — stay reserved, raw responses kept
  for future backfill
- packaging includes scripts/; README documents auto-sync, token, httpLog
KenZ1117 2 周之前
父節點
當前提交
7092644b99
共有 9 個文件被更改,包括 123 次插入 和 7 次删除
  1. 17 4
      README.md
  2. 28 0
      public/reports.js
  3. 37 1
      public/tasks.js
  4. 7 0
      server.js
  5. 2 0
      server/context.js
  6. 29 0
      server/routes/generate.js
  7. 1 0
      打包分发.bat
  8. 1 1
      打包分发.command
  9. 1 1
      打包分发.sh

+ 17 - 4
README.md

@@ -35,6 +35,9 @@
 - **趋势图表**(独立菜单):环比指标条(7 项指标 vs 上期 ±%)、主指标趋势(可叠加上期虚线对比)、门店排行 Top10、转化漏斗(曝光→进店→下单)、门店对比 Top5 折线;一键导出自包含 HTML 经营周报。
 - **智能问数机器人**:仅经营分析相关页面显示的右下角悬浮助手,弹窗对话、支持上下文追问(如先问"上月营业额Top5门店"再问"那二店的单量呢"),回答可附图表并一键应用到数据采集报表。需在「系统设置 → API 设置 → 对话模型」选择对话模型(未保存时默认 deepseek-v4.1-flash,Key 留空复用生图 API Key)。
 - **MCP 工具**:`node reports-mcp.js` 启动 stdio MCP server(reports_list_entities / reports_summary / reports_query),注册到 `~/.workbuddy/mcp.json` 即可供 Agent 直接查数,与 napos-mcp(取数)形成闭环。
+- **定时自动同步**:在「同步数据」弹窗开启后,每天到达设定时间(默认 06:00)且商家客户端在线时,自动把昨日经营数据补进本地数据湖(客户端离线则当天在线后补跑);默认关闭。同步记录照常进入同步记录列表。
+- **任务中心**:生成任务与门店采集任务统一展示,采集任务显示各门店完成进度,服务重启后被中断的采集会标记「已中断」。
+- **智能问数对话记忆**:问数对话按数据主体保存在浏览器本地,刷新或重开页面后可继续追问,「清空对话」同时清除本地记录。
 - **字段**:日期、门店名称、有效订单、收入、营业额、曝光人数、下单人数、进店转化率、下单转化率、客单价、支出、活动订单、活动补贴等;「营业时长 / 异常关店时长 / 商品销售额」为预留列,待后续数据源接入后自动回填。
 
 ## 环境要求
@@ -86,6 +89,9 @@
 | `maxConcurrentJobs` | 同时进行的生成任务数量。 |
 | `requestTimeoutMs` | 生成服务请求超时时间,单位毫秒。 |
 | Cookie | 用于淘宝闪购后台采集,仅保存在本地 `session.json`。 |
+| `accessToken` | 局域网访问口令:设置后其他电脑访问本服务需输入口令(本机 127.0.0.1 不受影响)。留空保持不变,输入 `-` 清除。也可用环境变量 `ACCESS_TOKEN` 临时开关(不落盘)。 |
+| `autoSync` | 每日自动同步:`{ "enabled": true, "time": "06:00" }`,也可在「同步数据」弹窗里开关。 |
+| `httpLog` | `true` 时在控制台打印每个 HTTP 请求(5xx 与超过 1 秒的慢请求始终打印)。 |
 
 API Key 获取与充值入口可在“系统设置 → API 设置”中打开。
 
@@ -150,14 +156,17 @@ workspace/
 │   │   ├── session.js     #   采集 Cookie 会话
 │   │   ├── crawl.js       #   门店采集:店铺清单、抓取控制、日志
 │   │   ├── settings.js    #   系统设置 + 生图/对话 API 连通性测试
-│   │   ├── generate.js    #   视觉生成、任务中心、画板保存、打开目录
-│   │   ├── history.js     #   门店扫描、历史创作、图片文件服务
+│   │   ├── generate.js    #   视觉生成、任务中心(含采集任务)、画板保存、打开目录
+│   │   ├── history.js     #   门店扫描(图片尺寸缓存)、历史创作、图片文件服务
 │   │   ├── reports.js     #   数据采集 + 智能问数(同步/查询/导出/周报)
 │   │   ├── static.js      #   静态前端(精确白名单 + /assets/)
 │   │   └── index.js       #   路由注册总入口
 │   └── services/          # 有状态引擎
-│       ├── crawl.js       #   采集引擎:翻页抓取、下载、断点跳过、可取消任务循环
-│       └── generation.js  #   生成引擎:任务队列、供应商 API、轮询、图片合成、任务持久化
+│       ├── crawl.js       #   采集引擎:翻页抓取、下载、断点跳过、可取消任务循环、进度持久化
+│       ├── generation.js  #   生成引擎:任务队列、供应商 API、轮询、图片合成、任务持久化
+│       └── auto-sync.js   #   经营数据每日自动同步(默认关闭)
+├── scripts/
+│   └── smoke.js           # 冒烟测试:零依赖拉起真实服务全量核对端点(node scripts/smoke.js)
 ├── napos-service.js       # 数据采集:取数服务层(同步任务 / 查询聚合 / CSV)
 ├── reports-store.js       # 数据采集:本地数据湖(多主体 / 日期×门店 upsert)
 ├── napos/                 # napos-mcp 取数引擎(CDP 附着商家客户端,零第三方依赖)
@@ -184,6 +193,10 @@ workspace/
 
 服务默认仅监听本机 `127.0.0.1`。如需局域网访问,可临时设置 `HOST=0.0.0.0`,但建议仅在可信网络中使用。
 
+### 局域网访问口令
+
+在「系统设置」设置 `accessToken`(或以 `ACCESS_TOKEN=xxx` 环境变量临时开启)后,局域网内其他电脑访问 `/api/*` 与图片/文件接口都需要口令:浏览器首次访问会弹窗引导输入一次(保存在浏览器 Cookie),命令行可用 `curl -H 'X-Access-Token: xxx' …` 或在 URL 后加 `?token=xxx`。本机回环访问、健康检查与静态页面不受影响。
+
 ### 凭据与数据的存储位置
 
 | 文件 | 内容 | 说明 |

+ 28 - 0
public/reports.js

@@ -767,6 +767,29 @@
 
   // ---------- 智能问数 · 机器人(仅经营分析页面) ----------
 
+  // 对话历史按主体持久化到 localStorage,刷新/重开后可继续追问
+  const CHAT_LS_KEY = 'shanReports.chat.v1';
+  const chatStoreKey = () => state.entity || '_';
+
+  function loadChatForEntity() {
+    const all = LS.get(CHAT_LS_KEY, {});
+    state.chat.messages = Array.isArray(all[chatStoreKey()]) ? all[chatStoreKey()] : [];
+    if (state.chat.open) renderChatMessages();
+  }
+
+  function saveChatForEntity() {
+    const key = chatStoreKey();
+    const all = LS.get(CHAT_LS_KEY, {});
+    const trimmed = state.chat.messages.filter((m) => !m.pending).slice(-20);
+    const attempt = (payload) => {
+      try { localStorage.setItem(CHAT_LS_KEY, JSON.stringify(payload)); return true; } catch { return false; }
+    };
+    if (attempt({ ...all, [key]: trimmed })) return;
+    // 图表行数据可能撑爆配额 → 退化为只存文字与图表配置
+    const slim = { ...all, [key]: trimmed.map((m) => ({ ...m, rows: undefined })) };
+    if (!(attempt(slim) || attempt({ [key]: slim[key].slice(-10) }))) { /* 配额不足则放弃持久化 */ }
+  }
+
   function chatVisibleFor(view) { return view === 'reports' || view === 'analysis'; }
 
   function setChatOpen(open) {
@@ -774,6 +797,7 @@
     $('chatbotPanel').hidden = !open;
     $('chatbotFab').classList.toggle('active', open);
     if (open) {
+      loadChatForEntity();
       $('chatbotFab').hidden = false;
       setTimeout(() => $('chatbotInput').focus(), 60);
       scrollChatBottom();
@@ -907,6 +931,7 @@
       };
       if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, msg);
       else state.chat.messages.push(msg);
+      saveChatForEntity();
     } catch (e) {
       const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
       const errMsg = { role: 'assistant', text: '出错了:' + e.message, error: true };
@@ -1049,6 +1074,9 @@
     $('btnChatClose').addEventListener('click', () => setChatOpen(false));
     $('btnChatClear').addEventListener('click', () => {
       state.chat.messages = [];
+      const all = LS.get(CHAT_LS_KEY, {});
+      delete all[state.entity || '_'];
+      LS.set(CHAT_LS_KEY, all);
       renderChatMessages();
     });
     $('btnChatSend').addEventListener('click', () => sendChat($('chatbotInput').value));

+ 37 - 1
public/tasks.js

@@ -85,7 +85,43 @@
       : `共 ${counts.all} 个任务 · ${counts.running} 个进行中 · ${counts.failed} 个失败`;
   }
 
+  function buildCrawlCard(task) {
+    const card = el('article', `task-card status-${task.status}`);
+    const main = el('div', 'task-main');
+
+    const titleRow = el('div', 'task-title-row');
+    const title = el('div', 'task-title');
+    title.textContent = task.name || '门店采集';
+    const badge = el('span', `badge task-badge ${task.status}`);
+    badge.textContent = { running: '进行中', success: '已完成', failed: '已停止/失败' }[task.status] || task.status;
+    titleRow.append(title, badge);
+
+    const progress = el('div', 'task-progress');
+    const bar = el('span', 'task-progress-bar');
+    bar.style.width = `${progressValue(task)}%`;
+    progress.appendChild(bar);
+
+    const meta = el('div', 'task-meta');
+    meta.textContent = [
+      task.detail || '',
+      task.createdAt ? `开始 ${formatTime(task.createdAt)}` : '',
+      task.finishedAt ? `结束 ${formatTime(task.finishedAt)}` : '',
+    ].filter(Boolean).join(' · ');
+
+    main.append(titleRow, progress, meta);
+    if (task.warning) main.appendChild(el('div', 'task-notice warning', task.warning));
+
+    const actions = el('div', 'task-actions');
+    const right = el('div', 'task-status-detail');
+    right.textContent = task.progress || '';
+    actions.appendChild(right);
+
+    card.append(main, actions);
+    return card;
+  }
+
   function buildTaskCard(task) {
+    if (task.type === 'crawl') return buildCrawlCard(task);
     const card = el('article', `task-card status-${task.status}`);
     const main = el('div', 'task-main');
 
@@ -142,7 +178,7 @@
       const icon = el('div', 'task-empty-icon');
       icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M4 12h16M4 17h7"/><circle cx="18" cy="7" r="2"/><circle cx="15" cy="17" r="2"/></svg>';
       const text = el('p', '', state.loading ? '正在加载任务' : '暂无匹配任务');
-      const help = el('small', '', state.loading ? '请稍候' : '提交生成任务后会自动显示在这里');
+      const help = el('small', '', state.loading ? '请稍候' : '提交生成任务或开始门店采集后会自动显示在这里');
       empty.append(icon, text, help);
       list.appendChild(empty);
       return;

+ 7 - 0
server.js

@@ -31,6 +31,13 @@ registerRoutes(app, ctx);
 const tokenGuard = makeTokenGuard(ctx.config);
 
 const server = http.createServer((req, res) => {
+  const startedAt = Date.now();
+  res.on('finish', () => {
+    const ms = Date.now() - startedAt;
+    if (ctx.config.httpLog || res.statusCode >= 500 || ms > 1000) {
+      console.log(`[http] ${req.method} ${req.url} -> ${res.statusCode} ${ms}ms`);
+    }
+  });
   try {
     const parsed = new URL(req.url, `http://${req.headers.host}`);
     if (!tokenGuard(req, parsed.pathname, parsed.searchParams)) {

+ 2 - 0
server/context.js

@@ -27,6 +27,8 @@ const CONFIG_DEFAULTS = {
   chatApiBase: '',
   chatApiKey: '',
   accessToken: '',
+  // HTTP 访问日志:true 时在控制台打印每个请求(5xx 与超过 1s 的慢请求始终打印)
+  httpLog: false,
   // 经营数据每日自动同步(商家客户端在线时自动补昨日数据),默认关闭
   autoSync: { enabled: false, time: '06:00' },
 };

+ 29 - 0
server/routes/generate.js

@@ -120,6 +120,33 @@ function publicTask(job) {
   };
 }
 
+// 采集任务映射为任务中心条目(与生成任务并列展示)
+function crawlTaskFromState(cs) {
+  if (!cs || cs.status === 'idle') return null;
+  const shops = Array.isArray(cs.shops) ? cs.shops : [];
+  const done = shops.filter((s) => s.state === 'done' || s.state === 'skipped_completed').length;
+  const failedShops = shops.filter((s) => s.state === 'failed').length;
+  const statusMap = { running: 'running', done: 'success', stopped: 'failed', interrupted: 'failed', error: 'failed' };
+  const labelMap = { running: '采集中', done: '采集完成', stopped: '采集已停止', interrupted: '采集被中断', error: '采集出错' };
+  const pct = shops.length ? Math.round((done / shops.length) * 100) : 0;
+  return {
+    id: 'crawl-current',
+    type: 'crawl',
+    status: statusMap[cs.status] || 'running',
+    name: `门店采集 · ${labelMap[cs.status] || cs.status}`,
+    kind: 'crawl',
+    shopName: '',
+    model: '',
+    progress: pct + '%',
+    detail: `${done}/${shops.length} 家门店完成${failedShops ? ` · ${failedShops} 家失败` : ''}`,
+    warning: cs.status === 'interrupted' ? '服务重启导致中断,未完成的门店可重新勾选采集' : '',
+    createdAt: cs.startedAt,
+    startedAt: cs.startedAt,
+    finishedAt: cs.finishedAt,
+    updatedAt: cs.finishedAt || cs.startedAt || Date.now(),
+  };
+}
+
 function register(app, ctx) {
   app.post('/api/generate/brand', async ({ req, res }) => {
     const body = await readBody(req);
@@ -159,6 +186,8 @@ function register(app, ctx) {
       .filter((job) => path.resolve(job.root) === root)
       .sort((a, b) => b.createdAt - a.createdAt)
       .map(publicTask);
+    const crawlTask = crawlTaskFromState(ctx.crawl.getState());
+    if (crawlTask) tasks.unshift(crawlTask);
     const summary = tasks.reduce((acc, task) => {
       acc[task.status] = (acc[task.status] || 0) + 1;
       return acc;

+ 1 - 0
打包分发.bat

@@ -15,6 +15,7 @@ for %%f in (server.js napos-service.js reports-store.js reports-mcp.js nl-query.
 robocopy public "%OUT%\public" /E /NFL /NDL /NJH /NJS >nul
 robocopy napos "%OUT%\napos" /E /NFL /NDL /NJH /NJS >nul
 robocopy server "%OUT%\server" /E /NFL /NDL /NJH /NJS >nul
+robocopy scripts "%OUT%\scripts" /E /NFL /NDL /NJH /NJS >nul
 robocopy node_modules "%OUT%\node_modules" /E /NFL /NDL /NJH /NJS >nul
 
 powershell -NoProfile -Command "Compress-Archive -LiteralPath '%OUT%' -DestinationPath '%ZIP%' -Force"

+ 1 - 1
打包分发.command

@@ -18,7 +18,7 @@ for f in server.js napos-service.js reports-store.js reports-mcp.js nl-query.js
 done
 
 # 目录(前端 / 取数引擎 / 依赖)
-for d in public server napos node_modules; do
+for d in public server scripts napos node_modules; do
   [ -d "$d" ] && cp -R "$d" "$OUT/"
 done
 

+ 1 - 1
打包分发.sh

@@ -18,7 +18,7 @@ for f in server.js napos-service.js reports-store.js reports-mcp.js nl-query.js
 done
 
 # 目录(前端 / 取数引擎 / 依赖)
-for d in public server napos node_modules; do
+for d in public server scripts napos node_modules; do
   [ -d "$d" ] && cp -R "$d" "$OUT/"
 done