|
@@ -307,6 +307,22 @@
|
|
|
color: var(--line-dark);
|
|
color: var(--line-dark);
|
|
|
display: block;
|
|
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 ── */
|
|
/* ── Dashboard ── */
|
|
|
.dash-section {
|
|
.dash-section {
|
|
@@ -2260,6 +2276,29 @@
|
|
|
);
|
|
);
|
|
|
/* 外链图片走本服务同源反代,绕过公众号(mmbiz.qpic.cn)等防盗链 */
|
|
/* 外链图片走本服务同源反代,绕过公众号(mmbiz.qpic.cn)等防盗链 */
|
|
|
const imgProxy = (u) => (/^https?:\/\//.test(u || "") ? "/api/img?u=" + encodeURIComponent(u) : u || "");
|
|
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 = {
|
|
const state = {
|
|
|
tab: "dashboard",
|
|
tab: "dashboard",
|
|
|
mode: "process",
|
|
mode: "process",
|
|
@@ -2605,8 +2644,7 @@
|
|
|
: "";
|
|
: "";
|
|
|
const thumbSrc = (p.images || []).filter(Boolean)[0];
|
|
const thumbSrc = (p.images || []).filter(Boolean)[0];
|
|
|
const thumb = thumbSrc
|
|
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>`;
|
|
: `<div class="thumb-ph" title="暂无图片">🖼️</div>`;
|
|
|
return `<div class="post ${p.case_id === state.caseId ? "on" : ""}" onclick="selectPost('${esc(p.case_id)}')">
|
|
return `<div class="post ${p.case_id === state.caseId ? "on" : ""}" onclick="selectPost('${esc(p.case_id)}')">
|
|
|
${thumb}
|
|
${thumb}
|
|
@@ -2803,7 +2841,11 @@
|
|
|
renderLightbox();
|
|
renderLightbox();
|
|
|
}
|
|
}
|
|
|
function 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}`;
|
|
$("#lb-count").textContent = `${lb.i + 1} / ${lb.imgs.length}`;
|
|
|
const multi = lb.imgs.length > 1;
|
|
const multi = lb.imgs.length > 1;
|
|
|
document.querySelector(".lb-prev").style.visibility = multi ? "visible" : "hidden";
|
|
document.querySelector(".lb-prev").style.visibility = multi ? "visible" : "hidden";
|
|
@@ -2836,8 +2878,7 @@
|
|
|
$("#pd-images").innerHTML = imgs.length
|
|
$("#pd-images").innerHTML = imgs.length
|
|
|
? imgs
|
|
? imgs
|
|
|
.map(
|
|
.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("")
|
|
.join("")
|
|
|
: '<p style="color:var(--ink-faint);font-size:12px">搜索详情未返回图片。</p>';
|
|
: '<p style="color:var(--ink-faint);font-size:12px">搜索详情未返回图片。</p>';
|
|
@@ -2887,38 +2928,78 @@
|
|
|
renderPosts();
|
|
renderPosts();
|
|
|
await loadExtract();
|
|
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() {
|
|
async function loadExtract() {
|
|
|
if (!state.caseId) return renderExtractEmpty();
|
|
if (!state.caseId) return renderExtractEmpty();
|
|
|
const isProc = state.mode === "process";
|
|
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)
|
|
// 版本列表 + 解构详情合一,一个请求拿全(服务端同连接两查,ETag 命中可走 304)
|
|
|
const url =
|
|
const url =
|
|
|
`/api/extract?mode=${state.mode}&case_id=` +
|
|
`/api/extract?mode=${state.mode}&case_id=` +
|
|
|
encodeURIComponent(state.caseId) +
|
|
encodeURIComponent(state.caseId) +
|
|
|
(state.version ? "&version=" + encodeURIComponent(state.version) : "");
|
|
(state.version ? "&version=" + encodeURIComponent(state.version) : "");
|
|
|
- let versions = [],
|
|
|
|
|
- data = null,
|
|
|
|
|
- missing = false;
|
|
|
|
|
|
|
+ let res;
|
|
|
try {
|
|
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);
|
|
renderExtractHead(versions, data, missing);
|
|
|
const body = $("#xp-body");
|
|
const body = $("#xp-body");
|
|
|
if (missing || !data) {
|
|
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>`;
|
|
<button class="btn primary" onclick="startExtract(['${esc(state.caseId)}'])">开始解构</button></div>`;
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
state.version = data.version;
|
|
state.version = data.version;
|
|
|
|
|
+ // 以「具体 version」补一条缓存别名,版本下拉切回时也能命中
|
|
|
|
|
+ _extractCache.set(_extractKey(state.caseId, data.version), { versions, data, missing });
|
|
|
syncVersionSelect();
|
|
syncVersionSelect();
|
|
|
body.innerHTML = isProc ? renderProcedures(data) : renderTools(data);
|
|
body.innerHTML = isProc ? renderProcedures(data) : renderTools(data);
|
|
|
requestAnimationFrame(markSrcTextClamp);
|
|
requestAnimationFrame(markSrcTextClamp);
|
|
|
if (isProc) requestAnimationFrame(markStepClamps);
|
|
if (isProc) requestAnimationFrame(markStepClamps);
|
|
|
else requestAnimationFrame(markClampedCells);
|
|
else requestAnimationFrame(markClampedCells);
|
|
|
}
|
|
}
|
|
|
- function renderExtractHead(versions, data, missing) {
|
|
|
|
|
|
|
+ function renderExtractHead(versions, data, missing, loading) {
|
|
|
const isProc = state.mode === "process";
|
|
const isProc = state.mode === "process";
|
|
|
const title = state.post ? esc(state.post.title || state.caseId) : esc(state.caseId || "");
|
|
const title = state.post ? esc(state.post.title || state.caseId) : esc(state.caseId || "");
|
|
|
const opts = versions
|
|
const opts = versions
|
|
@@ -2928,8 +3009,9 @@
|
|
|
)
|
|
)
|
|
|
.join("");
|
|
.join("");
|
|
|
const models = (isProc ? MODELS_PROC : MODELS_TOOL).map((m) => `<option>${m}</option>`).join("");
|
|
const models = (isProc ? MODELS_PROC : MODELS_TOOL).map((m) => `<option>${m}</option>`).join("");
|
|
|
|
|
+ const stat = loading ? "加载中…" : missing ? "未提取" : "已提取";
|
|
|
$("#xp-head").innerHTML = `
|
|
$("#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 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>
|
|
<span class="spacer"></span>
|
|
|
${versions.length ? `<select id="ver-sel">${opts}</select>` : ""}
|
|
${versions.length ? `<select id="ver-sel">${opts}</select>` : ""}
|
|
@@ -2959,7 +3041,7 @@
|
|
|
? `<div class="src-thumbs">${imgs
|
|
? `<div class="src-thumbs">${imgs
|
|
|
.map(
|
|
.map(
|
|
|
(s, i) =>
|
|
(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>`
|
|
.join("")}</div>`
|
|
|
: "";
|
|
: "";
|
|
@@ -3226,6 +3308,7 @@
|
|
|
});
|
|
});
|
|
|
showTask(`${isProc ? "工序" : "工具"}解构 · ${caseIds.length} 帖`, r.task_id, async () => {
|
|
showTask(`${isProc ? "工序" : "工具"}解构 · ${caseIds.length} 帖`, r.task_id, async () => {
|
|
|
state.selected.clear();
|
|
state.selected.clear();
|
|
|
|
|
+ caseIds.forEach(invalidateExtractCache); // 重新解构后数据已变,清这些帖的缓存
|
|
|
await selectQuery(state.queryId);
|
|
await selectQuery(state.queryId);
|
|
|
if (caseIds.includes(state.caseId)) {
|
|
if (caseIds.includes(state.caseId)) {
|
|
|
/* selectQuery 清了 caseId,恢复选中 */
|
|
/* selectQuery 清了 caseId,恢复选中 */
|