Procházet zdrojové kódy

fix: 门店结构改以数据湖为准(listEntitiesFull 注册表∪行反推,三个接口换用,只读机免重拉即出门店)+ 问数答案 markdown 渲染与运行时间线美化

KenZ1117 před 2 týdny
rodič
revize
7c645960ab
5 změnil soubory, kde provedl 138 přidání a 33 odebrání
  1. 61 3
      public/ask.js
  2. 28 1
      public/style.css
  3. 40 1
      reports-store.js
  4. 3 3
      server/routes/reports.js
  5. 6 25
      server/services/aitable.js

+ 61 - 3
public/ask.js

@@ -47,6 +47,64 @@
 
   function ymdToDash(s) { return /^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || ''); }
 
+  /** 极简安全 Markdown:先整体 HTML 转义,再做受限转换(标题/列表/表格/代码块/引用/加粗/链接)。
+   *  不引入外部依赖;链接仅放行 http(s)。 */
+  function renderMarkdown(src) {
+    const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n');
+    const inline = (s) => esc(s)
+      .replace(/`([^`]+)`/g, '<code>$1</code>')
+      .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
+      .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
+    const out = [];
+    let list = null;
+    let para = [];
+    const closeList = () => { if (list) { out.push(`</${list}>`); list = null; } };
+    const closePara = () => { if (para.length) { out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; } };
+    let i = 0;
+    while (i < lines.length) {
+      const line = lines[i];
+      if (/^```/.test(line)) {
+        closePara(); closeList();
+        const buf = [];
+        i += 1;
+        while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i += 1; }
+        i += 1;
+        out.push(`<pre class="md-code">${esc(buf.join('\n'))}</pre>`);
+        continue;
+      }
+      if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
+        closePara(); closeList();
+        const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
+        const head = cells(line);
+        i += 2;
+        const rows = [];
+        while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i += 1; }
+        out.push(`<table class="md-table"><thead><tr>${head.map((h) => `<th>${inline(h)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
+        continue;
+      }
+      const h = line.match(/^(#{1,4})\s+(.*)$/);
+      if (h) { closePara(); closeList(); const lv = h[1].length + 2; out.push(`<h${lv}>${inline(h[2])}</h${lv}>`); i += 1; continue; }
+      if (/^\s*-{3,}\s*$/.test(line)) { closePara(); closeList(); out.push('<hr>'); i += 1; continue; }
+      const q = line.match(/^\s*>\s?(.*)$/);
+      if (q) { closePara(); closeList(); out.push(`<blockquote>${inline(q[1])}</blockquote>`); i += 1; continue; }
+      const ul = line.match(/^\s*[-*]\s+(.*)$/);
+      const ol = line.match(/^\s*\d+[.、]\s+(.*)$/);
+      if (ul || ol) {
+        closePara();
+        const want = ul ? 'ul' : 'ol';
+        if (list !== want) { closeList(); out.push(`<${want} class="md-list">`); list = want; }
+        out.push(`<li>${inline((ul || ol)[1])}</li>`);
+        i += 1;
+        continue;
+      }
+      if (!line.trim()) { closePara(); closeList(); i += 1; continue; }
+      para.push(line);
+      i += 1;
+    }
+    closePara(); closeList();
+    return out.join('');
+  }
+
   function fmtDuration(ms) { return ms == null || ms === '' ? '' : (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : `${ms}ms`); }
   function fmtBytes(n) { return n == null ? '' : (n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : `${n} B`); }
   function fmtTokens(n) { return n == null ? '—' : (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)); }
