移动下载端改用共享核心包,与桌面端共用任务业务

- store 改由 createGalleryStore(variant:"mobile") 生成,保留“继续加载”交互
- 与桌面端共用同一份 API 解码、任务规范化、派生与排序实现
- 取消收藏不再因本地找不到 gid 抛 TypeError
- 删除重复的 taskSocket/progressiveImages/validate/thumbnail,改从共享包导入
- 远端搜索改用共享 fetchGalleries,翻页按游标转发
- main.js 按需注册 Element Plus,入口 JS 由约 1.13MB 降到约 486KB
- 依赖新增 file:../lionwebsite-frontend-shared

测试:12 项通过;构建成功,入口仍为 /asserts/mobile/index.js|css。
This commit is contained in:
2026-10-07 16:15:24 +08:00
parent dc39e468bb
commit c9af72dc85
17 changed files with 160 additions and 994 deletions
-33
View File
@@ -1,33 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {startImages, settleImage, retryImage} from '../src/utils/progressiveImages.js'
test('a failed image does not prevent later pages from loading', () => {
const urls = ['first', 'second', 'third']
const items = startImages(urls)
settleImage(items, urls, items[0], true)
assert.equal(items[0].failed, true)
assert.equal(items[1].url, 'second')
settleImage(items, urls, items[1])
assert.equal(items[2].url, 'third')
})
test('retry does not append a duplicate page and bypasses a failed URL cache', () => {
const urls = ['first?gid=1', 'second', 'third']
const items = startImages(urls)
settleImage(items, urls, items[0], true)
retryImage(items[0])
assert.equal(items[0].src, 'first?gid=1&retry=1')
settleImage(items, urls, items[0])
assert.equal(items.length, 2)
assert.equal(items[0].failed, false)
})
test('late image events cannot append content after changing pages', () => {
const old = startImages(['old'])[0]
const urls = ['new', 'next']
const items = startImages(urls)
settleImage(items, urls, old)
assert.equal(items.length, 1)
assert.deepEqual(startImages([]), [])
})
+38
View File
@@ -0,0 +1,38 @@
import test from "node:test"
import assert from "node:assert/strict"
import {buildSearchParams, fetchGalleries, readResponse} from "lionwebsite-frontend-shared"
test("keyword request never hand-builds the query string", () => {
assert.deepEqual(buildSearchParams({keyword: "cat girl"}), {keyword: "cat girl"})
})
test("next page forwards the upstream cursor instead of the keyword", () => {
const params = buildSearchParams({
keyword: "cat girl", direction: "next",
href: "https://exhentai.org/?f_search=cat+girl&next=777",
})
assert.equal(params.next, "777")
assert.equal(params.keyword, "cat girl")
})
test("fetchGalleries decodes object and JSON-string payloads identically", async () => {
const asObject = {get: async () => ({data: {result: "success", data: [{gid: 1}], next: "n1"}})}
const asString = {get: async () => ({data: {result: "success", data: '[{"gid":1}]', next: "n1"}})}
const one = await fetchGalleries(asObject, {keyword: "k"})
const two = await fetchGalleries(asString, {keyword: "k"})
assert.deepEqual(one.galleries, two.galleries)
assert.equal(one.pagination.next, "n1")
})
test("fetchGalleries surfaces the backend message on failure", async () => {
const http = {get: async () => ({data: {result: "failure", data: "没有搜索到结果"}})}
await assert.rejects(() => fetchGalleries(http, {keyword: "k"}),
error => error.payload === "没有搜索到结果")
})
test("readResponse keeps all four pagination cursors", () => {
const parsed = readResponse({data: {result: "success", data: "[]",
first: "f", previous: "p", next: "n", last: "l"}})
assert.deepEqual(parsed.pagination, {first: "f", previous: "p", next: "n", last: "l"})
})
+75 -79
View File
@@ -1,95 +1,91 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import vm from 'node:vm'
import test from "node:test"
import assert from "node:assert/strict"
function fixture() {
let code = fs.readFileSync(new URL('../src/store/index.js', import.meta.url), 'utf8')
.replace(/^import .*$/gm, '')
.replace('export default new vuex.Store(', 'globalThis.store = new vuex.Store(')
const messages = []
const showMessage = message => messages.push(message)
showMessage.error = message => messages.push(message)
const axios = {interceptors: {response: {use() {}}}}
const context = vm.createContext({axios, vuex: {Store: function (config) {Object.assign(this, config)}},
ElMessage: showMessage, URLSearchParams, console,
qs: {stringify: () => ''}})
vm.runInContext(code, context)
const store = context.store
const api = {state: store.state,
commit: (name, data) => store.mutations[name](store.state, data),
dispatch: (name, data) => Promise.resolve(store.actions[name](api, data))}
return {store, messages, axios, api}
// 直接加载真实的移动端 store;在 Node 里 import 到的就是下发到浏览器的那份配置。
const store = (await import("../src/store/index.js")).default
const tasks = () => [
{gid: 1, name: "beta [tag]", status: "下载完成", pages: 10, proceeding: 10,
thumb_link: "", createTime: 2, downloader: 7, isCollect: true},
{gid: 2, name: "alpha", status: "下载中", pages: 10, proceeding: 5,
thumb_link: "", createTime: 1, downloader: 7, isCollect: false},
{gid: 3, name: "gamma", status: "已提交", pages: 10, proceeding: 0,
thumb_link: "", createTime: 3, downloader: 8, isCollect: false},
]
function reset() {
store.state.AuthCode = "test-auth"
store.state.userId = 7
store.state.category = "total"
store.state.sortType = "createTime"
store.state.length = 2
store.state.visiblePages = 1
store.state.isAdmin = false
store.state.downloaderFilter = null
store.commit("_updateGalleryTasks", tasks())
}
test('link search matches a numeric gid and jumps to the right page', () => {
const {store, messages} = fixture()
store.state.currentTasks = [{gid: 1}, {gid: 2}, {gid: 12345, name: 'sample', shortName: 'sample'}]
store.state.length = 2
store.mutations._searchLocalByLink(store.state, 'https://example.org/g/12345/key/')
assert.equal(store.state.page, 2)
assert.match(messages[0], /已跳转/)
test("mobile variant shares one task list with the desktop core", () => {
reset()
assert.equal(store.state.totalGalleryTask.length, 3)
assert.equal(store.getters.downloadGallery.length, 2)
assert.equal(store.getters.collectGallery.length, 1)
})
test('missing gid still reports no match', () => {
const {store, messages} = fixture()
store.state.currentTasks = [{gid: 12345}]
store.mutations._searchLocalByLink(store.state, 'https://example.org/g/999/key/')
assert.equal(messages[0], '未找到此任务')
test("continue-loading reveals every task beyond the first page", () => {
reset()
assert.equal(store.getters.visibleTasks.length, 2)
assert.equal(store.getters.taskRemaining, 1)
store.commit("_showMoreTasks")
assert.equal(store.getters.visibleTasks.length, 3)
assert.equal(store.getters.taskRemaining, 0)
})
test('submission reloads persisted status without duplicating a completed task', async () => {
const {store, axios, api} = fixture()
const gallery = {gid: 123, name: 'sample', status: '下载完成', thumb_link: '', createTime: 1, downloader: 7}
store.state.chosenGallery = {...gallery, status: '已提交'}
store.state.totalGalleryTask.push(gallery)
axios.post = async () => ({data: {result: 'success'}})
axios.get = async url => ({data: {result: 'success', data: JSON.stringify(url.endsWith('weekUsedAmount') ? {} : [gallery])}})
await store.actions.postGalleryTask(api, {targetResolution: 'original'})
assert.equal(store.state.totalGalleryTask.length, 1)
assert.equal(store.state.totalGalleryTask[0].status, '下载完成')
assert.equal(store.state.chosenGallery, false)
test("json-string and object payloads decode to the same store result", () => {
reset()
const fromObjects = store.state.totalGalleryTask.map(task => task.gid)
store.state.totalGalleryTask = []
store.commit("_updateGalleryTasks", JSON.parse(JSON.stringify(tasks())))
assert.deepEqual(store.state.totalGalleryTask.map(task => task.gid), fromObjects)
})
test('gallery management subrequests carry the active auth code', async () => {
const {store, axios, api} = fixture()
store.state.AuthCode = 'test-auth'
let call
axios.post = async (...args) => {
call = args
return {data: {result: args[0].endsWith('/cache') ? 'failure' : 'success', data: 'expected test response'}}
}
await store.actions.reconnect(api)
assert.equal(call[0], 'https://downloader.lionwebsite.xyz/GalleryManage/reconnect')
assert.equal(call[1], null)
assert.equal(call[2].params.AuthCode, 'test-auth')
await store.actions.readOnlineGallery(api, {link: 'https://example.org/g/1/key/', images: []})
assert.equal(call[0], 'https://downloader.lionwebsite.xyz/GalleryManage/cache')
assert.equal(call[1], null)
assert.deepEqual({...call[2].params}, {
url: 'https://example.org/g/1/key/',
AuthCode: 'test-auth'
})
test("cancelling a collect for a task missing from the list never throws", () => {
reset()
// 旧实现会在 collectGallery 里找不到 gid 时抛 TypeError。
assert.doesNotThrow(() => store.commit("_setCollect", {gid: 999, collect: false}))
store.commit("_setCollect", {gid: 1, collect: false})
assert.equal(store.getters.collectGallery.length, 0)
})
test('thumbnail and online image URLs carry the active auth code', () => {
const {store} = fixture()
store.state.AuthCode = 'code with spaces'
store.mutations._updateGalleryTasks(store.state, [{
gid: 123,
name: 'sample',
status: '已提交',
thumb_link: 'https://example.org/thumb?a=1&b=2',
createTime: 1,
downloader: 7
test("sorting never reorders the single source array", () => {
reset()
const before = store.state.totalGalleryTask.map(task => task.gid)
store.commit("_setSortType", "name")
assert.deepEqual(store.state.totalGalleryTask.map(task => task.gid), before)
assert.deepEqual(store.getters.visibleTasks.map(task => task.name), ["alpha", "beta [tag]"])
})
test("link search surfaces the match as a search result on mobile", () => {
const messages = []
reset()
store.commit("_searchLocalByLink", "https://example.org/g/3/key/")
assert.equal(store.state.isSearch, true)
assert.equal(store.getters.searchResults.length, 1)
assert.equal(store.getters.searchResults[0].gid, 3)
})
test("thumbnail and online image urls carry the active auth code", () => {
store.state.AuthCode = "code with spaces"
store.commit("_updateGalleryTasks", [{
gid: 123, name: "sample", status: "已提交",
thumb_link: "https://example.org/thumb?a=1&b=2", createTime: 1, downloader: 7,
}])
const thumbnail = new URL(store.state.totalGalleryTask[0].thumb_link)
assert.equal(thumbnail.searchParams.get('AuthCode'), 'code with spaces')
assert.equal(thumbnail.searchParams.get('path'), 'https://example.org/thumb?a=1&b=2')
assert.equal(thumbnail.searchParams.get("AuthCode"), "code with spaces")
assert.equal(thumbnail.searchParams.get("path"), "https://example.org/thumb?a=1&b=2")
store.mutations._setReadingGallery(store.state, {gid: 123, pages: 1})
store.commit("_setReadingGallery", {gid: 123, pages: 1})
const image = new URL(store.state.readingGallery.images[0])
assert.equal(image.searchParams.get('gid'), '123')
assert.equal(image.searchParams.get('AuthCode'), 'code with spaces')
assert.equal(image.searchParams.get("gid"), "123")
assert.equal(image.searchParams.get("AuthCode"), "code with spaces")
})
-63
View File
@@ -1,63 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {createTaskSocket} from '../src/utils/taskSocket.js'
function fixture() {
const sockets = [], states = [], messages = [], timers = new Map()
let id = 0, opens = 0
const manager = createTaskSocket({url: 'wss://example.invalid',
onOpen: () => opens++, onState: value => states.push(value), onMessage: event => messages.push(event.data),
setTimer: (fn, delay) => { timers.set(++id, {fn, delay}); return id },
clearTimer: key => timers.delete(key),
createSocket: () => {
const socket = {sent: [], send(value) { this.sent.push(value) }, close() { this.closed = true }}
sockets.push(socket)
return socket
}
})
function tick(delay) {
const entry = [...timers].find(([, timer]) => timer.delay === delay)
assert.ok(entry, `expected timer ${delay}`)
timers.delete(entry[0]); entry[1].fn()
}
return {manager, sockets, states, messages, timers, tick, opens: () => opens}
}
test('reconnects and refreshes after each successful connection', () => {
const f = fixture(); f.manager.start()
f.sockets[0].onopen()
assert.deepEqual(f.sockets[0].sent, ['DownloaderWebsocket'])
f.sockets[0].onclose()
f.tick(1000)
f.sockets[1].onopen()
assert.equal(f.opens(), 2)
assert.equal(f.states.at(-1), 'connected')
assert.equal(f.timers.size, 0)
f.manager.stop()
})
test('backs off and times out connection attempts', () => {
const f = fixture(); f.manager.start()
f.tick(10000)
assert.equal(f.sockets[0].closed, true)
f.tick(1000); f.sockets[1].onerror(); f.tick(2000)
f.sockets[2].onerror(); f.tick(4000)
assert.equal(f.sockets.length, 4)
f.manager.stop()
})
test('stop cancels timers and ignores callbacks from replaced sockets', () => {
const f = fixture(); f.manager.start()
const oldOpen = f.sockets[0].onopen, oldMessage = f.sockets[0].onmessage
f.sockets[0].onerror()
f.manager.stop()
assert.equal(f.timers.size, 0)
f.manager.start()
oldOpen(); oldMessage({data: 'stale'})
assert.equal(f.opens(), 0)
assert.deepEqual(f.messages, [])
f.sockets[1].onopen()
f.sockets[1].onmessage({data: 'current'})
assert.deepEqual(f.messages, ['current'])
f.manager.stop()
})
-47
View File
@@ -1,47 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {ehThumbnailUrl, pickDefaultResolution} from '../src/utils/thumbnail.js'
test('remote thumbnail paths are wrapped with the auth code', () => {
const url = ehThumbnailUrl('samples/thumb.png', 'abc123')
assert.match(url, /^https:\/\/downloader\.lionwebsite\.xyz\/GalleryManage\/ehThumbnail\?/)
assert.match(url, /path=samples%2Fthumb\.png/)
assert.match(url, /AuthCode=abc123/)
})
test('a missing thumbnail path produces no url', () => {
assert.equal(ehThumbnailUrl('', 'abc123'), '')
assert.equal(ehThumbnailUrl(undefined, 'abc123'), '')
})
test('the original resolution is preselected when offered', () => {
const chosen = pickDefaultResolution({
'800x': '37.53MiB',
'1280x': '66.57MiB',
'2560x': '84.01MiB',
'Original': '498.2MiB',
})
assert.equal(chosen, 'Original')
})
test('the original label is matched case-insensitively', () => {
assert.equal(pickDefaultResolution({'800x': '1MiB', 'original': '2MiB'}), 'original')
})
test('without an original the largest entry wins across units', () => {
const chosen = pickDefaultResolution({
'800x': '37.53MiB',
'2560x': '84.01MiB',
'2160x': '1.2GiB',
})
assert.equal(chosen, '2160x')
})
test('an empty or absent resolution map selects nothing', () => {
assert.equal(pickDefaultResolution({}), '')
assert.equal(pickDefaultResolution(undefined), '')
})
test('unparseable sizes fall back to the first entry instead of throwing', () => {
assert.equal(pickDefaultResolution({'800x': 'unknown', '1280x': 'unknown'}), '800x')
})