Przeglądaj źródła

feat(mode_workflow): 优化图片加载,添加解构缓存与加载体验

优先使用浏览器直连CDN加载图片,减少本机上行带宽占用;添加上下文缓存和请求竞态处理,解决多次点击卡顿和残留旧内容问题;封装通用图片渲染函数,新增加载动画并更新gitignore忽略规则。
刘文武 1 dzień temu
rodzic
commit
73677c9c0f
2 zmienionych plików z 102 dodań i 18 usunięć
  1. 2 1
      examples/mode_workflow/.gitignore
  2. 100 17
      examples/mode_workflow/index.html

+ 2 - 1
examples/mode_workflow/.gitignore

@@ -7,4 +7,5 @@ __pycache__/
 .server.pid
 .cloudflared.log
 .cloudflared.pid
-.server_8772.out
+.server_8772.out
+import_process_knowledge.处理方式.md

+ 100 - 17
examples/mode_workflow/index.html

@@ -307,6 +307,22 @@
         color: var(--line-dark);
         display: block;
       }
+      .spinner {
+        display: inline-block;
+        width: 16px;
+        height: 16px;
+        border: 2px solid var(--line-dark);
+        border-top-color: var(--blue);
+        border-radius: 50%;
+        animation: spin 0.7s linear infinite;
+        vertical-align: -3px;
+        margin-right: 8px;
+      }
+      @keyframes spin {
+        to {
+          transform: rotate(360deg);
+        }
+      }
 
       /* ── Dashboard ── */
       .dash-section {
@@ -2260,6 +2276,29 @@
         );
       /* 外链图片走本服务同源反代,绕过公众号(mmbiz.qpic.cn)等防盗链 */
       const imgProxy = (u) => (/^https?:\/\//.test(u || "") ? "/api/img?u=" + encodeURIComponent(u) : u || "");
+      /* 图片加载策略:优先「浏览器直连 CDN」(referrerpolicy=no-referrer 多数能绕防盗链),
+         直连失败再回退到同源代理 /api/img。
+         为什么:公网走 Cloudflare 快速隧道(trycloudflare),所有经 /api/img 的图片字节都要
+         「上游CDN → 本机 → 隧道(受本机上行带宽限制)→ 客户」,一帖十几张图(每张 100~180KB)
+         就把本机上行打满,于是「本地快、公网慢」。改为浏览器直连后,绝大多数图片字节不再过本机/
+         隧道,只有少数仍被防盗链拦截的才回退代理。 */
+      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;
+        }
+        if (el.dataset.ph)
+          el.outerHTML = el.dataset.ph; // 代理也失败:换占位元素
+        else el.style.opacity = 0.25; // 无占位:淡化
+      }
+      function imgHtml(u, { cls = "", extra = "", ph = "" } = {}) {
+        const phAttr = ph ? ` data-ph="${esc(ph)}"` : "";
+        return `<img ${cls ? `class="${cls}" ` : ""}src="${esc(imgDirect(u))}" referrerpolicy="no-referrer" loading="lazy"${phAttr} ${extra} onerror="imgFallback(this,'${esc(imgProxy(u))}')">`;
+      }
       const state = {
         tab: "dashboard",
         mode: "process",
@@ -2605,8 +2644,7 @@
                 : "";
             const thumbSrc = (p.images || []).filter(Boolean)[0];
             const thumb = thumbSrc
-              ? `<img class="thumb" src="${esc(imgProxy(thumbSrc))}" loading="lazy"
-              onerror="this.outerHTML='<div class=\\'thumb-ph\\'>🖼️</div>'">`
+              ? imgHtml(thumbSrc, { cls: "thumb", ph: "<div class='thumb-ph'>🖼️</div>" })
               : `<div class="thumb-ph" title="暂无图片">🖼️</div>`;
             return `<div class="post ${p.case_id === state.caseId ? "on" : ""}" onclick="selectPost('${esc(p.case_id)}')">
       ${thumb}
@@ -2803,7 +2841,11 @@
         renderLightbox();
       }
       function renderLightbox() {
-        $("#lb-img").src = imgProxy(lb.imgs[lb.i]);
+        const url = lb.imgs[lb.i];
+        const el = $("#lb-img");
+        delete el.dataset.fb; // 切换图片前清除上一张的回退标记
+        el.onerror = () => imgFallback(el, imgProxy(url));
+        el.src = imgDirect(url); // 大图同样优先直连,失败回退代理
         $("#lb-count").textContent = `${lb.i + 1} / ${lb.imgs.length}`;
         const multi = lb.imgs.length > 1;
         document.querySelector(".lb-prev").style.visibility = multi ? "visible" : "hidden";
@@ -2836,8 +2878,7 @@
         $("#pd-images").innerHTML = imgs.length
           ? imgs
               .map(
-                (s, i) => `<img src="${esc(imgProxy(s))}" loading="lazy"
-        onclick="openLightbox(${i})" onerror="this.style.opacity=.25">`,
+                (s, i) => imgHtml(s, { extra: `onclick="openLightbox(${i})"` }),
               )
               .join("")
           : '<p style="color:var(--ink-faint);font-size:12px">搜索详情未返回图片。</p>';
@@ -2887,38 +2928,78 @@
         renderPosts();
         await loadExtract();
       }
+
+      /* ── 解构结果:客户端缓存 + 请求竞态守卫(解决「多次点击卡顿 / 残留上一帖」)──
+         · _extractCache:解构数据只在「重新解构」时变(由本页 startExtract 触发,届时清缓存),
+           故缓存可长留——再次点开看过的帖 0 往返、瞬时渲染。
+         · _extractSeq:每次发起 +1;晚到的旧响应据 seq 丢弃,杜绝快速连点时旧响应覆盖新选中。 */
+      const _extractCache = new Map(); // key: mode|case_id|version → {versions,data,missing}
+      const _extractKey = (cid, ver) => `${state.mode}|${cid}|${ver || ""}`;
+      let _extractSeq = 0;
+      function invalidateExtractCache(cid) {
+        for (const k of [..._extractCache.keys()]) if (k.includes(`|${cid}|`)) _extractCache.delete(k);
+      }
+
       async function loadExtract() {
         if (!state.caseId) return renderExtractEmpty();
         const isProc = state.mode === "process";
+        const seq = ++_extractSeq; // 本次请求序号
+        const key = _extractKey(state.caseId, state.version);
+
+        // 1) 命中缓存:瞬时渲染,0 往返
+        const cached = _extractCache.get(key);
+        if (cached) return paintExtract(cached, seq);
+
+        // 2) 未命中:立即显示「原文 + loading」。关键:重写 #xp-body 会移除上一帖的 <img>,
+        //    浏览器随即取消其 /api/img 下载,腾出每域 ~6 条连接额度,新的 /api/extract 不再
+        //    排在旧图片请求后面等待——这是「多次点击要等很久」的根因。同时给出加载态,
+        //    不再停留在上一帖的解构结果。
+        renderExtractHead([], null, false, true);
+        $("#xp-body").innerHTML =
+          renderSourceBlock() +
+          `<div class="empty"><span class="spinner"></span>正在加载${isProc ? "工序" : "工具"}解构…</div>`;
+
         // 版本列表 + 解构详情合一,一个请求拿全(服务端同连接两查,ETag 命中可走 304)
         const url =
           `/api/extract?mode=${state.mode}&case_id=` +
           encodeURIComponent(state.caseId) +
           (state.version ? "&version=" + encodeURIComponent(state.version) : "");
-        let versions = [],
-          data = null,
-          missing = false;
+        let res;
         try {
-          const res = await api(url);
-          versions = res.versions || [];
-          data = res.data;
-          missing = res.missing || !data;
-        } catch (e) {}
+          res = await api(url);
+        } catch (e) {
+          if (seq !== _extractSeq) return; // 已切到别的帖,丢弃本响应
+          $("#xp-body").innerHTML = renderSourceBlock() + '<div class="empty">解构数据加载失败,请重试</div>';
+          return;
+        }
+        if (seq !== _extractSeq) return; // 晚到的旧响应:不覆盖新选中
+        const payload = { versions: res.versions || [], data: res.data, missing: res.missing || !res.data };
+        _extractCache.set(key, payload);
+        paintExtract(payload, seq);
+      }
+
+      function paintExtract({ versions, data, missing }, seq) {
+        if (seq !== _extractSeq) return; // 渲染前再校验(缓存命中路径也经此)
+        const isProc = state.mode === "process";
         renderExtractHead(versions, data, missing);
         const body = $("#xp-body");
         if (missing || !data) {
-          body.innerHTML = `<div class="empty">该帖暂无${isProc ? "工序" : "工具"}解构<br><br>
+          body.innerHTML =
+            renderSourceBlock() +
+            `<div class="empty">该帖暂无${isProc ? "工序" : "工具"}解构<br><br>
       <button class="btn primary" onclick="startExtract(['${esc(state.caseId)}'])">开始解构</button></div>`;
           return;
         }
         state.version = data.version;
