fix: clear withdrawn gallery details and avoid duplicate thumbnail proxy
This commit is contained in:
@@ -19,6 +19,11 @@ watch(chosenGallery, (gallery) => {
|
|||||||
mode.value = "remote";
|
mode.value = "remote";
|
||||||
targetResolution.value = pickDefaultResolution(gallery.availableResolution);
|
targetResolution.value = pickDefaultResolution(gallery.availableResolution);
|
||||||
isOpen.value = true;
|
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";
|
mode.value = "task";
|
||||||
task.value = gallery;
|
task.value = gallery;
|
||||||
isOpen.value = true;
|
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 remoteThumb = computed(() => {
|
||||||
const gallery = chosenGallery.value;
|
const gallery = chosenGallery.value;
|
||||||
if (!gallery) return "";
|
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() {
|
function close() {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user