From dfee73b3f94d77f8efa3367684cb80b121149d6d Mon Sep 17 00:00:00 2001 From: chuzhongzai Date: Wed, 7 Oct 2026 23:50:52 +0800 Subject: [PATCH] fix: clear withdrawn gallery details and avoid duplicate thumbnail proxy --- src/components/TaskDetailSheet.vue | 15 ++- tests/taskDetailSheet.test.mjs | 207 +++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 tests/taskDetailSheet.test.mjs diff --git a/src/components/TaskDetailSheet.vue b/src/components/TaskDetailSheet.vue index 7f4e6a2..30b1fce 100644 --- a/src/components/TaskDetailSheet.vue +++ b/src/components/TaskDetailSheet.vue @@ -19,6 +19,11 @@ watch(chosenGallery, (gallery) => { mode.value = "remote"; targetResolution.value = pickDefaultResolution(gallery.availableResolution); isOpen.value = true; + } else if (mode.value === "remote") { + // The remote decision was withdrawn elsewhere: close instead of leaving a + // stale task/remote mix behind the same sheet. + mode.value = "task"; + isOpen.value = false; } }); @@ -27,6 +32,11 @@ watch(detailGallery, (gallery) => { mode.value = "task"; task.value = gallery; isOpen.value = true; + } else if (mode.value === "task") { + // Only the task flow closes on an empty detail; clearing the detail while a + // remote gallery is on screen must not tear the remote sheet down. + task.value = null; + isOpen.value = false; } }); @@ -44,11 +54,12 @@ const resolutions = computed(() => { })); }); -// A resolved gallery still carries a raw path, so the proxy URL is built here. +// A gallery resolved through queryGalleryTask already has its thumb_link proxied +// by normalizeTask; only a raw upstream path (thumbnailUrl) needs the proxy here. const remoteThumb = computed(() => { const gallery = chosenGallery.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 close() { diff --git a/tests/taskDetailSheet.test.mjs b/tests/taskDetailSheet.test.mjs new file mode 100644 index 0000000..0c430f3 --- /dev/null +++ b/tests/taskDetailSheet.test.mjs @@ -0,0 +1,207 @@ +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 单测覆盖不到。 +// 这里用编译期 loader hook 直接执行仓库中的 .vue 源码,再挂到最小渲染器上; +// 开关与模式都落在普通 DOM 属性上,因此不需要 jsdom/happy-dom。 +registerHooks({ + load(url, context, nextLoad) { + if (!url.endsWith("/TaskDetailSheet.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: "sheet-test", inlineTemplate: true, isProd: false}).content, + } + }, +}) + +const {default: TaskDetailSheet} = await import("../src/components/TaskDetailSheet.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 mountSheet() { + const app = createRenderer(nodeOps()).createApp(TaskDetailSheet) + // 真实入口在 main.js 里全局注册 Element Plus;这里只需要占位节点。 + 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 findByClass(root, className) { + return descendants(root).find(node => node.attrs.class === className) || null +} + +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(" ") +} + +function isOpen(root) { + const layer = findByClass(root, "sheet-layer") + assert.ok(layer, "抽屉根节点必须存在") + // v-show 只在关闭时写 display: none。 + return layer.style.display !== "none" +} + +const taskGallery = () => ({ + gid: 5, name: "本地任务", status: "已提交", pages: 10, proceeding: 0, + thumb_link: "", createTime: 1, downloader: 7, +}) +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.detailGallery = null + store.state.chosenGallery = false +} + +test("清空 detailGallery 会关上任务态抽屉", async () => { + reset() + const root = mountSheet() + store.state.detailGallery = taskGallery() + await nextTick() + assert.equal(isOpen(root), true) + assert.match(textOf(root), /本地任务/) + + store.state.detailGallery = null + await nextTick() + assert.equal(isOpen(root), false) +}) + +test("清空 chosenGallery 会关上远端态抽屉并复位模式", async () => { + reset() + const root = mountSheet() + store.state.chosenGallery = remoteGallery() + await nextTick() + assert.equal(isOpen(root), true) + assert.match(textOf(root), /远端画廊/) + + store.state.chosenGallery = false + await nextTick() + assert.equal(isOpen(root), false) + + // 模式必须回到 task,否则下一次打开任务会渲染成远端面板。 + store.state.detailGallery = taskGallery() + await nextTick() + assert.equal(isOpen(root), true) + assert.match(textOf(root), /删除任务/) +}) + +test("远端态下 detailGallery 变空不得误关抽屉", async () => { + reset() + const root = mountSheet() + store.state.chosenGallery = remoteGallery() + await nextTick() + assert.equal(isOpen(root), true) + + // 任务流的 detail 被清空(例如列表刷新),此时屏幕上的是远端画廊,必须保持打开。 + store.state.detailGallery = null + await nextTick() + assert.equal(isOpen(root), true) + assert.match(textOf(root), /远端画廊/) +}) + +test("已规范化的 thumb_link 直接使用,不再二次代理", async () => { + reset() + const root = mountSheet() + const proxied = ehThumbnailUrl("/t/9.jpg", "auth") + store.state.chosenGallery = { + ...remoteGallery(), thumb_link: proxied, thumbnailUrl: "https://example.org/raw.jpg", + } + await nextTick() + const image = descendants(root).find(node => node.tag === "img") + assert.ok(image, "远端态必须渲染缩略图") + assert.equal(image.attrs.src, proxied) + assert.equal(image.attrs.src.split("ehThumbnail").length - 1, 1) + // 外层 path 仍是原始图片路径,没有被嵌套成代理 URL。 + assert.equal(new URL(image.attrs.src).searchParams.get("path"), "/t/9.jpg") +}) + +test("只有原始上游地址时才在这里代理一次", async () => { + reset() + const root = mountSheet() + store.state.chosenGallery = {...remoteGallery(), thumbnailUrl: "https://example.org/raw.jpg"} + await nextTick() + const image = descendants(root).find(node => node.tag === "img") + 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") +}) +