Compare commits

...
26 Commits
Author SHA1 Message Date
lion dc39e468bb 修复 iOS 聚焦输入框导致的页面放大
- 输入控件字号统一提到 16px,规避 iOS Safari 的聚焦自动缩放
- maximum-scale/user-scalable 对 iOS 无效,仅作为其他浏览器兜底
- 用 !important 压过 Element Plus 的 is-small / --small 变体(13~14px)
2026-09-21 12:42:04 +08:00
lion 3a39677bb3 移动端支持下载人信息与筛选(仅管理员)
- 详情抽屉显示下载人昵称,取不到时回退为 #id
- 任务列表新增「下载人」筛选,选项从当前分类派生,切分类时清空
- 管理员判定改用后端下发的 isAdmin,不再硬编码 userId === 3
2026-09-20 22:54:55 +08:00
lion abb1cdb952 移动端下载器改版:重构为底部三标签的信息架构
- 以 TabBar + 三个视图(任务/搜索/设置)替换原有 DashBoard/Side/HentaiSearch 单页结构
- 拆分 TaskView / TaskRow / TaskDetailSheet / SearchView / SettingsView / AuthScreen
- 统一 theme.css 视觉规范,补 AppIcon 与缩略图工具 thumbnail.js 及其单测
- 提交弹层默认选中最高可用分辨率,缩略图加载失败走占位
- E站搜索结果与远端抽屉补回「在线看」入口
2026-09-20 18:41:00 +08:00
lion 169925c197 同步桌面端更新:重连/重试、断线自动重连、图片容错与鉴权参数
移植桌面端 2025-03-22 之后的更新到移动端,保留移动端自身布局:

- 补齐管理子路径鉴权参数:缩略图、在线图片、/cache 请求带上 AuthCode;
  原线上靠临时热补丁脚本 online-reader-auth-fix.js 绕过,现由源码原生支持。
- 断线自动重连:新增 src/utils/taskSocket.js,指数退避重连并在重连后重新同步
  任务列表与用量;App.vue 接入页面可见性/网络恢复事件,Side.vue 显示连接状态。
- 未完成任务重试:新增 retryGallery action 与重试按钮;补 "等待压缩" 状态。
- 图片容错:新增 src/utils/progressiveImages.js,单图加载失败不再阻塞后续页,
  支持单图重试。
- store 修复:收藏列表残留(slice→splice)、action 内改 context.state、
  gid 判空(null→undefined)、deleteTask 跳过下一元素、resetUndone 用 state 授权码、
  axios 全局错误拦截器、getShortname 简化。
