Преглед изворни кода

chore: baseline before server.js modularization

KenZ1117 пре 2 недеља
родитељ
комит
cbceac6844
26 измењених фајлова са 6435 додато и 41 уклоњено
  1. 4 0
      .gitignore
  2. 57 2
      README.md
  3. 478 0
      napos-service.js
  4. 918 0
      napos/cdp.js
  5. 393 0
      napos/classify.js
  6. 398 0
      napos/guard.js
  7. 966 0
      napos/kit.js
  8. 168 0
      napos/ws.js
  9. 264 0
      nl-query.js
  10. 2 2
      package-lock.json
  11. 2 2
      package.json
  12. 100 1
      public/app.js
  13. 364 22
      public/index.html
  14. 1075 0
      public/reports.js
  15. 252 1
      public/style.css
  16. 34 0
      public/vendor/echarts.min.js
  17. 140 0
      report-html.js
  18. 180 0
      reports-mcp.js
  19. 191 0
      reports-store.js
  20. 334 8
      server.js
  21. 1 1
      启动.bat
  22. 1 1
      启动.command
  23. 1 1
      启动.sh
  24. 26 0
      打包分发.bat
  25. 43 0
      打包分发.command
  26. 43 0
      打包分发.sh

+ 4 - 0
.gitignore

@@ -6,3 +6,7 @@ workspace/
 output/
 .playwright-cli/
 .DS_Store
+data/
+.v2c/
+.video_agent/
+.zcode/

+ 57 - 2
README.md

@@ -1,6 +1,6 @@
-# Hyreal Gen Pic · 门店装修工具
+# 智慧运营中台(淘宝闪购专业版)
 
-门店装修工具(淘宝闪购专业版)是一个本地 Web 应用,把「门店菜品采集」和「AI 视觉生成」放在统一界面中。两个能力保持独立入口,可以单独使用,也可以按“采集菜品 → 生成门店视觉资产 → 管理历史作品”的流程连续使用。
+智慧运营中台是一个本地 Web 应用,集「门店采集」「AI 视觉生成」「数据采集与智能分析」于一体:采集门店菜品并批量生成品牌视觉资产,同时从淘宝闪购商家客户端取数沉淀本地,形成可持续加工的经营数据中台。
 
 ## 功能概览
 
@@ -24,6 +24,19 @@
 - 支持悬浮放大预览和悬浮下载。
 - 重复生成会追加记录;历史列表读取并保留最近 48 小时内的记录。
 
