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

- 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
+8
View File
@@ -10,6 +10,7 @@
"dependencies": {
"axios": "^1.0.0",
"element-plus": "^2.2.15",
"lionwebsite-frontend-shared": "file:../lionwebsite-frontend-shared",
"qs": "^6.11.0",
"vue": "^3.2.37",
"vuex": "^4.0.2"
@@ -19,6 +20,9 @@
"vite": "8.3.0"
}
},
"../lionwebsite-frontend-shared": {
"version": "1.0.0"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -1281,6 +1285,10 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lionwebsite-frontend-shared": {
"resolved": "../lionwebsite-frontend-shared",
"link": true
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+2 -1
View File
@@ -14,7 +14,8 @@
"element-plus": "^2.2.15",
"qs": "^6.11.0",
"vue": "^3.2.37",
"vuex": "^4.0.2"
"vuex": "^4.0.2",
"lionwebsite-frontend-shared": "file:../lionwebsite-frontend-shared"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.9",
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup>
import {computed, ref, watch, onBeforeUnmount} from "vue";
import {startImages, settleImage, retryImage} from "../utils/progressiveImages.js";
import {startImages, settleImage, retryImage} from "lionwebsite-frontend-shared";
import store from "../store/index.js";
let onlineReadingScrollbar = ref()
let links = ref()
+16 -28
View File
@@ -4,8 +4,8 @@ import axios from "axios";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import TaskRow from "./TaskRow.vue";
import {validateLink} from "../utils/validate.js";
import {ehThumbnailUrl} from "../utils/thumbnail.js";
import {fetchGalleries, validateLink} from "lionwebsite-frontend-shared";
import {ehThumbnailUrl} from "lionwebsite-frontend-shared";
const mode = ref("local");
const localMode = ref("keyword");
@@ -56,32 +56,20 @@ async function retryGallery(gallery) {
}
}
function queryGalleries(link) {
let keyword
if (link != null) {
keyword = new URL(link).search.replace("?f_search=", "")
} else {
keyword = remoteKeyword.value
if (keyword.trim() === "") {
ElMessage("请输入搜索关键词")
return
}
// direction 为 "next"/"prev" 时使用后端返回的翻页游标;href 为空表示新关键词。
function queryGalleries(href, direction = null) {
if (href == null && remoteKeyword.value.trim() === "") {
ElMessage("请输入搜索关键词")
return
}
remoteLoading.value = true
axios.get("https://downloader.lionwebsite.xyz/query?keyword=" + keyword.replace(" ", "+"))
.then((res) => {
if (res.data.result === "success") {
remotePage.value = {
first: res.data.first,
previous: res.data.previous,
next: res.data.next,
last: res.data.last,
}
remoteGalleries.value.splice(0)
JSON.parse(res.data.data).forEach(gallery => remoteGalleries.value.push(gallery))
} else {
ElMessage({message: res.data.data, type: "error"})
}
fetchGalleries(axios, {keyword: remoteKeyword.value, direction, href: href || ""})
.then(({galleries, pagination}) => {
remotePage.value = pagination
remoteGalleries.value.splice(0, remoteGalleries.value.length, ...galleries)
})
.catch(error => {
ElMessage({message: error.payload || error.message || "搜索失败", type: "error"})
})
.finally(() => {
remoteLoading.value = false
@@ -186,8 +174,8 @@ function parseLink() {
<div class="pager-row" v-if="remoteGalleries.length">
<button type="button" :disabled="remotePage.first === undefined" @click="queryGalleries(remotePage.first)">首页</button>
<button type="button" :disabled="remotePage.previous === undefined" @click="queryGalleries(remotePage.previous)">上一页</button>
<button type="button" :disabled="remotePage.next === undefined" @click="queryGalleries(remotePage.next)">下一页</button>
<button type="button" :disabled="remotePage.previous === undefined" @click="queryGalleries(remotePage.previous, 'prev')">上一页</button>
<button type="button" :disabled="remotePage.next === undefined" @click="queryGalleries(remotePage.next, 'next')">下一页</button>
<button type="button" :disabled="remotePage.last === undefined" @click="queryGalleries(remotePage.last)">尾页</button>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
import {computed, ref, watch} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import {ehThumbnailUrl, pickDefaultResolution} from "../utils/thumbnail.js";
import {ehThumbnailUrl, pickDefaultResolution} from "lionwebsite-frontend-shared";
// Two flows share this sheet: a task already in the list, and a remote gallery
// that was resolved from a link or from the online search.
+1 -1
View File
@@ -39,7 +39,7 @@ const downloaderFilter = computed({
// downloader that would filter the visible list down to nothing.
const downloaderOptions = computed(() => {
const seen = new Map();
;(store.state.currentTasks || []).forEach(task => {
;(store.getters.categoryGallery || []).forEach(task => {
if (task.downloaderName && !seen.has(task.downloader))
seen.set(task.downloader, task.downloaderName);
});
+10 -2
View File
@@ -1,9 +1,17 @@
import { createApp } from 'vue'
import App from './App.vue'
import {
ElButton, ElCheckbox, ElDialog, ElForm, ElFormItem, ElImage, ElInput,
ElInputNumber, ElOption, ElProgress, ElScrollbar, ElSelect, ElSwitch,
} from "element-plus"
import 'element-plus/dist/index.css'
import element from "element-plus"
import "./reset.css"
import 'element-plus/theme-chalk/dark/css-vars.css'
import "./styles/theme.css"
createApp(App).use(element).mount('#app')
// 只注册本仓库用到的组件,避免把整个 Element Plus 打进入口包。
const app = createApp(App)
for (const component of [ElButton, ElCheckbox, ElDialog, ElForm, ElFormItem, ElImage,
ElInput, ElInputNumber, ElOption, ElProgress, ElScrollbar, ElSelect, ElSwitch])
app.component(component.name, component)
app.mount('#app')
+8 -604
View File
@@ -2,11 +2,7 @@ import vuex from "vuex"
import axios from "axios"
import {ElMessage} from "element-plus"
import qs from "qs"
import {createTaskSocket} from "../utils/taskSocket.js"
let taskSocket
let taskRefreshVersion = 0
const BaseUrl = "https://downloader.lionwebsite.xyz/"
const GalleryManageUrl = BaseUrl + "GalleryManage"
import {createGalleryStore} from "lionwebsite-frontend-shared"
// Axios 全局错误处理
axios.interceptors.response.use(
@@ -17,602 +13,10 @@ axios.interceptors.response.use(
}
)
const actions = {
reconnect(context) {
axios.post(GalleryManageUrl + "/reconnect", null, {
params: {
AuthCode: context.state.AuthCode
}
}).then(res => {
if (res.data.result === "success") {
ElMessage("重连成功")
} else {
ElMessage("重连失败")
}
})
},
updateGalleryTasks(context){
const version = ++taskRefreshVersion
return axios.get(GalleryManageUrl, {
params:{
AuthCode: context.state.AuthCode,
type: 'all'
}
}).then((res) => {
if(res.data.result === "success" && version === taskRefreshVersion)
context.commit("_updateGalleryTasks", JSON.parse(res.data.data))
})
},
postGalleryTask(context, data){
return axios.post(GalleryManageUrl + '?' + qs.stringify({
AuthCode: context.state.AuthCode,
...data
}, {indices:false})).then((res) => {
if(res.data.result === "success") {
ElMessage("提交成功")
context.commit("_setChosenGallery", {gallery: false})
}
else
if(res.data.data)
ElMessage(res.data.data)
else
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){
axios.get(GalleryManageUrl, {
params:{
param: link,
type: 'link',
AuthCode: context.state.AuthCode
}
}).then((res) => {
if(res.data.result === 'success'){
let gallery = JSON.parse(res.data.data)
if(gallery.status === "下载完成")
gallery.download = buildGalleryDownloadUrl(gallery.gid, context.state.AuthCode)
context.commit("_setChosenGallery", {gallery})
}
else
ElMessage("查询失败")
})
},
validate(context, AuthCode){
axios.post(BaseUrl + "validate?AuthCode=" + AuthCode).then((res)=>{
if(res.data.result === 'success'){
let data = JSON.parse(res.data.data);
if(!data.isAvailable)
ElMessage({duration:0, message:"节点挂了,不能下也不能看,找狮子处理", type: "error"})
context.commit("_authed", {AuthCode, ...data})
//初始化
context.dispatch("loadWeekUsedAmount").then()
context.dispatch("updateGalleryTasks").then(() => confirmCurrentTask(context.state))
context.dispatch("initWebsocket").then()
}
else {
context.dispatch("disconnectWebsocket")
context.commit("_unAuthed")
}
})
},
initWebsocket(context){
taskSocket?.stop()
taskSocket = createTaskSocket({
url: "wss://downloader.lionwebsite.xyz/ws/",
onState: status => context.commit("_setConnectionStatus", status),
onOpen: () => {
// Catch up on every connection, including events missed during an outage.
Promise.all([context.dispatch("updateGalleryTasks"), context.dispatch("loadWeekUsedAmount")]).catch(() => {})
},
onMessage: event => {
let message
try { message = JSON.parse(event.data) }
catch { return }
if(message.type === "updateTasks" && Array.isArray(message.data))
context.commit("_updateGalleryTaskProceeding", message.data)
else if(message.type === "fullUpdate")
context.dispatch("updateGalleryTasks").catch(() => {})
}
})
taskSocket.start()
},
disconnectWebsocket(context){
taskSocket?.stop()
taskSocket = undefined
context.commit("_setConnectionStatus", "disconnected")
},
loadWeekUsedAmount(context){
return axios.get(GalleryManageUrl + "/weekUsedAmount", {
params: {
AuthCode: context.state.AuthCode
}
}).then((res) => {
if(res.data.result === "success"){
context.state.weekUsed = JSON.parse(res.data.data)
}else
ElMessage("查询用量失败")
})
},
collectGallery(context, gid){
axios.post(GalleryManageUrl + "/collect?" +qs.stringify( {
gid, AuthCode:context.state.AuthCode
})).then((res) => {
ElMessage(res.data.data)
if(res.data.result === 'success')
context.commit("_collectGallery", gid)
})
},
disCollectGallery(context, gid){
axios.post(GalleryManageUrl + "/disCollect?" + qs.stringify({
gid, AuthCode:context.state.AuthCode
})).then((res) => {
ElMessage(res.data.data)
if(res.data.result === 'success')
context.commit("_disCollectGallery", gid)
})
},
deleteGallery(context, gid){
return axios.delete(GalleryManageUrl, {
params:{
AuthCode:context.state.AuthCode, gid
}}).then((res) => {
if(res.data.result === "success"){
ElMessage("删除成功")
context.commit("_deleteGallery", gid)
}
else
ElMessage(res.data.data)
})
},
retryGallery(context, gid){
return axios.post(GalleryManageUrl + "/retry?" + qs.stringify({
gid, AuthCode: context.state.AuthCode
})).then((res) => {
if(res.data.result === "success"){
ElMessage({message: "重试结果:" + res.data.data, type: "success"})
return context.dispatch("updateGalleryTasks")
}
ElMessage({message: res.data.data || "重试失败", type: "error"})
})
},
readOnlineGallery(context, gallery){
if(gallery.images !== undefined && gallery.images.length !== 0)
context.commit("_setReadingGallery", gallery)
else
return axios.post(GalleryManageUrl + "/cache", null, {
params: {url: gallery.link, AuthCode: context.state.AuthCode}
}).then((res) => {
if (res.data.result === 'success') {
gallery.pages = res.data.data.pages
setTimeout(() => {
context.commit("_setReadingGallery", gallery)
}, 100)
} else
ElMessage.error(res.data.data)
})
},
alterAuthCode(context, AuthCode){
axios.put(BaseUrl + "AuthCode?" + qs.stringify({'AuthCode': context.state.AuthCode, 'newAuthCode': AuthCode}))
.then((res) => {
if(res.data.result === 'success') {
ElMessage("修改成功")
if(localStorage.getItem("auth") === context.state.AuthCode)
localStorage.setItem("auth", AuthCode)
context.state.AuthCode = AuthCode
}
else
ElMessage(res.data.data)
})
},
resetUndone(context){
axios.post(GalleryManageUrl + "/reset?AuthCode=" + context.state.AuthCode).then((res) => {
ElMessage(res.data.data)
})
}
}
const mutations = {
_setConnectionStatus(state, status){
state.connectionStatus = status
},
_collectGallery(state, gid){
let tasks = state.totalGalleryTask
for(let i=0; i< tasks.length; i++){
if(!tasks[i].isCollect && tasks[i].gid === gid){
tasks[i].isCollect = true
state.collectGallery.push(tasks[i])
}
}
},
_disCollectGallery(state, gid){
let index
for(let i=0; i < state.collectGallery.length; i++)
if(state.collectGallery[i].gid === gid){
index = i
break
}
state.collectGallery[index].isCollect = false
state.collectGallery.splice(index, 1)
},
_updateGalleryTasks(state, tasks){
state.totalGalleryTask.splice(0)
state.collectGallery.splice(0)
state.downloadGallery.splice(0)
tasks.forEach((task) => {
//处理名字
task.shortName = getShortname(task.name)
if(task.thumb_link.trim() !== '')
task.thumb_link = buildGalleryManageUrl("/ehThumbnail", {
path: task.thumb_link,
AuthCode: state.AuthCode
})
else
delete task.thumb_link
//处理进度相关
switch (task.status) {
case "已提交":
case "等待压缩":
case "压缩中":
task.progress = task.status
break;
case "下载中":
task.progress = (Math.round((task.proceeding / task.pages) * 100)).toString() + "%"
break;
case "下载完成":
task.progress = task.status
task.download = buildGalleryDownloadUrl(task.gid, state.AuthCode)
break;
}
//处理时间戳
task.createTimeDisplay = new Date(task.createTime * 1000).toLocaleString("zh")
//处理是否收藏
if('isCollect' in task)
state.collectGallery.push(task)
else
task.isCollect = false
//处理是否下载
if(task.downloader === state.userId)
state.downloadGallery.push(task)
state.totalGalleryTask.push(task)
})
sortTasks(state)
if(state.isAuth && !state.loadComplete){
state.loadComplete = true
ElMessage("加载完成")
}
},
_updateGalleryTaskProceeding(state, tasks){
let galleries = Array.from(state.totalGalleryTask)
state.totalGalleryTask.splice(0)
galleries.forEach((gallery) => {
if(gallery.status !== '下载完成')
tasks.forEach((task) => {
if(task.gid === gallery.gid){
gallery.status = status[task.status]
gallery.proceeding = task.proceeding
if(gallery.status === '下载中')
gallery.progress = (Math.round((gallery.proceeding / gallery.pages) * 100)).toString() + "%"
else
gallery.progress = gallery.status
}
})
state.totalGalleryTask.push(gallery)
})
},
_changePage(state, targetPage){
state.page = targetPage
},
_setActiveTab(state, tab){
state.activeTab = tab
},
_setDetailGallery(state, gallery){
state.detailGallery = gallery
},
// The mobile list grows in place instead of paging through a footer control.
_showMoreTasks(state){
state.visiblePages += 1
},
_authed(state, data){
state.AuthCode = data.AuthCode
state.userId = data.userId
state.username = data.username
state.isAdmin = Boolean(data.isAdmin)
state.downloaderFilter = null
state.isAuth = true
ElMessage("验证成功,加载中")
},
_unAuthed(state){
state.isAuth = false
state.AuthCode = ""
state.userId = -1
state.username = ""
ElMessage("授权码错误")
localStorage.removeItem("auth")
},
_searchLocalByLink(state, link){
let tasks = state.currentTasks
let gid = link.split("/")[4]
let matched = null
let name = null
if(gid === undefined)
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].link === link) {
state.page = Math.floor(i / state.length) + 1
matched = tasks[i]
name = tasks[i].name
break
}
}
else for (let i = 0; i < tasks.length; i++)
if (String(tasks[i].gid) === gid) {
state.page = Math.floor(i / state.length) + 1
matched = tasks[i]
name = state.sortType === "shortName" ? tasks[i].shortName: tasks[i].name
break
}
if(!name)
ElMessage("未找到此任务")
else {
state.searchTask.splice(0)
if(matched)
state.searchTask.push(matched)
state.isSearch = true
state.visiblePages = 1
ElMessage("已跳转到该任务所在页数,任务名:" + name)
}
},
_searchLocalByKeyword(state, keyword){
state.searchTask.splice(0)
if(keyword.trim() !== '') {
state.page = 1
state.visiblePages = 1
let tasks = state.currentTasks
tasks.forEach((task) => {
if (task.name.includes(keyword))
state.searchTask.push(task)
})
if (state.searchTask.length === 0) {
ElMessage("未找到该关键字的任务")
state.isSearch = false
}
else {
state.isSearch = true
}
}else {
state.isSearch = false
confirmCurrentTask(state)
}
},
_deleteGallery(state, gid){
let tasks = [state.totalGalleryTask, state.downloadGallery, state.collectGallery]
deleteTask(tasks, 'gid', gid)
},
_setChosenGallery(state,data){
state.chosenGallery = data.gallery
},
_setCategory(state, category){
state.category = category
state.visiblePages = 1
// 换分类后旧的下载人筛选可能在新分类里根本不存在,先清掉。
state.downloaderFilter = null
confirmCurrentTask(state)
sortTasks(state)
},
_setSortType(state, sortType){
state.sortType = sortType
state.visiblePages = 1
sortTasks(state)
},
_setGalleryNameType(state, galleryNameType){
state.galleryNameType = galleryNameType
},
_setDownloaderFilter(state, downloader){
state.downloaderFilter = downloader === null || downloader === undefined
? null
: Number(downloader)
state.visiblePages = 1
},
_setShowNameType(state, type){
if(type === "shortName")
state.length = state.shortLength
else
state.length = state.defaultLength
},
_setReadingGallery(state, gallery){
if(gallery.images === undefined) {
gallery.images = []
for(let i=1; i<=gallery.pages; i++)
gallery.images.push(buildGalleryManageUrl("/onlineImage/" + i, {
gid: gallery.gid,
AuthCode: state.AuthCode
}));
}
state.readingGallery = gallery
state.isReading = true;
},
_closeReader(state){
state.isReading = false;
},
_openHistoryPanel(state){
state.isShowHistory = true
},
_closeHistoryPanel(state){
state.isShowHistory = false
}
}
const state = {
connectionStatus: "disconnected", // live task updates
websocket: null, //websocket
totalGalleryTask: [], //存放数据的数组
chosenGallery: false, //准备下载
collectGallery: [], //收藏
downloadGallery: [], //下载
isSearch: false, //用于决定是否显示搜索结果
readingGallery: {'name': '', 'images': []}, //在线看
isReading: false, //是否正在看
currentGid: "", //当前GID
lengthPerPage: 0, //在线预览每页图片数量
page: 1, //当前页数
length: 5, //每页能有多少个链接
defaultLength: 4, //默认个数
shortLength: 5, //简洁个数
userId: -1, //用户id
username: "", //用户名
isAuth: false, //是否授权
AuthCode: '', //授权码
loadComplete: false, //是否加载完成
galleryRefreshTimer: 0, //更新计时器id
isInclude: false, //是否搜索到任务
searchTask: [], //搜索到的任务
isShowHistory: false, //是否打开面板
activeTab: 'tasks', //移动端底部标签 tasks search settings
detailGallery: null, //详情抽屉里正在看的任务
visiblePages: 1, //列表已展开的页数,用于“继续加载”
galleryNameType: 'shortName', //名字类型 shortName name
category: 'myDownload', //分类 myDownload myCollect total
sortType:'shortName', //排序类型 shortName name createTime
currentTasks: [], //当前任务
weekUsed: {}, //每周用量
isAdmin: false, //管理员(后端 validate 下发)
downloaderFilter: null, //管理员按下载人筛选,null 表示全部
}
const getters = {
currentTasks(state){
const tasks = activeTaskList(state)
return tasks.slice((state.page - 1) * state.length, state.page * state.length)
},
min(){
return 1
},
visibleTasks(state){
return activeTaskList(state).slice(0, state.visiblePages * state.length)
},
taskTotal(state){
return activeTaskList(state).length
},
taskRemaining(state, getters){
return Math.max(0, getters.taskTotal - state.visiblePages * state.length)
},
searchResults(state){
return (state.searchTask || []).slice(0, state.visiblePages * state.length)
},
max(state){
let max = 0
const tasks = activeTaskList(state)
if(!tasks)
return 1
max = Math.floor(tasks.length/state.length)
if(tasks.length % state.length !== 0 || max === 0)
max += 1
return max
}
}
export default new vuex.Store({
actions,
mutations,
state,
getters
})
/**
* 当前视图实际要展示的任务。
* 下载人筛选只在管理员生效,作用于「我的下载/收藏/全部」三种分类的结果。
*/
function activeTaskList(state){
if(state.isSearch)
return state.searchTask
if(state.isAdmin && state.downloaderFilter !== null)
return state.currentTasks.filter(task => task.downloader === state.downloaderFilter)
return state.currentTasks
}
function getShortname(name){
if(name === null)
return null
if (!name.includes("[")) return name
// 截取最后一个 [ 之前的部分,然后移除所有 [...] 和 (...) 标签
const trimmed = name.substring(0, name.lastIndexOf("["))
.replace(/\s*\[[^\]]*\]\s*/g, '')
.replace(/\s*\([^\)]*\)\s*/g, '')
.trim()
// 名字以 [...] 开头时截取结果为空,退回原名避免整列空白
return trimmed === '' ? name.trim() : trimmed
}
function buildGalleryDownloadUrl(gid, AuthCode){
let params = new URLSearchParams({AuthCode, gid: gid.toString()})
return GalleryManageUrl + "/file/" + gid + ".zip?" + params.toString()
}
function buildGalleryManageUrl(path, params){
return GalleryManageUrl + path + "?" + new URLSearchParams(params).toString()
}
function confirmCurrentTask(state){
switch (state.category){
case 'myDownload':
state.currentTasks = state.downloadGallery
break
case 'myCollect':
state.currentTasks = state.collectGallery
break
case 'total':
state.currentTasks = state.totalGalleryTask
break
}
}
function sortTasks(state){
switch (state.sortType) {
case "name":
state.currentTasks = state.currentTasks.sort((before, after) => {
return before.name > after.name ? 1: -1
})
break
case "shortName":
state.currentTasks = state.currentTasks.sort((before, after) => {
return before.shortName > after.shortName ? 1: -1
})
break
case "createTime":
state.currentTasks = state.currentTasks.sort((before, after) => {
return before.createTime - after.createTime
})
}
}
function deleteTask(tasks, key, value){
for(let j=0; j < tasks.length; j++)
for(let i=0; i<tasks[j].length; i++)
if(tasks[j][i][key] === value){
tasks[j].splice(i, 1)
i--
break
}
}
let status = ['已提交', '下载中', '等待压缩', '压缩中', '下载完成']
// 移动端与桌面端共用同一份任务业务核心,移动端用“继续加载”代替分页条。
export default new vuex.Store(createGalleryStore({
variant: "mobile",
axios,
qs,
Message: ElMessage,
}))
-23
View File
@@ -1,23 +0,0 @@
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
@@ -1,76 +0,0 @@
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')
}
}
}
-27
View File
@@ -1,27 +0,0 @@
const BaseUrl = "https://downloader.lionwebsite.xyz/";
// Remote galleries carry a raw thumbnail path; it only resolves through the
// backend proxy once the active auth code is attached.
export function ehThumbnailUrl(path, AuthCode) {
if (!path) return "";
return BaseUrl + "GalleryManage/ehThumbnail?" + new URLSearchParams({path, AuthCode}).toString();
}
const UNIT_SCALE = {b: 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3, t: 1024 ** 4};
function sizeInBytes(text) {
const match = String(text).match(/([\d.]+)\s*([kmgt]?)(?:i?b)/i);
if (!match) return 0;
const scale = UNIT_SCALE[match[2].toLowerCase()] ?? 1;
return Number(match[1]) * scale;
}
// Prefer the original file; otherwise take whichever entry reports the most bytes.
export function pickDefaultResolution(availableResolution) {
const entries = Object.entries(availableResolution || {});
if (entries.length === 0) return "";
const original = entries.find(([resolution]) => /original/i.test(resolution));
if (original) return original[0];
return entries.reduce((best, current) =>
sizeInBytes(current[1]) > sizeInBytes(best[1]) ? current : best)[0];
}
-8
View File
@@ -1,8 +0,0 @@
export function validateLink(rawLink){
if(rawLink.trim() === "")
return false
if(rawLink.includes("hentai"))
return rawLink.includes("/g/")
else
return false
}
-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')
})