- 下载链接统一由 buildGalleryDownloadUrl 生成(按 gid 而非名字)。
- 构建配置:base 改为 /asserts/mobile/、固定产物名 index.js/index.css、新增 .gitignore。
- 升级 Vite 8.3.0 + @vitejs/plugin-vue 6.0.9,npm audit 0 漏洞。
- 新增 tests/(11 项 Node 测试,全部通过)。
2026-09-20 14:03:10 +08:00
lion 6ddd383294 修复无法修改排序以及显示类型的bug 2025-03-22 15:44:48 +08:00
chuzhongzai 6874ba442c 将缺失的文件补上 2024-11-23 14:08:06 +08:00
chuzhongzai b87703ec00 修复不能调整分类的bug 2024-11-03 15:28:14 +08:00
chuzhongzai 040811ec5b 新增设置默认显示类型、显示名字、排列顺序 2024-09-01 19:17:19 +08:00
chuzhongzai 47ccefb2d2 优化代码 2024-05-01 11:46:13 +08:00
chuzhongzai 8ba8a5d125 优化代码 2024-03-04 23:12:29 +08:00
chuzhongzai aee27de0c0 优化图片加载(同步更新) 2024-01-03 14:37:46 +08:00
chuzhongzai fb925886c5 去除定时夜间模式;无法在线看时给出错误提示;(同步更新)去除面板中的预览图 2023-12-29 15:25:01 +08:00
chuzhongzai f4b94a66b4 抛弃轮询,使用websocket获取任务进度;部分优化;去除残留的条件判断;修复去除临时本子时加载不存在的预览图(同步更新) 2023-12-28 16:34:54 +08:00
chuzhongzai 75d5b7ec67 给每页添加页数下标,防止因为乱序加载导致分不清页数(同步更新) 2023-12-28 10:58:36 +08:00
chuzhongzai 61c4dbc8e8 去除标签;修改在线看,允许未下载的本子在线看(同步更新);修复里站搜索时切换页面不会回到顶部;修复里站搜索时不会显示加载图标 2023-12-25 20:38:24 +08:00
chuzhongzai f1270e3ebb 去除本子更新(同步更新) 2023-12-23 15:33:20 +08:00
chuzhongzai 21d8053dbf 修复下载状态判断问题 2023-12-21 22:49:46 +08:00
chuzhongzai 1d28a89851 新增下载模式选择;新增下载前查看预览图;去除链接伪装;优化下载完成逻辑判断;仅允许下载里站链接(同步更新)修复未下载完成时查看预览导致后端缓存异常的问题 2023-12-21 18:32:05 +08:00
chuzhongzai 01b3f08d8c 部分代码优化 2023-12-08 15:06:00 +08:00
chuzhongzai 26b622262e 修复缩略图预览;优化下载链接;(同步更新) 调整搜索框长度 2023-09-10 13:04:52 +08:00
chuzhongzai 853ed0c2f2 对调主页跟侧边栏,优化界面 2023-08-27 15:40:36 +08:00
chuzhongzai 128fe31fe8 优化翻页效果(同步更新) 2023-08-20 15:28:48 +08:00
chuzhongzai 554e5864ea 上线时验证节点是否在线,自己可以再次提交未完成任务(同步更新) 2023-08-08 15:23:32 +08:00
chuzhongzai 925f952748 优化部分细节(同步更新) 2023-08-05 21:53:39 +08:00
chuzhongzai 4f87b7178e 链接全转https协议,去除获取在线预览链接,改为本地生成,修复部分bug,优化预览图(同步更新) 2023-07-31 22:47:44 +08:00
chuzhongzai 4c3d070f0b 链接全转https协议(同步更新) 2023-07-19 18:19:25 +08:00
30 changed files with 4678 additions and 1565 deletions
+29
View File
@@ -0,0 +1,29 @@
# Logs
logs
*.log
run.out
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
+4 -4
View File
@@ -1,10 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<title>Vite + Vue</title>
<link rel="icon" href="/asserts/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<title>LionWebsite</title>
<meta name="apple-mobile-web-app-capable" content="yes">
</head>
<body>
+1712
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "lionwebsiteformobile",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node --test tests/*.test.mjs"
},
"dependencies": {
"axios": "^1.0.0",
"element-plus": "^2.2.15",
"qs": "^6.11.0",
"vue": "^3.2.37",
"vuex": "^4.0.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.9",
"vite": "8.3.0"
}
}
+62 -14
View File
@@ -1,23 +1,71 @@
<script setup>
import Side from "./components/Side.vue";
import DashBoard from "./components/DashBoard.vue";
import {computed, onBeforeUnmount, onMounted} from "vue";
import store from "./store/index.js";
import TaskView from "./components/TaskView.vue";
import SearchView from "./components/SearchView.vue";
import SettingsView from "./components/SettingsView.vue";
import TabBar from "./components/TabBar.vue";
import AuthScreen from "./components/AuthScreen.vue";
import TaskDetailSheet from "./components/TaskDetailSheet.vue";
import OnlineReader from "./components/OnlineReader.vue";
const activeTab = computed(() => store.state.activeTab);
function isSystemDark() {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
// Theme is a single class on <html>; colours come from the CSS variables.
function adjustForStyle() {
const stored = localStorage.getItem("darkConfig");
if (stored !== null) {
const darkConfig = JSON.parse(stored);
document.documentElement.classList.toggle("dark", Boolean(darkConfig.followSystem) && isSystemDark());
} else {
document.documentElement.classList.remove("dark");
}
}
const resumeProgress = () => {
if (store.state.isAuth && document.visibilityState === "visible") store.dispatch("initWebsocket");
};
const pauseProgress = () => store.dispatch("disconnectWebsocket");
onMounted(() => {
adjustForStyle();
store.state.lengthPerPage = Number(localStorage.getItem("lengthPerPage")) || 30;
store.commit("_setCategory", localStorage.getItem("category") ?? "myDownload");
store.commit("_setSortType", localStorage.getItem("sortType") ?? "createTime");
store.commit("_setGalleryNameType", localStorage.getItem("galleryNameType") ?? "shortName");
const auth = localStorage.getItem("auth");
if (auth !== null) store.dispatch("validate", auth);
window.addEventListener("online", resumeProgress);
window.addEventListener("pageshow", resumeProgress);
window.addEventListener("pagehide", pauseProgress);
document.addEventListener("visibilitychange", resumeProgress);
});
onBeforeUnmount(() => {
window.removeEventListener("online", resumeProgress);
window.removeEventListener("pageshow", resumeProgress);
window.removeEventListener("pagehide", pauseProgress);
document.removeEventListener("visibilitychange", resumeProgress);
pauseProgress();
});
</script>
<template>
<div class="app">
<el-container>
<Side/>
<el-main>
<DashBoard/>
</el-main>
</el-container>
<main class="app-views">
<TaskView v-show="activeTab === 'tasks'"/>
<SearchView v-show="activeTab === 'search'"/>
<SettingsView v-show="activeTab === 'settings'"/>
</main>
<TabBar/>
<TaskDetailSheet/>
<OnlineReader/>
<AuthScreen/>
</div>
</template>
<style>
.app{
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
import {ref} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
const AuthCode = ref("");
const isRemember = ref(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 class="auth-screen" v-show="!store.state.loadComplete">
<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">验证</el-button>
</div>
</div>
</div>
</template>
-573
View File
@@ -1,573 +0,0 @@
<template>
<div class="DashBoard" v-show="loadComplete">
<span>E站额度本周已用:{{weekUsed.weekUsedAmount}} <br>上次重置时间:{{weekUsed.lastResetAmountTime}}</span><br>
<el-button @click="queryWeekUsedAmount">查询用量</el-button>
<hr>
<el-row>
<el-col>
查询参数类型: <el-select style="width: 125px" v-model="type" @change="resetLocalQuery()">
<el-option value="link" label="链接"/>
<el-option value="keyword" label="关键字"/>
<el-option value="tag" label="标签"/>
</el-select><br>
<el-input style="width: 200px;" v-model="param" v-if="type !== 'tag'">
<template #prepend>
参数:
</template>
</el-input>
<div v-if="type === 'tag'">
<el-tag v-for="tid in paramForTags" closable @close="removeQueryTag(tid)">
{{store.state.tags.get(tid).tag}}
</el-tag>
<el-autocomplete v-model="param" :fetch-suggestions="completeQueryTag" @select="handleTagSelect" ref="tagInput" placeholder="检索标签"/>
</div>
</el-col>
<el-col>
<el-button @click="queryRemoteTask" v-show="type === 'link'">远程查询</el-button>
<el-button @click="queryLocalTask">当前页查询</el-button>
<el-button @click="removeAllQueryTag" v-show="type === 'tag'">清空查询标签</el-button>
</el-col>
</el-row>
<hr>
<el-button @click="openPanel">打开面板</el-button>
<el-button @click="isQuerying = true">里站搜索</el-button>
<el-button @click="isAlterAuthCode = true">修改授权码</el-button>
<el-button @click="deleteAuthCode">删除本地授权码</el-button>
<el-button @click="isConfig = true">配置</el-button><br>
<span style="display: inline">夜间模式</span>
<el-switch @click="toggleStyle" v-model="isDark">夜间模式</el-switch>
<hr>
<el-button @click="isQuerying = true">里站搜索</el-button>
<el-button @click="isViewingTag = true">查看标签</el-button>
<br>
<div v-show="thumbnailGallery !== {}">
<span>
{{thumbnailGallery.shortName}}
</span><br>
<picture>
<el-image :src="thumbnailGallery.url" :preview-src-list="[thumbnailGallery.url,]" :initial-index="0" class="preview" style="height: 30vh"/>
</picture>
</div>
</div>
<el-dialog title="查询本子" v-model="chosenGallery" width="100%">
<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">
<el-option v-for="(fileSize, resolution) in chosenGallery.availableResolution" :value="resolution"
:label="resolution + ' ' + fileSize">
</el-option>
</el-select>
</tr>
<tr v-if="chosenGallery.availableResolution">
标签:<el-tag v-for="tid in paramForTags" closable @close="removeQueryTag(tid)">
{{store.state.tags.get(tid).tag}}
</el-tag>
<el-autocomplete v-model="param" :fetch-suggestions="completeQueryTag" @select="handleTagSelect" ref="tagInputForSubmit"/>
</tr>
</table>
<el-button @click="postTask" v-if="chosenGallery.availableResolution">下载</el-button>
<el-button @click="removeAllQueryTag" v-if="chosenGallery.availableResolution">清空标签</el-button>
<tr v-if="chosenGallery.status === '下载完成'">
<el-button @click="onlineGalleryReader(chosenGallery.gid)">在线预览</el-button>
<el-button @click="showThumbnail(chosenGallery)">查看封面图</el-button>
<el-button @click="deleteGallery">删除</el-button>
</tr>
</el-dialog>
<HentaiSearch :is-querying="isQuerying" @close="closeQueryPanel"></HentaiSearch>
<el-dialog title="修改授权码" v-model="isAlterAuthCode" width="100%">
<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>
<el-dialog title="配置" v-model="isConfig" width="100%">
<div>
夜间模式<hr>
<span style="display: inline-block">夜间模式跟随系统</span>
<el-switch v-model="darkConfig.followSystem"></el-switch><br>
<span style="display: inline-block">自定义起始时间(精确到分)</span>
<el-switch v-model="darkConfig.customTime"></el-switch><br>
<el-form :disabled="!darkConfig.customTime">
<el-time-picker v-model="darkConfig.startTime" /> ~
<el-time-picker v-model="darkConfig.endTime"/>
</el-form>
</div>
<div>
在线预览<hr>
<span style="display: inline-block">在线预览分页页数:</span>
<input v-model="lengthPerPage">
</div>
<template #footer>
<el-button type="primary" @click="saveConfig">保存</el-button>
</template>
</el-dialog>
<el-dialog title="查看标签" v-model="isViewingTag" style="margin-top: 0; width: 100%">
<div style="text-align: center">
输入关键字:<el-input style="width: 150px" v-model="tagKeyWord"></el-input><br>
<el-button @click="tagKeyWord = ''">重置关键字</el-button>
<el-button @click="pullNewTag">刷新</el-button>
</div>
<el-table :data="tags" height="350px" stripe>
<el-table-column prop="id" label="id" width="50px" sortable/>
<el-table-column prop="tag" label="标签" width="150px"/>
<el-table-column prop="usage" label="用量" width="75px" sortable/>
<el-table-column width="75px">
<template #default="scoped">
<el-button v-if="scoped.row.usage === 0" @click="deleteTag(scoped.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<span style="color: red; font: bold 15px sans-serif">
创建标签前先看看有没有符合的,尽量用统一一点的标签,比如已经有个图包就不要创建图集之类的了。毕竟标签多起来数据挺多的。
</span>
<template #footer>
输入新标签:<el-input v-model="tag" style="width: 100px"></el-input>
<el-button @click="postTag">创建标签</el-button>
</template>
</el-dialog>
<div class="DashBoard" v-show="!loadComplete">
<div class="validate">
<el-input v-model="AuthCode" placeholder="请输入授权码" />
<el-checkbox v-model="isRemember" >是否记住授权码</el-checkbox><br>
<el-button @click="validate" type="primary" @keydown.enter="validate">验证</el-button>
</div>
</div>
</template>
<script setup>
import store from "../store";
import {computed, ref, onMounted} from "vue";
import {ElMessage} from "element-plus"
import axios from "axios";
import HentaiSearch from "./HentaiSearch.vue";
//授权码相关
let AuthCode = ref("")
let isRemember = ref(false)
let isAlterAuthCode = ref(false)
let newAuthCode = ref("")
let tempAuthCode = ref("")
let isQuerying = ref(false)
let isViewingTag = ref(false)
let isConfig = ref(false)
let isDark = ref(false)
let keyword = ref("furry yaoi")
let darkConfig = ref({})
let lengthPerPage = ref(0)
//查询相关
let type = ref("link")
let param = ref("")
let paramForTags = ref([]) //tidS
let targetResolution = ref("")
let tag = ref("")
let tagKeyWord = ref("") //查询tag的关键字
let realAuthCode = computed(() => {
return store.state.AuthCode
})
let chosenGallery = computed(() => {
paramForTags.value.splice(0)
param.value = ''
return store.state.chosenGallery
})
let loadComplete = computed(() => {
return store.state.loadComplete
})
let weekUsed = computed(() => {
return store.state.weekUsed
})
let tags = computed(() => {
let tags = store.state.tags
let result = []
tags.forEach((tag) => {
result.push(tag)
})
if(isViewingTag.value) { //正在查看标签
if (tagKeyWord.value.trim() === '')
return result
else
return result.filter((tag) => {
return tag.tag.includes(tagKeyWord.value)
})
}
return result
})
let thumbnailGallery = computed(() => {
return store.state.thumbnailGallery
})
function pullNewTag(){
store.dispatch("loadTags")
}
function postTag(){
store.dispatch("postTag", tag.value)
}
function deleteTag(tid){
store.dispatch("deleteTag", tid)
}
//修改授权码
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
}
let tempLink = coverLink(chosenGallery.value.link)
store.dispatch("postGalleryTask", {link: tempLink,
targetResolution: targetResolution.value,
tags:tag.value})
targetResolution.value = ""
}
//查询任务
function queryRemoteTask(){
if(!validateLink(param.value)){
ElMessage("链接错误")
return
}
let tempLink = coverLink(param.value)
store.dispatch("queryGalleryTask", tempLink)
}
function queryLocalTask(){
switch (type.value){
case "link":
store.commit("_searchLocalByLink", param.value)
break
case "keyword":
store.commit("_searchLocalByKeyword", param.value)
break
case "tag":
store.commit("_searchLocalByTag", paramForTags.value)
break
}
}
let tagInput = ref({}) //用于查询
let tagInputForSubmit = ref({}) //用于提交
function completeQueryTag(keyWord, cb) {
if(keyWord.includes(' ')) { //查询多个标签的时候
let temp = keyWord.split(' ')
keyWord = temp[temp.length - 1]
}else{ //只有一个标签的时候
keyWord = param.value
}
let result = []
let skip
let hit = false //用于检测是否有重复标签
tags.value.forEach((tag) => {
if(tag.tag.includes(keyWord)) {
skip = false
for (let id of paramForTags.value) { //跳过已选中的标签
if(tag.id === id){
if(!hit && tag.tag === keyWord){ //是否命中标签
hit = true
}
skip = true
break
}
}
if(!skip)
result.push({value: tag.tag, tid: tag.id})
}
})
if(result.length === 0 && !keyWord.includes("#") && chosenGallery.value.gid !== undefined && !hit){ //未命中结果并且准备与下载任务一并提交
result.push({value: '新建 #' + keyWord + ' 标签?', tag:keyWord})
}
cb(result)
}
function removeQueryTag(tid){
for (let i=0; i<paramForTags.value.length; i++){
if(paramForTags.value[i] === tid){
paramForTags.value.splice(i, 1)
break
}
}
if(chosenGallery.value.gid === undefined) //查询本地标签
queryLocalTask()
}
function removeAllQueryTag(){
paramForTags.value.splice(0)
if(chosenGallery.value.gid === undefined) //查询本地标签
queryLocalTask()
}
function handleTagSelect(data){
if('tag' in data){ //需要新建,不知道怎么处理回调,直接把axios搬到vue里面
axios.post("http://downloader.lionwebsite.xyz/GalleryManage/tag?" + qs.stringify({
tag:data.tag,
AuthCode: store.state.AuthCode
})
).then((res) => {
if (res.data.result === 'success') {
ElMessage('创建标签成功')
paramForTags.value.push(parseInt(res.data.tid))
tagInputForSubmit.value.blur()
store.dispatch("loadTags", false).then()
}
else
ElMessage(res.data.data)
})}else{
paramForTags.value.push(data.tid)
console.log(chosenGallery)
if(chosenGallery.value.gid === undefined){
queryLocalTask()
tagInput.value.blur()
}else{
tagInputForSubmit.value.blur()
}
}
param.value = ''
}
function resetLocalQuery(){
store.commit("_searchLocalByKeyword", "")
store.commit("_searchLocalByTag", [''])
param.value = ""
}
function closeQueryPanel(){
isQuerying.value = false
}
//删除任务
function deleteGallery(){
store.dispatch("deleteGallery", chosenGallery.value.gid)
}
//验证授权码
function validate(){
if(AuthCode.value.trim() === ""){
ElMessage("请输入授权码后再验证")
}
else{
store.dispatch("validate", AuthCode.value)
if(isRemember.value)
localStorage.setItem("auth", AuthCode.value)
}
}
//验证链接以及伪装链接
function validateLink(rawLink){
if(rawLink.trim() === "")
return false
if(rawLink.includes("hentai"))
return rawLink.includes("/g/")
else
return false
}
function coverLink(rawLink){
return rawLink.replace("exhentai.org", "element-plus.org").replace("e-hentai.org", "element.org");
}
//打开面板以及在线阅读
function openPanel(){
store.commit("_openHistoryPanel")
}
function onlineGalleryReader(gid){
store.dispatch("queryOnlineLinks", gid)
}
function deleteAuthCode(){
localStorage.removeItem('auth')
ElMessage("删除授权码完成")
}
function toggleStyle(){
if(isDark.value)
dark()
else
light()
}
//显示缩略图
function showThumbnail(gallery){
store.commit("_changeThumbnailGallery", gallery)
setTimeout(() => {document.querySelector(".preview > img").click()}, 1)
}
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)
lengthPerPage.value = store.state.lengthPerPage
if(auth !== null){
store.dispatch("validate", auth)
}
})
function adjustForStyle(){
let darkConfigStr = localStorage.getItem("darkConfig")
if(darkConfigStr !== null) {
darkConfig.value = JSON.parse(darkConfigStr)
if (darkConfig.value.followSystem)
if (isSystemDark()) {
dark()
isDark.value = true
}
else {
light()
isDark.value = false
}
if (darkConfig.value.customTime)
if (isDarkTime(darkConfig.value)) {
dark()
isDark.value = true
}
else {
light()
isDark.value = false
}
if(isDark.value)
dark()
else
light()
}else {
light()
darkConfig.value = {'followSystem': false, 'customTime': false}
}
}
function isSystemDark(){
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
function isDarkTime(darkConfig){
let date = new Date()
let startTime = darkConfig.startTime
let endTime = darkConfig.endTime
if(startTime.hour > endTime.hour){ //隔夜 22:00 ~ 8:00
if(date.getHours() > endTime.getHours() && date.getHours() < startTime.getHours()){ // 大于结束时间且小于起始时间 16:00
return false
}else if(date.getHours() === endTime.getHours()){ //22:00 ~ 8:30 8:26
return date.getMinutes() <= endTime.getMinutes();
}else if(date.getHours() === startTime.getHours()){ //22:30 ~ 8:00 22:46
return date.getMinutes() > startTime.getMinutes();
}else
return true
}else{ //不隔夜 00:00 ~ 6:00 22:00 ~ 23:00
if(date.getHours() > endTime.getHours() || date.getHours() < startTime.getHours()){
return false
}else if(date.getHours() === startTime.getHours()){ // 01:30 ~ 06:00 01:32
return date.getMinutes() >= startTime.getMinutes();
}else if(date.getHours() === endTime.getHours()){ // 01:30 ~ 06:30 06:35
return date.getMinutes() <= endTime.getMinutes();
}else
return true
}
}
function dark(){
let html = document.querySelector("html")
if(!html.classList.contains("dark"))
html.classList.add('dark')
document.querySelector(".DashBoard").style.setProperty("background-color", null)
document.querySelector(".app").style.setProperty("background-color", null)
}
function light(){
let html = document.querySelector("html")
if(html.classList.contains("dark"))
html.classList.remove('dark')
document.querySelector(".DashBoard").style.setProperty("background-color", "ghostwhite")
document.querySelector(".app").style.setProperty("background-color", "#c6e2ff")
}
function saveConfig(){
if(darkConfig.value.customTime) {
if(darkConfig.value.startTime === undefined || darkConfig.value.endTime === undefined){
ElMessage("请正确选择起始时间")
return
}
}
store.state.lengthPerPage = Number(lengthPerPage.value)
localStorage.setItem("lengthPerPage", lengthPerPage.value)
localStorage.setItem("darkConfig", JSON.stringify(darkConfig.value))
isConfig.value = false
adjustForStyle()
}
</script>
<style>
.DashBoard{
width: auto;
height: 90vh;
text-align: center;
}
.validate{
width: 50vw;
background-color: ghostwhite;
display: block;
padding-top: 200px;
padding-left: 20vw;
text-align: center;
}
.el-input{
width: 25vw;
}
.el-dialogClass .el-dialog__body{
padding-left: 0;
padding-right: 0;
}
</style>
-134
View File
@@ -1,134 +0,0 @@
<script setup>
import {ref, watch} from "vue";
import axios from "axios";
import {ElMessage} from "element-plus";
import store from "../store/index.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()
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(" ", "+")
document.getElementById("loading").style.display = "inline-block";
axios.get("http://downloader.lionwebsite.xyz/query?keyword=" + tempParam)
.then((res) => {
document.getElementById("loading").style.display = "none";
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)
})
}else {
ElMessage({message: res.data.data, type: "error"})
}
})
}
function queryRemoteTask(){
if(!validateLink(param.value)){
ElMessage("链接错误")
return
}
let tempLink = coverLink(param.value)
store.dispatch("queryGalleryTask", tempLink)
}
function validateLink(rawLink){
if(rawLink.trim() === "")
return false
if(rawLink.includes("hentai"))
return rawLink.includes("/g/")
else
return false
}
function coverLink(rawLink){
return rawLink.replace("exhentai.org", "element-plus.org").replace("e-hentai.org", "element.org");
}
function close(){
emit("close")
}
</script>
<template>
<el-dialog title="里站搜索" v-model="isShowUp" top="0" style="margin-bottom: 0" width="100%" class="el-dialogClass" @close="close">
<div style="text-align: center">
<el-input v-model="keyword" style="width: 50vw"></el-input> <el-button @click="queryGalleries(null)">查询</el-button> <div id="loading"/>
</div>
<el-scrollbar height="65vh" ref="scrollBar">
<div style="height: 20vh; width: 100%; border-radius: 5px" v-for="gallery in galleries">
<el-image alt="picture" :preview-src-list="['http://downloader.lionwebsite.xyz/query/image?path=' + gallery.thumbnailUrl,]"
:src="'http://downloader.lionwebsite.xyz/query/image?path=' + gallery.thumbnailUrl"
style="height:20vh;width:35vw;float: left;"
fit="contain"
loading="lazy"
/>
<div style="font: bold 10px semi-condensed; margin-top: 1vh; padding-left: 40vw; padding-top: 2vw">
<span>{{gallery.name}}</span><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 style="position: relative; margin-left: 65%;" @click="param=gallery.link; queryRemoteTask()" type="primary">下载</el-button>
</div>
</div><br>
</el-scrollbar>
<div style="padding-top: 10px; 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 {
width: 25px;
height: 25px;
border: 2px solid #ccc;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
display: none;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.el-dialogClass .el-dialog__body{
padding-left: 0;
padding-right: 0;
}
</style>
+92 -25
View File
@@ -1,57 +1,124 @@
<script setup>
import {computed, ref, watch} from "vue";
import {computed, ref, watch, onBeforeUnmount} from "vue";
import {startImages, settleImage, retryImage} from "../utils/progressiveImages.js";
import store from "../store/index.js";
let props = defineProps(['currentLinks', 'isOnlineReading'])
let isShowUp = ref()
let onlineReadingScrollbar = ref()
let links = ref()
let index = ref(0)
let temp_index = ref(0) //用于跳转
let index = ref(0) //下标
let page = ref(0) //页数 下标+1=页数 用于跳转
let imagesForLoading = ref([])
let isReading = ref(false)
let max = ref(0)
let current_page = 0
let lengthPerPage = computed(() => {
return store.state.lengthPerPage
return Math.max(1, Number(store.state.lengthPerPage) || 10)
})
watch(props, (props)=>{
isShowUp.value = props.isOnlineReading
let readingGallery = computed(() => {
return store.state.readingGallery
})
watch(() => store.state.isReading, (isReadingVal) => {
if(isReadingVal && !isReading.value) {
alterPage()
isReading.value = true
}
})
//切换本子
//切换
function alterPage(){
console.log(lengthPerPage.value)
if(props.currentLinks.length > lengthPerPage.value){
links.value = props.currentLinks.slice(0, lengthPerPage.value)
max.value = Math.ceil(props.currentLinks.length / lengthPerPage.value)
if(readingGallery.value.images.length > lengthPerPage.value){
links.value = readingGallery.value.images.slice(0, lengthPerPage.value)
max.value = Math.ceil(readingGallery.value.images.length / lengthPerPage.value)
}else{
links.value = props.currentLinks
links.value = readingGallery.value.images
max.value = 0
}
index.value = 0
temp_index.value = 1
page.value = 1
imagesForLoading.value = startImages(links.value)
}
//跳转到对应页数
function jump(targetIndex){
links.value = props.currentLinks.slice(targetIndex * lengthPerPage.value, (targetIndex + 1) * lengthPerPage.value)
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
temp_index.value = targetIndex + 1
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
onlineReadingScrollbar.value.setScrollTop(onlineReadingScrollbar.value.wrapRef.scrollHeight)
}, 100)
//下一页
}else if(target_page === 0 && current_page === lengthPerPage.value - 1 && index.value < max.value - 1){
jump(index.value + 1)
current_page = 0
document.querySelector("div.el-scrollbar__wrap.el-scrollbar__wrap--hidden-default > div > div:nth-child(1) > img").click()
}
else{
current_page = target_page
}
}
function set_current_page(page){
current_page = page
}
</script>
<template>
<el-dialog v-model="isShowUp" title="在线预览" @close="closeDialog" width="100%" top="0">
<div style="font-size: 20px; text-align: center;" v-if="max > 0">
{{index + 1}} - {{(index) * lengthPerPage + 1}} ~ {{(index + 1) * lengthPerPage + 1 > currentLinks.length? currentLinks.length: (index + 1) * lengthPerPage + 1}}
<el-dialog v-model="isReading" @close="closeDialog" width="100%" top="0" fullscreen>
<template #header style="padding-bottom: 0">
在线预览: {{readingGallery.name}}<br>
页数:{{readingGallery.pages}}<br>
<div style="font-size: 3vh; display: inline" v-if="max > 0">
{{ index + 1 }} - {{ (index) * lengthPerPage + 1 }} ~
{{ (index + 1) * lengthPerPage + 1 > readingGallery.images.length ? readingGallery.images.length : (index + 1) * lengthPerPage }}
</div>
</template>
<el-scrollbar height="75vh" ref="onlineReadingScrollbar">
<el-image v-for="(link, i) in links" :src="link" :style="{'width': 'auto', 'text-align': 'center', 'background-color': 'ghostwhite'}"
:preview-src-list="currentLinks" :initial-index="index * lengthPerPage + i" loading="lazy"/>
<div v-for="(item, i) in imagesForLoading" :key="item.url + '-' + item.attempt" style="display: inline-block; text-align: center">
<el-image :src="item.src" :style="{'width': 'auto', 'text-align': 'center', 'background-color': 'ghostwhite'}"
:preview-src-list="links" :initial-index="i" @switch="switch_page" @show="set_current_page(i)" loading="lazy" @load="imageResult(item)" @error="imageResult(item, true)"/>
<div v-if="item.failed" role="alert">图片加载失败 <el-button size="small" @click="retryImage(item)">重试</el-button></div> <br>
{{lengthPerPage * index + i + 1}}
</div>
</el-scrollbar>
<!--五页以下-->
@@ -67,10 +134,10 @@ function closeDialog(){
<div v-if="max >= 4" style="text-align: center">
<el-button @click="jump(index - 1)" :disabled="index === 0" size="small">上一页</el-button>
1 &lt;
<el-input-number v-model="temp_index" :min="1" :max="max"/>
<el-input-number v-model="page" :min="1" :max="max"/>
&lt; {{max}}
<el-button @click="jump(index + 1)" :disabled="index === max - 1" size="small">下一页</el-button>
<el-button @click="jump(temp_index - 1)" size="large">跳转</el-button>
<el-button @click="jump(page - 1)" size="large">跳转</el-button>
</div>
</el-dialog>
</template>
+203
View File
@@ -0,0 +1,203 @@
<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 {validateLink} from "../utils/validate.js";
import {ehThumbnailUrl} from "../utils/thumbnail.js";
const mode = ref("local");
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 searchResults = computed(() => store.getters.searchResults);
const searchTotal = computed(() => store.state.searchTask.length);
const searchRemaining = computed(() =>
Math.max(0, searchTotal.value - store.state.visiblePages * store.state.length));
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 = "";
}
function openTask(gallery) {
store.commit("_setDetailGallery", gallery);
}
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);
}
}
function queryGalleries(link) {
let keyword
if (link != null) {
keyword = new URL(link).search.replace("?f_search=", "")
} else {
keyword = remoteKeyword.value
if (keyword.trim() === "") {
ElMessage("请输入搜索关键词")
return
}
}
remoteLoading.value = true
axios.get("https://downloader.lionwebsite.xyz/query?keyword=" + keyword.replace(" ", "+"))
.then((res) => {
if (res.data.result === "success") {
remotePage.value = {
first: res.data.first,
previous: res.data.previous,
next: res.data.next,
last: res.data.last,
}
remoteGalleries.value.splice(0)
JSON.parse(res.data.data).forEach(gallery => remoteGalleries.value.push(gallery))
} else {
ElMessage({message: res.data.data, type: "error"})
}
})
.finally(() => {
remoteLoading.value = false
})
}
function remoteThumbnail(path) {
return ehThumbnailUrl(path, store.state.AuthCode);
}
// Hand the remote gallery to the shared sheet so the 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);
}
</script>
<template>
<div class="view">
<header class="app-bar">
<h1>搜索</h1>
</header>
<div class="segmented" role="group" aria-label="搜索来源">
<button type="button" :aria-pressed="mode === 'local'" @click="mode = 'local'">本地</button>
<button type="button" :aria-pressed="mode === 'ehentai'" @click="mode = 'ehentai'">E站搜索</button>
<button type="button" :aria-pressed="mode === 'link'" @click="mode = 'link'">解析链接</button>
</div>
<div class="list-scroll" v-show="mode === 'local'">
<div class="segmented is-sub" 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>
<div class="search-bar">
<input v-model="localKeyword"
type="search"
:placeholder="localMode === 'keyword' ? '在我的下载里搜索' : '粘贴已下载任务的链接'"
@keydown.enter="runLocalSearch">
<button type="button" @click="runLocalSearch">搜索</button>
</div>
<template v-if="store.state.isSearch">
<p class="hint">
匹配到 {{ searchTotal }} 项
<button type="button" class="hint-action" @click="clearLocalSearch">清除</button>
</p>
<TaskRow v-for="gallery in searchResults"
:key="gallery.gid"
:gallery="gallery"
:retrying="retryingGids.has(gallery.gid)"
@open="openTask"
@download="downloadTask"
@retry="retryGallery"/>
<button v-if="searchRemaining > 0" type="button" class="load-more"
@click="store.commit('_showMoreTasks')">
继续加载(还有 {{ searchRemaining }} 项)
</button>
</template>
<p v-else class="empty-state">没有匹配的任务</p>
</div>
<div class="list-scroll" v-show="mode === 'ehentai'">
<div class="search-bar">
<input v-model="remoteKeyword" type="search" placeholder="在 E站搜索画廊" @keydown.enter="queryGalleries(null)">
<button type="button" :disabled="remoteLoading" @click="queryGalleries(null)">
{{ remoteLoading ? "搜索中" : "搜索" }}
</button>
</div>
<p class="hint" v-if="remoteGalleries.length">本页 {{ remoteGalleries.length }} 个结果</p>
<div class="remote-row" v-for="gallery in remoteGalleries" :key="gallery.link">
<button type="button" class="task-row-open" @click="submitRemote(gallery)">
<span class="task-thumb">
<img :src="remoteThumbnail(gallery.thumbnailUrl)" alt="" loading="lazy">
</span>
<span class="task-body">
<span class="task-title">{{ gallery.name }}</span>
<span class="task-meta">{{ gallery.page }} 页 · {{ gallery.type }} · {{ (gallery.uploadTime || "").split(" ")[0] }}</span>
</span>
</button>
<button type="button" class="task-side task-side-muted" aria-label="在线看" @click="previewRemote(gallery)">在线看</button>
<button type="button" class="task-side task-side-act" aria-label="提交下载" @click="submitRemote(gallery)">提交</button>
</div>
<p v-if="!remoteGalleries.length && !remoteLoading" class="empty-state">没有搜索结果</p>
<div class="pager-row" v-if="remoteGalleries.length">
<button type="button" :disabled="remotePage.first === undefined" @click="queryGalleries(remotePage.first)">首页</button>
<button type="button" :disabled="remotePage.previous === undefined" @click="queryGalleries(remotePage.previous)">上一页</button>
<button type="button" :disabled="remotePage.next === undefined" @click="queryGalleries(remotePage.next)">下一页</button>
<button type="button" :disabled="remotePage.last === undefined" @click="queryGalleries(remotePage.last)">尾页</button>
</div>
</div>
<div class="list-scroll" v-show="mode === 'link'">
<div class="search-bar">
<input v-model="linkParam" type="search" placeholder="粘贴 e-hentai 画廊链接" @keydown.enter="parseLink">
<button type="button" @click="parseLink">解析</button>
</div>
<p class="hint">解析成功后可直接选分辨率提交下载</p>
</div>
</div>
</template>
+218
View File
@@ -0,0 +1,218 @@
<script setup>
import {computed, onMounted, ref} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
const isAlterAuthCode = ref(false);
const newAuthCode = ref("");
const tempAuthCode = ref("");
const isConfig = ref(false);
const isDark = ref(false);
const darkConfig = ref({followSystem: false});
const lengthPerPage = ref(30);
const category = ref("myDownload");
const sortType = ref("createTime");
const galleryNameType = ref("shortName");
const realAuthCode = computed(() => store.state.AuthCode);
const weekUsed = computed(() => store.state.weekUsed);
const isAdmin = computed(() => store.state.isAdmin);
const connectionText = computed(() => ({
connected: "已连接",
connecting: "连接中",
reconnecting: "重连中",
disconnected: "已断开",
})[store.state.connectionStatus]);
function isSystemDark() {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function applyTheme(dark) {
document.documentElement.classList.toggle("dark", dark);
isDark.value = dark;
}
function toggleDark(value) {
darkConfig.value.followSystem = false;
applyTheme(value);
localStorage.setItem("darkConfig", JSON.stringify(darkConfig.value));
}
function toggleFollowSystem(value) {
darkConfig.value.followSystem = value;
applyTheme(value && isSystemDark());
localStorage.setItem("darkConfig", JSON.stringify(darkConfig.value));
}
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 deleteAuthCode() {
localStorage.removeItem("auth");
ElMessage("删除授权码完成");
}
function saveConfig() {
const length = Number(lengthPerPage.value);
if (!Number.isFinite(length) || length < 1 || length > 30) {
ElMessage("分页页数设置错误,范围1~30");
lengthPerPage.value = 30;
} else {
store.state.lengthPerPage = length;
localStorage.setItem("lengthPerPage", String(length));
}
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;
}
onMounted(() => {
const stored = localStorage.getItem("darkConfig");
darkConfig.value = stored === null ? {followSystem: false} : JSON.parse(stored);
isDark.value = document.documentElement.classList.contains("dark");
lengthPerPage.value = store.state.lengthPerPage || 30;
category.value = store.state.category;
sortType.value = store.state.sortType;
galleryNameType.value = store.state.galleryNameType;
});
</script>
<template>
<div class="view">
<header class="app-bar">
<h1>设置</h1>
</header>
<div class="list-scroll settings-scroll">
<p class="group-label">本周用量</p>
<section class="group">
<div class="setting-row">
<span class="setting-label">已用流量</span>
<span class="usage-value">
<strong>{{ weekUsed.weekUsedAmount ?? "—" }}</strong>
<small>上次重置 {{ weekUsed.lastResetAmountTime ?? "—" }}</small>
</span>
<button type="button" class="mini-button" @click="store.dispatch('loadWeekUsedAmount')">刷新</button>
</div>
</section>
<p class="group-label">节点</p>
<section class="group">
<div class="setting-row">
<span class="setting-label">进度连接</span>
<span class="connection" :class="{'is-connected': store.state.connectionStatus === 'connected'}">
<i class="connection-dot"></i>{{ connectionText }}
</span>
</div>
<button type="button" class="setting-row is-button" @click="store.dispatch('reconnect')">
<span class="setting-label">重连节点</span>
<span class="chevron">›</span>
</button>
</section>
<p class="group-label">账户</p>
<section class="group">
<button type="button" class="setting-row is-button" @click="isAlterAuthCode = true">
<span class="setting-label">修改授权码</span>
<span class="chevron">›</span>
</button>
<button type="button" class="setting-row is-button" @click="deleteAuthCode">
<span class="setting-label">删除本地授权码</span>
<span class="chevron">›</span>
</button>
<button v-if="isAdmin" type="button" class="setting-row is-button is-danger"
@click="store.dispatch('resetUndone')">
<span class="setting-label">重置未完成任务</span>
<span class="chevron">›</span>
</button>
</section>
<p class="group-label">外观</p>
<section class="group">
<div class="setting-row">
<span class="setting-label">夜间模式</span>
<el-switch v-model="isDark" @change="toggleDark"/>
</div>
<div class="setting-row">
<span class="setting-label">跟随系统</span>
<el-switch v-model="darkConfig.followSystem" @change="toggleFollowSystem"/>
</div>
<button type="button" class="setting-row is-button" @click="isConfig = true">
<span class="setting-label">默认设置</span>
<span class="setting-value">{{ category === "myDownload" ? "我的下载" : category === "myCollect" ? "我的收藏" : "全部" }}</span>
<span class="chevron">›</span>
</button>
</section>
</div>
<el-dialog title="修改授权码" v-model="isAlterAuthCode" width="100%">
<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-input>
</el-form-item>
<el-form-item label="再次输入授权码">
<el-input v-model="tempAuthCode" show-password></el-input>
</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>
<el-dialog title="默认设置" v-model="isConfig" width="100%">
<el-form label-position="top" class="config-form">
<h4 class="section-title">在线预览</h4>
<el-form-item label="每页图片数量">
<el-input-number v-model="lengthPerPage" :min="1" :max="30" :step="1" style="width: 100%"/>
<span class="field-hint">1 – 30</span>
</el-form-item>
<h4 class="section-title">列表默认值</h4>
<el-form-item label="分类">
<el-select v-model="category" default-first-option style="width: 100%">
<el-option label="全部" value="total"/>
<el-option label="我的下载" value="myDownload"/>
<el-option label="我的收藏" value="myCollect"/>
</el-select>
</el-form-item>
<el-form-item label="排序方式">
<el-select v-model="sortType" default-first-option style="width: 100%">
<el-option label="名字" value="name"/>
<el-option label="简洁名字" value="shortName"/>
<el-option label="任务创建时间" value="createTime"/>
</el-select>
</el-form-item>
<el-form-item label="显示类型">
<el-select v-model="galleryNameType" default-first-option style="width: 100%">
<el-option label="名字" value="name"/>
<el-option label="简洁名字" value="shortName"/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" @click="saveConfig">保存</el-button>
</template>
</el-dialog>
</div>
</template>
-347
View File
@@ -1,347 +0,0 @@
<template>
<el-drawer v-model="isOpenHistoryPanel" direction="ltr" size="100%" @close="closeHistoryPanel" >
<div class="side">
<div v-show="loadComplete" class="load_complete">
<el-table :data="currentTasks" :empty-text="emptyText" :row-key="gallery=>gallery.gid">
<el-table-column type="expand" width="25px">
<template #default="props">
名字:{{ props.row.name}} <br>
链接:<el-link :href="props.row.link">Link</el-link> <br>
语言:{{props.row.language}} <br>
页数:{{props.row.pages}} <br>
文件大小:{{props.row.fileSize}}<br>
分辨率:{{props.row.resolution}}<br>
任务创建时间:{{props.row.createTimeDisplay}}<br>
标签:{{props.row.tag === '' ? '无': props.row.tag}} <br>
<span v-show="isLion">
downloader:{{props.row.downloader}}
</span>
<el-button @click="downloadTask(props.row.download)" :disabled="props.row.download === undefined">下载</el-button>
<el-button @click="deleteGallery(props.row.gid)" :disabled="props.row.download === undefined">删除</el-button>
<el-button @click="shareGallery({gid:props.row.gid, shortName:props.row.shortName + '.zip'})" v-if="isLion">分享</el-button>
<el-button @click="changeGalleryCollect(props.row.gid, props.row.isCollect)" :disabled="props.row.download === undefined">{{props.row.isCollect ? '取消收藏' : '收藏'}}</el-button>
<el-button @click="onlineGalleryReader(props.row.gid)" :disabled="props.row.download === undefined">在线看</el-button>
<el-button @click="showThumbnail(props.row)" :disabled="props.row.download === undefined">预览封面</el-button><br>
<el-button @click="editGalleryTag(props.row)" :disabled="props.row.download === undefined">编辑标签</el-button>
<el-button @click="updateGallery(props.row.link)" :disabled="props.row.download === undefined">更新本子</el-button>
</template>
</el-table-column>
<el-table-column label="名字" width="240px">
<template #default="scoped">
<span @click="showThumbnail(scoped.row)">
{{galleryNameType === 'shortName' ? scoped.row.shortName: scoped.row.name}}
</span>
</template>
</el-table-column>
<el-table-column label="进度" width="55px">
<template #default="scoped">
{{ scoped.row.progress }}
</template>
</el-table-column>
</el-table>
<el-row class="pageChanger">
<el-col>
<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: 165px">
<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>
</div>
</el-drawer>
<el-dialog v-model="isEditingTag" title="编辑本子标签" width="100%">
<el-form>
<el-form-item>
<template #label>
标签:
</template>
<template #default>
<el-tag v-for="tid in galleryForTag.tags" closable @close="disMark(galleryForTag.gid, tid)">
{{tags.get(tid).tag}}
</el-tag>
<el-autocomplete v-model="newTag" :fetch-suggestions="querySimilarTag" @select="handleTagSelect" ref="tagInput"/>
</template>
</el-form-item>
</el-form>
</el-dialog>
<OnlineReader :currentLinks="currentLinks" :isOnlineReading="isOnlineReading"/>
</template>
<script setup>
import store from "../store";
import {computed, ref} from "vue";
import axios from "axios";
import {ElMessage} from "element-plus";
import OnlineReader from "./OnlineReader.vue";
//输入
let inputNode = ref(null)
//是否正在编辑页数
let isEditingPage = ref(false)
//是否正在编辑标签
let isEditingTag = ref(false)
//临时变量
let galleryForTag = ref({})
let category = ref("myDownload") //myDownload myCollect total
let galleryNameType = ref("shortName") // shortName name
let sortType = ref("shortName") // shortName name createTime
let targetPage = ref(1) // 当前页数
let onlineReadingScrollbar = ref(null)
let loadComplete = computed(() => {
return store.state.loadComplete
})
let currentTasks = computed(() => {
return store.getters.currentTasks ? store.getters.currentTasks: null
})
let isOpenHistoryPanel = computed(() => {
return store.state.isShowHistory
})
let isOnlineReading = computed(() => {
return store.state.isOnlineReading
})
//标签
let tags = computed(() => {
return store.state.tags
})
let newTag = ref("")
let min = computed(() => {
return store.getters.min
})
let max = computed(() => {
if(targetPage.value > store.getters.max)
store.commit("_changePage", store.getters.max)
return store.getters.max
})
let page = computed(() => {
targetPage.value = store.state.page
return store.state.page
})
let isLion = computed(() => {
return store.state.userId === 3
})
let emptyText = computed(() => {
let action = category.value === 'myDownload' ? '下载': '收藏'
return '您未' + action + '本子'
})
//在线预览
let currentLinks = computed(() => {
return store.state.currentLinks
})
//翻页
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){
inputNode.value.focus()
}
targetPage.value = page.value
}
//改变展示类型
function changeCategory(){
store.commit("_setCategory", category.value)
}
function changeGalleryNameType(){
store.commit("_setShowNameType", galleryNameType.value)
}
function changeSortType(){
store.commit("_setSortType", sortType.value)
}
//收藏,编辑标签,提交编辑
function changeGalleryCollect(gid, isCollect){
if(isCollect)
store.dispatch("disCollectGallery", gid)
else
store.dispatch("collectGallery", gid)
}
function editGalleryTag(gallery){
galleryForTag.value = gallery
isEditingTag.value = true
}
let tagInput = ref()
function querySimilarTag(keyWord, cb){
let result = []
let hit = false //用于检测是否有重复标签
let skip //用于过滤已经有了的标签
for(const [key, tag] of store.state.tags){
skip = false
for(let id of galleryForTag.value.tags)
if(id === key) {
if(!hit && tag.tag === keyWord) //是否命中标签
hit = true
skip = true
break
}else{
console.log("id:", id, key)
}
if(skip)
continue
if(tag.tag.includes(keyWord))
result.push({value: tag.tag, tag:tag})
}
if(keyWord.trim() !== '' && !keyWord.includes("#") && !hit && !keyWord.includes("?") && result.length === 0)
result.push({value: '新建 #' + keyWord + ' 标签?', tag:{tag:keyWord}})
cb(result)
}
function handleTagSelect(data){
if(data.value.includes('#')) {
if(data.tag.tag.includes("?")) {
ElMessage("非法字符?")
return
}
else
store.dispatch("createTagAndMark", {tag: data.tag.tag.replace('#', ''), gid: galleryForTag.value.gid})
}
else
store.dispatch("mark", {gid:galleryForTag.value.gid, tid:data.tag.id})
newTag.value = ""
tagInput.value.blur()
}
function mark(gid, tid){
store.dispatch("mark", {gid, tid})
}
function disMark(gid, tid){
store.dispatch("disMark", {gid, tid})
}
//下载,删除,在线看
function downloadTask(link){
window.open(link)
}
function updateGallery(link){
store.dispatch("updateGallery", link)
}
function deleteGallery(gid){
store.dispatch("deleteGallery", gid)
}
function onlineGalleryReader(gid){
store.dispatch("queryOnlineLinks", gid)
}
function shareGallery(data){
const {gid, shortName} = data
let link
axios.post("http://downloader.lionwebsite.xyz/GalleryManage/share?userId=3&expireHour=3&gid=" + gid).then((res) => {
if(res.data.result === "success"){
let data = JSON.parse(res.data.data)
link = 'http://lionwebsite.xyz/GetFile/{0}?ShareCode={1}'.replace('{0}', encodeURIComponent(shortName)).replace('{1}', data.shareCode)
ElMessage({dangerouslyUseHTMLString: true,
message: "<span>分享成功, 过期时间:" + data.expireTime + "</span><br><a href=" + link + ">链接</a>",
duration: 0,
'show-close': true
})
navigator.clipboard.writeText(link)
}
else
ElMessage(res.data.data)
})
}
//显示缩略图
function showThumbnail(gallery){
store.commit("_changeThumbnailGallery", gallery)
setTimeout(() => {document.querySelector(".preview > img").click()}, 1)
}
//移动端
function closeHistoryPanel(){
store.commit("_closeHistoryPanel")
}
</script>
<style scoped>
.side{
text-align: center;
}
span{
display: block;
}
.pageChanger{
margin-top: 5vh;
}
.el-select{
width: 135px;
}
.page{
display: inline-block;
width: 50px;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
<script setup>
import store from "../store/index.js";
const tabs = [
{key: "tasks", label: "任务"},
{key: "search", label: "搜索"},
{key: "settings", label: "设置"},
];
</script>
<template>
<nav class="tab-bar" role="tablist" aria-label="主导航">
<button v-for="tab in tabs"
:key="tab.key"
type="button"
role="tab"
class="tab-item"
:class="{'is-active': store.state.activeTab === tab.key}"
:aria-selected="store.state.activeTab === tab.key"
@click="store.commit('_setActiveTab', tab.key)">
{{ tab.label }}
</button>
</nav>
</template>
+201
View File
@@ -0,0 +1,201 @@
<script setup>
import {computed, ref, watch} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import {ehThumbnailUrl, pickDefaultResolution} from "../utils/thumbnail.js";
// Two flows share this sheet: a task already in the list, and a remote gallery
// that was resolved from a link or from the online search.
const isOpen = ref(false);
const mode = ref("task");
const task = ref(null);
const targetResolution = ref("");
const chosenGallery = computed(() => store.state.chosenGallery);
const detailGallery = computed(() => store.state.detailGallery);
watch(chosenGallery, (gallery) => {
if (gallery) {
mode.value = "remote";
targetResolution.value = pickDefaultResolution(gallery.availableResolution);
isOpen.value = true;
}
});
watch(detailGallery, (gallery) => {
if (gallery) {
mode.value = "task";
task.value = gallery;
isOpen.value = true;
}
});
// Admin-only fields come from the backend's isAdmin flag, not a hardcoded id.
const isAdmin = computed(() => store.state.isAdmin);
const isDone = computed(() => (task.value ? task.value.status === "下载完成" : false));
const isCollect = computed(() => Boolean(task.value && task.value.isCollect));
const resolutions = computed(() => {
const gallery = chosenGallery.value;
if (!gallery || !gallery.availableResolution) return [];
return Object.entries(gallery.availableResolution).map(([resolution, fileSize]) => ({
resolution,
fileSize,
}));
});
// A resolved gallery still carries a raw path, so the proxy URL is built here.
const remoteThumb = computed(() => {
const gallery = chosenGallery.value;
if (!gallery) return "";
return ehThumbnailUrl(gallery.thumb_link || gallery.thumbnailUrl, store.state.AuthCode);
});
function close() {
isOpen.value = false;
if (mode.value === "remote") store.commit("_setChosenGallery", {gallery: false});
else store.commit("_setDetailGallery", null);
}
function downloadTask() {
if (!task.value) return;
if (task.value.download) window.open(task.value.download);
else ElMessage({message: "下载地址不存在,请刷新任务后重试", type: "error"});
}
function readOnline(gallery) {
if (!gallery) return;
close();
store.dispatch("readOnlineGallery", gallery);
}
function toggleCollect() {
if (!task.value) return;
const gallery = task.value;
gallery.isCollect = !gallery.isCollect;
if (gallery.isCollect) store.dispatch("collectGallery", gallery.gid);
else store.dispatch("disCollectGallery", gallery.gid);
}
function deleteTask() {
if (!task.value) return;
store.dispatch("deleteGallery", task.value.gid).then(close);
}
function submitTask() {
const gallery = chosenGallery.value;
if (!gallery) return;
if (targetResolution.value === "") {
ElMessage("请选择分辨率再提交");
return;
}
store.dispatch("postGalleryTask", {
link: gallery.link,
targetResolution: targetResolution.value,
});
close();
}
</script>
<template>
<div class="sheet-layer" v-show="isOpen">
<button type="button" class="sheet-scrim" aria-label="关闭详情" @click="close"></button>
<section class="sheet" role="dialog" aria-modal="true" aria-label="任务详情">
<span class="sheet-grab" aria-hidden="true"></span>
<template v-if="mode === 'task' && task">
<div class="sheet-head">
<span class="sheet-thumb">
<img v-if="task.thumb_link" :src="task.thumb_link" alt="" loading="lazy">
</span>
<div class="sheet-heading">
<p class="sheet-title">{{ task.name }}</p>
<div class="sheet-sub">
<span class="status-pill" :class="task.status === '下载完成' ? 'is-done' : (task.status === '下载中' || task.status === '压缩中' ? 'is-running' : 'is-waiting')">
{{ task.status }}
</span>
<span>{{ task.pages }} 页 · {{ task.language }} · {{ task.fileSize }}</span>
</div>
</div>
</div>
<div class="progress-line" v-if="task.progress && task.status !== '下载完成'">
<el-progress :percentage="parseInt(task.progress) || 0" :stroke-width="4" :show-text="false"/>
<span class="progress-value">{{ task.progress }}</span>
</div>
<div class="sheet-actions">
<button type="button" class="sheet-action is-primary" :disabled="!isDone" @click="downloadTask">下载</button>
<button type="button" class="sheet-action" @click="readOnline(task)">在线看</button>
<button type="button" class="sheet-action" :class="{'is-on': isCollect}" :disabled="!isDone" @click="toggleCollect">
{{ isCollect ? "已收藏" : "收藏" }}
</button>
</div>
<dl class="sheet-facts">
<dt>分辨率</dt>
<dd>{{ task.resolution || "—" }}</dd>
<dt>创建时间</dt>
<dd>{{ task.createTimeDisplay || "—" }}</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 class="sheet-foot">
<button type="button" class="sheet-button is-danger" :disabled="!isDone" @click="deleteTask">删除任务</button>
<button type="button" class="sheet-button" @click="close">关闭</button>
</div>
</template>
<template v-if="mode === 'remote' && chosenGallery">
<div class="sheet-head">
<span class="sheet-thumb">
<img v-if="remoteThumb" :src="remoteThumb" alt="" loading="lazy">
</span>
<div class="sheet-heading">
<p class="sheet-title">{{ chosenGallery.name }}</p>
<div class="sheet-sub">
<span v-if="chosenGallery.status">{{ chosenGallery.status }}</span>
<span v-if="chosenGallery.pages">{{ chosenGallery.pages }} 页</span>
<span v-if="chosenGallery.fileSize">{{ chosenGallery.fileSize }}</span>
</div>
</div>
</div>
<template v-if="resolutions.length">
<div class="sheet-actions is-single">
<button type="button" class="sheet-action" @click="readOnline(chosenGallery)">在线看</button>
</div>
<p class="sheet-section">目标分辨率</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">
{{ item.resolution }} · {{ item.fileSize }}
</button>
</div>
<div class="sheet-foot">
<button type="button" class="sheet-button" @click="close">取消</button>
<button type="button" class="sheet-button is-accent" @click="submitTask">提交下载</button>
</div>
</template>
<template v-else>
<div class="sheet-actions">
<button type="button" class="sheet-action is-primary" @click="downloadTask">下载文件</button>
<button type="button" class="sheet-action" @click="readOnline(chosenGallery)">在线看</button>
</div>
<div class="sheet-foot">
<button type="button" class="sheet-button" @click="close">关闭</button>
</div>
</template>
</template>
</section>
</div>
</template>
+63
View File
@@ -0,0 +1,63 @@
<script setup>
import {computed} from "vue";
import store from "../store/index.js";
const props = defineProps({
gallery: {type: Object, required: true},
retrying: {type: Boolean, default: false},
});
defineEmits(["open", "download", "retry"]);
// Only a running download reports a percentage; everything else uses the status text.
const percent = computed(() => {
const gallery = props.gallery;
if (!gallery || gallery.status !== "下载中" || !gallery.pages) return null;
const done = Number(gallery.proceeding) || 0;
return Math.min(100, Math.max(0, Math.round((done / gallery.pages) * 100)));
});
const title = computed(() => {
return store.state.galleryNameType === "name" ? props.gallery.name : props.gallery.shortName;
});
// States arrive as Chinese labels from the backend; map them to pill colours.
const statusClass = computed(() => {
const status = props.gallery.status;
if (status === "下载完成") return "is-done";
if (status === "下载中" || status === "压缩中") return "is-running";
return "is-waiting";
});
</script>
<template>
<div class="task-row">
<button type="button" class="task-row-open" @click="$emit('open', gallery)">
<span class="task-thumb">
<img v-if="gallery.thumb_link" :src="gallery.thumb_link" alt="" loading="lazy">
</span>
<span class="task-body">
<span class="task-title">{{ title }}</span>
<span class="task-meta">{{ gallery.pages }} 页 · {{ gallery.language }} · {{ gallery.fileSize }}</span>
</span>
<span class="task-foot">
<span class="status-pill" :class="statusClass">{{ gallery.status }}</span>
</span>
</button>
<span v-if="percent !== null" class="task-side task-side-num">{{ percent }}%</span>
<button v-else-if="gallery.status === '下载完成'"
type="button"
class="task-side task-side-act"
aria-label="下载文件"
@click="$emit('download', gallery)">下载</button>
<button v-else
type="button"
class="task-side task-side-muted"
:disabled="retrying"
aria-label="重试任务"
@click="$emit('retry', gallery)">{{ retrying ? "重试中" : "重试" }}</button>
<span v-if="percent !== null" class="task-row-edge"><i :style="{width: percent + '%'}"></i></span>
</div>
</template>
+147
View File
@@ -0,0 +1,147 @@
<script setup>
import {computed, ref} from "vue";
import {ElMessage} from "element-plus";
import store from "../store/index.js";
import TaskRow from "./TaskRow.vue";
const retryingGids = ref(new Set());
const refreshing = ref(false);
const tasks = computed(() => store.getters.visibleTasks);
const total = computed(() => store.getters.taskTotal);
const remaining = computed(() => store.getters.taskRemaining);
const connectionShort = computed(() => ({
connected: "已连接",
connecting: "连接中",
reconnecting: "重连中",
disconnected: "已断开",
})[store.state.connectionStatus]);
const isConnected = computed(() => store.state.connectionStatus === "connected");
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 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.state.currentTasks || []).forEach(task => {
if (task.downloaderName && !seen.has(task.downloader))
seen.set(task.downloader, task.downloaderName);
});
return [...seen].map(([id, name]) => ({id, name}));
});
const emptyText = computed(() => {
if (store.state.category === "myDownload") return "您未下载过";
if (store.state.category === "myCollect") return "您未收藏过";
return "暂无任务";
});
function openTask(gallery) {
store.commit("_setDetailGallery", gallery);
}
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);
}
}
function showMore() {
store.commit("_showMoreTasks");
}
async function refreshTasks() {
if (refreshing.value) return;
refreshing.value = true;
try {
await store.dispatch("updateGalleryTasks");
} finally {
refreshing.value = false;
}
}
</script>
<template>
<div class="view">
<header class="app-bar">
<h1>任务</h1>
<span class="app-bar-count">{{ total }} 项</span>
<span class="app-bar-spacer"></span>
<span class="connection" :class="{'is-connected': isConnected}" role="status">
<i class="connection-dot"></i>{{ connectionShort }}
</span>
</header>
<div class="filter-bar" v-if="store.state.username !== 'test'">
<label class="filter">
<span class="filter-label">范围</span>
<el-select v-model="category" size="small" class="filter-select filter-select-range">
<el-option value="myDownload" label="我的下载"/>
<el-option value="myCollect" label="我的收藏"/>
<el-option value="total" label="全部"/>
</el-select>
</label>
<label class="filter">
<span class="filter-label">排序</span>
<el-select v-model="sortType" size="small" class="filter-select filter-select-sort">
<el-option value="createTime" label="创建时间"/>
<el-option value="name" label="名字"/>
<el-option value="shortName" label="简洁名"/>
</el-select>
</label>
<label class="filter" v-if="isAdmin">
<span class="filter-label">下载人</span>
<el-select v-model="downloaderFilter" size="small"
class="filter-select filter-select-downloader"
clearable placeholder="全部">
<el-option v-for="item in downloaderOptions"
:key="item.id"
:value="item.id"
:label="item.name"/>
</el-select>
</label>
<button type="button" class="chip-button" :disabled="refreshing" @click="refreshTasks">
{{ refreshing ? "刷新中" : "刷新" }}
</button>
</div>
<div class="list-scroll">
<p v-if="tasks.length === 0" class="empty-state">{{ emptyText }}</p>
<TaskRow v-for="gallery in tasks"
:key="gallery.gid"
:gallery="gallery"
:retrying="retryingGids.has(gallery.gid)"
@open="openTask"
@download="downloadTask"
@retry="retryGallery"/>
<button v-if="remaining > 0" type="button" class="load-more" @click="showMore">
继续加载(还有 {{ remaining }} 项)
</button>
</div>
</div>
</template>
+1
View File
@@ -4,5 +4,6 @@ import 'element-plus/dist/index.css'
import element from "element-plus"
import "./reset.css"
import 'element-plus/theme-chalk/dark/css-vars.css'
import "./styles/theme.css"
createApp(App).use(element).mount('#app')
+42
View File
@@ -0,0 +1,42 @@
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
+283 -428
View File
@@ -2,61 +2,63 @@ import vuex from "vuex"
import axios from "axios"
import {ElMessage} from "element-plus"
import qs from "qs"
const BaseUrl = "http://downloader.lionwebsite.xyz/"
import {createTaskSocket} from "../utils/taskSocket.js"
let taskSocket
let taskRefreshVersion = 0
const BaseUrl = "https://downloader.lionwebsite.xyz/"
const GalleryManageUrl = BaseUrl + "GalleryManage"
const actions = {
updateGalleryTasks(context, type){
axios.get(GalleryManageUrl, {
params:{
type,
AuthCode: state.AuthCode
}
}).then((res) => {
if(res.data.result === "success") {
let tasks = JSON.parse(res.data.data)
if (type === "all" && state.galleryRefreshTimer === 0) { //判断是否有未下载完成的本子以及定时更新是否开启
for (let i = tasks.length - 1; i > tasks.length - 11; i--) //从后往前遍历十个本子,查看是否有未下载完成的本子
if (tasks[i].status !== "下载完成") {
state.galleryRefreshTimer = setInterval(() => {
context.dispatch("updateGalleryTasks", "undone").then()
}, 20000)
break
}
}
context.commit("_updateGalleryTasks", {tasks, type})
// Axios 全局错误处理
axios.interceptors.response.use(
response => response,
error => {
ElMessage({message: "网络请求失败: " + (error.message || ""), type: "error"})
return Promise.reject(error)
}
)
else if(type === 'undone') {
context.dispatch("updateGalleryTasks", "all").then()
clearInterval(state.galleryRefreshTimer)
state.galleryRefreshTimer = 0
const actions = {
reconnect(context) {
axios.post(GalleryManageUrl + "/reconnect", null, {
params: {
AuthCode: context.state.AuthCode
}
}).then(res => {
if (res.data.result === "success") {
ElMessage("重连成功")
} else {
ElMessage("重连失败")
}
})
},
updateGalleryTasks(context){
const version = ++taskRefreshVersion
return axios.get(GalleryManageUrl, {
params:{
AuthCode: context.state.AuthCode,
type: 'all'
}
}).then((res) => {
if(res.data.result === "success" && version === taskRefreshVersion)
context.commit("_updateGalleryTasks", JSON.parse(res.data.data))
})
},
postGalleryTask(context, data){
axios.post(GalleryManageUrl, qs.stringify({
AuthCode: state.AuthCode,
link: data.link,
targetResolution: data.targetResolution,
tags:data.tags
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,
resolution: data.targetResolution,
tags: data.tags})
if(state.galleryRefreshTimer === 0)
state.galleryRefreshTimer = setInterval(() => {
context.dispatch("updateGalleryTasks", "undone").then()
}, 20000)
context.commit("_setChosenGallery", {gallery: false})
}
else{
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){
@@ -64,157 +66,78 @@ const actions = {
params:{
param: link,
type: 'link',
AuthCode: state.AuthCode
AuthCode: context.state.AuthCode
}
}).then((res) => {
if(res.data.result === 'success')
context.commit("_setChosenGallery", {gallery: JSON.parse(res.data.data)})
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("查询失败")
})
},
queryOnlineLinks(context, gid){
// 如果本地有缓存,则直接返回,没有再请求
if(context.state.onlineLinks[gid] !== undefined){
context.commit("_setOnlineLinks", gid)
}
else {
axios.get(GalleryManageUrl + "/onlineLinks", {
params: {
AuthCode: state.AuthCode,
gid,
}
}).then((res) => {
if (res.data.result === "success")
context.commit("_addAndSetOnlineLinks", {gid, links: res.data.data})
else
ElMessage(res.data.data)
})
}
},
updateGallery(context, link){
axios.post(GalleryManageUrl + "/update", qs.stringify({AuthCode: state.AuthCode, link}))
.then((res) => {
if(res.data.result === 'success' && state.galleryRefreshTimer === 0){
setTimeout(() => {
context.dispatch("updateGalleryTasks", "all").then()
}, 5000)
state.galleryRefreshTimer = setInterval(() => {
context.dispatch("updateGalleryTasks", "undone").then()
}, 20000)
}
ElMessage(res.data.data)
})
},
validate(context, AuthCode){
axios.post(BaseUrl + "validate?AuthCode=" + AuthCode).then((res)=>{
if(res.data.result === 'success'){
context.commit("_authed", {AuthCode, ...JSON.parse(res.data.data)})
let data = JSON.parse(res.data.data);
if(!data.isAvailable)
ElMessage({duration:0, message:"节点挂了,不能下也不能看,找狮子处理", type: "error"})
context.commit("_authed", {AuthCode, ...data})
//初始化
context.dispatch("loadTags", true).then()
context.dispatch("loadWeekUsedAmount").then()
context.dispatch("updateGalleryTasks", "all").then(() => confirmCurrentTask(context.state))
//手机不需要设置宽度
context.dispatch("updateGalleryTasks").then(() => confirmCurrentTask(context.state))
context.dispatch("initWebsocket").then()
}
else
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){
axios.get(GalleryManageUrl + "/weekUsedAmount", {
return axios.get(GalleryManageUrl + "/weekUsedAmount", {
params: {
AuthCode: state.AuthCode
AuthCode: context.state.AuthCode
}
}).then((res) => {
if(res.data.result === "success"){
context.state.weekUsed = JSON.parse(res.data.data)
ElMessage("查询用量成功")
}else
ElMessage("查询用量失败")
})
},
loadTags(context, isShowTip){
axios.get(GalleryManageUrl + "/allTag", {
params: {
AuthCode: state.AuthCode
}
}).then((res) => {
if(res.data.result === "success"){
context.commit("_loadTags", JSON.parse(res.data.data))
if(isShowTip)
ElMessage("加载标签成功")
}else
ElMessage(res.data.data)
})
},
postTag(context, tag){
axios.post(GalleryManageUrl + "/tag?" + qs.stringify({
tag,
AuthCode: state.AuthCode
})
).then((res) => {
if (res.data.result === 'success') {
ElMessage('创建标签成功')
context.commit("_postTag", {tag:tag, id:parseInt(res.data.tid), usage: 0})
}
else
ElMessage(res.data.data)
})
},
deleteTag(context, tid){
axios.delete(GalleryManageUrl + "/tag?" + qs.stringify({
tid,
AuthCode: state.AuthCode
})).then((res) => {
if (res.data.result === 'success') {
ElMessage('删除标签成功')
context.commit("_deleteTag", tid)
}
else
ElMessage(res.data.data)
})
},
mark(context, data){
data.AuthCode = context.state.AuthCode
axios.post(GalleryManageUrl + "/mark?" + qs.stringify(data)).then((res) => {
if(res.data.result === 'success'){
ElMessage("标记成功")
context.commit("_mark", data)
}else{
ElMessage(res.data.data)
}
})
},
disMark(context, data){
data.AuthCode = context.state.AuthCode
axios.post(GalleryManageUrl + "/disMark?" + qs.stringify(data)).then((res) => {
if(res.data.result === 'success'){
ElMessage("取消标记成功")
context.commit("_disMark", data)
}else {
ElMessage(res.data.data)
}
})
},
createTagAndMark(context, data){
data.AuthCode = context.state.AuthCode
axios.post(GalleryManageUrl + '/tagAndMark?' + qs.stringify(data)).then((res) => {
if(res.data.result === 'success') {
ElMessage("创建标签并标记成功")
context.commit("_mark", {tid:parseInt(res.data.tid), gid: data.gid, usage: 1, tag:data.tag})
}else {
ElMessage(res.data.data)
}
})
},
collectGallery(context, gid){
axios.post(GalleryManageUrl + "/collect?" +qs.stringify( {
gid,
AuthCode:state.AuthCode
gid, AuthCode:context.state.AuthCode
})).then((res) => {
ElMessage(res.data.data)
if(res.data.result === 'success')
@@ -222,28 +145,18 @@ const actions = {
})
},
disCollectGallery(context, gid){
axios.post(GalleryManageUrl + "/disCollect?" + qs.stringify(
{
gid,
AuthCode:state.AuthCode
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)
})
},
updateGalleryTag(context, data){
axios.post(GalleryManageUrl + "/tag?" + qs.stringify(data)).then((res) => {
ElMessage(res.data.data)
if(res.data.result === 'success')
context.commit("_updateGalleryTag", data)
})
},
deleteGallery(context, gid){
axios.delete(GalleryManageUrl, {
return axios.delete(GalleryManageUrl, {
params:{
AuthCode:state.AuthCode,
gid
AuthCode:context.state.AuthCode, gid
}}).then((res) => {
if(res.data.result === "success"){
ElMessage("删除成功")
@@ -253,22 +166,57 @@ const actions = {
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': state.AuthCode, 'newAuthCode': 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") === state.AuthCode)
if(localStorage.getItem("auth") === context.state.AuthCode)
localStorage.setItem("auth", AuthCode)
state.AuthCode = 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++){
@@ -280,58 +228,49 @@ const mutations = {
},
_disCollectGallery(state, gid){
let index
for(let i=0; i < state.collectGallery.length; i++){
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, data){
let {tasks, type} = data
if(type === 'all') {
_updateGalleryTasks(state, tasks){
state.totalGalleryTask.splice(0)
state.collectGallery.slice(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 "已提交":
task.progress = "已提交"
case "等待压缩":
case "压缩中":
task.progress = task.status
break;
case "下载中":
task.progress = (Math.round((task.proceeding / task.pages) * 100)).toString() + "%"
break;
case "下载完成":
task.progress = "下载完成"
let tempLink
tempLink = task.link.replace("element-plus.org", "exhentai.org").replace("element.org", "e-hentai.org")
task.download = GalleryManageUrl + "/file/" + encodeURI(task.name.replace("#", "")) + "?link=" + tempLink + "&AuthCode=" + state.AuthCode
task.progress = task.status
task.download = buildGalleryDownloadUrl(task.gid, state.AuthCode)
break;
}
//处理时间戳
task.createTimeDisplay = new Date(task.createTime * 1000).toLocaleString("zh")
//处理标签
if ('tags' in task) {
task.tag = ''
task.tags.forEach((tid) => {
task.tag += ' ' + state.tags.get(tid).tag
})
task.tag = task.tag.trim()
}else{
task.tag = ""
task.tags = []
}
//处理是否收藏
if('isCollect' in task)
state.collectGallery.push(task)
@@ -344,59 +283,50 @@ const mutations = {
state.totalGalleryTask.push(task)
})
}
else {
let tempArray = Array.from(state.totalGalleryTask)
state.totalGalleryTask.splice(0)
let preDeleteIndex
tempArray.forEach((task) => {
preDeleteIndex = -1
if(task.status !== "下载完成")
for(let i=0; i < tasks.length; i++)
if(tasks[i].name === task.name) {
preDeleteIndex = i
task.status = tasks[i].status
task.proceeding = tasks[i].proceeding
if (task.proceeding === 0)
task.progress = task.status
else
task.progress = (Math.round((task.proceeding / task.pages) * 100)).toString() + "%"
}
if(preDeleteIndex !== -1)
delete tasks[preDeleteIndex]
state.totalGalleryTask.push(task)
})
}
switch (state.sortType) {
case "name":
state.totalGalleryTask = state.totalGalleryTask.sort((before, after) => {
return before.name > after.name ? 1: -1
})
break
case "shortName":
state.totalGalleryTask = state.totalGalleryTask.sort((before, after) => {
return before.shortName > after.shortName ? 1: -1
})
break
case "createTime":
state.totalGalleryTask = state.totalGalleryTask.sort((before, after) => {
return before.createTime - after.createTime
})
}
sortTasks(state)
if(state.isAuth && !state.loadComplete){
state.loadComplete = true
ElMessage("加载完成")
}
},
_updateGalleryTaskProceeding(state, tasks){
let galleries = Array.from(state.totalGalleryTask)
state.totalGalleryTask.splice(0)
galleries.forEach((gallery) => {
if(gallery.status !== '下载完成')
tasks.forEach((task) => {
if(task.gid === gallery.gid){
gallery.status = status[task.status]
gallery.proceeding = task.proceeding
if(gallery.status === '下载中')
gallery.progress = (Math.round((gallery.proceeding / gallery.pages) * 100)).toString() + "%"
else
gallery.progress = gallery.status
}
})
state.totalGalleryTask.push(gallery)
})
},
_changePage(state, targetPage){
state.page = targetPage
},
_setActiveTab(state, tab){
state.activeTab = tab
},
_setDetailGallery(state, gallery){
state.detailGallery = gallery
},
// The mobile list grows in place instead of paging through a footer control.
_showMoreTasks(state){
state.visiblePages += 1
},
_authed(state, data){
state.AuthCode = data.AuthCode
state.userId = data.userId
state.username = data.username
state.isAdmin = Boolean(data.isAdmin)
state.downloaderFilter = null
state.isAuth = true
ElMessage("验证成功,加载中")
},
@@ -408,77 +338,45 @@ const mutations = {
ElMessage("授权码错误")
localStorage.removeItem("auth")
},
_loadTags(state, tags){
state.tags.clear()
for(let i in tags)
state.tags.set(parseInt(i), tags[i])
},
_postTag(state, tag){
state.tags.set(tag.id, tag)
},
_deleteTag(state, tid){
state.tags.delete(tid)
},
_mark(state, data){
state.totalGalleryTask.forEach((gallery) => {
if(gallery.gid === data.gid){
gallery.tags.push(data.tid)
if('tag' in data) //新建的tag
state.tags.set(data.tid, {id:data.tid, tag: data.tag, usage:data.usage})
else
state.tags.get(data.tid).usage++
generateNewTag(state, gallery)
}
})
},
_disMark(state, data){
state.totalGalleryTask.forEach((gallery) => {
if(gallery.gid === data.gid){
let index
for(index=0; index<gallery.tags.length; index++){
if(gallery.tags[index] === data.tid){
gallery.tags.splice(index, 1) //删除tid
state.tags.get(data.tid).usage--
break;
}
}
generateNewTag(state, gallery)
}
})
},
_searchLocalByLink(state, link){
let tasks = state.currentTasks
let i = 0
let gid = null
let gid = link.split("/")[4]
let matched = null
let name = null
if(state.showType === "gallery")
gid = link.split("/")[4]
if(gid === null)
for (i = 0; i < tasks.length; i++) {
if(gid === undefined)
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].link === link) {
state.page = Math.floor(i / state.length) + 1
matched = tasks[i]
name = tasks[i].name
break
}
}
else for (i = 0; i < tasks.length; i++)
if (tasks[i].gid === gid) {
else for (let i = 0; i < tasks.length; i++)
if (String(tasks[i].gid) === gid) {
state.page = Math.floor(i / state.length) + 1
matched = tasks[i]
name = state.sortType === "shortName" ? tasks[i].shortName: tasks[i].name
break
}
if(!name)
ElMessage("未找到此任务")
else
else {
state.searchTask.splice(0)
if(matched)
state.searchTask.push(matched)
state.isSearch = true
state.visiblePages = 1
ElMessage("已跳转到该任务所在页数,任务名:" + name)
}
},
_searchLocalByKeyword(state, keyword){
state.searchTask.splice(0)
if(keyword.trim() !== '') {
state.page = 1
state.visiblePages = 1
let tasks = state.currentTasks
tasks.forEach((task) => {
if (task.name.includes(keyword))
@@ -496,127 +394,78 @@ const mutations = {
confirmCurrentTask(state)
}
},
_searchLocalByTag(state, tidS) {
state.searchTask.splice(0)
state.page = 1
let hitAmount
let tasks = state.currentTasks
if (tidS.length > 0) {
tasks.forEach((task) => {
if(tidS.length <= task.tags.length) {
hitAmount = 0
for(let i=0; i<task.tag.length; i++){
for(let j=0; j<tidS.length; j++){
if(task.tags[i] === tidS[j])
hitAmount ++
}
}
if (hitAmount === tidS.length)
state.searchTask.push(task)
}
})
if (state.searchTask.length === 0) {
ElMessage("未找到符合这些tag的任务")
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){
if(data.gallery === false) {
state.chosenGallery.shortName = getShortname(state.chosenGallery.name)
state.chosenGallery.resolution = data.resolution
state.chosenGallery.fileSize = "等待下载完成后再查看"
state.chosenGallery.createTimeDisplay = "等待下载完成后再查看"
state.chosenGallery.progress = "已提交"
state.chosenGallery.tags = []
state.chosenGallery.downloader = state.userId
if(data.tags.length > 0){
for (let tag of data.tags) {
state.chosenGallery.tags.push(parseInt(tag))
}
}
generateNewTag(state, state.chosenGallery)
state.chosenGallery.downloader = state.userId
state.totalGalleryTask.push(state.chosenGallery)
state.downloadGallery.push(state.chosenGallery)
}
state.chosenGallery = data.gallery
},
_setCategory(state, category){
state.category = category
state.visiblePages = 1
// 换分类后旧的下载人筛选可能在新分类里根本不存在,先清掉。
state.downloaderFilter = null
confirmCurrentTask(state)
sortTasks(state)
},
_setSortType(state, sortType){
state.sortType = sortType
state.visiblePages = 1
sortTasks(state)
},
_setGalleryNameType(state, galleryNameType){
state.galleryNameType = galleryNameType
},
_setDownloaderFilter(state, downloader){
state.downloaderFilter = downloader === null || downloader === undefined
? null
: Number(downloader)
state.visiblePages = 1
},
_setShowNameType(state, type){
if(type === "shortName")
state.length = state.shortLength
else
state.length = state.defaultLength
},
_addAndSetOnlineLinks(state, data){
let links = JSON.parse(data.links)
state.onlineLinks[data.gid] = []
state.currentLinks.splice(0)
state.currentGid = data.gid
links.forEach((link) => {
state.onlineLinks[data.gid].push('http://downloader.lionwebsite.xyz/GalleryManage/onlineImage/' + link + '?gid=' + state.currentGid + '&AuthCode=' + state.AuthCode)
state.currentLinks.push('http://downloader.lionwebsite.xyz/GalleryManage/onlineImage/' + link + '?gid=' + state.currentGid + '&AuthCode=' + state.AuthCode)
})
state.isOnlineReading = true
_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;
},
_setOnlineLinks(state, gid){
state.currentLinks.splice(0)
state.onlineLinks[gid].forEach((link) => {
state.currentLinks.push(link)
})
state.currentGid = gid
state.isOnlineReading = true
_closeReader(state){
state.isReading = false;
},
_openHistoryPanel(state){
state.isShowHistory = true
},
_closeHistoryPanel(state){
state.isShowHistory = false
},
_changeThumbnailGallery(state, gallery){
state.thumbnailGallery = gallery
state.thumbnailGallery.url = GalleryManageUrl + "/thumbnail/" + encodeURIComponent(gallery.name) + ".webp?AuthCode="+state.AuthCode
}
}
const state = {
totalGalleryTask: [], //存放本子数据的数组
chosenGallery: false, //准备下载的本子
thumbnailGallery: {}, //预览本子
collectGallery: [], //收藏的本子
downloadGallery: [], //下载的本子
tags: new Map(), //可用tag
connectionStatus: "disconnected", // live task updates
websocket: null, //websocket
totalGalleryTask: [], //存放数据的数组
chosenGallery: false, //准备下载
collectGallery: [], //收藏
downloadGallery: [], //下载
isSearch: false, //用于决定是否显示搜索结果
onlineLinks: {}, //在线本子链接
isOnlineReading: false, //是否在线看
readingGallery: {'name': '', 'images': []}, //在线看
isReading: false, //是否正在看
currentGid: "", //当前GID
currentLinks: [], //当前本子链接
lengthPerPage: 0, //在线预览每页图片数量
page: 1, //当前页数
@@ -625,38 +474,50 @@ const state = {
shortLength: 5, //简洁个数
userId: -1, //用户id
username: ",", //用户名
username: "", //用户名
isAuth: false, //是否授权
AuthCode: '', //授权码
loadComplete: false, //是否加载完成
galleryRefreshTimer: 0, //本子更新计时器id
galleryRefreshTimer: 0, //更新计时器id
isInclude: false, //是否搜索到任务
searchTask: [], //搜索到的任务
isShowHistory: false, //是否打开面板
category: 'myDownload', //分类
sortType:'shortName', //排序类型
activeTab: 'tasks', //移动端底部标签 tasks search settings
detailGallery: null, //详情抽屉里正在看的任务
visiblePages: 1, //列表已展开的页数,用于“继续加载”
galleryNameType: 'shortName', //名字类型 shortName name
category: 'myDownload', //分类 myDownload myCollect total
sortType:'shortName', //排序类型 shortName name createTime
currentTasks: [], //当前任务
weekUsed: {}, //每周用量
isAdmin: false, //管理员(后端 validate 下发)
downloaderFilter: null, //管理员按下载人筛选,null 表示全部
}
const getters = {
currentTasks(state){
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)
const tasks = activeTaskList(state)
return tasks.slice((state.page - 1) * state.length, state.page * state.length)
},
min(){
return 1
},
visibleTasks(state){
return activeTaskList(state).slice(0, state.visiblePages * state.length)
},
taskTotal(state){
return activeTaskList(state).length
},
taskRemaining(state, getters){
return Math.max(0, getters.taskTotal - state.visiblePages * state.length)
},
searchResults(state){
return (state.searchTask || []).slice(0, state.visiblePages * state.length)
},
max(state){
let max = 0
let tasks
if(state.isSearch)
tasks = state.searchTask
else
tasks = state.currentTasks
const tasks = activeTaskList(state)
if(!tasks)
return 1
@@ -676,38 +537,39 @@ export default new vuex.Store({
getters
})
/**
* 当前视图实际要展示的任务。
* 下载人筛选只在管理员生效,作用于「我的下载/收藏/全部」三种分类的结果。
*/
function activeTaskList(state){
if(state.isSearch)
return state.searchTask
if(state.isAdmin && state.downloaderFilter !== null)
return state.currentTasks.filter(task => task.downloader === state.downloaderFilter)
return state.currentTasks
}
function getShortname(name){
if (name.includes("[")) {
let lastIndex = name.lastIndexOf("[")
name = name.substring(0, lastIndex)
while (name.includes("[") && name.includes("]")) {
let start = name.indexOf("[")
let end = name.indexOf("]") + 1
let temp = name.substring(start, end)
temp = name.replace(temp, "")
if(temp.trim() === ""){
name = name.replace("[", "").replace("]", "")
break
if(name === null)
return null
if (!name.includes("[")) return name
// 截取最后一个 [ 之前的部分,然后移除所有 [...] 和 (...) 标签
const trimmed = name.substring(0, name.lastIndexOf("["))
.replace(/\s*\[[^\]]*\]\s*/g, '')
.replace(/\s*\([^\)]*\)\s*/g, '')
.trim()
// 名字以 [...] 开头时截取结果为空,退回原名避免整列空白
return trimmed === '' ? name.trim() : trimmed
}
else
name = temp
}
while (name.includes("(") && name.includes(")")) {
let start = name.indexOf("(")
let end = name.indexOf(")") + 1
let temp = name.substring(start, end)
temp = name.replace(temp, "")
if(temp.trim() === ""){
name = name.replace("(", "").replace(")", "")
break
}
else
name = temp
}
return name.trim()
} else {
return name
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){
@@ -748,16 +610,9 @@ function deleteTask(tasks, key, value){
for(let i=0; i<tasks[j].length; i++)
if(tasks[j][i][key] === value){
tasks[j].splice(i, 1)
i--
break
}
}
function generateNewTag(state, gallery){
let tag = ''
console.log(state.tags)
console.log(gallery.tags)
gallery.tags.forEach((tid) => {
tag += state.tags.get(tid).tag + ' '
})
gallery.tag = tag.trim()
}
let status = ['已提交', '下载中', '等待压缩', '压缩中', '下载完成']
+1093
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
function imageItem(url, index) {
return {url, src: url, index, attempt: 0, failed: false, settled: false}
}
export function startImages(urls) {
return urls.length ? [imageItem(urls[0], 0)] : []
}
export function settleImage(items, urls, item, failed = false) {
// Ignore events from images removed by a page change or dialog close.
if (!items.includes(item)) return
item.failed = failed
if (item.settled) return
item.settled = true
if (items.length < urls.length)
items.push(imageItem(urls[items.length], items.length))
}
export function retryImage(item) {
item.failed = false
item.attempt++
item.src = item.url + (item.url.includes('?') ? '&' : '?') + 'retry=' + item.attempt
}
+76
View File
@@ -0,0 +1,76 @@
export function createTaskSocket({url, onOpen, onMessage, onState,
createSocket = address => new WebSocket(address),
setTimer = setTimeout, clearTimer = clearTimeout}) {
let socket = null
let retryTimer
let connectTimer
let attempts = 0
let stopped = true
function detach() {
clearTimer(connectTimer)
connectTimer = undefined
if (socket) {
const previous = socket
socket = null
previous.onopen = previous.onclose = previous.onerror = previous.onmessage = null
try { previous.close() } catch { /* already closed */ }
}
}
function schedule() {
if (stopped) return
onState('reconnecting')
const delay = Math.min(1000 * 2 ** attempts, 30000)
attempts = Math.min(attempts + 1, 5)
clearTimer(retryTimer)
retryTimer = setTimer(connect, delay)
}
function connect() {
if (stopped) return
clearTimer(retryTimer)
retryTimer = undefined
onState(attempts ? 'reconnecting' : 'connecting')
let current
try { current = socket = createSocket(url) }
catch { schedule(); return }
const retry = () => {
if (stopped || socket !== current) return
detach()
schedule()
}
current.onopen = () => {
if (stopped || socket !== current) return
clearTimer(connectTimer)
connectTimer = undefined
attempts = 0
try { current.send('DownloaderWebsocket') }
catch { retry(); return }
onState('connected')
onOpen()
}
current.onmessage = event => {
if (!stopped && socket === current) onMessage(event)
}
current.onclose = retry
current.onerror = retry
connectTimer = setTimer(retry, 10000)
}
return {
start() {
if (!stopped) return
stopped = false
attempts = 0
connect()
},
stop() {
stopped = true
clearTimer(retryTimer)
retryTimer = undefined
detach()
onState('disconnected')
}
}
}
+27
View File
@@ -0,0 +1,27 @@
const BaseUrl = "https://downloader.lionwebsite.xyz/";
// Remote galleries carry a raw thumbnail path; it only resolves through the
// backend proxy once the active auth code is attached.
export function ehThumbnailUrl(path, AuthCode) {
if (!path) return "";
return BaseUrl + "GalleryManage/ehThumbnail?" + new URLSearchParams({path, AuthCode}).toString();
}
const UNIT_SCALE = {b: 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3, t: 1024 ** 4};
function sizeInBytes(text) {
const match = String(text).match(/([\d.]+)\s*([kmgt]?)(?:i?b)/i);
if (!match) return 0;
const scale = UNIT_SCALE[match[2].toLowerCase()] ?? 1;
return Number(match[1]) * scale;
}
// Prefer the original file; otherwise take whichever entry reports the most bytes.
export function pickDefaultResolution(availableResolution) {
const entries = Object.entries(availableResolution || {});
if (entries.length === 0) return "";
const original = entries.find(([resolution]) => /original/i.test(resolution));
if (original) return original[0];
return entries.reduce((best, current) =>
sizeInBytes(current[1]) > sizeInBytes(best[1]) ? current : best)[0];
}
+8
View File
@@ -0,0 +1,8 @@
export function validateLink(rawLink){
if(rawLink.trim() === "")
return false
if(rawLink.includes("hentai"))
return rawLink.includes("/g/")
else
return false
}
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {startImages, settleImage, retryImage} from '../src/utils/progressiveImages.js'
test('a failed image does not prevent later pages from loading', () => {
const urls = ['first', 'second', 'third']
const items = startImages(urls)
settleImage(items, urls, items[0], true)
assert.equal(items[0].failed, true)
assert.equal(items[1].url, 'second')
settleImage(items, urls, items[1])
assert.equal(items[2].url, 'third')
})
test('retry does not append a duplicate page and bypasses a failed URL cache', () => {
const urls = ['first?gid=1', 'second', 'third']
const items = startImages(urls)
settleImage(items, urls, items[0], true)
retryImage(items[0])
assert.equal(items[0].src, 'first?gid=1&retry=1')
settleImage(items, urls, items[0])
assert.equal(items.length, 2)
assert.equal(items[0].failed, false)
})
test('late image events cannot append content after changing pages', () => {
const old = startImages(['old'])[0]
const urls = ['new', 'next']
const items = startImages(urls)
settleImage(items, urls, old)
assert.equal(items.length, 1)
assert.deepEqual(startImages([]), [])
})
+95
View File
@@ -0,0 +1,95 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import vm from 'node:vm'
function fixture() {
let code = fs.readFileSync(new URL('../src/store/index.js', import.meta.url), 'utf8')
.replace(/^import .*$/gm, '')
.replace('export default new vuex.Store(', 'globalThis.store = new vuex.Store(')
const messages = []
const showMessage = message => messages.push(message)
showMessage.error = message => messages.push(message)
const axios = {interceptors: {response: {use() {}}}}
const context = vm.createContext({axios, vuex: {Store: function (config) {Object.assign(this, config)}},
ElMessage: showMessage, URLSearchParams, console,
qs: {stringify: () => ''}})
vm.runInContext(code, context)
const store = context.store
const api = {state: store.state,
commit: (name, data) => store.mutations[name](store.state, data),
dispatch: (name, data) => Promise.resolve(store.actions[name](api, data))}
return {store, messages, axios, api}
}
test('link search matches a numeric gid and jumps to the right page', () => {
const {store, messages} = fixture()
store.state.currentTasks = [{gid: 1}, {gid: 2}, {gid: 12345, name: 'sample', shortName: 'sample'}]
store.state.length = 2
store.mutations._searchLocalByLink(store.state, 'https://example.org/g/12345/key/')
assert.equal(store.state.page, 2)
assert.match(messages[0], /已跳转/)
})
test('missing gid still reports no match', () => {
const {store, messages} = fixture()
store.state.currentTasks = [{gid: 12345}]
store.mutations._searchLocalByLink(store.state, 'https://example.org/g/999/key/')
assert.equal(messages[0], '未找到此任务')
})
test('submission reloads persisted status without duplicating a completed task', async () => {
const {store, axios, api} = fixture()
const gallery = {gid: 123, name: 'sample', status: '下载完成', thumb_link: '', createTime: 1, downloader: 7}
store.state.chosenGallery = {...gallery, status: '已提交'}
store.state.totalGalleryTask.push(gallery)
axios.post = async () => ({data: {result: 'success'}})
axios.get = async url => ({data: {result: 'success', data: JSON.stringify(url.endsWith('weekUsedAmount') ? {} : [gallery])}})
await store.actions.postGalleryTask(api, {targetResolution: 'original'})
assert.equal(store.state.totalGalleryTask.length, 1)
assert.equal(store.state.totalGalleryTask[0].status, '下载完成')
assert.equal(store.state.chosenGallery, false)
})
test('gallery management subrequests carry the active auth code', async () => {
const {store, axios, api} = fixture()
store.state.AuthCode = 'test-auth'
let call
axios.post = async (...args) => {
call = args
return {data: {result: args[0].endsWith('/cache') ? 'failure' : 'success', data: 'expected test response'}}
}
await store.actions.reconnect(api)
assert.equal(call[0], 'https://downloader.lionwebsite.xyz/GalleryManage/reconnect')
assert.equal(call[1], null)
assert.equal(call[2].params.AuthCode, 'test-auth')
await store.actions.readOnlineGallery(api, {link: 'https://example.org/g/1/key/', images: []})
assert.equal(call[0], 'https://downloader.lionwebsite.xyz/GalleryManage/cache')
assert.equal(call[1], null)
assert.deepEqual({...call[2].params}, {
url: 'https://example.org/g/1/key/',
AuthCode: 'test-auth'
})
})
test('thumbnail and online image URLs carry the active auth code', () => {
const {store} = fixture()
store.state.AuthCode = 'code with spaces'
store.mutations._updateGalleryTasks(store.state, [{
gid: 123,
name: 'sample',
status: '已提交',
thumb_link: 'https://example.org/thumb?a=1&b=2',
createTime: 1,
downloader: 7
}])
const thumbnail = new URL(store.state.totalGalleryTask[0].thumb_link)
assert.equal(thumbnail.searchParams.get('AuthCode'), 'code with spaces')
assert.equal(thumbnail.searchParams.get('path'), 'https://example.org/thumb?a=1&b=2')
store.mutations._setReadingGallery(store.state, {gid: 123, pages: 1})
const image = new URL(store.state.readingGallery.images[0])
assert.equal(image.searchParams.get('gid'), '123')
assert.equal(image.searchParams.get('AuthCode'), 'code with spaces')
})
+63
View File
@@ -0,0 +1,63 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {createTaskSocket} from '../src/utils/taskSocket.js'
function fixture() {
const sockets = [], states = [], messages = [], timers = new Map()
let id = 0, opens = 0
const manager = createTaskSocket({url: 'wss://example.invalid',
onOpen: () => opens++, onState: value => states.push(value), onMessage: event => messages.push(event.data),
setTimer: (fn, delay) => { timers.set(++id, {fn, delay}); return id },
clearTimer: key => timers.delete(key),
createSocket: () => {
const socket = {sent: [], send(value) { this.sent.push(value) }, close() { this.closed = true }}
sockets.push(socket)
return socket
}
})
function tick(delay) {
const entry = [...timers].find(([, timer]) => timer.delay === delay)
assert.ok(entry, `expected timer ${delay}`)
timers.delete(entry[0]); entry[1].fn()
}
return {manager, sockets, states, messages, timers, tick, opens: () => opens}
}
test('reconnects and refreshes after each successful connection', () => {
const f = fixture(); f.manager.start()
f.sockets[0].onopen()
assert.deepEqual(f.sockets[0].sent, ['DownloaderWebsocket'])
f.sockets[0].onclose()
f.tick(1000)
f.sockets[1].onopen()
assert.equal(f.opens(), 2)
assert.equal(f.states.at(-1), 'connected')
assert.equal(f.timers.size, 0)
f.manager.stop()
})
test('backs off and times out connection attempts', () => {
const f = fixture(); f.manager.start()
f.tick(10000)
assert.equal(f.sockets[0].closed, true)
f.tick(1000); f.sockets[1].onerror(); f.tick(2000)
f.sockets[2].onerror(); f.tick(4000)
assert.equal(f.sockets.length, 4)
f.manager.stop()
})
test('stop cancels timers and ignores callbacks from replaced sockets', () => {
const f = fixture(); f.manager.start()
const oldOpen = f.sockets[0].onopen, oldMessage = f.sockets[0].onmessage
f.sockets[0].onerror()
f.manager.stop()
assert.equal(f.timers.size, 0)
f.manager.start()
oldOpen(); oldMessage({data: 'stale'})
assert.equal(f.opens(), 0)
assert.deepEqual(f.messages, [])
f.sockets[1].onopen()
f.sockets[1].onmessage({data: 'current'})
assert.deepEqual(f.messages, ['current'])
f.manager.stop()
})
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {ehThumbnailUrl, pickDefaultResolution} from '../src/utils/thumbnail.js'
test('remote thumbnail paths are wrapped with the auth code', () => {
const url = ehThumbnailUrl('samples/thumb.png', 'abc123')
assert.match(url, /^https:\/\/downloader\.lionwebsite\.xyz\/GalleryManage\/ehThumbnail\?/)
assert.match(url, /path=samples%2Fthumb\.png/)
assert.match(url, /AuthCode=abc123/)
})
test('a missing thumbnail path produces no url', () => {
assert.equal(ehThumbnailUrl('', 'abc123'), '')
assert.equal(ehThumbnailUrl(undefined, 'abc123'), '')
})
test('the original resolution is preselected when offered', () => {
const chosen = pickDefaultResolution({
'800x': '37.53MiB',
'1280x': '66.57MiB',
'2560x': '84.01MiB',
'Original': '498.2MiB',
})
assert.equal(chosen, 'Original')
})
test('the original label is matched case-insensitively', () => {
assert.equal(pickDefaultResolution({'800x': '1MiB', 'original': '2MiB'}), 'original')
})
test('without an original the largest entry wins across units', () => {
const chosen = pickDefaultResolution({
'800x': '37.53MiB',
'2560x': '84.01MiB',
'2160x': '1.2GiB',
})
assert.equal(chosen, '2160x')
})
test('an empty or absent resolution map selects nothing', () => {
assert.equal(pickDefaultResolution({}), '')
assert.equal(pickDefaultResolution(undefined), '')
})
test('unparseable sizes fall back to the first entry instead of throwing', () => {
assert.equal(pickDefaultResolution({'800x': 'unknown', '1280x': 'unknown'}), '800x')
})
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
// Nginx 从 /asserts/mobile/ 提供移动端静态文件;部署文件名保持固定,
// 以便现有静态路由无需随每次构建改名。
base: '/asserts/mobile/',
assetsDir: '',
plugins: [vue()],
server:{
host: '0.0.0.0'
},
build: {
rollupOptions: {
output: {
entryFileNames: 'index.js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]'
}
}
}
})