@@ -412,7 +470,7 @@
         if (step.tokensIn != null) bits.push(`${fmtTokens(step.tokensIn)} in / ${fmtTokens(step.tokensOut)} out`);
         steps.push(`<div class="ask-step">
           <div class="ask-step-head"><span class="ask-dot done"></span><b>思考${step.turn ? ` · Turn ${step.turn}` : ''}</b></div>
-          <div class="ask-step-meta">${bits.filter(Boolean).join(' · ')}</div>
+          <div class="ask-step-meta">${bits.filter(Boolean).map((b) => `<span class="ask-chip">${esc(String(b))}</span>`).join('')}</div>
           ${step.text ? `<div class="ask-step-body">${esc(step.text)}</div>` : ''}
         </div>`);
       } else if (step.type === 'tool') {
@@ -421,7 +479,7 @@
         if (step.rows != null) bits.push(`${step.rows} 行`);
         steps.push(`<div class="ask-step">
           <div class="ask-step-head"><span class="ask-dot ${step.ok ? 'done' : 'failed'}"></span><b>${esc(label)}</b></div>
-          <div class="ask-step-meta">${bits.filter(Boolean).join(' · ')}</div>
+          <div class="ask-step-meta">${bits.filter(Boolean).map((b) => `<span class="ask-chip">${esc(String(b))}</span>`).join('')}</div>
           <details class="ask-tool"><summary>参数与结果</summary>
             <pre class="ask-pre">参数:${esc(JSON.stringify(step.args || {}))}
 
@@ -457,7 +515,7 @@ ${esc(step.summary || step.error || '')}</pre>
 
     const result = rec.result || null;
     if (result && result.answer) {
-      parts.push(`<div class="ask-answer">${esc(result.answer)}</div>`);
+      parts.push(`<div class="ask-answer">${renderMarkdown(result.answer)}</div>`);
       const chart = result.chart;
       if (chart && chart.type && chart.type !== 'none') {
         parts.push(`<div class="ask-chart" data-run="${esc(rec.runId)}"></div>`);

+ 28 - 1
public/style.css

@@ -1077,7 +1077,34 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 @keyframes askblink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }
 .ask-error { background: var(--fail-bg); color: var(--fail); border-radius: 8px; padding: 8px 10px; }
 .ask-note { color: var(--text-3); font-size: 11.5px; }
-.ask-answer { white-space: pre-wrap; }
+/* 答案区:markdown 渲染排版(renderMarkdown 产出 p/ul/table/pre 等结构) */
+.ask-answer { font-size: 13.5px; line-height: 1.75; color: var(--text); overflow-wrap: break-word; }
+.ask-answer > :first-child { margin-top: 0; }
+.ask-answer > :last-child { margin-bottom: 0; }
+.ask-answer p { margin: 8px 0; }
+.ask-answer h3, .ask-answer h4, .ask-answer h5, .ask-answer h6 { margin: 14px 0 6px; font-size: 13.5px; font-weight: 700; }
+.ask-answer h3 { font-size: 15px; }
+.ask-answer ul.md-list, .ask-answer ol.md-list { margin: 8px 0; padding-left: 20px; display: grid; gap: 4px; }
+.ask-answer blockquote { margin: 8px 0; padding: 6px 12px; border-left: 3px solid var(--border); color: var(--text-2); background: var(--border-light); border-radius: 0 8px 8px 0; }
+.ask-answer code { padding: 1px 5px; border-radius: 5px; background: var(--border-light); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+.ask-answer .md-code { margin: 8px 0; padding: 10px 12px; border-radius: 8px; background: var(--sidebar-bg); color: var(--bg); font-size: 12px; overflow: auto; line-height: 1.6; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+.ask-answer .md-table { margin: 10px 0; border-collapse: collapse; font-size: 12.5px; width: 100%; }
+.ask-answer .md-table th, .ask-answer .md-table td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; }
+.ask-answer .md-table th { background: var(--border-light); font-weight: 650; white-space: nowrap; }
+.ask-answer hr { border: 0; border-top: 1px solid var(--border); margin: 12px 0; }
+.ask-answer a { color: var(--accent); }
+.ask-answer strong { font-weight: 700; }
+
+/* 运行过程时间线:状态点 + 元信息胶囊 + 思考/工具卡 */
+.ask-step { padding-left: 22px; }
+.ask-step .ask-dot { top: 5px; }
+.ask-step-head b { font-size: 13px; }
+.ask-step-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 5px; }
+.ask-chip { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 999px; background: var(--border-light); color: var(--text-2); font-size: 11px; line-height: 1.7; }
+.ask-step-body { margin-top: 6px; padding: 8px 10px; border-radius: 8px; background: var(--border-light); color: var(--text-2); font-size: 12px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
+.ask-tool { margin-top: 6px; }
+.ask-tool summary { cursor: pointer; font-size: 11.5px; color: var(--text-3); user-select: none; }
+.ask-tool summary:hover { color: var(--text-2); }
 .ask-chart { width: 100%; height: 240px; margin-top: 4px; border: 1px solid var(--border-light); border-radius: 10px; background: #fff; }
 .ask-chart-title { font-size: 11px; color: var(--text-3); text-align: center; margin-top: 4px; }
 .ask-actions { margin-top: 4px; }

+ 40 - 1
reports-store.js

@@ -99,6 +99,44 @@ function listEntities() {
     .sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
 }
 
+// ---------- 主体结构:行反推树 ----------
+// 原则:数据中台/趋势图表/智能问数的门店结构以数据湖为准——湖里有什么店,结构就是什么。
+// 只读机(无商家客户端)拿不到 shopTree,注册表树为空,结构元数据只能来自数据行本身。
+
+/** 从行反推连锁/门店结构(纯函数)。company 行的 chainId=-1 不算连锁;无 ID 门店跳过。 */
+function treeFromRows(rows) {
+  const chains = new Map();
+  const shops = new Map();
+  for (const r of rows || []) {
+    const chainId = String(r.chainId || '');
+    const chainName = String(r.chainName || '');
+    if ((r.scope === 'chain' || r.scope === 'shop') && chainId && chainId !== '-1' && !chains.has(chainId)) {
+      chains.set(chainId, { chainId, chainName });
+    }
+    if (r.scope === 'shop' && r.shopId && !shops.has(String(r.shopId))) {
+      shops.set(String(r.shopId), { shopId: String(r.shopId), shopName: String(r.shopName || ''), chainId, chainName });
+    }
+  }
+  return { chains: [...chains.values()], shops: [...shops.values()] };
+}
+
+/** 注册表树 ∪ 行反推树:注册表条目优先(保留 busyLevel 等客户端字段),行补空缺。 */
+function mergeTree(registry, derived) {
+  const chains = new Map((registry.chains || []).map((c) => [String(c.chainId), c]));
+  for (const c of derived.chains) if (!chains.has(c.chainId)) chains.set(c.chainId, c);
+  const shops = new Map((registry.shops || []).map((s) => [String(s.shopId), s]));
+  for (const s of derived.shops) if (!shops.has(s.shopId)) shops.set(s.shopId, s);
+  return { chains: [...chains.values()], shops: [...shops.values()] };
+}
+
+/** listEntities + 行反推树合并:数据中台/趋势图表/智能问数等展示层用这份。 */
+function listEntitiesFull() {
+  return listEntities().map((e) => {
+    const merged = mergeTree(e, treeFromRows(loadRows(e.key)));
+    return { ...e, chains: merged.chains, shops: merged.shops, chainCount: merged.chains.length, shopCount: merged.shops.length };
+  });
+}
+
 function getEntity(key) {
   return loadRegistry().entities.find((e) => e.key === sanitizeKey(key)) || null;
 }
@@ -289,8 +327,9 @@ function loadSyncLogs(entityKey) {
 
 module.exports = {
   LAKE_DIR,
-  loadRegistry, saveRegistry, upsertEntity, listEntities, getEntity,
+  loadRegistry, saveRegistry, upsertEntity, listEntities, listEntitiesFull, getEntity,
   loadRows, saveRows, upsertRows, queryRows, datesInLake, deleteEntityData, rowKey,
   appendSyncLog, loadSyncLogs,
+  treeFromRows, mergeTree,
   sanitizeKey,
 };

+ 3 - 3
server/routes/reports.js

@@ -14,7 +14,7 @@ function register(app, ctx) {
   // 不限方法(保持拆分前行为:该路由未做方法检查)
   app.any('/api/reports/status', async ({ res }) => {
     const napos = await naposService.naposStatus();
-    sendJson(res, 200, { napos, entities: naposService.store.listEntities() });
+    sendJson(res, 200, { napos, entities: naposService.store.listEntitiesFull() });
   });
 
   // ---------- 智能问数(NL2Query,对话模型驱动) ----------
@@ -48,7 +48,7 @@ function register(app, ctx) {
   });
 
   app.get('/api/reports/entities', async ({ res }) => {
-    sendJson(res, 200, { entities: naposService.store.listEntities() });
+    sendJson(res, 200, { entities: naposService.store.listEntitiesFull() });
   });
 
   // 同步历史(数据采集页「同步历史」面板)
@@ -61,7 +61,7 @@ function register(app, ctx) {
   // 数据中台:本地湖全景(主体覆盖、行数分布、最近同步/推送),供中台页渲染
   app.get('/api/reports/lake', async ({ res }) => {
     const store = naposService.store;
-    const entities = store.listEntities().map((e) => {
+    const entities = store.listEntitiesFull().map((e) => {
       const rows = store.loadRows(e.key);
       const byScope = { company: 0, chain: 0, shop: 0, summary: 0 };
       let from = '';

+ 6 - 25
server/services/aitable.js

@@ -88,23 +88,7 @@ function detectLayerConflict(rows) {
   return { shopRows: shopRows.length };
 }
 
-/** 从拉回的行反推连锁/门店结构(纯函数):只读机没有商家客户端、拿不到 shopTree,
- *  云端拉取是它唯一的结构来源;不回填的话主体显示「0 家门店」且无法按门店筛选。 */
-function treeFromRows(rows) {
-  const chains = new Map();
-  const shops = new Map();
-  for (const r of rows) {
-    const chainId = String(r.chainId || '');
-    const chainName = String(r.chainName || '');
-    if ((r.scope === 'chain' || r.scope === 'shop') && chainId && chainId !== '-1' && !chains.has(chainId)) {
-      chains.set(chainId, { chainId, chainName });
-    }
-    if (r.scope === 'shop' && r.shopId && !shops.has(String(r.shopId))) {
-      shops.set(String(r.shopId), { shopId: String(r.shopId), shopName: String(r.shopName || ''), chainId, chainName });
-    }
-  }
-  return { chains: [...chains.values()], shops: [...shops.values()] };
-}
+const lakeStore = require('../../reports-store.js');
 
 function rowToRecord(row, accountName) {
   const fields = {
@@ -486,16 +470,12 @@ function createAitableService(ctx) {
       }
       const res = naposService.store.upsertRows(key, rows);
       // 反推连锁/门店结构回填注册表(与已有树取并集,已有条目优先、保留 busyLevel 等字段)
-      const derived = treeFromRows(rows);
-      const chains = new Map((meta.chains || []).map((c) => [String(c.chainId), c]));
-      for (const c of derived.chains) if (!chains.has(c.chainId)) chains.set(c.chainId, c);
-      const shops = new Map((meta.shops || []).map((s) => [String(s.shopId), s]));
-      for (const s of derived.shops) if (!shops.has(s.shopId)) shops.set(s.shopId, s);
+      const merged = lakeStore.mergeTree(meta, lakeStore.treeFromRows(rows));
       naposService.store.upsertEntity({
         merchantId: meta.merchantId || acc,
         merchantName: meta.name || acc,
-        chains: [...chains.values()],
-        shops: [...shops.values()],
+        chains: merged.chains,
+        shops: merged.shops,
       });
       pulledKeys.push(key);
       result.pulled += rows.length;
@@ -571,5 +551,6 @@ function createAitableService(ctx) {
 module.exports = {
   createAitableService, FIELD_COLUMNS, DIMENSION_LABEL, SHEET_DEFAULTS,
   rowToRecord, recordKey, recordToRow, splitRowsBySheet, resolveSheets, pushSummary,
-  detectLayerConflict, treeFromRows,
+  detectLayerConflict,
+  treeFromRows: (...args) => lakeStore.treeFromRows(...args),
 };