Compare commits

Author SHA1 Message Date
root 08e21f3b2e Upgrade Vite and follow-redirects security dependencies 2026-10-22 00:15:21 +08:00
root de7971fb23 fix: avoid applying thumbnail proxy twice in gallery details 2026-10-07 23:50:52 +08:00
root 97d635fbc7 桌面下载端改用共享核心包,并修两处已知缺陷
- store 改由 createGalleryStore 生成:totalGalleryTask 成为唯一任务源,
  收藏/下载/分类/搜索结果全部由 getter 派生,不再维护三份互相引用的数组
- 排序不再原地修改分类数组;本地搜索记住关键词,任务刷新后结果仍在
- 本地搜索结果补齐翻页入口:此前匹配 25 项却只显示 10 项,其余无法看到
- 取消收藏不再因本地找不到 gid 抛 TypeError
- 删除重复的 taskSocket/progressiveImages/validate/thumbnail,改从共享包导入
- 远端搜索改用共享 fetchGalleries,关键词交给 axios 序列化,翻页按游标转发
- main.js 按需注册 Element Plus,入口 JS 由约 1.14MB 降到约 494KB
- 依赖新增 file:../lionwebsite-frontend-shared

测试:14 项通过;构建成功,入口仍为 /asserts/index/index.js|css。
2026-10-07 16:15:23 +08:00
root 5208b7ccec 窄屏下把输入控件字号提到 16px,避免 iOS 聚焦放大
- iOS Safari 聚焦字号小于 16px 的输入框时会放大整个页面,且忽略
  viewport 的 user-scalable=no / maximum-scale,只能靠字号规避
- 该规则只在 max-width:600px 生效,1440 桌面端仍是 14px 不变
- viewport 补 maximum-scale=1 作为其他浏览器的兜底
2026-09-21 12:42:04 +08:00
root 9d36c5d654 桌面端下载器重构为工作台布局,并重做在线看阅读器
- 左侧导航轨 + 主视图 + 常驻详情检查器,替换原左右两栏平铺卡片结构
- 拆分为 TaskView / TaskRow / SearchView / SettingsView / DetailPanel /
  NavRail / AuthScreen,删除 DashBoard、Side、HentaiSearch
- 在线看改为全屏阅读器:适应高度/宽度、常驻翻页栏、缩略图与标题信息,
  翻页改为状态驱动,不再点击内部 DOM;补键盘快捷键与大图查看