+### 数据采集与智能分析
+
+淘宝闪购经营数据中台:从本机商家客户端取数、沉淀本地、多维报表。
+
+- **取数通道**:附着本机淘宝闪购商家客户端调试端口(默认 `9669`),由客户端页面自身的登录态代发请求(非爬虫),数据中心接口自动补签名。首次使用需以 `--remote-debugging-port=9669` 启动商家客户端并登录。
+- **多主体**:每登录一个商家账号并同步一次即形成一个主体 tab,可在多个主体之间切换查看;主体内支持全公司 / 连锁 / 单店三种粒度。
+- **数据沉淀**:同步结果按「日期 × 门店」写入本地数据湖 `data/reports/`,反复同步自动去重合并,可用于二次加工。
+- **BI 展示**:KPI 卡片 + 明细 / 按门店 / 按日期三种视图,列可自定义显示(列设置),支持排序、门店筛选、名称搜索与 CSV 导出。
+- **趋势图表**(独立菜单):环比指标条(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(取数)形成闭环。
+- **字段**:日期、门店名称、有效订单、收入、营业额、曝光人数、下单人数、进店转化率、下单转化率、客单价、支出、活动订单、活动补贴等;「营业时长 / 异常关店时长 / 商品销售额」为预留列,待后续数据源接入后自动回填。
+
 ## 环境要求
 
 - Node.js 18 或更高版本。
@@ -127,6 +140,13 @@ workspace/
 ```text
 .
 ├── server.js              # 本地服务、采集调度、生成队列和历史 API
+├── napos-service.js       # 数据采集:取数服务层(同步任务 / 查询聚合 / CSV)
+├── reports-store.js       # 数据采集:本地数据湖(多主体 / 日期×门店 upsert)
+├── napos/                 # napos-mcp 取数引擎(CDP 附着商家客户端,零第三方依赖)
+│   ├── cdp.js             # CDP 客户端 + 子站路由(按域选执行上下文)
+│   ├── kit.js             # 业务取数工具箱(组织树 / 经营数据 / 签名)
+│   ├── guard.js           # 写操作防护(只放行只读接口)
+│   └── data/apis.json     # 140 个后台接口目录(防护索引)
 ├── public/
 │   ├── index.html          # 应用外壳和页面结构
 │   ├── app.js              # 导航、启动画面和系统设置
@@ -134,6 +154,7 @@ workspace/
 │   ├── gen.js              # 视觉生成界面逻辑
 │   ├── history.js          # 历史创作界面逻辑
 │   ├── tasks.js             # 任务中心界面逻辑
+│   ├── reports.js          # 数据采集 + 智能分析界面逻辑
 │   └── style.css           # 统一视觉系统
 ├── config.example.json     # 配置模板
 ├── 启动.command            # macOS 启动脚本
@@ -145,6 +166,25 @@ workspace/
 
 服务默认仅监听本机 `127.0.0.1`。如需局域网访问,可临时设置 `HOST=0.0.0.0`,但建议仅在可信网络中使用。
 
+### 凭据与数据的存储位置
+
+| 文件 | 内容 | 说明 |
+| --- | --- | --- |
+| `config.json` | API Key、模型、端口等 | 系统设置保存时写入;首次启动若缺失会自动从模板生成 |
+| `session.json` | 淘宝闪购采集 Cookie(含 `ksid`) | 仅门店采集使用;界面保存后不回显 |
+| `data/reports/` | 经营数据本地数据湖 | 各主体经营数据,按「日期 × 门店」沉淀 |
+| `manual-shops.json` | 手动店铺清单 | 门店采集用 |
+
+以上文件均不进 Git;数据采集的取数不依赖 Cookie —— 它实时读取本机商家客户端的登录态(调试端口 9669)。
+
+### 发给别人部署(分发包)
+
+双击 **打包分发.command**(macOS/Linux)或 **打包分发.bat**(Windows),会在项目上级目录生成 `智慧运营中台-分发包.zip`。包内只含代码、依赖和配置模板,**不含**你的 `config.json` / `session.json` / `manual-shops.json` / `data/`(API Key、Cookie、报表数据只留在你本机)。
+
+对方解压后双击 `启动.command`(macOS)或 `启动.bat`(Windows)即可:首次启动自动生成其本机 `config.json`,在「系统设置」中填写自己的 API Key;数据采集页按提示以调试端口启动并登录自己的商家客户端后同步取数。
+
+**升级已装过旧版的电脑**:把分发包解压覆盖到原目录即可(不要先删旧目录)。包内不含配置文件,覆盖不会动他们的历史 `config.json` / `session.json`;新版启动时会把历史配置与默认值合并读取 —— API Key、模型、Cookie 全部原样沿用,无需重填;若历史 `rootDir` 在本机不可用,启动时会提示到「系统设置」修改,不会崩溃。
+
 健康检查:
 
 ```bash
@@ -162,6 +202,7 @@ session.json
 manual-shops.json
 output/
 workspace/
+data/
 .genpic/
 ```
 
@@ -184,3 +225,17 @@ workspace/
 ### 历史作品消失
 
 历史创作列表只显示最近 48 小时内的记录。本地文件被重新生成覆盖时,页面会优先使用 API 返回的远程地址。
+
+### 数据采集与智能分析提示「客户端未连接」
+
+数据采集的取数通道依赖本机淘宝闪购商家客户端的调试端口。完全退出客户端后,用以下方式重新启动并登录:
+
+```text
+淘宝闪购商家客户端 --remote-debugging-port=9669
+```
+
+连接后进入「数据采集 → 同步数据」,选择范围与日期区间开始同步。
+
+### 数据采集与智能分析同步部分门店失败
+
+单个门店取数失败不影响其他门店,失败项会计入「失败」计数并保留在同步步骤里。常见原因是登录态失效(客户端重新登录即可)或触发了后台风控(在客户端完成验证后重试)。

+ 478 - 0
napos-service.js

@@ -0,0 +1,478 @@
+'use strict';
+
+/**
+ * 数据报表 · 取数服务层
+ * ----------------------------------------------------------------------------
+ * 桥接 napos/(淘宝闪购商家客户端 CDP 取数引擎)与 reports-store(本地数据湖)。
+ *
+ * 取数通道:附着本机商家客户端调试端口(默认 9669),由客户端页面自身的登录态
+ * 代发请求(非爬虫、不模拟点击);数据中心接口按域自动补签名。
+ *
+ * 数据来源(数据中心 lsycm · historyOverview):
+ *   ds 日期 / valid_order_cnt 有效订单 / gross_amt 营业额 / income_amt 收入 /
+ *   expense_amt 支出 / exp_uv 曝光人数 / order_user_cnt 下单人数 /
+ *   clk_exp_rate 进店转化率 / ord_clk_rate 下单转化率 / per_actual_pay 客单价 …
+ *
+ * 营业时长 / 异常关店时长 / 商品销售额:当前接口目录暂无对应字段,
+ * 作为预留列返回 null,取数层保留原始返回值,后续补源即可回填。
+ */
+
+const kit = require('./napos/kit.js');
+const store = require('./reports-store.js');
+
+const DEFAULT_CDP_PORT = Number(process.env.NAPOS_CDP_PORT || 9669);
+
+// ---------- BI 列注册表(语义层)----------
+// synonyms / description 供「智能问数」的 LLM 做实体对齐与口径解释
+
+const REPORT_COLUMNS = [
+  { key: 'date', label: '日期', type: 'date', locked: true, aggregable: false,
+    synonyms: ['日期', '天', '哪天', '日期时间'],
+    description: '数据日期,格式 YYYYMMDD;当天数据未结算,有效区间一般为 T-1 及更早' },
+  { key: 'shopName', label: '门店名称', type: 'text', locked: true, aggregable: false,
+    synonyms: ['门店', '店铺', '店', '门店名'],
+    description: '门店名称,支持模糊筛选' },
+  { key: 'bizHours', label: '营业时长(h)', type: 'hours', reserved: true,
+    synonyms: ['营业时长', '营业时间', '开店时长'],
+    description: '预留列:当日营业小时数,数据源暂未接入' },
+  { key: 'abnormalCloseHours', label: '异常关店时长(h)', type: 'hours', reserved: true,
+    synonyms: ['异常关店', '异常关店时长', '关店时长', '异常闭店'],
+    description: '预留列:非计划性关店时长,数据源暂未接入' },
+  { key: 'validOrderCnt', label: '有效订单', type: 'int',
+    synonyms: ['有效订单', '订单量', '单量', '订单数', '有效单量'],
+    description: '剔除取消/无效后的订单量' },
+  { key: 'incomeAmt', label: '收入(元)', type: 'money',
+    synonyms: ['收入', '实际收入', '到账', '营收'],
+    description: '商家实际收入(元);营业额 = 收入 + 支出' },
+  { key: 'grossAmt', label: '营业额(元)', type: 'money',
+    synonyms: ['营业额', '流水', 'GMV', 'gmv', '销售额', '交易额'],
+    description: '营业额(元),口径 = 收入 + 支出;做排行/对比的首选指标' },
+  { key: 'goodsSaleAmt', label: '商品销售额(元)', type: 'money', reserved: true,
+    synonyms: ['商品销售额', '商品销售', '菜品销售额'],
+    description: '预留列:仅商品部分的销售金额,数据源暂未接入' },
+  { key: 'expUv', label: '曝光人数', type: 'int',
+    synonyms: ['曝光', '曝光人数', '曝光量', '展现人数'],
+    description: '看到店铺的去重人数' },
+  { key: 'orderUserCnt', label: '下单人数', type: 'int',
+    synonyms: ['下单人数', '下单用户', '购买人数', '下单用户数'],
+    description: '当日下单的去重用户数' },
+  { key: 'clkExpRate', label: '进店转化率', type: 'pct',
+    synonyms: ['进店转化率', '进店率', '点击率', '入店转化率'],
+    description: '进店人数 / 曝光人数;≥9% 健康,<6% 偏低' },
+  { key: 'ordClkRate', label: '下单转化率', type: 'pct',
+    synonyms: ['下单转化率', '下单率', '成交转化率', '访购率'],
+    description: '下单人数 / 进店人数;≥2.5% 健康,<2% 偏低' },
+  { key: 'perActualPay', label: '客单价(元)', type: 'money',
+    synonyms: ['客单价', '均价', '笔单价'],
+    description: '营业额 / 有效订单(元)' },
+  { key: 'expenseAmt', label: '支出(元)', type: 'money',
+    synonyms: ['支出', '费用', '成本', '平台扣费'],
+    description: '支出(元),含平台佣金、推广费用等' },
+  { key: 'actOrdCnt', label: '活动订单', type: 'int',
+    synonyms: ['活动订单', '活动单量', '营销订单'],
+    description: '参与营销活动的订单量' },
+  { key: 'actSubsidyAmt', label: '活动补贴(元)', type: 'money',
+    synonyms: ['活动补贴', '补贴金额', '商家补贴'],
+    description: '活动中商家承担的补贴金额(元)' },
+  { key: 'shopRoi', label: '推广ROI', type: 'num',
+    synonyms: ['ROI', 'roi', '投产比', '推广roi'],
+    description: '推广投入产出比;大盘口径常为 0' },
+  { key: 'rebuyRate30d', label: '30日复购率', type: 'pct',
+    synonyms: ['复购率', '30日复购', '老客回购率'],
+    description: '近 30 天老客复购比例' },
+];
+
+const COLUMN_MAP = Object.fromEntries(REPORT_COLUMNS.map((c) => [c.key, c]));
+
+// ---------- 原始字段 → 标准行 ----------
+
+const FIELD_MAP = {
+  valid_order_cnt: 'validOrderCnt',
+  gross_amt: 'grossAmt',
+  income_amt: 'incomeAmt',
+  expense_amt: 'expenseAmt',
+  exp_uv: 'expUv',
+  order_user_cnt: 'orderUserCnt',
+  clk_exp_rate: 'clkExpRate',
+  ord_clk_rate: 'ordClkRate',
+  per_actual_pay: 'perActualPay',
+  act_ord_cnt: 'actOrdCnt',
+  act_shop_subsidy_amt: 'actSubsidyAmt',
+  shop_roi: 'shopRoi',
+  rebuy_rate_30d: 'rebuyRate30d',
+};
+
+function trimRaw(raw) {
+  const out = {};
+  for (const [k, v] of Object.entries(raw || {})) {
+    if (k === 'ds') continue;
+    if (/_diff$|_mall_avg$|_mall_avg_rank$/.test(k)) continue;
+    out[k] = v;
+  }
+  return out;
+}
+
+function normalizeRow(rawDaily, ctx) {
+  const row = {
+    date: String(rawDaily.ds || ''),
+    scope: ctx.scope,
+    shopId: ctx.shopId == null ? '' : String(ctx.shopId),
+    shopName: ctx.shopName || '',
+    chainId: ctx.chainId == null ? '' : String(ctx.chainId),
+    chainName: ctx.chainName || '',
+    bizHours: null,
+    abnormalCloseHours: null,
+    goodsSaleAmt: null,
+    syncedAt: new Date().toISOString(),
+    raw: trimRaw(rawDaily),
+  };
+  for (const [src, dst] of Object.entries(FIELD_MAP)) {
+    const v = rawDaily[src];
+    row[dst] = v == null || v === '' ? 0 : Number(v);
+  }
+  return row;
+}
+
+// ---------- 连接状态 ----------
+
+/** 按平台给出「以调试端口启动商家客户端」的可执行指引 */
+function cdpLaunchHint(port = DEFAULT_CDP_PORT) {
+  if (process.platform === 'win32') {
+    return `Windows 启动方法:① 任务栏右键完全退出淘宝闪购客户端(任务管理器确认进程已退出);`
+      + `② 右键桌面快捷方式 → 属性 → 在「目标」末尾追加一个空格和 --remote-debugging-port=${port} → 确定后用该快捷方式启动。`
+      + `注意:客户端每次重启都要用这个快捷方式,否则端口不会开启。`;
+  }
+  return `macOS 启动方法:① 完全退出客户端(⌘Q,或活动监视器确认进程退出);`
+    + `② 终端执行:open -a "淘宝闪购商家版" --args --remote-debugging-port=${port}`
+    + `(应用名以实际为准;也可直接运行 "/Applications/客户端.app/Contents/MacOS/可执行文件" --remote-debugging-port=${port})。`
+    + `注意:客户端每次重启都要带此参数。`;
+}
+
+async function naposStatus() {
+  const out = { port: DEFAULT_CDP_PORT, connected: false };
+  try {
+    const st = await kit.status(DEFAULT_CDP_PORT);
+    out.connected = !!st.portOk;
+    out.browser = st.browser || null;
+    out.targets = (st.targets || []).length;
+    out.contexts = (st.contexts || []).length;
+    if (st.probeError) out.probeError = String(st.probeError);
+  } catch (e) {
+    out.error = e.message || String(e);
+  }
+  if (!out.connected) out.hint = cdpLaunchHint();
+  // 自检:本机能否连通调试端口(给用户的浏览器直测地址)
+  out.selfCheckUrl = `http://127.0.0.1:${DEFAULT_CDP_PORT}/json/version`;
+  return out;
+}
+
+// ---------- 主体结构(多主体 tab 的来源) ----------
+
+let treeCache = { at: 0, tree: null };
+
+async function fetchTree({ refresh = false } = {}) {
+  if (!refresh && treeCache.tree && Date.now() - treeCache.at < 5 * 60 * 1000) {
+    return treeCache.tree;
+  }
+  const tree = await kit.shopTree({ port: DEFAULT_CDP_PORT });
+  if (!tree.ok) {
+    const err = new Error('读取门店组织树失败:' + (tree.code || tree.status || '未知错误'));
+    err.code = 'NAPOS_TREE_FAIL';
+    throw err;
+  }
+  const entity = store.upsertEntity({
+    merchantId: (tree.merchant && tree.merchant.id) || 'default',
+    merchantName: (tree.merchant && tree.merchant.name) || '默认主体',
+    chains: tree.chains.map((c) => ({ chainId: String(c.chainId), chainName: c.chainName })),
+    shops: tree.shops.map((s) => ({
+      shopId: String(s.shopId), shopName: s.shopName,
+      chainId: s.chainId ? String(s.chainId) : '', chainName: s.chainName || '',
+      busyLevel: s.busyLevel || null,
+    })),
+  }).entity;
+  // 缓存必须存补齐 entity 后的完整对象:缓存的消费方(runSync / tree 路由)都依赖 tree.entity
+  const enriched = { ...tree, entity };
+  treeCache = { at: Date.now(), tree: enriched };
+  return enriched;
+}
+
+// ---------- 同步任务 ----------
+
+const syncState = {
+  running: false,
+  cancelRequested: false,
+  startedAt: null,
+  finishedAt: null,
+  error: null,
+  entity: null,
+  from: null,
+  to: null,
+  planTotal: 0,
+  done: 0,
+  current: '',
+  added: 0,
+  updated: 0,
+  failed: 0,
+  steps: [],
+};
+
+function syncSnapshot() {
+  const { running, cancelRequested, startedAt, finishedAt, error, entity, from, to,
+    planTotal, done, current, added, updated, failed } = syncState;
+  return { running, cancelRequested, startedAt, finishedAt, error,
+    entity, from, to, planTotal, done, current, added, updated, failed };
+}
+
+function dayShift(ymd, delta) {
+  const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
+  d.setDate(d.getDate() + delta);
+  const z = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+}
+
+function yesterday() { return dayShift(kit.today(), -1); }
+
+/**
+ * 同步一个区间到本地数据湖。
+ * scope:
+ *   'company' — 仅全公司聚合行
+ *   'chain'   — 各连锁聚合行
+ *   'shop'    — 逐店明细行(可选 chainId 限定 / shopIds 限定)
+ *   'all'     — 以上全部(默认)
+ */
+async function runSync({ from, to, scope = 'all', chainId, shopIds, port } = {}) {
+  if (syncState.running) {
+    const err = new Error('已有同步任务在进行中');
+    err.code = 'SYNC_BUSY';
+    throw err;
+  }
+  // 先同步置位,避免忙碌检查与实际启动之间因 await 出现并发窗口
+  Object.assign(syncState, {
+    running: true, cancelRequested: false, startedAt: new Date().toISOString(), finishedAt: null,
+    error: null, entity: null, from: null, to: null,
+    planTotal: 0, done: 0, current: '准备中', added: 0, updated: 0, failed: 0, steps: [],
+  });
+
+  try {
+    const tree = await fetchTree();
+    const entity = tree.entity;
+    const fromDay = kit.normDay(from || dayShift(yesterday(), -29));
+    const toDay = kit.normDay(to || yesterday());
+    if (!/^\d{8}$/.test(fromDay) || !/^\d{8}$/.test(toDay) || fromDay > toDay) {
+      const err = new Error('日期区间不合法(需 YYYYMMDD,且开始 ≤ 结束)');
+      err.code = 'SYNC_BAD_RANGE';
+      throw err;
+    }
+
+    const jobs = [];
+    if (scope === 'all' || scope === 'company') {
+      jobs.push({ kind: 'company', label: '全公司', fn: () => callHistory({ chainId: '-1', shopListIn: '' }, { scope: 'company', chainId: '-1', chainName: '' }) });
+    }
+    if (scope === 'all' || scope === 'chain') {
+      for (const c of tree.chains) {
+        jobs.push({
+          kind: 'chain', label: `连锁·${c.chainName}`,
+          fn: () => callHistory({ chainId: c.chainId, shopListIn: '' }, { scope: 'chain', chainId: c.chainId, chainName: c.chainName }),
+        });
+      }
+    }
+    if (scope === 'all' || scope === 'shop') {
+      let shops = tree.shops;
+      if (chainId) shops = shops.filter((s) => String(s.chainId) === String(chainId));
+      if (Array.isArray(shopIds) && shopIds.length) {
+        const wanted = new Set(shopIds.map(String));
+        shops = shops.filter((s) => wanted.has(String(s.shopId)));
+      }
+      for (const s of shops) {
+        jobs.push({
+          kind: 'shop', label: `门店·${s.shopName}`,
+          fn: () => callHistory(
+            { chainId: '-1', shopListIn: String(s.shopId) },
+            { scope: 'shop', shopId: s.shopId, shopName: s.shopName, chainId: s.chainId || '', chainName: s.chainName || '' },
+          ),
+        });
+      }
+    }
+
+    Object.assign(syncState, {
+      entity: { key: entity.key, name: entity.name }, from: fromDay, to: toDay,
+      planTotal: jobs.length, current: '',
+    });
+
+    const usedPort = Number(port || DEFAULT_CDP_PORT);
+    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+    for (const job of jobs) {
+      if (syncState.cancelRequested) break;
+      syncState.current = job.label;
+      try {
+        const rows = await job.fn(usedPort);
+        const res = store.upsertRows(entity.key, rows);
+        syncState.added += res.added;
+        syncState.updated += res.updated;
+        syncState.steps.push({ label: job.label, ok: true, added: res.added, updated: res.updated });
+      } catch (e) {
+        syncState.failed += 1;
+        syncState.steps.push({ label: job.label, ok: false, error: e.message || String(e) });
+      }
+      syncState.done += 1;
+      await sleep(180); // 温和节奏,避免连续轰接口
+    }
+    const run = {
+      at: new Date().toISOString(), from: fromDay, to: toDay, scope,
+      cancelled: syncState.cancelRequested,
+      added: syncState.added, updated: syncState.updated, failed: syncState.failed,
+      done: syncState.done, total: syncState.planTotal,
+      steps: syncState.steps,
+    };
+    store.appendSyncLog(entity.key, run);
+    syncState.error = null;
+  } catch (e) {
+    syncState.error = e.message || String(e);
+    syncState.steps.push({ label: '准备', ok: false, error: syncState.error });
+  } finally {
+    syncState.running = false;
+    syncState.current = '';
+    syncState.finishedAt = new Date().toISOString();
+  }
+  return syncSnapshot();
+}
+
+/**
+ * 调 historyOverview 取一个区间的逐日行并归一化。
+ * scopeKey = { chainId, shopListIn };ctx 携带行的归属信息。
+ */
+async function callHistory(scopeKey, ctx, port = DEFAULT_CDP_PORT) {
+  const o = {
+    port,
+    chainId: scopeKey.chainId,
+    shopListIn: scopeKey.shopListIn || '',
+    from: syncState.from,
+    to: syncState.to,
+    dateType: 'self_define',
+  };
+  const r = await kit.statsHistory(o);
+  if (r.risk) throw new Error('触发客户端风控(请稍后在客户端完成验证后重试)');
+  if (r.authFail) throw new Error('登录态失效(ksid 过期),请在商家客户端重新登录');
+  if (r.code != null && r.code !== 0 && String(r.code) !== '0') {
+    throw new Error('接口返回异常 code=' + r.code + (r.msg ? ' ' + r.msg : ''));
+  }
+  return (r.daily || []).map((d) => normalizeRow(d, ctx));
+}
+
+function requestSyncCancel() {
+  if (syncState.running) syncState.cancelRequested = true;
+  return syncSnapshot();
+}
+
+// ---------- 查询 / 汇总 ----------
+
+const SUM_FIELDS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt', 'actOrdCnt', 'actSubsidyAmt'];
+const RATE_FIELDS = ['clkExpRate', 'ordClkRate'];
+
+function summarizeRows(rows) {
+  const s = { days: new Set(), shops: new Set() };
+  for (const f of SUM_FIELDS) s[f] = 0;
+  let rateWeight = { clkExpRate: 0, ordClkRate: 0 };
+  let rateBase = { clkExpRate: 0, ordClkRate: 0 };
+  for (const r of rows) {
+    s.days.add(r.date);
+    if (r.shopId) s.shops.add(r.shopId);
+    for (const f of SUM_FIELDS) s[f] += Number(r[f]) || 0;
+    if (Number(r.expUv) > 0) { rateWeight.clkExpRate += (Number(r.clkExpRate) || 0) * Number(r.expUv); rateBase.clkExpRate += Number(r.expUv); }
+    if (Number(r.orderUserCnt) > 0) { rateWeight.ordClkRate += (Number(r.ordClkRate) || 0) * Number(r.orderUserCnt); rateBase.ordClkRate += Number(r.orderUserCnt); }
+  }
+  s.perActualPay = s.validOrderCnt > 0 ? +(s.grossAmt / s.validOrderCnt).toFixed(2) : 0;
+  for (const f of SUM_FIELDS) s[f] = Math.round(s[f] * 100) / 100;
+  for (const f of RATE_FIELDS) s[f] = rateBase[f] > 0 ? +(rateWeight[f] / rateBase[f]).toFixed(4) : 0;
+  s.dayCount = s.days.size;
+  s.shopCount = s.shops.size;
+  delete s.days;
+  delete s.shops;
+  return s;
+}
+
+function queryReport({ entity, from, to, shopId, shops, scope, q } = {}) {
+  const key = store.sanitizeKey(entity);
+  const meta = store.getEntity(key);
+  let rows = store.queryRows(key, { from, to, scope });
+  if (shopId) rows = rows.filter((r) => String(r.shopId) === String(shopId));
+  if (Array.isArray(shops) && shops.length) {
+    const wanted = new Set(shops.map(String));
+    rows = rows.filter((r) => wanted.has(String(r.shopId)));
+  }
+  if (q) {
+    const needle = String(q).toLowerCase();
+    rows = rows.filter((r) => String(r.shopName || '').toLowerCase().includes(needle));
+  }
+  return {
+    entity: meta ? { key: meta.key, name: meta.name } : { key, name: key },
+    from: from || null, to: to || null,
+    columns: REPORT_COLUMNS,
+    summary: summarizeRows(rows),
+    rows,
+  };
+}
+
+/** BI 聚合视图:group = 'shop'(按门店)| 'date'(按日期) */
+function aggregateReport({ entity, from, to, group = 'shop', shops } = {}) {
+  const key = store.sanitizeKey(entity);
+  let rows = store.queryRows(key, { from, to, scope: 'shop' });
+  if (Array.isArray(shops) && shops.length) {
+    const wanted = new Set(shops.map(String));
+    rows = rows.filter((r) => wanted.has(String(r.shopId)));
+  }
+  const buckets = new Map();
+  for (const r of rows) {
+    const k = group === 'date' ? r.date : String(r.shopId || '');
+    if (!buckets.has(k)) buckets.set(k, []);
+    buckets.get(k).push(r);
+  }
+  const out = [];
+  for (const [k, list] of buckets) {
+    const first = list[0] || {};
+    const base = group === 'date'
+      ? { date: k, shopName: `${list.length} 家门店`, shopId: '', scope: 'agg-date' }
+      : { date: '', shopName: first.shopName || k, shopId: k, scope: 'agg-shop' };
+    const s = summarizeRows(list);
+    out.push({ ...base, ...s });
+  }
+  out.sort((a, b) => (group === 'date' ? (a.date < b.date ? -1 : 1) : (b.grossAmt || 0) - (a.grossAmt || 0)));
+  return { columns: REPORT_COLUMNS, rows: out, summary: summarizeRows(rows) };
+}
+
+// ---------- CSV 导出 ----------
+
+function csvEscape(v) {
+  if (v == null) return '';
+  const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
+  return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
+}
+
+function rowsToCsv(columns, rows) {
+  const head = columns.map((c) => c.label);
+  const lines = [head.join(',')];
+  for (const r of rows) {
+    const cells = columns.map((c) => {
+      let v = r[c.key];
+      if (v == null) v = '';
+      if (c.type === 'pct') v = v === '' ? '' : (Number(v) * 100).toFixed(2) + '%';
+      if (c.type === 'date' && /^\d{8}$/.test(String(v))) {
+        const s = String(v);
+        v = `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
+      }
+      return csvEscape(v);
+    });
+    lines.push(cells.join(','));
+  }
+  return '\ufeff' + lines.join('\r\n');
+}
+
+module.exports = {
+  DEFAULT_CDP_PORT, REPORT_COLUMNS,
+  naposStatus, fetchTree,
+  runSync, syncSnapshot, requestSyncCancel,
+  queryReport, aggregateReport, summarizeRows,
+  rowsToCsv,
+  store,
+};

+ 918 - 0
napos/cdp.js

@@ -0,0 +1,918 @@
+'use strict';
+/**
+ * CDP(Chrome DevTools Protocol)客户端 + 淘宝闪购客户端发现工具。
+ *
+ * 连的是 eleme-napos 的 remote-debugging-port(默认 9669)。
+ *
+ * ⚠️ 核心设计(v0.2 修正):客户端里同时存在多个「子站」页面,各自属于不同的域:
+ *
+ *     · 商家后台   melody.shop.ele.me            —— 订单 / 商品 / 门店 / 营销
+ *     · 数据中心   melody-stats-next.ele.me      —— 生意参谋:流量 / 转化 / 报表
+ *     · 单店看板   napos-dashboard-pc.faas.ele.me
+ *
+ *   一个请求必须「由拥有该域的那个页面」发出。
+ *   若从错误的页面(跨站)发出,服务端会把它判为异常来源 ——
+ *   现象就是**客户端弹鉴权/验证码,接口回「用户未登录」(RGV587)**。
+ *
+ *   所以下面用 APPS 表把「请求域名」映射到「应该由哪个页面发出」,
+ *   并在拿不到正确页面时 **拒发**(抛 NAPOS_WRONG_ORIGIN),而不是硬发。
+ */
+
+const { RawWebSocket } = require('./ws');
+const guard = require('./guard');
+
+const DEFAULT_PORT = 9669;
+const BIZ_HOST_HINT = ['melody.shop.ele.me', 'melody-plus', 'melody-electron'];
+
+/**
+ * 请求子站路由表(顺序敏感:更具体的域放前面)。
+ *   hosts            该子站「拥有」的请求域名(正则,作用于 hostname)
+ *   pageHints        该子站页面 URL 里会出现的特征串,用于在 CDP target 列表里认人
+ *   openUrl          该子站页面的地址;null = 客户端默认页,无需自己开
+ *   authCarrierHosts 需要额外补鉴权头(如 lsycm 的 token)的域名
+ */
+const APPS = [
+  {
+    // ★ 渠道 / 联盟 / 推广 / 金融保险(实测 2026-10-11)
+    //   三个菜单全部是 r.ele.me 下的跨域 iframe:
+    //     闪购联盟 → https://r.ele.me/bs-taoke-pc/            路由 /app/unit/zw-promotion__home
+    //     店铺推广 → https://r.ele.me/doujin-isv-manage/...   路由 /app/unit/vas__dashboard
+    //     金融保险 → https://r.ele.me/alsc-enjoy-before-pay-pc/home?…
+    //   ★ 三类鉴权各不相同,且都**不需要**专属 H5 上下文(这点与账单域完全相反):
+    //     · callFromNapos(app-api.shop.ele.me/…/iOneRoute)→ ksid 在 **body.metas.ksid**
+    //     · 推广余额(agw.alsc.taobao.com/gw/h5api/alsc-ad-account.*)→ ksid 请求头
+    //     · 金融(httpizza.ele.me)→ 仅靠 Cookie(同一浏览器会话)
+    //   实测从商家后台主页面(melody.shop.ele.me)直发即可成功,故 ctxHosts 两个都收。
+    //   ★ 必须排在 shop 之前 —— 否则 shop 的 /ele\.me$/ 会吞掉 r.ele.me。
+    id: 'channel',
+    label: '闪购联盟 / 店铺推广 / 金融保险',
+    hosts: [/(^|\.)r\.ele\.me$/i, /(^|\.)httpizza\.ele\.me$/i],
+    urlHosts: [
+      /iOneRoute\.callFromNapos/i,
+      /alsc-ad-account\./i,
+      /fn-alsc-fin-ops\./i,
+    ],
+    pageHints: ['r.ele.me'],
+    // ★ 顺序敏感:优先用商家后台主页面上下文 —— 实测 r.ele.me 是 sandbox iframe,
+    //   其 window 缺 AbortController,且主页面(有完整 localStorage/cookie)发同样成功。
+    ctxHosts: ['melody.shop.ele.me', 'r.ele.me'],
+    openUrl: null,
+    authStyle: 'napos',
+  },
+  {
+    id: 'stats',
+    label: '数据中心 / 生意参谋',
+    hosts: [
+      /(^|\.)lsycm\.alibaba\.com$/i,
+      /(^|\.)melody-stats-next\.ele\.me$/i,
+      /(^|\.)napos-dashboard-pc\.faas\.ele\.me$/i,
+      /(^|\.)sycm\.taobao\.com$/i,
+    ],
+    pageHints: ['melody-stats-next.ele.me', 'napos-dashboard-pc.faas.ele.me', 'sycm.taobao.com'],
+    // 该子站页面/iframe 自己的 origin(精确后缀,用于挑执行上下文)
+    ctxHosts: ['melody-stats-next.ele.me', 'napos-dashboard-pc.faas.ele.me', 'sycm.taobao.com'],
+    openUrl: 'https://melody-stats-next.ele.me/new/',
+    // 实测:数据中心是嵌在商家后台页里的跨域 iframe,挂在 /app/unit/dashboard 这类路由上。
+    // 子站跑丢时可以把主页面导航回这个路由把它带回来(同源导航,不会触发鉴权)。
+    openPath: process.env.NAPOS_STATS_PATH || '/app/unit/dashboard',
+    authCarrierHosts: [/(^|\.)lsycm\.alibaba\.com$/i],
+  },
+  {
+    // ★ 财务管理(账单 / 结算 / 余额)
+    //   请求打到 agw.alsc.taobao.com(阿里本地生活网关),但它**必须由财务 H5 自己的
+    //   执行上下文发出** —— 从商家后台页跨站发出会被网关判为未登录(111006 UNLOGIN)。
+    //   实测(2026-10-11):宿主页 = https://napos-bill-pc.faas.ele.me(商家后台内的 iframe),
+    //   对应路由 /app/unit/billDetail#app.unit.billDetail。
+    //   鉴权头(实测抓包):ksid / current-shop-id / X-AGW-BIZ-CONTEXT: currentShopId=<unitId>
+    id: 'bill',
+    label: '财务管理(账单 / 结算)',
+    hosts: [
+      /(^|\.)agw\.alsc\.taobao\.com$/i,
+      /(^|\.)napos-bill-pc\.faas\.ele\.me$/i,
+    ],
+    pageHints: ['napos-bill-pc.faas.ele.me'],
+    ctxHosts: ['napos-bill-pc.faas.ele.me'],
+    openUrl: null,
+    openPath: process.env.NAPOS_BILL_PATH || '/app/unit/billDetail',
+    authStyle: 'agw',
+    agw: { currentShopId: '-1', contentType: 'application/json' },
+  },
+  {
+    // ★ 成长中心(产能分层 / 诊断 / 周报 / 任务)
+    //   请求打到 agw.ele.me,宿主页 = https://napos-shop-operation-pc.faas.ele.me,
+    //   对应路由 /app/unit/business-task-new#app.unit.business-task-new。
+    //   实测:成长 H5 的 ksid 存在 cookie(localStorage 里没有),故凭证探测要多来源。
+    id: 'growth',
+    label: '成长中心',
+    hosts: [
+      /(^|\.)agw\.ele\.me$/i,
+      /(^|\.)napos-shop-operation-pc\.faas\.ele\.me$/i,
+    ],
+    pageHints: ['napos-shop-operation-pc.faas.ele.me'],
+    ctxHosts: ['napos-shop-operation-pc.faas.ele.me'],
+    openUrl: null,
+    openPath: process.env.NAPOS_GROWTH_PATH || '/app/unit/business-task-new',
+    authStyle: 'agw',
+    agw: { currentShopId: '', contentType: 'application/json;charset=UTF-8' },
+  },
+  {
+    id: 'shop',
+    label: '商家后台',
+    hosts: [
+      /(^|\.)melody\.shop\.ele\.me$/i,
+      /(^|\.)app-api\.shop\.ele\.me$/i,
+      /(^|\.)napos\.ele\.me$/i,
+      /melody-plus/i,
+      /melody-electron/i,
+      /(^|\.)ele\.me$/i,
+    ],
+    pageHints: ['melody.shop.ele.me', 'melody-plus', 'melody-electron'],
+    ctxHosts: ['melody.shop.ele.me', 'melody-plus', 'melody-electron'],
+    openUrl: null,
+    authCarrierHosts: [],
+  },
+];
+
+const SHOP_APP = APPS.find((a) => a.id === 'shop');
+
+/** fetch 禁止设置的请求头,重放时一律剔除(由浏览器自己填) */
+const FORBIDDEN_REQ_HEADERS = new Set([
+  'host', 'content-length', 'connection', 'origin', 'referer', 'user-agent',
+  'accept-encoding', 'cookie', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-dest',
+  'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'accept-language',
+]);
+
+/** 风控 / 未登录的响应特征 —— 命中即说明这次访问"触发了鉴权" */
+const RISK_RE = /RGV587|FAIL_SYS_USER_VALIDATE|FAIL_SYS_ILLEGAL|punish|captcha|slide/i;
+const AUTH_RE = /用户未登录|未登录|NOT_LOGIN|not.?logined|please.?login|sessions?expired/i;
+
+// ------------------------------------------------------------------ 基础
+
+function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
+
+async function httpJson(url, { method = 'GET', timeout = 5000 } = {}) {
+  const ac = new AbortController();
+  const t = setTimeout(() => ac.abort(), timeout);
+  try {
+    const r = await fetch(url, { method, signal: ac.signal });
+    if (!r.ok) throw new Error(`HTTP ${r.status}`);
+    return await r.json();
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+/** 探测调试端口是否可用 */
+async function probe(port = DEFAULT_PORT) {
+  try {
+    const v = await httpJson(`http://127.0.0.1:${port}/json/version`);
+    return { ok: true, port, version: v };
+  } catch (e) {
+    return { ok: false, port, error: e.message };
+  }
+}
+
+async function listTargets(port = DEFAULT_PORT) {
+  return httpJson(`http://127.0.0.1:${port}/json/list`);
+}
+
+// ------------------------------------------------------------------ 子站路由
+
+/** 从 URL 取 hostname(兼容相对路径) */
+function hostOf(url) {
+  try { return new URL(String(url)).hostname; } catch { /* 相对路径 */ }
+  return String(url).replace(/^\/\//, '').split('/')[0].split(':')[0];
+}
+
+/**
+ * 这个请求地址属于哪个子站?未知返回 null
+ *   ① 先按 hostname 匹配(hosts)
+ *   ② 再按**完整 URL** 匹配(urlHosts)—— 同一个域名下不同业务线走不同宿主/鉴权的场景
+ *      (例:agw.alsc.taobao.com 的「账单」与「推广余额」是两套)
+ */
+function appForUrl(url) {
+  const h = hostOf(url);
+  if (!h) return null;
+  const full = String(url || '');
+  for (const a of APPS) {
+    if (a.hosts.some((re) => re.test(h))) return a;
+    if (a.urlHosts && a.urlHosts.some((re) => re.test(full))) return a;
+  }
+  return null;
+}
+
+/** 把 'stats' / 一个 URL / null 统一解析成 app 对象(未知回退到商家后台) */
+function resolveApp(x) {
+  if (!x) return SHOP_APP;
+  if (typeof x === 'object' && x.id) return x;
+  const s = String(x);
+  const byId = APPS.find((a) => a.id === s);
+  if (byId) return byId;
+  return appForUrl(s) || SHOP_APP;
+}
+
+function rankTarget(t) {
+  const url = t.url || '';
+  let score = 0;
+  if (t.type === 'page') score += 10;
+  if (BIZ_HOST_HINT.some((h) => url.includes(h))) score += 100;
+  if (/^(https?):/.test(url)) score += 5;
+  if (url.startsWith('file://')) score -= 50;
+  if (url.startsWith('devtools://')) score -= 100;
+  return score;
+}
+
+/**
+ * 只有真正的 document 页面才能承载 fetch。
+ * service_worker / iframe / 后台页里没有 window,也没有站点的登录态,
+ * 从那里发请求一律「用户未登录」——必须排除,否则会误判"来源页已就位"。
+ */
+function isPageTarget(t) {
+  return !!t && t.type === 'page';
+}
+
+function targetMatchesApp(t, app) {
+  return isPageTarget(t) && app.pageHints.some((h) => (t.url || '').includes(h));
+}
+
+function rankTargetForApp(t, app) {
+  const url = t.url || '';
+  let score = 0;
+  if (t.type === 'page') score += 10;
+  if (targetMatchesApp(t, app)) score += 1000;
+  else if (app === SHOP_APP && BIZ_HOST_HINT.some((h) => url.includes(h))) score += 100;
+  if (/^(https?):/.test(url)) score += 5;
+  if (url.startsWith('file://')) score -= 50;
+  if (url.startsWith('devtools://')) score -= 500;
+  return score;
+}
+
+/** 找到业务页面 target(= 商家后台页,向后兼容) */
+async function findBusinessTarget(port = DEFAULT_PORT) {
+  const all = await listTargets(port);
+  const usable = all.filter((t) => t.webSocketDebuggerUrl);
+  if (!usable.length) return null;
+  usable.sort((a, b) => rankTarget(b) - rankTarget(a));
+  return usable[0];
+}
+
+/**
+ * 找到「属于某个子站」的 target。
+ * @returns {{target:object, matched:boolean}|null} matched=false 表示没找到匹配页,返回的是兜底页
+ */
+async function findTargetForApp(port, app) {
+  const all = (await listTargets(port)).filter((t) => t.webSocketDebuggerUrl && isPageTarget(t));
+  if (!all.length) return null;
+  const matched = all.filter((t) => targetMatchesApp(t, app));
+  if (matched.length) {
+    matched.sort((a, b) => rankTargetForApp(b, app) - rankTargetForApp(a, app));
+    return { target: matched[0], matched: true };
+  }
+  const pool = all.slice().sort((a, b) => rankTargetForApp(b, app) - rankTargetForApp(a, app));
+  return { target: pool[0], matched: false };
+}
+
+// ------------------------------------------------------------------ CDP
+
+class CDP {
+  constructor(wsUrl) {
+    this.wsUrl = wsUrl;
+    this.ws = null;
+    this.seq = 0;
+    this.pending = new Map();
+    this.handlers = new Map();
+    this.attached = new Set();
+  }
+
+  async open(timeout = 10000) {
+    this.ws = new RawWebSocket(this.wsUrl);
+    this.ws.on('message', (m) => this._onMessage(m));
+    await this.ws.connect(timeout);
+    return this;
+  }
+
+  _onMessage(raw) {
+    let msg;
+    try { msg = JSON.parse(raw); } catch { return; }
+    if (msg.id != null && this.pending.has(msg.id)) {
+      const { resolve, reject, timer } = this.pending.get(msg.id);
+      clearTimeout(timer);
+      this.pending.delete(msg.id);
+      if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`));
+      else resolve(msg.result);
+      return;
+    }
+    if (msg.method) {
+      const list = this.handlers.get(msg.method);
+      if (list) for (const h of list) { try { h(msg.params, msg.sessionId); } catch { /* ignore */ } }
+    }
+  }
+
+  send(method, params = {}, sessionId) {
+    return new Promise((resolve, reject) => {
+      const id = ++this.seq;
+      const payload = { id, method, params };
+      if (sessionId) payload.sessionId = sessionId;
+      const timer = setTimeout(() => {
+        this.pending.delete(id);
+        reject(new Error(`CDP 调用超时: ${method}`));
+      }, 90000);
+      this.pending.set(id, { resolve, reject, timer });
+      this.ws.send(JSON.stringify(payload));
+    });
+  }
+
+  on(method, cb) {
+    if (!this.handlers.has(method)) this.handlers.set(method, []);
+    this.handlers.get(method).push(cb);
+  }
+
+  async attachTo(targetId) {
+    const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true });
+    this.attached.add(sessionId);
+    return sessionId;
+  }
+
+  async detach(sessionId) {
+    try { await this.send('Target.detachFromTarget', { sessionId }); } catch { /* ignore */ }
+    this.attached.delete(sessionId);
+  }
+
+  close() { try { this.ws && this.ws.close(); } catch { /* ignore */ } }
+}
+
+/**
+ * 真的把子站页面开出来(新建一个 target,不动用户正在看的那个页面)。
+ * 这是"正确来源页不存在时就地创建"的能力,用于一次性把数据中心页拉起。
+ */
+async function openAppTarget(port, app) {
+  if (!app || !app.openUrl) return null;
+  const p = await probe(port);
+  if (!p.ok) return null;
+  const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000);
+  try {
+    let res = null;
+    for (const opts of [
+      { url: app.openUrl, newWindow: false, background: true },
+      { url: app.openUrl, newWindow: true },
+      { url: app.openUrl },
+    ]) {
+      try { res = await cdp.send('Target.createTarget', opts); break; } catch { /* 换下一种开法 */ }
+    }
+    return res ? res.targetId : null;
+  } catch {
+    return null;
+  } finally {
+    cdp.close();
+  }
+}
+
+/** 保证拿到「属于该子站」的页面;没有就尝试开一个并等它就绪 */
+async function ensureAppTarget(port, app, { waitMs = 12000 } = {}) {
+  let found = await findTargetForApp(port, app);
+  if (found && found.matched) return found;
+  await openAppTarget(port, app);
+  const deadline = Date.now() + waitMs;
+  while (Date.now() < deadline) {
+    await sleep(700);
+    const f = await findTargetForApp(port, app);
+    if (f && f.matched) return f;
+  }
+  return found;
+}
+
+// ------------------------------------------------------------------ 执行上下文
+
+/**
+ * ★ 核心:子站不一定是一个独立窗口。
+ *   实测「数据中心」就是嵌在商家后台页面里的**跨域 iframe** ——
+ *   在 CDP 里它不产生新 target,只产生一个新的 executionContext。
+ *
+ *   于是"从正确的来源发请求" = 在**域匹配的那个执行上下文里**跑 fetch。
+ *   从外层页面(melody.shop.ele.me)发出去就是跨站请求:服务端判为异常来源 →
+ *   客户端弹鉴权/验证码,或请求直接挂死(既不返回也不报错)。
+ */
+async function collectContexts(cdp, sessionId, ms = 800) {
+  const map = new Map();
+  cdp.on('Runtime.executionContextCreated', (pa) => {
+    const ctx = pa && pa.context;
+    if (!ctx || !ctx.origin || !/^https?:/i.test(ctx.origin)) return;
+    const aux = ctx.auxData || {};
+    if (aux.isDefault === false || aux.type === 'isolated') return; // 跳过 Electron 隔离上下文
+    if (!map.has(ctx.origin)) map.set(ctx.origin, ctx);
+  });
+  try { await cdp.send('Runtime.enable', {}, sessionId); } catch { /* ignore */ }
+  await sleep(ms);
+  return map;
+}
+
+/** 找一个"域属于该请求"的执行上下文(按 ctxHosts 声明顺序,越靠前越优先) */
+function contextForUrl(ctxMap, url) {
+  const app = appForUrl(url);
+  if (!app || !ctxMap) return null;
+  const hints = app.ctxHosts || app.pageHints || [];
+  for (const hint of hints) {
+    for (const [origin, ctx] of ctxMap) {
+      let h;
+      try { h = new URL(origin).hostname; } catch { continue; }
+      if (h === hint || h.endsWith('.' + hint)) return ctx;
+    }
+  }
+  return null;
+}
+
+/**
+ * 某个执行上下文的 origin 是不是"这个子站自己的页面"。
+ * 用精确的 ctxHosts 后缀匹配 —— 不能用 APPS.hosts 那套宽泛正则,
+ * 否则 shop 的 `ele\.me$` 会把 napos-dashboard-pc.faas.ele.me 也吞进去。
+ */
+function ctxMatchesApp(origin, app) {
+  let h;
+  try { h = new URL(origin).hostname; } catch { return false; }
+  const hints = app.ctxHosts || app.pageHints || [];
+  return hints.some((s) => h === s || h.endsWith('.' + s));
+}
+
+/** 该子站现在能不能发请求?(独立页面 target,或页面里存在域匹配的执行上下文) */
+function appUsable(app, found, ctxMap) {
+  if (found && found.matched) return true;
+  for (const origin of (ctxMap ? ctxMap.keys() : [])) {
+    if (ctxMatchesApp(origin, app)) return true;
+  }
+  return false;
+}
+
+function wrongOriginError(app, found, ctxMap) {
+  const ctxs = [...(ctxMap ? ctxMap.keys() : [])];
+  const err = new Error(
+    `🛑 拒绝从错误的来源发请求 —— 这正是"每次调用都触发客户端鉴权"的原因。\n` +
+    `  该请求属于「${app.label}」,必须由这个域自己的页面/上下文发出。\n` +
+    `  当前页面:${(found && found.target && found.target.url) || '-'}\n` +
+    `  可用执行上下文:${ctxs.length ? ctxs.join(' , ') : '(无)'}\n` +
+    `  修复:在客户端打开「${app.label}」页面` +
+    (app.openPath ? `,或让本工具导航过去(napos_call 传 autoOpen=true)` : '') + '。'
+  );
+  err.code = 'NAPOS_WRONG_ORIGIN';
+  err.app = app.id;
+  err.availableContexts = ctxs;
+  return err;
+}
+
+/**
+ * 就地打开子站。
+ *   ① 优先新开一个 target(标准 Chromium 支持;本机 Electron 94 实测返回 "Not supported")
+ *   ② 退回:把商家后台页导航到子站内嵌路由(同源导航,不会触发鉴权)
+ */
+async function tryOpenApp(port, app, fallbackTarget) {
+  const p = await probe(port);
+  if (!p.ok) return false;
+  if (app.openUrl) {
+    const id = await openAppTarget(port, app);
+    if (id) return true;
+  }
+  if (!app.openPath || !fallbackTarget) return false;
+  const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000);
+  try {
+    const sessionId = await cdp.attachTo(fallbackTarget.id);
+    const url = `https://melody.shop.ele.me${app.openPath}`;
+    await cdp.send('Runtime.evaluate', {
+      expression: `location.href = ${JSON.stringify(url)}`,
+      userGesture: true,
+    }, sessionId);
+    await cdp.detach(sessionId).catch(() => {});
+    return true;
+  } catch {
+    return false;
+  } finally {
+    cdp.close();
+  }
+}
+
+/**
+ * 连接「某个子站的页面」并执行 fn(api)。
+ * api = { cdp, sessionId, targetId, target, app, contexts, contextFor, send, on, evaluate }
+ *
+ * @param {*} urlOrApp  URL(按域名自动选)或 app id('stats' / 'shop')
+ * @param {{needExact?:boolean, autoOpen?:boolean, timeout?:number}} opts
+ *        needExact=true 时,若该子站没有可用的页面/上下文 → 抛 NAPOS_WRONG_ORIGIN(拒绝硬发)
+ */
+async function withPageFor(port, urlOrApp, fn, { needExact = false, autoOpen = true, timeout = 15000 } = {}) {
+  const p = await probe(port);
+  if (!p.ok) {
+    throw new Error(
+      `调试端口 ${port} 不可用(${p.error})。\n` +
+      `请先完全退出淘宝闪购客户端,再用 --remote-debugging-port=${port} 重新启动。`
+    );
+  }
+  const app = resolveApp(urlOrApp);
+
+  let lastFound = null;
+  let lastCtx = new Map();
+
+  for (let attempt = 1; attempt <= 3; attempt++) {
+    const found = await findTargetForApp(port, app);
+    if (!found || !found.target) {
+      throw new Error('没有找到可用的页面 target(客户端可能还没加载完业务页面)');
+    }
+    const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(timeout);
+    if (!cdp) throw new Error('CDP 连接失败');
+    const sessionId = await cdp.attachTo(found.target.id);
+    const ctxMap = await collectContexts(cdp, sessionId, attempt === 1 ? 800 : 1500);
+    lastFound = found;
+    lastCtx = ctxMap;
+
+    const usable = appUsable(app, found, ctxMap);
+
+    if (usable || !needExact) {
+      const s = (m, params) => cdp.send(m, params, sessionId);
+      const api = {
+        cdp, sessionId, targetId: found.target.id, target: found.target, app,
+        exact: found.matched, contexts: ctxMap,
+        contextFor: (u) => contextForUrl(ctxMap, u),
+        send: s,
+        on: (m, cb) => cdp.on(m, cb),
+        async evaluate(expression, { awaitPromise = true, returnByValue = true, contextId } = {}) {
+          const params = { expression, awaitPromise, returnByValue, userGesture: true, includeCommandLineAPI: true };
+          if (contextId) params.contextId = contextId;
+          const r = await s('Runtime.evaluate', params);
+          if (r.exceptionDetails) {
+            const d = r.exceptionDetails;
+            throw new Error(`页面内执行出错: ${d.exception?.description || d.text}`);
+          }
+          return r.result?.value;
+        },
+      };
+      try {
+        return await fn(api);
+      } finally {
+        await cdp.detach(sessionId).catch(() => {});
+        cdp.close();
+      }
+    }
+
+    // ❌ 来源不对 → 断开、尝试就地打开、重试
+    await cdp.detach(sessionId).catch(() => {});
+    cdp.close();
+    if (!autoOpen || attempt === 3) break;
+    const opened = await tryOpenApp(port, app, found.target);
+    if (!opened) break;
+    await sleep(2500);
+  }
+
+  throw wrongOriginError(app, lastFound, lastCtx);
+}
+
+/** 向后兼容:等价于「连商家后台页」 */
+async function withBusinessPage(port, fn, opts = {}) {
+  return withPageFor(port, SHOP_APP, fn, { ...opts, needExact: false });
+}
+
+// ------------------------------------------------------------------ 鉴权头
+
+/**
+ * 在页面里装一个「请求头采集器」:把该页面对目标域名发出的真实请求头记下来。
+ * 页面自己发出的请求一定带齐了服务端要的鉴权(token / bx-et …),
+ * 我们照抄即可 —— 比猜参数可靠得多。
+ */
+function captureInstallCode(host) {
+  return `(() => {
+  try {
+    if (!window.__naposCap || !window.__naposCap.on) {
+      const cap = { on: true, seen: {} };
+      window.__naposCap = cap;
+      const TARGET = ${JSON.stringify(String(host))};
+      const isT = (u) => { try { return String(u || '').indexOf(TARGET) !== -1; } catch (e) { return false; } };
+      const norm = (h) => {
+        const o = {};
+        try {
+          if (h && typeof h.forEach === 'function') h.forEach((v, k) => { o[String(k)] = String(v); });
+          else if (h && typeof h === 'object') Object.keys(h).forEach((k) => { o[String(k)] = String(h[k]); });
+        } catch (e) {}
+        return o;
+      };
+      const rec = (u, h) => {
+        try {
+          if (!isT(u)) return;
+          const key = String(u).split('?')[0];
+          if (!cap.seen[key]) cap.seen[key] = { h: norm(h), url: String(u), t: Date.now() };
+        } catch (e) {}
+      };
+      const of = window.fetch;
+      if (of) {
+        window.fetch = function (input, init) {
+          try { rec(typeof input === 'string' ? input : (input && input.url), (init && init.headers) || (input && input.headers)); } catch (e) {}
+          return of.apply(this, arguments);
+        };
+      }
+      const XHR = window.XMLHttpRequest;
+      if (XHR && XHR.prototype) {
+        const ox = XHR.prototype.open, os = XHR.prototype.send, oh = XHR.prototype.setRequestHeader;
+        XHR.prototype.setRequestHeader = function (k, v) {
+          try { this.__nh = this.__nh || {}; this.__nh[k] = v; } catch (e) {}
+          return oh.apply(this, arguments);
+        };
+        XHR.prototype.open = function (m, u) { try { this.__nu = u; } catch (e) {} return ox.apply(this, arguments); };
+        XHR.prototype.send = function () {
+          try { rec(this.__nu, this.__nh); } catch (e) {}
+          return os.apply(this, arguments);
+        };
+      }
+    }
+    return Object.keys(window.__naposCap.seen || {});
+  } catch (e) { return []; }
+})()`;
+}
+
+function captureReadCode() {
+  return `(() => {
+  try {
+    const cap = window.__naposCap;
+    if (!cap || !cap.seen) return null;
+    const keys = Object.keys(cap.seen);
+    if (!keys.length) return null;
+    const merged = {};
+    for (const k of keys) Object.assign(merged, (cap.seen[k] || {}).h || {});
+    return merged;
+  } catch (e) { return null; }
+})()`;
+}
+
+/**
+ * 兜底:找 ksid。
+ * ⚠️ 关键:ksid 往往只存在于**外层页面**的 localStorage / Cookie,
+ *    子站 iframe 自己那份可能没有 —— 所以要在所有上下文里挨个找。
+ * 抓包实证:真实请求带的鉴权头就是 `token: <ksid>=-1`。
+ */
+const KSID_PROBE = `(() => {
+  const out = { ksid: null, source: null };
+  const g = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
+  const s = (k) => { try { return sessionStorage.getItem(k); } catch (e) { return null; } };
+  for (const k of ['ksid','KSID','ksid_chain','napos_ksid','chainKsid','_ksid']) {
+    const v = g(k) || s(k);
+    if (v && String(v).length > 8) { out.ksid = String(v); out.source = 'storage:' + k; return out; }
+  }
+  try {
+    const m = (document.cookie || '').match(/(?:^|;\\s*)ksid=([^;]+)/);
+    if (m && m[1] && m[1].length > 8) { out.ksid = decodeURIComponent(m[1]); out.source = 'cookie'; return out; }
+  } catch (e) {}
+  return out;
+})()`;
+
+/**
+ * ★ agw 网关(财务 / 成长)的凭证探测。
+ *   实测:两个 H5 子站的登录态都是同一枚 ksid,但**存放位置不同**
+ *   (财务在 localStorage.ksid,成长只在 cookie.ksid),
+ *   所以这里按 localStorage → sessionStorage → cookie 依次兜底。
+ *   同时取 unitId 用于拼业务上下文头 `X-AGW-BIZ-CONTEXT: currentShopId=<unitId>`。
+ */
+const AGW_CRED_PROBE = `(() => {
+  try {
+    const g = (s, k) => { try { return s.getItem(k); } catch (e) { return null; } };
+    const ck = (k) => { try { const m = (document.cookie || '').match(new RegExp('(?:^|;\\\\s*)' + k + '=([^;]+)')); return m ? m[1] : null; } catch (e) { return null; } };
+    const ksid = g(localStorage, 'ksid') || g(sessionStorage, 'ksid') || ck('ksid');
+    const unitId = g(localStorage, 'unitId') || g(sessionStorage, 'unitId') || ck('unitId');
+    const shopId = g(localStorage, 'shopId') || g(sessionStorage, 'shopId') || ck('shopId');
+    return { ksid: ksid ? String(ksid) : null, unitId: unitId || null, shopId: shopId || null };
+  } catch (e) { return null; }
+})()`;
+
+const CHANNEL_ID_PROBE = `(() => {
+  try {
+    const g = (s, k) => { try { return s.getItem(k); } catch (e) { return null; } };
+    const ck = (k) => { try { const m = (document.cookie || '').match(new RegExp('(?:^|;\\\\s*)' + k + '=([^;]+)')); return m ? m[1] : null; } catch (e) { return null; } };
+    const one = (k) => g(localStorage, k) || g(sessionStorage, k) || ck(k);
+    return {
+      ksid: one('ksid') || null,
+      unitId: one('unitId') || null,
+      keeperId: one('keeperId') || null,
+      loginShopId: one('loginShopId') || null,
+      shopName: g(localStorage, 'shopName') || null,
+    };
+  } catch (e) { return null; }
+})()`;
+
+/** 在所有可用的执行上下文(含外层页面)里找 ksid */
+async function findKsid(api) {
+  const ids = [];
+  if (api && api.contexts && api.contexts.size) for (const c of api.contexts.values()) ids.push(c.id);
+  ids.push(undefined); // 再加一次默认上下文兜底
+  for (const contextId of ids) {
+    try {
+      const v = await api.evaluate(KSID_PROBE, contextId ? { contextId } : undefined);
+      if (v && v.ksid) return v.ksid;
+    } catch { /* 换下一个上下文 */ }
+  }
+  return null;
+}
+
+/**
+ * 为一个跨域数据接口凑齐鉴权头。
+ *   ① 照抄「该子站自己发出的真实请求头」—— 最准
+ *   ② 兜底:跨上下文找 ksid,按 `token: <ksid>=-1` 拼出来(格式来自真实抓包)
+ */
+async function buildAuthHeaders(api, url) {
+  const app = appForUrl(url);
+  if (!app) return {};
+
+  // ★ 渠道风格(闪购联盟 / 店铺推广 / 金融保险)
+  //   鉴权分两种,**按域名区分**(实测血泪):
+  //     · agw 网关(推广余额)→ ksid 请求头
+  //     · app-api.shop.ele.me 的 callFromNapos → **不能带 ksid 头**!它只允许 content-type
+  //       (CORS 白名单里没有 ksid),ksid 必须放进 body.metas(由 kit 层注入);
+  //     · httpizza.ele.me(金融)→ 只靠 Cookie,无需任何自定义头。
+  if (app.authStyle === 'napos') {
+    const host = hostOf(url);
+    const out = { Accept: 'application/json, text/plain, */*' };
+    if (/(^|\.)agw\./i.test(host)) {
+      const ksid = await findKsid(api);
+      if (ksid) out.ksid = String(ksid);
+    }
+    return out;
+  }
+
+  // ★ agw 风格(财务管理 / 成长中心)
+  //   与 lsycm 的 `token: <ksid>=-1` 完全不同 —— 它要的是
+  //     ksid: <H5 自己的 ksid>
+  //     current-shop-id: <-1 | ''>
+  //     X-AGW-BIZ-CONTEXT: currentShopId=<unitId>
+  //   凭证一律「在该子站自己的执行上下文里」运行时读取,不硬编码。
+  if (app.authStyle === 'agw') {
+    const ctx = typeof api.contextFor === 'function' ? api.contextFor(url) : null;
+    const evalOpt = ctx ? { contextId: ctx.id } : undefined;
+    let cred = null;
+    try { cred = await api.evaluate(AGW_CRED_PROBE, evalOpt); } catch { /* ignore */ }
+    if (!cred || !cred.ksid) return {};
+    const out = {
+      ksid: String(cred.ksid),
+      'current-shop-id': (app.agw && app.agw.currentShopId != null) ? String(app.agw.currentShopId) : '',
+    };
+    const unit = cred.unitId || cred.shopId;
+    if (unit) out['X-AGW-BIZ-CONTEXT'] = 'currentShopId=' + unit;
+    if (app.agw && app.agw.contentType) out['Content-Type'] = app.agw.contentType;
+    out.Accept = 'application/json, text/plain, */*';
+    return out;
+  }
+
+  if (!app.authCarrierHosts || !app.authCarrierHosts.length) return {};
+  const host = hostOf(url);
+  if (!app.authCarrierHosts.some((re) => re.test(host))) return {};
+
+  // ★ 必须在「属于该域的上下文」里采集 —— 外层页面的请求头跟子站不是一套
+  const ctx = typeof api.contextFor === 'function' ? api.contextFor(url) : null;
+  const evalOpt = ctx ? { contextId: ctx.id } : undefined;
+
+  let captured = null;
+  try { await api.evaluate(captureInstallCode(host), evalOpt); } catch { /* 忽略 */ }
+  try { captured = await api.evaluate(captureReadCode(), evalOpt); } catch { /* 忽略 */ }
+
+  const out = {};
+  if (captured && typeof captured === 'object') {
+    for (const [k, v] of Object.entries(captured)) {
+      const lk = String(k).toLowerCase();
+      if (FORBIDDEN_REQ_HEADERS.has(lk)) continue;
+      if (lk === 'content-type' || lk === 'accept') continue; // 由调用方决定
+      out[k] = String(v);
+    }
+  }
+  const hasToken = Object.keys(out).some((k) => k.toLowerCase() === 'token');
+  if (!hasToken) {
+    const ksid = await findKsid(api);
+    if (ksid) out.token = `${ksid}=-1`;
+  }
+  return out;
+}
+
+// ------------------------------------------------------------------ 请求节流
+
+let _chain = Promise.resolve();
+let _lastReqAt = 0;
+const MIN_GAP_MS = Number(process.env.NAPOS_MIN_GAP_MS || 300);
+
+/** 串行 + 最小间隔:短时间高频请求本身就是触发风控的主因之一 */
+function throttle() {
+  const p = _chain.then(async () => {
+    const wait = _lastReqAt + MIN_GAP_MS - Date.now();
+    if (wait > 0) await sleep(wait);
+    _lastReqAt = Date.now();
+  });
+  _chain = p.catch(() => {});
+  return p;
+}
+
+// ------------------------------------------------------------------ 发请求
+
+/**
+ * 刷新请求体里的一次性 id。
+ *
+ * 后台的 RPC 调用普遍带 `"id": "<32位hex>|<毫秒时间戳>"`,
+ * 服务端会校验/去重,直接重放旧值可能返回 SERVER_ERROR。
+ * 这里把符合该模式的 id 重新生成。
+ */
+function refreshRequestId(body) {
+  if (typeof body !== 'string') return body;
+  const t = body.trim();
+  if (!t || t[0] !== '{') return body;
+  let obj;
+  try { obj = JSON.parse(t); } catch { return body; }
+  let changed = false;
+  const REGEN = /^[0-9a-f]{32}\|\d+$/i;
+  const walk = (v) => {
+    if (Array.isArray(v)) { v.forEach(walk); return; }
+    if (!v || typeof v !== 'object') return;
+    for (const k of Object.keys(v)) {
+      if (k === 'id' && typeof v[k] === 'string' && REGEN.test(v[k])) {
+        const hex = Array.from({ length: 32 }, () => '0123456789ABCDEF'[Math.floor(Math.random() * 16)]).join('');
+        v[k] = `${hex}|${Date.now()}`;
+        changed = true;
+      } else walk(v[k]);
+    }
+  };
+  walk(obj);
+  return changed ? JSON.stringify(obj) : body;
+}
+
+/**
+ * 用业务页面的身份发一次请求。
+ *
+ * v0.2 修正(解决"每次调用都触发客户端鉴权"):
+ *   · 执行上下文路由         —— 在**域匹配的那个 iframe 上下文**里发请求(关键修复)。
+ *                              子站是跨域 iframe,从外层页面发就是跨站 → 鉴权/风控
+ *   · credentials: 'include' —— 跨子域时才会带 Cookie(原实现 same-origin,等于一个都不带)
+ *   · 自动补鉴权头           —— 从子站自己的真实请求里抄 token / bx-et
+ *   · 请求串行 + 最小间隔    —— 避免高频请求触发风控
+ *   · 请求超时               —— 被风控挂死时快速失败,不再卡 90 秒
+ *   · 识别风控/未登录响应    —— 用 risk / authFail 字段明确回报,不再假装成功
+ *
+ * 🔒 写操作防护:所有出站请求都必须先过 guard(默认只放行读接口)。
+ */
+async function pageFetch(api, {
+  method = 'GET', url, body, headers = {}, raw = false,
+  allowWrite, guardIndex, authHeaders = true, timeoutMs,
+}) {
+  const M = String(method || 'GET').toUpperCase();
+
+  // ---- 写操作防护(唯一出口,拦在这里就够了)
+  guard.assertAllowed({ method: M, url, body }, { index: guardIndex, allowWrite });
+
+  await throttle();
+
+  const extra = authHeaders === false ? {} : await buildAuthHeaders(api, url);
+
+  const payload = {
+    method: M,
+    credentials: 'include',
+    headers: { 'Content-Type': 'application/json', ...extra, ...headers },
+  };
+  if (body != null && M !== 'GET' && M !== 'HEAD') {
+    payload.body = typeof body === 'string' ? refreshRequestId(body) : JSON.stringify(body);
+  }
+
+  // ★ 关键:把请求放进"拥有该域的那个上下文"里执行
+  const wantApp = appForUrl(url);
+  const ctx = (typeof api.contextFor === 'function') ? api.contextFor(url) : null;
+  const originMismatch = !!(wantApp && wantApp !== SHOP_APP && !ctx);
+  const pageUrl = (api && api.target && api.target.url) || '';
+  const ms = Number(timeoutMs || process.env.NAPOS_REQ_TIMEOUT_MS || 30000);
+
+  const code = `
+(async () => {
+  const AC = (typeof AbortController !== 'undefined') ? AbortController : null;
+  const ctl = AC ? new AC() : null;
+  const timer = ctl ? setTimeout(() => ctl.abort(), ${ms}) : null;
+  try {
+    const res = await fetch(${JSON.stringify(url)}, Object.assign(${JSON.stringify(payload)}, ctl ? { signal: ctl.signal } : {}));
+    const text = await res.text();
+    let data;
+    let parsed = true;
+    try { data = JSON.parse(text); } catch (e) { parsed = false; data = text.slice(0, 200000); }
+    if (timer) clearTimeout(timer);
+    return { ok: res.ok, status: res.status, url: res.url, parsed, data, pageUrl: location.href, origin: location.origin };
+  } catch (e) {
+    if (timer) clearTimeout(timer);
+    return { ok: false, timeout: String(e && e.name) === 'AbortError', error: String((e && e.message) || e), pageUrl: location.href, origin: location.origin };
+  }
+})()
+`;
+  const out = await api.evaluate(code, ctx ? { contextId: ctx.id } : undefined);
+  if (out && typeof out === 'object') {
+    const probeText = typeof out.data === 'string' ? out.data : JSON.stringify(out.data || '');
+    out.risk = RISK_RE.test(probeText);
+    out.authFail = AUTH_RE.test(probeText);
+    out.pageUrl = out.pageUrl || pageUrl;
+    out.contextOrigin = ctx ? ctx.origin : null;
+    if (originMismatch) out.originMismatch = true;
+  }
+  return out;
+}
+
+module.exports = {
+  DEFAULT_PORT, BIZ_HOST_HINT, APPS, FORBIDDEN_REQ_HEADERS,
+  httpJson, sleep, probe, listTargets,
+  hostOf, appForUrl, resolveApp, contextForUrl, appUsable, ctxMatchesApp,
+  findBusinessTarget, findTargetForApp, ensureAppTarget, openAppTarget, tryOpenApp,
+  rankTarget, rankTargetForApp, targetMatchesApp, isPageTarget,
+  CDP, withBusinessPage, withPageFor, pageFetch, refreshRequestId,
+  buildAuthHeaders, throttle,
+  // ★ 供 skill / kit.js 运行时读取登录态用(跨执行上下文查找,不硬编码)
+  findKsid, KSID_PROBE, AGW_CRED_PROBE, CHANNEL_ID_PROBE,
+};

+ 393 - 0
napos/classify.js

@@ -0,0 +1,393 @@
+'use strict';
+/**
+ * 把抓包流水(traffic.jsonl)归纳成接口目录(apis.json)。
+ *
+ * 淘宝闪购后台有个特点:**大量调用走通用 RPC 网关**,真实方法名不在 URL 路径里,
+ * 而在 query 的 method= 或请求体的 service/method 字段里。例如:
+ *
+ *   POST /nevermore.goods/invoke?method=FoodService2.searchRecycleBinView
+ *        body = { service:'FoodService2', method:'searchRecycleBinView', params:{...} }
+ *   POST /xtop/xtop.vas.gateway.iOneRoute.callFromNapos/1.0?service=shopService&method=queryShopAuthority
+ *        body = { metas:{...}, params:{ from,to,service,method,params:[...],reqId } }
+ *
+ * 不把这些拆开,122 个接口会被压成几十个无意义的同名 URL。
+ * 本模块负责拆开、抽参数、推结构、判读写。
+ */
+
+// ------------------------------------------------------------------ 噪声识别
+
+const NOISE_HOST_SUFFIX = [
+  '.alicdn.com', '.elemecdn.com', '.aliapp.org', '.alipay.com', '.1688.com',
+  '.koubei.com', '.alibaba-inc.com', '.taobao.com', '.mmstat.com',
+  '.oss-accelerate.aliyuncs.com', '.oss-cn-zhangjiakou.aliyuncs.com',
+];
+/** 例外:同后缀里其实是有用数据的 */
+const NOISE_HOST_EXCEPT = [
+  'lsycm.alibaba.com',          // 生意参谋/经营数据
+  'xux-web-config.oss-accelerate.aliyuncs.com', // 前端配置
+];
+
+const NOISE_PATH = [
+  /\/arms\.\d/, /\/beacon/, /\/report\b/, /\/track\b/, /\.gif$/, /\.png$/, /\.jpg$/,
+  /\/mtb\//, /\/ynuf/, /\/mdap/, /\/druid/, /\.js$/, /\.css$/, /\.woff2?$/,
+];
+
+function isNoise(url) {
+  let u;
+  try { u = new URL(url); } catch { return true; }
+  const host = u.hostname;
+  const excepted = NOISE_HOST_EXCEPT.some((h) => host === h || host.endsWith('.' + h));
+  if (!excepted) {
+    const base = NOISE_HOST_SUFFIX.find((s) => host.endsWith(s));
+    // .taobao.com 下的 gw/h5api 是真业务网关(agw.alsc.taobao.com)
+    if (base === '.taobao.com') {
+      if (!/\/gw\//.test(u.pathname)) return true;
+    } else if (base) return true;
+  }
+  return NOISE_PATH.some((r) => r.test(u.pathname));
+}
+
+// ------------------------------------------------------------------ 路径模板
+
+function templatePath(pathname) {
+  return pathname
+    .split('/')
+    .map((seg) => {
+      if (!seg) return seg;
+      if (/^\d{4}-\d{2}-\d{2}/.test(seg)) return '{date}';
+      if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return '{uuid}';
+      if (/^[0-9a-f]{16,}$/i.test(seg)) return '{hash}';
+      if (/^\d{4,}$/.test(seg)) return '{id}';
+      return seg;
+    })
+    .join('/');
+}
+
+// ------------------------------------------------------------------ 类型草图
+
+function typeOf(v) {
+  if (v === null) return 'null';
+  if (Array.isArray(v)) return 'array';
+  return typeof v;
+}
+
+function shape(v, depth = 0) {
+  if (depth >= 4) return typeOf(v);
+  if (v === null || v === undefined) return 'null';
+  if (Array.isArray(v)) return v.length ? [shape(v[0], depth + 1)] : [];
+  if (typeof v === 'object') {
+    const o = {};
+    for (const k of Object.keys(v).slice(0, 40)) o[k] = shape(v[k], depth + 1);
+    return o;
+  }
+  return typeof v;
+}
+
+function tryParseJson(s) {
+  if (typeof s !== 'string') return undefined;
+  const t = s.trim();
+  if (!t || (t[0] !== '{' && t[0] !== '[')) return undefined;
+  try { return JSON.parse(t); } catch { return undefined; }
+}
+
+/** 收集对象里所有叶子字段路径(深度受限),用于"这个接口要传什么" */
+function leafKeys(v, prefix = '', out = [], depth = 0) {
+  if (depth > 3 || v === null || v === undefined) return out;
+  if (Array.isArray(v)) {
+    if (v.length) leafKeys(v[0], prefix + '[]', out, depth + 1);
+    return out;
+  }
+  if (typeof v === 'object') {
+    for (const k of Object.keys(v)) leafKeys(v[k], prefix ? `${prefix}.${k}` : k, out, depth + 1);
+    return out;
+  }
+  if (prefix) out.push(prefix);
+  return out;
+}
+
+// ------------------------------------------------------------------ 读写判定
+
+// 注意:这两张词表被 guard.js 复用,改这里等于改写操作防护策略。
+//
+// 用**词元精确匹配**而不是正则子串匹配,因为子串会大面积撞词:
+//   marketingActivity… 里的 mark → 误判成"标记"
+//   shopSettings…      里的 set  → 误判成"设置"
+//   getTaskCompleteRate 里的 complete → 误判成"完成操作"
+// 切成 camelCase 词元后,"marketing" ≠ "mark"、"settings" ≠ "set"、"completeRate" 里的
+// "Complete" 虽然等于 "complete",但它前面已经先命中了 "get",按**先出现者为准**判为读。
+//
+// 为什么"先出现者为准":这类接口的动作动词几乎都在方法名最前面
+// (getXxx / queryXxx / createXxx / saveXxx…),后面的多是被操作的业务名词。
+const READ_WORDS = new Set([
+  'get', 'query', 'list', 'find', 'search', 'page', 'count', 'stat', 'stats', 'statistics',
+  'is', 'has', 'exists', 'exist', 'check', 'valid', 'detail', 'details', 'info',
+  'view', 'overview', 'gray', 'tree', 'fetch', 'load', 'read', 'preview',
+  'export', 'download', 'calc', 'calculate', 'estimate', 'summary', 'insight',
+  'trend', 'rank', 'trace', 'config', 'report', 'analysis', 'analyze', 'analyse',
+  'dashboard', 'filter',
+]);
+// 刻意不收 'permission' / 'authority' / 'total':
+//   userPermissionChange 这类名字里,动作词在后面,把名词当读词会导致**漏拦写操作**。
+//   而带这些名词的读接口(getBrandVersionPermission、queryShopAuthority)本来就有 get/query 打头。
+
+const WRITE_WORDS = new Set([
+  'create', 'update', 'delete', 'remove', 'save', 'submit', 'set', 'modify', 'add', 'edit',
+  'cancel', 'confirm', 'publish', 'unbind', 'bind', 'enable', 'disable', 'close', 'open',
+  'import', 'upload', 'agree', 'reject', 'refund', 'audit', 'apply', 'batch',
+  'start', 'stop', 'push', 'send', 'reply', 'mark', 'offline', 'online',
+  'transfer', 'revoke', 'grant', 'incr', 'decr', 'deduct', 'pay', 'sign',
+  'clear', 'reset', 'finish', 'withdraw', 'freeze', 'sync', 'move', 'sort', 'arrange',
+  'copy', 'clone', 'invalid', 'expire', 'lock', 'unlock', 'change', 'switch', 'join', 'quit',
+]);
+
+/**
+ * 把标识符切成小写词元。
+ *   saveShopInfo            → ['save','shop','info']
+ *   marketingActivityEffect → ['marketing','activity','effect']
+ *   IGwShopSummary          → ['i','gw','shop','summary']
+ *   mtop….user.set          → [...,'user','set']
+ *   h5api                   → ['h','5api']
+ */
+function wordsOf(text) {
+  const out = [];
+  for (const part of String(text || '').split(/[._\-\/\\\s]+/)) {
+    if (!part) continue;
+    for (const w of part.split(/(?=[A-Z])|(?<=[a-z])(?=\d)/)) {
+      if (w) out.push(w.toLowerCase());
+    }
+  }
+  return out;
+}
+
+/** 按"先出现者为准"判定;读词与写词都有可能命中时,取位置靠前的那个 */
+function detectKind(text) {
+  for (const w of wordsOf(text)) {
+    if (READ_WORDS.has(w)) return { kind: 'read', word: w };
+    if (WRITE_WORDS.has(w)) return { kind: 'write', word: w };
+  }
+  return null;
+}
+
+/** 取方法名最后一段,例如 ShopSummaryQueryFacade.queryChainSettleListByPage → queryChainSettleListByPage */
+function lastSegment(name) {
+  if (!name) return '';
+  const parts = String(name).split('.');
+  return parts[parts.length - 1];
+}
+
+/**
+ * 从 URL 路径里挖方法名。
+ *
+ * 大量接口的方法名直接写在路径里,形如:
+ *   /xtop/xtop.napos.keeper.shop.queryShopTree/1.0
+ *   /gw/h5api/alsc-ad-account.IGwShopSummaryCouponService.offlineExistsCouponActivity
+ * 最后一段往往是版本号(1.0),所以不能只看 path 末尾。
+ */
+function methodFromPath(pathTemplate) {
+  for (const s of String(pathTemplate || '').split('/').filter(Boolean)) {
+    if (!s.includes('.')) continue;
+    if (/^\d+(\.\d+)*$/.test(s)) continue;          // 版本号段 1.0 / 2.1
+    const cand = s.split('.').pop();
+    if (cand) return cand;
+  }
+  return '';
+}
+
+/**
+ * 判定接口是读还是写。
+ * @returns {{kind:'read'|'write'|'unknown', source:string}}
+ *   unknown = 拿不到任何动作词且不是 GET,无法确认 → 由防护层保守拦截
+ */
+function inferKind(httpMethod, apiMethod, pathTemplate) {
+  // 1) body / query 里的真实方法名,最可信
+  if (apiMethod) {
+    const d = detectKind(lastSegment(apiMethod));
+    if (d) return { kind: d.kind, source: 'name' };
+  }
+  // 2) 路径里自带的方法名(xtop / gw 风格)
+  const pm = methodFromPath(pathTemplate);
+  if (pm) {
+    const d = detectKind(pm);
+    if (d) return { kind: d.kind, source: 'pathMethod' };
+  }
+  // 3) 整个路径的词元(REST 纯名词路径,如 /api/customer/chain/general/chainPrvtOverview)
+  const d = detectKind(pathTemplate);
+  if (d) return { kind: d.kind, source: 'path' };
+
+  return { kind: httpMethod === 'GET' ? 'read' : 'unknown', source: 'http' };
+}
+
+// ------------------------------------------------------------------ 概述
+
+const GENERIC_INVOKER = /\/invoke\/?$|invoke\/?$|iOneRoute|h5api|callFromNapos|nevermore|xtop\//i;
+
+function buildCatalog(records) {
+  const map = new Map();
+  const hosts = {};
+
+  for (const r of records) {
+    if (!r || !r.url) continue;
+    let u;
+    try { u = new URL(r.url); } catch { continue; }
+    if (!/^https?:/.test(u.protocol)) continue;
+
+    const host = u.hostname;
+    hosts[host] = (hosts[host] || 0) + 1;
+
+    const pt = templatePath(u.pathname);
+    const body = tryParseJson(r.postData);
+
+    // ---- 拆出真实 RPC 方法名
+    let apiService = '';
+    let apiMethod = '';
+    if (body && typeof body === 'object' && !Array.isArray(body)) {
+      if (typeof body.service === 'string') apiService = body.service;
+      if (typeof body.method === 'string') apiMethod = body.method;
+      // iOneRoute:真名在 body.params 里
+      const inner = body.params && !Array.isArray(body.params) ? body.params : null;
+      if (inner && typeof inner.method === 'string') {
+        apiMethod = inner.method;
+        if (typeof inner.service === 'string' && !apiService) apiService = inner.service;
+      }
+    }
+    if (!apiMethod) {
+      const qm = u.searchParams.get('method');
+      if (qm) {
+        if (qm.includes('.') && !apiService) {
+          const i = qm.lastIndexOf('.');
+          apiService = qm.slice(0, i);
+          apiMethod = qm.slice(i + 1);
+        } else apiMethod = qm;
+      }
+    }
+    if (!apiService) {
+      const qs = u.searchParams.get('service');
+      if (qs) apiService = qs;
+    }
+
+    const isGeneric = GENERIC_INVOKER.test(u.pathname);
+    const qualified = apiMethod ? (apiService ? `${apiService}.${apiMethod}` : apiMethod) : '';
+    // 只有通用网关才把方法名并进 key;普通 REST 路径本身就是唯一标识
+    const key = qualified && isGeneric
+      ? `${r.method || 'GET'} ${host} ${pt} :: ${qualified}`
+      : `${r.method || 'GET'} ${host} ${pt}`;
+
+    let e = map.get(key);
+    if (!e) {
+      e = {
+        key,
+        method: r.method || 'GET',
+        host,
+        pathTemplate: pt,
+        scheme: u.protocol.replace(':', ''),
+        genericInvoker: isGeneric || undefined,
+        service: apiService || undefined,
+        rpcMethod: apiMethod || undefined,
+        count: 0,
+        statuses: {},
+        queryKeys: [],
+        bodyKeys: [],
+        paramKeys: [],
+        paramShape: undefined,
+        shopIds: [],
+        mimeTypes: [],
+        pages: [],
+        noise: isNoise(r.url),
+        sampleUrl: r.url,
+        // 请求体样本(截断保存):写操作防护需要它来提取通用网关的真实方法名
+        sampleBody: r.postData ? String(r.postData).slice(0, 2000) : undefined,
+      };
+      map.set(key, e);
+    }
+
+    e.count++;
+    if (r.status != null) e.statuses[r.status] = (e.statuses[r.status] || 0) + 1;
+    if (r.mimeType && !e.mimeTypes.includes(r.mimeType)) e.mimeTypes.push(r.mimeType);
+    if (r.page && !e.pages.includes(r.page)) e.pages.push(r.page);
+
+    for (const k of u.searchParams.keys()) {
+      if (k === '_' || k === 't') continue;
+      if (!e.queryKeys.includes(k)) e.queryKeys.push(k);
+    }
+
+    if (body && typeof body === 'object' && !Array.isArray(body)) {
+      for (const k of Object.keys(body)) if (!e.bodyKeys.includes(k)) e.bodyKeys.push(k);
+      const p = body.params;
+      if (p !== undefined) {
+        if (!e.paramShape) e.paramShape = shape(p);
+        for (const leaf of leafKeys(p)) {
+          if (!e.paramKeys.includes(leaf) && e.paramKeys.length < 40) e.paramKeys.push(leaf);
+        }
+      }
+      for (const sid of [body.metas && body.metas.shopId, body.shopId].filter((x) => x != null)) {
+        if (!e.shopIds.includes(sid)) e.shopIds.push(sid);
+      }
+    } else if (r.postData && !e.bodyKeys.length) {
+      e.bodyKeys.push('<form-or-raw>');
+    }
+
+    if (!e.responseShape || e.responseShape === undefined) {
+      if (r.body) {
+        const bj = tryParseJson(r.body);
+        e.responseShape = bj !== undefined ? shape(bj) : `<${r.mimeType || 'raw'} ${String(r.body).length}B>`;
+        e.responseSample = String(r.body).slice(0, 1500);
+      }
+    }
+  }
+
+  const endpoints = [...map.values()];
+  for (const e of endpoints) {
+    const k = inferKind(e.method, e.rpcMethod, e.pathTemplate);
+    e.kind = k.kind;
+    e.kindSource = k.source;
+    e.statuses = Object.keys(e.statuses).map(Number).sort();
+    e.business = !e.noise;
+  }
+
+  endpoints.sort((a, b) => {
+    if (a.business !== b.business) return a.business ? -1 : 1;
+    if (a.host !== b.host) return a.host.localeCompare(b.host);
+    return b.count - a.count;
+  });
+
+  const biz = endpoints.filter((e) => e.business);
+
+  // 按业务域归组,方便看"这个后台有哪些能力"
+  const groups = {};
+  const GROUP_RULES = [
+    [/order|weborder|fulfill|delivery|trace|afterSale/i, '订单履约'],
+    [/goods|item|FoodService|menu|MenuService|spu|sku|deco/i, '商品/门店装修'],
+    [/shop|keeper|unit|chain|brand|ShopService|ShopSetting|authority/i, '店铺/账号'],
+    [/marketing|activity|coupon|promotion|ad-|advert|balance|bill|fin|settle|account/i, '营销/财务'],
+    [/stat|data|report|dashboard|analy|ycm|biz|metric/i, '数据/报表'],
+    [/customer|user|member|comment|review|rating/i, '顾客/评价'],
+    [/gray|config|permission|isInGray|framework/i, '灰度/配置'],
+  ];
+  for (const e of biz) {
+    const hay = `${e.host} ${e.pathTemplate} ${e.rpcMethod || ''} ${e.service || ''}`;
+    let g = '其他';
+    for (const [re, name] of GROUP_RULES) { if (re.test(hay)) { g = name; break; } }
+    (groups[g] = groups[g] || []).push(e.key);
+  }
+
+  return {
+    generatedAt: new Date().toISOString(),
+    totals: {
+      records: records.length,
+      endpoints: endpoints.length,
+      businessEndpoints: biz.length,
+      read: biz.filter((e) => e.kind === 'read').length,
+      write: biz.filter((e) => e.kind === 'write').length,
+      unknown: biz.filter((e) => e.kind === 'unknown').length,
+      hosts: Object.keys(hosts).length,
+    },
+    hosts,
+    groups,
+    endpoints,
+  };
+}
+
+module.exports = {
+  buildCatalog, templatePath, shape, isNoise, tryParseJson, leafKeys,
+  inferKind, methodFromPath, lastSegment, wordsOf, detectKind, READ_WORDS, WRITE_WORDS,
+};

+ 398 - 0
napos/guard.js

@@ -0,0 +1,398 @@
+'use strict';
+/**
+ * 写操作防护(Write Guard)
+ * ============================================================================
+ * 背景:淘宝闪购商家后台的写接口(接单 / 改价 / 上下架 / 提交活动…)一旦被
+ * 重放会产生**真实业务副作用**,不可撤销。
+ *
+ * 当前策略(用户指令):
+ *   「写接口暂时不验证」→ 写接口默认**全面拦截**,只读接口才放行。
+ *
+ * 三道判定,优先级从高到低:
+ *   1. 目录命中:请求能对上 data/apis.json 里的接口 → 直接采用它的 kind
+ *   2. 方法名动词:从 query 的 method= / body.method / body.params.method 取真名,
+ *      按 get/query/list… vs create/update/delete… 判定
+ *   3. HTTP 方法兜底:GET/HEAD/OPTIONS → read;其余 → 视为 write(保守)
+ *
+ * 放行方式(必须显式):
+ *   · CLI  : 加 --allow-write
+ *   · 环境 : NAPOS_ALLOW_WRITE=1
+ */
+
+const fs = require('fs');
+const { templatePath, tryParseJson, lastSegment, methodFromPath, detectKind } = require('./classify');
+
+// 动词表与目录归纳器共用同一份,避免两处判定打架
+const GENERIC_INVOKER = /\/invoke\/?$|invoke\/?$|iOneRoute|h5api|callFromNapos|nevermore|xtop\//i;
+
+const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
+
+/**
+ * 实测确认的「只读服务族」。
+ *
+ * 为什么需要它:归纳器靠方法名动词判读写,但有些服务的方法名里**根本没有动词**
+ * (`PollingService.abnormalOrders` / `unprocessedOrders` / `nonCoreOrders`
+ * 全被切成 ['abnormal','orders'] 这类名词,判不出来)→ 保守拦成 unknown。
+ *
+ * 证据(2026-10-11 抓包对照):这些是**订单看板的轮询口**,由前端每 N 秒自动调用,
+ * body 只有 `{shopId}`,无任何可变业务参数;方法语义均为"取当前状态"。
+ * 与之对照的真实写操作(接单/拒单/取消)都带 accept/reject/cancel 这类动词,
+ * 不会被这条规则误放行。
+ */
+const READ_ONLY_SERVICES = new Set(['PollingService']);
+
+/**
+ * 实测确认的「只读路径前缀」(lsycm 数据中心 / 生意参谋)。
+ *
+ * 为什么需要它:这些接口是 POST 且 URL 里没有方法名(方法语义在 body 里,如
+ * `/api/marketing/chain/card/marketingXxhfOverviewChain`),归纳器判不出动词 →
+ * 保守拦成 unknown。但它们全是**数据中心的统计卡片 / 排行榜 / 归因**接口,无写能力。
+ *
+ * 证据(2026-10-11 抓包 + 实测):
+ *   · `/api/overview/chain/custom/*`  → 经营概览(historyOverview / queryBusinessSummary / queryChainRealtimeInfo)
+ *   · `/api/marketing/chain/*`        → 营销统计(先享后付 XXHF / 活动效果 / 门店洞察排行)
+ *   · `/api/customer/chain/general/*` → 顾客私域分析(PrvtOverview / PrvtTouchAnalyze / PrvtFlowTrans / PrvtUserStick)
+ *   · `/api/diagnosis/chain/*`        → 经营诊断
+ *   · `/api/focus/*`                  → 指标定义
+ *   · `/api/naposOverview/*`          → 概览辅助(城市列表 / 最新时间)
+ *
+ * 与之对照:lsycm 上的**写操作不存在**(生意参谋是纯读分析域);
+ * 唯一需要永久拒绝的 `proteinStandardQuery/*` 是埋点/风控网关,已在 RISK_DENY 里一票否决,
+ * 优先级高于本白名单。
+ */
+const READ_ONLY_PATH_HOST = /(^|\.)lsycm\.alibaba\.com$/i;
+const READ_ONLY_PATH_PREFIXES = [
+  '/api/overview/',
+  '/api/marketing/chain/',
+  '/api/customer/chain/',
+  '/api/diagnosis/chain/',
+  '/api/focus/',
+  '/api/naposOverview/',
+  '/api/napos/',
+  '/api/ark/',
+  '/naposGrayConfig/',
+];
+
+/** error.code 用于上层区分"被防护挡住"和"真出错" */
+const CODE_WRITE_BLOCKED = 'NAPOS_WRITE_BLOCKED';
+const CODE_RISK_BLOCKED = 'NAPOS_RISK_BLOCKED';
+
+// ============================================================================
+// ★ 风控防线(Risk Guard)
+// ----------------------------------------------------------------------------
+// 实测结论(2026-10-11,逐接口对照):
+//   往**验签类接口**发不带 `secretkey` 的请求,服务端会判为伪造来源 →
+//   返回 `_____tmd_____/punish` 挑战页 → **客户端弹出验证码**。
+//
+//   证据:
+//     · historyOverview      —— 带 secretkey → 正常
+//     · queryChainRealtimeInfo —— 不带 secretkey → 命中 punish(客户端挂出验证 iframe)
+//     · proteinStandardQuery/TG3gM96 —— 不带 secretkey → 命中 punish
+//
+//   还有一类是**埋点/风控网关**接口,本身就不是给脚本调的,手构请求必触发。
+//
+//   防线统一放在这里(所有出站请求的唯一出口),
+//   这样走 MCP 工具 / CLI / skill 脚本三条路都拦得住。
+// ============================================================================
+
+/** 一票否决:这些请求永不外发 */
+const RISK_DENY = [
+  { re: /proteinStandardQuery/i, why: '埋点 / 风控网关(TG3gM96 家族),手构请求必触发验证码' },
+  { re: /_____tmd_____|\/punish\b|slidecaptcha|captcha/i, why: '这是风控验证页本身,不是业务接口' },
+  { re: /\/\/?[^/]*\/um\.json|ynuf\.aliapp\.org/i, why: '阿里统一用户追踪(风控探针)' },
+];
+
+/**
+ * 必须带签名才允许外发的接口(发往 lsycm 数据中心的 POST)。
+ * 这些接口一旦不带 `secretkey`,服务端判为伪造 → 风控。
+ */
+const SIGN_REQUIRED_HOST = /(^|\.)lsycm\.alibaba\.com$/i;
+
+/**
+ * 例外白名单:这些 lsycm 接口**实测不带签名也是安全的**(零风控、零鉴权失败)。
+ * 只有实测过、确认安全的才准进这张表。
+ */
+const SIGN_SAFE_PATHS = [
+  /^\/api\/overview\/chain\/custom\/queryBusinessSummary$/,
+  /^\/api\/overview\/chain\/custom\/historyOverview$/, // 走 kit 时本来就会签名
+  /^\/api\/naposOverview\/queryChainCityList$/,
+  /^\/api\/naposOverview\/getLatestTime$/,
+  /^\/api\/customer\/chain\/general\/chainPrvtOverview$/,
+  /^\/api\/customer\/chain\/general\/chainPrvtTouchAnalyze$/,
+  /^\/api\/napos\/isGray$/,
+  /^\/api\/napos\/isSycmPermission$/,
+  /^\/api\/napos\/isSuperChainAdmin$/,
+  /^\/naposGrayConfig\/getGray$/,
+  /^\/api\/overview\/chain\/custom\/inGray$/,
+];
+
+class RiskBlockedError extends Error {
+  constructor(info) {
+    super(
+      `🛑 已拦截高危请求(防触发风控验证码)\n` +
+      `  接口    : ${info.method} ${info.url}\n` +
+      `  原因    : ${info.reason}\n` +
+      (info.advice ? `  怎么办  : ${info.advice}\n` : '') +
+      `  ℹ️ 不带签名调用验签接口 = 伪造来源,会弹验证码,故一律拒发。`
+    );
+    this.name = 'RiskBlockedError';
+    this.code = CODE_RISK_BLOCKED;
+    this.info = info;
+  }
+}
+
+/**
+ * 风控预检。通过返回 {ok:true};命中红线抛 RiskBlockedError。
+ * @param {object} req {method,url,body}
+ * @param {object} [opts] {allowUnsigned}
+ */
+function checkRisk(req, opts = {}) {
+  const method = String(req.method || 'GET').toUpperCase();
+  const url = String(req.url || '');
+
+  // 1) 一票否决
+  for (const d of RISK_DENY) {
+    if (d.re.test(url)) {
+      throw new RiskBlockedError({
+        method, url, reason: d.why,
+        advice: '该接口不在技能支持范围内;若确需数据,请改用白名单内的经营数据接口。',
+      });
+    }
+  }
+
+  // 2) 验签接口必须带 secretkey
+  let u;
+  try { u = new URL(url); } catch { u = null; }
+  if (u && SIGN_REQUIRED_HOST.test(u.hostname) && !READ_METHODS.has(method)) {
+    const safe = SIGN_SAFE_PATHS.some((re) => re.test(u.pathname));
+    const body = typeof req.body === 'string' ? tryParseJson(req.body) : req.body;
+    const hasSign = !!(body && typeof body === 'object' && body.secretkey);
+    if (!safe && !hasSign && !opts.allowUnsigned) {
+      throw new RiskBlockedError({
+        method, url, reason: '该接口需要 secretkey 签名,但请求里没有 —— 会被服务端判为伪造并触发风控',
+        advice: '用 kit.statsHistory()/kit.sign() 补齐签名;确认过安全可加 allowUnsigned。',
+      });
+    }
+  }
+  return { ok: true };
+}
+
+/** 环境变量是否显式放行无签名请求(默认否) */
+function allowUnsignedFromEnv() {
+  const v = String(process.env.NAPOS_ALLOW_UNSIGNED || '').trim();
+  return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes';
+}
+
+
+class WriteBlockedError extends Error {
+  constructor(info) {
+    const head = info.kind === 'write' ? '已拦截写操作(策略:写接口暂不调用)' : '已拦截:无法确认是否为只读接口';
+    super(
+      `${head}\n` +
+      `  接口    : ${info.method} ${info.url}\n` +
+      (info.apiMethod ? `  方法名  : ${info.apiMethod}\n` : '') +
+      `  判定依据: ${info.reason}\n` +
+      `  如需放行: CLI 加 --allow-write,或设 NAPOS_ALLOW_WRITE=1`
+    );
+    this.name = 'WriteBlockedError';
+    this.code = CODE_WRITE_BLOCKED;
+    this.info = info;
+  }
+}
+
+// ------------------------------------------------------------------ 真名提取
+
+/** 从 URL query / body 里取真实 RPC 方法名与服务名 */
+function extractApiMethod(url, body) {
+  let service = '';
+  let method = '';
+  const b = typeof body === 'string' ? tryParseJson(body) : body;
+
+  if (b && typeof b === 'object' && !Array.isArray(b)) {
+    if (typeof b.service === 'string') service = b.service;
+    if (typeof b.method === 'string') method = b.method;
+    const inner = b.params && typeof b.params === 'object' && !Array.isArray(b.params) ? b.params : null;
+    if (inner && typeof inner.method === 'string') {
+      method = inner.method;
+      if (typeof inner.service === 'string' && !service) service = inner.service;
+    }
+  }
+
+  if (!method || !service) {
+    let q;
+    try { q = new URL(url).searchParams; } catch { q = new URLSearchParams(); }
+    if (!method) {
+      const qm = q.get('method');
+      if (qm) {
+        if (qm.includes('.')) {
+          const i = qm.lastIndexOf('.');
+          if (!service) service = qm.slice(0, i);
+          method = qm.slice(i + 1);
+        } else method = qm;
+      }
+    }
+    if (!service) {
+      const qs = q.get('service');
+      if (qs) service = qs;
+    }
+  }
+  return { service, method };
+}
+
+// ------------------------------------------------------------------ 目录索引
+
+/**
+ * 用 apis.json 建索引。
+ * 返回 { byKey: Map(规范key → kind), byRpc: Map('service.method' → kind) }
+ */
+function buildCatalogIndex(cat) {
+  const byKey = new Map();
+  const byRpc = new Map();
+  if (!cat || !Array.isArray(cat.endpoints)) return { byKey, byRpc };
+  for (const e of cat.endpoints) {
+    if (e.key) byKey.set(e.key, e.kind);
+    if (e.rpcMethod) {
+      const q = e.service ? `${e.service}.${e.rpcMethod}` : e.rpcMethod;
+      // 同名冲突时,只要有一个是 write 就按 write 处理(保守)
+      const prev = byRpc.get(q);
+      byRpc.set(q, prev === 'write' ? 'write' : e.kind);
+    }
+  }
+  return { byKey, byRpc };
+}
+
+/** 按目录生成规范 key(与 classify.js 的 key 构造保持一致) */
+function catalogKey(method, url, apiMethod, apiService) {
+  let u;
+  try { u = new URL(url); } catch { return null; }
+  const pt = templatePath(u.pathname);
+  const qualified = apiMethod ? (apiService ? `${apiService}.${apiMethod}` : apiMethod) : '';
+  const isGeneric = GENERIC_INVOKER.test(u.pathname);
+  if (qualified && isGeneric) return `${method} ${u.hostname} ${pt} :: ${qualified}`;
+  return `${method} ${u.hostname} ${pt}`;
+}
+
+// ------------------------------------------------------------------ 判定
+
+/**
+ * 判定一个请求是读、写,还是无法判定。
+ * @returns {{kind:'read'|'write'|'unknown', reason:string, source:string, apiMethod:string, apiService:string}}
+ */
+function classifyRequest(req, index) {
+  const method = String(req.method || 'GET').toUpperCase();
+  const url = String(req.url || '');
+  const { service, method: apiMethod } = extractApiMethod(url, req.body);
+  const base = { apiMethod, apiService: service };
+
+  // 0) 实测只读服务族(最高优先级 —— 目录对它们判不出,会落到 unknown)
+  if (service && READ_ONLY_SERVICES.has(service)) {
+    return {
+      ...base, kind: 'read', source: 'service-allowlist',
+      reason: `服务 ${service} 属实测只读轮询族(订单看板轮询口,无副作用)`,
+    };
+  }
+
+  // 0.5) lsycm 数据中心只读路径(生意参谋统计卡片/排行/归因,POST 无方法名判不出)
+  {
+    let u;
+    try { u = new URL(url); } catch { u = null; }
+    if (u && READ_ONLY_PATH_HOST.test(u.hostname)
+        && READ_ONLY_PATH_PREFIXES.some((p) => u.pathname.startsWith(p))) {
+      return {
+        ...base, kind: 'read', source: 'path-allowlist',
+        reason: `lsycm 数据中心只读路径(${u.pathname.split('/').slice(0, 4).join('/')}…,生意参谋统计接口,无写能力)`,
+      };
+    }
+  }
+
+  // 1) 目录命中优先(最权威)
+  if (index) {
+    const key = catalogKey(method, url, apiMethod, service);
+    if (key && index.byKey.has(key)) {
+      const kind = index.byKey.get(key);
+      return { ...base, kind, source: 'catalog', reason: `命中接口目录(${key})→ ${kindLabel(kind)}` };
+    }
+    if (apiMethod) {
+      const q = service ? `${service}.${apiMethod}` : apiMethod;
+      if (index.byRpc.has(q)) {
+        const kind = index.byRpc.get(q);
+        return { ...base, kind, source: 'catalog-rpc', reason: `命中方法名索引(${q})→ ${kindLabel(kind)}` };
+      }
+    }
+  }
+
+  // 2) 动作词(apiMethod 优先,其次路径里自带的方法名,最后整条路径)
+  //    判定逻辑与接口目录归纳器共用 detectKind,两处不会打架
+  const texts = [];
+  if (apiMethod) texts.push({ t: lastSegment(apiMethod), src: 'name' });
+  let pathname = '';
+  try { pathname = new URL(url).pathname; } catch { pathname = ''; }
+  const pm = methodFromPath(pathname);
+  if (pm) texts.push({ t: pm, src: 'pathMethod' });
+  texts.push({ t: pathname, src: 'path' });
+
+  for (const { t, src } of texts) {
+    if (!t) continue;
+    const d = detectKind(t);
+    if (!d) continue;
+    return {
+      ...base, kind: d.kind, source: `verb-${src}`,
+      reason: d.kind === 'write'
+        ? `动作词 ${d.word} 属写操作(来自${src === 'name' ? '方法名' : src === 'pathMethod' ? '路径方法名' : '路径'})`
+        : `动作词 ${d.word} 属读取(来自${src === 'name' ? '方法名' : src === 'pathMethod' ? '路径方法名' : '路径'})`,
+    };
+  }
+
+  // 3) HTTP 方法兜底
+  if (READ_METHODS.has(method)) {
+    return { ...base, kind: 'read', source: 'http', reason: `${method} 视为只读` };
+  }
+  // 拿不到方法名的 POST:无法确认,交防护层保守拦截
+  return { ...base, kind: 'unknown', source: 'http', reason: `${method} 且未能识别方法名 → 无法确认是否为写操作` };
+}
+
+function kindLabel(k) {
+  return k === 'read' ? '只读' : k === 'write' ? '写操作' : '无法判定';
+}
+
+/** 环境变量是否显式放行 */
+function allowWriteFromEnv() {
+  const v = String(process.env.NAPOS_ALLOW_WRITE || '').trim();
+  return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes';
+}
+
+/**
+ * 出口校验:先过风控红线,再判读/写;只放行读接口。
+ * @param {object} req    {method,url,body}
+ * @param {object} opts   {index, allowWrite, allowUnsigned}
+ */
+function assertAllowed(req, opts = {}) {
+  // ★ 风控防线优先 —— 比读写判定更致命(会弹验证码)
+  checkRisk(req, { allowUnsigned: opts.allowUnsigned || allowUnsignedFromEnv() });
+
+  const info = classifyRequest(req, opts.index);
+  const allow = opts.allowWrite === true || allowWriteFromEnv();
+  if (info.kind === 'read' || allow) {
+    return { ...info, allowed: true, blocked: false, forced: info.kind !== 'read' && allow };
+  }
+  throw new WriteBlockedError({ ...info, method: String(req.method || 'GET').toUpperCase(), url: String(req.url || '') });
+}
+
+module.exports = {
+  CODE_WRITE_BLOCKED,
+  CODE_RISK_BLOCKED,
+  WriteBlockedError,
+  RiskBlockedError,
+  RISK_DENY,
+  SIGN_SAFE_PATHS,
+  READ_ONLY_SERVICES,
+  READ_ONLY_PATH_PREFIXES,
+  checkRisk,
+  allowUnsignedFromEnv,
+  extractApiMethod,
+  buildCatalogIndex,
+  catalogKey,
+  classifyRequest,
+  allowWriteFromEnv,
+  assertAllowed,
+};

+ 966 - 0
napos/kit.js

@@ -0,0 +1,966 @@
+'use strict';
+/**
+ * napos kit —— 给「运营 skill 脚本」复用的运行时工具箱。
+ *
+ * 为什么单独抽一层:
+ *   MCP 的 9 个工具是「一次请求」粒度(napos_call),拿不下跨域签名、多轮拼装、
+ *   分页聚合这类活儿。skill 需要的是「一次调用完成一个业务动作」。
+ *   所以这里把公共能力沉淀下来:登录态获取、签名、跨域取数、输出落盘。
+ *
+ * ★ 可迁移的关键设计:ksid(登录态)一律**运行时从页面里读**,绝不硬编码。
+ *   硬编码的脚本拷到别的电脑必然失效 —— 这是能力迁移的第一道坎。
+ *
+ * 依赖:仅 ./cdp(同目录)。零第三方依赖。
+ */
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const cdp = require('./cdp');
+const guard = require('./guard');
+
+/** 数据中心里「实测不带签名也安全」的白名单(与 guard 共用同一份,避免两处打架) */
+const SIGN_SAFE_PATHS = guard.SIGN_SAFE_PATHS;
+
+const DEFAULT_PORT = Number(process.env.NAPOS_CDP_PORT || cdp.DEFAULT_PORT || 9669);
+
+/** 经营数据(数据中心)—— 唯一的自定义区间取数口 */
+const HISTORY_URL = 'https://lsycm.alibaba.com/api/overview/chain/custom/historyOverview';
+const HISTORY_METHOD = 'historyOverview'; // 参与签名的 targetId
+const SALT = '!@naposStats@!';
+
+const md5 = (s) => crypto.createHash('md5').update(String(s), 'utf8').digest('hex');
+
+/**
+ * 接口签名。算法来自真实抓包反推:
+ *   secretkey = md5(ksid + '&' + targetId + '&' + timestamp + '&' + '!@naposStats@!')
+ */
+function sign(ksid, targetId, timestamp, salt = SALT) {
+  return md5(`${ksid}&${targetId}&${timestamp}&${salt}`);
+}
+
+// ------------------------------------------------------------------ 登录态
+
+/**
+ * 运行时读取登录态 ksid。
+ * 不传 api 时自己开一次页面连接;传了就复用(省一次握手)。
+ * @returns {Promise<string|null>}
+ */
+async function getKsid({ port = DEFAULT_PORT, api = null } = {}) {
+  if (api) return cdp.findKsid(api);
+  return cdp.withPageFor(port, 'shop', (a) => cdp.findKsid(a), { autoOpen: false });
+}
+
+// ------------------------------------------------------------------ 形态 A:直接调(无需签名)
+
+/**
+ * 调一个商家后台/数据中心接口(不带签名)。
+ * 自动按域名选正确的子站页面;拿不到正确来源页时**拒发**(避免触发客户端鉴权)。
+ */
+async function call({ port = DEFAULT_PORT, url, method = 'GET', query = {}, body = null, headers = {}, needExact = true } = {}) {
+  if (!url) throw new Error('call 需要 url');
+  let full = url;
+  if (query && Object.keys(query).length) {
+    const u = new URL(url);
+    for (const [k, v] of Object.entries(query)) {
+      if (v === undefined || v === null) continue;
+      u.searchParams.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
+    }
+    full = u.toString();
+  }
+  return cdp.withPageFor(port, full, (api) =>
+    cdp.pageFetch(api, { method, url: full, body, headers, allowWrite: false }),
+    { needExact, autoOpen: true });
+}
+
+// ------------------------------------------------------------------ 形态 B:签名调(经营数据)
+
+/**
+ * 经营数据 historyOverview —— 支持任意区间(含超 30 天)。
+ *
+ * @param {object} o
+ * @param {string} [o.chainId='-1']   链 ID,'-1' = 全公司
+ * @param {string} [o.shopListIn='']  门店 ID;非空 = 单店/多店粒度(这是钥匙,不是 shopId)
+ * @param {string} [o.from]           起始日 YYYYMMDD
+ * @param {string} [o.to]             结束日 YYYYMMDD
+ * @param {string} [o.dateType='self_define']  ★ 自定义区间必须用 self_define
+ *                                    预设值:recent_7d / recent_30d / week / month
+ * @param {string} [o.compareOption='']
+ * @returns {Promise<{code:*, all:object|null, rows:object[], daily:object[], ksidSource:string}>}
+ */
+async function statsHistory(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const url = o.url || HISTORY_URL;
+  const target = o.target || HISTORY_METHOD;
+  const chainId = String(o.chainId == null ? '-1' : o.chainId);
+  const shopListIn = o.shopListIn == null ? '' : String(o.shopListIn);
+  const dateType = o.dateType || 'self_define';
+  const from = normDay(o.from);
+  const to = normDay(o.to);
+  const key = o.key || dateType;
+
+  return cdp.withPageFor(port, url, async (api) => {
+    const ksid = await cdp.findKsid(api);
+    if (!ksid) {
+      const err = new Error(
+        '读不到登录态 ksid:客户端可能未登录,或数据中心页面未就绪。\n' +
+        '  处理:在客户端打开「数据中心/生意参谋」页面后重试。'
+      );
+      err.code = 'NAPOS_NO_KSID';
+      throw err;
+    }
+    const ts = Date.now();
+    const body = {
+      shopId: '', secretkey: sign(ksid, target, ts), timestamp: ts,
+      chainId, dateType, key, compareOption: o.compareOption || '',
+      beginDate: from || '', endDate: to || '', bizDate: o.bizDate || to || '',
+      shopListIn,
+    };
+    const res = await cdp.pageFetch(api, { method: 'POST', url, body, allowWrite: false });
+    const d = (res && res.data) || {};
+    let rowsRaw = [];
+    if (Array.isArray(d.data)) rowsRaw = d.data;
+    else if (d.data && Array.isArray(d.data.list)) rowsRaw = d.data.list;
+    else if (Array.isArray(d.result)) rowsRaw = d.result;
+    const daily = rowsRaw.filter((x) => x && x.ds && /^\d{8}$/.test(String(x.ds)));
+    const all = rowsRaw.find((x) => x && x.ds === 'all') || null;
+    return {
+      ok: !!res.ok, status: res.status, risk: !!res.risk, authFail: !!res.authFail,
+      code: d.code != null ? d.code : (d.success === false ? 'success:false' : undefined),
+      msg: d.msg || d.message || undefined,
+      contextOrigin: res.contextOrigin || null,
+      all, rows: rowsRaw, daily,
+      _raw: res,
+    };
+  }, { needExact: true, autoOpen: true });
+}
+
+function normDay(v) {
+  if (v == null || v === '') return '';
+  const s = String(v).replace(/[-/.]/g, '').trim();
+  return /^\d{8}$/.test(s) ? s : String(v);
+}
+
+/** 天数差(YYYYMMDD) */
+function dayDiff(from, to) {
+  const p = (s) => new Date(Number(s.slice(0, 4)), Number(s.slice(4, 6)) - 1, Number(s.slice(6, 8)));
+  return Math.round((p(normDay(to)) - p(normDay(from))) / 86400000);
+}
+
+/** 今天 YYYYMMDD(本地时区) */
+function today() {
+  const d = new Date();
+  const z = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+}
+
+/** N 天前 YYYYMMDD */
+function daysAgo(n) {
+  const d = new Date(Date.now() - n * 86400000);
+  const z = (x) => String(x).padStart(2, '0');
+  return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+}
+
+// ------------------------------------------------------------------ 形态 C:xtop 网关(商家后台)
+
+const XTOP_BASE = 'https://app-api.shop.ele.me/xtop';
+
+/**
+ * 调 xtop 网关接口。
+ * 真实请求体结构(来自抓包):
+ *   { metas: { shopId, ksid, loginRestaurantId }, params: { request: {...} } }
+ * ★ ksid 在 body 里也要带 —— 同样运行时读取,不硬编码。
+ *
+ * @param {object} o
+ * @param {string} o.method  完整服务方法名,如 'xtop.napos.keeper.shop.queryShopTree'
+ * @param {object} [o.request] 业务参数(放进 params.request)
+ * @param {number|string} [o.shopId=-1]
+ * @param {number|string} [o.loginRestaurantId=-1]
+ */
+async function xtop(o = {}) {
+  if (!o.method) throw new Error('xtop 需要 method');
+  const port = Number(o.port || DEFAULT_PORT);
+  const url = `${XTOP_BASE}/${o.method}/1.0`;
+  return cdp.withPageFor(port, 'shop', async (api) => {
+    const ksid = o.ksid || (await cdp.findKsid(api));
+    if (!ksid) {
+      const err = new Error('读不到登录态 ksid —— 客户端可能未登录。');
+      err.code = 'NAPOS_NO_KSID';
+      throw err;
+    }
+    const body = {
+      metas: { shopId: o.shopId == null ? -1 : o.shopId, ksid, loginRestaurantId: o.loginRestaurantId == null ? -1 : o.loginRestaurantId },
+      params: { request: o.request || {} },
+    };
+    const res = await cdp.pageFetch(api, { method: 'POST', url, body, allowWrite: false });
+    return { ok: !!res.ok, status: res.status, risk: !!res.risk, authFail: !!res.authFail, data: res.data };
+  }, { needExact: false, autoOpen: false });
+}
+
+/**
+ * 门店组织树 —— 一次拿到「总部 → 连锁 → 门店」三层 + 每店营业状态。
+ * @returns {Promise<{merchant:object|null, chains:object[], shops:object[], raw:object}>}
+ */
+async function shopTree(o = {}) {
+  const r = await xtop({
+    port: o.port,
+    method: 'xtop.napos.keeper.shop.queryShopTree',
+    request: { restaurantId: o.restaurantId == null ? -1 : o.restaurantId, rootChainId: o.rootChainId == null ? -1 : o.rootChainId },
+    shopId: -1, loginRestaurantId: -1,
+  });
+  const root = (r.data && r.data.result && r.data.result.shopTreeNode) || null;
+  const chains = [];
+  const shops = [];
+  const walk = (n, chain) => {
+    if (!n) return;
+    if (n.nodeType === 'SHOP') {
+      shops.push({
+        shopId: n.id, shopName: n.name, busyLevel: n.busyLevel || null,
+        chainId: chain ? chain.chainId : null, chainName: chain ? chain.chainName : null,
+        restaurantType: n.restaurantType, shopBizType: n.shopBizType, shopSubBizType: n.shopSubBizType,
+      });
+      return;
+    }
+    if (n.nodeType === 'ROOT_CHAIN' || n.menuType === 'CHAIN') {
+      const c = { chainId: n.id, chainName: n.name, parentId: n.parentId };
+      chains.push(c);
+      (n.childList || []).forEach((k) => walk(k, c));
+      return;
+    }
+    (n.childList || []).forEach((k) => walk(k, chain)); // MERCHANT / UNIT 层透传
+  };
+  walk(root, null);
+  return {
+    ok: r.ok, code: (r.data && r.data.code) || r.status,
+    merchant: root ? { id: root.id, name: root.name, nodeType: root.nodeType } : null,
+    chains, shops, raw: r.data,
+  };
+}
+
+/** 营业状态语义(busyLevel) */
+const BUSY_LEVEL = {
+  OPEN: { label: '营业中', group: '在营', weight: 1 },
+  CLOSE: { label: '休息中(非营业时段)', group: '在营', weight: 2 },
+  SUSPEND_BUSINESS: { label: '暂停营业', group: '异常', weight: 3 },
+  INVALID: { label: '未生效 / 已下架', group: '异常', weight: 4 },
+};
+
+// ------------------------------------------------------------------ 形态 D:数据中心其它只读接口
+
+const STATS_BASE = 'https://lsycm.alibaba.com';
+
+/**
+ * 调数据中心(lsycm)任意只读 POST 接口。
+ * 自动走数据中心上下文 + 自动补齐该域鉴权头。
+ *
+ * ★ 风控自保:lsycm 的验签接口若不带 secretkey,服务端判为伪造并弹验证码。
+ *   这里对「非白名单」接口**自动补签名**(timestamp + secretkey),调用方无需关心。
+ */
+async function statsApi(pathname, body = {}, o = {}) {
+  const url = /^https?:/i.test(pathname) ? pathname : STATS_BASE + pathname;
+  const port = Number(o.port || DEFAULT_PORT);
+  const path = (() => { try { return new URL(url).pathname; } catch { return pathname; } })();
+  const isStats = /(^|\.)lsycm\.alibaba\.com$/i.test((() => { try { return new URL(url).hostname; } catch { return ''; } })());
+  const needSign = isStats && !SIGN_SAFE_PATHS.some((re) => re.test(path));
+
+  return cdp.withPageFor(port, url, async (api) => {
+    const payload = { ...body };
+    if (needSign && !payload.secretkey) {
+      const ksid = await cdp.findKsid(api);
+      if (!ksid) {
+        const err = new Error('读不到登录态 ksid,无法为验签接口补签名。请确认客户端已登录。');
+        err.code = 'NAPOS_NO_KSID';
+        throw err;
+      }
+      const ts = Date.now();
+      // targetId 对齐前端真实行为:有 chainId 用 chainId,否则用 shopId(实测 16 个接口 100% 命中)
+      const targetId = o.target != null
+        ? o.target
+        : (payload.chainId != null && payload.chainId !== ''
+            ? String(payload.chainId)
+            : (payload.shopId != null && payload.shopId !== '' ? String(payload.shopId) : path.split('/').filter(Boolean).pop()));
+      payload.timestamp = ts;
+      payload.secretkey = sign(ksid, targetId, ts);
+      payload.shopId = payload.shopId == null ? '' : payload.shopId;
+      if (payload.compareOption == null) payload.compareOption = '';
+    }
+    const r = await cdp.pageFetch(api, { method: o.method || 'POST', url, body: payload, allowWrite: false });
+    return { ok: !!r.ok, status: r.status, risk: !!r.risk, authFail: !!r.authFail, contextOrigin: r.contextOrigin, data: r.data };
+  }, { needExact: true, autoOpen: false });
+}
+
+/**
+ * 经营归因(近 7 日 vs 上周)—— 返回 factors[],指出「谁在拉高/拉低」。
+ * ⚠️ 固定窗口,不随日期参数变化;要自定义区间请用 statsHistory()。
+ */
+async function businessSummary(o = {}) {
+  const r = await statsApi('/api/overview/chain/custom/queryBusinessSummary',
+    { chainId: String(o.chainId == null ? '-1' : o.chainId) }, { port: o.port });
+  const d = (r.data && r.data.data) || null;
+  return { ...r, summary: d };
+}
+
+/**
+ * 城市维度汇总(近 7 日 vs 上周)—— 用于区域/渠道对比。
+ * ⚠️ 同样是固定窗口。
+ */
+async function cityList(o = {}) {
+  const r = await statsApi('/api/naposOverview/queryChainCityList',
+    { chainId: String(o.chainId == null ? '-1' : o.chainId) }, { port: o.port });
+  const rows = (r.data && r.data.data && r.data.data.data) || (r.data && r.data.data) || [];
+  return { ...r, cities: Array.isArray(rows) ? rows : [] };
+}
+
+/** 数据最近更新时间 */
+async function latestTime(o = {}) {
+  const r = await statsApi('/api/naposOverview/getLatestTime', {}, { port: o.port });
+  return (r.data && r.data.data) || null;
+}
+
+// ------------------------------------------------------------------ 形态 D:melody 业务接口(履约 / 订单 / 设置)
+
+const MELODY_BASE = 'https://app-api.shop.ele.me';
+
+/** 生成 melody 风格请求 id:<32位大写HEX>|<毫秒时间戳> */
+function melodyReqId() {
+  let h = '';
+  for (let i = 0; i < 32; i++) h += '0123456789ABCDEF'[(Math.random() * 16) | 0];
+  return `${h}|${Date.now()}`;
+}
+
+/**
+ * 调商家后台「melody」业务接口(body 结构来自真实抓包)。
+ *
+ * body = { service, method, params, id, metas:{appVersion,appName:'melody',ksid,shopId}, ncp:'2.0.0' }
+ * ★ ksid 放在 metas 里 —— 运行时读取,不硬编码。
+ *
+ * @param {object} o
+ * @param {string} o.service  如 'PollingService'
+ * @param {string} o.method   如 'abnormalOrders'
+ * @param {string} o.path     接口路径,如 '/fulfill/weborder/abnormalOrders/'
+ * @param {object} [o.params] 业务参数(内含 shopId)
+ */
+async function melody(o = {}) {
+  if (!o.service || !o.method || !o.path) throw new Error('melody 需要 service / method / path');
+  const port = Number(o.port || DEFAULT_PORT);
+  const url = `${MELODY_BASE}${o.path}?method=${o.service}.${o.method}`;
+  return cdp.withPageFor(port, url, async (api) => {
+    const ksid = o.ksid || (await cdp.findKsid(api));
+    if (!ksid) {
+      const err = new Error('读不到登录态 ksid —— 客户端可能未登录。');
+      err.code = 'NAPOS_NO_KSID';
+      throw err;
+    }
+    const params = o.params || {};
+    const shopId = o.shopId != null ? o.shopId : (params.shopId != null ? params.shopId : -1);
+    const body = {
+      service: o.service, method: o.method, params,
+      id: melodyReqId(),
+      metas: { appVersion: o.appVersion || '1.0.0', appName: 'melody', ksid, shopId },
+      ncp: '2.0.0',
+    };
+    const res = await cdp.pageFetch(api, { method: 'POST', url, body, allowWrite: false });
+    const d = res.data || {};
+    return {
+      ok: !!res.ok, status: res.status, risk: !!res.risk, authFail: !!res.authFail,
+      error: d.error || null,
+      result: d.result !== undefined ? d.result : (d.data !== undefined ? d.data : null),
+      code: d.code, raw: d,
+    };
+  }, { needExact: false, autoOpen: false });
+}
+
+/**
+ * 订单履约巡检 —— 一次抓「当前」的异常/待办订单计数(全公司或指定门店)。
+ * ⚠️ 这是**实时快照**,不是历史;要看趋势必须定时落库累积。
+ *
+ * @returns {Promise<{newOrder:number, urge:number, preparing:number, callDelivery:number,
+ *   riderAccept:number, unprocessedBooking:number, bookingOrderIds:string[],
+ *   exceptional:number, cancel:number, refund:number, fastRefund:number,
+ *   noProcesNegotiate:*, timeOutNegotiate:*, needRemindException:boolean, raw:object}>}
+ */
+async function orderAlerts(o = {}) {
+  const shopId = o.shopId == null ? -1 : o.shopId;
+  const P = { port: o.port, ksid: o.ksid, shopId };
+  const [ab, non, un] = await Promise.all([
+    melody({ ...P, service: 'PollingService', method: 'abnormalOrders', path: '/fulfill/weborder/abnormalOrders/', params: { shopId } }),
+    melody({ ...P, service: 'PollingService', method: 'nonCoreOrders', path: '/fulfill/weborder/nonCoreOrders/', params: { shopId } }),
+    melody({ ...P, service: 'PollingService', method: 'unprocessedOrders', path: '/fulfill/weborder/unprocessedOrders/', params: { shopId } }),
+  ]);
+  const A = (ab.result && typeof ab.result === 'object') ? ab.result : {};
+  const N = (non.result && typeof non.result === 'object') ? non.result : {};
+  const U = (un.result && typeof un.result === 'object') ? un.result : {};
+  const n = (v) => Number(v) || 0;
+  const bookingIds = (N.bookingNotice && Array.isArray(N.bookingNotice.orderIds)) ? N.bookingNotice.orderIds : [];
+  return {
+    shopId,
+    newOrder: n(U.newOrderCount),
+    urge: n(N.urgeOrderCount),
+    preparing: n(N.preparingOrderCount),
+    callDelivery: n(N.waitingCallDeliveryCount),
+    riderAccept: n(N.riderAcceptOrderCount),
+    unprocessedBooking: n(N.unprocessedBookingOrderCount),
+    bookingOrderIds: bookingIds,
+    exceptional: n(A.exceptionalOrderCount),
+    cancel: n(A.cancelOrderCount),
+    refund: n(A.refundOrderCount),
+    fastRefund: n(A.fastRefundOrderCount),
+    noProcesNegotiate: A.noProcesNegotiateOrder == null ? null : n(A.noProcesNegotiateOrder),
+    timeOutNegotiate: A.timeOutNegotiateOrder == null ? null : n(A.timeOutNegotiateOrder),
+    needRemindException: !!A.needRemindExceptionOrder,
+    raw: { abnormal: A, nonCore: N, unprocessed: U },
+    risk: ab.risk || non.risk || un.risk,
+  };
+}
+
+/** 接单/履约设置(91 字段,含预订单、备餐提醒、自动接单等) */
+async function orderSetting(o = {}) {
+  const shopId = o.shopId == null ? -1 : o.shopId;
+  const r = await melody({ port: o.port, ksid: o.ksid, shopId, service: 'ShopSettingService', method: 'getOrderSetting', path: '/nevermore/invoke/', params: { shopId } });
+  const setting = (r.result && r.result.result) ? r.result.result : (r.result || null);
+  return { ...r, setting };
+}
+
+/** 售后托管设置(是否开启托管/补发/补偿,含补偿比例上限) */
+async function afterSaleHosting(o = {}) {
+  const shopId = o.shopId == null ? -1 : o.shopId;
+  const r = await xtop({ port: o.port, method: 'xtop.eleme.merchant.fulfillment.shopSettings.getAfterSaleHostingSettings', request: { shopId }, shopId: -1 });
+  const inner = (r.data && r.data.result && r.data.result.data) ? r.data.result.data : null;
+  return { ...r, hosting: inner };
+}
+
+/** 出餐/配送设置类小接口合集(提醒时长、索赔提示、轮询策略) */
+async function orderTips(o = {}) {
+  const shopId = o.shopId == null ? -1 : o.shopId;
+  const P = { port: o.port, ksid: o.ksid, shopId };
+  const [cook, claim, poll] = await Promise.all([
+    melody({ ...P, service: 'ShopSettingService', method: 'getRemindCookTimeSetting', path: '/fulfill/strategy/getRemindCookTimeSetting/', params: { shopId } }),
+    melody({ ...P, service: 'DeliveryClaimService', method: 'getOrderClaimTips', path: '/fulfill/weborder/getOrderClaimTips/', params: { shopId } }),
+    melody({ ...P, service: 'PollingService', method: 'getPollingStrategy', path: '/fulfill/weborder/getPollingStrategy/', params: { shopId } }),
+  ]);
+  return { cookTime: cook.result, claimTips: claim.result, polling: poll.result };
+}
+
+// ------------------------------------------------------------------ 形态 E:agw 网关(财务 / 成长)
+
+/**
+ * agw 网关 = 阿里本地生活网关,财务(账单/结算)与成长中心都挂在上面。
+ *
+ * ★ 与 lsycm 完全不同的两个坑(实测,2026-10-11):
+ *   ① 请求**必须**由「该子站自己的 H5 执行上下文」发出 —— 子站是商家后台页里的
+ *      跨域 iframe,从外层页面发会被判为伪造来源 → 网关直接回 `111006 用户未登录`。
+ *        财务 H5 → https://napos-bill-pc.faas.ele.me        路由 /app/unit/billDetail
+ *        成长 H5 → https://napos-shop-operation-pc.faas.ele.me 路由 /app/unit/business-task-new
+ *      (路由表与鉴权头已在 cdp.js 的 APPS / buildAuthHeaders 里配好,这里只管取数)
+ *   ② 鉴权是 `ksid` + `current-shop-id` + `X-AGW-BIZ-CONTEXT` 三个头,**不是** secretkey。
+ */
+const AGW_BASE = { bill: 'https://agw.alsc.taobao.com', growth: 'https://agw.ele.me' };
+const AGW_QUERY = {
+  bill: { source: 'ELE', h: 'napos-bill-pc.faas.ele.me' },
+  growth: { source: 'ELE' },
+};
+
+/** YYYYMMDD → 毫秒(which: 'start' 当日 00:00:00 / 'end' 当日 23:59:59.999) */
+function msOf(day, which) {
+  const s = normDay(day);
+  if (!/^\d{8}$/.test(s)) return undefined;
+  const y = +s.slice(0, 4), m = +s.slice(4, 6), d = +s.slice(6, 8);
+  return which === 'end' ? new Date(y, m - 1, d, 23, 59, 59, 999).getTime() : new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
+}
+
+/** 毫秒 → YYYYMMDD */
+function ymd(ms) {
+  const d = new Date(Number(ms));
+  const z = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+}
+
+/** 毫秒 → YYYY-MM-DD */
+function ymdDash(ms) {
+  const s = ymd(ms);
+  return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
+}
+
+/**
+ * 确保某个 agw 子站的宿主页已就绪(不存在就导航主页面过去把它拉起来)。
+ * @returns {Promise<{ok:boolean, opened:boolean, contexts:string[]}>}
+ */
+async function ensureApp(appId, o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const app = typeof appId === 'object' && appId.id ? appId : cdp.resolveApp(appId);
+  const has = async () => {
+    try {
+      const ctxs = await cdp.withPageFor(port, app.id, (api) => [...api.contexts.keys()], { needExact: false, autoOpen: false });
+      return ctxs.some((o2) => cdp.ctxMatchesApp(o2, app)) ? ctxs : null;
+    } catch { return null; }
+  };
+  const hit = await has();
+  if (hit) return { ok: true, opened: false, contexts: hit };
+  if (!app.openPath) return { ok: false, opened: false, contexts: [] };
+  // 导航主页面到该子站路由(同源导航,不会触发鉴权),把 iframe 带起来
+  try {
+    await cdp.withPageFor(port, 'shop', (api) => api.evaluate(
+      `location.href = ${JSON.stringify('https://melody.shop.ele.me' + app.openPath)}`
+    ), { needExact: false, autoOpen: false });
+  } catch { /* 忽略 */ }
+  const deadline = Date.now() + Number(o.waitMs || 20000);
+  while (Date.now() < deadline) {
+    await cdp.sleep(1200);
+    const r = await has();
+    if (r) return { ok: true, opened: true, contexts: r };
+  }
+  return { ok: false, opened: true, contexts: [] };
+}
+
+/** 读该 agw 子站上下文里的凭证(ksid / unitId / shopId)—— 全部运行时读,不硬编码 */
+async function agwCred(appId, o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const app = cdp.resolveApp(appId);
+  return cdp.withPageFor(port, app.id, async (api) => {
+    let ctx = null;
+    for (const [origin, c] of api.contexts) { if (cdp.ctxMatchesApp(origin, app)) { ctx = c; break; } }
+    const v = await api.evaluate(cdp.AGW_CRED_PROBE, ctx ? { contextId: ctx.id } : undefined);
+    return v || {};
+  }, { needExact: false, autoOpen: false });
+}
+
+/** 调一个 agw 接口(body 固定为 { params: [...] } 形态) */
+async function agw(appId, path, params, o = {}) {
+  const base = AGW_BASE[appId];
+  if (!base) throw new Error(`agw 不支持子站 ${appId}(现支持:${Object.keys(AGW_BASE).join(' / ')})`);
+  const url = /^https?:/i.test(path) ? path : base + path;
+  const query = { ...(AGW_QUERY[appId] || {}), ...(o.query || {}) };
+  const r = await call({ port: o.port, url, method: 'POST', query, body: { params }, needExact: o.needExact !== false });
+  const d = (r.data && (r.data.data !== undefined ? r.data.data : r.data.result)) || null;
+  return { ...r, data: r.data, payload: d };
+}
+
+// ---- 财务:账单 / 结算 -------------------------------------------------------
+
+/**
+ * 可查账单的时间范围(最早可查日 + 服务器当前时间)。
+ * 实测最早可查 ≈ 2023-12-01(比经营数据只保留 180 天宽松得多)。
+ */
+async function billRange(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  await ensureApp('bill', { port, waitMs: o.waitMs });
+  const r = await agw('bill', '/gw/h5api/ele-shop-bill-query.GrayFacade.getGrayDatetime', [{}], { port });
+  const d = r.payload || {};
+  return { ...r, startDisplayDate: d.startDisplayDate || null, currentDateTime: d.currentDateTime || null,
+    startDay: d.startDisplayDate ? ymd(d.startDisplayDate) : null };
+}
+
+/**
+ * 连锁结算账单(日结,逐日一行;自动翻页聚合)。
+ *
+ * ★ 血泪教训:`shopId` / `chainId` 传 `'-1'` 会**返回空列表**(和经营数据一样,
+ *   -1 在账单域不是"全公司")。必须传真实 `unitId`,脚本已自动读取。
+ *
+ * @returns {Promise<{ok,unitId,biz,from,to,rows:object[],meta:{totalCount,shopCount,settleAmount,summaryPeriod}}>}
+ *   rows[] = { billDate, incomeAmount, expendAmount, settleAmount, settleStatus, settleShopCount }
+ *   settleStatus: TO_BE_SETTLED(待结算) / SETTLED(已结算) …
+ */
+async function settleList(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const ens = await ensureApp('bill', { port, waitMs: o.waitMs });
+  let unit = o.unitId || o.shopId || '';
+  if (!unit) unit = (await agwCred('bill', { port })).unitId || '';
+  unit = String(unit || '');
+  if (!unit) {
+    const err = new Error('读不到 unitId —— 无法确定账单主体。请确认财务页已登录(napos-ops-core 体检)。');
+    err.code = 'NAPOS_NO_UNIT';
+    throw err;
+  }
+  const biz = o.settleBizType || 'TRADE';
+  const from = o.from ? normDay(o.from) : daysAgo(o.days == null ? 30 : Number(o.days));
+  const to = o.to ? normDay(o.to) : today();
+  const size = Number(o.pageSize || 100);
+  const maxPage = Number(o.maxPage || 60);
+  const rows = [];
+  let pageNo = 1, totalPage = 1, meta = null;
+  while (pageNo <= Math.min(totalPage, maxPage)) {
+    const r = await agw('bill', '/gw/h5api/ele-shop-bill-query.ShopSummaryQueryFacade.queryChainSettleListByPage',
+      [{ shopId: unit, pageNo, pageSize: size, startBillDate: msOf(from, 'start'), endBillDate: msOf(to, 'end'), chainId: unit, settleBizType: biz }],
+      { port });
+    const d = r.payload || {};
+    if (Array.isArray(d.list)) rows.push(...d.list);
+    totalPage = Number(d.totalPage || 1) || 1;
+    if (!meta) meta = { totalCount: d.totalCount != null ? d.totalCount : null, shopCount: d.shopCount != null ? d.shopCount : null, settleAmount: d.settleAmount != null ? d.settleAmount : null, summaryPeriod: d.summaryPeriod || null };
+    if (!Array.isArray(d.list) || d.list.length < size) break;
+    pageNo++;
+  }
+  return { ok: true, unitId: unit, biz, from, to, rows, meta, ensured: ens };
+}
+
+/** 结算账单区间汇总(按日聚合 → 收入/支出/结算额/天数/状态分布) */
+function summarizeSettle(rows) {
+  const n = (v) => Number(v) || 0;
+  const s = { days: rows.length, income: 0, expense: 0, settle: 0, settleShopCount: 0, status: {} };
+  for (const r of rows) {
+    s.income += n(r.incomeAmount);
+    s.expense += n(r.expendAmount);
+    s.settle += n(r.settleAmount);
+    s.settleShopCount += n(r.settleShopCount);
+    const k = r.settleStatus || 'UNKNOWN';
+    s.status[k] = (s.status[k] || 0) + 1;
+  }
+  s.income = +s.income.toFixed(2);
+  s.expense = +s.expense.toFixed(2);
+  s.settle = +s.settle.toFixed(2);
+  s.margin = s.income > 0 ? +((s.settle / s.income) * 100).toFixed(2) : 0; // 结算率
+  return s;
+}
+
+// ---- 成长:产能 / 诊断 / 周报 / 任务 -------------------------------------------
+
+/** 成长中心汇总:门店产能分层(高/中/低)+ 经营诊断(L1/L2 未通过门店数) */
+async function growthSummary(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  await ensureApp('growth', { port, waitMs: o.waitMs });
+  const chainId = o.chainId == null ? '' : String(o.chainId);
+  const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getSummaryInfo', [{ chainId }], { port });
+  return { ...r, summary: r.payload || null };
+}
+
+/** 成长周报(GMV / 营收 / 曝光 / 访购率 + 环比 + 核心策略) */
+async function growthWeekly(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  await ensureApp('growth', { port, waitMs: o.waitMs });
+  const chainId = o.chainId == null ? '' : String(o.chainId);
+  const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getWeeklyReportData',
+    [{ chainId, brandId: o.brandId == null ? -1 : o.brandId }], { port });
+  return { ...r, weekly: r.payload || null };
+}
+
+/** 生意任务完成率(按门店 DetailType=DETAIL_TYPE_SHOP,自动翻页) */
+async function growthTaskRate(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  await ensureApp('growth', { port, waitMs: o.waitMs });
+  const chainId = o.chainId == null ? '' : String(o.chainId);
+  const size = Number(o.size || 50), maxPage = Number(o.maxPage || 20);
+  const rows = [];
+  let page = 1, total = 1;
+  while (page <= Math.min(total, maxPage)) {
+    const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getBusinessTaskCompleteRateByPage',
+      [{ chainId, page, size, data: { detailType: o.detailType || 'DETAIL_TYPE_SHOP', brandId: o.brandId == null ? -1 : o.brandId, useNew: true } }], { port });
+    const d = r.payload || {};
+    if (Array.isArray(d.data)) rows.push(...d.data);
+    total = Number(d.total || 0) ? Math.ceil(Number(d.total) / size) : 1;
+    if (!Array.isArray(d.data) || d.data.length < size) break;
+    page++;
+  }
+  return { ok: true, rows };
+}
+
+/** 成长中心品牌列表(brandId 用于周报 / 任务过滤) */
+async function growthBrands(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  await ensureApp('growth', { port, waitMs: o.waitMs });
+  const chainId = o.chainId == null ? '' : String(o.chainId);
+  const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getBrands', [{ chainId }], { port });
+  return { ...r, brands: r.payload || null };
+}
+
+// ------------------------------------------------------------------ 形态 F:渠道 / 联盟 / 金融
+
+/**
+ * 渠道域身份(ksid / unitId / keeperId / 当前店)。
+ * ★ 全部运行时读(localStorage → sessionStorage → cookie),绝不硬编码 —— 换机可用。
+ *   实测:localStorage 与 cookie 里都有 unitId / keeperId / ksid(同值)。
+ */
+async function channelIdentity(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const app = cdp.resolveApp('channel');
+  return cdp.withPageFor(port, app.id, async (api) => {
+    const ids = [undefined];
+    for (const [origin, c] of api.contexts) if (cdp.ctxMatchesApp(origin, app)) ids.push(c.id);
+    let fallback = {};
+    for (const contextId of ids) {
+      let v = null;
+      try { v = await api.evaluate(cdp.CHANNEL_ID_PROBE, contextId ? { contextId } : undefined); } catch { continue; }
+      if (v && v.ksid && v.unitId) return v;
+      if (v && v.ksid && !fallback.ksid) fallback = v;
+    }
+    return fallback;
+  }, { needExact: false, autoOpen: false });
+}
+
+const VAS_URL = 'https://app-api.shop.ele.me/xtop/xtop.vas.gateway.iOneRoute.callFromNapos/1.0';
+
+/** callFromNapos 的 reqId(实测格式:<随机数><毫秒><unitId>z6SvSyc9A+C) */
+function vasReqId(unitId) {
+  return `${Math.floor(Math.random() * 1e12)}${Date.now()}${unitId}z6SvSyc9A+C`;
+}
+
+/**
+ * 调「增值服务」网关(vas)—— 闪购联盟 / 店铺推广的公共入口。
+ *
+ * ★ 与 lsycm(签名)和 agw(头鉴权)都不同:鉴权是 **body.metas.ksid**(请求头里没有),
+ *   业务参数形如 `params.params = [身份包{loginId,bizCode,isChain}, 业务参数]`。
+ *   bizCode:闪购联盟 = 'eleCps' / 店铺推广 = 'eleChain'。
+ * ★ 实测(2026-10-11):从商家后台主页面直接发即可成功,**不需要**专属 H5 上下文。
+ *
+ * @param {string} service  例 oneReportService
+ * @param {string} method   例 findTotalReport
+ * @param {object} arg      业务参数(params[1])
+ * @param {object} o        { bizCode, isChain, unitId, identity, port }
+ */
+async function vas(service, method, arg = {}, o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const id = o.identity || await channelIdentity({ port });
+  const unit = o.unitId || id.unitId;
+  if (!id.ksid) { const e = new Error('读不到 ksid —— 请确认客户端已登录商家后台(napos-ops-core 体检)。'); e.code = 'NAPOS_NO_KSID'; throw e; }
+  if (!unit) { const e = new Error('读不到 unitId —— 请确认客户端已登录商家后台。'); e.code = 'NAPOS_NO_UNIT'; throw e; }
+  const url = `${VAS_URL}?service=${encodeURIComponent(service)}&method=${encodeURIComponent(method)}`;
+  const body = {
+    metas: { ksid: String(id.ksid), shopId: Number(unit), appName: 'napos' },
+    params: {
+      from: 'napos', to: 'alsc-ad-onebp', method, service,
+      params: [{ loginId: Number(unit), bizCode: o.bizCode || 'eleCps', isChain: o.isChain !== false }, arg || {}],
+      reqId: vasReqId(unit),
+    },
+  };
+  const r = await call({ port, url, method: 'POST', body, needExact: true });
+  const d = r.data || {};
+  const payload = d.result ? d.result.data : (d.data !== undefined ? d.data : null);
+  return { ...r, payload };
+}
+
+// ---- 渠道:闪购联盟(CPS / 联盟) --------------------------------------------
+
+/** 联盟开通概览:门店总数 / CPS 已开通门店数 / 权限码 / 商品与卡券概况 */
+async function cpsShopInfo(o = {}) {
+  const r = await vas('shopService', 'getShopInfo', {}, { bizCode: 'eleCps', isChain: true, ...o });
+  return { ...r, info: r.payload || null };
+}
+
+/** 联盟权益列表(queryType:AVOID_COMMISSION 免佣 …) */
+async function cpsRights(queryType = 'AVOID_COMMISSION', status = [1, 2], o = {}) {
+  const r = await vas('oneRightService', 'getRightInfoList', { queryType, status }, { bizCode: 'eleCps', isChain: true, ...o });
+  return { ...r, rows: Array.isArray(r.payload) ? r.payload : [] };
+}
+
+/** 联盟合约签署状态(signBaseValue / openValue / invoiceType) */
+async function cpsContract(o = {}) {
+  const r = await vas('oneUserService', 'getSignedContract', {}, { bizCode: 'eleCps', isChain: true, ...o });
+  return { ...r, contract: r.payload || null };
+}
+
+/** 联盟门店授权 */
+async function cpsAuthority(o = {}) {
+  const r = await vas('shopService', 'queryShopAuthority', { isChain: true }, { bizCode: o.bizCode || 'eleCps', isChain: true, ...o });
+  return { ...r, authority: r.payload || null };
+}
+
+/** 联盟推广组(oneAdGroupService.get) */
+async function cpsAdGroup(o = {}) {
+  const r = await vas('oneAdGroupService', 'get', {}, { bizCode: 'eleCps', isChain: true, ...o });
+  return { ...r, groups: r.payload };
+}
+
+// ---- 渠道:店铺推广(推广余额 / 花费 / 锦囊) ---------------------------------
+
+/** 推广账户余额(连锁汇总;detail 里是各类余额分项,balance 单位为分) */
+async function promoBalance(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const r = await call({
+    port, method: 'POST', needExact: true,
+    url: 'https://agw.alsc.taobao.com/gw/h5api/alsc-ad-account.IGwBalanceService.findBalanceInfo',
+    query: { source: 'ELE', h: 'r.ele.me' },
+    body: { params: [{}] },
+  });
+  const d = (r.data && r.data.result) || null;
+  const fen = d && d.detail ? Number(d.detail.balance || 0) : null;
+  return { ...r, balance: d, balanceFen: fen, balanceYuan: fen == null ? null : (fen / 100).toFixed(2) };
+}
+
+/** 门店推广余额汇总(total 单位为分) */
+async function promoLeafBalance(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const r = await call({
+    port, method: 'POST', needExact: true,
+    url: 'https://agw.alsc.taobao.com/gw/h5api/alsc-ad-account.IGwBalanceService.findLeafShopBalanceInfoAsync',
+    query: { source: 'ELE', h: 'r.ele.me' },
+    body: { params: [{}] },
+  });
+  const d = (r.data && r.data.result && r.data.result.result) || null;
+  const fen = d ? Number(d.total || 0) : null;
+  return { ...r, summary: d, totalFen: fen, totalYuan: fen == null ? null : (fen / 100).toFixed(2) };
+}
+
+/** 推广花费报表(默认近 7 天;reportStartDate/reportEndDate 支持自定义区间) */
+async function promoReport(o = {}) {
+  const endMs = o.to ? msOf(o.to, 'end') : (Date.now() - 86400000);
+  const startMs = o.from ? msOf(o.from, 'start') : (endMs - Math.max(0, Number(o.days || 7) - 1) * 86400000);
+  const from = ymdDash(startMs), to = ymdDash(endMs);
+  const arg = { reportTypeEnum: o.reportType || 'CHAIN_HIS_COST', showDetail: true, reportStartDate: from, reportEndDate: to };
+  const r = await vas('oneReportService', 'findTotalReport', arg, { bizCode: o.bizCode || 'eleChain', isChain: false, ...o });
+  return { ...r, report: r.payload || null, from, to };
+}
+
+/** 店铺推广:锦囊 / 流量袋 */
+async function silkBag(o = {}) {
+  const arg = {
+    shopId: Number(o.shopId || 0), pageSize: Number(o.pageSize || 20),
+    stateList: o.stateList || [10, 20], pageNum: Number(o.pageNum || 1),
+    bagType: o.bagType || 'BROCADEBAG',
+  };
+  const r = await vas('marketingToolService', 'findSilkBagPage', arg, { bizCode: o.bizCode || 'eleChain', isChain: false, ...o });
+  const d = r.payload;
+  const rows = Array.isArray(d) ? d : (d && Array.isArray(d.data) ? d.data : []);
+  return { ...r, rows, total: d && d.totalCount != null ? d.totalCount : rows.length };
+}
+
+// ---- 渠道:金融保险(储值卡 / 可见金融产品) ---------------------------------
+
+/** 可见金融资源(借款等,code:napos_eat_loan_profit …) */
+async function finResources(code = 'napos_eat_loan_profit', o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const r = await call({
+    port, method: 'POST', needExact: true,
+    url: 'https://agw.ele.me/gw/h5api/fn-alsc-fin-ops.alsc-fin-ops.common-config',
+    query: { source: 'ELE', h: 'r.ele.me' },
+    body: { params: [{ className: 'me.ele.fin.promotion.fn.service.H5ResourceService', methodName: 'queryVisibleResourceList', param: { code, cityId: o.cityId || '' } }] },
+  });
+  return { ...r, rows: (r.data && r.data.data) || [] };
+}
+
+/** 储值卡可签门店清单(全公司,unitType=UNIT) */
+async function finShops(o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const id = o.identity || await channelIdentity({ port });
+  const unitId = o.unitId || id.unitId, keeperId = o.keeperId || id.keeperId;
+  if (!unitId || !keeperId) { const e = new Error('读不到 unitId / keeperId —— 请确认客户端已登录商家后台。'); e.code = 'NAPOS_NO_UNIT'; throw e; }
+  const r = await call({
+    port, method: 'POST', needExact: true,
+    url: 'https://httpizza.ele.me/xtop/xtop.storedcard.commonShopApi.getShopIdsByKeeperIdAndUnitId/1.0',
+    body: { params: { request: { keeperId: Number(keeperId), unitId: Number(unitId), unitType: o.unitType || 'UNIT' } } },
+  });
+  const ids = (r.data && r.data.result && r.data.result.data && r.data.result.data.shopIds) || [];
+  return { ...r, shopIds: ids };
+}
+
+/** 储值卡签约状态(逐店 status:SIGNED / CAN_SIGN …) */
+async function finSign(shopIds, o = {}) {
+  const port = Number(o.port || DEFAULT_PORT);
+  const id = o.identity || await channelIdentity({ port });
+  const unitId = o.unitId || id.unitId;
+  if (!unitId) { const e = new Error('读不到 unitId —— 请确认客户端已登录商家后台。'); e.code = 'NAPOS_NO_UNIT'; throw e; }
+  const r = await call({
+    port, method: 'POST', needExact: true,
+    url: 'https://httpizza.ele.me/xtop/xtop.storedcard.merchant.signrecord.get/1.0',
+    body: { params: { request: { shopIds: (shopIds || []).map(Number), shopType: o.shopType || 'UNIT', unitId: Number(unitId) } } },
+  });
+  const rows = (r.data && r.data.result && r.data.result.data) || [];
+  return { ...r, rows };
+}
+
+// ------------------------------------------------------------------ 商品域(nevermore.goods)
+
+/**
+ * nevermore.goods 网关通用只读调用。
+ * 商品 / 门店属性 / 菜单 / 回收站等接口都在这个网关下,方法名在 query 的 method 里。
+ */
+async function goodsInvoke(service, method, params = {}, shopId = -1, o = {}) {
+  return melody({ service, method, path: '/nevermore.goods/invoke/', params, shopId, appVersion: o.appVersion || '4.4.0' });
+}
+
+/** 门店商品属性(包装费 / 口味分类 / 各类开关)—— 返回 result 为对象 */
+async function restaurantAttribute(shopId, o = {}) {
+  return goodsInvoke('GrayService', 'getRestaurantAttribute', { shopId }, shopId, o);
+}
+
+// ------------------------------------------------------------------ 输出落盘
+
+function ensureDir(p) {
+  try { fs.mkdirSync(p, { recursive: true }); } catch { /* ignore */ }
+  return p;
+}
+
+function csvCell(v) {
+  if (v === null || v === undefined) return '';
+  const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
+  return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
+}
+
+/** 写 CSV(UTF-8 BOM + CRLF,Excel 直接可读) */
+function writeCsv(file, header, rows) {
+  const head = Array.isArray(header) ? header : Object.keys(rows[0] || {});
+  const lines = [head.join(',')];
+  for (const r of rows) {
+    // 支持两种行形态:数组(按表头顺序)或对象(键 = 表头文字)
+    const cells = Array.isArray(r) ? r : head.map((k) => (r ? r[k] : ''));
+    lines.push(cells.map(csvCell).join(','));
+  }
+  ensureDir(path.dirname(file));
+  fs.writeFileSync(file, '\ufeff' + lines.join('\r\n'), 'utf8');
+  return file;
+}
+
+function writeJson(file, obj) {
+  ensureDir(path.dirname(file));
+  fs.writeFileSync(file, JSON.stringify(obj, null, 2), 'utf8');
+  return file;
+}
+
+// ------------------------------------------------------------------ 汇总口径
+
+/** 经营指标字段(historyOverview 返回值)*/
+const METRIC_KEYS = [
+  'valid_order_cnt', 'gross_amt', 'exp_uv', 'clk_exp_rate', 'ord_clk_rate',
+  'per_actual_pay', 'order_user_cnt', 'income_amt', 'expense_amt', 'shop_roi',
+];
+
+/** 把逐日序列按指标求和/求均值,得到区间合计 */
+function summarize(daily) {
+  const s = { days: daily.length };
+  for (const k of METRIC_KEYS) s[k] = 0;
+  let rateW = 0, rateD = 0;
+  for (const r of daily) {
+    for (const k of ['valid_order_cnt', 'gross_amt', 'exp_uv', 'order_user_cnt', 'income_amt', 'expense_amt']) {
+      s[k] += Number(r[k]) || 0;
+    }
+    const uv = Number(r.exp_uv) || 0;
+    if (uv > 0) {
+      if (r.clk_exp_rate != null) { rateW += (Number(r.clk_exp_rate) || 0) * uv; rateD += uv; }
+    }
+  }
+  s.clk_exp_rate = rateD > 0 ? +(rateW / rateD).toFixed(4) : 0;
+  s.per_actual_pay = s.valid_order_cnt > 0 ? +(s.gross_amt / s.valid_order_cnt).toFixed(2) : 0;
+  s.ord_clk_rate = s.exp_uv > 0 ? +(s.valid_order_cnt / s.exp_uv).toFixed(4) : 0;
+  for (const k of ['gross_amt', 'income_amt', 'expense_amt']) s[k] = +s[k].toFixed(2);
+  return s;
+}
+
+// ------------------------------------------------------------------ 便捷
+
+/** 状态体检(不含 MCP 目录,纯通道) */
+async function status(port = DEFAULT_PORT) {
+  const p = await cdp.probe(port);
+  const out = { port, portOk: p.ok, browser: p.ok ? p.version.Browser : null };
+  if (p.ok) {
+    try {
+      const targets = (await cdp.listTargets(port)).filter((t) => t.webSocketDebuggerUrl);
+      out.targets = targets.map((t) => `${t.type} | ${t.url}`);
+      const ctxs = await cdp.withPageFor(port, 'shop', (api) => [...api.contexts.keys()], { autoOpen: false });
+      out.contexts = ctxs;
+    } catch (e) { out.probeError = String(e.message || e); }
+  }
+  return out;
+}
+
+module.exports = {
+  DEFAULT_PORT, HISTORY_URL, HISTORY_METHOD, SALT, METRIC_KEYS, XTOP_BASE, STATS_BASE, MELODY_BASE, BUSY_LEVEL, AGW_BASE,
+  md5, sign, getKsid, call, xtop, shopTree, statsHistory, statsApi, businessSummary, cityList, latestTime, status,
+  melody, melodyReqId, orderAlerts, orderSetting, afterSaleHosting, orderTips,
+  ensureApp, agwCred, agw, billRange, settleList, summarizeSettle,
+  growthSummary, growthWeekly, growthTaskRate, growthBrands,
+  channelIdentity, vas, vasReqId,
+  cpsShopInfo, cpsRights, cpsContract, cpsAuthority, cpsAdGroup,
+  promoBalance, promoLeafBalance, promoReport, silkBag,
+  finResources, finShops, finSign,
+  goodsInvoke, restaurantAttribute,
+  normDay, dayDiff, today, daysAgo, msOf, ymd, ymdDash,
+  ensureDir, writeCsv, writeJson, summarize,
+  cdp,
+};

+ 168 - 0
napos/ws.js

@@ -0,0 +1,168 @@
+'use strict';
+/**
+ * 零依赖的 WebSocket 客户端(RFC6455 子集,够 CDP 用)。
+ *
+ * 为什么不用 Node 内置的全局 WebSocket:
+ *   Chromium 的 DevTools WebSocket 端点会拒绝带 Origin 头的握手。
+ *   浏览器环境自动带 Origin,undici 的实现行为不确定。
+ *   这里用 http.request 手动升级,完全掌控请求头 —— 不发 Origin,握手必定通过。
+ *
+ * 支持:文本/二进制帧、分片续帧、ping/pong、64 位长度、关闭帧。
+ * 客户端发帧一律 mask(协议要求)。
+ */
+
+const http = require('http');
+const crypto = require('crypto');
+const { EventEmitter } = require('events');
+
+const OP = { CONT: 0x0, TEXT: 0x1, BIN: 0x2, CLOSE: 0x8, PING: 0x9, PONG: 0xa };
+
+function encodeFrame(payload, opcode = OP.TEXT) {
+  const len = payload.length;
+  let head;
+  if (len < 126) {
+    head = Buffer.alloc(2);
+    head[1] = len;
+  } else if (len < 65536) {
+    head = Buffer.alloc(4);
+    head[1] = 126;
+    head.writeUInt16BE(len, 2);
+  } else {
+    head = Buffer.alloc(10);
+    head[1] = 127;
+    head.writeUInt32BE(Math.floor(len / 4294967296), 2);
+    head.writeUInt32BE(len >>> 0, 6);
+  }
+  head[0] = 0x80 | opcode; // FIN + opcode
+  head[1] |= 0x80;         // MASK
+
+  const mask = crypto.randomBytes(4);
+  const out = Buffer.alloc(head.length + 4 + len);
+  head.copy(out, 0);
+  mask.copy(out, head.length);
+  const off = head.length + 4;
+  for (let i = 0; i < len; i++) out[off + i] = payload[i] ^ mask[i & 3];
+  return out;
+}
+
+class RawWebSocket extends EventEmitter {
+  constructor(url) {
+    super();
+    this.url = new URL(url);
+    this.sock = null;
+    this.buf = Buffer.alloc(0);
+    this.frag = [];        // 分片累积
+    this.fragOp = null;
+    this.readyState = 0;   // 0 connecting 1 open 2 closing 3 closed
+  }
+
+  connect(timeoutMs = 10000) {
+    return new Promise((resolve, reject) => {
+      const key = crypto.randomBytes(16).toString('base64');
+      const req = http.request({
+        host: this.url.hostname,
+        port: this.url.port || 80,
+        path: this.url.pathname + this.url.search,
+        method: 'GET',
+        headers: {
+          Connection: 'Upgrade',
+          Upgrade: 'websocket',
+          'Sec-WebSocket-Key': key,
+          'Sec-WebSocket-Version': '13',
+        },
+        timeout: timeoutMs,
+      });
+
+      let settled = false;
+      const fail = (e) => { if (!settled) { settled = true; reject(e); } };
+
+      req.on('upgrade', (res, socket, head) => {
+        settled = true;
+        this.sock = socket;
+        this.readyState = 1;
+        socket.setNoDelay(true);
+        socket.on('data', (d) => this._feed(d));
+        socket.on('close', () => { this.readyState = 3; this.emit('close'); });
+        socket.on('error', (e) => this.emit('error', e));
+        if (head && head.length) this._feed(head);
+        resolve(res.headers);
+      });
+      req.on('response', (res) => fail(new Error(`握手被拒: HTTP ${res.statusCode}`)));
+      req.on('error', fail);
+      req.on('timeout', () => req.destroy(new Error('WebSocket 握手超时')));
+      req.end();
+    });
+  }
+
+  _feed(chunk) {
+    this.buf = Buffer.concat([this.buf, chunk]);
+    for (;;) {
+      if (this.buf.length < 2) return;
+      const b0 = this.buf[0];
+      const b1 = this.buf[1];
+      const fin = (b0 & 0x80) !== 0;
+      const opcode = b0 & 0x0f;
+      const masked = (b1 & 0x80) !== 0;
+      let len = b1 & 0x7f;
+      let pos = 2;
+      if (len === 126) {
+        if (this.buf.length < pos + 2) return;
+        len = this.buf.readUInt16BE(pos); pos += 2;
+      } else if (len === 127) {
+        if (this.buf.length < pos + 8) return;
+        const hi = this.buf.readUInt32BE(pos);
+        const lo = this.buf.readUInt32BE(pos + 4);
+        len = hi * 4294967296 + lo; pos += 8;
+      }
+      let mask = null;
+      if (masked) {
+        if (this.buf.length < pos + 4) return;
+        mask = this.buf.subarray(pos, pos + 4); pos += 4;
+      }
+      if (this.buf.length < pos + len) return;
+      let payload = this.buf.subarray(pos, pos + len);
+      this.buf = this.buf.subarray(pos + len);
+
+      if (mask) {
+        payload = Buffer.from(payload); // 复制,避免改动原缓冲
+        for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
+      }
+
+      if (opcode === OP.PING) { this._sendFrame(payload, OP.PONG); continue; }
+      if (opcode === OP.PONG) continue;
+      if (opcode === OP.CLOSE) { this.close(); continue; }
+
+      if (opcode === OP.CONT) {
+        this.frag.push(payload);
+      } else {
+        this.frag = [payload];
+        this.fragOp = opcode;
+      }
+      if (fin) {
+        const full = this.frag.length === 1 ? this.frag[0] : Buffer.concat(this.frag);
+        const startOp = this.fragOp;
+        this.frag = []; this.fragOp = null;
+        if (startOp === OP.TEXT) this.emit('message', full.toString('utf8'));
+        else this.emit('binary', full);
+      }
+    }
+  }
+
+  _sendFrame(payload, opcode) {
+    if (!this.sock || this.readyState !== 1) return;
+    try { this.sock.write(encodeFrame(payload, opcode)); } catch (e) { this.emit('error', e); }
+  }
+
+  send(text) { this._sendFrame(Buffer.from(text, 'utf8'), OP.TEXT); }
+
+  close() {
+    if (this.readyState === 3) return;
+    this.readyState = 2;
+    try { this._sendFrame(Buffer.from([0x03, 0xe8]), OP.CLOSE); } catch { /* ignore */ }
+    try { this.sock.end(); } catch { /* ignore */ }
+    this.readyState = 3;
+    this.emit('close');
+  }
+}
+
+module.exports = { RawWebSocket, encodeFrame };

+ 264 - 0
nl-query.js

@@ -0,0 +1,264 @@
+'use strict';
+
+/**
+ * 智能问数 · NL2Query
+ * ----------------------------------------------------------------------------
+ * 参考 spring-ai-alibaba/DataAgent 的「意图理解 → 查询规划 → 报告呈现」链路,
+ * 但不引入 SQL/向量库/Java 栈:
+ *   · 语义对齐:基于 napos-service 的列注册表(同义词 + 口径描述),一个 prompt 装得下
+ *   · 查询执行:把自然语言翻译成现有 /api/reports 查询参数,复用数据湖查询
+ *   · 结果呈现:reply 文本 + ECharts 图表配置(前端渲染)
+ *
+ * 需要 OpenAI 兼容的对话模型(config.chatModel / chatApiKey / chatApiBase)。
+ */
+
+const https = require('https');
+const http = require('http');
+
+const COLUMN_KEYS = new Set(['date', 'shopName', 'bizHours', 'abnormalCloseHours', 'validOrderCnt',
+  'incomeAmt', 'grossAmt', 'goodsSaleAmt', 'expUv', 'orderUserCnt',
+  'clkExpRate', 'ordClkRate', 'perActualPay', 'expenseAmt', 'actOrdCnt', 'actSubsidyAmt',
+  'shopRoi', 'rebuyRate30d']);
+
+const z2 = (n) => String(n).padStart(2, '0');
+function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
+function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
+function normDay(v) { return /^\d{8}$/.test(String(v || '')) ? String(v) : ''; }
+
+// ---------- OpenAI 兼容对话调用 ----------
+
+function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000 }) {
+  return new Promise((resolve, reject) => {
+    let base;
+    try { base = new URL(apiBase); } catch (e) { return reject(new Error('对话模型地址不合法: ' + apiBase)); }
+    const body = JSON.stringify({ model, messages, temperature: 0, stream: false });
+    const options = {
+      hostname: base.hostname,
+      port: base.port || 443,
+      path: (base.pathname.replace(/\/+$/, '') || '') + '/v1/chat/completions',
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${apiKey}`,
+        'Content-Type': 'application/json; charset=utf-8',
+        'Content-Length': Buffer.byteLength(body),
+      },
+      timeout: timeoutMs,
+    };
+    const req = (base.protocol === 'http:' ? http : https).request(options, (res) => {
+      const chunks = [];
+      res.on('data', (c) => chunks.push(c));
+      res.on('end', () => {
+        const raw = Buffer.concat(chunks).toString('utf8');
+        let json = null;
+        try { json = JSON.parse(raw); } catch { /* 保留 raw */ }
+        if (res.statusCode !== 200) {
+          const msg = (json && json.error && json.error.message) || raw.slice(0, 200) || `HTTP ${res.statusCode}`;
+          return reject(new Error(`对话模型请求失败: ${msg}`));
+        }
+        const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content;
+        if (!content) return reject(new Error('对话模型返回为空'));
+        resolve(content);
+      });
+    });
+    req.on('error', reject);
+    req.on('timeout', () => req.destroy(new Error('对话模型请求超时')));
+    req.write(body);
+    req.end();
+  });
+}
+
+/** 从模型输出中稳健地提取第一个 JSON 对象 */
+function extractJson(text) {
+  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
+  const candidate = fenced ? fenced[1] : text;
+  const start = candidate.indexOf('{');
+  const end = candidate.lastIndexOf('}');
+  if (start === -1 || end <= start) throw new Error('模型未返回 JSON');
+  return JSON.parse(candidate.slice(start, end + 1));
+}
+
+// ---------- Prompt 构造 ----------
+
+function semanticModelText() {
+  const { REPORT_COLUMNS } = require('./napos-service.js');
+  return REPORT_COLUMNS.filter((c) => c.key !== 'date' && c.key !== 'shopName').map((c) => {
+    const syn = (c.synonyms || []).join('/');
+    const desc = c.description || '';
+    return `- ${c.key} | ${c.label} | 同义词:${syn} | ${desc}${c.reserved ? ' | ⚠️预留列,当前无数据,不要用于查询' : ''}`;
+  }).join('\n');
+}
+
+function buildMessages({ q, entity, shops, dateRange, history }) {
+  const shopLines = (shops || []).slice(0, 300)
+    .map((s) => `${s.shopId}|${s.shopName}`).join('\n');
+  const system = `你是「淘宝闪购门店经营数据」的问数助手。把用户的自然语言问题翻译成一个结构化查询 JSON。
+
+## 今天日期
+${ymdLocal(new Date())}(YYYYMMDD)。数据可用区间:${dateRange.from} ~ ${dateRange.to}(区间外无数据;当天未结算,查询截止日默认取昨天)。
+
+## 指标语义(key | 名称 | 同义词 | 口径)
+${semanticModelText()}
+
+## 门店清单(shopId|shopName)
+${shopLines || '(暂无门店清单,shopNames 留空)'}
+
+## 输出格式(只输出一个 JSON 对象,不要输出其他文字)
+{
+  "reply": "结合对话上下文,用一两句话直接回答用户本轮问题(可沿用上一轮已确认的门店/指标);不要编造具体数字",
+  "mode": "detail|shop|date",        // detail=按日期×门店明细; shop=按门店汇总; date=按日期汇总。问"每家/各门店/排行/Top"用 shop;问"每天/趋势/最近几天"用 date;问具体某天各门店用 detail
+  "from": "YYYYMMDD",                // 缺省 = 昨天-29
+  "to": "YYYYMMDD",                  // 缺省 = 昨天
+  "shopNames": ["门店名"],           // 可选。用户指定门店时给名称(支持部分匹配);没指定给 []
+  "metrics": ["grossAmt"],           // 展示的指标 key 数组,1~4 个,必须来自上面的指标语义;用户说"营业额和单量"就是 ["grossAmt","validOrderCnt"]
+  "sort": "grossAmt",                // 排序指标 key,可选;shop 模式默认营业额降序
+  "sortDir": "desc",
+  "limit": 10,                       // 可选,shop 模式 Top N;用户问"全部/所有"给 0 表示不限
+  "chart": { "type": "bar|line|none", "x": "shopName|date", "y": ["grossAmt"] },  // 推荐图表:mode=shop→bar(x=shopName),mode=date→line(x=date),用户只是问一个数字→none
+  "chartTitle": "图表标题"
+}
+
+## 规则
+- 对话可能带上下文(之前的问答):用户说"那它呢/换成二店/再看单量"时,结合上一轮的门店与指标补全本轮查询
+- 指标同义词要对齐:如"流水/GMV/销售额"→grossAmt,"单量"→validOrderCnt,"进店率"→clkExpRate,"访购率"→ordClkRate
+- "上月/最近30天/本周"等要换算成具体 from/to,且不晚于 ${dateRange.to}
+- 预留列(营业时长/异常关店时长/商品销售额)没有数据:用户问到时,reply 里说明该数据源暂未接入,metrics 仍可带上(展示为空)
+- 除非用户明确要全部门店,shop 模式默认 limit 10`;
+  const msgs = [{ role: 'system', content: system }];
+  for (const h of (Array.isArray(history) ? history : []).slice(-8)) {
+    if (!h || !h.content) continue;
+    if (h.role !== 'user' && h.role !== 'assistant') continue;
+    msgs.push({ role: h.role, content: String(h.content).slice(0, 600) });
+  }
+  msgs.push({ role: 'user', content: String(q || '').slice(0, 500) });
+  return msgs;
+}
+
+// ---------- 查询执行 ----------
+
+function matchShopsByName(shops, names) {
+  if (!Array.isArray(names) || !names.length) return null;
+  const picked = new Set();
+  for (const raw of names) {
+    const want = String(raw || '').trim().toLowerCase();
+    if (!want) continue;
+    let hit = (shops || []).find((s) => String(s.shopName).toLowerCase() === want);
+    if (!hit) hit = (shops || []).find((s) => String(s.shopName).toLowerCase().includes(want));
+    if (hit) picked.add(String(hit.shopId));
+  }
+  return picked.size ? [...picked] : null;
+}
+
+function sanitizeQuery(raw, shops, dateRange) {
+  const q = raw || {};
+  const mode = ['detail', 'shop', 'date'].includes(q.mode) ? q.mode : 'shop';
+  const from = normDay(q.from) || daysAgoYmd(30);
+  const to = normDay(q.to) || daysAgoYmd(1);
+  const clamped = {
+    mode,
+    from: from < dateRange.from ? dateRange.from : from,
+    to: to > dateRange.to ? dateRange.to : (to > from ? to : dateRange.to),
+    shopIds: matchShopsByName(shops, q.shopNames),
+    metrics: (Array.isArray(q.metrics) ? q.metrics : ['grossAmt'])
+      .map(String).filter((k) => COLUMN_KEYS.has(k)).slice(0, 4),
+    sort: COLUMN_KEYS.has(q.sort) ? q.sort : 'grossAmt',
+    sortDir: q.sortDir === 'asc' ? 'asc' : 'desc',
+    limit: Number(q.limit) > 0 ? Math.min(Number(q.limit), 200) : 0,
+    chart: q.chart && ['bar', 'line', 'none'].includes(q.chart.type)
+      ? { type: q.chart.type, x: q.chart.x === 'date' ? 'date' : 'shopName', y: (Array.isArray(q.chart.y) ? q.chart.y : ['grossAmt']).filter((k) => COLUMN_KEYS.has(k)).slice(0, 2) }
+      : { type: mode === 'date' ? 'line' : 'bar', x: mode === 'date' ? 'date' : 'shopName', y: ['grossAmt'] },
+    chartTitle: String(q.chartTitle || '').slice(0, 60),
+  };
+  if (!clamped.metrics.length) clamped.metrics = ['grossAmt'];
+  if (!clamped.chart.y.length) clamped.chart.y = [clamped.metrics[0]];
+  return clamped;
+}
+
+/** 执行 sanitize 后的查询,返回面向展示的 rows/columns/summary */
+function executeQuery(entityKey, query, svc) {
+  const common = { entity: entityKey, from: query.from, to: query.to };
+  let result;
+  if (query.mode === 'detail') {
+    result = svc.queryReport({ ...common, scope: 'shop', shops: query.shopIds || undefined });
+  } else {
+    result = svc.aggregateReport({ ...common, group: query.mode === 'date' ? 'date' : 'shop' });
+    if (query.shopIds && query.mode === 'shop') {
+      const wanted = new Set(query.shopIds.map(String));
+      result.rows = result.rows.filter((r) => wanted.has(String(r.shopId)));
+    }
+  }
+  let rows = result.rows || [];
+  const sortKey = query.sort;
+  rows = rows.slice().sort((a, b) => {
+    const av = a[sortKey], bv = b[sortKey];
+    const an = Number(av), bn = Number(bv);
+    if (Number.isFinite(an) && Number.isFinite(bn)) return query.sortDir === 'asc' ? an - bn : bn - an;
+    return String(av || '').localeCompare(String(bv || ''), 'zh-CN') * (query.sortDir === 'asc' ? 1 : -1);
+  });
+  if (query.limit > 0) rows = rows.slice(0, query.limit);
+  return {
+    columns: (svc.REPORT_COLUMNS || []).filter((c) => ['date', 'shopName'].includes(c.key) || query.metrics.includes(c.key)),
+    rows,
+    summary: result.summary,
+    applied: {
+      mode: query.mode, from: query.from, to: query.to,
+      shopIds: query.shopIds || [], metrics: query.metrics,
+      sort: query.sort, sortDir: query.sortDir, limit: query.limit,
+    },
+  };
+}
+
+// ---------- 对外入口 ----------
+
+/** 湖内可用日期范围(用于 prompt 约束 + 查询钳制) */
+function lakeDateRange(entityKey, svc) {
+  const dates = svc.store.datesInLake(entityKey);
+  if (!dates.length) {
+    const to = daysAgoYmd(1);
+    return { from: daysAgoYmd(30), to };
+  }
+  return { from: dates[0], to: dates[dates.length - 1] };
+}
+
+/**
+ * 智能问数主入口。
+ * @param {object} o { q, entity, cfg: {chatApiBase, chatApiKey, chatModel}, svc: napos-service }
+ */
+async function ask(o) {
+  const { q, entity, cfg, svc } = o;
+  if (!q || !String(q).trim()) { const e = new Error('请输入问题'); e.code = 'ASK_EMPTY'; throw e; }
+  for (const [k, label] of [['chatApiBase', '对话模型地址'], ['chatApiKey', '对话模型 Key'], ['chatModel', '对话模型']]) {
+    if (!cfg[k]) {
+      const e = new Error(`对话模型未配置(缺少${label}):请到「系统设置 → API 设置 → 对话模型」选择模型后点「保存设置」`);
+      e.code = 'ASK_NO_MODEL';
+      throw e;
+    }
+  }
+  const meta = svc.store.getEntity(entity);
+  const shops = (meta && meta.shops) || [];
+  const dateRange = lakeDateRange(entity, svc);
+  const messages = buildMessages({ q, entity, shops, dateRange, history: o.history });
+
+  let raw;
+  try {
+    raw = await chatCompletion({ apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel, messages });
+  } catch (e) {
+    const err = new Error(e.message);
+    err.code = 'ASK_LLM_FAIL';
+    throw err;
+  }
+  const parsed = extractJson(raw);
+  const query = sanitizeQuery(parsed, shops, dateRange);
+  const result = executeQuery(entity, query, svc);
+  return { reply: String(parsed.reply || '').slice(0, 500), ...result, chart: query.chart, chartTitle: query.chartTitle };
+}
+
+/** 设置页「测试对话模型」:1 token 的最小请求 */
+async function ping(cfg) {
+  const reply = await chatCompletion({
+    apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel, timeoutMs: 20000,
+    messages: [{ role: 'user', content: '回复"OK"两个字母即可' }],
+  });
+  return String(reply).trim().slice(0, 40);
+}
+
+module.exports = { ask, ping, extractJson, sanitizeQuery, matchShopsByName, lakeDateRange };

+ 2 - 2
package-lock.json

@@ -1,11 +1,11 @@
 {
-  "name": "shanhuivisual",
+  "name": "smart-ops-platform",
   "version": "1.0.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "shanhuivisual",
+      "name": "smart-ops-platform",
       "version": "1.0.0",
       "dependencies": {
         "jpeg-js": "^0.4.4",

+ 2 - 2
package.json

@@ -1,7 +1,7 @@
 {
-  "name": "shanhuivisual",
+  "name": "smart-ops-platform",
   "version": "1.0.0",
-  "description": "门店装修工具",
+  "description": "智慧运营中台(淘宝闪购专业版)",
   "scripts": {
     "start": "node server.js"
   },

+ 100 - 1
public/app.js

@@ -6,6 +6,8 @@
     gen: document.getElementById('viewGen'),
     tasks: document.getElementById('viewTasks'),
     history: document.getElementById('viewHistory'),
+    reports: document.getElementById('viewReports'),
+    analysis: document.getElementById('viewAnalysis'),
     settings: document.getElementById('viewSettings'),
   };
   const navItems = document.querySelectorAll('.nav-item[data-view]');
@@ -49,6 +51,13 @@
     cookieInput.placeholder = settings.cookieConfigured
       ? '已配置,输入新 Cookie 可更换'
       : '登录 melody.shop.ele.me 后从 DevTools 复制';
+    document.getElementById('settingsChatModel').value = settings.chatModel || 'deepseek-v4.1-flash';
+    document.getElementById('settingsChatApiBase').value = settings.chatApiBase || '';
+    const chatKeyInput = document.getElementById('settingsChatApiKey');
+    chatKeyInput.value = settings.chatApiKey || '';
+    chatKeyInput.placeholder = settings.chatKeyFallback
+      ? '留空复用上方 API Key'
+      : (settings.chatApiKey ? '已配置,输入新 Key 可更换' : '留空复用上方 API Key;否则填对话模型专用 Key');
     await loadModels(settings.model);
     showSettingsStatus('已同步');
   }
@@ -67,6 +76,10 @@
           rootDir: document.getElementById('settingsRootDir').value.trim(),
           pageSize: Number(document.getElementById('pageSizeInput').value),
           delayMs: Number(document.getElementById('delayInput').value),
+          chatModel: document.getElementById('settingsChatModel').value.trim(),
+          ...(document.getElementById('settingsChatApiKey').value.trim() && !document.getElementById('settingsChatApiKey').value.includes('••••')
+            ? { chatApiKey: document.getElementById('settingsChatApiKey').value.trim() }
+            : {}),
           ...(document.getElementById('settingsCookie').value.trim()
             ? { cookie: document.getElementById('settingsCookie').value.trim() }
             : {}),
@@ -83,9 +96,90 @@
     }
   }
 
+  async function testImageApi() {
+    const status = document.getElementById('imageTestStatus');
+    const button = document.getElementById('btnTestImage');
+    button.disabled = true;
+    status.textContent = '测试中…';
+    status.className = 'badge badge-info';
+    try {
+      const response = await fetch('/api/image/test', { method: 'POST' });
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
+      if (data.statusCode === 0) throw new Error(data.error || '网络不可达');
+      const model = data.model || '';
+      if (data.statusCode === 200 && data.modelFound) {
+        status.textContent = '连通正常,模型在列表中';
+        status.className = 'badge badge-ok';
+      } else if (data.statusCode === 200 && Array.isArray(data.models)) {
+        status.textContent = `服务可达(${data.models.length} 个模型);列表中未找到 ${model}`;
+        status.className = 'badge badge-warn';
+      } else if (data.statusCode === 200) {
+        status.textContent = '服务可达;该网关不提供模型列表,无法校验模型名';
+        status.className = 'badge badge-warn';
+      } else if (data.statusCode === 401 || data.statusCode === 403) {
+        status.textContent = `服务返回 ${data.statusCode}:Key 可能无效`;
+        status.className = 'badge badge-fail';
+      } else {
+        status.textContent = `服务返回 ${data.statusCode}`;
+        status.className = 'badge badge-fail';
+      }
+    } catch (error) {
+      status.textContent = '失败:' + error.message;
+      status.className = 'badge badge-fail';
+    } finally {
+      button.disabled = false;
+    }
+  }
+
+  async function testChatModel() {
+    const status = document.getElementById('chatTestStatus');
+    const button = document.getElementById('btnTestChat');
+    button.disabled = true;
+    status.textContent = '测试中…';
+    status.className = 'badge badge-info';
+    try {
+      const response = await fetch('/api/chat/test', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          chatModel: document.getElementById('settingsChatModel').value.trim(),
+          chatApiKey: document.getElementById('settingsChatApiKey').value.trim(),
+        }),
+      });
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
+      status.textContent = `连通正常:${data.reply || 'OK'}`;
+      status.className = 'badge badge-ok';
+    } catch (error) {
+      status.textContent = '失败:' + error.message;
+      status.className = 'badge badge-fail';
+    } finally {
+      button.disabled = false;
+    }
+  }
+
+  function bindSettingsTabs() {
+    const tabs = document.getElementById('settingsApiTabs');
+    if (!tabs) return;
+    tabs.addEventListener('click', (event) => {
+      const btn = event.target.closest('button[data-tab]');
+      if (!btn || btn.classList.contains('active')) return;
+      tabs.querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
+      document.getElementById('settingsTabImage').hidden = btn.dataset.tab !== 'image';
+      document.getElementById('settingsTabChat').hidden = btn.dataset.tab !== 'chat';
+    });
+  }
+
   async function initSettings() {
     if (settingsLoaded) return;
+    bindSettingsTabs();
+    if (new URLSearchParams(location.search).get('tab') === 'chat') {
+      document.querySelector('#settingsApiTabs button[data-tab="chat"]')?.click();
+    }
     document.getElementById('btnSaveSettings').addEventListener('click', saveSettings);
+    document.getElementById('btnTestImage').addEventListener('click', testImageApi);
+    document.getElementById('btnTestChat').addEventListener('click', testChatModel);
     document.getElementById('btnClearLog').addEventListener('click', () => {
       clearLogs().catch(() => {});
     });
@@ -153,12 +247,17 @@
     if (name === 'gen' && window.shanGenInit) window.shanGenInit();
     if (name === 'tasks' && window.shanTasksInit) window.shanTasksInit();
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
+    if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
+    if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate();
     if (name === 'settings') initSettings();
+    // 智能问数机器人:仅经营分析相关页面显示
+    if (window.shanSetAnalysisVisible) window.shanSetAnalysisVisible(name === 'reports' || name === 'analysis');
   }
 
   navItems.forEach((n) => n.addEventListener('click', () => switchView(n.dataset.view)));
 
-  switchView('crawl');
+  const requestedView = new URLSearchParams(location.search).get('view');
+  switchView(views[requestedView] ? requestedView : 'crawl');
   startLogPolling();
 
   const splash = document.getElementById('splash');

+ 364 - 22
public/index.html

@@ -3,7 +3,7 @@
 <head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>门店采集 · 门店装修工具</title>
+<title>智慧运营中台</title>
 <link rel="icon" href="/assets/logo.png">
 <link rel="stylesheet" href="/style.css">
 </head>
@@ -11,8 +11,8 @@
 
 <!-- Splash Screen -->
 <div class="splash" id="splash">
-  <img src="/assets/logo.png" alt="门店装修工具" class="splash-logo">
-  <div class="splash-name">门店装修工具</div>
+  <img src="/assets/logo.png" alt="智慧运营中台" class="splash-logo">
+  <div class="splash-name">智慧运营中台</div>
   <div class="splash-sub">淘宝闪购专业版</div>
   <div class="splash-bar"><span></span></div>
   <div class="splash-powered">
@@ -27,19 +27,21 @@
     <div class="sidebar-brand">
       <div class="sidebar-logo"><img src="/assets/logo.png" alt="门"></div>
       <div class="sidebar-titles">
-        <div class="t1">门店装修工具</div>
+        <div class="t1">智慧运营中台</div>
         <div class="t2">淘宝闪购专业版</div>
       </div>
     </div>
 
-    <div class="nav-section-label">采集</div>
+    <div class="nav-section-label">门店装修</div>
     <button class="nav-item active" data-view="crawl"><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="M4 6h16M4 12h10M4 18h16"/></svg></span><span>门店采集</span></button>
-
-    <div class="nav-section-label">生成</div>
     <button class="nav-item" data-view="gen"><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 3l1.8 4.9L19 9.7l-4.3 3 1.2 5.3-3.9-3-3.9 3 1.2-5.3-4.3-3 5.2-1.8z"/></svg></span><span>视觉生成</span></button>
     <button class="nav-item" data-view="tasks"><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="M4 7h10M4 12h16M4 17h7"/><circle cx="18" cy="7" r="2"/><circle cx="15" cy="17" r="2"/></svg></span><span>任务中心</span></button>
     <button class="nav-item" data-view="history"><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"><rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="9" cy="10" r="1.8"/><path d="M21 16l-5-5-6 6"/></svg></span><span>历史创作</span></button>
 
+    <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="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>
     <button class="nav-item" data-view="settings"><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"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.05A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.05A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6h.09A1.7 1.7 0 0 0 10 3.05V3a2 2 0 1 1 4 0v.05A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.09a1.7 1.7 0 0 0 1.55.91H21a2 2 0 1 1 0 4h-.05a1.7 1.7 0 0 0-1.55 1z"/></svg></span><span>系统设置</span></button>
 
@@ -259,6 +261,179 @@
       </main>
     </div>
 
+    <!-- ==================== 数据采集 ==================== -->
+    <div id="viewReports" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>数据采集</h1>
+            <div class="sub">同步商家客户端经营数据到本地数据湖,多维报表与导出</div>
+          </div>
+          <div class="toolbar">
+            <span class="conn-help-wrap">
+              <span class="badge badge-info" id="reportConnBadge">检测连接…</span>
+              <button type="button" class="help-btn" id="btnClientHelp" title="客户端连接排查" aria-expanded="false">?</button>
+              <div class="help-popover" id="clientHelpPopover" hidden>
+                <div class="help-popover-title">客户端连接排查</div>
+                <ol class="help-popover-steps">
+                  <li><b>验证调试端口:</b>客户端带参启动后,浏览器打开 <code>http://127.0.0.1:9669/json/version</code> —— 看到一段 JSON(含 "Browser")就是通的;打不开就是客户端没带参。</li>
+                  <li><b>macOS 带参启动:</b>⌘Q 完全退出客户端(活动监视器确认进程没了)→ 终端执行:<code>open -a "淘宝闪购商家版" --args --remote-debugging-port=9669</code></li>
+                  <li><b>Windows 带参启动:</b>任务栏右键完全退出(任务管理器确认)→ 右键快捷方式 → 属性 → 「目标」末尾加 <code>--remote-debugging-port=9669</code> → 以后都用这个快捷方式启动。</li>
+                  <li>回到本页点「刷新」,徽章变「客户端已连接」后同步即可。若同步弹窗报 <code>Failed to fetch</code>,先确认服务在跑(启动窗口未关闭)。</li>
+                </ol>
+                <div class="help-popover-note">注意:客户端每次重启都必须带参启动,否则端口不会开启。</div>
+              </div>
+            </span>
+            <button class="btn btn-ghost btn-small" id="btnReportRefresh">
+              <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-primary btn-small" id="btnOpenSync">
+              <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>
+          </div>
+        </div>
+
+        <div class="entity-tabs" id="entityTabs"></div>
+
+        <div class="report-toolbar panel">
+          <div class="field report-field-sm">
+            <label>日期范围</label>
+            <select id="reportRangePreset">
+              <option value="7">近 7 天</option>
+              <option value="30" selected>近 30 天</option>
+              <option value="90">近 90 天</option>
+              <option value="custom">自定义</option>
+            </select>
+          </div>
+          <div class="field report-field-md" id="reportCustomRange" hidden>
+            <label>起止日期</label>
+            <div class="report-date-range">
+              <input type="date" id="reportFromDate">
+              <span>–</span>
+              <input type="date" id="reportToDate">
+            </div>
+          </div>
+          <div class="field report-field-lg">
+            <label>门店</label>
+            <div class="scan-dropdown" id="reportShopDropdown">
+              <button type="button" class="scan-dropdown-trigger" id="reportShopTrigger" aria-expanded="false" aria-haspopup="listbox">
+                <span id="reportShopText">全部门店</span>
+                <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>
+              </button>
+              <div class="scan-dropdown-menu" id="reportShopMenu" role="listbox" aria-multiselectable="true" hidden></div>
+            </div>
+          </div>
+          <div class="field">
+            <label>视图</label>
+            <div class="seg-control" id="reportViewModes" role="tablist">
+              <button type="button" class="active" data-mode="detail">明细</button>
+              <button type="button" data-mode="shop">按门店</button>
+              <button type="button" data-mode="date">按日期</button>
+            </div>
+          </div>
+          <div class="field report-field-grow">
+            <label>搜索门店</label>
+            <input type="text" id="reportSearch" placeholder="输入门店名称过滤">
+          </div>
+          <div class="report-toolbar-actions">
+            <button class="btn btn-ghost btn-small" id="btnColumnCfg">列设置</button>
+            <button class="btn btn-ghost btn-small" id="btnExportCsv">导出 CSV</button>
+          </div>
+        </div>
+
+        <div class="stats-grid" id="reportKpis"></div>
+
+        <div class="panel panel-fixed report-table-panel">
+          <div class="panel-title">
+            <span>报表数据</span>
+            <span class="actions"><span class="badge badge-info" id="reportRowCount">0 行</span></span>
+          </div>
+          <div class="report-table-wrap" id="reportTableWrap">
+            <table class="data-table report-table" id="reportTable">
+              <thead id="reportTableHead"></thead>
+              <tbody id="reportTableBody"></tbody>
+            </table>
+            <div id="reportEmpty" class="empty-hint" hidden>
+              <div class="empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20V11M10 20V4M16 20v-6M2.5 20h19"/></svg></div>
+              <p id="reportEmptyText">暂无数据<br><small>请确认商家客户端已登录,然后点击右上角「同步数据」</small></p>
+            </div>
+          </div>
+        </div>
+      </main>
+    </div>
+
+    <!-- ==================== 趋势图表 ==================== -->
+    <div id="viewAnalysis" 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="btnExportHtml">
+              <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>
+          </div>
+        </div>
+
+        <div class="entity-tabs" id="analysisEntityTabs"></div>
+
+        <div class="report-toolbar panel">
+          <div class="field report-field-sm">
+            <label>日期范围</label>
+            <select id="analysisRangePreset">
+              <option value="7">近 7 天</option>
+              <option value="30" selected>近 30 天</option>
+              <option value="90">近 90 天</option>
+            </select>
+          </div>
+          <div class="field report-field-lg">
+            <label>门店</label>
+            <div class="scan-dropdown" id="anaShopDropdown">
+              <button type="button" class="scan-dropdown-trigger" id="anaShopTrigger" aria-expanded="false" aria-haspopup="listbox">
+                <span id="anaShopText">全部门店</span>
+                <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>
+              </button>
+              <div class="scan-dropdown-menu" id="anaShopMenu" role="listbox" aria-multiselectable="true" hidden></div>
+            </div>
+          </div>
+          <div class="field report-field-sm">
+            <label>主指标</label>
+            <select id="chartMetric" class="chart-metric-select"></select>
+          </div>
+          <label class="ana-compare-toggle" title="主趋势图中叠加上一周期的虚线对照">
+            <input type="checkbox" id="anaComparePrev" checked>
+            <span>对比上期</span>
+          </label>
+        </div>
+
+        <div class="delta-strip" id="analysisDeltas"></div>
+
+        <div class="ana-grid">
+          <div class="panel ana-cell ana-span2">
+            <div class="panel-title"><span id="anaTrendTitle">主指标趋势</span></div>
+            <div id="chartTrend" class="chart chart-lg"></div>
+          </div>
+          <div class="panel ana-cell">
+            <div class="panel-title"><span>门店排行 Top 10</span></div>
+            <div id="chartRank" class="chart chart-lg"></div>
+          </div>
+          <div class="panel ana-cell">
+            <div class="panel-title"><span>转化漏斗(曝光 → 进店 → 下单)</span></div>
+            <div id="chartFunnel" class="chart chart-lg"></div>
+          </div>
+          <div class="panel ana-cell ana-span2">
+            <div class="panel-title"><span id="anaCompareTitle">门店对比 · Top 5</span></div>
+            <div id="chartCompare" class="chart chart-lg"></div>
+          </div>
+        </div>
+      </main>
+    </div>
+
     <!-- ==================== 系统设置 ==================== -->
     <div id="viewSettings" class="view">
       <main class="content">
@@ -271,16 +446,29 @@
         <div class="settings-workspace">
           <div class="settings-forms">
           <div class="panel panel-narrow">
-            <div class="stage-marker"><span class="num">1</span><span>API 设置</span></div>
+            <div class="stage-marker">
+              <span class="num">1</span><span>API 设置</span>
+              <div class="seg-control settings-api-tabs" id="settingsApiTabs" role="tablist">
+                <button type="button" class="active" data-tab="image">图像模型</button>
+                <button type="button" data-tab="chat">对话模型</button>
+              </div>
+            </div>
             <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg></span>
             <div class="panel-body">
+              <div id="settingsTabImage" class="settings-tab-body" role="tabpanel">
               <div class="field">
                 <div class="field-label-row">
                   <label>API Key<span class="req">*</span></label>
-                  <a class="settings-link settings-link-compact" href="https://o-hubs.com/api/console/keys" target="_blank" rel="noopener noreferrer">
-                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg>
-                    <span>获取 API Key</span>
-                  </a>
+                  <span class="label-links">
+                    <a class="settings-link settings-link-compact" href="https://o-hubs.com/api/console/keys" target="_blank" rel="noopener noreferrer">
+                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="4.5"/><path d="M11 12L21 2"/><path d="M16 7l3 3"/></svg>
+                      <span>获取 API Key</span>
+                    </a>
+                    <a class="settings-link settings-link-compact" href="https://o-hubs.com/home" target="_blank" rel="noopener noreferrer">
+                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="3"/><path d="M3 10h18"/><path d="M7 15h3"/></svg>
+                      <span>充值</span>
+                    </a>
+                  </span>
                 </div>
                 <input type="text" id="settingsApiKey" placeholder="sk-...">
               </div>
@@ -288,16 +476,44 @@
                 <label>API 地址</label>
                 <input type="text" id="settingsApiBase" value="https://api.lk888.ai" disabled>
               </div>
-	              <div class="field">
-	                <label>默认模型</label>
-	                <select id="settingsModel"></select>
-	              </div>
-	              <div class="settings-links">
-	                <a class="settings-link" href="https://o-hubs.com/home" target="_blank" rel="noopener noreferrer">
-	                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="3"/><path d="M3 10h18"/><path d="M7 15h3"/></svg>
-	                  <span>充值</span>
-	                </a>
-	              </div>
+              <div class="field">
+                <label>默认模型</label>
+                <select id="settingsModel"></select>
+              </div>
+              <div class="settings-links">
+                <button type="button" class="settings-link settings-link-btn" id="btnTestImage">
+                  <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>
+                  <span>测试 API 与模型</span>
+                </button>
+                <span id="imageTestStatus" class="badge"></span>
+              </div>
+              </div>
+              <div id="settingsTabChat" class="settings-tab-body" role="tabpanel" hidden>
+              <div class="field">
+                <label>API Key</label>
+                <input type="text" id="settingsChatApiKey" placeholder="留空复用生图 API Key;否则填对话模型专用 Key">
+                <div class="help">留空则复用「图像模型」的 API Key</div>
+              </div>
+              <div class="field">
+                <label>API 地址</label>
+                <input type="text" id="settingsChatApiBase" disabled placeholder="默认与生图 API 地址一致">
+                <div class="help">默认使用生图的 API 地址,无需修改</div>
+              </div>
+              <div class="field">
+                <label>对话模型</label>
+                <select id="settingsChatModel">
+                  <option value="deepseek-v4.1-flash">deepseek-v4.1-flash</option>
+                  <option value="glm-5.3-flash">glm-5.3-flash</option>
+                </select>
+              </div>
+              <div class="settings-links">
+                <button type="button" class="settings-link settings-link-btn" id="btnTestChat">
+                  <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>
+                  <span>测试对话模型</span>
+                </button>
+                <span id="chatTestStatus" class="badge"></span>
+              </div>
+              </div>
             </div>
           </div>
           <div class="panel panel-narrow">
@@ -350,6 +566,43 @@
 
 <div class="toast-wrap" id="toast-wrap"></div>
 
+<!-- 智能问数 · 机器人(仅经营分析相关页面显示) -->
+<div class="chatbot-fab" id="chatbotFab" title="智能问数" hidden>
+  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 8V5"/><circle cx="12" cy="3.6" r="1.4"/><circle cx="9" cy="13" r="1.1" fill="currentColor" stroke="none"/><circle cx="15" cy="13" r="1.1" fill="currentColor" stroke="none"/><path d="M9.5 16.2h5"/></svg>
+</div>
+<div class="chatbot-panel" id="chatbotPanel" hidden>
+  <div class="chatbot-head">
+    <span class="chatbot-title">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 8V5"/><circle cx="12" cy="3.6" r="1.4"/><circle cx="9" cy="13" r="1.1" fill="currentColor" stroke="none"/><circle cx="15" cy="13" r="1.1" fill="currentColor" stroke="none"/><path d="M9.5 16.2h5"/></svg>
+      智能问数
+    </span>
+    <span class="chatbot-sub">支持上下文追问</span>
+    <button type="button" class="chatbot-icon-btn" id="btnChatClear" title="清空对话">
+      <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 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/></svg>
+    </button>
+    <button type="button" class="chatbot-icon-btn" id="btnChatClose" title="收起">
+      <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 6l12 12M18 6L6 18"/></svg>
+    </button>
+  </div>
+  <div class="chatbot-body" id="chatbotBody">
+    <div class="chatbot-msg bot">
+      <div class="chatbot-bubble">你好,我是经营数据助手。可以直接问,也支持追问 —— 比如先问「上月营业额Top5门店」,再问「那二店的单量呢」。</div>
+    </div>
+    <div class="chatbot-chips" id="chatbotChips">
+      <button type="button" data-q="上月营业额Top5门店">上月营业额Top5门店</button>
+      <button type="button" data-q="最近7天进店转化率趋势">最近7天进店转化率趋势</button>
+      <button type="button" data-q="昨天营业额和订单总量">昨天营业额和订单总量</button>
+      <button type="button" data-q="哪几家门店曝光高但下单少">哪几家门店曝光高但下单少</button>
+    </div>
+  </div>
+  <div class="chatbot-input-row">
+    <input type="text" id="chatbotInput" placeholder="输入问题,回车发送…" autocomplete="off">
+    <button type="button" class="btn btn-primary btn-small" id="btnChatSend" title="发送">
+      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 11l18-8-8 18-2.5-7.5z"/></svg>
+    </button>
+  </div>
+</div>
+
 <!-- 添加店铺弹窗 -->
 <div class="modal-overlay" id="modalAddShop" hidden>
   <div class="modal">
@@ -376,10 +629,99 @@
   </div>
 </div>
 
+<!-- 列设置弹窗 -->
+<div class="modal-overlay" id="modalReportColumns" hidden>
+  <div class="modal">
+    <div class="modal-header">
+      <div class="modal-title">
+        <span class="modal-status-icon">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
+        </span>
+        <span>列设置</span>
+      </div>
+      <button class="modal-close" id="btnCloseColumnCfg">&times;</button>
+    </div>
+    <div class="modal-body">
+      <div class="help" style="margin-bottom:8px;">勾选需要在报表中展示的字段(日期与门店名称固定展示)</div>
+      <div class="colcfg-list" id="columnCfgList"></div>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-small" id="btnResetColumns">恢复默认</button>
+      <button class="btn btn-primary btn-small" id="btnApplyColumns">应用</button>
+    </div>
+  </div>
+</div>
+
+<!-- 数据同步弹窗 -->
+<div class="modal-overlay" id="modalReportSync" hidden>
+  <div class="modal modal-wide">
+    <div class="modal-header">
+      <div class="modal-title">
+        <span class="modal-status-icon">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" 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>
+      </div>
+      <button class="modal-close" id="btnCloseSync">&times;</button>
+    </div>
+    <div class="modal-body">
+      <div class="badge" id="syncEntityBadge">未识别主体</div>
+      <div class="field">
+        <label>同步范围</label>
+        <select id="syncScope">
+          <option value="all" selected>全部(公司 + 连锁 + 门店明细)</option>
+          <option value="shop">仅门店明细</option>
+          <option value="company">仅公司 / 连锁汇总</option>
+          <option value="custom">指定门店</option>
+        </select>
+      </div>
+      <div class="field" id="syncShopPickWrap" hidden>
+        <label>选择门店</label>
+        <div class="scan-dropdown" id="syncShopDropdown">
+          <button type="button" class="scan-dropdown-trigger" id="syncShopTrigger" aria-expanded="false" aria-haspopup="listbox">
+            <span id="syncShopText">选择门店</span>
+            <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>
+          </button>
+          <div class="scan-dropdown-menu" id="syncShopMenu" role="listbox" aria-multiselectable="true" hidden></div>
+        </div>
+      </div>
+      <div class="field">
+        <label>日期范围</label>
+        <select id="syncRange">
+          <option value="7">近 7 天</option>
+          <option value="30" selected>近 30 天</option>
+          <option value="90">近 90 天</option>
+        </select>
+        <div class="help">经营数据后台保留约 180 天;当天数据未结算,同步区间右端取昨日</div>
+      </div>
+      <div id="syncProgressWrap" hidden>
+        <div class="sync-progress-row">
+          <div class="sync-progress-bar"><span id="syncProgressBar"></span></div>
+          <span class="badge badge-info" id="syncProgressText">0 / 0</span>
+        </div>
+        <div class="sync-stat-row">
+          <span class="badge badge-ok" id="syncStatAdded">新增 0</span>
+          <span class="badge badge-info" id="syncStatUpdated">更新 0</span>
+          <span class="badge badge-fail" id="syncStatFailed">失败 0</span>
+          <button class="btn btn-ghost btn-small" id="btnSyncCancel" hidden>取消</button>
+        </div>
+        <div class="sync-steps" id="syncSteps"></div>
+      </div>
+      <div class="help" id="syncHint">同步会把所选区间逐日、逐店的经营数据写入本地数据湖(data/reports/),可反复同步自动去重合并。</div>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-small" id="btnCancelSync">关闭</button>
+      <button class="btn btn-primary btn-small" id="btnStartSync">开始同步</button>
+    </div>
+  </div>
+</div>
+
 <script src="/crawl.js"></script>
 <script src="/gen.js"></script>
 <script src="/tasks.js"></script>
 <script src="/history.js"></script>
+<script src="/vendor/echarts.min.js"></script>
+<script src="/reports.js"></script>
 <script src="/app.js"></script>
 </body>
 </html>

+ 1075 - 0
public/reports.js

@@ -0,0 +1,1075 @@
+'use strict';
+
+/**
+ * 数据采集 + 趋势图表 · 前端逻辑
+ * 数据采集:多主体 tab / 同步 / 筛选 / BI 表格 / CSV 导出
+ * 趋势图表:环比指标条 / 主趋势 / 门店排行 / 转化漏斗 / 门店对比 / 周报导出
+ * 智能问数:右下角机器人(仅经营分析页面显示),弹窗对话,支持上下文追问
+ */
+(function () {
+
+  // ---------- 工具 ----------
+
+  function showToast(msg, type) {
+    const wrap = document.getElementById('toast-wrap');
+    const node = document.createElement('div');
+    node.className = 'toast toast-' + (type || 'success');
+    node.textContent = msg;
+    wrap.appendChild(node);
+    setTimeout(() => { node.style.opacity = '0'; node.style.transition = 'opacity 0.3s'; }, 2600);
+    setTimeout(() => { if (node.parentNode) wrap.removeChild(node); }, 3000);
+  }
+
+  async function jsonFetch(url, opts) {
+    const r = await fetch(url, opts);
+    const data = await r.json().catch(() => ({}));
+    if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
+    return data;
+  }
+
+  const z2 = (n) => String(n).padStart(2, '0');
+  function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
+  function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
+  function ymdToDash(s) { return /^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || ''); }
+  function dashToYmd(s) { return /^\d{4}-\d{2}-\d{2}$/.test(String(s)) ? s.replace(/-/g, '') : ''; }
+
+  function fmtNum(v) {
+    if (v == null || v === '') return '—';
+    const n = Number(v);
+    return Number.isFinite(n) ? n.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) : String(v);
+  }
+  function fmtMoney(v) {
+    if (v == null || v === '') return '—';
+    const n = Number(v);
+    return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v);
+  }
+  function fmtPct(v) {
+    if (v == null || v === '') return '—';
+    const n = Number(v);
+    return Number.isFinite(n) ? (n * 100).toFixed(2) + '%' : String(v);
+  }
+  function fmtCell(col, v) {
+    if (col.type === 'pct') return fmtPct(v);
+    if (col.type === 'money') return fmtMoney(v);
+    if (col.type === 'int') return fmtNum(v);
+    if (col.type === 'num') return v == null || v === '' ? '—' : Number(v).toFixed(2);
+    if (col.type === 'date') return v ? ymdToDash(v) : '—';
+    return v == null || v === '' ? '—' : String(v);
+  }
+  function fmtByCol(col, v) {
+    if (v == null || v === '') return '—';
+    if (!col) return String(v);
+    if (col.type === 'pct') return fmtPct(v);
+    if (col.type === 'money') return fmtMoney(v);
+    if (col.type === 'int') return fmtNum(v);
+    if (col.type === 'num') return Number(v).toFixed(2);
+    return String(v);
+  }
+
+  const RATE_THRESHOLDS = { clkExpRate: { good: 0.09, bad: 0.06 }, ordClkRate: { good: 0.025, bad: 0.02 } };
+  function rateClass(key, v) {
+    const t = RATE_THRESHOLDS[key];
+    if (!t || v == null || v === '') return '';
+    const n = Number(v);
+    if (!Number.isFinite(n)) return '';
+    if (n >= t.good) return 'val-good';
+    if (n < t.bad) return 'val-bad';
+    return 'val-warn';
+  }
+
+  const LS = {
+    get(k, fallback) { try { const v = localStorage.getItem(k); return v == null ? fallback : JSON.parse(v); } catch { return fallback; } },
+    set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* ignore */ } },
+  };
+
+  // ---------- 状态 ----------
+
+  const state = {
+    inited: false,
+    entities: [],
+    entity: null,
+    preset: '30',
+    mode: 'detail',
+    shopFilter: new Set(),
+    q: '',
+    columns: [],
+    visible: new Set(),
+    sortKey: 'date',
+    sortDir: 'desc',
+    rows: [],
+    summary: null,
+    conn: null,
+    syncShops: new Set(),
+    syncPollTimer: null,
+    reportAbort: null,
+    analysisShopFilter: new Set(),
+    analysisPreset: '30',
+    comparePrev: true,
+    chat: { open: false, busy: false, messages: [] },
+  };
+
+  let chartMetric = LS.get('shanReports.chartMetric', 'grossAmt');
+  const chartInstances = {};
+  let anaCache = null;
+  let lastAskApply = null;
+
+  const $ = (id) => document.getElementById(id);
+
+  // ---------- 日期区间 ----------
+
+  function currentRange() {
+    if (state.preset === 'custom') {
+      const from = dashToYmd($('reportFromDate').value);
+      const to = dashToYmd($('reportToDate').value);
+      if (from && to && from <= to) return { from, to };
+      return { from: daysAgoYmd(30), to: daysAgoYmd(0) };
+    }
+    const days = Number(state.preset) || 30;
+    return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
+  }
+
+  function analysisRange() {
+    const sel = $('analysisRangePreset');
+    const days = Number(sel && sel.value) || 30;
+    return { from: daysAgoYmd(days), to: daysAgoYmd(0) };
+  }
+
+  // ---------- 主体 tabs(数据采集 / 趋势图表 共用选中) ----------
+
+  function activeEntity() {
+    return state.entities.find((e) => e.key === state.entity) || null;
+  }
+
+  function renderEntityTabs() {
+    for (const containerId of ['entityTabs', 'analysisEntityTabs']) {
+      const wrap = $(containerId);
+      if (!wrap) continue;
+      wrap.innerHTML = '';
+      if (!state.entities.length) {
+        const empty = document.createElement('div');
+        empty.className = 'entity-tabs-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();
+          loadReport();
+          renderCharts();
+        });
+        wrap.appendChild(btn);
+      }
+    }
+  }
+
+  // ---------- 门店多选下拉(数据采集 / 同步 / 趋势图表 三处) ----------
+
+  function toggleDropdown(menuId, triggerId, open) {
+    const trigger = $(triggerId);
+    const menu = menuId ? $(menuId) : null;
+    const shouldOpen = open === undefined ? (menu ? menu.hidden : false) : open;
+    if (menu) menu.hidden = !shouldOpen;
+    if (trigger) trigger.setAttribute('aria-expanded', String(shouldOpen));
+  }
+
+  function fillShopMenu(menu, shops) {
+    menu.innerHTML = '';
+    const all = document.createElement('button');
+    all.type = 'button';
+    all.className = 'scan-dropdown-item';
+    all.dataset.value = '__all__';
+    all.setAttribute('role', 'option');
+    all.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>';
+    menu.appendChild(all);
+    const sorted = shops.slice().sort((a, b) => String(a.shopName).localeCompare(String(b.shopName), 'zh-CN'));
+    for (const s of sorted) {
+      const item = document.createElement('button');
+      item.type = 'button';
+      item.className = 'scan-dropdown-item';
+      item.dataset.value = String(s.shopId);
+      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>';
+      item.lastElementChild.textContent = s.shopName;
+      menu.appendChild(item);
+    }
+  }
+
+  function renderDropdownSelection(menu, selection, textEl, allLabel) {
+    menu.querySelectorAll('.scan-dropdown-item').forEach((node) => {
+      const selected = selection.has(node.dataset.value);
+      node.classList.toggle('selected', selected);
+      node.setAttribute('aria-selected', String(selected));
+    });
+    const picked = Array.from(selection).filter((v) => v !== '__all__');
+    textEl.textContent = picked.length ? `已选 ${picked.length} 家门店` : allLabel;
+  }
+
+  function renderShopOptions() {
+    const entity = activeEntity();
+    const shops = (entity && entity.shops) || [];
+    const menus = [
+      { menu: $('reportShopMenu'), sel: state.shopFilter, text: $('reportShopText'), label: '全部门店' },
+      { menu: $('syncShopMenu'), sel: state.syncShops, text: $('syncShopText'), label: '选择门店' },
+      { menu: $('anaShopMenu'), sel: state.analysisShopFilter, text: $('anaShopText'), label: '全部门店' },
+    ];
+    for (const m of menus) {
+      if (!m.menu) continue;
+      fillShopMenu(m.menu, shops);
+      renderDropdownSelection(m.menu, m.sel, m.text, m.label);
+    }
+  }
+
+  function makeDropdownHandler(menu, selection, textEl, allLabel, onChange) {
+    return (event) => {
+      const item = event.target.closest('[data-value]');
+      if (!item) return;
+      const value = item.dataset.value;
+      if (value === '__all__') selection.clear();
+      else if (selection.has(value)) selection.delete(value);
+      else selection.add(value);
+      renderDropdownSelection(menu, selection, textEl, allLabel);
+      if (onChange) onChange();
+    };
+  }
+
+  // ---------- KPI 卡片(数据采集页) ----------
+
+  function renderKpis() {
+    const wrap = $('reportKpis');
+    wrap.innerHTML = '';
+    const s = state.summary || {};
+    const cards = [
+      { label: '营业额(元)', value: fmtMoney(s.grossAmt), cls: 'accent' },
+      { label: '收入(元)', value: fmtMoney(s.incomeAmt), cls: 'ok' },
+      { label: '有效订单', value: fmtNum(s.validOrderCnt), cls: 'accent' },
+      { label: '曝光人数', value: fmtNum(s.expUv), cls: '' },
+      { label: '下单人数', value: fmtNum(s.orderUserCnt), cls: '' },
+      { label: '进店转化率', value: fmtPct(s.clkExpRate), cls: 'warn', rate: 'clkExpRate' },
+      { label: '客单价(元)', value: fmtMoney(s.perActualPay), cls: 'ok' },
+    ];
+    for (const c of cards) {
+      const div = document.createElement('div');
+      div.className = 'metric ' + (c.cls || '');
+      div.innerHTML = `<span class="metric-body"><span class="metric-label"></span><span class="metric-value ${c.rate ? rateClass(c.rate, s[c.rate]) : ''}"></span></span>`;
+      div.querySelector('.metric-label').textContent = c.label;
+      div.querySelector('.metric-value').textContent = c.value;
+      wrap.appendChild(div);
+    }
+  }
+
+  // ---------- 报表表格(数据采集页) ----------
+
+  function metricColumns() {
+    return state.columns.filter((c) => c.key !== 'date' && c.key !== 'shopName' && state.visible.has(c.key));
+  }
+
+  function tableColumns() {
+    const metricCols = metricColumns();
+    if (state.mode === 'shop') return [{ key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
+    if (state.mode === 'date') return [{ key: 'date', label: '日期', type: 'date' }, ...metricCols];
+    return [{ key: 'date', label: '日期', type: 'date' }, { key: 'shopName', label: '门店名称', type: 'text' }, ...metricCols];
+  }
+
+  function sortedRows() {
+    return state.rows.slice().sort((a, b) => {
+      const av = a[state.sortKey], bv = 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);
+      return String(av).localeCompare(String(bv), 'zh-CN') * (state.sortDir === 'asc' ? 1 : -1);
+    });
+  }
+
+  function renderTable() {
+    const cols = tableColumns();
+    const thead = $('reportTableHead');
+    const tbody = $('reportTableBody');
+    $('reportRowCount').textContent = `${state.rows.length} 行`;
+    const empty = $('reportEmpty');
+    if (!state.rows.length) {
+      thead.innerHTML = '';
+      tbody.innerHTML = '';
+      empty.hidden = false;
+      return;
+    }
+    empty.hidden = true;
+    thead.innerHTML = '<tr>' + cols.map((c) => {
+      const active = state.sortKey === c.key;
+      const arrow = active ? (state.sortDir === 'asc' ? ' ↑' : ' ↓') : '';
+      return `<th class="col-${c.type}${['money', 'int', 'pct', 'num'].includes(c.type) ? ' col-num' : ''}" data-key="${c.key}">${c.label}${arrow}</th>`;
+    }).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>`;
+    }).join('') + '</tr>').join('');
+  }
+
+  // ---------- 数据加载(数据采集页) ----------
+
+  async function loadReport() {
+    const entity = state.entity;
+    if (!entity) {
+      state.rows = []; state.summary = null;
+      renderTable(); renderKpis();
+      return;
+    }
+    if (state.reportAbort) state.reportAbort.abort();
+    const ctrl = new AbortController();
+    state.reportAbort = ctrl;
+    const { from, to } = currentRange();
+    try {
+      let url = state.mode === 'detail'
+        ? `/api/reports/daily?entity=${encodeURIComponent(entity)}&from=${from}&to=${to}&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)}`;
+      const data = await jsonFetch(url, { signal: ctrl.signal });
+      if (state.entity !== entity) return;
+      state.rows = data.rows || [];
+      state.summary = data.summary || null;
+      renderKpis();
+      renderTable();
+    } catch (e) {
+      if (e.name === 'AbortError') return;
+      showToast('报表加载失败:' + e.message, 'error');
+    }
+  }
+
+  // ---------- 连接状态 ----------
+
+  async function refreshConn() {
+    const badge = $('reportConnBadge');
+    try {
+      const data = await jsonFetch('/api/reports/status');
+      state.conn = data.napos || null;
+      state.entities = data.entities || [];
+      if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
+        const saved = LS.get('shanReports.entity', null);
+        state.entity = (saved && state.entities.some((e) => e.key === saved)) ? saved : (state.entities[0] ? state.entities[0].key : null);
+      }
+      renderEntityTabs();
+      renderShopOptions();
+      const ok = state.conn && state.conn.connected;
+      badge.textContent = ok ? '客户端已连接' : '客户端未连接';
+      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') || '未检测到调试端口';
+    } catch {
+      badge.textContent = '状态检测失败';
+      badge.className = 'badge badge-fail';
+    }
+  }
+
+  // ---------- 列设置 ----------
+
+  function loadColumnPrefs() {
+    const saved = LS.get('shanReports.columns.v1', null);
+    const defaults = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
+    if (Array.isArray(saved)) {
+      const valid = new Set(state.columns.map((c) => c.key));
+      state.visible = new Set(saved.filter((k) => valid.has(k)));
+    } else {
+      state.visible = defaults;
+    }
+    if (!state.visible.size) state.visible = defaults;
+  }
+
+  function renderColumnCfg() {
+    const list = $('columnCfgList');
+    list.innerHTML = '';
+    for (const c of state.columns) {
+      if (c.locked) continue;
+      const label = document.createElement('label');
+      label.className = 'colcfg-item';
+      const checked = state.visible.has(c.key) ? 'checked' : '';
+      label.innerHTML = `<input type="checkbox" value="${c.key}" ${checked}><span></span>${c.reserved ? '<em class="colcfg-reserved">预留</em>' : ''}`;
+      label.querySelector('span').textContent = c.label;
+      list.appendChild(label);
+    }
+  }
+
+  function applyColumnCfg() {
+    const picks = new Set();
+    $('columnCfgList').querySelectorAll('input[type=checkbox]').forEach((cb) => {
+      if (cb.checked) picks.add(cb.value);
+    });
+    state.visible = picks;
+    LS.set('shanReports.columns.v1', Array.from(picks));
+    $('modalReportColumns').hidden = true;
+    renderTable();
+  }
+
+  // ---------- 同步 ----------
+
+  function renderSyncProgress(st) {
+    const pct = st.planTotal ? Math.round((st.done / st.planTotal) * 100) : 0;
+    $('syncProgressBar').style.width = pct + '%';
+    $('syncProgressText').textContent = `${st.done} / ${st.planTotal}`;
+    $('syncStatAdded').textContent = `新增 ${st.added}`;
+    $('syncStatUpdated').textContent = `更新 ${st.updated}`;
+    $('syncStatFailed').textContent = `失败 ${st.failed}`;
+    const steps = (st.steps || []).slice(-8);
+    $('syncSteps').innerHTML = steps.map((s) => {
+      const cls = s.ok ? 'ok' : 'fail';
+      const detail = s.ok ? `+${s.added}/~${s.updated}` : (s.error || '失败').slice(0, 60);
+      return `<div class="sync-step ${cls}"><span></span><em>${detail}</em></div>`;
+    }).join('');
+    const stepNames = $('syncSteps').querySelectorAll('.sync-step span');
+    steps.forEach((s, i) => { stepNames[i].textContent = s.label; });
+    $('btnSyncCancel').hidden = !st.running;
+    $('btnStartSync').disabled = !!st.running;
+    $('btnStartSync').textContent = st.running ? '同步中…' : '开始同步';
+  }
+
+  function pollSyncStatus() {
+    if (state.syncPollTimer) clearInterval(state.syncPollTimer);
+    state.syncPollTimer = setInterval(async () => {
+      try {
+        const st = await jsonFetch('/api/reports/sync/status');
+        renderSyncProgress(st);
+        if (!st.running) {
+          clearInterval(state.syncPollTimer);
+          state.syncPollTimer = null;
+          if (st.error) showToast('同步出错:' + st.error, 'error');
+          else showToast(st.cancelled ? '同步已取消' : `同步完成:新增 ${st.added},更新 ${st.updated}`);
+          refreshConn().then(loadReport);
+        }
+      } catch { /* 轮询失败忽略 */ }
+    }, 900);
+  }
+
+  async function openSyncModal() {
+    $('modalReportSync').hidden = false;
+    $('syncEntityBadge').textContent = '识别主体中…';
+    $('syncEntityBadge').className = 'badge badge-info';
+    $('syncShopPickWrap').hidden = true;
+    $('syncProgressWrap').hidden = true;
+    try {
+      const tree = await jsonFetch('/api/reports/tree');
+      const badge = $('syncEntityBadge');
+      badge.textContent = `当前主体:${(tree.entity && tree.entity.name) || (tree.merchant && tree.merchant.name) || '未知'} · 门店 ${(tree.shops || []).length} 家`;
+      badge.className = 'badge badge-ok';
+      if (tree.entity && tree.entity.key && tree.entity.key !== state.entity) {
+        state.entity = tree.entity.key;
+        LS.set('shanReports.entity', tree.entity.key);
+      }
+      await refreshConn();
+      renderSyncShopOptions(tree.shops || []);
+    } catch (e) {
+      const badge = $('syncEntityBadge');
+      badge.textContent = '未连接客户端:' + e.message;
+      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(' ');
+      } catch { /* 服务不可达时保留默认文案 */ }
+    }
+  }
+
+  function renderSyncShopOptions(shops) {
+    state.syncShops.clear();
+    fillShopMenu($('syncShopMenu'), shops);
+    renderDropdownSelection($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店');
+  }
+
+  async function startSync() {
+    const scope = $('syncScope').value;
+    const days = Number($('syncRange').value) || 30;
+    const body = { from: daysAgoYmd(days), to: daysAgoYmd(1), scope: scope === 'custom' ? 'shop' : scope };
+    if (scope === 'custom') {
+      const shops = Array.from(state.syncShops).filter((v) => v !== '__all__');
+      if (!shops.length) { showToast('请先选择要同步的门店', 'error'); return; }
+      body.shopIds = shops;
+    }
+    try {
+      await jsonFetch('/api/reports/sync', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(body),
+      });
+      $('syncProgressWrap').hidden = false;
+      renderSyncProgress({ planTotal: 0, done: 0, added: 0, updated: 0, failed: 0, steps: [], running: true });
+      pollSyncStatus();
+    } catch (e) {
+      showToast('同步启动失败:' + e.message, 'error');
+    }
+  }
+
+  // ---------- 导出 ----------
+
+  function exportCsv() {
+    if (!state.entity) { showToast('暂无主体可导出', 'error'); 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}`;
+    if (state.shopFilter.size) url += `&shops=${encodeURIComponent(Array.from(state.shopFilter).join(','))}`;
+    if (state.q) url += `&q=${encodeURIComponent(state.q)}`;
+    window.location.href = url;
+  }
+
+  function exportHtml() {
+    if (!state.entity) { showToast('暂无主体可导出', 'error'); return; }
+    const { from, to } = analysisRange();
+    window.open(`/api/reports/report-html?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}`, '_blank');
+  }
+
+  // ---------- 趋势图表 ----------
+
+  function chartCol(key) {
+    return state.columns.find((c) => c.key === key) || { key, label: key, type: 'num' };
+  }
+
+  function ensureChart(instanceKey, domId) {
+    if (typeof echarts === 'undefined') return null;
+    const dom = $(domId);
+    if (!dom) return null;
+    if (!chartInstances[instanceKey]) chartInstances[instanceKey] = echarts.init(dom);
+    return chartInstances[instanceKey];
+  }
+
+  function metricColumnOptions() {
+    return state.columns.filter((c) => ['int', 'money', 'pct', 'num'].includes(c.type) && !c.reserved);
+  }
+
+  function renderChartMetricSelect() {
+    const sel = $('chartMetric');
+    if (!sel) return;
+    sel.innerHTML = '';
+    for (const c of metricColumnOptions()) {
+      const opt = document.createElement('option');
+      opt.value = c.key;
+      opt.textContent = c.label;
+      if (c.key === chartMetric) opt.selected = true;
+      sel.appendChild(opt);
+    }
+    if (!sel.value && sel.options.length) sel.value = sel.options[0].value;
+    chartMetric = sel.value || 'grossAmt';
+  }
+
+  const AXIS_TXT = { axisLabel: { color: '#6b6560' }, axisLine: { lineStyle: { color: '#e8e4df' } } };
+  const SPLIT_LINE = { lineStyle: { color: '#f0ede8' } };
+  const ACCENT = '#ff6b35';
+
+  async function ensureAnalysisData() {
+    const { from, to } = analysisRange();
+    const shopSuffix = state.analysisShopFilter.size
+      ? `&shops=${encodeURIComponent(Array.from(state.analysisShopFilter).join(','))}`
+      : '';
+    const key = [state.entity, from, to, state.analysisShopFilter.size ? [...state.analysisShopFilter].sort().join(',') : ''].join('|');
+    if (anaCache && anaCache.key === key) return anaCache;
+
+    const fd = (s) => new Date(Number(String(s).slice(0, 4)), Number(String(s).slice(4, 6)) - 1, Number(String(s).slice(6, 8)));
+    const spanDays = Math.max(1, Math.round((fd(to) - fd(from)) / 86400000) + 1);
+    const prevToD = fd(from);
+    prevToD.setDate(prevToD.getDate() - 1);
+    const prevTo = ymdLocal(prevToD);
+    const prevFromD = new Date(prevToD);
+    prevFromD.setDate(prevFromD.getDate() - (spanDays - 1));
+    const prevFrom = ymdLocal(prevFromD);
+
+    const base = `/api/reports/aggregate?entity=${encodeURIComponent(state.entity)}`;
+    const [byDate, byDatePrev, byShop, topDaily] = await Promise.all([
+      jsonFetch(`${base}&from=${from}&to=${to}&group=date${shopSuffix}`).then((d) => d.rows || []),
+      jsonFetch(`${base}&from=${prevFrom}&to=${prevTo}&group=date${shopSuffix}`).then((d) => d.rows || []),
+      jsonFetch(`${base}&from=${from}&to=${to}&group=shop${shopSuffix}`).then((d) => d.rows || []),
+      jsonFetch(`/api/reports/daily?entity=${encodeURIComponent(state.entity)}&from=${from}&to=${to}&scope=shop${shopSuffix}`).then((d) => d.rows || []),
+    ]);
+    anaCache = { key, from, to, prevFrom, prevTo, byDate, byDatePrev, byShop, topDaily };
+    return anaCache;
+  }
+
+  function renderDeltas(data) {
+    const wrap = $('analysisDeltas');
+    if (!wrap) return;
+    wrap.innerHTML = '';
+    const sum = (rows, f) => rows.reduce((a, r) => a + (Number(r[f]) || 0), 0);
+    const wAvg = (rows, f, wb) => {
+      let num = 0, den = 0;
+      for (const r of rows) { const wt = Number(r[wb]) || 0; num += (Number(r[f]) || 0) * wt; den += wt; }
+      return den > 0 ? num / den : 0;
+    };
+    const curOrders = sum(data.byDate, 'validOrderCnt');
+    const prevOrders = sum(data.byDatePrev, 'validOrderCnt');
+    const items = [
+      { label: '营业额', cur: sum(data.byDate, 'grossAmt'), prev: sum(data.byDatePrev, 'grossAmt'), fmt: fmtMoney },
+      { label: '有效订单', cur: curOrders, prev: prevOrders, fmt: fmtNum },
+      { label: '收入', cur: sum(data.byDate, 'incomeAmt'), prev: sum(data.byDatePrev, 'incomeAmt'), fmt: fmtMoney },
+      { label: '曝光人数', cur: sum(data.byDate, 'expUv'), prev: sum(data.byDatePrev, 'expUv'), fmt: fmtNum },
+      { label: '下单人数', cur: sum(data.byDate, 'orderUserCnt'), prev: sum(data.byDatePrev, 'orderUserCnt'), fmt: fmtNum },
+      { label: '进店转化率', cur: wAvg(data.byDate, 'clkExpRate', 'expUv'), prev: wAvg(data.byDatePrev, 'clkExpRate', 'expUv'), fmt: fmtPct },
+      { label: '客单价', cur: curOrders > 0 ? sum(data.byDate, 'grossAmt') / curOrders : 0, prev: prevOrders > 0 ? sum(data.byDatePrev, 'grossAmt') / prevOrders : 0, fmt: fmtMoney },
+    ];
+    for (const it of items) {
+      const cur = Math.round(it.cur * 100) / 100;
+      const div = document.createElement('div');
+      div.className = 'delta-card';
+      let deltaHtml;
+      const delta = it.prev > 0 ? (it.cur - it.prev) / it.prev : null;
+      if (delta == null || !Number.isFinite(delta)) deltaHtml = '<span class="delta-none">上期无数据</span>';
+      else if (Math.abs(delta) < 0.0001) deltaHtml = '<span class="delta-flat">持平</span>';
+      else {
+        const up = delta > 0;
+        deltaHtml = `<span class="delta ${up ? 'up' : 'down'}">${up ? '↑' : '↓'} ${Math.abs(delta * 100).toFixed(1)}%</span>`
+          + `<span class="delta-sub">vs 上期 ${it.fmt(Math.round(it.prev * 100) / 100)}</span>`;
+      }
+      div.innerHTML = `<span class="delta-label"></span><span class="delta-value"></span><span class="delta-line">${deltaHtml}</span>`;
+      div.querySelector('.delta-label').textContent = it.label;
+      div.querySelector('.delta-value').textContent = it.fmt(cur);
+      wrap.appendChild(div);
+    }
+  }
+
+  async function renderCharts() {
+    let data;
+    try { data = await ensureAnalysisData(); } catch { return; }
+    renderDeltas(data);
+    const col = chartCol(chartMetric);
+    const compareOn = $('anaComparePrev') && $('anaComparePrev').checked;
+
+    const trend = ensureChart('trend', 'chartTrend');
+    if (trend) {
+      const cur = data.byDate.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
+      const prev = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
+      const xData = cur.map((r) => ymdToDash(r.date));
+      const series = [{
+        name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5,
+        data: cur.map((r) => Number(r[chartMetric]) || 0),
+        itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 },
+        areaStyle: { color: 'rgba(255,107,53,.08)' },
+      }];
+      if (compareOn && prev.length) {
+        series.push({
+          name: '上期同期', type: 'line', smooth: true,
+          data: xData.map((_, i) => (prev[i] ? Number(prev[i][chartMetric]) || 0 : 0)),
+          itemStyle: { color: '#a5a09a' }, lineStyle: { type: 'dashed', width: 1.6 }, symbol: 'none',
+        });
+      }
+      trend.setOption({
+        tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+        legend: { data: series.map((x) => x.name), top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+        grid: { left: 64, right: 24, top: 36, bottom: 30 },
+        xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
+        yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+        series,
+      }, true);
+      const title = $('anaTrendTitle');
+      if (title) title.textContent = `主指标趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`;
+    }
+
+    const rank = ensureChart('rank', 'chartRank');
+    if (rank) {
+      const top = data.byShop.slice()
+        .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
+        .slice(0, 10).reverse();
+      rank.setOption({
+        tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+        grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
+        xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+        yAxis: {
+          type: 'category', data: top.map((r) => r.shopName || r.shopId),
+          axisLabel: { color: '#2b2a28', width: 130, overflow: 'truncate' },
+          axisLine: { lineStyle: { color: '#e8e4df' } },
+        },
+        series: [{ type: 'bar', barMaxWidth: 18, data: top.map((r) => Number(r[chartMetric]) || 0), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }],
+      }, true);
+    }
+
+    const funnel = ensureChart('funnel', 'chartFunnel');
+    if (funnel) {
+      const sum = (f) => data.byDate.reduce((a, r) => a + (Number(r[f]) || 0), 0);
+      const expUv = sum('expUv');
+      const enter = data.byDate.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0);
+      const order = sum('orderUserCnt');
+      funnel.setOption({
+        tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtNum(Math.round(p.value))} 人` },
+        series: [{
+          type: 'funnel', left: '8%', width: '84%', top: 12, bottom: 8, minSize: '20%',
+          label: { color: '#fff', fontWeight: 600, formatter: (p) => `${p.name}  ${fmtNum(Math.round(p.value))}` },
+          data: [
+            { name: '曝光人数', value: Math.round(expUv), itemStyle: { color: '#ffb199' } },
+            { name: '进店人数', value: Math.round(enter), itemStyle: { color: '#ff8a5c' } },
+            { name: '下单人数', value: Math.round(order), itemStyle: { color: ACCENT } },
+          ],
+        }],
+      }, true);
+    }
+
+    const compare = ensureChart('compare', 'chartCompare');
+    if (compare) {
+      const top5 = data.byShop.slice()
+        .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
+        .slice(0, 5);
+      const dates = [...new Set(data.topDaily.map((r) => r.date))].sort();
+      const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6'];
+      const series = top5.map((shop, i) => ({
+        name: shop.shopName || shop.shopId,
+        type: 'line', smooth: true, symbolSize: 4,
+        data: dates.map((d) => {
+          const r = data.topDaily.find((x) => x.date === d && String(x.shopId) === String(shop.shopId));
+          return r ? (Number(r[chartMetric]) || 0) : 0;
+        }),
+        itemStyle: { color: palette[i % palette.length] }, lineStyle: { width: 2 },
+      }));
+      compare.setOption({
+        tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+        legend: { top: 0, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+        grid: { left: 64, right: 24, top: 36, bottom: 30 },
+        xAxis: { type: 'category', boundaryGap: false, data: dates.map(ymdToDash), ...AXIS_TXT },
+        yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+        series,
+      }, true);
+      const title = $('anaCompareTitle');
+      if (title) title.textContent = `门店对比 · Top ${top5.length}(${col.label})`;
+    }
+  }
+
+  // ---------- 智能问数 · 机器人(仅经营分析页面) ----------
+
+  function chatVisibleFor(view) { return view === 'reports' || view === 'analysis'; }
+
+  function setChatOpen(open) {
+    state.chat.open = open;
+    $('chatbotPanel').hidden = !open;
+    $('chatbotFab').classList.toggle('active', open);
+    if (open) {
+      $('chatbotFab').hidden = false;
+      setTimeout(() => $('chatbotInput').focus(), 60);
+      scrollChatBottom();
+    }
+  }
+
+  function scrollChatBottom() {
+    const body = $('chatbotBody');
+    body.scrollTop = body.scrollHeight;
+  }
+
+  function renderChatMessages() {
+    const body = $('chatbotBody');
+    [...body.querySelectorAll('.chatbot-msg:not(.static)')].forEach((n) => n.remove());
+    body.querySelectorAll('.chatbot-chips').forEach((n) => { n.hidden = state.chat.messages.length > 0; });
+    for (let i = 0; i < state.chat.messages.length; i++) {
+      const m = state.chat.messages[i];
+      const div = document.createElement('div');
+      div.className = 'chatbot-msg ' + (m.role === 'user' ? 'user' : 'bot');
+      if (m.pending) {
+        div.innerHTML = '<div class="chatbot-bubble typing"><span></span><span></span><span></span></div>';
+      } else {
+        const bubble = document.createElement('div');
+        bubble.className = 'chatbot-bubble';
+        bubble.textContent = m.text || '';
+        div.appendChild(bubble);
+        if (m.chart && m.chart.type !== 'none' && m.rows && m.rows.length) {
+          const chartWrap = document.createElement('div');
+          chartWrap.className = 'chatbot-chart';
+          chartWrap.dataset.idx = String(i);
+          div.appendChild(chartWrap);
+          const actions = document.createElement('div');
+          actions.className = 'chatbot-msg-actions';
+          const applyBtn = document.createElement('button');
+          applyBtn.type = 'button';
+          applyBtn.className = 'btn btn-ghost btn-small';
+          applyBtn.textContent = '应用到报表';
+          applyBtn.addEventListener('click', () => applyMsgQuery(i));
+          actions.appendChild(applyBtn);
+          div.appendChild(actions);
+        }
+      }
+      body.appendChild(div);
+    }
+    scrollChatBottom();
+    // 渲染消息内图表
+    state.chat.messages.forEach((m, i) => {
+      if (m.pending || !m.chart || m.chart.type === 'none' || !m.rows || !m.rows.length) return;
+      const wrap = body.querySelector(`.chatbot-chart[data-idx="${i}"]`);
+      if (!wrap) return;
+      const col = (key) => (state.columns.find((c) => c.key === key) || { label: key, type: 'num' });
+      const isDate = m.chart.x === 'date';
+      const rows = m.rows.slice();
+      if (isDate) rows.sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
+      const chart = echarts.init(wrap);
+      chart.setOption({
+        tooltip: { trigger: 'axis' },
+        grid: { left: 56, right: 16, top: 20, bottom: m.chart.type === 'bar' && !isDate ? 52 : 24 },
+        xAxis: {
+          type: 'category',
+          data: rows.map((r) => (isDate ? ymdToDash(r.date) : (r.shopName || r.shopId))),
+          axisLabel: { color: '#6b6560', interval: 0, rotate: m.chart.type === 'bar' && !isDate ? 30 : 0, width: 80, overflow: 'truncate' },
+          axisLine: { lineStyle: { color: '#e8e4df' } },
+        },
+        yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { lineStyle: { color: '#f0ede8' } } },
+        series: (m.chart.y || []).map((key, yi) => {
+          const c = col(key);
+          return {
+            name: c.label || key,
+            type: m.chart.type === 'line' ? 'line' : 'bar',
+            smooth: true, barMaxWidth: 26,
+            data: rows.map((r) => {
+              const v = r[key];
+              if (v == null || v === '') return 0;
+              const n = Number(v);
+              return c.type === 'pct' ? +(n * 100).toFixed(2) : n;
+            }),
+            itemStyle: { color: yi === 0 ? '#ff6b35' : '#2563eb' },
+          };
+        }),
+      });
+      setTimeout(() => chart.resize(), 40);
+    });
+  }
+
+  function applyMsgQuery(idx) {
+    const m = state.chat.messages[idx];
+    const a = m && m.applied;
+    if (!a) return;
+    state.mode = ['detail', 'shop', 'date'].includes(a.mode) ? a.mode : 'shop';
+    $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === state.mode));
+    state.preset = 'custom';
+    $('reportRangePreset').value = 'custom';
+    $('reportCustomRange').hidden = false;
+    $('reportFromDate').value = ymdToDash(a.from);
+    $('reportToDate').value = ymdToDash(a.to);
+    state.shopFilter = new Set((a.shopIds || []).map(String));
+    renderShopOptions();
+    loadReport();
+    const nav = document.querySelector('.nav-item[data-view="reports"]');
+    if (nav) nav.click();
+  }
+
+  async function sendChat(text) {
+    const q = String(text || '').trim();
+    if (!q || state.chat.busy) return;
+    state.chat.busy = true;
+    state.chat.messages.push({ role: 'user', text: q });
+    state.chat.messages.push({ role: 'assistant', pending: true });
+    renderChatMessages();
+    $('chatbotInput').value = '';
+    try {
+      const history = state.chat.messages
+        .filter((m) => !m.pending && (m.role === 'user' || (m.role === 'assistant' && m.text)))
+        .slice(0, -1)
+        .slice(-8)
+        .map((m) => ({ role: m.role, content: m.role === 'assistant' ? `${m.text}${m.applied ? `(已查询:mode=${m.applied.mode} ${m.applied.from}~${m.applied.to}${m.applied.shopIds && m.applied.shopIds.length ? ' 门店 ' + m.applied.shopIds.join(',') : ''})` : ''}` : m.text }));
+      const result = await jsonFetch('/api/reports/ask', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ entity: state.entity, q, history }),
+      });
+      const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
+      const msg = {
+        role: 'assistant',
+        text: result.reply || '(模型未给出文字回答)',
+        chart: result.chart,
+        rows: result.rows,
+        applied: result.applied,
+        chartTitle: result.chartTitle,
+      };
+      if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, msg);
+      else state.chat.messages.push(msg);
+    } catch (e) {
+      const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
+      const errMsg = { role: 'assistant', text: '出错了:' + e.message, error: true };
+      if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, errMsg);
+      else state.chat.messages.push(errMsg);
+    } finally {
+      state.chat.busy = false;
+      renderChatMessages();
+    }
+  }
+
+  // ---------- 事件绑定 ----------
+
+  function bindEvents() {
+    $('btnReportRefresh').addEventListener('click', async () => {
+      await refreshConn();
+      await loadReport();
+      showToast('已刷新');
+    });
+
+    $('btnClientHelp').addEventListener('click', (event) => {
+      event.stopPropagation();
+      const pop = $('clientHelpPopover');
+      pop.hidden = !pop.hidden;
+      $('btnClientHelp').classList.toggle('active', !pop.hidden);
+      $('btnClientHelp').setAttribute('aria-expanded', String(!pop.hidden));
+    });
+    document.addEventListener('click', (event) => {
+      const pop = $('clientHelpPopover');
+      if (!pop.hidden && !event.target.closest('.conn-help-wrap')) {
+        pop.hidden = true;
+        $('btnClientHelp').classList.remove('active');
+        $('btnClientHelp').setAttribute('aria-expanded', 'false');
+      }
+    });
+
+    $('btnOpenSync').addEventListener('click', openSyncModal);
+    $('btnCloseSync').addEventListener('click', () => {
+      $('modalReportSync').hidden = true;
+      if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
+      $('syncProgressWrap').hidden = true;
+    });
+    $('btnCancelSync').addEventListener('click', () => $('modalReportSync').hidden = true);
+    $('btnStartSync').addEventListener('click', startSync);
+    $('btnSyncCancel').addEventListener('click', async () => {
+      try { await jsonFetch('/api/reports/sync/cancel', { method: 'POST' }); showToast('已请求取消,等待当前步骤结束…'); } catch { /* ignore */ }
+    });
+    $('syncScope').addEventListener('change', () => {
+      $('syncShopPickWrap').hidden = $('syncScope').value !== 'custom';
+    });
+    $('syncShopTrigger').addEventListener('click', () => toggleDropdown('syncShopMenu', 'syncShopTrigger'));
+    $('syncShopMenu').addEventListener('click', makeDropdownHandler($('syncShopMenu'), state.syncShops, $('syncShopText'), '选择门店'));
+    document.addEventListener('click', (event) => {
+      if (!$('syncShopDropdown').contains(event.target)) toggleDropdown('syncShopMenu', 'syncShopTrigger', false);
+    });
+
+    $('reportRangePreset').addEventListener('change', () => {
+      state.preset = $('reportRangePreset').value;
+      $('reportCustomRange').hidden = state.preset !== 'custom';
+      loadReport();
+    });
+    $('reportFromDate').addEventListener('change', loadReport);
+    $('reportToDate').addEventListener('change', loadReport);
+
+    $('reportShopTrigger').addEventListener('click', () => toggleDropdown('reportShopMenu', 'reportShopTrigger'));
+    $('reportShopMenu').addEventListener('click', makeDropdownHandler($('reportShopMenu'), state.shopFilter, $('reportShopText'), '全部门店', loadReport));
+    document.addEventListener('click', (event) => {
+      if (!$('reportShopDropdown').contains(event.target)) toggleDropdown('reportShopMenu', 'reportShopTrigger', false);
+    });
+
+    $('reportViewModes').addEventListener('click', (event) => {
+      const btn = event.target.closest('button[data-mode]');
+      if (!btn) return;
+      state.mode = btn.dataset.mode;
+      $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
+      state.sortKey = state.mode === 'detail' ? 'date' : (state.mode === 'shop' ? 'shopName' : 'date');
+      state.sortDir = 'desc';
+      loadReport();
+    });
+
+    let searchTimer = null;
+    $('reportSearch').addEventListener('input', () => {
+      clearTimeout(searchTimer);
+      searchTimer = setTimeout(() => {
+        state.q = $('reportSearch').value.trim();
+        loadReport();
+      }, 300);
+    });
+
+    $('btnColumnCfg').addEventListener('click', () => {
+      renderColumnCfg();
+      $('modalReportColumns').hidden = false;
+    });
+    $('btnCloseColumnCfg').addEventListener('click', () => $('modalReportColumns').hidden = true);
+    $('btnApplyColumns').addEventListener('click', applyColumnCfg);
+    $('btnResetColumns').addEventListener('click', () => {
+      state.visible = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
+      LS.set('shanReports.columns.v1', Array.from(state.visible));
+      renderColumnCfg();
+      renderTable();
+    });
+
+    $('btnExportCsv').addEventListener('click', exportCsv);
+    $('btnExportHtml').addEventListener('click', exportHtml);
+
+    $('reportTableHead').addEventListener('click', (event) => {
+      const th = event.target.closest('th[data-key]');
+      if (!th) return;
+      const key = th.dataset.key;
+      if (state.sortKey === key) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
+      else { state.sortKey = key; state.sortDir = 'desc'; }
+      renderTable();
+    });
+
+    // 趋势图表
+    $('analysisRangePreset').addEventListener('change', () => {
+      state.analysisPreset = $('analysisRangePreset').value;
+      anaCache = null;
+      renderCharts();
+    });
+    $('anaShopTrigger').addEventListener('click', () => toggleDropdown('anaShopMenu', 'anaShopTrigger'));
+    $('anaShopMenu').addEventListener('click', makeDropdownHandler($('anaShopMenu'), state.analysisShopFilter, $('anaShopText'), '全部门店', () => {
+      anaCache = null;
+      renderCharts();
+    }));
+    document.addEventListener('click', (event) => {
+      if (!$('anaShopDropdown').contains(event.target)) toggleDropdown('anaShopMenu', 'anaShopTrigger', false);
+    });
+    $('chartMetric').addEventListener('change', () => {
+      chartMetric = $('chartMetric').value || 'grossAmt';
+      LS.set('shanReports.chartMetric', chartMetric);
+      renderCharts();
+    });
+    $('anaComparePrev').addEventListener('change', renderCharts);
+
+    // 智能问数机器人
+    $('chatbotFab').addEventListener('click', () => setChatOpen(!state.chat.open));
+    $('btnChatClose').addEventListener('click', () => setChatOpen(false));
+    $('btnChatClear').addEventListener('click', () => {
+      state.chat.messages = [];
+      renderChatMessages();
+    });
+    $('btnChatSend').addEventListener('click', () => sendChat($('chatbotInput').value));
+    $('chatbotInput').addEventListener('keydown', (event) => {
+      if (event.key === 'Enter') sendChat($('chatbotInput').value);
+    });
+    $('chatbotChips').addEventListener('click', (event) => {
+      const chip = event.target.closest('[data-q]');
+      if (chip) sendChat(chip.dataset.q);
+    });
+
+    window.addEventListener('resize', () => {
+      Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
+    });
+  }
+
+  // ---------- 初始化 ----------
+
+  window.shanReportsInit = async function () {
+    if (!state.inited) {
+      state.inited = true;
+      bindEvents();
+      try {
+        const data = await jsonFetch('/api/reports/columns');
+        state.columns = data.columns || [];
+      } catch { state.columns = []; }
+      loadColumnPrefs();
+      renderChartMetricSelect();
+    }
+    await refreshConn();
+    await loadReport();
+  };
+
+  window.shanAnalysisActivate = async function () {
+    await window.shanReportsInit();
+    renderChartMetricSelect();
+    await renderCharts();
+    Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
+  };
+
+  window.shanSetAnalysisVisible = function (visible) {
+    const fab = $('chatbotFab');
+    if (!fab) return;
+    fab.hidden = !visible;
+    if (!visible) setChatOpen(false);
+  };
+
+})();

