From de7971fb235fbc58f109bea288b9af5ed240c70f Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 23:50:52 +0800 Subject: [PATCH] fix: avoid applying thumbnail proxy twice in gallery details --- src/components/DetailPanel.vue | 5 +- tests/detailPanel.test.mjs | 175 +++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 tests/detailPanel.test.mjs diff --git a/src/components/DetailPanel.vue b/src/components/DetailPanel.vue index 999fb7c..5ac1900 100644 --- a/src/components/DetailPanel.vue +++ b/src/components/DetailPanel.vue @@ -51,11 +51,12 @@ const resolutions = computed(() => { })); }); -// Remote galleries carry a raw path; the proxy URL needs the active auth code. +// A task coming back from the task list already has its thumbnail proxied by +// normalizeTask. Only a raw upstream path (thumbnailUrl) still needs the proxy. const remoteThumb = computed(() => { const gallery = remote.value; if (!gallery) return ""; - return ehThumbnailUrl(gallery.thumb_link || gallery.thumbnailUrl, store.state.AuthCode); + return gallery.thumb_link || ehThumbnailUrl(gallery.thumbnailUrl, store.state.AuthCode); }); function closeInspector() { diff --git a/tests/detailPanel.test.mjs b/tests/detailPanel.test.mjs new file mode 100644 index 0000000..c9affa4 --- /dev/null +++ b/tests/detailPanel.test.mjs @@ -0,0 +1,175 @@ +import test from "node:test" +import assert from "node:assert/strict" +import fs from "node:fs" +import {registerHooks} from "node:module" +import {compileScript, parse} from "@vue/compiler-sfc" +import {createRenderer, nextTick} from "vue" +import {ehThumbnailUrl} from "lionwebsite-frontend-shared" + +// 桌面详情面板的缩略图规则必须与共享 store 的规范化保持一致: +// normalizeTask 已经代理过 thumb_link,组件再用 ehThumbnailUrl 套一层就会把 +// 代理地址当成原始 path 再代理一次。这里直接执行仓库里的 .vue 源码来盯住它。 +registerHooks({ + load(url, context, nextLoad) { + if (!url.endsWith(".vue")) return nextLoad(url, context) + const source = fs.readFileSync(new URL(url), "utf8") + const {descriptor, errors} = parse(source, {filename: url}) + assert.deepEqual(errors, [], "SFC 解析必须无错") + return { + format: "module", + shortCircuit: true, + source: compileScript(descriptor, {id: "panel-test", inlineTemplate: true, isProd: false}).content, + } + }, +}) + +const {default: DetailPanel} = await import("../src/components/DetailPanel.vue") +const store = (await import("../src/store/index.js")).default + +// 最小渲染器:能插入节点、读回属性、处理 v-show 的 style 即可。 +function fakeElement(tag) { + return {nodeType: 1, tag, children: [], parentNode: null, style: {}, attrs: {}, listeners: {}} +} + +function nodeOps() { + const remove = node => { + const parent = node.parentNode + if (!parent) return + const index = parent.children.indexOf(node) + if (index >= 0) parent.children.splice(index, 1) + node.parentNode = null + } + const insert = (node, parent, anchor = null) => { + if (node.parentNode) remove(node) + const index = anchor ? parent.children.indexOf(anchor) : -1 + if (index >= 0) parent.children.splice(index, 0, node) + else parent.children.push(node) + node.parentNode = parent + } + return { + insert, + remove, + createElement: fakeElement, + createText: text => ({nodeType: 3, text, parentNode: null}), + createComment: text => ({nodeType: 8, text, parentNode: null}), + setText: (node, text) => { node.text = text }, + setElementText: (node, text) => { node.children = [{nodeType: 3, text, parentNode: node}] }, + parentNode: node => node.parentNode, + nextSibling: node => { + const parent = node.parentNode + if (!parent) return null + const index = parent.children.indexOf(node) + return parent.children[index + 1] || null + }, + querySelector: () => null, + setScopeId: (el, id) => { el.scopeId = id }, + cloneNode: el => ({...fakeElement(el.tag), attrs: {...el.attrs}}), + insertStaticContent: (content, parent, anchor) => { + const node = fakeElement("static") + insert(node, parent, anchor) + return [node, node] + }, + patchProp: (el, key, previous, next) => { + if (key === "style") el.style = next || {} + else if (/^on[A-Z]/.test(key)) el.listeners[key] = next + else el.attrs[key] = next + }, + } +} + +function mountPanel() { + const app = createRenderer(nodeOps()).createApp(DetailPanel) + // 真实入口在 main.js 里全局注册 Element Plus;这里只需要占位节点。 + app.component("el-button", {render: () => null}) + app.component("el-progress", {render: () => null}) + const root = fakeElement("root") + app.mount(root) + return root +} + +function descendants(node, out = []) { + for (const child of node.children || []) { + if (child.nodeType === 1) { + out.push(child) + descendants(child, out) + } + } + return out +} + +function textOf(root) { + const parts = [] + const walk = node => { + if (node.nodeType === 3) { parts.push(node.text); return } + for (const child of node.children || []) walk(child) + } + walk(root) + return parts.join(" ") +} + +const previewImage = root => descendants(root).find(node => node.tag === "img") || null +const remoteGallery = () => ({ + gid: 9, name: "远端画廊", link: "https://example.org/g/9/k/", thumbnailUrl: "/t/9.jpg", +}) + +function reset() { + store.state.AuthCode = "auth" + store.state.userId = 7 + store.state.selectedGallery = null + store.state.chosenGallery = false +} + +test("已规范化的 thumb_link 直接作为 src,外层 path 仍是原始图片路径", async () => { + reset() + const root = mountPanel() + const proxied = ehThumbnailUrl("/t/9.jpg", "auth") + store.state.chosenGallery = { + ...remoteGallery(), thumb_link: proxied, thumbnailUrl: "https://example.org/raw.jpg", + } + await nextTick() + const image = previewImage(root) + assert.ok(image, "远端面板必须渲染缩略图") + assert.equal(image.attrs.src, proxied) + assert.equal(image.attrs.src.split("ehThumbnail").length - 1, 1) + assert.equal(new URL(image.attrs.src).searchParams.get("path"), "/t/9.jpg") +}) + +test("只有原始上游地址时才在这里代理一次", async () => { + reset() + const root = mountPanel() + store.state.chosenGallery = {...remoteGallery(), thumbnailUrl: "https://example.org/raw.jpg"} + await nextTick() + const image = previewImage(root) + assert.ok(image) + assert.equal(image.attrs.src, ehThumbnailUrl("https://example.org/raw.jpg", "auth")) + assert.equal(new URL(image.attrs.src).searchParams.get("path"), "https://example.org/raw.jpg") +}) + +test("清空 chosenGallery 后远端面板收起,不会残留上一张缩略图", async () => { + reset() + const root = mountPanel() + store.state.chosenGallery = {...remoteGallery(), thumb_link: ehThumbnailUrl("/t/9.jpg", "auth")} + await nextTick() + assert.ok(previewImage(root), "先确认远端面板已打开") + + store.state.chosenGallery = false + await nextTick() + assert.equal(previewImage(root), null) + assert.match(textOf(root), /选择一条任务查看详情/) +}) + +test("列表任务已带代理缩略图,详情面板不再重复代理", async () => { + reset() + const root = mountPanel() + store.commit("_updateGalleryTasks", [{ + gid: 3, name: "任务", status: "已提交", pages: 1, proceeding: 0, + thumb_link: "/t/3.jpg", createTime: 1, downloader: 7, + }]) + store.commit("_selectGallery", store.state.totalGalleryTask[0]) + await nextTick() + const image = previewImage(root) + assert.ok(image) + assert.equal(new URL(image.attrs.src).searchParams.get("path"), "/t/3.jpg") + assert.equal(image.attrs.src.split("ehThumbnail").length - 1, 1) +}) +