- 提交弹层默认选中原图(无原图时选体积最大的一个)
- 管理员可见「下载人」筛选与详情里的下载人昵称,判定改用后端 isAdmin
- store.deleteGallery 返回 promise,便于删除后清理选中态
- 新增 thumbnail 工具与 4 项单测
2026-09-20 22:54:39 +08:00
root e836becda6 桌面端下载器调优:任务列表信息层级与操作区重排
- 统一 theme.css 视觉变量与卡片、状态标签样式
- DashBoard 与 Side 的任务行重排:状态标签与进度对齐,排序/显示条件更明确
- 新增 AppIcon 图标组件,store 同步空名短标题修复
2026-09-20 18:41:09 +08:00
27 changed files with 3109 additions and 1945 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/asserts/favicon.ico" /> <link rel="icon" href="/asserts/favicon.ico" />
<link rel="stylesheet" href="/src/reset.css" type="text/css"> <link rel="stylesheet" href="/src/reset.css" type="text/css">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>LionWebsite</title> <title>LionWebsite</title>
</head> </head>
<body> <body>
+86 -78
View File
@@ -10,15 +10,19 @@
"dependencies": { "dependencies": {
"axios": "^1.0.0", "axios": "^1.0.0",
"element-plus": "^2.2.0", "element-plus": "^2.2.0",
"lionwebsite-frontend-shared": "file:../lionwebsite-frontend-shared",
"qs": "^6.11.0", "qs": "^6.11.0",
"vue": "^3.0.4", "vue": "^3.0.4",
"vuex": "^4.0.2" "vuex": "^4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "6.0.9", "@vitejs/plugin-vue": "6.0.9",
"vite": "8.3.0" "vite": "8.3.3"
} }
}, },
"../lionwebsite-frontend-shared": {
"version": "1.0.0"
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -115,9 +119,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.149.0", "version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -136,9 +140,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -153,9 +157,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -170,9 +174,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -187,9 +191,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -204,9 +208,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -221,9 +225,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -238,9 +242,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -258,9 +262,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -278,9 +282,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -298,9 +302,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -318,9 +322,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -338,9 +342,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -358,9 +362,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -375,9 +379,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -392,9 +396,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -839,9 +843,9 @@
} }
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -1272,6 +1276,10 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lionwebsite-frontend-shared": {
"resolved": "../lionwebsite-frontend-shared",
"link": true
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1452,13 +1460,13 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.8", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.149.0", "@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@@ -1468,21 +1476,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.8", "@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.8", "@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.8", "@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.8", "@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.8", "@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.8", "@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.8", "@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.8", "@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.8", "@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.8", "@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.8", "@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.8", "@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.8", "@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.8" "@rolldown/binding-win32-x64-msvc": "1.2.15"
} }
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
@@ -1558,9 +1566,9 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -1584,16 +1592,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.0", "version": "8.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
"postcss": "^8.5.28", "postcss": "^8.5.28",
"rolldown": "~1.2.6", "rolldown": "~1.2.11",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
+3 -2
View File
@@ -13,10 +13,11 @@
"element-plus": "^2.2.0", "element-plus": "^2.2.0",
"qs": "^6.11.0", "qs": "^6.11.0",
"vue": "^3.0.4", "vue": "^3.0.4",
"vuex": "^4.0.2" "vuex": "^4.0.2",
"lionwebsite-frontend-shared": "file:../lionwebsite-frontend-shared"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "6.0.9", "@vitejs/plugin-vue": "6.0.9",
"vite": "8.3.0" "vite": "8.3.3"
} }
} }
+58 -27
View File
@@ -1,11 +1,20 @@
<script setup> <script setup>
import {onMounted, onBeforeUnmount} from "vue"; import {computed, onMounted, onBeforeUnmount} from "vue";
import store from "./store/index.js"; import store from "./store/index.js";
import AuthScreen from "./components/AuthScreen.vue";
import NavRail from "./components/NavRail.vue";
import TaskView from "./components/TaskView.vue";
import SearchView from "./components/SearchView.vue";
import SettingsView from "./components/SettingsView.vue";
import DetailPanel from "./components/DetailPanel.vue";
import OnlineReader from "./components/OnlineReader.vue";
const resumeProgress = () => { const resumeProgress = () => {
if (store.state.isAuth && document.visibilityState === 'visible') store.dispatch("initWebsocket"); if (store.state.isAuth && document.visibilityState === 'visible') store.dispatch("initWebsocket");
}; };
const pauseProgress = () => store.dispatch("disconnectWebsocket"); const pauseProgress = () => store.dispatch("disconnectWebsocket");
onMounted(() => { onMounted(() => {
restoreSettings();
window.addEventListener('online', resumeProgress); window.addEventListener('online', resumeProgress);
window.addEventListener('pageshow', resumeProgress); window.addEventListener('pageshow', resumeProgress);
window.addEventListener('pagehide', pauseProgress); window.addEventListener('pagehide', pauseProgress);
@@ -18,33 +27,55 @@ onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', resumeProgress); document.removeEventListener('visibilitychange', resumeProgress);
pauseProgress(); pauseProgress();
}); });
import Side from "./components/Side.vue";
import DashBoard from "./components/DashBoard.vue"; // Preferences are persisted in SettingsView; this restores them on load.
function restoreSettings() {
const auth = localStorage.getItem("auth");
store.state.lengthPerPage = localStorage.getItem("lengthPerPage") === null
? 30
: Number(localStorage.getItem("lengthPerPage"));
store.state.category = localStorage.getItem("category") || "myDownload";
store.state.sortType = localStorage.getItem("sortType") || "createTime";
store.state.galleryNameType = localStorage.getItem("galleryNameType") || "shortName";
const darkConfigStr = localStorage.getItem("darkConfig");
let followSystem = false;
if (darkConfigStr !== null) {
try {
followSystem = Boolean(JSON.parse(darkConfigStr).followSystem);
} catch {
followSystem = false;
}
}
if (followSystem && window.matchMedia("(prefers-color-scheme: dark)").matches)
document.documentElement.classList.add("dark");
else
document.documentElement.classList.remove("dark");
if (auth !== null) store.dispatch("validate", auth);
}
const loadComplete = computed(() => store.state.loadComplete);
const activeView = computed(() => store.state.activeView);
const selectedGid = computed(() => store.state.selectedGallery?.gid ?? null);
function openTask(gallery) {
store.commit("_selectGallery", gallery);
}
</script> </script>
<template> <template>
<el-container class="app-layout"> <AuthScreen :load-complete="loadComplete" />
<el-aside width="720px" class="side-area">
<Side/>
</el-aside>
<el-main class="main-area">
<DashBoard/>
</el-main>
</el-container>
</template>
<style> <div class="app" v-show="loadComplete">
.app-layout { <NavRail />
height: 100vh; <main class="workspace">
} <TaskView v-show="activeView === 'tasks'" :selected-gid="selectedGid" @open="openTask" />
.side-area { <SearchView v-show="activeView === 'search'" />
overflow: hidden; <SettingsView v-show="activeView === 'settings'" />
border-right: 1px solid var(--border-color); </main>
background: var(--bg-surface); <DetailPanel />
} </div>
.main-area {
padding: 0; <OnlineReader />
overflow: hidden; </template>
background-color: var(--bg-body);
}
</style>
+53
View File
@@ -0,0 +1,53 @@
<script setup>
import { computed } from "vue";
// Small stroke-based icon set (24x24 grid) so the UI needs no extra dependency.
// Brand-ish icons are stroked; star/sun/moon render filled for legibility.
const FILLED = new Set(['star', 'sun', 'moon']);
const PATHS = {
download: ["M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4", "M7 10l5 5 5-5", "M12 15V3"],
eye: ["M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z", "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"],
star: ["M12 2.5l2.9 5.9 6.5.95-4.7 4.6 1.1 6.45L12 17.35 6.2 20.4l1.1-6.45-4.7-4.6 6.5-.95L12 2.5z"],
refresh: ["M21 12a9 9 0 0 1-15.5 6.2L3 16", "M3 21v-5h5", "M3 12a9 9 0 0 1 15.5-6.2L21 8", "M21 3v5h-5"],
trash: ["M3 6h18", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6", "M10 11v6", "M14 11v6", "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"],
search: ["M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z", "M20 20l-4-4"],
external: ["M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6", "M15 3h6v6", "M10 14L21 3"],
"chevron-left": ["M15 18l-6-6 6-6"],
"chevron-right": ["M9 6l6 6-6 6"],
"chevrons-left": ["M11 17l-5-5 5-5", "M18 17l-5-5 5-5"],
"chevrons-right": ["M13 17l5-5-5-5", "M6 17l5-5-5-5"],
sun: ["M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M12 2v2", "M12 20v2", "M4.5 4.5l1.4 1.4", "M18.1 18.1l1.4 1.4", "M2 12h2", "M20 12h2", "M4.5 19.5l1.4-1.4", "M18.1 5.9l1.4-1.4"],
moon: ["M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"],
list: ["M8 6h13", "M8 12h13", "M8 18h13", "M3 6h.01", "M3 12h.01", "M3 18h.01"],
settings: ["M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"],
sort: ["M11 5h10", "M11 9h7", "M11 13h4", "M3 17l3 3 3-3", "M6 18V4"],
clock: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z", "M12 7v5l3 2"],
close: ["M18 6L6 18", "M6 6l12 12"],
image: ["M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", "M8.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z", "M21 15l-5-5L5 21"],
};
const props = defineProps({
name: { type: String, required: true },
size: { type: [Number, String], default: 16 },
filled: { type: Boolean, default: false },
});
const paths = computed(() => PATHS[props.name] || []);
const isFilled = computed(() => props.filled || FILLED.has(props.name));
</script>
<template>
<svg class="app-icon"
:width="size"
:height="size"
viewBox="0 0 24 24"
:fill="isFilled ? 'currentColor' : 'none'"
stroke="currentColor"
:stroke-width="isFilled ? 0 : 1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false">
<path v-for="(d, i) in paths" :key="i" :d="d" />
</svg>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup>
import {ref} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
const AuthCode = ref("");
const isRemember = ref(false);
defineProps({
loadComplete: {type: Boolean, default: false},
});
function validate() {
if (AuthCode.value.trim() === "") {
ElMessage("请输入授权码后再验证");
return;
}
store.dispatch("validate", AuthCode.value);
if (isRemember.value) localStorage.setItem("auth", AuthCode.value);
}
</script>
<template>
<div v-show="!loadComplete" class="auth-container">
<div class="auth-card">
<div class="auth-brand">
<span class="auth-mark" aria-hidden="true">L</span>
<span class="auth-name">LionWebsite</span>
</div>
<h1>下载管理器</h1>
<p class="auth-subtitle">输入授权码以继续</p>
<div class="auth-form">
<el-input v-model="AuthCode"
placeholder="请输入授权码"
size="large"
show-password
@keydown.enter="validate" />
<el-checkbox v-model="isRemember">记住授权码</el-checkbox>
<el-button @click="validate" type="primary" size="large" class="auth-btn">验证</el-button>
</div>
</div>
</div>
</template>
-374
View File
@@ -1,374 +0,0 @@
<template>
<!-- Auth Screen -->
<div v-show="!loadComplete" class="auth-container">
<div class="auth-card">
<h1>LionWebsite</h1>
<p class="auth-subtitle">请输入授权码以访问下载管理器</p>
<div class="auth-form">
<el-input v-model="AuthCode" placeholder="请输入授权码" size="large" />
<el-checkbox v-model="isRemember">记住授权码</el-checkbox>
<el-button @click="validate" type="primary" size="large" @keydown.enter="validate" class="auth-btn">验证</el-button>
</div>
</div>
</div>
<!-- Dashboard Main -->
<div v-show="loadComplete" class="dashboard">
<div class="control-card">
<h3>用量统计</h3>
<div class="card-row">
<span class="usage-text">本周已用: <b>{{ weekUsed.weekUsedAmount }}</b> | 上次重置: {{ weekUsed.lastResetAmountTime }}</span>
<el-button @click="queryWeekUsedAmount" size="small">查询用量</el-button>
</div>
</div>
<div class="control-card">
<h3>查询任务</h3>
<div class="card-row">
<el-select v-model="type" @change="resetLocalQuery" size="small" style="width: 90px">
<el-option value="link" label="链接"/>
<el-option value="keyword" label="关键字"/>
</el-select>
<el-input v-model="param" size="small" placeholder="输入参数" style="width: 160px" />
<el-button @click="queryRemoteTask" v-show="type === 'link'" size="small">远程查询</el-button>
<el-button @click="queryLocalTask" size="small">当前页查询</el-button>
</div>
</div>
<div class="control-card">
<h3>管理</h3>
<div class="card-row">
<el-button @click="isAlterAuthCode = true" size="small">修改授权码</el-button>
<el-button @click="deleteAuthCode" size="small">删除授权码</el-button>
<el-button @click="isConfig = true" size="small">配置</el-button>
<span style="font-size:13px;color:var(--text-secondary)">夜间模式</span>
<el-switch v-model="isDark" @change="toggleStyle" size="small" />
</div>
</div>
<div class="control-card">
<h3>系统</h3>
<div class="card-row">
<el-button @click="isQuerying = true" size="small">在线搜索</el-button>
<el-button @click="reconnect" size="small">重连节点</el-button>
<el-button v-if="isLion" @click="resetUndone" size="small">重置任务</el-button>
</div>
</div>
<div v-show="thumbnailGallery.thumb_link !== undefined" class="control-card card-wide">
<h3>缩略图</h3>
<div class="thumbnail-body">
<p class="gallery-name">{{ thumbnailGallery.shortName }}</p>
<el-image :src="thumbnailGallery.thumb_link" :preview-src-list="[thumbnailGallery.thumb_link]" style="width:100%;height:500px" fit="contain" />
</div>
</div>
</div>
<el-dialog title="查询" v-model="chosenGallery" style="height: 350px">
<el-image v-show='chosenGallery.thumb_link !== undefined'
style="float: right; width: 250px; height: 250px;" fit="contain"
:src="chosenGallery.thumb_link !== undefined ? thumbnailUrl(chosenGallery.thumb_link) : ''"/>
<table>
<tr>名字:{{chosenGallery.name}}</tr>
<tr>页数:{{chosenGallery.pages}}</tr>
<tr>语言:{{chosenGallery.language}}</tr>
<tr>大小:{{chosenGallery.fileSize}}</tr>
<tr>状态:{{chosenGallery.status}}</tr>
<tr v-if="chosenGallery.availableResolution">
目标分辨率:<el-select v-model="targetResolution" style="width: 200px">
<el-option v-for="(fileSize, resolution) in chosenGallery.availableResolution" :value="resolution"
:label="resolution + ' ' + fileSize">
</el-option>
</el-select>
</tr>
</table>
<template #footer>
<el-button @click="postTask" v-if="chosenGallery.availableResolution">下载</el-button>
<el-button @click="downloadChosenGallery" v-else-if="chosenGallery.status === '下载完成'">下载文件</el-button>
<el-button @click="readOnlineGallery(chosenGallery)">在线预览</el-button>
<el-button v-if="chosenGallery.status === '下载完成'" @click="deleteGallery">删除</el-button>
</template>
</el-dialog>
<el-dialog title="修改授权码" v-model="isAlterAuthCode">
<el-form>
<el-form-item>
<template #label>当前授权码</template>
<template #default>{{realAuthCode}}</template>
</el-form-item>
<el-form-item>
<template #label>新的授权码</template>
<template #default>
<el-input v-model="newAuthCode"></el-input>
</template>
</el-form-item>
<el-form-item>
<template #label>再次输入授权码</template>
<template #default>
<el-input v-model="tempAuthCode"></el-input>
</template>
</el-form-item>
</el-form>
<el-footer>
<el-button @click="alterAuthCode">提交</el-button>
</el-footer>
</el-dialog>
<HentaiSearch v-model:is-querying="isQuerying" @close="isQuerying = false"></HentaiSearch>
<el-dialog title="配置" v-model="isConfig">
<div>
夜间模式<br>
<span style="display: inline-block">夜间模式跟随系统</span>
<el-switch v-model="darkConfig.followSystem"></el-switch><hr>
</div>
<div>
在线预览<br>
<span style="display: inline-block">在线预览分页页数:</span>
<input v-model="lengthPerPage"><hr>
</div>
<div>
默认设置<br>
<span style="display: inline-block">分类:</span>
<el-select v-model="category" default-first-option>
<el-option label="全部" value="total"/>
<el-option label="我的下载" value="myDownload"/>
<el-option label="我的收藏" value="myCollect"/>
</el-select> <br>
<span style="display: inline-block">排序方式:</span>
<el-select v-model="sortType" default-first-option>
<el-option label="名字" value="name"/>
<el-option label="简洁名字" value="shortName"/>
<el-option label="任务创建时间" value="createTime"/>
</el-select> <br>
<span style="display: inline-block">显示类型:</span>
<el-select v-model="galleryNameType" default-first-option>
<el-option label="名字" value="name"/>
<el-option label="简洁名字" value="shortName"/>
</el-select> <br>
</div>
<template #footer>
<el-button type="primary" @click="saveConfig">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import store from "../store";
import {computed, ref, onMounted, watch} from "vue";
import {ElMessage} from "element-plus"
import HentaiSearch from "./HentaiSearch.vue";
import {validateLink} from "../utils/validate.js";
let AuthCode = ref("")
let isRemember = ref(false)
let isAlterAuthCode = ref(false)
let newAuthCode = ref("")
let tempAuthCode = ref("")
let isQuerying = ref(false)
let isConfig = ref(false)
let isDark = ref(false)
let keyword = ref("")
let darkConfig = ref({})
let lengthPerPage = ref(0)
let category = ref("")
let sortType = ref("")
let galleryNameType = ref("")
let type = ref("link")
let param = ref("")
let targetResolution = ref("")
let realAuthCode = computed(() => {
return store.state.AuthCode
})
let chosenGallery = computed(() => {
return store.state.chosenGallery
})
let loadComplete = computed(() => {
return store.state.loadComplete
})
let weekUsed = computed(() => {
return store.state.weekUsed
})
let thumbnailGallery = computed(() => {
return store.state.thumbnailGallery
})
let isLion = computed(() => {
return store.state.userId === 3
})
watch(chosenGallery, () => {
param.value = ''
targetResolution.value = ''
})
function reconnect(){
store.dispatch("reconnect")
}
function alterAuthCode(){
if(newAuthCode.value.trim() === "" || tempAuthCode.value.trim() === "" || newAuthCode.value !== tempAuthCode.value)
ElMessage("请检查授权码输入是否错误")
else {
store.dispatch("alterAuthCode", newAuthCode.value)
isAlterAuthCode.value = false
newAuthCode.value = ""
tempAuthCode.value = ""
}
}
function queryWeekUsedAmount(){
store.dispatch("loadWeekUsedAmount")
}
function postTask(){
if(!validateLink(chosenGallery.value.link)){
ElMessage("链接错误")
return
}
if(targetResolution.value === ''){
ElMessage("请选择分辨率再提交")
return
}
store.dispatch("postGalleryTask",
{link: chosenGallery.value.link,
targetResolution: targetResolution.value})
}
function queryRemoteTask(){
if(!validateLink(param.value)){
ElMessage("链接错误")
return
}
if(param.value.includes("e-hentai"))
param.value = param.value.replace("e-hentai", "exhentai")
store.dispatch("queryGalleryTask", param.value)
}
function queryLocalTask(){
switch (type.value){
case "link":
store.commit("_searchLocalByLink", param.value)
break
case "keyword":
store.commit("_searchLocalByKeyword", param.value)
break
}
}
function resetLocalQuery(){
store.commit("_searchLocalByKeyword", "")
param.value = ""
}
function deleteGallery(){
store.dispatch("deleteGallery", chosenGallery.value.gid)
}
function downloadChosenGallery(){
if(chosenGallery.value.download)
window.open(chosenGallery.value.download)
else
ElMessage({message: "下载地址不存在,请刷新任务后重试", type: "error"})
}
function validate(){
if(AuthCode.value.trim() === ""){
ElMessage("请输入授权码后再验证")
}
else{
store.dispatch("validate", AuthCode.value)
if(isRemember.value)
localStorage.setItem("auth", AuthCode.value)
}
}
function readOnlineGallery(gallery){
store.dispatch("readOnlineGallery", gallery)
}
function thumbnailUrl(path) {
return "https://downloader.lionwebsite.xyz/GalleryManage/ehThumbnail?" + new URLSearchParams({
path,
AuthCode: store.state.AuthCode
}).toString()
}
function resetUndone(){
store.dispatch("resetUndone").then()
}
function deleteAuthCode(){
localStorage.removeItem('auth')
ElMessage("删除授权码完成")
}
function toggleStyle(){
if(isDark.value)
document.documentElement.classList.add('dark')
else
document.documentElement.classList.remove('dark')
}
onMounted(() => {
const auth = localStorage.getItem("auth")
adjustForStyle()
store.state.lengthPerPage = localStorage.getItem("lengthPerPage")
store.state.lengthPerPage = store.state.lengthPerPage === null ? 30: Number(store.state.lengthPerPage)
category.value = store.state.category = localStorage.getItem("category") === null ? "myDownload" : localStorage.getItem("category")
sortType.value = store.state.sortType = localStorage.getItem("sortType") === null ? "createTime" : localStorage.getItem("sortType")
galleryNameType.value = store.state.galleryNameType = localStorage.getItem("galleryNameType") === null ? "shortName" : localStorage.getItem("galleryNameType")
lengthPerPage.value = store.state.lengthPerPage
if(auth !== null){
store.dispatch("validate", auth)
AuthCode.value = localStorage.getItem("auth")
}
})
function adjustForStyle(){
let darkConfigStr = localStorage.getItem("darkConfig")
if(darkConfigStr !== null) {
darkConfig.value = JSON.parse(darkConfigStr)
if (darkConfig.value.followSystem && isSystemDark())
document.documentElement.classList.add('dark')
else
document.documentElement.classList.remove('dark')
}else {
document.documentElement.classList.remove('dark')
darkConfig.value = {'followSystem': false}
}
isDark.value = document.documentElement.classList.contains('dark')
}
function isSystemDark(){
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
function saveConfig(){
if(lengthPerPage.value < 0 || lengthPerPage.value > 30) {
ElMessage("分页页数设置错误,范围1~30")
lengthPerPage.value = 30
}
else {
store.state.lengthPerPage = Number(lengthPerPage.value)
localStorage.setItem("lengthPerPage", lengthPerPage.value)
}
localStorage.setItem("darkConfig", JSON.stringify(darkConfig.value))
localStorage.setItem("category", category.value)
localStorage.setItem("sortType", sortType.value)
localStorage.setItem("galleryNameType", galleryNameType.value)
store.commit("_setCategory", category.value)
store.commit("_setSortType", sortType.value)
store.commit("_setGalleryNameType", galleryNameType.value)
isConfig.value = false
adjustForStyle()
}
</script>
<style scoped>
</style>
+255
View File
@@ -0,0 +1,255 @@
<script setup>
import {computed, ref, watch} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import AppIcon from "./AppIcon.vue";
import {ehThumbnailUrl, pickDefaultResolution} from "lionwebsite-frontend-shared";
const targetResolution = ref("");
const isRetrying = ref(false);
// Two flows share this panel: a task already in the list, and a remote gallery
// that was resolved from a link or from the online search.
const task = computed(() => store.state.selectedGallery);
const remote = computed(() => store.state.chosenGallery || null);
// A freshly resolved gallery is a pending decision, so it takes the panel over
// even if a task was selected earlier.
const showRemote = computed(() => Boolean(remote.value));
watch(remote, gallery => {
targetResolution.value = gallery
? pickDefaultResolution(gallery.availableResolution)
: "";
});
// Admin-only fields come from the backend's isAdmin flag, not a hardcoded id.
const isAdmin = computed(() => store.state.isAdmin);
const isDone = computed(() => Boolean(task.value && task.value.status === "下载完成"));
const statusClass = computed(() => {
const status = task.value?.status;
if (status === "下载完成") return "is-done";
if (status === "下载中" || status === "压缩中") return "is-running";
return "is-waiting";
});
const percent = computed(() => {
const item = task.value;
if (!item || item.status !== "下载中" || !item.pages) return null;
const done = Number(item.proceeding) || 0;
return Math.min(100, Math.max(0, Math.round((done / item.pages) * 100)));
});
const resolutions = computed(() => {
const gallery = remote.value;
if (!gallery || !gallery.availableResolution) return [];
return Object.entries(gallery.availableResolution).map(([resolution, fileSize]) => ({
resolution,
fileSize,
}));
});
// A task coming back from the task list already has its thumbnail proxied by
// normalizeTask. Only a raw upstream path (thumbnailUrl) still needs the proxy.
const remoteThumb = computed(() => {
const gallery = remote.value;
if (!gallery) return "";
return gallery.thumb_link || ehThumbnailUrl(gallery.thumbnailUrl, store.state.AuthCode);
});
function closeInspector() {
if (remote.value) store.commit("_setChosenGallery", {gallery: false});
else store.commit("_selectGallery", null);
}
function downloadTask() {
if (!task.value) return;
if (task.value.download) window.open(task.value.download);
else ElMessage({message: "下载地址不存在,请刷新任务后重试", type: "error"});
}
function downloadRemote() {
const gallery = remote.value;
if (!gallery) return;
if (gallery.download) window.open(gallery.download);
else ElMessage({message: "下载地址不存在,请刷新任务后重试", type: "error"});
}
function readOnline(gallery) {
if (!gallery) return;
store.dispatch("readOnlineGallery", gallery);
}
function toggleCollect() {
if (!task.value) return;
const gallery = task.value;
if (gallery.isCollect) store.dispatch("disCollectGallery", gallery.gid);
else store.dispatch("collectGallery", gallery.gid);
}
function deleteTask() {
if (!task.value) return;
store.dispatch("deleteGallery", task.value.gid).then(() => store.commit("_selectGallery", null));
}
async function retryTask() {
if (!task.value || isRetrying.value) return;
isRetrying.value = true;
try {
await store.dispatch("retryGallery", task.value.gid);
} finally {
isRetrying.value = false;
}
}
function submitTask() {
const gallery = remote.value;
if (!gallery) return;
if (targetResolution.value === "") {
ElMessage("请选择分辨率再提交");
return;
}
store.dispatch("postGalleryTask", {
link: gallery.link,
targetResolution: targetResolution.value,
});
}
</script>
<template>
<aside class="inspector" aria-label="详情">
<template v-if="showRemote">
<header class="inspector-bar">
<h2 class="section-title">远端任务</h2>
<span class="view-bar-spacer"></span>
<button type="button" class="icon-button" title="取消" aria-label="取消" @click="closeInspector">
<AppIcon name="close" :size="15" />
</button>
</header>
<div class="inspector-scroll">
<div class="preview-frame">
<img v-if="remoteThumb" :src="remoteThumb" alt="" loading="lazy">
<span v-else class="preview-empty">
<AppIcon name="image" :size="22" />无缩略图
</span>
</div>
<h3 class="inspector-title">{{ remote.name }}</h3>
<div class="inspector-badges">
<span v-if="remote.status" class="status-pill is-waiting">{{ remote.status }}</span>
<span v-if="remote.pages" class="inspector-badge">{{ remote.pages }} 页</span>
<span v-if="remote.fileSize" class="inspector-badge">{{ remote.fileSize }}</span>
</div>
<template v-if="resolutions.length">
<p class="section-title inspector-label">目标分辨率</p>
<div class="resolution-list">
<button v-for="item in resolutions"
:key="item.resolution"
type="button"
:aria-pressed="targetResolution === item.resolution"
@click="targetResolution = item.resolution">
<span>{{ item.resolution }}</span>
<span class="resolution-size">{{ item.fileSize }}</span>
</button>
</div>
</template>
</div>
<div class="inspector-actions is-remote">
<template v-if="resolutions.length">
<el-button type="primary" @click="submitTask">提交下载</el-button>
<el-button @click="readOnline(remote)">
<AppIcon name="eye" :size="15" />在线看
</el-button>
</template>
<template v-else>
<el-button type="primary" @click="downloadRemote">
<AppIcon name="download" :size="15" />下载文件
</el-button>
<el-button @click="readOnline(remote)">
<AppIcon name="eye" :size="15" />在线看
</el-button>
</template>
</div>
</template>
<template v-else-if="task">
<header class="inspector-bar">
<h2 class="section-title">任务详情</h2>
<span class="view-bar-spacer"></span>
<button type="button" class="icon-button" title="收起详情" aria-label="收起详情" @click="closeInspector">
<AppIcon name="close" :size="15" />
</button>
</header>
<div class="inspector-scroll">
<div class="preview-frame">
<img v-if="task.thumb_link" :src="task.thumb_link" alt="" loading="lazy">
<span v-else class="preview-empty">
<AppIcon name="image" :size="22" />无缩略图
</span>
</div>
<h3 class="inspector-title">{{ task.name }}</h3>
<div class="inspector-badges">
<span class="status-pill" :class="statusClass">{{ task.status }}</span>
<span class="inspector-badge">{{ task.pages }} 页</span>
<span class="inspector-badge">{{ task.language }}</span>
<span class="inspector-badge">{{ task.fileSize }}</span>
</div>
<div v-if="percent !== null" class="progress-line">
<el-progress :percentage="percent" :stroke-width="5" :show-text="false" />
<span class="progress-value">{{ percent }}% · {{ task.proceeding }}/{{ task.pages }} 页</span>
</div>
<dl class="inspector-facts">
<dt>分辨率</dt>
<dd>{{ task.resolution || "—" }}</dd>
<dt>创建时间</dt>
<dd>{{ task.createTimeDisplay || "—" }}</dd>
<dt>文件大小</dt>
<dd>{{ task.fileSize || "—" }}</dd>
<dt>原始页面</dt>
<dd>
<a v-if="task.link" :href="task.link" target="_blank" rel="noopener">打开链接</a>
<span v-else>—</span>
</dd>
<template v-if="isAdmin">
<dt>下载人</dt>
<dd>{{ task.downloaderName || `#${task.downloader}` }}</dd>
</template>
</dl>
</div>
<div class="inspector-actions">
<el-button type="primary" :disabled="!isDone" @click="downloadTask">
<AppIcon name="download" :size="15" />下载
</el-button>
<el-button @click="readOnline(task)">
<AppIcon name="eye" :size="15" />在线看
</el-button>
<el-button :class="{'is-collected': task.isCollect}" @click="toggleCollect">
<AppIcon name="star" :size="15" />{{ task.isCollect ? "已收藏" : "收藏" }}
</el-button>
<el-button v-if="!isDone" :loading="isRetrying" @click="retryTask">
<AppIcon name="refresh" :size="15" />重试
</el-button>
<el-button type="danger" plain :disabled="!isDone" @click="deleteTask">
<AppIcon name="trash" :size="15" />删除
</el-button>
</div>
</template>
<div v-else class="inspector-empty">
<AppIcon name="image" :size="26" />
<p>选择一条任务查看详情</p>
</div>
</aside>
</template>
-131
View File
@@ -1,131 +0,0 @@
<script setup>
import {ref, watch} from "vue";
import axios from "axios";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import {validateLink} from "../utils/validate.js";
let props = defineProps(['isQuerying'])
let emit = defineEmits(['close'])
let scrollBar = ref()
let keyword = ref("")
let queryPage = ref({})
let galleries = ref([])
let param = ref()
let isShowUp = ref()
let isLoading = ref(false)
watch(props, () => {
isShowUp.value = props.isQuerying
})
function queryGalleries(link) {
let tempParam
if (link !== null) {
let url = new URL(link)
tempParam = url.search.replace("?f_search=", "")
} else {
tempParam = keyword.value
}
tempParam = tempParam.replace(" ", "+")
isLoading.value = true
axios.get("https://downloader.lionwebsite.xyz/query?keyword=" + tempParam)
.then((res) => {
if (res.data.result === "success") {
let tempGalleries = JSON.parse(res.data.data)
queryPage.value.first = 'first' in res.data ? res.data.first : undefined
queryPage.value.previous = 'previous' in res.data ? res.data.previous : undefined
queryPage.value.next = 'next' in res.data ? res.data.next : undefined
queryPage.value.last = 'last' in res.data ? res.data.last : undefined
galleries.value.splice(0)
tempGalleries.forEach((gallery) => {
galleries.value.push(gallery)
})
scrollBar.value.setScrollTop(0)
} else {
ElMessage({message: res.data.data, type: "error"})
}
}).finally(() => {
isLoading.value = false
})
}
function queryRemoteTask(){
if(!validateLink(param.value)){
ElMessage("链接错误")
return
}
store.dispatch("queryGalleryTask", param.value)
}
function close(){
emit("close")
}
function thumbnailUrl(path) {
return "https://downloader.lionwebsite.xyz/GalleryManage/ehThumbnail?" + new URLSearchParams({
path,
AuthCode: store.state.AuthCode
}).toString()
}
</script>
<template>
<el-dialog title="在线搜索" v-model="isShowUp" top="0" style="margin-bottom: 0" @close="close">
<div style="text-align: center">
<el-input v-model="keyword"></el-input> <el-button @click="queryGalleries(null)">查询</el-button> <div class="loading" v-show="isLoading"/>
</div>
<el-scrollbar height="75vh" ref="scrollBar">
<div style="height: 251px; width: 100%; " v-for="gallery in galleries">
<el-image alt="picture" :preview-src-list="[thumbnailUrl(gallery.thumbnailUrl)]"
:src="thumbnailUrl(gallery.thumbnailUrl)"
style="height:250px;width:250px;float: left;"
fit="contain"
loading="lazy"
/>
<div style="font: bold 16px semi-condensed; margin-top: 10px; padding-top: 15px; padding-left: 275px">
<span>{{gallery.name}}</span><br><br>
<span>上传时间:{{gallery.uploadTime}}</span><br>
<span>页数:{{gallery.page}}</span><br>
<span class="ct6">类型:{{gallery.type}}</span><br>
<a :href="gallery.link">链接</a><br>
<el-button-group style=" margin-left: 50%; margin-top: -5vh">
<el-button @click="store.dispatch('readOnlineGallery', gallery)">在线看</el-button>
<el-button @click="param=gallery.link; queryRemoteTask()">查看详情</el-button>
</el-button-group>
</div>
</div>
</el-scrollbar>
<div style="padding-top: 1vh; text-align: center">
<el-button @click="queryGalleries(queryPage.first)" :disabled="queryPage.first === undefined">首页</el-button>
<el-button @click="queryGalleries(queryPage.previous)" :disabled="queryPage.previous === undefined">上一页</el-button>
<el-button @click="queryGalleries(queryPage.next)" :disabled="queryPage.next === undefined">下一页</el-button>
<el-button @click="queryGalleries(queryPage.last)" :disabled="queryPage.last === undefined">尾页</el-button>
</div>
</el-dialog>
</template>
<style scoped>
.el-input{
width: 200px;
}
.loading {
display: inline-block;
width: 25px;
height: 25px;
border: 2px solid #ccc;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+73
View File
@@ -0,0 +1,73 @@
<script setup>
import {computed} from "vue";
import store from "../store/index.js";
import AppIcon from "./AppIcon.vue";
const activeView = computed({
get: () => store.state.activeView,
set: value => store.commit("_setActiveView", value),
});
const taskCount = computed(() => store.state.totalGalleryTask.length);
const searchCount = computed(() => store.state.searchTask.length);
const isConnected = computed(() => store.state.connectionStatus === "connected");
const connectionLabel = computed(() => ({
connected: "进度已连接",
connecting: "正在连接进度",
reconnecting: "连接中断,正在重连",
disconnected: "进度连接已断开",
})[store.state.connectionStatus]);
const username = computed(() => store.state.username || "未登录");
</script>
<template>
<nav class="nav-rail" aria-label="主导航">
<div class="nav-brand" aria-hidden="true">L</div>
<div class="nav-items">
<button type="button"
class="nav-item"
:class="{'is-active': activeView === 'tasks'}"
:aria-current="activeView === 'tasks' ? 'page' : undefined"
title="任务"
@click="activeView = 'tasks'">
<AppIcon name="list" :size="20" />
<span v-if="taskCount" class="nav-badge">{{ taskCount }}</span>
<span class="nav-label">任务</span>
</button>
<button type="button"
class="nav-item"
:class="{'is-active': activeView === 'search'}"
:aria-current="activeView === 'search' ? 'page' : undefined"
title="搜索"
@click="activeView = 'search'">
<AppIcon name="search" :size="20" />
<span v-if="searchCount" class="nav-badge">{{ searchCount }}</span>
<span class="nav-label">搜索</span>
</button>
<button type="button"
class="nav-item"
:class="{'is-active': activeView === 'settings'}"
:aria-current="activeView === 'settings' ? 'page' : undefined"
title="设置"
@click="activeView = 'settings'">
<AppIcon name="settings" :size="20" />
<span class="nav-label">设置</span>
</button>
</div>
<div class="nav-foot">
<span class="nav-connection"
:class="{'is-connected': isConnected}"
role="status"
:title="connectionLabel">
<i class="connection-dot"></i>
</span>
<span class="nav-user" :title="username">{{ username }}</span>
</div>
</nav>
</template>
+412 -120
View File
@@ -1,142 +1,434 @@
<script setup> <script setup>
import {computed, ref, watch, onBeforeUnmount} from "vue"; import {computed, nextTick, onBeforeUnmount, ref, watch} from "vue";
import {startImages, settleImage, retryImage} from "../utils/progressiveImages.js"; import {startImages, settleImage, retryImage} from "lionwebsite-frontend-shared";
import store from "../store/index.js"; import store from "../store/index.js";
let onlineReadingScrollbar = ref() import AppIcon from "./AppIcon.vue";
let links = ref() import {ehThumbnailUrl} from "lionwebsite-frontend-shared";
let index = ref(0) //下标
let page = ref(0) //页数 下标+1=页数 用于跳转
let imagesForLoading = ref([]) const viewer = ref(null);
let isReading = ref(false) const readingGallery = computed(() => store.state.readingGallery);
let max = ref(0) // Images arrive a page at a time; "page" is a window into the full gallery.
let current_page = 0 const links = ref([]);
let lengthPerPage = computed(() => { const pageIndex = ref(0);
return Math.max(1, Number(store.state.lengthPerPage) || 10) const pageInput = ref(1);
}) const isReading = ref(false);
let readingGallery = computed(() => { const imagesForLoading = ref([]);
return store.state.readingGallery const currentImage = ref(0);
}) const fitMode = ref("height");
watch(() => store.state.isReading, (isReadingVal) => {
if(isReadingVal && !isReading.value) { // Remote galleries keep a raw thumbnail path; tasks store an already-proxied URL.
alterPage() const thumbUrl = computed(() => {
isReading.value = true const path = readingGallery.value.thumb_link;
if (!path) return "";
return /^https?:/i.test(path) ? path : ehThumbnailUrl(path, store.state.AuthCode);
});
const lengthPerPage = computed(() => Math.max(1, Number(store.state.lengthPerPage) || 10));
const totalImages = computed(() => readingGallery.value.images.length);
const pageCount = computed(() =>
Math.max(1, Math.ceil(totalImages.value / lengthPerPage.value)));
const rangeLabel = computed(() => {
if (totalImages.value <= lengthPerPage.value) return `共 ${totalImages.value} 页`;
const start = pageIndex.value * lengthPerPage.value + 1;
const end = Math.min(totalImages.value, start + links.value.length - 1);
return `${start} - ${end} / ${totalImages.value}`;
});
watch(() => store.state.isReading, open => {
if (open && !isReading.value) {
showPage(0);
isReading.value = true;
} else if (!open && isReading.value) {
isReading.value = false;
} }
}) });
//切换 function showPage(target) {
function alterPage(){ if (target < 0 || target >= pageCount.value) return;
if(readingGallery.value.images.length > lengthPerPage.value){ pageIndex.value = target;
links.value = readingGallery.value.images.slice(0, lengthPerPage.value) pageInput.value = target + 1;
max.value = Math.ceil(readingGallery.value.images.length / lengthPerPage.value) currentImage.value = 0;
}else{ links.value = readingGallery.value.images.slice(
links.value = readingGallery.value.images target * lengthPerPage.value,
max.value = 0 (target + 1) * lengthPerPage.value
);
imagesForLoading.value = startImages(links.value);
nextTick(() => viewer.value?.setScrollTop(0));
}
function imageResult(item, failed = false) {
settleImage(imagesForLoading.value, links.value, item, failed);
}
function close() {
imagesForLoading.value = [];
links.value = [];
store.commit("_closeReader");
}
// Scroll the active image into view when the user steps between images.
function revealCurrent() {
nextTick(() => {
const node = viewer.value?.wrapRef?.querySelectorAll(".reader-image")[currentImage.value];
node?.scrollIntoView({block: "nearest", behavior: "smooth"});
});
}
function step(delta) {
if (currentImage.value + delta < 0) {
if (pageIndex.value > 0) showPage(pageIndex.value - 1);
return;
} }
index.value = 0 if (currentImage.value + delta >= links.value.length) {
page.value = 1 if (pageIndex.value < pageCount.value - 1) showPage(pageIndex.value + 1);
imagesForLoading.value = startImages(links.value) return;
}
//跳转到对应页数
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)
index.value = targetIndex
page.value = targetIndex + 1
imagesForLoading.value = startImages(links.value)
onlineReadingScrollbar.value.setScrollTop(0)
}
function imageResult(item, failed = false){
settleImage(imagesForLoading.value, links.value, item, failed)
}
let pageSwitchTimer
function clearPageSwitchTimer(){
clearInterval(pageSwitchTimer)
pageSwitchTimer = undefined
}
onBeforeUnmount(clearPageSwitchTimer)
function closeDialog(){
clearPageSwitchTimer()
imagesForLoading.value = []
onlineReadingScrollbar.value.setScrollTop(0)
isReading.value = false
store.commit("_closeReader")
}
function switch_page(target_page){
//上一页
if(target_page > (current_page + 1) && current_page === 0 && index.value > 0) {
document.querySelector("span.el-image-viewer__btn.el-image-viewer__close").click()
jump(index.value - 1)
onlineReadingScrollbar.value.setScrollTop(onlineReadingScrollbar.value.wrapRef.scrollHeight)
let top = 0
pageSwitchTimer = setInterval(() => {
if(onlineReadingScrollbar.value.scrollTop === top){
clearPageSwitchTimer()
document.querySelector("div.el-scrollbar__wrap.el-scrollbar__wrap--hidden-default > div > div:last-child > img").click()
} }
top = onlineReadingScrollbar.value.scrollTop currentImage.value += delta;
onlineReadingScrollbar.value.setScrollTop(onlineReadingScrollbar.value.wrapRef.scrollHeight) revealCurrent();
}, 100) }
//下一页
}else if(target_page === 0 && current_page === lengthPerPage.value - 1 && index.value < max.value - 1){ // Reader shortcuts: arrows step, space/esc close or advance, +/- fit.
jump(index.value + 1) function onKeydown(event) {
current_page = 0 if (!isReading.value) return;
document.querySelector("div.el-scrollbar__wrap.el-scrollbar__wrap--hidden-default > div > div:nth-child(1) > img").click() // The built-in image viewer owns the keyboard while it is open.
} if (document.querySelector(".el-image-viewer__wrapper")) return;
else{ const key = event.key;
current_page = target_page if (key === "ArrowRight" || key === "PageDown" || key === " ") {
event.preventDefault();
step(1);
} else if (key === "ArrowLeft" || key === "PageUp") {
event.preventDefault();
step(-1);
} else if (key === "Escape") {
close();
} else if (key === "+" || key === "=") {
fitMode.value = "height";
} else if (key === "-") {
fitMode.value = "width";
} }
} }
function set_current_page(page){ window.addEventListener("keydown", onKeydown);
current_page = page onBeforeUnmount(() => {
} window.removeEventListener("keydown", onKeydown);
});
</script> </script>
<template> <template>
<el-dialog v-model="isReading" width="90%" style="margin-top: 0; margin-bottom: 0; padding: 0" @close="closeDialog"> <Teleport to="body">
<template #header style="padding-bottom: 0"> <div v-if="isReading" class="reader-layer" role="dialog" aria-modal="true" aria-label="在线预览">
在线预览: {{readingGallery.name}}<br> <header class="reader-bar">
页数:{{readingGallery.pages}}<br> <span class="reader-thumb">
<div style="font-size: 3vh; display: inline" v-if="max > 0"> <img v-if="thumbUrl" :src="thumbUrl" alt="">
{{ index + 1 }} - {{ (index) * lengthPerPage + 1 }} ~ <AppIcon v-else name="image" :size="16" />
{{ (index + 1) * lengthPerPage + 1 > readingGallery.images.length ? readingGallery.images.length : (index + 1) * lengthPerPage }} </span>
<div class="reader-heading">
<p class="reader-title" :title="readingGallery.name">{{ readingGallery.name }}</p>
<p class="reader-sub">
<span>{{ rangeLabel }}</span>
<span v-if="readingGallery.language">{{ readingGallery.language }}</span>
<span v-if="readingGallery.fileSize">{{ readingGallery.fileSize }}</span>
</p>
</div> </div>
</template> <div class="reader-tools">
<el-scrollbar height="75vh" ref="onlineReadingScrollbar"> <div class="segmented is-dark" role="group" aria-label="缩放模式">
<div v-for="(item, i) in imagesForLoading" :key="item.url + '-' + item.attempt" style="display: inline-block; text-align: center; min-height: 300px"> <button type="button" :aria-pressed="fitMode === 'height'" @click="fitMode = 'height'">适应高度</button>
<el-image :src="item.src" :style="{'width': '66vw', 'background-color': 'gary'}" <button type="button" :aria-pressed="fitMode === 'width'" @click="fitMode = 'width'">适应宽度</button>
: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>
<div v-if="item.failed" role="alert">图片加载失败 <el-button size="small" @click="retryImage(item)">重试</el-button></div><br> <a v-if="readingGallery.link" class="icon-button reader-external" title="打开原始页面"
{{lengthPerPage * index + i + 1}} aria-label="打开原始页面" :href="readingGallery.link" target="_blank" rel="noopener">
<AppIcon name="external" :size="15" />
</a>
<button type="button" class="icon-button" title="关闭" aria-label="关闭" @click="close">
<AppIcon name="close" :size="15" />
</button>
</div>
</header>
<el-scrollbar class="reader-scroll" ref="viewer">
<div class="reader-stack" :class="`is-${fitMode}`">
<figure class="reader-image" v-for="(item, i) in imagesForLoading" :key="item.url + '-' + item.attempt">
<el-image :src="item.src"
:preview-src-list="links"
:initial-index="i"
fit="contain"
loading="lazy"
@show="currentImage = i"
@load="imageResult(item)"
@error="imageResult(item, true)" />
<div v-if="item.failed" class="reader-error" role="alert">
图片加载失败
<el-button size="small" @click="retryImage(item)">重试</el-button>
</div>
<figcaption>{{ pageIndex * lengthPerPage + i + 1 }}</figcaption>
</figure>
</div> </div>
</el-scrollbar> </el-scrollbar>
<!-- 十页以下--> <footer class="reader-foot">
<span v-if="max > 1 && max < 9"> <button type="button" class="text-button" :disabled="pageIndex === 0" @click="showPage(pageIndex - 1)">
<el-button @click="jump(index - 1)" :disabled="index === 0">上一页</el-button> <AppIcon name="chevrons-left" :size="15" />上一组
<el-button v-for="i in max" @click="jump(i - 1)"> </button>
{{i}} <button type="button" class="text-button" :disabled="currentImage === 0" @click="step(-1)">
</el-button> <AppIcon name="chevron-left" :size="15" />上一张
<el-button @click="jump(index + 1)" :disabled="index === max - 1">下一页</el-button> </button>
<span class="reader-pager">
<el-input-number v-model="pageInput"
:min="1"
:max="pageCount"
size="small"
controls-position="right"
@change="showPage(Number(pageInput) - 1)" />
<span class="reader-page-total">/ {{ pageCount }} 组</span>
</span> </span>
<!-- 十页及以上--> <button type="button" class="text-button"
<span v-if="max >= 9"> :disabled="currentImage >= links.length - 1"
<el-button @click="jump(index - 1)" :disabled="index === 0">上一页</el-button> @click="step(1)">
1 &lt; 下一张<AppIcon name="chevron-right" :size="15" />
<el-input-number v-model="page" :min="1" :max="max"/> </button>
&lt; {{max}} <button type="button" class="text-button"
<el-button @click="jump(index + 1)" :disabled="index === max - 1">下一页</el-button> :disabled="pageIndex >= pageCount - 1"
<el-button @click="jump(page - 1)" size="large">跳转</el-button> @click="showPage(pageIndex + 1)">
</span> 下一组<AppIcon name="chevrons-right" :size="15" />
</el-dialog> </button>
<span class="reader-foot-spacer"></span>
<span class="reader-hint">{{ currentImage + 1 }} / {{ links.length }}</span>
</footer>
</div>
</Teleport>
</template> </template>
<style scoped>
.reader-layer {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
flex-direction: column;
background: #0f1116;
}
.reader-bar {
flex: none;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #171a21;
border-bottom: 1px solid #262a33;
color: #e8eaee;
}
.reader-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 46px;
border-radius: 4px;
background: #22262f;
color: #6b7280;
overflow: hidden;
flex: none;
}
.reader-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.reader-heading {
min-width: 0;
flex: 1;
}
.reader-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e8eaee;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reader-sub {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin: 3px 0 0;
font-size: 12px;
color: #9aa3b2;
font-variant-numeric: tabular-nums;
}
.reader-tools {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
.reader-tools .icon-button {
background: #1e222a;
border-color: #2f3542;
color: #c6ccd6;
}
.reader-tools .icon-button:hover {
border-color: #4b5563;
color: #fff;
}
/* The segmented control needs a dark variant inside the reader chrome. */
.reader-tools .segmented.is-dark {
background: #1e222a;
border-color: #2f3542;
}
.reader-tools .segmented.is-dark button {
color: #9aa3b2;
}
.reader-tools .segmented.is-dark button[aria-pressed="true"] {
background: #2f3542;
color: #fff;
box-shadow: none;
}
.reader-external {
text-decoration: none;
}
.reader-scroll {
flex: 1;
min-height: 0;
}
.reader-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 16px;
}
.reader-image {
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
width: 100%;
}
.reader-image :deep(.el-image) {
background: #171a21;
border-radius: 6px;
}
.reader-stack.is-height .reader-image :deep(.el-image) {
height: calc(100vh - 168px);
max-width: 100%;
}
.reader-stack.is-width .reader-image :deep(.el-image) {
width: min(1100px, 100%);
}
.reader-stack.is-width .reader-image :deep(.el-image img) {
width: 100%;
height: auto;
}
.reader-error {
margin-top: 8px;
font-size: 13px;
color: #f87171;
}
.reader-image figcaption {
margin-top: 8px;
font-size: 12px;
color: #6b7280;
font-variant-numeric: tabular-nums;
}
.reader-foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #171a21;
border-top: 1px solid #262a33;
}
.reader-foot .text-button {
background: #1e222a;
border-color: #2f3542;
color: #c6ccd6;
}
.reader-foot .text-button:hover:not(:disabled) {
border-color: #4b5563;
color: #fff;
}
.reader-foot .text-button:disabled {
color: #4b5563;
}
.reader-pager {
display: inline-flex;
align-items: center;
gap: 6px;
}
.reader-pager :deep(.el-input-number) {
width: 104px;
}
/* Keep the group picker in the dark palette used by the reader chrome. */
.reader-pager :deep(.el-input__wrapper) {
background: #1e222a;
box-shadow: 0 0 0 1px #2f3542 inset;
}
.reader-pager :deep(.el-input__inner) {
color: #e8eaee;
}
.reader-pager :deep(.el-input-number__increase),
.reader-pager :deep(.el-input-number__decrease) {
background: #262a33;
border-color: #2f3542;
color: #c6ccd6;
}
.reader-pager :deep(.el-input-number__increase:hover),
.reader-pager :deep(.el-input-number__decrease:hover) {
color: #fff;
}
.reader-page-total {
font-size: 13px;
color: #9aa3b2;
font-variant-numeric: tabular-nums;
}
.reader-foot-spacer {
flex: 1;
}
.reader-hint {
font-size: 12px;
color: #9aa3b2;
font-variant-numeric: tabular-nums;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<script setup>
import {computed, ref} from "vue";
import axios from "axios";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import TaskRow from "./TaskRow.vue";
import AppIcon from "./AppIcon.vue";
import {ehThumbnailUrl, fetchGalleries, validateLink} from "lionwebsite-frontend-shared";
const mode = ref("ehentai");
const localMode = ref("keyword");
const localKeyword = ref("");
const linkParam = ref("");
const remoteKeyword = ref("");
const remoteGalleries = ref([]);
const remotePage = ref({});
const remoteLoading = ref(false);
const retryingGids = ref(new Set());
const localResults = computed(() => store.getters.currentTasks || []);
const localTotal = computed(() => (store.getters.filteredTasks || []).length);
const isSearching = computed(() => store.state.isSearch);
const localPage = computed(() => store.state.page);
const localMin = computed(() => store.getters.min);
const localMax = computed(() => store.getters.max);
function gotoLocalPage(value) {
if (value < localMin.value || value > localMax.value) return;
store.commit("_changePage", value);
}
function runLocalSearch() {
if (localKeyword.value.trim() === "") {
ElMessage("请输入查询内容");
return;
}
if (localMode.value === "link") store.commit("_searchLocalByLink", localKeyword.value);
else store.commit("_searchLocalByKeyword", localKeyword.value);
}
function clearLocalSearch() {
store.commit("_searchLocalByKeyword", "");
localKeyword.value = "";
}
// direction 为 "next"/"prev" 时使用后端返回的翻页游标;href 为空表示新关键词。
function queryGalleries(href, direction = null) {
if (href == null && remoteKeyword.value.trim() === "") {
ElMessage("请输入搜索关键词");
return;
}
remoteLoading.value = true;
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;
});
}
// Hand a remote gallery to the shared resolver so its resolution can be picked.
function submitRemote(gallery) {
store.dispatch("queryGalleryTask", gallery.link);
}
// Preview a remote gallery without submitting it first.
function previewRemote(gallery) {
store.dispatch("readOnlineGallery", gallery);
}
function parseLink() {
let link = linkParam.value;
if (!validateLink(link)) {
ElMessage("链接错误");
return;
}
if (link.includes("e-hentai")) link = link.replace("e-hentai", "exhentai");
store.dispatch("queryGalleryTask", link);
}
function thumbFor(path) {
return ehThumbnailUrl(path, store.state.AuthCode);
}
function downloadTask(gallery) {
if (gallery.download) window.open(gallery.download);
else ElMessage({message: "下载地址不存在,请刷新任务后重试", type: "error"});
}
async function retryGallery(gallery) {
if (retryingGids.value.has(gallery.gid)) return;
retryingGids.value.add(gallery.gid);
try {
await store.dispatch("retryGallery", gallery.gid);
} finally {
retryingGids.value.delete(gallery.gid);
}
}
</script>
<template>
<div class="view">
<header class="view-bar">
<h1 class="view-title">搜索</h1>
<span class="view-count" v-if="mode === 'ehentai' && remoteGalleries.length">本页 {{ remoteGalleries.length }} 个结果</span>
<span class="view-bar-spacer"></span>
</header>
<div class="filter-bar">
<div class="segmented is-quiet" role="group" aria-label="搜索来源">
<button type="button" :aria-pressed="mode === 'ehentai'" @click="mode = 'ehentai'">E站搜索</button>
<button type="button" :aria-pressed="mode === 'local'" @click="mode = 'local'">本地任务</button>
<button type="button" :aria-pressed="mode === 'link'" @click="mode = 'link'">解析链接</button>
</div>
</div>
<div class="list-scroll">
<template v-if="mode === 'ehentai'">
<div class="search-bar">
<el-input v-model="remoteKeyword"
placeholder="在 E站搜索画廊"
clearable
@keydown.enter="queryGalleries(null)" />
<el-button type="primary" :loading="remoteLoading" @click="queryGalleries(null)">搜索</el-button>
</div>
<div class="remote-row" v-for="gallery in remoteGalleries" :key="gallery.link">
<span class="task-thumb">
<img :src="thumbFor(gallery.thumbnailUrl)" alt="" loading="lazy">
</span>
<div class="task-body">
<p class="task-title" :title="gallery.name">{{ gallery.name }}</p>
<p class="task-meta">
<span>{{ gallery.page }} 页</span>
<span>{{ gallery.type }}</span>
<span>{{ (gallery.uploadTime || "").split(" ")[0] }}</span>
</p>
</div>
<div class="task-acts">
<button type="button" class="text-button" @click="previewRemote(gallery)">
<AppIcon name="eye" :size="15" />在线看
</button>
<el-button size="small" type="primary" @click="submitRemote(gallery)">提交</el-button>
</div>
</div>
<p v-if="!remoteGalleries.length && !remoteLoading" class="empty-state">输入关键词开始搜索</p>
<div class="pager-row" v-if="remoteGalleries.length">
<el-button size="small" :disabled="remotePage.first === undefined" @click="queryGalleries(remotePage.first)">首页</el-button>
<el-button size="small" :disabled="remotePage.previous === undefined" @click="queryGalleries(remotePage.previous, 'prev')">上一页</el-button>
<el-button size="small" :disabled="remotePage.next === undefined" @click="queryGalleries(remotePage.next, 'next')">下一页</el-button>
<el-button size="small" :disabled="remotePage.last === undefined" @click="queryGalleries(remotePage.last)">尾页</el-button>
</div>
</template>
<template v-else-if="mode === 'local'">
<div class="search-bar">
<div class="segmented is-quiet" role="group" aria-label="本地查询方式">
<button type="button" :aria-pressed="localMode === 'keyword'" @click="localMode = 'keyword'">关键字</button>
<button type="button" :aria-pressed="localMode === 'link'" @click="localMode = 'link'">链接</button>
</div>
<el-input v-model="localKeyword"
:placeholder="localMode === 'keyword' ? '在我的下载里搜索' : '粘贴已下载任务的链接'"
clearable
@keydown.enter="runLocalSearch" />
<el-button type="primary" @click="runLocalSearch">搜索</el-button>
</div>
<template v-if="isSearching">
<p class="hint">
匹配到 {{ localTotal }} 项
<button type="button" class="hint-action" @click="clearLocalSearch">清除</button>
</p>
<TaskRow v-for="gallery in localResults"
:key="gallery.gid"
:gallery="gallery"
:retrying="retryingGids.has(gallery.gid)"
@open="store.commit('_selectGallery', $event)"
@download="downloadTask"
@retry="retryGallery" />
<!-- 命中项可能多于每页条数,必须给出翻页入口,否则后面的结果看不到。 -->
<div class="pager-row" v-if="localMax > 1">
<el-button size="small" :disabled="localPage <= localMin" @click="gotoLocalPage(localPage - 1)">上一页</el-button>
<span class="hint">第 {{ localPage }} / {{ localMax }} 页</span>
<el-button size="small" :disabled="localPage >= localMax" @click="gotoLocalPage(localPage + 1)">下一页</el-button>
</div>
</template>
<p v-else class="empty-state">输入关键字或链接在已有任务里查找</p>
</template>
<template v-else>
<div class="search-bar">
<el-input v-model="linkParam"
placeholder="粘贴 e-hentai 画廊链接"
clearable
@keydown.enter="parseLink" />
<el-button type="primary" @click="parseLink">解析</el-button>
</div>
<p class="hint">解析成功后可直接选分辨率提交下载</p>
</template>
</div>
</div>
</template>
+229
View File
@@ -0,0 +1,229 @@
<script setup>
import {computed, onMounted, ref} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import AppIcon from "./AppIcon.vue";
const weekUsed = computed(() => store.state.weekUsed);
const isAdmin = computed(() => store.state.isAdmin);
const username = computed(() => store.state.username || "—");
const isDark = ref(document.documentElement.classList.contains("dark"));
const followSystem = ref(false);
const lengthPerPage = ref(30);
const category = computed({
get: () => store.state.category,
set: value => {
store.commit("_setCategory", value);
localStorage.setItem("category", value);
},
});
const sortType = computed({
get: () => store.state.sortType,
set: value => {
store.commit("_setSortType", value);
localStorage.setItem("sortType", value);
},
});
const galleryNameType = computed({
get: () => store.state.galleryNameType,
set: value => {
store.commit("_setGalleryNameType", value);
localStorage.setItem("galleryNameType", value);
},
});
// Auth code editing stays in a dialog: it is a destructive, infrequent action.
const isAlterAuthCode = ref(false);
const newAuthCode = ref("");
const tempAuthCode = ref("");
const realAuthCode = computed(() => store.state.AuthCode);
function applyTheme() {
localStorage.setItem("darkConfig", JSON.stringify({followSystem: followSystem.value}));
const dark = followSystem.value
? window.matchMedia("(prefers-color-scheme: dark)").matches
: isDark.value;
document.documentElement.classList.toggle("dark", dark);
isDark.value = dark;
}
function toggleDark(value) {
followSystem.value = false;
isDark.value = value;
applyTheme();
}
function toggleFollowSystem(value) {
followSystem.value = value;
applyTheme();
}
function saveLengthPerPage(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 30) {
ElMessage("分页页数设置错误,范围 1~30");
lengthPerPage.value = store.state.lengthPerPage || 30;
return;
}
store.state.lengthPerPage = parsed;
localStorage.setItem("lengthPerPage", String(parsed));
}
function alterAuthCode() {
if (newAuthCode.value.trim() === "" || newAuthCode.value !== tempAuthCode.value) {
ElMessage("请检查授权码输入是否错误");
return;
}
store.dispatch("alterAuthCode", newAuthCode.value);
isAlterAuthCode.value = false;
newAuthCode.value = "";
tempAuthCode.value = "";
}
function deleteAuthCode() {
localStorage.removeItem("auth");
ElMessage("删除授权码完成");
}
function reconnect() {
store.dispatch("reconnect");
}
function resetUndone() {
store.dispatch("resetUndone").then();
}
function refreshUsage() {
store.dispatch("loadWeekUsedAmount");
}
onMounted(() => {
const darkConfigStr = localStorage.getItem("darkConfig");
if (darkConfigStr !== null) {
try {
followSystem.value = Boolean(JSON.parse(darkConfigStr).followSystem);
} catch {
followSystem.value = false;
}
}
lengthPerPage.value = Number(store.state.lengthPerPage) || 30;
});
</script>
<template>
<div class="view">
<header class="view-bar">
<h1 class="view-title">设置</h1>
<span class="view-count">{{ username }}</span>
<span class="view-bar-spacer"></span>
</header>
<div class="settings-scroll">
<section class="settings-group">
<h2 class="section-title">本周用量</h2>
<div class="usage-line">
<span class="usage-value">{{ weekUsed.weekUsedAmount ?? "—" }}</span>
<span class="usage-note">上次重置 {{ weekUsed.lastResetAmountTime ?? "—" }}</span>
<span class="view-bar-spacer"></span>
<button type="button" class="text-button" @click="refreshUsage">
<AppIcon name="refresh" :size="15" />刷新
</button>
</div>
</section>
<section class="settings-group">
<h2 class="section-title">外观</h2>
<div class="setting-row">
<span>夜间模式</span>
<el-switch :model-value="isDark" @update:model-value="toggleDark" />
</div>
<div class="setting-row">
<span>跟随系统</span>
<el-switch :model-value="followSystem" @update:model-value="toggleFollowSystem" />
</div>
</section>
<section class="settings-group">
<h2 class="section-title">默认视图</h2>
<div class="setting-row">
<span>分类</span>
<el-select v-model="category" style="width: 180px">
<el-option label="我的下载" value="myDownload" />
<el-option label="我的收藏" value="myCollect" />
<el-option label="全部" value="total" />
</el-select>
</div>
<div class="setting-row">
<span>排序方式</span>
<el-select v-model="sortType" style="width: 180px">
<el-option label="简洁名字" value="shortName" />
<el-option label="名字" value="name" />
<el-option label="任务创建时间" value="createTime" />
</el-select>
</div>
<div class="setting-row">
<span>显示类型</span>
<el-select v-model="galleryNameType" style="width: 180px">
<el-option label="简洁名字" value="shortName" />
<el-option label="名字" value="name" />
</el-select>
</div>
<div class="setting-row">
<span>在线预览每页图片数量</span>
<el-input-number :model-value="lengthPerPage"
:min="1"
:max="30"
:step="1"
style="width: 160px"
@change="saveLengthPerPage" />
</div>
</section>
<section class="settings-group">
<h2 class="section-title">授权</h2>
<div class="setting-row">
<span>当前授权码</span>
<span class="mono-value">{{ realAuthCode }}</span>
</div>
<div class="setting-row">
<span>管理授权码</span>
<span class="setting-actions">
<el-button size="small" @click="isAlterAuthCode = true">修改授权码</el-button>
<el-button size="small" @click="deleteAuthCode">删除本地授权码</el-button>
</span>
</div>
</section>
<section class="settings-group">
<h2 class="section-title">系统</h2>
<div class="setting-row">
<span>下载节点</span>
<span class="setting-actions">
<el-button size="small" @click="reconnect">重连节点</el-button>
<el-button v-if="isAdmin" size="small" type="danger" plain @click="resetUndone">重置任务</el-button>
</span>
</div>
</section>
</div>
<el-dialog title="修改授权码" v-model="isAlterAuthCode" width="420px">
<el-form label-position="top">
<el-form-item label="当前授权码">
<span class="mono-value">{{ realAuthCode }}</span>
</el-form-item>
<el-form-item label="新的授权码">
<el-input v-model="newAuthCode" show-password />
</el-form-item>
<el-form-item label="再次输入授权码">
<el-input v-model="tempAuthCode" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="isAlterAuthCode = false">取消</el-button>
<el-button type="primary" @click="alterAuthCode">提交</el-button>
</template>
</el-dialog>
</div>
</template>
-293
View File
@@ -1,293 +0,0 @@
<template>
<div class="side-table-wrapper">
<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"
:height="tableHeight"
:empty-text="emptyText"
:row-key="gallery=>gallery.gid"
class="table"
@cellMouseEnter="showThumbnail"
>
<el-table-column type="expand">
<template #default="props">
<div class="detail-info">
名字:{{ props.row.name}} <br>
链接:<el-link :href="props.row.link">链接</el-link> <br>
语言:{{props.row.language}} <br>
页数:{{props.row.pages}} <br>
文件大小:{{props.row.fileSize}}<br>
分辨率:{{props.row.resolution}}<br>
任务创建时间:{{props.row.createTimeDisplay}}<br>
<span v-show="isLion">
downloader:{{props.row.downloader}}
</span>
</div>
<el-button @click="deleteGallery(props.row.gid)" :disabled="props.row.status !== '下载完成'">删除</el-button>
</template>
</el-table-column>
<el-table-column label="名字" min-width="200">
<template #default="scoped">
{{galleryNameType === 'shortName' ? scoped.row.shortName: scoped.row.name}}
</template>
</el-table-column>
<el-table-column label="操作" width="360">
<template #default="scoped">
<span>
<el-button @click="downloadTask(scoped.row.download)" :disabled="scoped.row.status !== '下载完成'">下载</el-button>
<el-button @click="changeGalleryCollect(scoped.row.gid, scoped.row.isCollect)">{{scoped.row.isCollect ? '取消收藏' : '收藏'}}</el-button>
<el-button @click="readOnlineGallery(scoped.row)">在线看</el-button>
<el-button v-if="scoped.row.status !== '下载完成'"
@click="retryGallery(scoped.row.gid)"
:loading="retryingGids.has(scoped.row.gid)">
重试
</el-button>
</span>
</template>
</el-table-column>
<el-table-column label="进度" width="80">
<template #default="scoped">
{{ scoped.row.progress }}
</template>
</el-table-column>
</el-table>
<el-row class="pageChanger">
<el-col v-if="username !== 'test'">
<el-select v-model="category" @change="changeCategory">
<template #prefix>
分类
</template>
<el-option value="myCollect" label="我的收藏"/>
<el-option value="myDownload" label="我的下载"/>
<el-option value="total" label="全部"/>
</el-select>
<el-select v-model="sortType" @change="changeSortType" style="width: 170px">
<template #prefix>
排序
</template>
<el-option value="name" label="名字"/>
<el-option value="shortName" label="简洁名字"/>
<el-option value="createTime" label="任务创建时间"/>
</el-select>
<el-select v-model="galleryNameType" @change="changeGalleryNameType">
<template #prefix>
显示
</template>
<el-option value="name" label="名字"/>
<el-option value="shortName" label="简洁名字"/>
</el-select>
</el-col>
<el-col>
<el-button @click="toMin">{{min}}</el-button>
<el-button @click="previous">-</el-button>
<el-input v-model="targetPage"
@change="changePage"
v-show="isEditingPage"
@blur="reverseEditMode"
class="page"
ref="inputNode"></el-input>
<span @click="reverseEditMode" v-show="!isEditingPage" class="page">{{page}}</span>
<el-button @click="next">+</el-button>
<el-button @click="toMax">{{max}}</el-button>
</el-col>
</el-row>
</div>
<OnlineReader/>
<span v-show="!loadComplete" class="loading-text">请输入授权码后再查看</span>
</div>
</template>
<script setup>
import store from "../store";
import {computed, ref, watch} from "vue";
import OnlineReader from "./OnlineReader.vue";
//输入
let inputNode = ref(null)
//是否正在编辑页数
let isEditingPage = ref(false)
let category = computed(() => {
return store.state.category
})
let galleryNameType = computed(() => {
return store.state.galleryNameType
})
let sortType = computed(() => {
return store.state.sortType
})
let targetPage = ref(1) // 当前页数
let username = computed(() => {
return store.state.username
})
//防抖计时器
let debounceTimer = 0
let retryingGids = ref(new Set())
//是否加载完成
let loadComplete = computed(() => {
return store.state.loadComplete
})
let currentTasks = computed(() => {
return store.getters.currentTasks ? store.getters.currentTasks: null
})
let tableHeight = computed(() => 'calc(100vh - 130px)')
let min = computed(() => {
return store.getters.min
})
let max = computed(() => {
return store.getters.max
})
let page = computed(() => {
return store.state.page
})
let isLion = computed(() => {
return store.state.userId === 3
})
// 同步 store.page → local targetPage,并在 page 超出 max 时修正
watch(page, (newPage) => {
targetPage.value = newPage
})
watch(max, (newMax) => {
if(targetPage.value > newMax)
store.commit("_changePage", newMax)
})
let emptyText = computed(() => {
let action = category.value === 'myDownload' ? '下载': '收藏'
return '您未' + action + "过"
})
//翻页
function next() {
if(targetPage.value < max.value) {
targetPage.value++
store.commit("_changePage", targetPage.value)
}
}
function previous() {
if(targetPage.value > min.value) {
targetPage.value--
store.commit("_changePage", targetPage.value)
}
}
function toMax() {
store.commit("_changePage", max.value)
targetPage.value = max.value
}
function toMin(){
store.commit("_changePage", min.value)
targetPage.value = min.value
}
function changePage(){
if(targetPage.value >= min.value && targetPage.value <= max.value)
store.commit("_changePage", targetPage.value)
}
function reverseEditMode(){
isEditingPage.value = !isEditingPage.value
if(isEditingPage.value){
inputNode.value.focus()
}
targetPage.value = page.value
}
//改变展示类型
function changeCategory(value){
store.commit("_setCategory", value)
}
function changeGalleryNameType(value){
store.commit("_setGalleryNameType", value)
}
function changeSortType(value){
store.commit("_setSortType", value)
}
//收藏
function changeGalleryCollect(gid, isCollect){
if(isCollect)
store.dispatch("disCollectGallery", gid)
else
store.dispatch("collectGallery", gid)
}
//下载,删除,在线看
function downloadTask(link){
window.open(link)
}
function deleteGallery(gid){
store.dispatch("deleteGallery", gid)
}
async function retryGallery(gid){
if(retryingGids.value.has(gid))
return
retryingGids.value.add(gid)
try {
await store.dispatch("retryGallery", gid)
} finally {
retryingGids.value.delete(gid)
}
}
function readOnlineGallery(gallery){
store.dispatch("readOnlineGallery", gallery)
}
//显示缩略图
function showThumbnail(gallery){
if(gallery.status === "下载完成" && 'thumb_link' in gallery) {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
store.commit("_changeThumbnailGallery", gallery)
}, 500)
}
}
</script>
<style scoped>
.side-table-wrapper {
height: 100%;
padding: 16px;
box-sizing: border-box;
}
.loading-text {
display: block;
text-align: center;
padding-top: 200px;
color: var(--text-secondary, #86909c);
font-size: 14px;
}
.pageChanger {
margin-top: 24px;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.page {
display: inline-block;
width: 50px;
cursor: pointer;
text-align: center;
}
.detail-info {
line-height: 1.8;
}
.detail-info > span {
display: block;
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script setup>
import {computed} from "vue";
import AppIcon from "./AppIcon.vue";
const props = defineProps({
gallery: {type: Object, required: true},
nameType: {type: String, default: "shortName"},
selected: {type: Boolean, default: false},
retrying: {type: Boolean, default: false},
});
const emit = defineEmits(["open", "download", "retry", "collect"]);
const title = computed(() =>
props.nameType === "shortName" ? props.gallery.shortName : props.gallery.name);
const statusClass = computed(() => {
const status = props.gallery.status;
if (status === "下载完成") return "is-done";
if (status === "下载中" || status === "压缩中") return "is-running";
return "is-waiting";
});
const percent = computed(() => {
const task = props.gallery;
if (task.status !== "下载中" || !task.pages) return null;
const done = Number(task.proceeding) || 0;
return Math.min(100, Math.max(0, Math.round((done / task.pages) * 100)));
});
function open() {
emit("open", props.gallery);
}
</script>
<template>
<article class="task-row" :class="{'is-selected': selected}" @click="open">
<span class="task-thumb">
<img v-if="gallery.thumb_link" :src="gallery.thumb_link" alt="" loading="lazy">
<AppIcon v-else name="image" :size="16" />
</span>
<div class="task-body">
<p class="task-title">{{ title }}</p>
<p class="task-meta">
<span>{{ gallery.pages }} 页</span>
<span>{{ gallery.language }}</span>
<span>{{ gallery.fileSize }}</span>
</p>
<div v-if="percent !== null" class="task-progress">
<el-progress :percentage="percent" :stroke-width="4" :show-text="false" />
<span class="progress-value">{{ percent }}% · {{ gallery.proceeding }}/{{ gallery.pages }} 页</span>
</div>
</div>
<div class="task-side">
<span class="status-pill" :class="statusClass">{{ gallery.status }}</span>
<div class="task-acts" @click.stop>
<button type="button"
class="icon-button"
title="下载文件"
aria-label="下载文件"
:disabled="gallery.status !== '下载完成'"
@click="emit('download', gallery)">
<AppIcon name="download" :size="15" />
</button>
<button type="button"
class="icon-button"
:class="{'is-on': gallery.isCollect}"
:title="gallery.isCollect ? '取消收藏' : '收藏'"
:aria-label="gallery.isCollect ? '取消收藏' : '收藏'"
@click="emit('collect', gallery)">
<AppIcon name="star" :size="15" />
</button>
<button v-if="gallery.status !== '下载完成'"
type="button"
class="icon-button"
title="重试任务"
aria-label="重试任务"
:disabled="retrying"
@click="emit('retry', gallery)">
<AppIcon name="refresh" :size="15" />
</button>
</div>
</div>
</article>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup>
import {computed, ref, watch} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import TaskRow from "./TaskRow.vue";
import AppIcon from "./AppIcon.vue";
const props = defineProps({
selectedGid: {type: [Number, String], default: null},
});
const emit = defineEmits(["open"]);
const retryingGids = ref(new Set());
const refreshing = ref(false);
const isEditingPage = ref(false);
const inputNode = ref(null);
const currentTasks = computed(() => store.getters.currentTasks || []);
const total = computed(() => (store.getters.filteredTasks || []).length);
const isSearching = computed(() => store.state.isSearch);
const category = computed({
get: () => store.state.category,
set: value => store.commit("_setCategory", value),
});
const sortType = computed({
get: () => store.state.sortType,
set: value => store.commit("_setSortType", value),
});
const galleryNameType = computed({
get: () => store.state.galleryNameType,
set: value => store.commit("_setGalleryNameType", value),
});
const isAdmin = computed(() => store.state.isAdmin);
const downloaderFilter = computed({
get: () => store.state.downloaderFilter,
set: value => store.commit("_setDownloaderFilter", value),
});
// Options come from the current category, so the dropdown can never offer a
// downloader that would filter the visible list down to nothing.
const downloaderOptions = computed(() => {
const seen = new Map();
;(store.getters.categoryGallery || []).forEach(task => {
if (task.downloaderName && !seen.has(task.downloader))
seen.set(task.downloader, task.downloaderName);
});
return [...seen].map(([id, name]) => ({id, name}));
});
const isConnected = computed(() => store.state.connectionStatus === "connected");
const connectionLabel = computed(() => ({
connected: "进度已连接",
connecting: "正在连接进度",
reconnecting: "连接中断,正在重连",
disconnected: "进度连接已断开",
})[store.state.connectionStatus]);
const emptyText = computed(() => {
if (isSearching.value) return "没有匹配的任务";
if (category.value === "myDownload") return "您未下载过";
if (category.value === "myCollect") return "您未收藏过";
return "暂无任务";
});
const min = computed(() => store.getters.min);
const max = computed(() => store.getters.max);
const page = computed(() => store.state.page);
const targetPage = ref(1);
watch(page, value => {
targetPage.value = value;
}, {immediate: true});
function goto(value) {
if (value < min.value || value > max.value) return;
store.commit("_changePage", value);
}
function reverseEditMode() {
isEditingPage.value = !isEditingPage.value;
if (isEditingPage.value) inputNode.value?.focus();
targetPage.value = page.value;
}
function downloadTask(gallery) {
if (gallery.download) window.open(gallery.download);
else ElMessage({message: "下载地址不存在,请刷新任务后重试", type: "error"});
}
function toggleCollect(gallery) {
if (gallery.isCollect) store.dispatch("disCollectGallery", gallery.gid);
else store.dispatch("collectGallery", gallery.gid);
}
async function retryGallery(gallery) {
if (retryingGids.value.has(gallery.gid)) return;
retryingGids.value.add(gallery.gid);
try {
await store.dispatch("retryGallery", gallery.gid);
} finally {
retryingGids.value.delete(gallery.gid);
}
}
async function refreshTasks() {
if (refreshing.value) return;
refreshing.value = true;
try {
await store.dispatch("updateGalleryTasks");
} finally {
refreshing.value = false;
}
}
function clearSearch() {
store.commit("_searchLocalByKeyword", "");
}
</script>
<template>
<div class="view">
<header class="view-bar">
<h1 class="view-title">任务</h1>
<span class="view-count">{{ total }} 项</span>
<span class="connection" :class="{'is-connected': isConnected}" role="status" :title="connectionLabel">
<i class="connection-dot"></i>{{ connectionLabel }}
</span>
<span class="view-bar-spacer"></span>
<button type="button"
class="text-button"
:disabled="refreshing"
@click="refreshTasks">
<AppIcon name="refresh" :size="15" />刷新
</button>
</header>
<div class="filter-bar">
<div class="filter">
<span class="filter-label">分类</span>
<el-select v-model="category" class="filter-select filter-select-category" size="small">
<el-option value="myDownload" label="我的下载" />
<el-option value="myCollect" label="我的收藏" />
<el-option value="total" label="全部" />
</el-select>
</div>
<div class="filter">
<span class="filter-label">排序</span>
<el-select v-model="sortType" class="filter-select filter-select-sort" size="small">
<el-option value="shortName" label="简洁名字" />
<el-option value="name" label="名字" />
<el-option value="createTime" label="任务创建时间" />
</el-select>
</div>
<div class="filter">
<span class="filter-label">显示</span>
<el-select v-model="galleryNameType" class="filter-select filter-select-name" size="small">
<el-option value="shortName" label="简洁名字" />
<el-option value="name" label="名字" />
</el-select>
</div>
<div v-if="isAdmin" class="filter">
<span class="filter-label">下载人</span>
<el-select v-model="downloaderFilter"
class="filter-select filter-select-downloader"
size="small"
clearable
placeholder="全部">
<el-option v-for="item in downloaderOptions"
:key="item.id"
:value="item.id"
:label="item.name" />
</el-select>
</div>
<button v-if="isSearching" type="button" class="chip-button" @click="clearSearch">
清除搜索
</button>
<span class="view-bar-spacer"></span>
<div class="pager">
<button type="button" class="icon-button" title="第一页" :disabled="page <= min" @click="goto(min)">
<AppIcon name="chevrons-left" :size="15" />
</button>
<button type="button" class="icon-button" title="上一页" :disabled="page <= min" @click="goto(page - 1)">
<AppIcon name="chevron-left" :size="15" />
</button>
<span class="pager-readout">
<el-input v-if="isEditingPage"
v-model="targetPage"
class="page-input"
size="small"
ref="inputNode"
@change="goto(Number(targetPage))"
@blur="reverseEditMode" />
<button v-else type="button" class="page-current" @click="reverseEditMode">{{ page }}</button>
<span class="page-total">/ {{ max }}</span>
</span>
<button type="button" class="icon-button" title="下一页" :disabled="page >= max" @click="goto(page + 1)">
<AppIcon name="chevron-right" :size="15" />
</button>
<button type="button" class="icon-button" title="最后一页" :disabled="page >= max" @click="goto(max)">
<AppIcon name="chevrons-right" :size="15" />
</button>
</div>
</div>
<div class="list-scroll">
<p v-if="currentTasks.length === 0" class="empty-state">{{ emptyText }}</p>
<TaskRow v-for="gallery in currentTasks"
:key="gallery.gid"
:gallery="gallery"
:name-type="galleryNameType"
:selected="String(gallery.gid) === String(selectedGid)"
:retrying="retryingGids.has(gallery.gid)"
@open="emit('open', $event)"
@download="downloadTask"
@collect="toggleCollect"
@retry="retryGallery" />
</div>
</div>
</template>
+11 -2
View File
@@ -1,9 +1,18 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.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-plus/dist/index.css'
import element from "element-plus"
import "./reset.css" import "./reset.css"
import 'element-plus/theme-chalk/dark/css-vars.css' import 'element-plus/theme-chalk/dark/css-vars.css'
import "./styles/theme.css" import "./styles/theme.css"
createApp(App).use(element).mount('#app') // 只注册本仓库用到的组件,避免把整个 Element Plus 打进入口包。
// ElMessage 是函数式 API,按需在组件里 import 即可。
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 -543
View File
@@ -2,11 +2,7 @@ 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" import {createGalleryStore} from "lionwebsite-frontend-shared"
let taskSocket
let taskRefreshVersion = 0
const BaseUrl = "https://downloader.lionwebsite.xyz/"
const GalleryManageUrl = BaseUrl + "GalleryManage"
// Axios 全局错误处理 // Axios 全局错误处理
axios.interceptors.response.use( axios.interceptors.response.use(
@@ -17,541 +13,10 @@ axios.interceptors.response.use(
} }
) )
const actions = { // 桌面端与移动端共用同一份任务业务核心,只在分页交互上不同。
reconnect(context) { export default new vuex.Store(createGalleryStore({
axios.post(GalleryManageUrl + "/reconnect", null, { variant: "desktop",
params: { axios,
AuthCode: context.state.AuthCode qs,
} Message: ElMessage,
}).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){
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
},
_authed(state, data){
state.AuthCode = data.AuthCode
state.userId = data.userId
state.username = data.username
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 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
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
name = state.sortType === "shortName" ? tasks[i].shortName: tasks[i].name
break
}
if(!name)
ElMessage("未找到此任务")
else
ElMessage("已跳转到该任务所在页数,任务名:" + name)
},
_searchLocalByKeyword(state, keyword){
state.searchTask.splice(0)
if(keyword.trim() !== '') {
state.page = 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
confirmCurrentTask(state)
sortTasks(state)
},
_setSortType(state, sortType){
state.sortType = sortType
sortTasks(state)
},
_setGalleryNameType(state, galleryNameType){
state.galleryNameType = galleryNameType
},
_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;
},
_changeThumbnailGallery(state, gallery){
state.thumbnailGallery = gallery
}
}
const state = {
connectionStatus: "disconnected", // live task updates
totalGalleryTask: [], //存放数据的数组
chosenGallery: false, //准备下载
thumbnailGallery: {}, //缩略图
collectGallery: [], //收藏
downloadGallery: [], //下载
isSearch: false, //用于决定是否显示搜索结果
readingGallery: {'name': '', 'images': []}, //在线看
isReading: false, //是否正在看
currentGid: "", //当前GID
lengthPerPage: 0, //在线预览每页图片数量
page: 1, //当前页数
length: 10, //每页能有多少个链接
defaultLength: 7, //默认个数
shortLength: 10, //简洁个数
userId: -1, //用户id
username: "", //用户名
isAuth: false, //是否授权
AuthCode: '', //授权码
loadComplete: false, //是否加载完成
galleryRefreshTimer: 0, //更新计时器id
isInclude: false, //是否搜索到任务
searchTask: [], //搜索到的任务
category: 'myDownload', //分类 myDownload myCollect total
sortType:'shortName', //排序类型 shortName name createTime
galleryNameType: 'shortName', //名字类型 shortName name
currentTasks: [], //当前任务
weekUsed: {}, //每周用量
}
const getters = {
currentTasks(state){
if(state.isSearch)
return state.searchTask.slice((state.page - 1) * state.length, state.page * state.length)
else
return state.currentTasks.slice((state.page - 1) * state.length, state.page * state.length)
},
min(){
return 1
},
max(state){
let max = 0
let tasks
if(state.isSearch)
tasks = state.searchTask
else
tasks = state.currentTasks
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 getShortname(name){
if(name === null){
console.log(name)
return null
}
if (!name.includes("[")) return name
// 截取最后一个 [ 之前的部分,然后移除所有 [...] 和 (...) 标签
name = name.substring(0, name.lastIndexOf("["))
return name.replace(/\s*\[[^\]]*\]\s*/g, '').replace(/\s*\([^\)]*\)\s*/g, '').trim()
}
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 = ['已提交', '下载中', '等待压缩', '压缩中', '下载完成']
+1033 -89
View File
File diff suppressed because it is too large Load Diff
-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')
}
}
}
-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
}
+175
View File
@@ -0,0 +1,175 @@
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 的规范化保持一致:
// normalizeTask 已经代理过 thumb_link,组件再用 ehThumbnailUrl 套一层就会把
// 代理地址当成原始 path 再代理一次。这里直接执行仓库里的 .vue 源码来盯住它。
registerHooks({
load(url, context, nextLoad) {
if (!url.endsWith(".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: "panel-test", inlineTemplate: true, isProd: false}).content,
}
},
})
const {default: DetailPanel} = await import("../src/components/DetailPanel.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 mountPanel() {
const app = createRenderer(nodeOps()).createApp(DetailPanel)
// 真实入口在 main.js 里全局注册 Element Plus;这里只需要占位节点。
app.component("el-button", {render: () => null})
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 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(" ")
}
const previewImage = root => descendants(root).find(node => node.tag === "img") || null
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.selectedGallery = null
store.state.chosenGallery = false
}
test("已规范化的 thumb_link 直接作为 src,外层 path 仍是原始图片路径", async () => {
reset()
const root = mountPanel()
const proxied = ehThumbnailUrl("/t/9.jpg", "auth")
store.state.chosenGallery = {
...remoteGallery(), thumb_link: proxied, thumbnailUrl: "https://example.org/raw.jpg",
}
await nextTick()
const image = previewImage(root)
assert.ok(image, "远端面板必须渲染缩略图")
assert.equal(image.attrs.src, proxied)
assert.equal(image.attrs.src.split("ehThumbnail").length - 1, 1)
assert.equal(new URL(image.attrs.src).searchParams.get("path"), "/t/9.jpg")
})
test("只有原始上游地址时才在这里代理一次", async () => {
reset()
const root = mountPanel()
store.state.chosenGallery = {...remoteGallery(), thumbnailUrl: "https://example.org/raw.jpg"}
await nextTick()
const image = previewImage(root)
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")
})
test("清空 chosenGallery 后远端面板收起,不会残留上一张缩略图", async () => {
reset()
const root = mountPanel()
store.state.chosenGallery = {...remoteGallery(), thumb_link: ehThumbnailUrl("/t/9.jpg", "auth")}
await nextTick()
assert.ok(previewImage(root), "先确认远端面板已打开")
store.state.chosenGallery = false
await nextTick()
assert.equal(previewImage(root), null)
assert.match(textOf(root), /选择一条任务查看详情/)
})
test("列表任务已带代理缩略图,详情面板不再重复代理", async () => {
reset()
const root = mountPanel()
store.commit("_updateGalleryTasks", [{
gid: 3, name: "任务", status: "已提交", pages: 1, proceeding: 0,
thumb_link: "/t/3.jpg", createTime: 1, downloader: 7,
}])
store.commit("_selectGallery", store.state.totalGalleryTask[0])
await nextTick()
const image = previewImage(root)
assert.ok(image)
assert.equal(new URL(image.attrs.src).searchParams.get("path"), "/t/3.jpg")
assert.equal(image.attrs.src.split("ehThumbnail").length - 1, 1)
})
-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([]), [])
})
+37
View File
@@ -0,0 +1,37 @@
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"})
})
+108 -78
View File
@@ -1,95 +1,125 @@
import test from 'node:test' import test from "node:test"
import assert from 'node:assert/strict' import assert from "node:assert/strict"
import fs from 'node:fs'
import vm from 'node:vm'
function fixture() { // 直接加载真实的 store 模块;在 Node 里 import 到的就是下发到浏览器的那份配置。
let code = fs.readFileSync(new URL('../src/store/index.js', import.meta.url), 'utf8') const store = (await import("../src/store/index.js")).default
.replace(/^import .*$/gm, '')
.replace('export default new vuex.Store(', 'globalThis.store = new vuex.Store(') const tasks = () => [
const messages = [] {gid: 1, name: "beta [tag]", status: "下载完成", pages: 10, proceeding: 10,
const showMessage = message => messages.push(message) thumb_link: "", createTime: 2, downloader: 7, isCollect: true},
showMessage.error = message => messages.push(message) {gid: 2, name: "alpha", status: "下载中", pages: 10, proceeding: 5,
const axios = {interceptors: {response: {use() {}}}} thumb_link: "", createTime: 1, downloader: 7, isCollect: false},
const context = vm.createContext({axios, vuex: {Store: function (config) {Object.assign(this, config)}}, {gid: 3, name: "gamma", status: "已提交", pages: 10, proceeding: 0,
ElMessage: showMessage, URLSearchParams, console, thumb_link: "", createTime: 3, downloader: 8, isCollect: false},
qs: {stringify: () => ''}}) ]
vm.runInContext(code, context)
const store = context.store function reset() {
const api = {state: store.state, store.state.AuthCode = "test-auth"
commit: (name, data) => store.mutations[name](store.state, data), store.state.userId = 7
dispatch: (name, data) => Promise.resolve(store.actions[name](api, data))} store.state.category = "total"
return {store, messages, axios, api} store.state.sortType = "createTime"
store.state.length = 2
store.state.page = 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', () => { test("module exposes one normalized task list plus derived getters", () => {
const {store, messages} = fixture() reset()
store.state.currentTasks = [{gid: 1}, {gid: 2}, {gid: 12345, name: 'sample', shortName: 'sample'}] assert.equal(store.state.totalGalleryTask.length, 3)
assert.equal("collectGallery" in store.state, false)
assert.equal("downloadGallery" in store.state, false)
assert.equal(store.getters.downloadGallery.length, 2)
assert.equal(store.getters.collectGallery.length, 1)
})
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("local search keeps every match reachable through the pager", () => {
reset()
store.state.length = 2 store.state.length = 2
store.mutations._searchLocalByLink(store.state, 'https://example.org/g/12345/key/') store.commit("_searchLocalByKeyword", "a")
assert.equal(store.state.page, 2) assert.equal(store.state.isSearch, true)
assert.match(messages[0], /已跳转/) assert.equal(store.getters.filteredTasks.length, 3)
assert.equal(store.getters.max, 2)
assert.equal(store.getters.currentTasks.length, 2)
store.commit("_changePage", 2)
assert.equal(store.getters.currentTasks.length, 1)
}) })
test('missing gid still reports no match', () => { test("a search matching more than one page exposes every page", () => {
const {store, messages} = fixture() store.state.AuthCode = "auth"
store.state.currentTasks = [{gid: 12345}] store.state.userId = 7
store.mutations._searchLocalByLink(store.state, 'https://example.org/g/999/key/') store.state.category = "total"
assert.equal(messages[0], '未找到此任务') const many = Array.from({length: 25}, (_, index) => ({
gid: index + 1, name: "match " + index, status: "已提交",
pages: 1, proceeding: 0, thumb_link: "", createTime: index, downloader: 7,
}))
store.state.length = 10
store.commit("_updateGalleryTasks", many)
store.commit("_searchLocalByKeyword", "match")
assert.equal(store.getters.filteredTasks.length, 25)
assert.equal(store.getters.max, 3)
assert.equal(store.getters.currentTasks.length, 10)
store.commit("_changePage", 3)
assert.equal(store.getters.currentTasks.length, 5)
}) })
test('submission reloads persisted status without duplicating a completed task', async () => { test("cancelling a collect for a task missing from the list never throws", () => {
const {store, axios, api} = fixture() reset()
const gallery = {gid: 123, name: 'sample', status: '下载完成', thumb_link: '', createTime: 1, downloader: 7} // 旧实现会在 collectGallery 里找不到 gid 时抛 TypeError。
store.state.chosenGallery = {...gallery, status: '已提交'} assert.doesNotThrow(() => store.commit("_setCollect", {gid: 999, collect: false}))
store.state.totalGalleryTask.push(gallery) store.commit("_setCollect", {gid: 1, collect: false})
axios.post = async () => ({data: {result: 'success'}}) assert.equal(store.getters.collectGallery.length, 0)
axios.get = async url => ({data: {result: 'success', data: JSON.stringify(url.endsWith('weekUsedAmount') ? {} : [gallery])}}) store.commit("_setCollect", {gid: 1, collect: true})
await store.actions.postGalleryTask(api, {targetResolution: 'original'}) assert.equal(store.getters.collectGallery.length, 1)
assert.equal(store.state.totalGalleryTask.length, 1)
assert.equal(store.state.totalGalleryTask[0].status, '下载完成')
assert.equal(store.state.chosenGallery, false)
}) })
test('gallery management subrequests carry the active auth code', async () => { test("sorting never reorders the single source array", () => {
const {store, axios, api} = fixture() reset()
store.state.AuthCode = 'test-auth' const before = store.state.totalGalleryTask.map(task => task.gid)
let call store.commit("_setSortType", "name")
axios.post = async (...args) => { assert.deepEqual(store.state.totalGalleryTask.map(task => task.gid), before)
call = args assert.deepEqual(store.state.currentTasks.map(task => task.name), ["alpha", "beta [tag]", "gamma"])
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('thumbnail and online image URLs carry the active auth code', () => { test("admin downloader filter narrows the visible list only for admins", () => {
const {store} = fixture() reset()
store.state.AuthCode = 'code with spaces' store.commit("_setDownloaderFilter", 8)
store.mutations._updateGalleryTasks(store.state, [{ assert.equal(store.getters.filteredTasks.length, 3)
gid: 123, store.state.isAdmin = true
name: 'sample', store.commit("_setDownloaderFilter", 8)
status: '已提交', assert.equal(store.getters.filteredTasks.length, 1)
thumb_link: 'https://example.org/thumb?a=1&b=2', assert.equal(store.getters.filteredTasks[0].gid, 3)
createTime: 1, })
downloader: 7
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) const thumbnail = new URL(store.state.totalGalleryTask[0].thumb_link)
assert.equal(thumbnail.searchParams.get('AuthCode'), 'code with spaces') 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("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]) const image = new URL(store.state.readingGallery.images[0])
assert.equal(image.searchParams.get('gid'), '123') assert.equal(image.searchParams.get("gid"), "123")
assert.equal(image.searchParams.get('AuthCode'), 'code with spaces') assert.equal(image.searchParams.get("AuthCode"), "code with spaces")
})
test("proceeding websocket updates refresh the progress text", () => {
reset()
store.commit("_updateGalleryTaskProceeding", [{gid: 3, status: 1, proceeding: 5}])
const task = store.state.totalGalleryTask.find(item => item.gid === 3)
assert.equal(task.status, "下载中")
assert.equal(task.progress, "50%")
}) })
-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()
})