+        // 以「具体 version」补一条缓存别名,版本下拉切回时也能命中
+        _extractCache.set(_extractKey(state.caseId, data.version), { versions, data, missing });
         syncVersionSelect();
         body.innerHTML = isProc ? renderProcedures(data) : renderTools(data);
         requestAnimationFrame(markSrcTextClamp);
         if (isProc) requestAnimationFrame(markStepClamps);
         else requestAnimationFrame(markClampedCells);
       }
-      function renderExtractHead(versions, data, missing) {
+      function renderExtractHead(versions, data, missing, loading) {
         const isProc = state.mode === "process";
         const title = state.post ? esc(state.post.title || state.caseId) : esc(state.caseId || "");
         const opts = versions
@@ -2928,8 +3009,9 @@
           )
           .join("");
         const models = (isProc ? MODELS_PROC : MODELS_TOOL).map((m) => `<option>${m}</option>`).join("");
+        const stat = loading ? "加载中…" : missing ? "未提取" : "已提取";
         $("#xp-head").innerHTML = `
-    <span class="st">大模型${isProc ? "工序" : "工具"}:<em>${missing ? "未提取" : "已提取"}</em></span>
+    <span class="st">大模型${isProc ? "工序" : "工具"}:<em>${stat}</em></span>
     <span style="font-size:12px;color:var(--ink-faint);max-width:330px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${title}">${title}</span>
     <span class="spacer"></span>
     ${versions.length ? `<select id="ver-sel">${opts}</select>` : ""}
@@ -2959,7 +3041,7 @@
           ? `<div class="src-thumbs">${imgs
               .map(
                 (s, i) =>
-                  `<img src="${esc(imgProxy(s))}" loading="lazy" onclick="openSrcLightbox(${i})" onerror="this.style.opacity=.25">`,
+                  imgHtml(s, { extra: `onclick="openSrcLightbox(${i})"` }),
               )
               .join("")}</div>`
           : "";
@@ -3226,6 +3308,7 @@
           });
           showTask(`${isProc ? "工序" : "工具"}解构 · ${caseIds.length} 帖`, r.task_id, async () => {
             state.selected.clear();
+            caseIds.forEach(invalidateExtractCache); // 重新解构后数据已变,清这些帖的缓存
             await selectQuery(state.queryId);
             if (caseIds.includes(state.caseId)) {
               /* selectQuery 清了 caseId,恢复选中 */