+ 252 - 1
public/style.css

@@ -1,5 +1,5 @@
 /* ═══════════════════════════════════════════════════════════════
-   Design System — 门店装修工具 · 淘宝闪购专业版
+   Design System — 智慧运营中台 · 淘宝闪购专业版
    Extracted from dish-crawl.html design spec
    ═══════════════════════════════════════════════════════════════ */
 
@@ -708,3 +708,254 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
   .history-search { flex-basis: auto; }
   .history-filters { justify-content: flex-start; }
 }
+
+/* ═══════════════════════════════════════════════════════════════
+   数据报表 · 淘宝闪购经营数据中台
+   ═══════════════════════════════════════════════════════════════ */
+
+.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); }
+
+.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; }
+.report-field-md { width: 240px; flex: 0 0 auto; }
+.report-field-lg { width: 220px; flex: 0 0 auto; }
+.report-field-grow { flex: 1 1 160px; min-width: 140px; }
+.report-date-range { display: flex; align-items: center; gap: 6px; }
+.report-date-range input { min-width: 0; flex: 1; }
+.report-toolbar-actions { display: flex; gap: 8px; margin-left: auto; }
+
+#reportKpis { grid-template-columns: repeat(7, minmax(0, 1fr)); margin-bottom: 14px; }
+#reportKpis .metric { min-height: 84px; padding: 14px 16px; }
+@media (max-width: 1280px) { #reportKpis { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
+
+.report-table-panel { min-height: clamp(320px, calc(100vh - 430px), 640px); }
+.report-table-wrap { overflow: auto; flex: 1; min-height: 0; }
+.report-table { min-width: 1080px; }
+.report-table th { position: sticky; top: 0; z-index: 2; cursor: pointer; user-select: none; white-space: nowrap; }
+.report-table th:hover { color: var(--accent); }
+.report-table .col-num { text-align: right; font-variant-numeric: tabular-nums; }
+.report-table td.col-date, .report-table td.col-text { white-space: nowrap; }
+.report-table td.col-money, .report-table td.col-int { color: var(--text); }
+.report-table td.col-reserved { color: var(--text-3); }
+.val-good { color: var(--ok); font-weight: 650; }
+.val-warn { color: var(--warn); font-weight: 650; }
+.val-bad { color: var(--fail); font-weight: 650; }
+
+.colcfg-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 18px; max-height: 46vh; overflow-y: auto; padding: 4px 2px; }
+.colcfg-item { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-body); color: var(--text); cursor: pointer; padding: 6px 8px; border-radius: var(--radius-sm); }
+.colcfg-item:hover { background: #f6f4f2; }
+.colcfg-item input { margin: 0; }
+.colcfg-item span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.colcfg-reserved { font-size: 10px; font-style: normal; color: var(--text-3); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 0 6px; }
+
+.modal-wide { width: min(520px, calc(100vw - 48px)); }
+.modal-body .badge { margin-bottom: 12px; }
+.sync-progress-row { display: flex; align-items: center; gap: 10px; margin: 12px 0 8px; }
+.sync-progress-bar { flex: 1; height: 8px; border-radius: var(--radius-pill); background: #f1efec; overflow: hidden; }
+.sync-progress-bar span { display: block; height: 100%; width: 0; border-radius: var(--radius-pill); background: var(--accent); transition: width var(--duration-slow) var(--ease-out); }
+.sync-stat-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
+.sync-steps { max-height: 150px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: var(--radius-sm); padding: 6px 8px; display: flex; flex-direction: column; gap: 4px; }
+.sync-step { display: flex; align-items: baseline; gap: 8px; font-size: var(--font-size-small); }
+.sync-step span { flex: 0 0 auto; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
+.sync-step em { font-style: normal; color: var(--text-3); }
+.sync-step.ok em { color: var(--ok); }
+.sync-step.fail em { color: var(--fail); }
+
+/* ── 分段控制器(数据报表 · 视图切换) ───────────────────── */
+.seg-control { display: inline-flex; align-items: stretch; padding: 3px; gap: 2px; background: #f1efec; border-radius: var(--radius-pill); }
+.seg-control button {
+  min-height: 26px; min-width: 0; padding: 4px 14px; border: 0; border-radius: var(--radius-pill);
+  background: transparent; color: var(--text-2); font-size: var(--font-size-body); font-weight: 500; line-height: 1.4;
+  cursor: pointer; transition: background var(--duration-base) var(--ease-out), color var(--duration-base) var(--ease-out);
+  white-space: nowrap;
+}
+.seg-control button:hover { color: var(--text); }
+.seg-control button.active { background: var(--card-bg); color: var(--accent); font-weight: 600; box-shadow: 0 1px 3px rgba(31, 27, 24, .12); }
+.seg-control button:disabled { opacity: .5; cursor: not-allowed; }
+
+/* ── 智能问数 + 图表面板 ─────────────────────────────────── */
+.ask-panel { padding: 14px 16px; margin-bottom: 14px; }
+.ask-row { display: flex; align-items: center; gap: 10px; }
+.ask-label { display: inline-flex; align-items: center; gap: 6px; font-weight: 650; color: var(--accent); white-space: nowrap; }
+.ask-label svg { width: 16px; height: 16px; }
+.ask-row input[type="text"] { flex: 1; min-width: 0; }
+.ask-row .btn { flex: 0 0 auto; }
+.ask-reply { display: flex; align-items: flex-start; gap: 8px; margin-top: 12px; font-size: var(--font-size-body); }
+.ask-reply .badge { flex: 0 0 auto; margin-top: 1px; }
+.ask-chart { width: 100%; height: 300px; margin-top: 8px; }
+.ask-actions { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
+.ask-error { margin-top: 10px; }
+.help-inline { font-weight: 400; color: var(--text-3); font-size: 11px; margin-left: 6px; }
+.settings-link-btn { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; border: 0; background: transparent; color: var(--info); font-size: var(--font-size-small); padding: 4px 6px; border-radius: var(--radius-sm); }
+.settings-link-btn:hover { background: var(--info-bg); }
+.settings-link-btn svg { width: 12px; height: 12px; }
+.settings-link-btn:disabled { opacity: .5; cursor: not-allowed; }
+
+.charts-panel { padding: 16px; margin-bottom: 14px; }
+.chart-metric-select { max-width: 170px; }
+.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+.chart-box { min-width: 0; }
+.chart-title { font-size: var(--font-size-small); color: var(--text-2); font-weight: 600; margin-bottom: 6px; }
+.chart { width: 100%; height: 260px; }
+@media (max-width: 1080px) { .charts-grid { grid-template-columns: 1fr; } .ask-chart { height: 240px; } }
+
+/* ── 设置页 · API 设置面板 tab ──────────────────────────── */
+.settings-api-tabs { margin-left: auto; }
+.settings-api-tabs button { min-height: 24px; padding: 3px 12px; font-size: var(--font-size-small); }
+.settings-tab-body[hidden] { display: none; }
+.label-links { display: inline-flex; align-items: center; gap: 10px; }
+
+/* ── 客户端连接排查 · ? 弹窗 ─────────────────────────────── */
+.conn-help-wrap { position: relative; display: inline-flex; align-items: center; gap: 6px; }
+.help-btn {
+  width: 18px; height: 18px; min-width: 0; min-height: 0; padding: 0;
+  border-radius: 50%; border: 1px solid var(--border);
+  background: var(--card-bg); color: var(--text-2);
+  font-size: 11px; font-weight: 700; line-height: 1;
+  display: inline-flex; align-items: center; justify-content: center;
+  cursor: pointer; transition: color var(--duration-base), border-color var(--duration-base), background var(--duration-base);
+}
+.help-btn:hover, .help-btn.active { color: #fff; background: var(--accent); border-color: var(--accent); }
+.help-popover {
+  position: absolute; top: calc(100% + 10px); right: 0; z-index: 60;
+  width: min(460px, calc(100vw - 64px));
+  background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
+  box-shadow: 0 18px 44px rgba(31, 27, 24, .16);
+  padding: 16px 18px; text-align: left;
+  font-size: var(--font-size-body); color: var(--text);
+}
+.help-popover::before {
+  content: ""; position: absolute; top: -6px; right: 14px; width: 10px; height: 10px;
+  background: var(--card-bg); border-left: 1px solid var(--border); border-top: 1px solid var(--border);
+  transform: rotate(45deg);
+}
+.help-popover-title { font-weight: 700; font-size: var(--font-size-h2); margin-bottom: 8px; }
+.help-popover-steps { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 8px; }
+.help-popover-steps li { line-height: 1.55; }
+.help-popover-steps code, .help-popover-note code {
+  background: #f6f4f2; border: 1px solid var(--border-light); border-radius: 4px;
+  padding: 1px 5px; font-size: 11px; word-break: break-all;
+}
+.help-popover-note {
+  margin-top: 10px; padding: 8px 10px; border-radius: 8px;
+  background: var(--warn-bg); color: var(--warn); font-size: var(--font-size-small);
+}
+
+/* ── KPI 卡 · 数值自适应不截断 ──────────────────────────── */
+#reportKpis .metric { padding: 12px 14px; }
+#reportKpis .metric-body { gap: 6px; }
+#reportKpis .metric-value {
+  font-size: clamp(15px, 1.35vw, 26px);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 100%;
+}
+@media (max-width: 1500px) { #reportKpis .metric-value { font-size: clamp(14px, 1.15vw, 20px); } }
+/* 空状态徽章不占位,保证测试按钮与图像模型 tab 同样居中 */
+.badge:empty { display: none; }
+
+/* ── 趋势图表 · 环比指标条 ──────────────────────────────── */
+.delta-strip { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; }
+.delta-card {
+  background: var(--card-bg); border: 1px solid var(--border-light); border-radius: 12px;
+  padding: 10px 12px; display: flex; flex-direction: column; gap: 3px; min-width: 0;
+}
+.delta-label { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
+.delta-value { font-size: 17px; font-weight: 750; color: var(--text); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.delta-line { display: flex; align-items: center; gap: 5px; font-size: 11px; min-height: 15px; flex-wrap: wrap; }
+.delta { font-weight: 700; }
+.delta.up { color: var(--ok); }
+.delta.down { color: var(--fail); }
+.delta-flat { color: var(--text-3); }
+.delta-none { color: var(--text-3); }
+.delta-sub { color: var(--text-3); font-size: 10px; }
+@media (max-width: 1280px) { .delta-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
+
+/* ── 趋势图表 · 四图网格 ────────────────────────────────── */
+.ana-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; padding-bottom: 40px; }
+.ana-cell { min-width: 0; }
+.ana-span2 { grid-column: span 2; }
+.chart-lg { width: 100%; height: 300px; }
+.ana-compare-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: var(--font-size-body); color: var(--text-2); cursor: pointer; padding-bottom: 2px; white-space: nowrap; }
+.ana-compare-toggle input { margin: 0; }
+@media (max-width: 1080px) { .ana-grid { grid-template-columns: 1fr; } .ana-span2 { grid-column: span 1; } }
+
+/* ── 智能问数 · 机器人 FAB + 对话弹窗 ───────────────────── */
+.chatbot-fab {
+  position: fixed; right: 26px; bottom: 26px; z-index: 90;
+  width: 54px; height: 54px; border-radius: 50%; border: none; cursor: pointer;
+  background: linear-gradient(135deg, var(--accent) 0%, #ff8a5c 100%);
+  color: #fff; display: flex; align-items: center; justify-content: center;
+  box-shadow: 0 10px 26px rgba(255, 107, 53, .4);
+  transition: transform var(--duration-base) var(--ease-out), box-shadow var(--duration-base) var(--ease-out);
+}
+.chatbot-fab svg { width: 26px; height: 26px; }
+.chatbot-fab:hover { transform: translateY(-2px) scale(1.04); box-shadow: 0 14px 32px rgba(255, 107, 53, .48); }
+.chatbot-fab.active { box-shadow: 0 0 0 3px var(--focus-ring), 0 10px 26px rgba(255, 107, 53, .4); }
+.chatbot-fab[hidden] { display: none; }
+
+.chatbot-panel {
+  position: fixed; right: 26px; bottom: 92px; z-index: 95;
+  width: min(400px, calc(100vw - 40px)); height: min(600px, calc(100vh - 140px));
+  display: flex; flex-direction: column; overflow: hidden;
+  background: var(--card-bg); border: 1px solid var(--border); border-radius: 16px;
+  box-shadow: 0 24px 60px rgba(31, 27, 24, .22);
+}
+.chatbot-panel[hidden] { display: none; }
+.chatbot-head {
+  display: flex; align-items: center; gap: 8px; padding: 12px 14px;
+  border-bottom: 1px solid var(--border-light); background: linear-gradient(135deg, #fff 0%, #faf8f5 100%);
+}
+.chatbot-title { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; font-size: var(--font-size-h2); color: var(--text); }
+.chatbot-title svg { width: 18px; height: 18px; color: var(--accent); }
+.chatbot-sub { flex: 1; font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.chatbot-icon-btn {
+  width: 26px; height: 26px; min-width: 0; min-height: 0; padding: 0; border: 0; border-radius: 7px;
+  background: transparent; color: var(--text-2); cursor: pointer;
+  display: inline-flex; align-items: center; justify-content: center;
+}
+.chatbot-icon-btn:hover { background: #f1efec; color: var(--text); }
+.chatbot-icon-btn svg { width: 15px; height: 15px; }
+
+.chatbot-body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; background: #faf9f7; }
+.chatbot-msg { display: flex; }
+.chatbot-msg.user { justify-content: flex-end; }
+.chatbot-msg.bot { justify-content: flex-start; }
+.chatbot-bubble {
+  max-width: 86%; padding: 9px 12px; border-radius: 12px; font-size: 12.5px; line-height: 1.55;
+  background: #fff; border: 1px solid var(--border-light); color: var(--text); word-break: break-word;
+}
+.chatbot-msg.user .chatbot-bubble { background: var(--accent); border-color: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
+.chatbot-msg.bot .chatbot-bubble { border-bottom-left-radius: 4px; }
+.chatbot-msg .chatbot-bubble.error { background: var(--fail-bg); border-color: transparent; color: var(--fail); }
+.chatbot-bubble.typing { display: inline-flex; gap: 4px; align-items: center; padding: 12px 14px; }
+.chatbot-bubble.typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); animation: chatblink 1.2s infinite; }
+.chatbot-bubble.typing span:nth-child(2) { animation-delay: .2s; }
+.chatbot-bubble.typing span:nth-child(3) { animation-delay: .4s; }
+@keyframes chatblink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }
+.chatbot-chart { width: 100%; height: 220px; margin-top: 8px; border: 1px solid var(--border-light); border-radius: 10px; background: #fff; }
+.chatbot-msg-actions { margin-top: 6px; }
+.chatbot-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 2px 4px; }
+.chatbot-chips button {
+  border: 1px solid var(--border); background: #fff; color: var(--text-2);
+  font-size: 11px; padding: 4px 10px; border-radius: var(--radius-pill); cursor: pointer;
+}
+.chatbot-chips button:hover { border-color: var(--accent); color: var(--accent); }
+
+.chatbot-input-row { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border-light); background: #fff; }
+.chatbot-input-row input { flex: 1; min-width: 0; }
+.chatbot-input-row .btn { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0 12px; }
+.chatbot-input-row .btn svg { width: 15px; height: 15px; }

Разлика између датотеке није приказан због своје велике величине
+ 34 - 0
public/vendor/echarts.min.js


+ 140 - 0
report-html.js

@@ -0,0 +1,140 @@
+'use strict';
+
+/**
+ * 数据报表 · 经营周报导出
+ * ----------------------------------------------------------------------------
+ * 参考 DataAgent ReportGeneratorNode 的「自包含 HTML 报告」产物形态:
+ * 单文件、内联 ECharts、离线可打开,适合发群/汇报。
+ */
+
+const fs = require('fs');
+const path = require('path');
+const svc = require('./napos-service.js');
+
+const ECHARTS_SRC = path.join(__dirname, 'public', 'vendor', 'echarts.min.js');
+
+function esc(v) {
+  return String(v == null ? '' : v)
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+
+function fmtMoney(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
+function fmtInt(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); }
+function fmtPct(v) { return v == null || v === '' ? '—' : (Number(v) * 100).toFixed(2) + '%'; }
+
+function fmtByType(type, v) {
+  if (type === 'money') return fmtMoney(v);
+  if (type === 'int') return fmtInt(v);
+  if (type === 'pct') return fmtPct(v);
+  if (type === 'num') return v == null ? '—' : Number(v).toFixed(2);
+  if (type === 'date') return /^\d{8}$/.test(String(v)) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : (v || '—');
+  return v == null ? '—' : String(v);
+}
+
+function buildReportHtml({ entity, from, to }) {
+  const meta = svc.store.getEntity(entity) || { key: entity, name: entity, shops: [] };
+  const shopNameById = new Map((meta.shops || []).map((s) => [String(s.shopId), s.shopName]));
+  const daily = svc.queryReport({ entity, from, to, scope: 'shop' });
+  const aggDate = svc.aggregateReport({ entity, from, to, group: 'date' });
+  const aggShop = svc.aggregateReport({ entity, from, to, group: 'shop' });
+  const s = daily.summary || {};
+
+  // 图表数据:日趋势(营业额/有效订单 双轴)+ 门店排行 Top15
+  const trendDates = aggDate.rows.map((r) => fmtByType('date', r.date));
+  const trendGross = aggDate.rows.map((r) => Number(r.grossAmt) || 0);
+  const trendOrders = aggDate.rows.map((r) => Number(r.validOrderCnt) || 0);
+  const topShops = aggShop.rows.slice(0, 15);
+  const topNames = topShops.map((r) => shopNameById.get(String(r.shopId)) || r.shopName || r.shopId);
+  const topGross = topShops.map((r) => Number(r.grossAmt) || 0);
+
+  const tableRows = aggShop.rows.map((r, i) => {
+    const name = esc(shopNameById.get(String(r.shopId)) || r.shopName || r.shopId);
+    return `<tr><td class="rank">${i + 1}</td><td class="shop">${name}</td><td class="num">${fmtMoney(r.grossAmt)}</td><td class="num">${fmtInt(r.validOrderCnt)}</td><td class="num">${fmtMoney(r.incomeAmt)}</td><td class="num">${fmtInt(r.expUv)}</td><td class="num">${fmtInt(r.orderUserCnt)}</td><td class="num">${fmtPct(r.clkExpRate)}</td><td class="num">${fmtMoney(r.perActualPay)}</td></tr>`;
+  }).join('');
+
+  const kpi = [
+    ['营业额(元)', fmtMoney(s.grossAmt), '#ff6b35'],
+    ['收入(元)', fmtMoney(s.incomeAmt), '#1a8a4c'],
+    ['有效订单', fmtInt(s.validOrderCnt), '#ff6b35'],
+    ['曝光人数', fmtInt(s.expUv), '#6b6560'],
+    ['下单人数', fmtInt(s.orderUserCnt), '#6b6560'],
+    ['进店转化率', fmtPct(s.clkExpRate), '#b8860b'],
+    ['客单价(元)', fmtMoney(s.perActualPay), '#1a8a4c'],
+  ].map(([label, value, color]) => `<div class="kpi"><span class="kpi-label">${label}</span><span class="kpi-value" style="color:${color}">${value}</span></div>`).join('');
+
+  const echartsJs = fs.existsSync(ECHARTS_SRC) ? fs.readFileSync(ECHARTS_SRC, 'utf8') : '';
+
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>经营周报 · ${esc(meta.name)} · ${esc(from)}~${esc(to)}</title>
+<style>
+  * { box-sizing: border-box; }
+  body { margin: 0; padding: 28px; background: #f4f2ef; color: #2b2a28; font: 14px/1.5 -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
+  .wrap { max-width: 1200px; margin: 0 auto; }
+  h1 { font-size: 20px; margin: 0 0 4px; }
+  .sub { color: #a5a09a; font-size: 12px; margin-bottom: 20px; }
+  .kpis { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; margin-bottom: 16px; }
+  .kpi { background: #fff; border: 1px solid #e8e4df; border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
+  .kpi-label { font-size: 11.5px; color: #6b6560; }
+  .kpi-value { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
+  .card { background: #fff; border: 1px solid #e8e4df; border-radius: 14px; padding: 16px; margin-bottom: 16px; }
+  .card h2 { font-size: 14px; margin: 0 0 10px; }
+  .chart { width: 100%; height: 320px; }
+  table { width: 100%; border-collapse: collapse; font-size: 13px; }
+  th { text-align: left; padding: 8px 10px; background: #f4f2ef; color: #6b6560; font-size: 11.5px; border-bottom: 1px solid #e8e4df; }
+  td { padding: 8px 10px; border-bottom: 1px solid #f0ede8; }
+  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
+  td.rank { color: #a5a09a; width: 40px; }
+  td.shop { font-weight: 600; }
+  @media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <h1>经营周报 · ${esc(meta.name)}</h1>
+  <div class="sub">区间 ${esc(from)} ~ ${esc(to)} · 覆盖 ${s.shopCount || 0} 家门店 · ${s.dayCount || 0} 天 · 生成于 ${new Date().toLocaleString('zh-CN')} · 智慧运营中台</div>
+  <div class="kpis">${kpi}</div>
+  <div class="card"><h2>日趋势(营业额 / 有效订单)</h2><div class="chart" id="trend"></div></div>
+  <div class="card"><h2>门店排行 · 营业额 Top ${topNames.length}</h2><div class="chart" id="rank"></div></div>
+  <div class="card"><h2>门店汇总(按营业额降序)</h2>
+    <table><thead><tr><th>#</th><th>门店</th><th class="num">营业额(元)</th><th class="num">有效订单</th><th class="num">收入(元)</th><th class="num">曝光人数</th><th class="num">下单人数</th><th class="num">进店转化率</th><th class="num">客单价(元)</th></tr></thead>
+    <tbody>${tableRows || '<tr><td colspan="9">区间内无数据</td></tr>'}</tbody></table>
+  </div>
+</div>
+<script>${echartsJs}</script>
+<script>
+  var AX = { axisLabel: { color: '#6b6560' }, axisLine: { lineStyle: { color: '#e8e4df' } } };
+  var trend = echarts.init(document.getElementById('trend'));
+  trend.setOption({
+    tooltip: { trigger: 'axis' },
+    legend: { data: ['营业额(元)', '有效订单'] },
+    grid: { left: 60, right: 60, top: 40, bottom: 30 },
+    xAxis: { type: 'category', data: ${JSON.stringify(trendDates)}, ...AX },
+    yAxis: [
+      { type: 'value', name: '营业额', ...AX },
+      { type: 'value', name: '订单', ...AX }
+    ],
+    series: [
+      { name: '营业额(元)', type: 'line', smooth: true, data: ${JSON.stringify(trendGross)}, itemStyle: { color: '#ff6b35' }, areaStyle: { color: 'rgba(255,107,53,.08)' } },
+      { name: '有效订单', type: 'line', smooth: true, yAxisIndex: 1, data: ${JSON.stringify(trendOrders)}, itemStyle: { color: '#2563eb' } }
+    ]
+  });
+  var rank = echarts.init(document.getElementById('rank'));
+  rank.setOption({
+    tooltip: { trigger: 'axis' },
+    grid: { left: 10, right: 30, top: 10, bottom: 30, containLabel: true },
+    xAxis: { type: 'value', ...AX },
+    yAxis: { type: 'category', data: ${JSON.stringify(topNames)}, axisLabel: { color: '#2b2a28', width: 150, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } } },
+    series: [{ type: 'bar', data: ${JSON.stringify(topGross)}, itemStyle: { color: '#ff6b35', borderRadius: [0, 6, 6, 0] } }]
+  });
+  window.addEventListener('resize', function () { trend.resize(); rank.resize(); });
+</script>
+</body>
+</html>`;
+}
+
+module.exports = { buildReportHtml };

+ 180 - 0
reports-mcp.js

@@ -0,0 +1,180 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * 智慧运营中台 · 数据报表 MCP Server(stdio JSON-RPC)
+ * ----------------------------------------------------------------------------
+ * 把本地数据湖暴露成 MCP 工具,供 WorkBuddy 等 Agent 直接「查数」,
+ * 与 napos-mcp(负责「取数」)形成闭环。零第三方依赖。
+ *
+ * 注册(~/.workbuddy/mcp.json):
+ *   "napos-reports": { "command": "node", "args": ["<本项目路径>/reports-mcp.js"] }
+ *
+ * 工具:
+ *   reports_list_entities — 列出数据湖中的主体(含门店清单)
+ *   reports_summary       — 区间 KPI 汇总
+ *   reports_query         — 查询明细/按门店汇总/按日期汇总
+ */
+
+const naposService = require('./napos-service.js');
+const nlQuery = require('./nl-query.js');
+
+const TOOLS = [
+  {
+    name: 'reports_list_entities',
+    description: '列出本地数据湖中已沉淀的经营数据主体(商家账号),含每个主体的门店清单、数据覆盖的日期范围',
+    inputSchema: { type: 'object', properties: {} },
+  },
+  {
+    name: 'reports_summary',
+    description: '查询某主体在指定日期区间的 KPI 汇总(营业额/收入/有效订单/曝光/下单人数/转化率/客单价)',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        entity: { type: 'string', description: '主体 key(来自 reports_list_entities)' },
+        from: { type: 'string', description: '起始日 YYYYMMDD,缺省=可用区间起点' },
+        to: { type: 'string', description: '结束日 YYYYMMDD,缺省=可用区间终点' },
+      },
+      required: ['entity'],
+    },
+  },
+  {
+    name: 'reports_query',
+    description: '查询经营数据行。mode=shop 按门店汇总(可排序取 TopN)、mode=date 按日期汇总、mode=detail 日期×门店明细',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        entity: { type: 'string', description: '主体 key' },
+        from: { type: 'string', description: '起始日 YYYYMMDD' },
+        to: { type: 'string', description: '结束日 YYYYMMDD' },
+        mode: { type: 'string', enum: ['detail', 'shop', 'date'], description: '缺省 shop' },
+        shopIds: { type: 'array', items: { type: 'string' }, description: '可选,限定门店' },
+        q: { type: 'string', description: '可选,按门店名称模糊过滤' },
+        sort: { type: 'string', description: '排序指标 key,如 grossAmt' },
+        sortDir: { type: 'string', enum: ['asc', 'desc'] },
+        limit: { type: 'number', description: '返回行数上限,缺省 50' },
+      },
+      required: ['entity'],
+    },
+  },
+];
+
+function jsonOut(obj) {
+  process.stdout.write(JSON.stringify(obj) + '\n');
+}
+
+function clampRange(entity) {
+  const range = nlQuery.lakeDateRange(entity, naposService);
+  return range;
+}
+
+function handleListEntities() {
+  const entities = naposService.store.listEntities().map((e) => {
+    const range = nlQuery.lakeDateRange(e.key, naposService);
+    return {
+      key: e.key, name: e.name, shopCount: (e.shops || []).length,
+      chains: e.chains || [], shops: e.shops || [],
+      dataRange: range,
+    };
+  });
+  return { entities };
+}
+
+function handleSummary(args) {
+  const entity = String(args.entity || '');
+  const range = clampRange(entity);
+  const from = /^\d{8}$/.test(args.from || '') ? args.from : range.from;
+  const to = /^\d{8}$/.test(args.to || '') ? args.to : range.to;
+  const daily = naposService.queryReport({ entity, from, to, scope: 'shop' });
+  return { entity, from, to, summary: daily.summary };
+}
+
+function handleQuery(args) {
+  const entity = String(args.entity || '');
+  const range = clampRange(entity);
+  const from = /^\d{8}$/.test(args.from || '') ? args.from : range.from;
+  const to = /^\d{8}$/.test(args.to || '') ? args.to : range.to;
+  const mode = ['detail', 'shop', 'date'].includes(args.mode) ? args.mode : 'shop';
+  const limit = Math.min(Number(args.limit) > 0 ? Number(args.limit) : 50, 500);
+  let result;
+  if (mode === 'detail') {
+    result = naposService.queryReport({ entity, from, to, scope: 'shop', shops: args.shopIds, q: args.q });
+  } else {
+    result = naposService.aggregateReport({ entity, from, to, group: mode === 'date' ? 'date' : 'shop', shops: args.shopIds });
+    if (args.q) result.rows = result.rows.filter((r) => String(r.shopName || '').toLowerCase().includes(String(args.q).toLowerCase()));
+  }
+  let rows = result.rows || [];
+  const sortKey = naposService.REPORT_COLUMNS.some((c) => c.key === args.sort) ? args.sort : null;
+  if (sortKey) {
+    const dir = args.sortDir === 'asc' ? 1 : -1;
+    rows = rows.slice().sort((a, b) => {
+      const an = Number(a[sortKey]), bn = Number(b[sortKey]);
+      if (Number.isFinite(an) && Number.isFinite(bn)) return (an - bn) * dir;
+      return String(a[sortKey] || '').localeCompare(String(b[sortKey] || ''), 'zh-CN') * dir;
+    });
+  }
+  return {
+    entity, from, to, mode,
+    columns: result.columns.map((c) => ({ key: c.key, label: c.label, type: c.type })),
+    summary: result.summary,
+    rows: rows.slice(0, limit),
+    totalRows: rows.length,
+    truncated: rows.length > limit,
+  };
+}
+
+function dispatch(name, args) {
+  switch (name) {
+    case 'reports_list_entities': return handleListEntities();
+    case 'reports_summary': return handleSummary(args || {});
+    case 'reports_query': return handleQuery(args || {});
+    default: throw new Error('未知工具: ' + name);
+  }
+}
+
+let buffer = '';
+process.stdin.setEncoding('utf8');
+process.stdin.on('data', (chunk) => {
+  buffer += chunk;
+  let idx;
+  while ((idx = buffer.indexOf('\n')) !== -1) {
+    const line = buffer.slice(0, idx).trim();
+    buffer = buffer.slice(idx + 1);
+    if (!line) continue;
+    let msg = null;
+    try { msg = JSON.parse(line); } catch { continue; }
+    const { id, method, params } = msg;
+    if (method === 'initialize') {
+      jsonOut({
+        jsonrpc: '2.0', id,
+        result: {
+          protocolVersion: (params && params.protocolVersion) || '2024-11-05',
+          capabilities: { tools: {} },
+          serverInfo: { name: 'napos-reports', version: '1.0.0' },
+        },
+      });
+    } else if (method === 'notifications/initialized' || (method || '').startsWith('notifications/')) {
+      // 通知无需回应
+    } else if (method === 'tools/list') {
+      jsonOut({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
+    } else if (method === 'tools/call') {
+      try {
+        const data = dispatch(params.name, params.arguments);
+        jsonOut({
+          jsonrpc: '2.0', id,
+          result: { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] },
+        });
+      } catch (e) {
+        jsonOut({
+          jsonrpc: '2.0', id,
+          result: { content: [{ type: 'text', text: '错误: ' + (e.message || e) }], isError: true },
+        });
+      }
+    } else if (method === 'ping') {
+      jsonOut({ jsonrpc: '2.0', id, result: {} });
+    } else if (id !== undefined) {
+      jsonOut({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
+    }
+  }
+});
+process.stdin.on('end', () => process.exit(0));

+ 191 - 0
reports-store.js

@@ -0,0 +1,191 @@
+'use strict';
+
+/**
+ * 数据报表 · 本地数据湖
+ * ----------------------------------------------------------------------------
+ * 把从淘宝闪购商家后台取回的经营数据沉淀到本地 JSON 文件,支持:
+ *   · 多主体(每登录一个商家账号即一个主体 entity)
+ *   · 主体内分:全公司 / 连锁 / 单店 三种粒度的行
+ *   · 按「日期 × 门店」upsert 合并,可反复同步同一区间不产生重复
+ *
+ * 文件布局(项目根 data/reports/):
+ *   entities.json            主体注册表(含快照的连锁/门店结构)
+ *   <entityKey>/daily.json   该主体的数据行(唯一键 date|shopId)
+ *   <entityKey>/syncs.json   同步历史
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const LAKE_DIR = path.join(__dirname, 'data', 'reports');
+
+// ---------- 通用 ----------
+
+function ensureDir(p) {
+  fs.mkdirSync(p, { recursive: true });
+  return p;
+}
+
+function readJsonSafe(file, fallback) {
+  try {
+    return JSON.parse(fs.readFileSync(file, 'utf8'));
+  } catch {
+    return fallback;
+  }
+}
+
+function writeJsonAtomic(file, obj) {
+  ensureDir(path.dirname(file));
+  const temp = file + '.tmp';
+  fs.writeFileSync(temp, JSON.stringify(obj), 'utf8');
+  fs.renameSync(temp, file);
+}
+
+function sanitizeKey(v) {
+  return String(v || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'default';
+}
+
+// ---------- 主体注册表 ----------
+
+function registryFile() {
+  return path.join(LAKE_DIR, 'entities.json');
+}
+
+function loadRegistry() {
+  return readJsonSafe(registryFile(), { entities: [] });
+}
+
+function saveRegistry(reg) {
+  writeJsonAtomic(registryFile(), reg);
+}
+
+/**
+ * 注册 / 更新一个主体(来自 shopTree 快照)。
+ * 返回 { entity, created }。
+ */
+function upsertEntity({ merchantId, merchantName, chains = [], shops = [] }) {
+  const reg = loadRegistry();
+  const key = sanitizeKey(merchantId);
+  const now = new Date().toISOString();
+  let entity = reg.entities.find((e) => e.key === key);
+  let created = false;
+  if (!entity) {
+    created = true;
+    entity = { key, firstSeen: now, lastSeen: now };
+    reg.entities.push(entity);
+  }
+  entity.lastSeen = now;
+  entity.merchantId = merchantId || null;
+  entity.name = merchantName || entity.name || key;
+  entity.chainCount = chains.length;
+  entity.shopCount = shops.length;
+  entity.chains = chains;
+  entity.shops = shops;
+  saveRegistry(reg);
+  return { entity, created };
+}
+
+function listEntities() {
+  const reg = loadRegistry();
+  return reg.entities
+    .slice()
+    .sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
+}
+
+function getEntity(key) {
+  return loadRegistry().entities.find((e) => e.key === sanitizeKey(key)) || null;
+}
+
+// ---------- 数据行 ----------
+
+function dailyFile(entityKey) {
+  return path.join(LAKE_DIR, sanitizeKey(entityKey), 'daily.json');
+}
+
+function loadRows(entityKey) {
+  const data = readJsonSafe(dailyFile(entityKey), { rows: [] });
+  return Array.isArray(data.rows) ? data.rows : [];
+}
+
+function saveRows(entityKey, rows) {
+  rows.sort((a, b) => (a.date === b.date
+    ? String(a.shopId).localeCompare(String(b.shopId))
+    : (a.date < b.date ? -1 : 1)));
+  writeJsonAtomic(dailyFile(entityKey), { version: 1, updated: new Date().toISOString(), rows });
+}
+
+function rowKey(row) {
+  return `${row.date}|${row.shopId == null ? '' : row.shopId}`;
+}
+
+/** 合并写入:按 date|shopId upsert,浅字段覆盖(raw 整体替换) */
+function upsertRows(entityKey, incoming) {
+  if (!Array.isArray(incoming) || !incoming.length) return { added: 0, updated: 0, total: 0 };
+  const rows = loadRows(entityKey);
+  const index = new Map(rows.map((r, i) => [rowKey(r), i]));
+  let added = 0;
+  let updated = 0;
+  for (const next of incoming) {
+    if (!next || !next.date) continue;
+    const k = rowKey(next);
+    const prev = index.has(k) ? rows[index.get(k)] : null;
+    if (prev) {
+      Object.assign(prev, next, { raw: next.raw || prev.raw || null });
+      updated += 1;
+    } else {
+      rows.push(next);
+      index.set(k, rows.length - 1);
+      added += 1;
+    }
+  }
+  saveRows(entityKey, rows);
+  return { added, updated, total: rows.length };
+}
+
+/** 行过滤:主体 + 区间 + 门店 + 粒度 */
+function queryRows(entityKey, { from, to, shopId, scope } = {}) {
+  return loadRows(entityKey).filter((r) => {
+    if (from && r.date < from) return false;
+    if (to && r.date > to) return false;
+    if (shopId && String(r.shopId) !== String(shopId)) return false;
+    if (scope && r.scope !== scope) return false;
+    return true;
+  });
+}
+
+function datesInLake(entityKey) {
+  const set = new Set();
+  for (const r of loadRows(entityKey)) set.add(r.date);
+  return [...set].sort();
+}
+
+function deleteEntityData(entityKey) {
+  const dir = path.join(LAKE_DIR, sanitizeKey(entityKey));
+  fs.rmSync(dir, { recursive: true, force: true });
+}
+
+// ---------- 同步历史 ----------
+
+function syncsFile(entityKey) {
+  return path.join(LAKE_DIR, sanitizeKey(entityKey), 'syncs.json');
+}
+
+function appendSyncLog(entityKey, entry) {
+  const file = syncsFile(entityKey);
+  const data = readJsonSafe(file, { runs: [] });
+  data.runs.unshift(entry);
+  if (data.runs.length > 50) data.runs.length = 50;
+  writeJsonAtomic(file, data);
+}
+
+function loadSyncLogs(entityKey) {
+  return readJsonSafe(syncsFile(entityKey), { runs: [] }).runs;
+}
+
+module.exports = {
+  LAKE_DIR,
+  loadRegistry, saveRegistry, upsertEntity, listEntities, getEntity,
+  loadRows, saveRows, upsertRows, queryRows, datesInLake, deleteEntityData, rowKey,
+  appendSyncLog, loadSyncLogs,
+  sanitizeKey,
+};

+ 334 - 8
server.js

@@ -10,13 +10,55 @@ const { URL } = require('url');
 const jpeg = require('jpeg-js');
 const { PNG } = require('pngjs');
 const { getImageSize } = require('./imageMeta.js');
+const naposService = require('./napos-service.js');
+const nlQuery = require('./nl-query.js');
+const reportHtml = require('./report-html.js');
+
+const CONFIG_PATH = path.join(__dirname, 'config.json');
+const CONFIG_EXAMPLE_PATH = path.join(__dirname, 'config.example.json');
+const CONFIG_DEFAULTS = {
+  port: 5177,
+  apiKey: '',
+  apiBase: 'https://api.lk888.ai',
+  model: 'gpt-image-2',
+  maxConcurrentJobs: 4,
+  pageSize: 50,
+  delayMs: 300,
+  requestTimeoutMs: 20000,
+  chatModel: '',
+  chatApiBase: '',
+  chatApiKey: '',
+};
+
+function writeConfigFile(cfg) {
+  const temp = CONFIG_PATH + '.tmp';
+  fs.writeFileSync(temp, JSON.stringify(cfg, null, 2), 'utf8');
+  fs.renameSync(temp, CONFIG_PATH);
+}
+
+// 缺失/损坏时自动用模板(或内置默认)在本机生成新的 config.json —— 分发包开箱即用
+function loadConfig() {
+  try {
+    return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
+  } catch (e) {
+    if (e.code !== 'ENOENT') {
+      console.warn(`config.json 解析失败(${e.message || e}),已改用默认配置;保存设置后会重建该文件。`);
+    }
+    let seeded = {};
+    try { seeded = JSON.parse(fs.readFileSync(CONFIG_EXAMPLE_PATH, 'utf8')); } catch { /* 无模板则用内置默认 */ }
+    const cfg = { ...CONFIG_DEFAULTS, ...seeded };
+    try {
+      writeConfigFile(cfg);
+      console.log('已生成本机默认 config.json —— 请在「系统设置」中填写你自己的 API Key。');
+    } catch { /* 只读目录等场景忽略 */ }
+    return cfg;
+  }
+}
 
-let config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8'));
+let config = loadConfig();
 
 function saveConfig() {
-  const temp = path.join(__dirname, 'config.json.tmp');
-  fs.writeFileSync(temp, JSON.stringify(config, null, 2), 'utf8');
-  fs.renameSync(temp, path.join(__dirname, 'config.json'));
+  writeConfigFile(config);
 }
 const WORKSPACE_ROOT = path.join(path.dirname(__dirname), 'workspace');
 function getWorkspaceRoot() {
@@ -52,7 +94,12 @@ function safeShopDir(root, folderName) {
   const shopDir = path.resolve(rootDir, folderName);
   return shopDir.startsWith(rootDir + path.sep) ? shopDir : null;
 }
-fs.mkdirSync(getWorkspaceRoot(), { recursive: true });
+try {
+  fs.mkdirSync(getWorkspaceRoot(), { recursive: true });
+} catch (e) {
+  // 历史配置的 rootDir 在本机不可创建(权限/目录被移动)时不要崩,允许进设置页修改
+  console.warn(`输出根目录不可用:${getWorkspaceRoot()}(${e.code || e.message})。请到「系统设置」修改输出根目录。`);
+}
 const SESSION_PATH = path.join(__dirname, 'session.json');
 const MANUAL_SHOPS_PATH = path.join(__dirname, 'manual-shops.json');
 
@@ -1680,6 +1727,8 @@ const STATIC_MAP = {
   '/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
   '/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'],
+  '/vendor/echarts.min.js': ['vendor/echarts.min.js', 'application/javascript; charset=utf-8'],
   '/style.css': ['style.css', 'text/css; charset=utf-8'],
 };
 
@@ -1804,6 +1853,10 @@ const server = http.createServer((req, res) => {
       delayMs: config.delayMs,
       cookieConfigured: !!session.cookie && !!getKsid(),
       port: config.port,
+      chatModel: config.chatModel || '',
+      chatApiBase: config.chatApiBase || config.apiBase,
+      chatApiKey: config.chatApiKey ? config.chatApiKey.slice(0, 8) + '••••••••' : '',
+      chatKeyFallback: !config.chatApiKey && !!config.apiKey,
     });
   }
   if (req.method === 'POST' && pathname === '/api/settings') {
@@ -1833,6 +1886,9 @@ const server = http.createServer((req, res) => {
         }
         config.delayMs = delayMs;
       }
+      if (body.chatModel !== undefined) config.chatModel = String(body.chatModel).trim();
+      if (body.chatApiBase !== undefined) config.chatApiBase = String(body.chatApiBase).trim();
+      if (body.chatApiKey !== undefined && !String(body.chatApiKey).includes('••••')) config.chatApiKey = String(body.chatApiKey).trim();
       saveConfig();
       if (body.cookie !== undefined) {
         session.cookie = body.cookie;
@@ -1900,15 +1956,285 @@ const server = http.createServer((req, res) => {
     }).catch(respond500);
   }
 
+  // ---------- 数据报表(淘宝闪购经营数据中台) ----------
+
+  if (pathname === '/api/reports/status') {
+    return Promise.all([naposService.naposStatus()])
+      .then(([napos]) => sendJson(res, 200, { napos, entities: naposService.store.listEntities() }))
+      .catch(respond500);
+  }
+  // ---------- 智能问数(NL2Query,对话模型驱动) ----------
+  const DEFAULT_CHAT_MODEL = 'deepseek-v4.1-flash';
+  function chatCfg() {
+    return {
+      chatApiBase: config.chatApiBase || config.apiBase,
+      chatApiKey: config.chatApiKey || config.apiKey,
+      chatModel: config.chatModel || DEFAULT_CHAT_MODEL,
+    };
+  }
+  if (req.method === 'POST' && pathname === '/api/reports/ask') {
+    return readBody(req).then(async (body) => {
+      const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
+      if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'ASK_NO_ENTITY'; throw e; }
+      const result = await nlQuery.ask({ q: body.q, entity, cfg: chatCfg(), svc: naposService, history: Array.isArray(body.history) ? body.history : undefined });
+      sendJson(res, 200, result);
+    }).catch((e) => sendJson(res, e.code === 'ASK_NO_MODEL' || e.code === 'ASK_EMPTY' || e.code === 'ASK_NO_ENTITY' ? 400 : 502, { error: e.message, code: e.code }));
+  }
+  if (req.method === 'POST' && pathname === '/api/chat/test') {
+    return readBody(req).then(async (body) => {
+      const cfg = {
+        chatApiBase: body.chatApiBase || config.chatApiBase || config.apiBase,
+        chatApiKey: (body.chatApiKey && !String(body.chatApiKey).includes('••••')) ? body.chatApiKey : (config.chatApiKey || config.apiKey),
+        chatModel: body.chatModel || config.chatModel || DEFAULT_CHAT_MODEL,
+      };
+      const reply = await nlQuery.ping(cfg);
+      sendJson(res, 200, { ok: true, reply });
+    }).catch((e) => sendJson(res, 502, { error: e.message }));
+  }
+  // 生图 API 连通性测试:GET /v1/models(轻量、不产生生图费用)
+  if (req.method === 'POST' && pathname === '/api/image/test') {
+    return new Promise((resolve) => {
+      let base;
+      try { base = new URL(config.apiBase); } catch { return sendJson(res, 400, { error: 'API 地址不合法: ' + config.apiBase }); }
+      const options = {
+        hostname: base.hostname, port: base.port || 443, path: '/v1/models', method: 'GET',
+        headers: { Authorization: `Bearer ${config.apiKey}` }, timeout: 15000,
+      };
+      const up = https.request(options, (upRes) => {
+        const chunks = [];
+        upRes.on('data', (c) => chunks.push(c));
+        upRes.on('end', () => {
+          let json = null;
+          try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* 保留空 */ }
+          const models = json && Array.isArray(json.data) ? json.data.map((m) => (m && m.id) || '').filter(Boolean) : null;
+          sendJson(res, 200, { statusCode: upRes.statusCode, models, modelFound: !!(models && models.includes(config.model)), model: config.model });
+        });
+      });
+      up.on('error', (e) => sendJson(res, 200, { statusCode: 0, error: e.message || String(e) }));
+      up.on('timeout', () => up.destroy(new Error('请求超时')));
+      up.end();
+    });
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/report-html') {
+    try {
+      const entity = query.entity || (naposService.store.listEntities()[0] || {}).key;
+      if (!entity) {
+        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+        return res.end('<meta charset="utf-8"><body style="font-family:sans-serif;padding:40px">暂无数据主体,请先在「数据报表」完成一次同步。</body>');
+      }
+      const range = nlQuery.lakeDateRange(entity, naposService);
+      const from = /^\d{8}$/.test(query.from || '') ? query.from : range.from;
+      const to = /^\d{8}$/.test(query.to || '') ? query.to : range.to;
+      const html = reportHtml.buildReportHtml({ entity, from, to });
+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+      return res.end(html);
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/entities') {
+    return sendJson(res, 200, { entities: naposService.store.listEntities() });
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/tree') {
+    return naposService.fetchTree({ refresh: query.refresh === '1' })
+      .then((tree) => sendJson(res, 200, {
+        merchant: tree.merchant, chains: tree.chains,
+        shops: tree.shops.map((s) => ({ shopId: String(s.shopId), shopName: s.shopName, chainId: s.chainId ? String(s.chainId) : '', chainName: s.chainName || '', busyLevel: s.busyLevel || null })),
+        entity: tree.entity,
+      }))
+      .catch((e) => sendJson(res, 502, { error: e.message || String(e), code: e.code }));
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/columns') {
+    return sendJson(res, 200, { columns: naposService.REPORT_COLUMNS });
+  }
+  if (req.method === 'POST' && pathname === '/api/reports/sync') {
+    return readBody(req).then((body) => {
+      naposService.runSync({
+        from: body.from, to: body.to,
+        scope: body.scope || 'all',
+        chainId: body.chainId || '',
+        shopIds: Array.isArray(body.shopIds) ? body.shopIds : undefined,
+      }).catch(() => {});
+      return sendJson(res, 200, { ok: true, ...naposService.syncSnapshot() });
+    }).catch(respond500);
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/sync/status') {
+    return sendJson(res, 200, naposService.syncSnapshot());
+  }
+  if (req.method === 'POST' && pathname === '/api/reports/sync/cancel') {
+    return sendJson(res, 200, naposService.requestSyncCancel());
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/daily') {
+    try {
+      return sendJson(res, 200, naposService.queryReport({
+        entity: query.entity, from: query.from, to: query.to,
+        shopId: query.shopId,
+        shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
+        scope: query.scope || 'shop', q: query.q,
+      }));
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/aggregate') {
+    try {
+      return sendJson(res, 200, naposService.aggregateReport({
+        entity: query.entity, from: query.from, to: query.to,
+        group: query.group || 'shop',
+        shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
+      }));
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/export') {
+    try {
+      const group = query.group === 'date' ? 'date' : (query.group === 'shop' ? 'shop' : null);
+      let rows;
+      if (group) {
+        const agg = naposService.aggregateReport({ entity: query.entity, from: query.from, to: query.to, group });
+        rows = agg.rows;
+      } else {
+        const daily = naposService.queryReport({
+          entity: query.entity, from: query.from, to: query.to,
+          shopId: query.shopId,
+          shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
+          scope: query.scope || 'shop', q: query.q,
+        });
+        rows = daily.rows;
+      }
+      const columns = naposService.REPORT_COLUMNS;
+      const csv = naposService.rowsToCsv(columns, rows);
+      res.writeHead(200, {
+        'Content-Type': 'text/csv; charset=utf-8',
+        'Content-Disposition': `attachment; filename="report-${query.entity || 'all'}-${query.from || ''}-${query.to || ''}.csv"`,
+      });
+      return res.end(csv);
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+  if (req.method === 'GET' && pathname === '/api/reports/syncs') {
+    const key = naposService.store.sanitizeKey(query.entity);
+    return sendJson(res, 200, { runs: naposService.store.loadSyncLogs(key) });
+  }
+  if (req.method === 'DELETE' && pathname === '/api/reports/data') {
+    if (!query.entity) return sendJson(res, 400, { error: '缺少 entity 参数' });
+    try {
+      naposService.store.deleteEntityData(query.entity);
+      pushSystemLog(`数据报表:已清除主体 ${query.entity} 的本地数据`, 'warn');
+      return sendJson(res, 200, { ok: true });
+    } catch (e) {
+      return respond500(e);
+    }
+  }
+
   return serveStatic(pathname, res);
 });
 
 loadPersistedJobs();
 
-server.listen(config.port, HOST, () => {
-  const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${config.port}`;
+// PORT 环境变量可临时覆盖 config.json 的端口(多实例/调试用,不落盘)
+const LISTEN_PORT = Number(process.env.PORT || config.port) || config.port;
+
+function runCmd(cmd, args, timeoutMs = 8000) {
+  return new Promise((resolve) => {
+    execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => resolve(err ? '' : String(stdout || '')));
+  });
+}
+
+/** 找出正在监听指定端口的 PID 列表 */
+async function pidsListeningOnPort(port) {
+  if (process.platform === 'win32') {
+    const out = await runCmd('powershell', ['-NoProfile', '-Command',
+      `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique) -join ','`]);
+    return out.trim().split(',').map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0);
+  }
+  const out = await runCmd('lsof', ['-ti', `tcp:${port}`, '-s', 'TCP:LISTEN']);
+  return out.trim().split('\n').map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0);
+}
+
+/** 校验该 PID 是否是「本项目的 server.js」(命令行 + 工作目录双重确认,避免误杀无关进程) */
+async function isOurServerProcess(pid) {
+  try {
+    if (process.platform === 'win32') {
+      const out = await runCmd('powershell', ['-NoProfile', '-Command',
+        `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`]);
+      return /server\.js/i.test(out) && !!out;
+    }
+    const cmd = await runCmd('ps', ['-p', String(pid), '-o', 'command=']);
+    if (!/server\.js/i.test(cmd)) return false;
+    const cwdOut = await runCmd('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
+    const cwdLine = cwdOut.split('\n').find((l) => l.startsWith('n')) || '';
+    const cwdPath = cwdLine.slice(1);
+    return !cwdPath || cwdPath === __dirname || cwdPath.startsWith(__dirname + path.sep) || cmd.includes(__dirname);
+  } catch {
+    return false;
+  }
+}
+
+/** 端口被占时,自动退出本项目遗留的旧实例并等待端口释放 */
+async function reclaimPortFromStaleInstance(port) {
+  const pids = await pidsListeningOnPort(port);
+  let killedAny = false;
+  for (const pid of pids) {
+    if (pid === process.pid) continue;
+    if (!(await isOurServerProcess(pid))) continue;
+    try {
+      process.kill(pid, 'SIGTERM');
+      killedAny = true;
+      console.log(`已自动退出旧实例(PID ${pid}),正在重新启动...`);
+    } catch { /* 无权限等,忽略 */ }
+  }
+  if (!killedAny) return false;
+  for (let i = 0; i < 20; i++) {
+    await new Promise((r) => setTimeout(r, 200));
+    if (!(await pidsListeningOnPort(port)).length) return true;
+  }
+  // SIGTERM 没退干净 → 强杀兜底
+  for (const pid of await pidsListeningOnPort(port)) {
+    try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
+  }
+  await new Promise((r) => setTimeout(r, 400));
+  return !(await pidsListeningOnPort(port)).length;
+}
+
+function printPortBusyHelp() {
+  console.error(`启动失败:端口 ${LISTEN_PORT} 已被其他程序占用(非本工具实例),无法自动处理。`);
+  console.error('可任选其一:');
+  console.error(`  · 退出占用该端口的程序:macOS/Linux 执行 lsof -ti tcp:${LISTEN_PORT} 查 PID,Windows 执行 netstat -ano | findstr :${LISTEN_PORT}`);
+  console.error(`  · 或修改 config.json 里的 port(或以 PORT=xxxx 环境变量临时指定)后再启动。`);
+}
+
+let listenAttempts = 0;
+const MAX_LISTEN_ATTEMPTS = 3;
+
+server.on('error', async (err) => {
+  if (err && err.code === 'EADDRINUSE' && listenAttempts < MAX_LISTEN_ATTEMPTS) {
+    listenAttempts += 1;
+    console.log(`端口 ${LISTEN_PORT} 被占用,正在检测是否为本工具的旧实例...`);
+    let reclaimed = false;
+    try {
+      reclaimed = await reclaimPortFromStaleInstance(LISTEN_PORT);
+    } catch { /* 检测失败按未回收处理 */ }
+    if (reclaimed) {
+      server.listen(LISTEN_PORT, HOST);
+      return;
+    }
+    printPortBusyHelp();
+    process.exit(1);
+  }
+  if (err && err.code === 'EADDRINUSE') {
+    printPortBusyHelp();
+    process.exit(1);
+  }
+  throw err;
+});
+
+server.listen(LISTEN_PORT, HOST, () => {
+  const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${LISTEN_PORT}`;
   pushSystemLog(`服务已启动:${url}`, 'success');
-  console.log(`门店装修工具已启动: ${url}`);
+  console.log(`智慧运营中台已启动: ${url}`);
   // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;
   // the empty "" argument is the window-title placeholder start expects before the URL.
   execFile('cmd', ['/c', 'start', '', url], () => {});

+ 1 - 1
启动.bat

@@ -4,5 +4,5 @@ if not exist node_modules (
   echo 正在安装依赖...
   call npm install
 )
-echo 正在启动门店装修工具...
+echo 正在启动智慧运营中台...
 call npm start

+ 1 - 1
启动.command

@@ -4,5 +4,5 @@ if [ ! -d node_modules ]; then
   echo "正在安装依赖..."
   npm install
 fi
-echo "正在启动门店装修工具..."
+echo "正在启动智慧运营中台..."
 npm start

+ 1 - 1
启动.sh

@@ -4,5 +4,5 @@ if [ ! -d node_modules ]; then
   echo "正在安装依赖..."
   npm install
 fi
-echo "正在启动门店装修工具..."
+echo "正在启动智慧运营中台..."
 npm start

+ 26 - 0
打包分发.bat

@@ -0,0 +1,26 @@
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0"
+set NAME=智慧运营中台-分发包
+set OUT=..\%NAME%
+set ZIP=..\%NAME%.zip
+
+if exist "%OUT%" rmdir /s /q "%OUT%"
+if exist "%ZIP%" del /q "%ZIP%"
+mkdir "%OUT%"
+
+for %%f in (server.js napos-service.js reports-store.js reports-mcp.js nl-query.js report-html.js imageMeta.js package.json package-lock.json config.example.json README.md 启动.bat) do (
+  if exist "%%f" copy /y "%%f" "%OUT%\" >nul
+)
+robocopy public "%OUT%\public" /E /NFL /NDL /NJH /NJS >nul
+robocopy napos "%OUT%\napos" /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"
+if exist "%ZIP%" rmdir /s /q "%OUT%"
+
+echo 已生成分发包: %ZIP%
+echo 包内不含 config.json / session.json / manual-shops.json / data/
+echo (API Key、Cookie、报表数据只留在你本机)
+echo 对方解压后双击 启动.bat 即可,首次启动自动生成其本机配置。
+pause

+ 43 - 0
打包分发.command

@@ -0,0 +1,43 @@
+#!/bin/bash
+# 一键打包分发:只打代码与运行所需文件,本机密钥与数据(config.json / session.json /
+# manual-shops.json / data/ 等)绝不进包 —— 对方启动后会自动生成他们本机的配置。
+cd "$(dirname "$0")" || exit 1
+
+NAME="智慧运营中台-分发包"
+OUT="../$NAME"
+ZIP="../$NAME.zip"
+
+rm -rf "$OUT" "$ZIP"
+mkdir -p "$OUT"
+
+# 白名单文件(代码 / 模板 / 启动脚本 / 文档)
+for f in server.js napos-service.js reports-store.js reports-mcp.js nl-query.js report-html.js imageMeta.js \
+         package.json package-lock.json config.example.json README.md \
+         启动.command 启动.sh 启动.bat; do
+  [ -f "$f" ] && cp "$f" "$OUT/"
+done
+
+# 目录(前端 / 取数引擎 / 依赖)
+for d in public napos node_modules; do
+  [ -d "$d" ] && cp -R "$d" "$OUT/"
+done
+
+find "$OUT" -name ".DS_Store" -delete 2>/dev/null
+
+(
+  cd .. || exit 1
+  zip -qr "$NAME.zip" "$NAME"
+)
+
+if [ ! -f "$ZIP" ]; then
+  echo "打包失败:$ZIP 未生成"; exit 1
+fi
+rm -rf "$OUT"
+
+echo "已生成分发包: $ZIP"
+echo "包内不含 config.json / session.json / manual-shops.json / data/"
+echo "(API Key、Cookie、报表数据只留在你本机)"
+echo "对方解压后双击 启动.command(macOS)或 启动.bat(Windows)即可,"
+echo "首次启动会自动生成他们本机的配置,并在「系统设置」里填自己的 API Key。"
+echo "若对方电脑装过旧版:把包解压覆盖到原目录即可(不要先删旧目录),"
+echo "其历史 config.json / session.json 会被新版直接读取,API Key 与 Cookie 无需重填。"

+ 43 - 0
打包分发.sh

@@ -0,0 +1,43 @@
+#!/bin/bash
+# 一键打包分发:只打代码与运行所需文件,本机密钥与数据(config.json / session.json /
+# manual-shops.json / data/ 等)绝不进包 —— 对方启动后会自动生成他们本机的配置。
+cd "$(dirname "$0")" || exit 1
+
+NAME="智慧运营中台-分发包"
+OUT="../$NAME"
+ZIP="../$NAME.zip"
+
+rm -rf "$OUT" "$ZIP"
+mkdir -p "$OUT"
+
+# 白名单文件(代码 / 模板 / 启动脚本 / 文档)
+for f in server.js napos-service.js reports-store.js reports-mcp.js nl-query.js report-html.js imageMeta.js \
+         package.json package-lock.json config.example.json README.md \
+         启动.command 启动.sh 启动.bat; do
+  [ -f "$f" ] && cp "$f" "$OUT/"
+done
+
+# 目录(前端 / 取数引擎 / 依赖)
+for d in public napos node_modules; do
+  [ -d "$d" ] && cp -R "$d" "$OUT/"
+done
+
+find "$OUT" -name ".DS_Store" -delete 2>/dev/null
+
+(
+  cd .. || exit 1
+  zip -qr "$NAME.zip" "$NAME"
+)
+
+if [ ! -f "$ZIP" ]; then
+  echo "打包失败:$ZIP 未生成"; exit 1
+fi
+rm -rf "$OUT"
+
+echo "已生成分发包: $ZIP"
+echo "包内不含 config.json / session.json / manual-shops.json / data/"
+echo "(API Key、Cookie、报表数据只留在你本机)"
+echo "对方解压后双击 启动.command(macOS)或 启动.bat(Windows)即可,"
+echo "首次启动会自动生成他们本机的配置,并在「系统设置」里填自己的 API Key。"
+echo "若对方电脑装过旧版:把包解压覆盖到原目录即可(不要先删旧目录),"
+echo "其历史 config.json / session.json 会被新版直接读取,API Key 与 Cookie 无需重填。"

Неке датотеке нису приказане због велике количине промена