Compare commits

..
4 Commits
Author SHA1 Message Date
root 5defb62bc2 修复 npm 依赖漏洞(npm audit fix,9 项 -> 0 项)
axios / element-plus / follow-redirects / form-data(critical) / lodash /
lodash-es / nanoid / postcss / qs 升级到已修复版本。其中 element-plus
2.4.4 -> 2.14.5、vue 3.4.0 -> 3.5.42 跨度较大,已重新构建并用无头浏览器
实测渲染:页面正常、element-plus 组件样式正常、无 console 错误、无失败请求。
构建产物 965KB -> 1171KB(element-plus 体积增长)。
2026-09-14 14:29:35 +08:00
root d0eb2f05be 修复按链接查找数字画廊ID并补充状态回归测试 2026-09-08 09:35:31 +08:00
root b0f832c37e 断线后自动重连并重新同步任务列表 2026-09-08 09:34:37 +08:00
root 287dc094df 图片失败后继续加载阅读页并支持单图重试 2026-09-08 09:32:09 +08:00
10 changed files with 863 additions and 359 deletions
+531 -308
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -1,4 +1,23 @@
<script setup> <script setup>
import {onMounted, onBeforeUnmount} from "vue";
import store from "./store/index.js";
const resumeProgress = () => {
if (store.state.isAuth && document.visibilityState === 'visible') store.dispatch("initWebsocket");
};
const pauseProgress = () => store.dispatch("disconnectWebsocket");
onMounted(() => {
window.addEventListener('online', resumeProgress);
window.addEventListener('pageshow', resumeProgress);
window.addEventListener('pagehide', pauseProgress);
document.addEventListener('visibilitychange', resumeProgress);
});
onBeforeUnmount(() => {
window.removeEventListener('online', resumeProgress);
window.removeEventListener('pageshow', resumeProgress);
window.removeEventListener('pagehide', pauseProgress);
document.removeEventListener('visibilitychange', resumeProgress);
pauseProgress();
});
import Side from "./components/Side.vue"; import Side from "./components/Side.vue";
import DashBoard from "./components/DashBoard.vue"; import DashBoard from "./components/DashBoard.vue";
</script> </script>
+25 -18
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import {computed, ref, watch} from "vue"; import {computed, ref, watch, onBeforeUnmount} from "vue";
import {startImages, settleImage, retryImage} from "../utils/progressiveImages.js";
import store from "../store/index.js"; import store from "../store/index.js";
let onlineReadingScrollbar = ref() let onlineReadingScrollbar = ref()
let links = ref() let links = ref()
@@ -7,13 +8,12 @@ let index = ref(0) //下标
let page = ref(0) //页数 下标+1=页数 用于跳转 let page = ref(0) //页数 下标+1=页数 用于跳转
let imagesForLoading = ref([]) let imagesForLoading = ref([])
let loadIndex = ref(0)
let isReading = ref(false) let isReading = ref(false)
let max = ref(0) let max = ref(0)
let current_page = 0 let current_page = 0
let lengthPerPage = computed(() => { let lengthPerPage = computed(() => {
return store.state.lengthPerPage return Math.max(1, Number(store.state.lengthPerPage) || 10)
}) })
let readingGallery = computed(() => { let readingGallery = computed(() => {
return store.state.readingGallery return store.state.readingGallery
@@ -36,28 +36,34 @@ function alterPage(){
} }
index.value = 0 index.value = 0
page.value = 1 page.value = 1
loadIndex.value = 0 imagesForLoading.value = startImages(links.value)
imagesForLoading.value.splice(0)
loadImage()
} }
//跳转到对应页数 //跳转到对应页数
function jump(targetIndex){ function jump(targetIndex){
if(targetIndex < 0 || targetIndex >= Math.ceil(readingGallery.value.images.length / lengthPerPage.value)) return
clearPageSwitchTimer()
links.value = readingGallery.value.images.slice(targetIndex * lengthPerPage.value, (targetIndex + 1) * lengthPerPage.value) links.value = readingGallery.value.images.slice(targetIndex * lengthPerPage.value, (targetIndex + 1) * lengthPerPage.value)
index.value = targetIndex index.value = targetIndex
page.value = targetIndex + 1 page.value = targetIndex + 1
imagesForLoading.value.splice(0) imagesForLoading.value = startImages(links.value)
loadIndex.value = 0
loadImage()
onlineReadingScrollbar.value.setScrollTop(0) onlineReadingScrollbar.value.setScrollTop(0)
} }
function loadImage(){ function imageResult(item, failed = false){
if(loadIndex.value < links.value.length) settleImage(imagesForLoading.value, links.value, item, failed)
imagesForLoading.value.push(links.value[loadIndex.value++])
} }
let pageSwitchTimer
function clearPageSwitchTimer(){
clearInterval(pageSwitchTimer)
pageSwitchTimer = undefined
}
onBeforeUnmount(clearPageSwitchTimer)
function closeDialog(){ function closeDialog(){
clearPageSwitchTimer()
imagesForLoading.value = []
onlineReadingScrollbar.value.setScrollTop(0) onlineReadingScrollbar.value.setScrollTop(0)
isReading.value = false isReading.value = false
store.commit("_closeReader") store.commit("_closeReader")
@@ -70,16 +76,16 @@ function switch_page(target_page){
jump(index.value - 1) jump(index.value - 1)
onlineReadingScrollbar.value.setScrollTop(onlineReadingScrollbar.value.wrapRef.scrollHeight) onlineReadingScrollbar.value.setScrollTop(onlineReadingScrollbar.value.wrapRef.scrollHeight)
let top = 0 let top = 0
let timer = setInterval(() => { pageSwitchTimer = setInterval(() => {
if(onlineReadingScrollbar.value.scrollTop === top){ if(onlineReadingScrollbar.value.scrollTop === top){
clearInterval(timer) clearPageSwitchTimer()
document.querySelector("div.el-scrollbar__wrap.el-scrollbar__wrap--hidden-default > div > div:last-child > img").click() document.querySelector("div.el-scrollbar__wrap.el-scrollbar__wrap--hidden-default > div > div:last-child > img").click()
} }
top = onlineReadingScrollbar.value.scrollTop top = onlineReadingScrollbar.value.scrollTop
onlineReadingScrollbar.value.setScrollTop(onlineReadingScrollbar.value.wrapRef.scrollHeight) onlineReadingScrollbar.value.setScrollTop(onlineReadingScrollbar.value.wrapRef.scrollHeight)
}, 100) }, 100)
//下一页 //下一页
}else if(target_page === 0 && current_page === lengthPerPage.value - 1 && index.value < max.value){ }else if(target_page === 0 && current_page === lengthPerPage.value - 1 && index.value < max.value - 1){
jump(index.value + 1) jump(index.value + 1)
current_page = 0 current_page = 0
document.querySelector("div.el-scrollbar__wrap.el-scrollbar__wrap--hidden-default > div > div:nth-child(1) > img").click() document.querySelector("div.el-scrollbar__wrap.el-scrollbar__wrap--hidden-default > div > div:nth-child(1) > img").click()
@@ -105,9 +111,10 @@ function set_current_page(page){
</div> </div>
</template> </template>
<el-scrollbar height="75vh" ref="onlineReadingScrollbar"> <el-scrollbar height="75vh" ref="onlineReadingScrollbar">
<div v-for="(link, i) in imagesForLoading" style="display: inline-block; text-align: center; min-height: 300px"> <div v-for="(item, i) in imagesForLoading" :key="item.url + '-' + item.attempt" style="display: inline-block; text-align: center; min-height: 300px">
<el-image :src="link" :style="{'width': '66vw', 'background-color': 'gary'}" <el-image :src="item.src" :style="{'width': '66vw', 'background-color': 'gary'}"
:preview-src-list="links" :initial-index="i" @switch="switch_page" @show="set_current_page(i)" loading="lazy" @load="loadImage"/><br> :preview-src-list="links" :initial-index="i" @switch="switch_page" @show="set_current_page(i)" loading="lazy" @load="imageResult(item)" @error="imageResult(item, true)"/>
<div v-if="item.failed" role="alert">图片加载失败 <el-button size="small" @click="retryImage(item)">重试</el-button></div><br>
{{lengthPerPage * index + i + 1}} {{lengthPerPage * index + i + 1}}
</div> </div>
</el-scrollbar> </el-scrollbar>
+3
View File
@@ -1,6 +1,9 @@
<template> <template>
<div class="side-table-wrapper"> <div class="side-table-wrapper">
<div v-show="loadComplete" class="load_complete"> <div v-show="loadComplete" class="load_complete">
<el-tag :type="store.state.connectionStatus === 'connected' ? 'success' : 'warning'" role="status">
{{ {connected: '进度已连接', connecting: '正在连接进度', reconnecting: '连接中断,正在重连', disconnected: '进度连接已断开'}[store.state.connectionStatus] }}
</el-tag>
<el-table :data="currentTasks" <el-table :data="currentTasks"
:height="tableHeight" :height="tableHeight"
:empty-text="emptyText" :empty-text="emptyText"
+40 -33
View File
@@ -2,6 +2,9 @@ import vuex from "vuex"
import axios from "axios" import axios from "axios"
import {ElMessage} from "element-plus" import {ElMessage} from "element-plus"
import qs from "qs" import qs from "qs"
import {createTaskSocket} from "../utils/taskSocket.js"
let taskSocket
let taskRefreshVersion = 0
const BaseUrl = "https://downloader.lionwebsite.xyz/" const BaseUrl = "https://downloader.lionwebsite.xyz/"
const GalleryManageUrl = BaseUrl + "GalleryManage" const GalleryManageUrl = BaseUrl + "GalleryManage"
@@ -29,31 +32,33 @@ const actions = {
}) })
}, },
updateGalleryTasks(context){ updateGalleryTasks(context){
const version = ++taskRefreshVersion
return axios.get(GalleryManageUrl, { return axios.get(GalleryManageUrl, {
params:{ params:{
AuthCode: context.state.AuthCode, AuthCode: context.state.AuthCode,
type: 'all' type: 'all'
} }
}).then((res) => { }).then((res) => {
if(res.data.result === "success") if(res.data.result === "success" && version === taskRefreshVersion)
context.commit("_updateGalleryTasks", JSON.parse(res.data.data)) context.commit("_updateGalleryTasks", JSON.parse(res.data.data))
}) })
}, },
postGalleryTask(context, data){ postGalleryTask(context, data){
axios.post(GalleryManageUrl + '?' + qs.stringify({ return axios.post(GalleryManageUrl + '?' + qs.stringify({
AuthCode: context.state.AuthCode, AuthCode: context.state.AuthCode,
...data ...data
}, {indices:false})).then((res) => { }, {indices:false})).then((res) => {
if(res.data.result === "success") { if(res.data.result === "success") {
ElMessage("提交成功") ElMessage("提交成功")
context.commit("_setChosenGallery", {gallery: false, context.commit("_setChosenGallery", {gallery: false})
resolution:data.targetResolution})
} }
else else
if(res.data.data) if(res.data.data)
ElMessage(res.data.data) ElMessage(res.data.data)
else else
ElMessage("提交失败") ElMessage("提交失败")
// A node timeout can still leave a persisted task; always reload its actual state.
return Promise.all([context.dispatch("updateGalleryTasks"), context.dispatch("loadWeekUsedAmount")])
}) })
}, },
queryGalleryTask(context, link){ queryGalleryTask(context, link){
@@ -86,30 +91,40 @@ const actions = {
context.dispatch("updateGalleryTasks").then(() => confirmCurrentTask(context.state)) context.dispatch("updateGalleryTasks").then(() => confirmCurrentTask(context.state))
context.dispatch("initWebsocket").then() context.dispatch("initWebsocket").then()
} }
else else {
context.dispatch("disconnectWebsocket")
context.commit("_unAuthed") context.commit("_unAuthed")
}
}) })
}, },
initWebsocket(context){ initWebsocket(context){
context.state.websocket = new WebSocket("wss://downloader.lionwebsite.xyz/ws/") taskSocket?.stop()
context.state.websocket.onopen = () => { taskSocket = createTaskSocket({
context.state.websocket.send("DownloaderWebsocket") url: "wss://downloader.lionwebsite.xyz/ws/",
} onState: status => context.commit("_setConnectionStatus", status),
onOpen: () => {
context.state.websocket.onmessage = (event) => { // Catch up on every connection, including events missed during an outage.
let message = JSON.parse(event.data) Promise.all([context.dispatch("updateGalleryTasks"), context.dispatch("loadWeekUsedAmount")]).catch(() => {})
},
switch (message.type){ onMessage: event => {
case "updateTasks": let message
try { message = JSON.parse(event.data) }
catch { return }
if(message.type === "updateTasks" && Array.isArray(message.data))
context.commit("_updateGalleryTaskProceeding", message.data) context.commit("_updateGalleryTaskProceeding", message.data)
break else if(message.type === "fullUpdate")
case "fullUpdate": context.dispatch("updateGalleryTasks").catch(() => {})
context.dispatch("updateGalleryTasks").then()
} }
} })
taskSocket.start()
},
disconnectWebsocket(context){
taskSocket?.stop()
taskSocket = undefined
context.commit("_setConnectionStatus", "disconnected")
}, },
loadWeekUsedAmount(context){ loadWeekUsedAmount(context){
axios.get(GalleryManageUrl + "/weekUsedAmount", { return axios.get(GalleryManageUrl + "/weekUsedAmount", {
params: { params: {
AuthCode: context.state.AuthCode AuthCode: context.state.AuthCode
} }
@@ -197,6 +212,9 @@ const actions = {
} }
const mutations = { const mutations = {
_setConnectionStatus(state, status){
state.connectionStatus = status
},
_collectGallery(state, gid){ _collectGallery(state, gid){
let tasks = state.totalGalleryTask let tasks = state.totalGalleryTask
for(let i=0; i< tasks.length; i++){ for(let i=0; i< tasks.length; i++){
@@ -317,7 +335,7 @@ const mutations = {
} }
} }
else for (let i = 0; i < tasks.length; i++) else for (let i = 0; i < tasks.length; i++)
if (tasks[i].gid === gid) { if (String(tasks[i].gid) === gid) {
state.page = Math.floor(i / state.length) + 1 state.page = Math.floor(i / state.length) + 1
name = state.sortType === "shortName" ? tasks[i].shortName: tasks[i].name name = state.sortType === "shortName" ? tasks[i].shortName: tasks[i].name
break break
@@ -354,17 +372,6 @@ const mutations = {
deleteTask(tasks, 'gid', gid) deleteTask(tasks, 'gid', gid)
}, },
_setChosenGallery(state,data){ _setChosenGallery(state,data){
if(data.gallery === false) {
state.chosenGallery.shortName = getShortname(state.chosenGallery.name)
state.chosenGallery.resolution = data.resolution
state.chosenGallery.fileSize = "等待下载完成后再查看"
state.chosenGallery.createTimeDisplay = "等待下载完成后再查看"
state.chosenGallery.progress = "已提交"
state.chosenGallery.downloader = state.userId
state.chosenGallery.thumb_link = GalleryManageUrl + "/ehThumbnail?path=" + state.chosenGallery.thumb_link
state.totalGalleryTask.push(state.chosenGallery)
state.downloadGallery.push(state.chosenGallery)
}
state.chosenGallery = data.gallery state.chosenGallery = data.gallery
}, },
_setCategory(state, category){ _setCategory(state, category){
@@ -403,7 +410,7 @@ const mutations = {
} }
const state = { const state = {
websocket: null, //websocket connectionStatus: "disconnected", // live task updates
totalGalleryTask: [], //存放数据的数组 totalGalleryTask: [], //存放数据的数组
chosenGallery: false, //准备下载 chosenGallery: false, //准备下载
+23
View File
@@ -0,0 +1,23 @@
function imageItem(url, index) {
return {url, src: url, index, attempt: 0, failed: false, settled: false}
}
export function startImages(urls) {
return urls.length ? [imageItem(urls[0], 0)] : []
}
export function settleImage(items, urls, item, failed = false) {
// Ignore events from images removed by a page change or dialog close.
if (!items.includes(item)) return
item.failed = failed
if (item.settled) return
item.settled = true
if (items.length < urls.length)
items.push(imageItem(urls[items.length], items.length))
}
export function retryImage(item) {
item.failed = false
item.attempt++
item.src = item.url + (item.url.includes('?') ? '&' : '?') + 'retry=' + item.attempt
}
+76
View File
@@ -0,0 +1,76 @@
export function createTaskSocket({url, onOpen, onMessage, onState,
createSocket = address => new WebSocket(address),
setTimer = setTimeout, clearTimer = clearTimeout}) {
let socket = null
let retryTimer
let connectTimer
let attempts = 0
let stopped = true
function detach() {
clearTimer(connectTimer)
connectTimer = undefined
if (socket) {
const previous = socket
socket = null
previous.onopen = previous.onclose = previous.onerror = previous.onmessage = null
try { previous.close() } catch { /* already closed */ }
}
}
function schedule() {
if (stopped) return
onState('reconnecting')
const delay = Math.min(1000 * 2 ** attempts, 30000)
attempts = Math.min(attempts + 1, 5)
clearTimer(retryTimer)
retryTimer = setTimer(connect, delay)
}
function connect() {
if (stopped) return
clearTimer(retryTimer)
retryTimer = undefined
onState(attempts ? 'reconnecting' : 'connecting')
let current
try { current = socket = createSocket(url) }
catch { schedule(); return }
const retry = () => {
if (stopped || socket !== current) return
detach()
schedule()
}
current.onopen = () => {
if (stopped || socket !== current) return
clearTimer(connectTimer)
connectTimer = undefined
attempts = 0
try { current.send('DownloaderWebsocket') }
catch { retry(); return }
onState('connected')
onOpen()
}
current.onmessage = event => {
if (!stopped && socket === current) onMessage(event)
}
current.onclose = retry
current.onerror = retry
connectTimer = setTimer(retry, 10000)
}
return {
start() {
if (!stopped) return
stopped = false
attempts = 0
connect()
},
stop() {
stopped = true
clearTimer(retryTimer)
retryTimer = undefined
detach()
onState('disconnected')
}
}
}
+33
View File
@@ -0,0 +1,33 @@
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([]), [])
})
+50
View File
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import vm from 'node:vm'
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 axios = {interceptors: {response: {use() {}}}}
const context = vm.createContext({axios, vuex: {Store: function (config) {Object.assign(this, config)}},
ElMessage: message => messages.push(message), 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}
}
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('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('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)
})
+63
View File
@@ -0,0 +1,63 @@
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()
})