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

feat(mode_workflow): 新增按case_id获取帖子接口及知识库原文展示功能

新增数据库方法fetch_post_by_case用于按case_id查询帖子数据,支持search_tools和search_process两张表;新增对应的/api/post_by_case接口;在search.html中添加原文块组件,实现懒加载、展开收起的帖子内容展示,包含标题、配图和正文。
刘文武 пре 2 недеља
родитељ
комит
34ea9c6e06
3 измењених фајлова са 122 додато и 2 уклоњено
  1. 22 0
      examples/mode_workflow/db.py
  2. 96 2
      examples/mode_workflow/search.html
  3. 4 0
      examples/mode_workflow/server.py

+ 22 - 0
examples/mode_workflow/db.py

@@ -761,6 +761,28 @@ def fetch_post(query_id, case_id, table="search_process"):
     return row
 
 
+def fetch_post_by_case(case_id):
+    """按 case_id 取单帖原文(标题/正文/配图/链接),**不限 query、不限方向**。
+
+    供知识库(search.html)按 source_id=case_id 展示原文用——那里只有 source_id,没有
+    query_id/mode。先查 search_tools 再查 search_process(同一帖两表内容一致),取任一命中行。
+    只回列表展示所需列(title/url/body/images/platform/publish_time),无则 None。
+    """
+    conn = _conn()
+    try:
+        with conn.cursor() as cur:
+            for table in ("search_tools", "search_process"):
+                cur.execute(f"""SELECT case_id, title, url, body, images, platform, publish_time
+                                FROM {table} WHERE case_id=%s LIMIT 1""", (case_id,))
+                row = cur.fetchone()
+                if row:
+                    row["images"] = _loads(row["images"])
+                    return row
+    finally:
+        conn.close()
+    return None
+
+
 def fetch_all_posts(mode="process", *, query_ids=None, case_ids=None, adopted_only=False, distinct=False,
                     limit=None, offset=0):
     """某方向「全部帖子」:跨所有 query 的列表(瘦身列,口径同 fetch_posts,不拉

+ 96 - 2
examples/mode_workflow/search.html

@@ -336,6 +336,25 @@
       max-height: 300px; overflow-y: auto;
       white-space: pre-wrap; word-break: break-all;
     }
+    /* ── 原文卡片(按 source_id=case_id 懒加载;标题+缩略图+正文,可整体展开/收起)── */
+    .src-block { border: 1px solid var(--border); border-radius: 8px; margin: 10px 0; background: #F8FAFC; overflow: hidden; }
+    .src-head { display: flex; align-items: center; gap: 8px; padding: 8px 11px; cursor: pointer; user-select: none; }
+    .src-head:hover { background: #F1F5F9; }
+    .src-caret { flex: 0 0 auto; color: var(--text-muted); font-size: 11px; transition: transform .15s; }
+    .src-block.open .src-caret { transform: rotate(90deg); }
+    .src-label { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-light); padding: 1px 7px; border-radius: 4px; }
+    .src-title { flex: 1; min-width: 0; font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
+    .src-link { flex: 0 0 auto; font-size: 12px; color: var(--primary); text-decoration: none; }
+    .src-case { flex: 0 0 auto; font-size: 11px; font-family: 'Courier New', monospace; color: var(--text-muted); background: var(--bg); border: 1px solid var(--border); padding: 1px 7px; border-radius: 4px; white-space: nowrap; }
+    .src-body { padding: 0 11px 11px; }
+    .src-block:not(.open) .src-body { display: none; }
+    .src-thumbs { display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 10px; scrollbar-width: thin; }
+    .src-thumbs img { flex: 0 0 auto; height: 96px; width: auto; max-width: 150px; border-radius: 8px; border: 1px solid var(--border); object-fit: cover; background: #F0EFE9; cursor: zoom-in; }
+    .src-text { font-size: 13px; line-height: 1.7; color: var(--text-sub); white-space: pre-wrap; }
+    .src-text.clamp { max-height: 88px; overflow: hidden; -webkit-mask-image: linear-gradient(180deg,#000 62%,transparent); mask-image: linear-gradient(180deg,#000 62%,transparent); }
+    .src-text-toggle { margin-top: 4px; background: none; border: none; color: var(--primary); cursor: pointer; font-size: 12px; padding: 2px 0; }
+    .src-loading, .src-empty { font-size: 12px; color: var(--text-muted); padding: 8px 2px; }
+
     /* ── 工序步骤表(复用自工序解构的 .steps 表格)─────────────────── */
     .steps-wrap { overflow-x: auto; margin-top: 6px; border: 1px solid var(--border); border-radius: 6px; }
     .steps { width: 100%; min-width: 1500px; table-layout: fixed; border-collapse: collapse; font-size: 12px; }
@@ -1116,6 +1135,80 @@ function markToolClamps() {
   });
 }
 
+// ── 原文块(按 source_id=case_id 懒加载,复用 #dataset 的取数/图片策略)──────────
+// 图片:优先浏览器直连 CDN(referrerpolicy=no-referrer 多能绕防盗链),失败回退同源代理 /api/img。
+const _imgProxy = u => (/^https?:\/\//.test(u || '') ? '/api/img?u=' + encodeURIComponent(u) : (u || ''));
+const _imgDirect = u => (/^https?:\/\//.test(u || '') ? u : _imgProxy(u));
+function _imgFallback(el, proxy) {
+  el.onerror = null;
+  if (!el.dataset.fb) { el.dataset.fb = '1'; el.onerror = () => _imgFallback(el, proxy); el.src = proxy; return; }
+  el.style.opacity = 0.25;
+}
+function _srcImg(u) {
+  return `<img src="${esc(_imgDirect(u))}" referrerpolicy="no-referrer" loading="lazy" onerror="_imgFallback(this,'${esc(_imgProxy(u))}')">`;
+}
+// 折叠态的原文条:标题先用知识里的 source_title 占位,展开后用真实原文标题覆盖。
+function renderSourceBlock(hit) {
+  const caseId = hit.source_id || '';
+  if (!caseId) return '';
+  const placeholder = esc(hit.source_title || hit.title || '(展开加载原文)');
+  return `<div class="src-block" id="src-${esc(hit.id)}">
+    <div class="src-head" onclick="toggleKbSrc('${esc(caseId)}','${esc(hit.id)}')">
+      <span class="src-caret">▸</span>
+      <span class="src-label">原文</span>
+      <span class="src-title">${placeholder}</span>
+      <a class="src-link" target="_blank" rel="noreferrer" onclick="event.stopPropagation()" hidden>原文 ↗</a>
+      <span class="src-case" title="case_id" onclick="event.stopPropagation()">${esc(caseId)}</span>
+    </div>
+    <div class="src-body"><div class="src-loading">展开加载原文…</div></div>
+  </div>`;
+}
+function _srcBodyHtml(p) {
+  const imgs = (p.images || []).filter(Boolean);
+  const thumbs = imgs.length
+    ? `<div class="src-thumbs">${imgs.map(u => `<a href="${esc(u)}" target="_blank" rel="noreferrer">${_srcImg(u)}</a>`).join('')}</div>`
+    : '';
+  const body = p.body
+    ? `<div class="src-text clamp">${esc(p.body)}</div><button class="src-text-toggle" hidden onclick="toggleKbText(this)">展开 ▼</button>`
+    : '';
+  return (thumbs + body) || '<div class="src-empty">(无正文/配图)</div>';
+}
+async function toggleKbSrc(caseId, hitId) {
+  const block = document.getElementById('src-' + hitId);
+  if (!block) return;
+  const open = block.classList.toggle('open');
+  if (!open || block.dataset.loaded) return;
+  block.dataset.loaded = '1';
+  const body = block.querySelector('.src-body');
+  try {
+    const r = await fetch('/api/post_by_case?case_id=' + encodeURIComponent(caseId));
+    if (!r.ok) throw new Error(r.status);
+    const p = await r.json();
+    const titleEl = block.querySelector('.src-title');
+    if (p.title) titleEl.textContent = p.title;
+    const linkEl = block.querySelector('.src-link');
+    if (p.url) { linkEl.href = p.url; linkEl.hidden = false; }
+    body.innerHTML = _srcBodyHtml(p);
+    requestAnimationFrame(() => _markKbSrcClamp(hitId));
+  } catch (e) {
+    block.dataset.loaded = '';  // 失败允许重试
+    body.innerHTML = '<div class="src-empty">原文加载失败,点击标题重试</div>';
+  }
+}
+function toggleKbText(btn) {
+  const t = btn.previousElementSibling;
+  const clamped = t.classList.toggle('clamp');
+  btn.textContent = clamped ? '展开 ▼' : '收起 ▲';
+}
+// 仅当正文在定高下溢出时才显示「展开」
+function _markKbSrcClamp(hitId) {
+  const block = document.getElementById('src-' + hitId);
+  const t = block && block.querySelector('.src-text');
+  const btn = block && block.querySelector('.src-text-toggle');
+  if (!t || !btn) return;
+  btn.hidden = !(t.scrollHeight > t.clientHeight + 2);
+}
+
 function renderCard(hit) {
   let titleHtml = esc(hit.title || '(无标题)');
   if (hit.highlight?.title?.[0]) titleHtml = hit.highlight.title[0];
@@ -1183,14 +1276,14 @@ function renderCard(hit) {
     ? new Date(hit.updated_at).toLocaleDateString('zh-CN') : '';
 
   // 正文展开区:content 可能是一条工序解构(procedure,含 steps)或一个工具解构(tool,含工具名称)。
-  // 工序 → 工序步骤表;工具 → 工具解构表;都解析不出时回退为格式化 JSON
+  // 工序 → 工序步骤表;工具 → 直接展示 content 的格式化 JSON(不再渲染工具解构表)
   let contentSection = '';
   if (hit.content) {
     let inner = '';
     try {
       const obj = JSON.parse(hit.content);
       if (obj && Array.isArray(obj.steps) && obj.steps.length) inner = renderSteps(obj.steps);
-      else if (obj && ('工具名称' in obj || '实质作用域' in obj || '创作层级' in obj)) inner = renderToolTable(obj);
+      // 工具解构:按需求改为直接显示 content JSON,落到下方 content-pre 回退分支。
     } catch {}
     if (!inner) {
       let display = hit.content;
@@ -1205,6 +1298,7 @@ function renderCard(hit) {
     <div class="card-top"><div class="card-title">${titleHtml}</div>${score}</div>
     ${meta ? `<div class="card-meta">${meta}</div>` : ''}
     ${tags ? `<div class="tag-row">${tags}</div>` : ''}
+    ${renderSourceBlock(hit)}
     <div class="card-foot">
       <div class="ext-row">${extHtml}</div>
       <span class="card-time">${updatedAt ? updatedAt + ' 更新' : ''}</span>

+ 4 - 0
examples/mode_workflow/server.py

@@ -693,6 +693,10 @@ class Handler(BaseHTTPRequestHandler):
                 r = db.fetch_post(qs.get("query_id", ""), qs.get("case_id", ""),
                                   qs.get("mode", "process"))
                 self._json_etag(r) if r else self._err("无此帖", 404)
+            elif u.path == "/api/post_by_case":
+                # 按 case_id 取原文(知识库 search.html 用:只有 source_id=case_id,无 query_id/mode)
+                r = db.fetch_post_by_case(qs.get("case_id", ""))
+                self._json_etag(r) if r else self._err("无此帖", 404)
             elif u.path == "/api/extract":
                 # 一次点击合一:单连接同时取版本列表 + 解构详情,前端少一次往返。
                 self._json_etag(db.fetch_extract(