Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a781d857 | ||
|
|
a07a32f648 | ||
|
|
1e1117b187 | ||
|
|
0e161b1735 | ||
|
|
98cbb8dc29 | ||
|
|
9c17d2d335 | ||
|
|
d4ea7f48c0 | ||
|
|
3ecfe3ba94 | ||
|
|
a959594348 | ||
|
|
faa1027c04 | ||
|
|
6f68732ef9 | ||
|
|
3a4e936938 | ||
|
|
8b4b79fa10 | ||
|
|
ce0dda0455 | ||
|
|
fd1ee398c4 | ||
|
|
1488017bf2 | ||
|
|
367f4236ad | ||
|
|
ec202f22e8 | ||
|
|
08081da29e | ||
|
|
5511424bd6 | ||
|
|
9f2c848413 | ||
|
|
d5efe2b499 | ||
|
|
9b989fc40f | ||
|
|
567e85d1f8 | ||
|
|
af00036e7f | ||
|
|
e58e6e2a3e | ||
|
|
370039664f | ||
|
|
d244363321 | ||
|
|
984d502259 | ||
|
|
34b418af96 | ||
|
|
eee0c0c878 | ||
|
|
952ef368ab | ||
|
|
dfaa789f7c | ||
|
|
74dd549ffb | ||
|
|
4d8f369ba0 | ||
|
|
824e2d72c7 | ||
|
|
143aa79797 | ||
|
|
0e1120f407 | ||
|
|
c8dbb9672a | ||
|
|
372b74776f | ||
|
|
abcc3c6411 | ||
|
|
ec4ab8762c | ||
|
|
bc93de8ff2 | ||
|
|
b426f3c6f2 | ||
|
|
99d9bb29ce | ||
|
|
5e109c666b | ||
|
|
0bed216735 | ||
|
|
d55bb8d336 | ||
|
|
7c32b3edf0 | ||
|
|
121cb7e442 | ||
|
|
dec3e1ea92 | ||
|
|
664b6610f3 | ||
|
|
44163f0fb2 | ||
|
|
d43865fcad | ||
|
|
fed92f3853 | ||
|
|
823d2a816e | ||
|
|
046c21edf6 | ||
|
|
8236d80b42 | ||
|
|
90e7eb1c79 | ||
|
|
ef09868af1 | ||
|
|
028981e3ae | ||
|
|
e8a6274cf6 | ||
|
|
ffd0265526 | ||
|
|
13d7344bc0 | ||
|
|
2ad36f92c5 | ||
|
|
36b02f4423 | ||
|
|
01df990aa8 | ||
|
|
49b71fcf5d | ||
|
|
9e43d77ac4 | ||
|
|
3ab9af720b | ||
|
|
abae304f87 | ||
|
|
659d8bff66 | ||
|
|
1786e10101 |
@@ -2,6 +2,9 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="pragma" content="no-cache">
|
||||
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="expires" content="0">
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
|
||||
@@ -145,11 +148,6 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
console.log(event)
|
||||
window.reload()
|
||||
})
|
||||
|
||||
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
|
||||
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "1.7.0-1",
|
||||
"version": "1.7.9-1",
|
||||
"private": true,
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
@@ -37,6 +37,7 @@
|
||||
"postcss-purgecss": "^5.0.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"pull-refresh-vue3": "^0.3.1",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"roboto-fontface": "^0.10.0",
|
||||
"sass": "^1.59.3",
|
||||
"tailwindcss": "^3.3.2",
|
||||
@@ -52,7 +53,7 @@
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vue3-apexcharts": "^1.4.1",
|
||||
"vue3-perfect-scrollbar": "^1.6.0",
|
||||
"vuetify": "3.3.5",
|
||||
"vuetify": "3.5.7",
|
||||
"vuetify-use-dialog": "^0.6.0",
|
||||
"vuex": "^4.1.0",
|
||||
"vuex-persistedstate": "^4.1.0",
|
||||
|
||||
@@ -75,6 +75,26 @@ http {
|
||||
# 超时设置
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /cookiecloud {
|
||||
# 后端cookiecloud地址
|
||||
proxy_pass http://backend_api;
|
||||
rewrite ^.+mock-server/?(.*)$ /$1 break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Nginx-Proxy true;
|
||||
|
||||
# 超时设置
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
upstream backend_api {
|
||||
|
||||
@@ -25,6 +25,16 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// 配置代理中间件将CookieCloud请求转发给后端API
|
||||
app.use(
|
||||
'/cookiecloud',
|
||||
proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
|
||||
// 路径加上 /cookiecloud 前缀
|
||||
proxyReqPathResolver: (req) => {
|
||||
return `/cookiecloud${req.url}`
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 处理根路径的请求
|
||||
app.get('/', (req, res) => {
|
||||
|
||||
18
src/@core/utils/compatibility.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 浏览器兼容性处理
|
||||
*/
|
||||
|
||||
/**
|
||||
* 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||
*/
|
||||
export function fixArrayAt() {
|
||||
if (!Array.prototype.at) {
|
||||
Array.prototype.at = function(index: number) {
|
||||
if (index >= 0) {
|
||||
return this[index]
|
||||
} else {
|
||||
return this[this.length + index]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,3 +147,23 @@ export function formatEp(nums: number[]): string {
|
||||
|
||||
return formattedRanges.join('、')
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd hh:mm:ss转换为时间差,如:1小时前,1天前
|
||||
export function formatDateDifference(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const currentDate = new Date()
|
||||
const timeDifference = currentDate.getTime() - date.getTime()
|
||||
const secondsDifference = Math.floor(timeDifference / 1000)
|
||||
const minutesDifference = Math.floor(secondsDifference / 60)
|
||||
const hoursDifference = Math.floor(minutesDifference / 60)
|
||||
const daysDifference = Math.floor(hoursDifference / 24)
|
||||
|
||||
if (daysDifference > 0)
|
||||
return `${daysDifference}天前`
|
||||
else if (hoursDifference > 0)
|
||||
return `${hoursDifference}小时前`
|
||||
else if (minutesDifference > 0)
|
||||
return `${minutesDifference}分钟前`
|
||||
else
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ $layout-horizontal-nav-layout-navbar-z-index: 11 !default;
|
||||
$layout-boxed-content-width: 90rem !default;
|
||||
|
||||
// 👉Footer
|
||||
$layout-vertical-nav-footer-height: 3.5rem !default;
|
||||
$layout-vertical-nav-footer-height: 0rem !default;
|
||||
|
||||
// 👉 Layout overlay
|
||||
$layout-overlay-z-index: 11 !default;
|
||||
|
||||
141
src/api/types.ts
@@ -81,7 +81,7 @@ export interface Subscribe {
|
||||
best_version: any
|
||||
|
||||
// 使用 imdbid 搜索
|
||||
search_imdbid?: boolean
|
||||
search_imdbid?: any
|
||||
|
||||
// 当前优先级
|
||||
current_priority: number
|
||||
@@ -181,6 +181,9 @@ export interface MediaInfo {
|
||||
// 豆瓣ID
|
||||
douban_id?: string
|
||||
|
||||
// Bangumi ID
|
||||
bangumi_id?: string
|
||||
|
||||
// 媒体原语种
|
||||
original_language?: string
|
||||
|
||||
@@ -282,6 +285,9 @@ export interface MediaInfo {
|
||||
|
||||
// 下一集
|
||||
next_episode_to_air?: object
|
||||
|
||||
// 别名
|
||||
names?: string[]
|
||||
}
|
||||
|
||||
// TMDB季信息
|
||||
@@ -422,6 +428,28 @@ export interface DoubanPerson {
|
||||
|
||||
}
|
||||
|
||||
// Bangumi人物信息
|
||||
export interface BangumiPerson {
|
||||
// ID
|
||||
id?: number
|
||||
|
||||
// 名称
|
||||
name?: string
|
||||
|
||||
// 类型
|
||||
type?: number
|
||||
|
||||
// 角色
|
||||
career?: string[]
|
||||
|
||||
// images large/normal
|
||||
images?: { [key: string]: string }
|
||||
|
||||
// 关系
|
||||
relation?: string
|
||||
|
||||
}
|
||||
|
||||
// 站点
|
||||
export interface Site {
|
||||
|
||||
@@ -513,8 +541,11 @@ export interface DownloadingInfo {
|
||||
// 媒体信息
|
||||
media: { [key: string]: any }
|
||||
|
||||
// 下载用户
|
||||
// 下载用户ID
|
||||
userid?: string
|
||||
|
||||
// 下载用户名称
|
||||
username?: string
|
||||
}
|
||||
|
||||
// 缺失剧集信息
|
||||
@@ -799,18 +830,37 @@ export interface Context {
|
||||
|
||||
// 用户信息
|
||||
export interface User {
|
||||
// 用户ID
|
||||
id: number
|
||||
|
||||
// 用户名称
|
||||
name: string
|
||||
|
||||
// 用户密码
|
||||
password: string
|
||||
|
||||
// 用户邮箱
|
||||
email: string
|
||||
|
||||
// 是否激活
|
||||
is_active: boolean
|
||||
|
||||
// 是否管理员
|
||||
is_superuser: boolean
|
||||
|
||||
// 头像
|
||||
avatar: string
|
||||
|
||||
// 是否开启双重验证
|
||||
is_otp: boolean
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
export interface Storage {
|
||||
// 总空间
|
||||
total_storage: number
|
||||
|
||||
// 已使用空间
|
||||
used_storage: number
|
||||
}
|
||||
|
||||
@@ -915,45 +965,132 @@ export interface Setting {
|
||||
|
||||
// 文件浏览接口
|
||||
export interface EndPoints {
|
||||
// 文件列表
|
||||
list: any
|
||||
|
||||
// 创建目录
|
||||
mkdir: any
|
||||
|
||||
// 删除文件
|
||||
delete: any
|
||||
|
||||
// 下载文件
|
||||
download: any
|
||||
|
||||
// 图片预览
|
||||
image: any
|
||||
|
||||
// 重命名
|
||||
rename: any
|
||||
}
|
||||
|
||||
// 文件浏览项目
|
||||
export interface FileItem {
|
||||
// 类型
|
||||
type: string
|
||||
|
||||
// 文件名
|
||||
name: string
|
||||
|
||||
// 文件名不含扩展名
|
||||
basename: string
|
||||
|
||||
// 文件路径
|
||||
path: string
|
||||
|
||||
// 文件扩展名
|
||||
extension: string
|
||||
|
||||
// 文件大小
|
||||
size: number
|
||||
|
||||
// 文件子元素
|
||||
children: FileItem[]
|
||||
|
||||
// 文件创建时间
|
||||
modify_time: number
|
||||
}
|
||||
|
||||
// 媒体服务器播放条目
|
||||
export interface MediaServerPlayItem {
|
||||
// ID
|
||||
id?: string | number
|
||||
|
||||
// 标题
|
||||
title: string
|
||||
|
||||
// 副标题
|
||||
subtitle?: string
|
||||
|
||||
// 类型
|
||||
type?: string
|
||||
|
||||
// 海报
|
||||
image?: string
|
||||
|
||||
// 链接
|
||||
link?: string
|
||||
|
||||
// 播放百分比
|
||||
percent?: number
|
||||
}
|
||||
|
||||
// 媒体服务器媒体库
|
||||
export interface MediaServerLibrary {
|
||||
// 服务器名称
|
||||
server: string
|
||||
|
||||
// ID
|
||||
id?: string | number
|
||||
|
||||
// 名称
|
||||
name: string
|
||||
|
||||
// 路径
|
||||
path?: string
|
||||
|
||||
// 类型
|
||||
type?: string
|
||||
|
||||
// 图片
|
||||
image?: string
|
||||
|
||||
// 图片列表
|
||||
image_list?: string[]
|
||||
|
||||
// 链接
|
||||
link?: string
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
export interface Message {
|
||||
// 消息类型
|
||||
mtype?: string
|
||||
|
||||
// 消息标题
|
||||
title?: string
|
||||
|
||||
// 消息内容
|
||||
text?: string
|
||||
|
||||
// 消息链接
|
||||
link?: string
|
||||
|
||||
// 消息图片
|
||||
image?: string
|
||||
|
||||
// 消息时间
|
||||
date?: string
|
||||
|
||||
// 登记时间
|
||||
reg_time?: string
|
||||
|
||||
// 用户ID
|
||||
userid?: string
|
||||
|
||||
// 消息方向:0-接收,1-发送
|
||||
action?: number
|
||||
|
||||
// JSON
|
||||
note?: string
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB |
BIN
src/assets/images/misc/teamwork.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 253 KiB |
@@ -4,7 +4,6 @@ import axios from 'axios'
|
||||
import List from './filebrowser/List.vue'
|
||||
|
||||
import Toolbar from './filebrowser/Toolbar.vue'
|
||||
import Tree from './filebrowser/Tree.vue'
|
||||
import type { EndPoints } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
@@ -70,12 +69,10 @@ const storagesArray = computed(() => {
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading) {
|
||||
if (loading)
|
||||
loading++
|
||||
}
|
||||
else if (loading > 0) {
|
||||
else if (loading > 0)
|
||||
loading--
|
||||
}
|
||||
}
|
||||
|
||||
function storageChanged(storage: string) {
|
||||
@@ -114,38 +111,20 @@ onMounted(() => {
|
||||
@foldercreated="refreshPending = true"
|
||||
@sortchanged="sortChanged"
|
||||
/>
|
||||
<VRow no-gutters>
|
||||
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
|
||||
<Tree
|
||||
:path="path"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:refreshpending="refreshPending"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
/>
|
||||
</VCol>
|
||||
<VDivider v-if="tree" vertical />
|
||||
<VCol>
|
||||
<List
|
||||
:path="path"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<List
|
||||
:path="path"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import miscpose from '@images/pages/pose-fs-9.png'
|
||||
import image from '@images/misc/teamwork.png'
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
@@ -11,25 +11,24 @@ interface Props {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<ErrorHeader
|
||||
:error-code="props.errorCode"
|
||||
:error-title="props.errorTitle"
|
||||
:error-description="props.errorDescription"
|
||||
/>
|
||||
<VEmptyState
|
||||
:image="image"
|
||||
size="250"
|
||||
>
|
||||
<template #title>
|
||||
<div class="mt-8 text-2xl">
|
||||
{{ props.errorTitle }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 👉 Image -->
|
||||
<div class="text-center">
|
||||
<VImg
|
||||
:src="miscpose"
|
||||
class="mx-auto pt-10"
|
||||
max-width="250"
|
||||
cover
|
||||
/>
|
||||
<template #text>
|
||||
<div class="text-subtitle">
|
||||
{{ props.errorDescription }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<slot name="button" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VEmptyState>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
|
||||
95
src/components/cards/BangumiPersonCard.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
import personIcon from '@images/misc/person-icon.png'
|
||||
import type { BangumiPerson } from '@/api/types'
|
||||
|
||||
const personProps = defineProps({
|
||||
person: Object as PropType<BangumiPerson>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 当前人物
|
||||
const personInfo = ref(personProps.person)
|
||||
|
||||
// 人物图片是否加载
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 人物图片地址
|
||||
function getPersonImage() {
|
||||
if (!personInfo.value?.images)
|
||||
return personIcon
|
||||
return personInfo.value?.images?.medium
|
||||
}
|
||||
|
||||
// 使用、拼装人物角色
|
||||
function getPersonCharacter() {
|
||||
if (!personInfo.value?.career)
|
||||
return ''
|
||||
return personInfo.value?.career.join('、')
|
||||
}
|
||||
|
||||
// 打开人物详情
|
||||
function goPersonDetail() {
|
||||
if (!personInfo.value?.id)
|
||||
return
|
||||
window.open(`https://bangumi.tv/person/${personInfo.value?.id}`, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover v-bind="personProps">
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="rounded-lg"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<div
|
||||
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
|
||||
>
|
||||
<div style="padding-bottom: 150%;">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
||||
<VAvatar
|
||||
size="120"
|
||||
:class="{
|
||||
'ring-1 ring-gray-700': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
v-img
|
||||
:src="getPersonImage()"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="w-full truncate text-center font-bold">
|
||||
{{ personInfo?.name }}
|
||||
</div>
|
||||
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
|
||||
{{ getPersonCharacter() }}
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.person-card {
|
||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
|
||||
}
|
||||
|
||||
.person-card:hover {
|
||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-custom-background)) 60%);
|
||||
}
|
||||
</style>
|
||||
@@ -25,8 +25,8 @@ const isDownloading = ref(props.info?.state === 'downloading')
|
||||
|
||||
// 监听props.info?.state的变化
|
||||
watch(() => props.info?.state, (newValue) => {
|
||||
isDownloading.value = newValue === 'downloading';
|
||||
});
|
||||
isDownloading.value = newValue === 'downloading'
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
@@ -36,6 +36,7 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
|
||||
{ title: '特效字幕', value: ' SPECSUB ' },
|
||||
{ title: '中文字幕', value: ' CNSUB ' },
|
||||
{ title: '国语配音', value: ' CNVOI ' },
|
||||
{ title: '官种', value: ' GZ ' },
|
||||
{ title: '排除: 国语配音', value: ' !CNVOI ' },
|
||||
{ title: '粤语配音', value: ' HKVOI ' },
|
||||
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
|
||||
|
||||
@@ -16,11 +16,6 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 订阅规则
|
||||
const subscribeRules = ref({
|
||||
show_edit_dialog: false,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -57,6 +52,15 @@ const seasonInfos = ref<TmdbSeason[]>([])
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<TmdbSeason[]>([])
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
return props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: props.media?.douban_id
|
||||
? `douban:${props.media?.douban_id}`
|
||||
: `bangumi:${props.media?.bangumi_id}`
|
||||
}
|
||||
|
||||
// 订阅弹窗选择的多季
|
||||
function subscribeSeasons() {
|
||||
subscribeSeasonDialog.value = false
|
||||
@@ -131,6 +135,7 @@ async function addSubscribe(season = 0) {
|
||||
year: props.media?.year,
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
season,
|
||||
best_version,
|
||||
})
|
||||
@@ -151,9 +156,12 @@ async function addSubscribe(season = 0) {
|
||||
)
|
||||
|
||||
// 弹出订阅编辑弹窗
|
||||
if (result.success && seasonsSelected.value.length <= 1 && subscribeRules.value.show_edit_dialog) {
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
if (result.success && seasonsSelected.value.length <= 1) {
|
||||
const show_edit_dialog = await querySubscribeRules()
|
||||
if (show_edit_dialog) {
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -186,9 +194,7 @@ async function removeSubscribe() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: `douban:${props.media?.douban_id}`
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(
|
||||
`subscribe/media/${mediaid}`,
|
||||
@@ -249,9 +255,7 @@ async function handleCheckExists() {
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
try {
|
||||
const mediaid = props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: `douban:${props.media?.douban_id}`
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
@@ -314,11 +318,12 @@ async function querySubscribeRules() {
|
||||
'system/setting/DefaultFilterRules',
|
||||
)
|
||||
if (result.data?.value)
|
||||
subscribeRules.value = result.data?.value
|
||||
return result.data.value.show_edit_dialog
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 爱心订阅按钮响应
|
||||
@@ -362,11 +367,7 @@ function goMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${
|
||||
props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: `douban:${props.media?.douban_id}`
|
||||
}`,
|
||||
mediaid: getMediaId(),
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
@@ -377,11 +378,7 @@ function handleSearch() {
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: `${
|
||||
props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: `douban:${props.media?.douban_id}`
|
||||
}`,
|
||||
keyword: getMediaId(),
|
||||
type: props.media?.type,
|
||||
area: 'title',
|
||||
},
|
||||
@@ -392,7 +389,6 @@ function handleSearch() {
|
||||
onBeforeMount(() => {
|
||||
handleCheckSubscribe()
|
||||
handleCheckExists()
|
||||
querySubscribeRules()
|
||||
})
|
||||
|
||||
// 计算图片地址
|
||||
@@ -442,6 +438,7 @@ function getYear(airDate: string) {
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -457,60 +454,60 @@ function getYear(airDate: string) {
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !isExists"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.vote_average }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
@click.stop="goMediaDetail"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.year }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.overview }}
|
||||
</p>
|
||||
<div class="flex align-center justify-between">
|
||||
<IconBtn
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
@click.stop="handleSearch"
|
||||
/>
|
||||
<IconBtn
|
||||
icon="mdi-heart"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
@click.stop="handleSubscribe"
|
||||
/>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VImg>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !isExists"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.vote_average }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.year }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.overview }}
|
||||
</p>
|
||||
<div class="flex align-center justify-between">
|
||||
<IconBtn
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
@click.stop="handleSearch"
|
||||
/>
|
||||
<IconBtn
|
||||
icon="mdi-heart"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
@click.stop="handleSubscribe"
|
||||
/>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅季弹窗 -->
|
||||
<VBottomSheet
|
||||
v-if="subscribeSeasonDialog"
|
||||
v-model="subscribeSeasonDialog"
|
||||
inset
|
||||
scrollable
|
||||
@@ -594,6 +591,7 @@ function getYear(airDate: string) {
|
||||
</VBottomSheet>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditForm
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
|
||||
112
src/components/cards/MessageCard.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
message: Object as PropType<Message>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
}
|
||||
|
||||
// 链接打开新窗口
|
||||
function openLink() {
|
||||
if (props.message?.link)
|
||||
window.open(props.message.link, '_blank')
|
||||
}
|
||||
|
||||
// 将note转换为json
|
||||
function noteToJson() {
|
||||
if (props.message?.note) {
|
||||
try {
|
||||
return JSON.parse(props.message.note)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 将\n转换为html属性的换行符
|
||||
function replaceNewLine(value: string) {
|
||||
if (!value)
|
||||
return ''
|
||||
return value.replace(/\n/g, '<br/>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
variant="tonal"
|
||||
@click="openLink"
|
||||
>
|
||||
<div
|
||||
v-if="props.message?.image"
|
||||
class="relative text-center card-cover-blurred"
|
||||
>
|
||||
<VImg
|
||||
:src="props.message?.image"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</div>
|
||||
<VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
|
||||
{{ props.message?.title }}
|
||||
</VCardTitle>
|
||||
<VAlert
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
variant="tonal"
|
||||
type="success"
|
||||
>
|
||||
<template #prepend />
|
||||
{{ props.message?.text }}
|
||||
</VAlert>
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
v-html="replaceNewLine(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="props.message?.note">
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(value, key) in noteToJson()"
|
||||
:key="key"
|
||||
two-line
|
||||
>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
|
||||
{{ value.description }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<div class="text-end">
|
||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
||||
<span class="text-sm italic me-2">{{ formatDateDifference(props.message?.reg_time || props.message?.date || '') }}</span>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ const props = defineProps({
|
||||
plugin: Object as PropType<Plugin>,
|
||||
width: String,
|
||||
height: String,
|
||||
count: Number,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -49,7 +50,7 @@ async function installPlugin() {
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在安装 ${props.plugin?.plugin_name} ${props?.plugin?.plugin_version} 插件...`
|
||||
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
`plugin/install/${props.plugin?.id}`,
|
||||
@@ -163,15 +164,6 @@ const dropdownItems = ref([
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.plugin?.has_update"
|
||||
class="me-n3 absolute top-0 left-1"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-new-box"
|
||||
class="text-white"
|
||||
/>
|
||||
</div>
|
||||
<VAvatar
|
||||
size="8rem"
|
||||
>
|
||||
@@ -186,20 +178,28 @@ const dropdownItems = ref([
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<VCardTitle>{{ props.plugin?.plugin_name }}</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
<VCardTitle>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="pb-2">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
作者:<a
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a><br>
|
||||
版本:{{ props.plugin?.plugin_version }}
|
||||
<VCardText class="flex items-center justify-start pb-2">
|
||||
<span>
|
||||
<VIcon icon="mdi-account" class="me-1" />
|
||||
<a
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 安装插件进度框 -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { VIcon } from 'vuetify/lib/components/index.mjs'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import FormRender from '@/components/render/FormRender.vue'
|
||||
@@ -13,12 +14,14 @@ import store from '@/store'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: Object as PropType<Plugin>,
|
||||
count: Number, // 下载次数
|
||||
action: Boolean, // 动作标识
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
const emit = defineEmits(['remove', 'save', 'actionDone'])
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
@@ -41,12 +44,18 @@ const pluginConfigDialog = ref(false)
|
||||
// 插件配置表单数据
|
||||
const pluginConfigForm = ref({})
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 插件表单配置项
|
||||
let pluginFormItems = reactive([])
|
||||
|
||||
// 插件数据页面
|
||||
const pluginInfoDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('正在更新插件...')
|
||||
|
||||
// 插件数据页面配置项
|
||||
let pluginPageItems = reactive([])
|
||||
|
||||
@@ -56,6 +65,14 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 监听动作标识,如为true则打开详情
|
||||
watch(() => props.action, (newAction, oldAction) => {
|
||||
if (newAction && !oldAction) {
|
||||
openPluginDetail()
|
||||
emit('actionDone')
|
||||
}
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -83,7 +100,12 @@ async function uninstallPlugin() {
|
||||
return
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在卸载 ${props.plugin?.plugin_name} ...`
|
||||
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
if (result.success) {
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`)
|
||||
|
||||
@@ -140,15 +162,20 @@ async function loadPluginConf() {
|
||||
|
||||
// 调用API保存配置数据
|
||||
async function savePluginConf() {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
progressDialog.value = false
|
||||
pluginConfigDialog.value = false
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
}
|
||||
else {
|
||||
progressDialog.value = false
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
|
||||
}
|
||||
}
|
||||
@@ -221,6 +248,41 @@ async function resetPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新插件
|
||||
async function updatePlugin() {
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在更新 ${props.plugin?.plugin_name} ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
`plugin/install/${props.plugin?.id}`,
|
||||
{
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 更新成功!`)
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
}
|
||||
else {
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 更新失败:${result.message}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 访问作者主页
|
||||
function visitAuthorPage() {
|
||||
window.open(props.plugin?.author_url, '_blank')
|
||||
@@ -233,6 +295,14 @@ function openLoggerWindow() {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 打开插件详情
|
||||
function openPluginDetail() {
|
||||
if (props.plugin?.has_page)
|
||||
showPluginInfo()
|
||||
else
|
||||
showPluginConfig()
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -254,8 +324,18 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重置',
|
||||
title: '更新',
|
||||
value: 3,
|
||||
show: props.plugin?.has_update,
|
||||
props: {
|
||||
prependIcon: 'mdi-arrow-up-circle-outline',
|
||||
color: 'success',
|
||||
click: updatePlugin,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重置',
|
||||
value: 4,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-cancel',
|
||||
@@ -265,7 +345,7 @@ const dropdownItems = ref([
|
||||
},
|
||||
{
|
||||
title: '卸载',
|
||||
value: 4,
|
||||
value: 5,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-trash-can-outline',
|
||||
@@ -275,7 +355,7 @@ const dropdownItems = ref([
|
||||
},
|
||||
{
|
||||
title: '查看日志',
|
||||
value: 5,
|
||||
value: 6,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
@@ -286,7 +366,7 @@ const dropdownItems = ref([
|
||||
},
|
||||
{
|
||||
title: '作者主页',
|
||||
value: 5,
|
||||
value: 7,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-home-circle-outline',
|
||||
@@ -294,6 +374,13 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 监听插件状态变化
|
||||
watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
|
||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||
if (updateItemIndex !== -1)
|
||||
dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -302,17 +389,21 @@ const dropdownItems = ref([
|
||||
v-if="isVisible"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="() => {
|
||||
if (props.plugin?.has_page)
|
||||
showPluginInfo()
|
||||
else
|
||||
showPluginConfig()
|
||||
}"
|
||||
@click="openPluginDetail"
|
||||
>
|
||||
<div
|
||||
class="relative pa-4 text-center card-cover-blurred"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
v-if="props.plugin?.has_update"
|
||||
class="me-n3 absolute top-0 left-1"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-new-box"
|
||||
class="text-white"
|
||||
/>
|
||||
</div>
|
||||
<div class="me-n3 absolute top-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" class="text-white" />
|
||||
@@ -352,10 +443,14 @@ const dropdownItems = ref([
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<span v-if="props.count" class="absolute bottom-1 right-2 flex items-center">
|
||||
<VIcon icon="mdi-fire" />
|
||||
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
<VCardItem class="py-2">
|
||||
<VCardTitle class="flex items-center flex-row">
|
||||
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
|
||||
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">{{ props.plugin?.plugin_version }}</span>
|
||||
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
@@ -430,6 +525,25 @@ const dropdownItems = ref([
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 更新插件进度框 -->
|
||||
<VDialog
|
||||
v-model="progressDialog"
|
||||
:scrim="false"
|
||||
width="25rem"
|
||||
>
|
||||
<VCard
|
||||
color="primary"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
<VProgressLinear
|
||||
indeterminate
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import type { PropType } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import SiteAddEditForm from '../form/SiteAddEditForm.vue'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import SiteTorrentTable from '../table/SiteTorrentTable.vue'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { Site, TorrentInfo } from '@/api/types'
|
||||
import type { Site } from '@/api/types'
|
||||
import ExistIcon from '@core/components/ExistIcon.vue'
|
||||
|
||||
// 输入参数
|
||||
@@ -51,31 +51,6 @@ const progressDialog = ref(false)
|
||||
// 进度文本
|
||||
const progressText = ref('请稍候 ...')
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
{ title: '标题', key: 'title', sortable: false },
|
||||
{ title: '时间', key: 'pubdate', sortable: true },
|
||||
{ title: '大小', key: 'size', sortable: true },
|
||||
{ title: '做种', key: 'seeders', sortable: true },
|
||||
{ title: '下载', key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 用户名密码表单
|
||||
const userPwForm = ref({
|
||||
username: '',
|
||||
@@ -83,16 +58,6 @@ const userPwForm = ref({
|
||||
code: '',
|
||||
})
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
try {
|
||||
@@ -131,7 +96,6 @@ async function handleSiteUpdate() {
|
||||
// 打开资源浏览弹窗
|
||||
async function handleResourceBrowse() {
|
||||
resourceDialog.value = true
|
||||
getResourceList()
|
||||
}
|
||||
|
||||
// 调用API,更新站点Cookie UA
|
||||
@@ -171,30 +135,6 @@ async function updateSiteCookie() {
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0)
|
||||
return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1)
|
||||
return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1)
|
||||
return 'text-white bg-sky-500'
|
||||
else
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${cardProps.site?.id}`)
|
||||
resourceLoading.value = false
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开站点页面
|
||||
function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
@@ -386,6 +326,7 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<SiteAddEditForm
|
||||
v-if="siteEditDialog"
|
||||
v-model="siteEditDialog"
|
||||
:siteid="cardProps.site?.id"
|
||||
@save="siteEditDialog = false; emit('update')"
|
||||
@@ -394,130 +335,17 @@ onMounted(() => {
|
||||
/>
|
||||
<!-- 站点资源弹窗 -->
|
||||
<VDialog
|
||||
v-if="resourceDialog"
|
||||
v-model="resourceDialog"
|
||||
max-width="80rem"
|
||||
scrollable
|
||||
z-index="1010"
|
||||
>
|
||||
<!-- Dialog Content -->
|
||||
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
|
||||
<DialogCloseBtn @click="resourceDialog = false" />
|
||||
<VCardText class="pt-2">
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
{{ item.raw.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
{{ item.raw.description }}
|
||||
</div>
|
||||
<VChip
|
||||
v-if="item.raw?.hit_and_run"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1 text-white bg-black"
|
||||
>
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.raw?.freedate_diff"
|
||||
variant="elevated"
|
||||
color="secondary"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.raw?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.raw?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.raw?.downloadvolumefactor !== 1 || item.raw?.uploadvolumefactor !== 1"
|
||||
:class="
|
||||
getVolumeFactorClass(item.raw?.downloadvolumefactor, item.raw?.uploadvolumefactor)
|
||||
"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.raw?.volume_factor }}
|
||||
</VChip>
|
||||
</template>
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.raw.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
{{ item.raw.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.raw.size) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.raw.seeders }}</div>
|
||||
</template>
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.raw.peers }}</div>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
@click="openTorrentDetail(item.raw.page_url)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="item.raw.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile(item.raw.enclosure)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
<template #no-data>
|
||||
没有数据
|
||||
</template>
|
||||
</VDataTable>
|
||||
<SiteTorrentTable :site="cardProps.site?.id" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -285,6 +285,7 @@ const dropdownItems = ref([
|
||||
</VCard>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditForm
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="props.media?.id"
|
||||
@remove="() => { emit('remove');subscribeEditDialog = false; }"
|
||||
|
||||
@@ -93,7 +93,7 @@ onMounted(() => {
|
||||
single-line
|
||||
placeholder="电影或电视剧名称"
|
||||
variant="solo"
|
||||
append-inner-icon="mdi-magnify"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
flat
|
||||
class="mx-1"
|
||||
:loading="loading"
|
||||
@@ -101,7 +101,7 @@ onMounted(() => {
|
||||
@keydown.enter="searchMedias"
|
||||
/>
|
||||
</VToolbar>
|
||||
|
||||
<DialogCloseBtn @click="() => { emit('close') }" />
|
||||
<VList
|
||||
v-if="items.length > 0"
|
||||
lines="three"
|
||||
@@ -131,7 +131,6 @@ onMounted(() => {
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2" v-html="item.overview" />
|
||||
</VListItem>
|
||||
<VDivider v-if="i < items.length - 1" class="mt-1" inset />
|
||||
</template>
|
||||
</VList>
|
||||
</VCard>
|
||||
|
||||
@@ -73,6 +73,9 @@ const nameTestResult = ref<Context>()
|
||||
// 识别结果对话框
|
||||
const nameTestDialog = ref(false)
|
||||
|
||||
// 延迟加载
|
||||
const defer = (_: number) => true
|
||||
|
||||
// 目录过滤
|
||||
const dirs = computed(() =>
|
||||
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
|
||||
@@ -343,6 +346,36 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<VCard class="d-flex flex-column">
|
||||
<VToolbar v-if="!loading" density="compact" flat color="gray">
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
v-model="filter"
|
||||
hide-details
|
||||
flat
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
placeholder="搜索 ..."
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
class="me-2"
|
||||
rounded="0"
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
|
||||
<VIcon color="primary">
|
||||
mdi-text-recognition
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
|
||||
<VIcon color="primary">
|
||||
mdi-download
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="load">
|
||||
<VIcon color="primary">
|
||||
mdi-refresh
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
</VToolbar>
|
||||
<VCardText
|
||||
v-if="loading"
|
||||
class="text-center flex flex-col items-center"
|
||||
@@ -374,175 +407,92 @@ onMounted(() => {
|
||||
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
|
||||
</VCardText>
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList v-if="dirs.length" subheader>
|
||||
<VListSubheader>目录</VListSubheader>
|
||||
<VHover
|
||||
v-for="(item, index) in dirs"
|
||||
:key="index"
|
||||
>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
v-bind="hover.props"
|
||||
class="px-3 pe-1"
|
||||
@click="changePath(item.path)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-folder-outline" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<template #append>
|
||||
<IconBtn class="d-sm-none">
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
<VList subheader>
|
||||
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
v-bind="hover.props"
|
||||
class="px-3 pe-1"
|
||||
@click="changePath(item.path)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon v-if="inProps.icons && item.extension" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
|
||||
<VIcon v-else icon="mdi-folder-outline" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<VListItemSubtitle v-if="item.size">
|
||||
{{ formatBytes(item.size) }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<IconBtn class="d-sm-none">
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-if="hover.isHovering" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-show="hover.isHovering" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</VList>
|
||||
<VDivider v-if="dirs.length && files.length" />
|
||||
<VList v-if="files.length" subheader>
|
||||
<VListSubheader>文件</VListSubheader>
|
||||
<VHover
|
||||
v-for="(item, index) in files"
|
||||
:key="index"
|
||||
>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
v-bind="hover.props"
|
||||
class="pl-3 pe-1"
|
||||
@click="changePath(item.path)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
|
||||
</template>
|
||||
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
|
||||
|
||||
<template #append>
|
||||
<IconBtn class="d-sm-none">
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-show="hover.isHovering" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VHover>
|
||||
</template>
|
||||
</VHover>
|
||||
</VVirtualScroll>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText
|
||||
@@ -557,39 +507,10 @@ onMounted(() => {
|
||||
>
|
||||
空目录
|
||||
</VCardText>
|
||||
<VDivider v-if="path" />
|
||||
<VToolbar v-if="!loading" density="compact" flat color="gray">
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
v-model="filter"
|
||||
hide-details
|
||||
flat
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
placeholder="搜索 ..."
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
class="me-2"
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
|
||||
<VIcon color="primary">
|
||||
mdi-text-recognition
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
|
||||
<VIcon color="primary">
|
||||
mdi-download
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="load">
|
||||
<VIcon color="primary">
|
||||
mdi-refresh
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
</VToolbar>
|
||||
</VCard>
|
||||
<!-- 重命名弹窗 -->
|
||||
<VDialog
|
||||
v-if="renamePopper"
|
||||
v-model="renamePopper"
|
||||
max-width="50rem"
|
||||
>
|
||||
@@ -615,6 +536,7 @@ onMounted(() => {
|
||||
</VDialog>
|
||||
<!-- 文件整理弹窗 -->
|
||||
<ReorganizeForm
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:path="currentItem?.path"
|
||||
@done="transferPopper = false; load()"
|
||||
@@ -642,6 +564,7 @@ onMounted(() => {
|
||||
</VDialog>
|
||||
<!-- 识别结果对话框 -->
|
||||
<VDialog
|
||||
v-if="nameTestDialog"
|
||||
v-model="nameTestDialog"
|
||||
width="50rem"
|
||||
>
|
||||
@@ -661,4 +584,7 @@ onMounted(() => {
|
||||
.v-toolbar{
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
.virtual-scroll-div {
|
||||
height: calc(100vh - 230px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
path: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
refreshpending: Boolean,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged', 'loading', 'refreshed'])
|
||||
|
||||
// 变量
|
||||
const open = ref<string[]>([])
|
||||
// 活跃的文件夹
|
||||
const active = ref<string[]>([])
|
||||
// 内容
|
||||
const items = ref<FileItem[]>([])
|
||||
// 过滤
|
||||
const filter = ref('')
|
||||
|
||||
// 方法
|
||||
function init() {
|
||||
open.value = []
|
||||
items.value = [{
|
||||
type: 'dir',
|
||||
path: '/',
|
||||
basename: 'root',
|
||||
extension: '',
|
||||
name: 'root',
|
||||
children: [],
|
||||
size: 0,
|
||||
modify_time: 0,
|
||||
}]
|
||||
}
|
||||
|
||||
// 调用API读取文件夹
|
||||
async function readFolder(item: FileItem) {
|
||||
emit('loading', true)
|
||||
const url = props.endpoints?.list.url
|
||||
.replace(/{storage}/g, props.storage)
|
||||
.replace(/{path}/g, item.path)
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: props.endpoints?.list.method || 'get',
|
||||
}
|
||||
|
||||
const response: FileItem[] = await props.axios?.request(config) ?? []
|
||||
|
||||
item.children = response.map((item: FileItem) => {
|
||||
if (item.type === 'dir')
|
||||
item.children = []
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
emit('loading', false)
|
||||
}
|
||||
|
||||
// 选中变化
|
||||
function activeChanged(_active: string[]) {
|
||||
let path = ''
|
||||
if (active.value.length)
|
||||
path = active.value[0]
|
||||
|
||||
if (props.path !== path)
|
||||
emit('pathchanged', path)
|
||||
}
|
||||
|
||||
// 查找文件
|
||||
function findItem(path: string) {
|
||||
const stack: FileItem[] = []
|
||||
stack.push(items.value[0])
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()
|
||||
if (node?.path === path) {
|
||||
return node
|
||||
}
|
||||
else if (node?.children && node.children.length) {
|
||||
for (const element of node.children)
|
||||
stack.push(element)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 监听存储空间变量
|
||||
watch(() => props.storage, () => {
|
||||
init()
|
||||
})
|
||||
|
||||
// 监听路径变化
|
||||
watch(
|
||||
() => props.path,
|
||||
() => {
|
||||
if (props.path) {
|
||||
active.value = [props.path]
|
||||
if (!open.value.includes(props.path))
|
||||
open.value.push(props.path)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 refreshPending
|
||||
watch(
|
||||
() => props.refreshpending,
|
||||
async () => {
|
||||
if (props.refreshpending && props.path) {
|
||||
const item = findItem(props.path)
|
||||
if (item) {
|
||||
await readFolder(item)
|
||||
emit('refreshed')
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard flat width="250" min-height="500" class="d-flex flex-column folders-tree-card">
|
||||
<div class="grow scroll-x">
|
||||
<VTreeview
|
||||
:open="open"
|
||||
:active="active"
|
||||
:items="items"
|
||||
:search="filter"
|
||||
:load-children="readFolder"
|
||||
item-key="path"
|
||||
item-text="basename"
|
||||
dense
|
||||
activatable
|
||||
transition
|
||||
class="folders-tree"
|
||||
@update:active="activeChanged"
|
||||
>
|
||||
<template #prepend="{ item, open }">
|
||||
<VIcon
|
||||
v-if="item.type === 'dir'"
|
||||
>
|
||||
{{ open ? 'mdi-folder-open-outline' : 'mdi-folder-outline' }}
|
||||
</VIcon>
|
||||
<VIcon v-else-if="props.icons" :icon="props.icons[item.extension.toLowerCase()] || props.icons.other" />
|
||||
</template>
|
||||
<template #label="{ item }">
|
||||
{{ item.basename }}
|
||||
<VBtn
|
||||
v-if="item.type === 'dir'"
|
||||
icon
|
||||
class="ml-1"
|
||||
@click.stop="readFolder(item)"
|
||||
>
|
||||
<VIcon class="pa-0 mdi-18px" color="grey lighten-1">
|
||||
mdi-refresh
|
||||
</VIcon>
|
||||
</VBtn>
|
||||
</template>
|
||||
</VTreeview>
|
||||
</div>
|
||||
<VDivider />
|
||||
<VToolbar
|
||||
density="compact"
|
||||
>
|
||||
<VBtn icon @click="init">
|
||||
<VIcon icon="mdi-collapse-all-outline" />
|
||||
</VBtn>
|
||||
</VToolbar>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folders-tree-card {
|
||||
height: 100%;
|
||||
|
||||
.scroll-x {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
::v-deep .folders-tree {
|
||||
width: fit-content;
|
||||
min-width: 250px;
|
||||
|
||||
.v-treeview-node {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-toolbar{
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
</style>
|
||||
@@ -161,6 +161,7 @@ async function transfer() {
|
||||
v-model="transferForm.target"
|
||||
label="目的路径"
|
||||
placeholder="留空自动"
|
||||
hint="留空将自动整理到媒体库目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -204,6 +205,7 @@ async function transfer() {
|
||||
placeholder="留空自动识别"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
hint="点击图标按名称搜索,留空将自动重新识别"
|
||||
@click:append-inner="tmdbSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -225,6 +227,7 @@ async function transfer() {
|
||||
v-model="transferForm.episode_format"
|
||||
label="集数定位"
|
||||
placeholder="使用{ep}定位集数"
|
||||
hint="使用{ep}定位文件名中的集数部分,其余相同部分直接填写,不同部分使用{a}进行忽略,例如:{a}葬送的芙莉莲_Sousou no Frieren 第{ep}话{b}"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -232,6 +235,7 @@ async function transfer() {
|
||||
v-model="transferForm.episode_detail"
|
||||
label="指定集数"
|
||||
placeholder="起始集,终止集,如1或1,2"
|
||||
hint="直接指定集数或者范围,格式:起始集,终止集,如1或1,2"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -239,6 +243,7 @@ async function transfer() {
|
||||
v-model="transferForm.episode_part"
|
||||
label="指定Part"
|
||||
placeholder="如part1"
|
||||
hint="指定集数的Part,如part1"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -246,6 +251,7 @@ async function transfer() {
|
||||
v-model.number="transferForm.episode_offset"
|
||||
label="集数偏移"
|
||||
placeholder="如-10"
|
||||
hint="对集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -254,6 +260,7 @@ async function transfer() {
|
||||
label="最小文件大小(MB)"
|
||||
:rules="[numberValidator]"
|
||||
placeholder="0"
|
||||
hint="最小文件大小,小于此大小的文件将被忽略不进行整理"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -297,6 +304,7 @@ async function transfer() {
|
||||
v-model="tmdbSelectorDialog"
|
||||
width="40rem"
|
||||
scrollable
|
||||
max-height="85vh"
|
||||
>
|
||||
<TmdbSelectorCard
|
||||
v-model="transferForm.tmdbid"
|
||||
|
||||
@@ -142,6 +142,7 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.url"
|
||||
label="站点地址"
|
||||
:rules="[requiredValidator]"
|
||||
hint="格式:http://www.example.com/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -153,6 +154,7 @@ async function updateSiteInfo() {
|
||||
label="优先级"
|
||||
:items="priorityItems"
|
||||
:rules="[requiredValidator]"
|
||||
hint="站点资源下载优先级,优先级数字越小越优先下载"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -171,18 +173,21 @@ async function updateSiteInfo() {
|
||||
<VTextField
|
||||
v-model="siteForm.rss"
|
||||
label="RSS地址"
|
||||
hint="订阅模式为站点RSS时,将会使用此地址获取站点种子资源,该地址一般会自动获取,也可手动补充"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="siteForm.cookie"
|
||||
label="站点Cookie"
|
||||
hint="浏览器打开站点首页,打开开发人员工具,刷新页面后在网络选项中找到首页地址,在请求头中获取Cookie信息"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="siteForm.ua"
|
||||
label="站点User-Agent"
|
||||
hint="在开发人员工具,网络请求头中获取User-Agent信息,需与站点Cookie配套使用"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -195,6 +200,7 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.limit_interval"
|
||||
label="单位周期(秒)"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定站点限流的单位周期,单位为秒,0为不限流"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -205,6 +211,7 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.limit_count"
|
||||
label="访问次数"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定单位周期内站点允许的访问次数,0为不限制"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -215,6 +222,7 @@ async function updateSiteInfo() {
|
||||
v-model="siteForm.limit_seconds"
|
||||
label="访问间隔(秒)"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定单位周期内每次站点访问需间隔时间,单位为秒,0为不限制"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -226,6 +234,7 @@ async function updateSiteInfo() {
|
||||
<VSwitch
|
||||
v-model="siteForm.proxy"
|
||||
label="代理"
|
||||
hint="站点是否需要代理访问,需要设置好代理服务器信息"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -235,6 +244,7 @@ async function updateSiteInfo() {
|
||||
<VSwitch
|
||||
v-model="siteForm.render"
|
||||
label="仿真"
|
||||
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Site, Subscribe } from '@/api/types'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
subid: Number,
|
||||
default: Boolean,
|
||||
type: String,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -30,7 +32,7 @@ const subscribeForm = ref<Subscribe>({
|
||||
total_episode: 0,
|
||||
start_episode: 0,
|
||||
best_version: 0,
|
||||
search_imdbid: false,
|
||||
search_imdbid: 0,
|
||||
sites: [],
|
||||
type: '',
|
||||
name: '',
|
||||
@@ -63,6 +65,50 @@ async function updateSubscribeInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置用户设置的默认订阅规则
|
||||
async function saveDefaultSubscribeConfig() {
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影')
|
||||
subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else
|
||||
subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
subscribe_config_url,
|
||||
subscribeForm.value)
|
||||
if (result.success)
|
||||
$toast.success(`${props.type}订阅默认规则保存成功`)
|
||||
else
|
||||
$toast.error(`${props.type}订阅默认规则保存失败!`)
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询用户设置的默认订阅规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影')
|
||||
subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else
|
||||
subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
if (result.data.value)
|
||||
subscribeForm.value = result.data?.value ?? ''
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取站点列表数据
|
||||
async function loadSites() {
|
||||
try {
|
||||
@@ -100,6 +146,7 @@ async function getSubscribeInfo() {
|
||||
)
|
||||
subscribeForm.value = result
|
||||
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
|
||||
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
@@ -207,11 +254,13 @@ const effectOptions = ref([
|
||||
},
|
||||
])
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.subid) {
|
||||
getSiteList()
|
||||
onMounted(() => {
|
||||
getSiteList()
|
||||
if (props.subid)
|
||||
getSubscribeInfo()
|
||||
}
|
||||
|
||||
if (props.default)
|
||||
queryDefaultSubscribeConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -221,7 +270,7 @@ watchEffect(() => {
|
||||
max-width="60rem"
|
||||
>
|
||||
<VCard
|
||||
:title="`编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`"
|
||||
:title="`${props.default ? `设置${props.type}默认订阅规则` : `编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<VCardText class="pt-2">
|
||||
@@ -233,8 +282,10 @@ watchEffect(() => {
|
||||
md="8"
|
||||
>
|
||||
<VTextField
|
||||
v-if="!props.default"
|
||||
v-model="subscribeForm.keyword"
|
||||
label="搜索关键词"
|
||||
hint="设定搜索关键词后,将使用此关键词搜索站点资源,否则自动使用themoviedb中的名称搜索"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -246,6 +297,7 @@ watchEffect(() => {
|
||||
v-model="subscribeForm.total_episode"
|
||||
label="总集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定剧集的总集数,以应对themoviedb中剧集信息未维护完整,导致提前结束订阅的情况"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -257,6 +309,7 @@ watchEffect(() => {
|
||||
v-model="subscribeForm.start_episode"
|
||||
label="开始集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="只订阅下载此集数及之后的剧集"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -300,6 +353,7 @@ watchEffect(() => {
|
||||
<VTextField
|
||||
v-model="subscribeForm.include"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -309,6 +363,7 @@ watchEffect(() => {
|
||||
<VTextField
|
||||
v-model="subscribeForm.exclude"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -321,6 +376,7 @@ watchEffect(() => {
|
||||
chips
|
||||
label="订阅站点"
|
||||
multiple
|
||||
hint="只订阅选中的订阅站点,不选则订阅所有可订阅站点"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -331,6 +387,7 @@ watchEffect(() => {
|
||||
<VTextField
|
||||
v-model="subscribeForm.save_path"
|
||||
label="保存路径"
|
||||
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -342,6 +399,7 @@ watchEffect(() => {
|
||||
<VSwitch
|
||||
v-model="subscribeForm.best_version"
|
||||
label="洗版"
|
||||
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -351,6 +409,7 @@ watchEffect(() => {
|
||||
<VSwitch
|
||||
v-model="subscribeForm.search_imdbid"
|
||||
label="使用 ImdbID 搜索"
|
||||
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -358,13 +417,13 @@ watchEffect(() => {
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="removeSubscribe">
|
||||
<VBtn v-if="!props.default" color="error" @click="removeSubscribe">
|
||||
取消订阅
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="updateSubscribeInfo"
|
||||
@click="`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
|
||||
245
src/components/table/SiteTorrentTable.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Number,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
{ title: '标题', key: 'title', sortable: false },
|
||||
{ title: '时间', key: 'pubdate', sortable: true },
|
||||
{ title: '大小', key: 'size', sortable: true },
|
||||
{ title: '做种', key: 'seeders', sortable: true },
|
||||
{ title: '下载', key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site}`)
|
||||
resourceLoading.value = false
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0)
|
||||
return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1)
|
||||
return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1)
|
||||
return 'text-white bg-sky-500'
|
||||
else
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_torrent: any) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认下载【${_torrent.site_name}】${_torrent?.title} ?`,
|
||||
confirmationText: '确认',
|
||||
cancellationText: '取消',
|
||||
dialogProps: {
|
||||
maxWidth: '50rem',
|
||||
},
|
||||
confirmationButtonProps: {
|
||||
variant: 'tonal',
|
||||
},
|
||||
})
|
||||
|
||||
if (!isConfirmed)
|
||||
return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/add', _torrent)
|
||||
|
||||
if (result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
}
|
||||
else {
|
||||
// 添加下载失败
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<VChip
|
||||
v-if="item.hit_and_run"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1 text-white bg-black"
|
||||
>
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.freedate_diff"
|
||||
variant="elevated"
|
||||
color="secondary"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="
|
||||
getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)
|
||||
"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</a>
|
||||
</template>
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
{{ item.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.seeders }}</div>
|
||||
</template>
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.peers }}</div>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
<template #no-data>
|
||||
没有数据
|
||||
</template>
|
||||
</VDataTable>
|
||||
</template>
|
||||
@@ -44,7 +44,7 @@ const superUser = store.state.auth.superUser
|
||||
</IconBtn>
|
||||
|
||||
<!-- 👉 Shortcuts -->
|
||||
<ShortcutBar />
|
||||
<ShortcutBar v-if="superUser" />
|
||||
|
||||
<!-- 👉 Theme -->
|
||||
<NavbarThemeSwitcher class="me-2" />
|
||||
|
||||
@@ -4,7 +4,9 @@ import NetTestView from '@/views/system/NetTestView.vue'
|
||||
import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import RuleTestView from '@/views/system/RuleTestView.vue'
|
||||
import ModuleTestView from '@/views/system/ModuleTestView.vue'
|
||||
import MessageView from '@/views/system/MessageView.vue'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
|
||||
// App捷径
|
||||
const appsMenu = ref(false)
|
||||
@@ -24,11 +26,53 @@ const ruleTestDialog = ref(false)
|
||||
// 系统健康检查弹窗
|
||||
const systemTestDialog = ref(false)
|
||||
|
||||
// 消息中心弹窗
|
||||
const messageDialog = ref(false)
|
||||
|
||||
// 输入消息
|
||||
const user_message = ref('')
|
||||
|
||||
// 发送按钮是否可用
|
||||
const sendButtonDisabled = ref(false)
|
||||
|
||||
// 聊天容器
|
||||
const chatContainer = ref<HTMLDivElement>()
|
||||
|
||||
// 滚动到底部
|
||||
function scrollMessageToEnd() {
|
||||
nextTick(() => {
|
||||
if (chatContainer.value) {
|
||||
const scrollDiv = chatContainer.value.$el
|
||||
scrollDiv.scrollTop = scrollDiv.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 拼接全部日志url
|
||||
function allLoggingUrl() {
|
||||
const token = store.state.auth.token
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1`
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
async function sendMessage() {
|
||||
if (user_message.value) {
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${user_message.value}`)
|
||||
user_message.value = ''
|
||||
sendButtonDisabled.value = false
|
||||
scrollMessageToEnd()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollMessageToEnd()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,7 +168,7 @@ function allLoggingUrl() {
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
日志
|
||||
</h6>
|
||||
<span class="text-sm">查看实时日志</span>
|
||||
<span class="text-sm">实时日志</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
<VCol
|
||||
@@ -145,7 +189,7 @@ function allLoggingUrl() {
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
网络
|
||||
</h6>
|
||||
<span class="text-sm">测试网速连通性</span>
|
||||
<span class="text-sm">网速连通性测试</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -168,7 +212,28 @@ function allLoggingUrl() {
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
系统
|
||||
</h6>
|
||||
<span class="text-sm">系统健康检查</span>
|
||||
<span class="text-sm">健康检查</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
|
||||
@click="() => {}"
|
||||
>
|
||||
<VListItem
|
||||
class="pa-4"
|
||||
@click="messageDialog = true"
|
||||
>
|
||||
<VAvatar
|
||||
size="48"
|
||||
variant="tonal"
|
||||
>
|
||||
<VIcon icon="mdi-message-outline" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
消息
|
||||
</h6>
|
||||
<span class="text-sm">消息中心</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -249,4 +314,41 @@ function allLoggingUrl() {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 消息中心弹窗 -->
|
||||
<VDialog
|
||||
v-model="messageDialog"
|
||||
max-width="60rem"
|
||||
scrollable
|
||||
>
|
||||
<VCard title="消息中心">
|
||||
<DialogCloseBtn @click="messageDialog = false" />
|
||||
<VCardText ref="chatContainer">
|
||||
<MessageView @scroll="scrollMessageToEnd" />
|
||||
</VCardText>
|
||||
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
v-model="user_message"
|
||||
placeholder="输入消息或命令"
|
||||
outlined
|
||||
hide-details
|
||||
single-line
|
||||
clearable
|
||||
density="compact"
|
||||
:disabled="sendButtonDisabled"
|
||||
@keydown.enter="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="sendButtonDisabled"
|
||||
@click="sendMessage"
|
||||
>
|
||||
发送
|
||||
</VBtn>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,16 @@ function setDashboardConfig() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 底部操作按钮 -->
|
||||
<VFab
|
||||
icon="mdi-view-dashboard-edit"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="dialog = true"
|
||||
/>
|
||||
<VRow class="match-height">
|
||||
<VCol
|
||||
v-if="config.storage"
|
||||
@@ -132,10 +142,6 @@ function setDashboardConfig() {
|
||||
<MediaServerLatest />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<!-- 底部操作按钮 -->
|
||||
<span class="fixed right-5 bottom-5">
|
||||
<VBtn icon="mdi-view-dashboard-edit" class="me-2" color="primary" size="x-large" @click="dialog = true" />
|
||||
</span>
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
<VDialog
|
||||
v-model="dialog"
|
||||
@@ -168,6 +174,7 @@ function setDashboardConfig() {
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="setDashboardConfig"
|
||||
>
|
||||
保存
|
||||
|
||||
@@ -13,6 +13,7 @@ const store = useStore()
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
otp_password: '',
|
||||
remember: true,
|
||||
})
|
||||
|
||||
@@ -30,6 +31,12 @@ const backgroundImageUrl = ref('')
|
||||
// 背景图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 是否开启双重验证
|
||||
const isOTP = ref(false)
|
||||
|
||||
// 用户名称输入框
|
||||
const usernameInput = ref()
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImage() {
|
||||
api
|
||||
@@ -42,19 +49,38 @@ async function fetchBackgroundImage() {
|
||||
})
|
||||
}
|
||||
|
||||
// 查询是否开启双重验证
|
||||
async function fetchOTP() {
|
||||
const userid = usernameInput.value?.value
|
||||
if (!userid) {
|
||||
isOTP.value = false
|
||||
return
|
||||
}
|
||||
api
|
||||
.get(`/user/otp/${userid}`)
|
||||
.then((response: any) => {
|
||||
isOTP.value = response.success
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.log(error)
|
||||
})
|
||||
}
|
||||
|
||||
// 登录获取token事件
|
||||
function login() {
|
||||
errorMessage.value = ''
|
||||
|
||||
// 进行表单校验
|
||||
if (!form.value.username || !form.value.password)
|
||||
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
|
||||
errorMessage.value = '请输入完整信息'
|
||||
return
|
||||
|
||||
}
|
||||
// 用户名密码
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('username', form.value.username)
|
||||
formData.append('password', form.value.password)
|
||||
formData.append('otp_password', form.value.otp_password)
|
||||
|
||||
// 请求token
|
||||
api
|
||||
@@ -85,13 +111,13 @@ function login() {
|
||||
if (!error.response)
|
||||
errorMessage.value = '登录失败,请检查网络连接'
|
||||
else if (error.response.status === 401)
|
||||
errorMessage.value = '登录失败,请检查用户名和密码是否正确'
|
||||
errorMessage.value = '登录失败,请检查用户名、密码或双重验证是否正确'
|
||||
else if (error.response.status === 403)
|
||||
errorMessage.value = '登录失败,您没有权限访问'
|
||||
else if (error.response.status === 500)
|
||||
errorMessage.value = '登录失败,服务器错误'
|
||||
else
|
||||
errorMessage.value = `登录失败 ${error.response.status},请检查用户名和密码是否正确`
|
||||
errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或双重验证码是否正确`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,13 +174,14 @@ onMounted(() => {
|
||||
<!-- username -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
ref="usernameInput"
|
||||
v-model="form.username"
|
||||
label="用户名"
|
||||
type="text"
|
||||
:rules="[requiredValidator]"
|
||||
@input="fetchOTP"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- password -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
@@ -167,23 +194,24 @@ onMounted(() => {
|
||||
:rules="[requiredValidator]"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="text-error mt-1"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="isOTP"
|
||||
v-model="form.otp_password"
|
||||
label="双重验证码"
|
||||
type="input"
|
||||
/>
|
||||
<!-- remember me checkbox -->
|
||||
<div class="d-flex align-center justify-space-between flex-wrap mt-1 mb-4">
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<VCheckbox
|
||||
v-model="form.remember"
|
||||
label="保持登录"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<!-- login button -->
|
||||
<VBtn
|
||||
block
|
||||
@@ -192,6 +220,12 @@ onMounted(() => {
|
||||
>
|
||||
登录
|
||||
</VBtn>
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="text-error mt-2 text-shadow"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
|
||||
@@ -16,6 +16,12 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||
title="正在热映"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="bangumi/calendar"
|
||||
linkurl="/browse/bangumi/calendar?title=Bangumi每日放送"
|
||||
title="Bangumi每日放送"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="tmdb/movies"
|
||||
linkurl="/browse/tmdb/movies?title=热门电影"
|
||||
|
||||
@@ -75,7 +75,7 @@ async function fetchData() {
|
||||
else {
|
||||
startLoadingProgress()
|
||||
// 优先按TMDBID精确查询
|
||||
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:')) {
|
||||
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
|
||||
dataList.value = await api.get(`search/media/${keyword}`, {
|
||||
params: {
|
||||
mtype: type,
|
||||
@@ -127,20 +127,24 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<!-- 视图切换 -->
|
||||
<span v-if="dataList.length > 0" class="fixed right-5 bottom-5">
|
||||
<VBtn
|
||||
v-if="viewType === 'list'"
|
||||
size="x-large"
|
||||
icon="mdi-view-grid"
|
||||
color="primary"
|
||||
@click="setViewType('card')"
|
||||
/>
|
||||
<VBtn
|
||||
v-else
|
||||
size="x-large"
|
||||
icon="mdi-view-list"
|
||||
color="primary"
|
||||
@click="setViewType('list')"
|
||||
/>
|
||||
</span>
|
||||
<VFab
|
||||
v-if="viewType === 'list'"
|
||||
icon="mdi-view-grid"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="setViewType('card')"
|
||||
/>
|
||||
<VFab
|
||||
v-else
|
||||
icon="mdi-view-list"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="setViewType('list')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -80,6 +80,7 @@ export default {
|
||||
// set v-rating default color to primary
|
||||
color: 'rgba(var(--v-theme-on-background),0.23)',
|
||||
activeColor: 'warning',
|
||||
halfIncrements: true,
|
||||
},
|
||||
VProgressCircular: {
|
||||
// set v-progress-circular default color to primary
|
||||
|
||||
@@ -44,7 +44,7 @@ onMounted(fetchData)
|
||||
<template #content>
|
||||
<template
|
||||
v-for="data in dataList"
|
||||
:key="data.tmdb_id || data.douban_id"
|
||||
:key="data.tmdb_id || data.douban_id || data.bangumi_id"
|
||||
>
|
||||
<MediaCard
|
||||
:media="data"
|
||||
|
||||
@@ -51,6 +51,15 @@ const subscribeRules = ref({
|
||||
show_edit_dialog: false,
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
return mediaDetail.value?.tmdb_id
|
||||
? `tmdb:${mediaDetail.value?.tmdb_id}`
|
||||
: mediaDetail.value?.douban_id
|
||||
? `douban:${mediaDetail.value?.douban_id}`
|
||||
: `bangumi:${mediaDetail.value?.bangumi_id}`
|
||||
}
|
||||
|
||||
// 调用API查询详情
|
||||
async function getMediaDetail() {
|
||||
if (mediaProps.mediaid && mediaProps.type) {
|
||||
@@ -60,7 +69,7 @@ async function getMediaDetail() {
|
||||
},
|
||||
})
|
||||
isRefreshed.value = true
|
||||
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id)
|
||||
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id)
|
||||
return
|
||||
|
||||
// 检查存在状态
|
||||
@@ -113,7 +122,7 @@ async function checkExists() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function checkSubscribe(season = 0) {
|
||||
try {
|
||||
const mediaid = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
@@ -198,6 +207,7 @@ async function addSubscribe(season = 0) {
|
||||
year: mediaDetail.value?.year,
|
||||
tmdbid: mediaDetail.value?.tmdb_id,
|
||||
doubanid: mediaDetail.value?.douban_id,
|
||||
bangumiid: mediaDetail.value?.bangumi_id,
|
||||
season,
|
||||
best_version,
|
||||
})
|
||||
@@ -253,9 +263,7 @@ async function removeSubscribe(season: number) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = mediaDetail.value?.tmdb_id
|
||||
? `tmdb:${mediaDetail.value?.tmdb_id}`
|
||||
: `douban:${mediaDetail.value?.douban_id}`
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(
|
||||
`subscribe/media/${mediaid}`,
|
||||
@@ -330,6 +338,11 @@ function getTvdbLink() {
|
||||
return `https://www.thetvdb.com/series/${mediaDetail.value.tvdb_id}`
|
||||
}
|
||||
|
||||
// 拼装Bangumi地址
|
||||
function getBangumiLink() {
|
||||
return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}`
|
||||
}
|
||||
|
||||
// 拼装集图片地址
|
||||
function getEpisodeImage(stillPath: string) {
|
||||
if (!stillPath)
|
||||
@@ -405,7 +418,7 @@ function joinArray(arr: string[]) {
|
||||
|
||||
// 开始搜索
|
||||
function handleSearch(area: string) {
|
||||
const keyword = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
|
||||
const keyword = getMediaId()
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
@@ -453,7 +466,7 @@ onBeforeMount(() => {
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" class="max-w-8xl mx-auto px-4">
|
||||
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" class="max-w-8xl mx-auto px-4">
|
||||
<template v-if="mediaDetail.backdrop_path || mediaDetail.poster_path">
|
||||
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
|
||||
<VImg class="h-96" :src="mediaDetail.backdrop_path || mediaDetail.poster_path" cover />
|
||||
@@ -492,7 +505,7 @@ onBeforeMount(() => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="media-actions">
|
||||
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" variant="tonal" color="info" class="mb-2">
|
||||
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" variant="tonal" color="info" class="mb-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</template>
|
||||
@@ -518,7 +531,7 @@ onBeforeMount(() => {
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
|
||||
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
|
||||
<template #prepend>
|
||||
<VIcon :icon="getSubscribeIcon" />
|
||||
</template>
|
||||
@@ -580,6 +593,12 @@ onBeforeMount(() => {
|
||||
<span class="ms-1">TheTvDb</span>
|
||||
</div>
|
||||
</a>
|
||||
<a v-if="mediaDetail.bangumi_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getBangumiLink()" target="_blank">
|
||||
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700">
|
||||
<VIcon icon="mdi-link" />
|
||||
<span class="ms-1">Bangumi</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">
|
||||
季
|
||||
@@ -740,6 +759,33 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.bangumi_id" class="media-overview-right">
|
||||
<div class="media-facts">
|
||||
<div v-if="mediaDetail.vote_average" class="media-ratings">
|
||||
<VRating
|
||||
v-model="mediaDetail.vote_average"
|
||||
density="compact"
|
||||
length="10"
|
||||
class="ma-2"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.bangumi_id" class="media-fact">
|
||||
<span>ID</span>
|
||||
<span class="media-fact-value">{{ mediaDetail.bangumi_id }}</span>
|
||||
</div>
|
||||
<div v-if="mediaDetail.original_title" class="media-fact">
|
||||
<span>原始标题</span>
|
||||
<span class="media-fact-value">{{ mediaDetail.original_title }}</span>
|
||||
</div>
|
||||
<div v-if="mediaDetail.release_date" class="media-fact border-b-0">
|
||||
<span>上映日期</span>
|
||||
<span class="media-fact-value">
|
||||
{{ mediaDetail.release_date }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id">
|
||||
<PersonCardSlideView
|
||||
@@ -757,6 +803,14 @@ onBeforeMount(() => {
|
||||
type="douban"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.bangumi_id">
|
||||
<PersonCardSlideView
|
||||
:apipath="`bangumi/credits/${mediaDetail.bangumi_id}`"
|
||||
:linkurl="`/credits/bangumi/credits/${mediaDetail.bangumi_id}?title=演员阵容&type=bangumi`"
|
||||
title="演员阵容"
|
||||
type="bangumi"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`"
|
||||
@@ -771,6 +825,13 @@ onBeforeMount(() => {
|
||||
title="推荐"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.bangumi_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`bangumi/recommend/${mediaDetail.bangumi_id}`"
|
||||
:linkurl="`/browse/bangumi/recommend/${mediaDetail.bangumi_id}?title=推荐`"
|
||||
title="推荐"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"
|
||||
@@ -781,7 +842,7 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && isRefreshed"
|
||||
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
|
||||
error-code="500"
|
||||
error-title="出错啦!"
|
||||
error-description="未识别到媒体信息。"
|
||||
|
||||
@@ -3,6 +3,7 @@ import TmdbPersonCard from '@/components/cards/TmdbPersonCard.vue'
|
||||
import api from '@/api'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import DoubanPersonCard from '@/components/cards/DoubanPersonCard.vue'
|
||||
import BangumiPersonCard from '@/components/cards/BangumiPersonCard.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -59,6 +60,12 @@ onMounted(fetchData)
|
||||
height="15rem"
|
||||
width="10rem"
|
||||
/>
|
||||
<BangumiPersonCard
|
||||
v-if="props.type === 'bangumi'"
|
||||
:person="data"
|
||||
height="15rem"
|
||||
width="10rem"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</SlideView>
|
||||
|
||||
@@ -68,6 +68,14 @@ function initOptions(data: Context) {
|
||||
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
|
||||
}
|
||||
|
||||
// 对季过滤选项进行排序
|
||||
const sortSeasonFilterOptions = computed(() => {
|
||||
return seasonFilterOptions.value.sort((a, b) => {
|
||||
// 按字符串升序排序
|
||||
return a.localeCompare(b, 'zh-Hans-CN', { sensitivity: 'accent' })
|
||||
})
|
||||
})
|
||||
|
||||
// 计算分组后的列表
|
||||
onMounted(() => {
|
||||
// 数据分组
|
||||
@@ -154,7 +162,7 @@ watchEffect(() => {
|
||||
<VCol v-if="seasonFilterOptions.length > 0" cols="6" md="">
|
||||
<VSelect
|
||||
v-model="filterForm.season"
|
||||
:items="seasonFilterOptions"
|
||||
:items="sortSeasonFilterOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
|
||||
// 已安装插件列表
|
||||
const dataList = ref<Plugin[]>([])
|
||||
@@ -20,16 +22,118 @@ const isAppMarketLoaded = ref(false)
|
||||
// APP市场窗口
|
||||
const PluginAppDialog = ref(false)
|
||||
|
||||
// 插件安装统计
|
||||
const PluginStatistics = ref<{ [key: string]: number }>({})
|
||||
|
||||
// 搜索窗口
|
||||
const SearchDialog = ref(false)
|
||||
|
||||
// 搜索关键字
|
||||
const keyword = ref('')
|
||||
|
||||
// 每一个插件的图标加载状态
|
||||
const pluginIconLoaded = ref<{ [key: string]: boolean }>({})
|
||||
|
||||
// 每一个插件的动作标识
|
||||
const pluginActions = ref<{ [key: string]: boolean }>({})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('正在安装插件...')
|
||||
|
||||
// 关闭插件市场窗口
|
||||
function pluginDialogClose() {
|
||||
PluginAppDialog.value = false
|
||||
}
|
||||
|
||||
// 安装插件
|
||||
async function installPlugin(item: Plugin) {
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在安装 ${item?.plugin_name} v${item?.plugin_version} ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
`plugin/install/${item?.id}`,
|
||||
{
|
||||
params: {
|
||||
repo_url: item?.repo_url,
|
||||
force: item?.has_update,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`插件 ${item?.plugin_name} 安装成功!`)
|
||||
|
||||
// 刷新
|
||||
refreshData()
|
||||
}
|
||||
else {
|
||||
$toast.error(`插件 ${item?.plugin_name} 安装失败:${result.message}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开插件搜索结果
|
||||
function openPlugin(item: Plugin) {
|
||||
// 如果是已安装插件则打开插件详情
|
||||
if (item.installed === true) {
|
||||
// 标记插件动作
|
||||
pluginActions.value[item.id || '0'] = true
|
||||
}
|
||||
else {
|
||||
// 如果是未安装插件则安装
|
||||
installPlugin(item)
|
||||
}
|
||||
closeSearchDialog()
|
||||
}
|
||||
|
||||
// 关闭插件搜索窗口
|
||||
function closeSearchDialog() {
|
||||
SearchDialog.value = false
|
||||
}
|
||||
|
||||
// 插件图标加载错误
|
||||
function pluginIconError(item: Plugin) {
|
||||
pluginIconLoaded.value[item.id || '0'] = false
|
||||
}
|
||||
|
||||
// 插件图标地址
|
||||
function pluginIcon(item: Plugin) {
|
||||
// 如果图片加载错误
|
||||
if (pluginIconLoaded.value[item.id || '0'] === false)
|
||||
return noImage
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (item?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(item?.plugin_icon)}/1`
|
||||
|
||||
return `./plugin_icon/${item?.plugin_icon}`
|
||||
}
|
||||
|
||||
// 过滤插件
|
||||
const filterPlugins = computed(() => {
|
||||
const all_list = [...dataList.value, ...uninstalledList.value]
|
||||
return all_list.filter((item: Plugin) => {
|
||||
return item.plugin_name?.includes(keyword.value) || item.plugin_desc?.includes(keyword.value)
|
||||
})
|
||||
})
|
||||
|
||||
// 新安装了插件
|
||||
function pluginInstalled() {
|
||||
fetchInstalledPlugins()
|
||||
pluginDialogClose()
|
||||
fetchUninstalledPlugins()
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// 获取插件列表数据
|
||||
@@ -55,17 +159,53 @@ async function fetchUninstalledPlugins() {
|
||||
state: 'market',
|
||||
},
|
||||
})
|
||||
// 设置APP市场加载完成
|
||||
isAppMarketLoaded.value = true
|
||||
// 设置更新状态
|
||||
for (const uninstalled of uninstalledList.value) {
|
||||
for (const data of dataList.value) {
|
||||
if (uninstalled.id === data.id) {
|
||||
data.has_update = true
|
||||
data.repo_url = uninstalled.repo_url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onBeforeMount(() => {
|
||||
// 加载插件统计数据
|
||||
async function getPluginStatistics() {
|
||||
try {
|
||||
PluginStatistics.value = await api.get('plugin/statistic')
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有数据
|
||||
function refreshData() {
|
||||
fetchInstalledPlugins()
|
||||
fetchUninstalledPlugins()
|
||||
}
|
||||
|
||||
// 对uninstalledList进行排序,按PluginStatistics倒序
|
||||
const sortedUninstalledList = computed(() => {
|
||||
const list = uninstalledList.value.filter(item => !item.has_update)
|
||||
if (PluginStatistics.value.length === 0)
|
||||
return list
|
||||
return list.sort((a, b) => {
|
||||
return PluginStatistics.value[b.id || '0'] - PluginStatistics.value[a.id || '0']
|
||||
})
|
||||
})
|
||||
|
||||
// 加载时获取数据
|
||||
onBeforeMount(() => {
|
||||
refreshData()
|
||||
getPluginStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -87,10 +227,13 @@ onBeforeMount(() => {
|
||||
>
|
||||
<PluginCard
|
||||
v-for="data in dataList"
|
||||
:key="data.id"
|
||||
:key="`${data.id}_v${data.plugin_version}`"
|
||||
:count="PluginStatistics[data.id || '0']"
|
||||
:plugin="data"
|
||||
@remove="fetchInstalledPlugins"
|
||||
@save="fetchInstalledPlugins"
|
||||
:action="pluginActions[data.id || '0']"
|
||||
@remove="refreshData"
|
||||
@save="refreshData"
|
||||
@action-done="pluginActions[data.id || '0'] = false"
|
||||
/>
|
||||
</div>
|
||||
<NoDataFound
|
||||
@@ -110,11 +253,14 @@ onBeforeMount(() => {
|
||||
>
|
||||
<!-- Dialog Activator -->
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
icon="mdi-store-plus"
|
||||
<VFab
|
||||
v-bind="props"
|
||||
icon="mdi-store-plus"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
class="fixed right-5 bottom-5"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -154,9 +300,10 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card">
|
||||
<PluginAppCard
|
||||
v-for="data in uninstalledList"
|
||||
:key="data.id"
|
||||
v-for="data in sortedUninstalledList"
|
||||
:key="`${data.id}_v${data.plugin_version}`"
|
||||
:plugin="data"
|
||||
:count="PluginStatistics[data.id || '0']"
|
||||
@install="pluginInstalled"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,11 +316,108 @@ onBeforeMount(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 插件搜索 -->
|
||||
<VDialog
|
||||
v-model="SearchDialog"
|
||||
scrollable
|
||||
:z-index="1010"
|
||||
max-width="40rem"
|
||||
max-height="85vh"
|
||||
>
|
||||
<!-- Dialog Activator -->
|
||||
<template #activator="{ props }">
|
||||
<VFab
|
||||
v-bind="props"
|
||||
icon="mdi-magnify"
|
||||
color="info"
|
||||
location="bottom end"
|
||||
class="mb-2"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
/>
|
||||
</template>
|
||||
<VCard
|
||||
class="mx-auto"
|
||||
width="100%"
|
||||
>
|
||||
<VToolbar flat class="p-0">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
label="搜索插件"
|
||||
single-line
|
||||
placeholder="插件名称或描述"
|
||||
variant="solo"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
flat
|
||||
class="mx-1"
|
||||
/>
|
||||
</VToolbar>
|
||||
<DialogCloseBtn @click="closeSearchDialog" />
|
||||
<VList
|
||||
v-if="filterPlugins.length > 0"
|
||||
lines="two"
|
||||
>
|
||||
<template v-for="(item, i) in filterPlugins" :key="i">
|
||||
<VListItem
|
||||
@click="openPlugin(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VAvatar>
|
||||
<VImg
|
||||
:src="pluginIcon(item)"
|
||||
@error="pluginIconError(item)"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{ item.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ item?.plugin_version }}</span>
|
||||
<VIcon
|
||||
v-if="item.installed"
|
||||
color="success"
|
||||
icon="mdi-check-circle"
|
||||
class="ms-2"
|
||||
size="small"
|
||||
/>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2" v-html="item.plugin_desc" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 安装插件进度框 -->
|
||||
<VDialog
|
||||
v-model="progressDialog"
|
||||
:scrim="false"
|
||||
width="25rem"
|
||||
>
|
||||
<VCard
|
||||
color="primary"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
<VProgressLinear
|
||||
indeterminate
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.grid-plugin-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
padding-block-end: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -44,7 +44,7 @@ const filteredDataList = computed(() => {
|
||||
if (superUser)
|
||||
return dataList.value
|
||||
else
|
||||
return dataList.value.filter(data => data.userid === userName)
|
||||
return dataList.value.filter(data => data.userid === userName || data.username === userName)
|
||||
})
|
||||
|
||||
// 加载时获取数据
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import type { TransferHistory } from '@/api/types'
|
||||
import ReorganizeForm from '@/components/form/ReorganizeForm.vue'
|
||||
import { fixArrayAt } from '@/@core/utils/compatibility'
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -304,6 +305,9 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||
fixArrayAt()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -349,34 +353,36 @@ const dropdownItems = ref([
|
||||
show-select
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
class="data-table-div"
|
||||
@update:options="fetchData"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar>
|
||||
<VIcon :icon="getIcon(item.value.type || '')" />
|
||||
<VIcon :icon="getIcon(item.type || '')" />
|
||||
</VAvatar>
|
||||
<div class="d-flex flex-column ms-1">
|
||||
<span class="d-block text-high-emphasis">
|
||||
{{ item.value.title }} {{ item.value.seasons }}{{ item.value.episodes }}
|
||||
{{ item?.title }} {{ item?.seasons }}{{ item?.episodes }}
|
||||
</span>
|
||||
<small>{{ item.value.category }}</small>
|
||||
<small>{{ item?.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.src="{ item }">
|
||||
<small>{{ item.value.src }} <br>=> {{ item.value.dest }}</small>
|
||||
<small>{{ item?.src }} <br>=> {{ item?.dest }}</small>
|
||||
</template>
|
||||
<template #item.mode="{ item }">
|
||||
<VChip variant="outlined" color="primary" size="small">
|
||||
{{ TransferDict[item.value.mode] }}
|
||||
{{ TransferDict[item?.mode || ''] }}
|
||||
</VChip>
|
||||
</template>
|
||||
<template #item.status="{ item }">
|
||||
<VChip v-if="item.value.status" color="success" size="small">
|
||||
<VChip v-if="item?.status" color="success" size="small">
|
||||
成功
|
||||
</VChip>
|
||||
<v-tooltip v-else :text="item.value.errmsg">
|
||||
<v-tooltip v-else :text="item?.errmsg">
|
||||
<template #activator="{ props }">
|
||||
<VChip v-bind="props" color="error" size="small">
|
||||
失败
|
||||
@@ -385,7 +391,7 @@ const dropdownItems = ref([
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<template #item.date="{ item }">
|
||||
<small>{{ item.value.date }}</small>
|
||||
<small>{{ item?.date }}</small>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<IconBtn>
|
||||
@@ -397,7 +403,7 @@ const dropdownItems = ref([
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item.value)"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
@@ -413,19 +419,6 @@ const dropdownItems = ref([
|
||||
</template>
|
||||
</VDataTableServer>
|
||||
</VCard>
|
||||
<!-- 底部操作按钮 -->
|
||||
<span v-if="selected.length > 0" class="fixed right-5 bottom-5">
|
||||
<VTooltip text="批量重新整理">
|
||||
<template #activator="{ props }">
|
||||
<VBtn v-bind="props" icon="mdi-redo-variant" class="me-2" color="primary" size="x-large" @click="retransferBatch" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="批量删除">
|
||||
<template #activator="{ props }">
|
||||
<VBtn v-bind="props" icon="mdi-trash-can-outline" color="error" size="x-large" @click="removeHistoryBatch" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
<!-- 底部弹窗 -->
|
||||
<VBottomSheet v-model="deleteConfirmDialog" inset>
|
||||
<VCard class="text-center rounded-t">
|
||||
@@ -469,10 +462,38 @@ const dropdownItems = ref([
|
||||
"
|
||||
@close="redoDialog = false"
|
||||
/>
|
||||
<!-- 底部操作按钮 -->
|
||||
<span>
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
icon="mdi-trash-can-outline"
|
||||
color="error"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="removeHistoryBatch"
|
||||
/>
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
class="mb-2"
|
||||
icon="mdi-redo-variant"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="retransferBatch"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table-div {
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { VForm } from 'vuetify/lib/components/index.mjs'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
@@ -19,6 +21,18 @@ const refInputEl = ref<HTMLElement>()
|
||||
// 新增用户窗口
|
||||
const addUserDialog = ref(false)
|
||||
|
||||
// 开启双重验证窗口
|
||||
const otpDialog = ref(false)
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
// 新增用户表单
|
||||
const userForm = reactive({
|
||||
name: '',
|
||||
@@ -35,11 +49,15 @@ const accountInfo = ref<User>({
|
||||
is_active: false,
|
||||
is_superuser: false,
|
||||
avatar: '',
|
||||
is_otp: false,
|
||||
})
|
||||
|
||||
// 所有用户信息
|
||||
const allUsers = ref<User[]>([])
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// changeAvatar function
|
||||
function changeAvatar(file: Event) {
|
||||
const fileReader = new FileReader()
|
||||
@@ -65,7 +83,7 @@ function resetAvatar() {
|
||||
async function loadAccountInfo() {
|
||||
try {
|
||||
const user: User = await api.get('user/current')
|
||||
|
||||
console.log(user)
|
||||
accountInfo.value = user
|
||||
if (!accountInfo.value.avatar)
|
||||
accountInfo.value.avatar = avatar1
|
||||
@@ -167,6 +185,65 @@ async function addUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
async function getOtpUri() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user/otp/generate')
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
otpDialog.value = true
|
||||
}
|
||||
else {
|
||||
$toast.error(`获取otp uri失败:${result.message}!`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
async function disableOtp() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user/otp/disable')
|
||||
if (result.success) {
|
||||
accountInfo.value.is_otp = false
|
||||
$toast.success('关闭登录双重验证成功!')
|
||||
}
|
||||
else {
|
||||
$toast.error(`关闭otp失败:${result.message}!`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error('请填写6位验证码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user/otp/judge', { uri: otpUri.value, otpPassword: otpPassword.value })
|
||||
|
||||
if (result.success) {
|
||||
$toast.success('开启登录双重验证成功!')
|
||||
otpDialog.value = false
|
||||
accountInfo.value.is_otp = true
|
||||
}
|
||||
else {
|
||||
$toast.error(`开启otp失败:${result.message}!`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载当前用户数据
|
||||
onMounted(() => {
|
||||
loadAccountInfo()
|
||||
@@ -196,9 +273,8 @@ onMounted(() => {
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-cloud-upload-outline"
|
||||
class="d-sm-none"
|
||||
/>
|
||||
<span class="d-none d-sm-block">上传头像</span>
|
||||
<span class="d-none d-sm-block ms-2">上传头像</span>
|
||||
</VBtn>
|
||||
|
||||
<input
|
||||
@@ -216,11 +292,21 @@ onMounted(() => {
|
||||
variant="tonal"
|
||||
@click="resetAvatar"
|
||||
>
|
||||
<span class="d-none d-sm-block">重置</span>
|
||||
<VIcon
|
||||
icon="mdi-refresh"
|
||||
class="d-sm-none"
|
||||
/>
|
||||
<span class="d-none d-sm-block ms-2">重置</span>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
:color="accountInfo.is_otp ? 'error' : 'info'"
|
||||
variant="tonal"
|
||||
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-account-key"
|
||||
/>
|
||||
<span class="d-none d-sm-block ms-2">{{ accountInfo.is_otp ? "关闭验证" : "双重验证" }}</span>
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
@@ -268,11 +354,9 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="newPassword"
|
||||
:type="isNewPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
||||
"
|
||||
:append-inner-icon="isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
label="新密码"
|
||||
autocomplete="new-password"
|
||||
autocomplete=""
|
||||
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -369,7 +453,7 @@ onMounted(() => {
|
||||
</td>
|
||||
<td>{{ user.is_superuser ? "是" : "否" }}</td>
|
||||
<td>
|
||||
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name != user.name">
|
||||
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name !== user.name">
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu
|
||||
activator="parent"
|
||||
@@ -409,11 +493,12 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<!-- 站点编辑弹窗 -->
|
||||
<!-- =弹窗 -->
|
||||
<VDialog
|
||||
v-model="addUserDialog"
|
||||
max-width="50rem"
|
||||
persistent
|
||||
z-index="1010"
|
||||
>
|
||||
<!-- Dialog Content -->
|
||||
<VCard title="新增用户">
|
||||
@@ -469,4 +554,60 @@ onMounted(() => {
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 双重验证弹窗 -->
|
||||
<VDialog
|
||||
v-model="otpDialog"
|
||||
max-width="45rem"
|
||||
persistent
|
||||
z-index="1010"
|
||||
>
|
||||
<!-- 开启双重验证弹窗内容 -->
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="otpDialog = false" />
|
||||
<VCardText>
|
||||
<h4 class="text-h4 text-center mb-6 mt-5">
|
||||
登录双重验证
|
||||
</h4><h5 class="text-h5 font-weight-medium mb-2">
|
||||
身份验证器
|
||||
</h5>
|
||||
<p class="mb-6">
|
||||
使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。
|
||||
</p>
|
||||
<div class="my-6">
|
||||
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
|
||||
</div>
|
||||
<VAlert
|
||||
:title="secret"
|
||||
variant="tonal"
|
||||
type="warning"
|
||||
class="my-4"
|
||||
text="如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。"
|
||||
>
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm>
|
||||
<VTextField
|
||||
v-model="otpPassword"
|
||||
type="text"
|
||||
label="输入验证码以确认开启双重验证"
|
||||
autocomplete=""
|
||||
class="mb-8"
|
||||
variant="outlined"
|
||||
/>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn @click="judgeOtpPassword">
|
||||
确定
|
||||
<template #append>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -209,6 +209,7 @@ onMounted(() => {
|
||||
chips
|
||||
:items="NotificationChannels"
|
||||
label="当前使用通知渠道"
|
||||
hint="选中的渠道才会按消息类型的设定发送消息"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -246,36 +247,42 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_CORPID"
|
||||
label="企业ID"
|
||||
hint="登录企业微信后台,在 https://work.weixin.qq.com/wework_admin/frame#profile 中查看"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_APP_SECRET"
|
||||
label="应用密钥"
|
||||
label="应用Secret"
|
||||
hint="在企业微信中创建应用,查看应用的Secret"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_APP_ID"
|
||||
label="应用ID"
|
||||
label="应用 AgentId"
|
||||
hint="在企业微信中创建应用,查看应用的AgentId"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_PROXY"
|
||||
label="代理地址"
|
||||
hint="由于微信官方限制,2022年6月20日后创建的企业微信应用需要有固定的公网IP地址并加入IP白名单后才能接收消息,使用有固定公网IP的代理服务器转发可解决该问题;代理服务器需自行搭建,搭建方法参考项目主页说明,不使用代理需保留默认值"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_TOKEN"
|
||||
label="Token"
|
||||
hint="在微信企业应用管理后台-接收消息设置页面生成"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_ENCODING_AESKEY"
|
||||
label="EncodingAESKey"
|
||||
hint="在微信企业应用管理后台-接收消息设置页面生成,所有信息填入完成后保存,然后再在企业微信应用消息接收服务中输入回调地址:http(s)://domain:port/api/v1/message/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -283,6 +290,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.WECHAT_ADMINS"
|
||||
label="管理员白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用菜单管理功能,不填写则所有用户都能使用,菜单会自动生成,不需要手动创建"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -295,12 +303,14 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.TELEGRAM_TOKEN"
|
||||
label="Bot Token"
|
||||
hint="Telegram机器人的token,关注BotFather创建机器人并获取token,格式为:123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationSettings.TELEGRAM_CHAT_ID"
|
||||
label="Chat ID"
|
||||
hint="接受消息通知的用户、群组或频道Chat ID,关注@getidsbot获取"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -308,6 +318,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.TELEGRAM_USERS"
|
||||
label="用户白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用Telegram机器人,不填写则所有用户都能使用,多个用户用英文,分隔"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -315,6 +326,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.TELEGRAM_ADMINS"
|
||||
label="管理员白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用管理功能,不填写则所有用户都能使用,多个用户用英文,分隔。菜单会自动生成,不需要手动创建"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -327,12 +339,16 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.SLACK_OAUTH_TOKEN"
|
||||
label="Slack Bot User OAuth Token"
|
||||
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
hint="在 https://api.slack.com/apps 中创建应用,查看OAuth & Permissions页面中的Bot User OAuth Token"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="5">
|
||||
<VTextField
|
||||
v-model="notificationSettings.SLACK_APP_TOKEN"
|
||||
label="Slack App-Level Token"
|
||||
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
hint="在 https://api.slack.com/apps 中创建应用,查看OAuth & Permissions页面中的App-Level Token"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
@@ -340,6 +356,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.SLACK_CHANNEL"
|
||||
label="频道名称"
|
||||
placeholder="全体"
|
||||
hint="消息发送到的频道名称,不填写则发送到全体频道"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -351,13 +368,15 @@ onMounted(() => {
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationSettings.SYNOLOGYCHAT_WEBHOOK"
|
||||
label="Webhook"
|
||||
label="机器人传入URL"
|
||||
hint="在Synology Chat中创建机器人,获取机器人传入URL"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationSettings.SYNOLOGYCHAT_TOKEN"
|
||||
label="Token"
|
||||
label="令牌"
|
||||
hint="在Synology Chat中创建机器人,获取机器人令牌"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -376,6 +395,7 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.VOCECHAT_API_KEY"
|
||||
label="机器人密钥"
|
||||
hint="在VoceChat中创建机器人,获取机器人密钥"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -383,6 +403,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.VOCECHAT_CHANNEL_ID"
|
||||
label="频道ID"
|
||||
placeholder="不包含#号"
|
||||
hint="在VoceChat中创建频道,获取频道ID,不包含#号"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -390,6 +390,7 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.include"
|
||||
type="text"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -397,6 +398,7 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.exclude"
|
||||
type="text"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -24,6 +24,7 @@ const cookieCloudSetting = ref({
|
||||
COOKIECLOUD_PASSWORD: '',
|
||||
COOKIECLOUD_INTERVAL: 0,
|
||||
USER_AGENT: '',
|
||||
COOKIECLOUD_ENABLE_LOCAL: '',
|
||||
})
|
||||
|
||||
// 种子优先规则下拉框
|
||||
@@ -108,6 +109,7 @@ async function loadCookieCloudSettings() {
|
||||
COOKIECLOUD_PASSWORD,
|
||||
COOKIECLOUD_INTERVAL,
|
||||
USER_AGENT,
|
||||
COOKIECLOUD_ENABLE_LOCAL,
|
||||
} = result.data
|
||||
cookieCloudSetting.value = {
|
||||
COOKIECLOUD_HOST,
|
||||
@@ -115,6 +117,7 @@ async function loadCookieCloudSettings() {
|
||||
COOKIECLOUD_PASSWORD,
|
||||
COOKIECLOUD_INTERVAL,
|
||||
USER_AGENT,
|
||||
COOKIECLOUD_ENABLE_LOCAL,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,18 +158,30 @@ onMounted(() => {
|
||||
<VCardSubtitle> 从CookieCloud快速同步站点数据。 </VCardSubtitle>
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCheckbox
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
|
||||
label="启用本地CookieCloud服务器"
|
||||
hint="启用后,将使用内建CookieCloud服务同步站点数据,服务地址为:http://localhost:3000/cookiecloud"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_HOST"
|
||||
label="CookieCloud服务器地址"
|
||||
label="远程CookieCloud服务器地址"
|
||||
placeholder="https://movie-pilot.org/cookiecloud"
|
||||
:disabled="!!cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
|
||||
hint="格式:https://movie-pilot.org/cookiecloud"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_KEY"
|
||||
label="用户KEY"
|
||||
hint="在CookieCloud浏览器插件中生成"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -174,6 +189,7 @@ onMounted(() => {
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_PASSWORD"
|
||||
type="password"
|
||||
label="端对端加密密码"
|
||||
hint="在CookieCloud浏览器插件中生成"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -181,12 +197,14 @@ onMounted(() => {
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_INTERVAL"
|
||||
label="自动同步间隔"
|
||||
:items="CookieCloudIntervalItems"
|
||||
hint="设置定时从CookieCloud服务器同步站点Cookie到MoviePilot的时间周期"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.USER_AGENT"
|
||||
label="浏览器User-Agent"
|
||||
hint="设置为CookieCloud插件所在的浏览器的User-Agent,用于模拟浏览器请求,正确填写后有助于提升站点访问成功率"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -213,6 +231,7 @@ onMounted(() => {
|
||||
v-model="selectedTorrentPriority"
|
||||
:items="TorrentPriorityItems"
|
||||
label="当前使用下载优先规则"
|
||||
hint="站点优先:优先下载站点优先级最高的站点的种子;做种数优先:优先下载做种数量最多的种子。注意下载优先级仍然低于搜索和订阅中设定的优先级规则"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -232,7 +251,11 @@ onMounted(() => {
|
||||
<VCard title="站点重置">
|
||||
<VCardText>
|
||||
<div>
|
||||
<VCheckbox v-model="isConfirmResetSites" label="确认删除所有站点数据并重新同步。" />
|
||||
<VCheckbox
|
||||
v-model="isConfirmResetSites"
|
||||
label="确认删除所有站点数据并重新同步。"
|
||||
hint="删除所有站点数据并重新同步,站点图标短时间内会因数缓存而混乱,重启或者等待2两时自动恢复。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VBtn :disabled="!isConfirmResetSites || resetSitesDisabled" color="error" class="mt-3" @click="resetSites">
|
||||
|
||||
@@ -411,6 +411,7 @@ onMounted(() => {
|
||||
v-model="selectedSubscribeMode"
|
||||
:items="subscribeModeItems"
|
||||
label="订阅模式"
|
||||
hint="自动:系统自动爬取站点首页资源;站点RSS:使用站点RSS订阅资源,站点RSS会自动获取,也可手动在站点管理中补全"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -418,6 +419,7 @@ onMounted(() => {
|
||||
v-model="selectedRssInterval"
|
||||
:items="rssIntervalItems"
|
||||
label="站点RSS周期"
|
||||
hint="设置站点RSS运行周期,在订阅模式为站点RSS时生效"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -426,6 +428,7 @@ onMounted(() => {
|
||||
<VSwitch
|
||||
v-model="enableIntervalSearch"
|
||||
label="开启订阅定时搜索"
|
||||
hint="开启后,系统每隔24小时将按名称搜索全站,补全订阅可能漏掉的资源"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -581,6 +584,7 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.include"
|
||||
type="text"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -588,6 +592,7 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.exclude"
|
||||
type="text"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -596,6 +601,7 @@ onMounted(() => {
|
||||
type="text"
|
||||
label="电影文件大小(GB)"
|
||||
placeholder="0-30"
|
||||
hint="格式:0-30,表示0到30GB之间的资源"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -604,6 +610,7 @@ onMounted(() => {
|
||||
type="text"
|
||||
label="剧集单集文件大小(GB)"
|
||||
placeholder="0-10"
|
||||
hint="格式:0-10,表示0到10GB之间的资源"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -612,12 +619,14 @@ onMounted(() => {
|
||||
type="text"
|
||||
label="最小做种数"
|
||||
placeholder="0"
|
||||
hint="小于该值的资源将被过滤掉,0表示不过滤"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="defaultFilterRules.show_edit_dialog"
|
||||
label="订阅时编辑更多规则"
|
||||
hint="开启后,添加订阅时将自动弹出订阅编辑框,要设置更多订阅选项"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -8,6 +8,9 @@ import { requiredValidator } from '@/@validators'
|
||||
// 选中的媒体服务器
|
||||
const selectedMediaServers = ref([])
|
||||
|
||||
// 选中的下载器
|
||||
const selectedDownloaders = ref([])
|
||||
|
||||
// 下载器选中标签页
|
||||
const downloaderTab = ref('qbittorrent')
|
||||
|
||||
@@ -33,7 +36,6 @@ const mediaSettings = ref({
|
||||
|
||||
// 下载器设置项
|
||||
const downloaderSettings = ref({
|
||||
DOWNLOADER: '',
|
||||
DOWNLOADER_MONITOR: true,
|
||||
TORRENT_TAG: '',
|
||||
QB_HOST: '',
|
||||
@@ -182,12 +184,15 @@ async function saveMediaSetting() {
|
||||
}
|
||||
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownladerSetting() {
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
if (result.success) {
|
||||
const result1: { [key: string]: any } = await api.get('system/setting/DOWNLOADER')
|
||||
if (result1.success)
|
||||
selectedDownloaders.value = result1.data?.value?.split(',')
|
||||
|
||||
const result2: { [key: string]: any } = await api.get('system/env')
|
||||
if (result2.success) {
|
||||
const {
|
||||
DOWNLOADER,
|
||||
DOWNLOADER_MONITOR,
|
||||
TORRENT_TAG,
|
||||
QB_HOST,
|
||||
@@ -199,9 +204,8 @@ async function loadDownladerSetting() {
|
||||
TR_HOST,
|
||||
TR_USER,
|
||||
TR_PASSWORD,
|
||||
} = result.data
|
||||
} = result2.data
|
||||
downloaderSettings.value = {
|
||||
DOWNLOADER,
|
||||
DOWNLOADER_MONITOR,
|
||||
TORRENT_TAG,
|
||||
QB_HOST,
|
||||
@@ -214,7 +218,6 @@ async function loadDownladerSetting() {
|
||||
TR_USER,
|
||||
TR_PASSWORD,
|
||||
}
|
||||
downloaderTab.value = DOWNLOADER === 'qbittorrent' ? 'qbittorrent' : 'transmission'
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -225,12 +228,16 @@ async function loadDownladerSetting() {
|
||||
// 调用API保存下载器设置
|
||||
async function saveDownloaderSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
const result1: { [key: string]: any } = await api.post(
|
||||
'system/setting/DOWNLOADER',
|
||||
selectedDownloaders.value.join(','),
|
||||
)
|
||||
const result2: { [key: string]: any } = await api.post(
|
||||
'system/env',
|
||||
downloaderSettings.value,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
if (result1.success && result2.success) {
|
||||
$toast.success('保存下载器设置成功')
|
||||
reloadModule()
|
||||
}
|
||||
@@ -323,7 +330,7 @@ async function reloadModule() {
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
loadDownladerSetting()
|
||||
loadDownloaderSetting()
|
||||
loadMediaServerSetting()
|
||||
loadMediaSettings()
|
||||
})
|
||||
@@ -333,21 +340,25 @@ onMounted(() => {
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard title="下载器">
|
||||
<VCardSubtitle>只有选中的下载器才会被默认使用。</VCardSubtitle>
|
||||
<VCardSubtitle>只有选中的第1个下载器才会被默认使用。</VCardSubtitle>
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="downloaderSettings.DOWNLOADER"
|
||||
v-model="selectedDownloaders"
|
||||
multiple
|
||||
chips
|
||||
:items="Downloaders"
|
||||
label="当前使用下载器"
|
||||
hint="MoviePilot自动添加的下载任务将使用选中的第1个下载器"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderSettings.TORRENT_TAG"
|
||||
label="下载器种子标签"
|
||||
hint="设置种子标签用于区分MoviePilot添加的下载任务,默认标签为`MOVIEPILOT`"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -355,7 +366,8 @@ onMounted(() => {
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.DOWNLOADER_MONITOR"
|
||||
label="监控下载器"
|
||||
label="监控默认下载器"
|
||||
hint="监控选中的第1个下载器,当任务下载完成时自动整理文件到媒体库"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -385,6 +397,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.QB_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT,如启用了HTTPS,请使用https://IP:PORT"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -392,6 +405,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.QB_USER"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="QB的登录用户名"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -399,24 +413,28 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.QB_PASSWORD"
|
||||
type="password"
|
||||
label="密码"
|
||||
hint="QB的登录密码"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_CATEGORY"
|
||||
label="自动分类管理"
|
||||
hint="开启后,下载目录将由QB控制自动下载到分类到目录,此时MoviePilot的下载目录设定无效,需在QB中提前创建分类"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_SEQUENTIAL"
|
||||
label="顺序下载"
|
||||
hint="开启后QB将按照文件顺序依次下载"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_FORCE_RESUME"
|
||||
label="强制继续"
|
||||
hint="开启后,QB将设置为强制继续、强制上传模式(带[F]标识)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -430,6 +448,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.TR_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT,如启用了HTTPS,请使用https://IP:PORT"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -437,6 +456,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.TR_USER"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="TR的登录用户名"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -444,6 +464,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.TR_PASSWORD"
|
||||
type="password"
|
||||
label="密码"
|
||||
hint="TR的登录密码"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -483,6 +504,7 @@ onMounted(() => {
|
||||
chips
|
||||
:items="MediaServers"
|
||||
label="当前使用媒体服务器"
|
||||
hint="媒体服务器用于搜索下载等判断库中是否已存在,以避免重复下载"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -490,6 +512,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.MEDIASERVER_SYNC_INTERVAL"
|
||||
:items="syncIntervalItems"
|
||||
label="同步周期"
|
||||
hint="设置后数据将定时同步到MoviePilot数据库,以便展示媒体库是否存在标识"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -497,6 +520,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.MEDIASERVER_SYNC_BLACKLIST"
|
||||
label="媒体库同步黑名单"
|
||||
placeholder="使用,分隔"
|
||||
hint="设置不同步数据的媒体库名称,使用,分隔,如:电影,电视剧"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -529,6 +553,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.EMBY_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -536,12 +561,14 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.EMBY_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Emby时将优先使用此地址"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.EMBY_API_KEY"
|
||||
label="API密钥"
|
||||
hint="Emby的API密钥,在 Emby设置->高级->API 密钥 中生成"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -555,6 +582,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.JELLYFIN_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -562,12 +590,14 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.JELLYFIN_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Jellyfin时将优先使用此地址"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.JELLYFIN_API_KEY"
|
||||
label="API密钥"
|
||||
hint="Jellyfin的API密钥,在 Jellyfin设置->高级->API 密钥 中生成"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -581,6 +611,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.PLEX_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -588,12 +619,14 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.PLEX_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Plex时将优先使用此地址"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.PLEX_TOKEN"
|
||||
label="API密钥"
|
||||
hint="Plex网页Url中的X-Plex-Token,通过浏览器F12->网络从请求URL中获取"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -631,30 +664,35 @@ onMounted(() => {
|
||||
v-model="mediaSettings.DOWNLOAD_PATH"
|
||||
label="下载目录"
|
||||
:rules="[requiredValidator]"
|
||||
hint="MoviePilot添加的下载任务的默认保存目录,必须设置"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaSettings.DOWNLOAD_MOVIE_PATH"
|
||||
label="电影下载目录"
|
||||
hint="为电影设置单独的下载保存目录,不设置则使用下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaSettings.DOWNLOAD_TV_PATH"
|
||||
label="电视剧下载目录"
|
||||
hint="为电视剧设置单独的下载保存目录,不设置则使用下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaSettings.DOWNLOAD_ANIME_PATH"
|
||||
label="动漫下载目录"
|
||||
hint="为动漫设置单独的下载保存目录,不设置则使用下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaSettings.DOWNLOAD_CATEGORY"
|
||||
label="下载目录自动分类"
|
||||
hint="开启后,下载任务保存目录将根据二级分类策略自动分类存放到下载目录的二级子目录中,二级分类策略需要编辑配置文件目录下的`category.yml`文件,插件市场有提供文件编辑插件"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -664,6 +702,7 @@ onMounted(() => {
|
||||
v-model="mediaSettings.TRANSFER_TYPE"
|
||||
:items="transferTypeItems"
|
||||
label="整理方式"
|
||||
hint="硬链接需要确保下载目录和媒体库目录不跨盘、不跨共享目录、不分别映射;rclone需要手动在容器中完成配置,且配置名为:`MP`"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -671,12 +710,14 @@ onMounted(() => {
|
||||
v-model="mediaSettings.OVERWRITE_MODE"
|
||||
:items="overwriteModeItems"
|
||||
label="覆盖模式"
|
||||
hint="从不覆盖:不覆盖已存在的文件;按大小覆盖:大文件将覆盖小文件;总是覆盖:总是覆盖已存在的文件;仅保留最新版本:保留最新版本的文件,删除其它版本的文件"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaSettings.SCRAP_METADATA"
|
||||
label="自动刮削媒体信息"
|
||||
hint="开启后,整理完成后将自动刮削媒体信息,如海报、简介等"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -687,6 +728,7 @@ onMounted(() => {
|
||||
label="媒体库目录"
|
||||
placeholder="多个目录使用,分隔"
|
||||
:rules="[requiredValidator]"
|
||||
hint="整理完成后的媒体文件存放的根目录,所有整理场景下未设定目的目录时都将整理到该目录下,必须设置"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -694,6 +736,7 @@ onMounted(() => {
|
||||
v-model="mediaSettings.LIBRARY_MOVIE_NAME"
|
||||
label="电影目录名称"
|
||||
placeholder="电影"
|
||||
hint="设置电影的存放一级目录名称,不设置则使用使用`电影`做为目录名称"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -701,6 +744,7 @@ onMounted(() => {
|
||||
v-model="mediaSettings.LIBRARY_TV_NAME"
|
||||
label="电视剧目录名称"
|
||||
placeholder="电视剧"
|
||||
hint="设置电视剧的存放一级目录名称,不设置则使用使用`电视剧`做为目录名称"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -708,12 +752,14 @@ onMounted(() => {
|
||||
v-model="mediaSettings.LIBRARY_ANIME_NAME"
|
||||
label="动漫目录名称"
|
||||
placeholder="动漫"
|
||||
hint="设置动漫的存放一级目录名称,不设置则使用使用`动漫`做为目录名称"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaSettings.LIBRARY_CATEGORY"
|
||||
label="媒体库目录自动分类"
|
||||
hint="开启后,整理完成后的媒体文件将根据二级分类策略自动分类存放到媒体库一级目录的二级子目录中,二级分类策略需要编辑配置文件目录下的`category.yml`文件,插件市场有提供文件编辑插件"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -167,6 +167,7 @@ onMounted(() => {
|
||||
v-model="customIdentifiers"
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行为一组"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行为一组"
|
||||
/>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
@@ -204,6 +205,7 @@ onMounted(() => {
|
||||
v-model="customReleaseGroups"
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
||||
/>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
@@ -224,6 +226,7 @@ onMounted(() => {
|
||||
v-model="customization"
|
||||
auto-grow
|
||||
placeholder="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
|
||||
hint="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
|
||||
/>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
@@ -244,6 +247,7 @@ onMounted(() => {
|
||||
v-model="transferExcludeWords"
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
||||
/>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Site } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SiteAddEditForm from '@/components/form/SiteAddEditForm.vue'
|
||||
import { useDefer } from '@/@core/utils/dom'
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Site[]>([])
|
||||
@@ -14,11 +15,15 @@ const isRefreshed = ref(false)
|
||||
// 新增站点对话框
|
||||
const siteAddDialog = ref(false)
|
||||
|
||||
// 延迟加载
|
||||
let defer = (_: number) => true
|
||||
|
||||
// 获取站点列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
dataList.value = await api.get('site/')
|
||||
isRefreshed.value = true
|
||||
defer = useDefer(dataList.value.length)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
@@ -45,13 +50,18 @@ onBeforeMount(fetchData)
|
||||
v-if="dataList.length > 0"
|
||||
class="grid gap-3 grid-site-card"
|
||||
>
|
||||
<SiteCard
|
||||
v-for="data in dataList"
|
||||
:key="data.id"
|
||||
:site="data"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
/>
|
||||
<div
|
||||
v-for="(data, index) in dataList"
|
||||
:key="index"
|
||||
>
|
||||
<SiteCard
|
||||
v-if="defer(index)"
|
||||
:key="data.id"
|
||||
:site="data"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
@@ -60,13 +70,16 @@ onBeforeMount(fetchData)
|
||||
error-description="已添加并支持的站点将会在这里显示。"
|
||||
/>
|
||||
<!-- 新增站点按钮 -->
|
||||
<VBtn
|
||||
<VFab
|
||||
icon="mdi-plus"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
class="fixed right-5 bottom-5"
|
||||
oper="add"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="siteAddDialog = true"
|
||||
/>
|
||||
<!-- 新增站点弹窗 -->
|
||||
<SiteAddEditForm
|
||||
v-if="siteAddDialog"
|
||||
v-model="siteAddDialog"
|
||||
|
||||
@@ -20,6 +20,7 @@ const calendarOptions: Ref<CalendarOptions> = ref({
|
||||
],
|
||||
initialView: 'dayGridMonth',
|
||||
weekends: true,
|
||||
firstDay: 1,
|
||||
headerToolbar: {
|
||||
left: 'prev',
|
||||
center: 'title',
|
||||
@@ -197,6 +198,11 @@ onMounted(() => {
|
||||
--fc-event-border-color: currentcolor;
|
||||
}
|
||||
|
||||
// 当天背景渐变
|
||||
.fc-day-today {
|
||||
background-image: linear-gradient(to bottom, #AF85FD ,rgba(var(--v-theme-on-surface), 0.04));
|
||||
}
|
||||
|
||||
.v-application .fc a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import SubscribeEditForm from '@/components/form/SubscribeEditForm.vue'
|
||||
import store from '@/store'
|
||||
|
||||
// 输入参数
|
||||
@@ -17,6 +18,9 @@ const isRefreshed = ref(false)
|
||||
// 数据列表
|
||||
const dataList = ref<Subscribe[]>([])
|
||||
|
||||
// 弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -49,7 +53,7 @@ const filteredDataList = computed(() => {
|
||||
if (superUser)
|
||||
return dataList.value.filter(data => data.type === props.type)
|
||||
else
|
||||
return dataList.value.filter(data => data.type === props.type && data.username === userName)
|
||||
return dataList.value.filter(data => data.type === props.type && (data.username === userName))
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -88,6 +92,25 @@ const filteredDataList = computed(() => {
|
||||
error-description="请通过搜索添加电影、电视剧订阅。"
|
||||
/>
|
||||
</PullRefresh>
|
||||
<!-- 底部操作按钮 -->
|
||||
<VFab
|
||||
icon="mdi-file-document-edit"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="subscribeEditDialog = true"
|
||||
/>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditForm
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:default="true"
|
||||
:type="props.type"
|
||||
@save="subscribeEditDialog = false"
|
||||
@close="subscribeEditDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -4,11 +4,14 @@ import store from '@/store'
|
||||
// 日志列表
|
||||
const logs = ref<string[]>([])
|
||||
|
||||
// SSE消息对象
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
// SSE持续获取日志
|
||||
function startSSELogging() {
|
||||
const token = store.state.auth.token
|
||||
if (token) {
|
||||
const eventSource = new EventSource(
|
||||
eventSource = new EventSource(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}`,
|
||||
)
|
||||
|
||||
@@ -17,10 +20,6 @@ function startSSELogging() {
|
||||
if (message)
|
||||
logs.value.push(message)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventSource.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +33,7 @@ function extractLogDetailsFromLogs(logs: string[]): { level: string; time: strin
|
||||
const matches = RegExp(logPattern).exec(log)
|
||||
if (matches && matches.length === 5) {
|
||||
const [_, level, time, program, content] = matches
|
||||
logDetails.push({ level, time, program, content })
|
||||
logDetails.unshift({ level, time, program, content })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +64,11 @@ const extractLogDetails = computed(() => {
|
||||
onMounted(() => {
|
||||
startSSELogging()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (eventSource)
|
||||
eventSource.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
156
src/views/system/MessageView.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script lang="ts" setup>
|
||||
import store from '@/store'
|
||||
import type { Message } from '@/api/types'
|
||||
import MessageCard from '@/components/cards/MessageCard.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['scroll'])
|
||||
|
||||
// 消息列表
|
||||
const messages = ref<Message[]>([])
|
||||
// 当前页数据
|
||||
const currData = ref<Message[]>([])
|
||||
|
||||
// 是否完成加载
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 当前页码
|
||||
const page = ref(1)
|
||||
|
||||
// 存量消息最新时间
|
||||
const lastTime = ref('')
|
||||
|
||||
// SSE消息对象
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
// SSE持续获取消息
|
||||
function startSSEMessager() {
|
||||
const token = store.state.auth.token
|
||||
if (token) {
|
||||
eventSource = new EventSource(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}&role=user`,
|
||||
)
|
||||
|
||||
eventSource.addEventListener('message', (event) => {
|
||||
const message = event.data
|
||||
if (message) {
|
||||
const object = JSON.parse(message)
|
||||
if (compareTime(object.date, lastTime.value) <= 0)
|
||||
return
|
||||
messages.value.push(object)
|
||||
emit('scroll')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API加载存量消息
|
||||
async function loadMessages({ done }: { done: any }) {
|
||||
// 如果正在加载中,直接返回
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
try {
|
||||
currData.value = await api.get('message/web', {
|
||||
params: {
|
||||
page: page.value,
|
||||
size: 20,
|
||||
},
|
||||
})
|
||||
if (currData.value.length > 0) {
|
||||
// 取最后一条时间为存量消息最新时间
|
||||
lastTime.value = currData.value[currData.value.length - 1].reg_time ?? ''
|
||||
// 合并数据
|
||||
messages.value = [...currData.value, ...messages.value]
|
||||
// 加载完成
|
||||
done('ok')
|
||||
if (page.value === 1) {
|
||||
// 滚动到底部
|
||||
emit('scroll')
|
||||
// 监听SSE消息
|
||||
startSSEMessager()
|
||||
}
|
||||
// 页码+1
|
||||
page.value++
|
||||
}
|
||||
else {
|
||||
done('ok')
|
||||
// 监听SSE消息
|
||||
startSSEMessager()
|
||||
}
|
||||
loading.value = false
|
||||
isLoaded.value = true
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 比较yyyy-MM-dd HH:mm:ss时间大小
|
||||
function compareTime(time1: string, time2: string) {
|
||||
if (!time1)
|
||||
return -1
|
||||
if (!time2)
|
||||
return 1
|
||||
return new Date(time1).getTime() - new Date(time2).getTime()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (eventSource)
|
||||
eventSource.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="start"
|
||||
:items="messages"
|
||||
class="overflow-hidden"
|
||||
@load="loadMessages"
|
||||
>
|
||||
<template #loading>
|
||||
<VProgressCircular
|
||||
v-if="loading"
|
||||
indeterminate
|
||||
size="48"
|
||||
class="mb-5"
|
||||
color="primary"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<VRow
|
||||
v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
:class="{
|
||||
'justify-end': msg.action === 0,
|
||||
'justify-start': msg.action === 1,
|
||||
}"
|
||||
>
|
||||
<VCol
|
||||
cols="10"
|
||||
lg="6"
|
||||
xl="4"
|
||||
style="position: relative;"
|
||||
>
|
||||
<MessageCard
|
||||
:message="msg"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
<div
|
||||
v-if="messages.length === 0 && isLoaded && !loading"
|
||||
class="w-full text-center flex flex-col items-center"
|
||||
>
|
||||
<span class="mb-3">当前没有消息</span>
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
@@ -54,6 +54,12 @@ export default defineConfig({
|
||||
build: {
|
||||
chunkSizeWarningLimit: 5000,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: '[name].js',
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['vuetify'],
|
||||
|
||||
13
yarn.lock
@@ -6461,6 +6461,11 @@ purgecss@^5.0.0:
|
||||
postcss "^8.4.4"
|
||||
postcss-selector-parser "^6.0.7"
|
||||
|
||||
qrcode.vue@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/qrcode.vue/-/qrcode.vue-3.4.1.tgz#dd8141da9c4ea07ee56b111cd13eadf123af822a"
|
||||
integrity sha512-wq/zHsifH4FJ1GXQi8/wNxD1KfQkckIpjK1KPTc/qwYU5/Bkd4me0w4xZSg6EXk6xLBkVDE0zxVagewv5EMAVA==
|
||||
|
||||
qs@6.11.0:
|
||||
version "6.11.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
|
||||
@@ -7961,10 +7966,10 @@ vuetify-use-dialog@^0.6.0:
|
||||
dependencies:
|
||||
nanoid "^4.0.2"
|
||||
|
||||
vuetify@3.3.5:
|
||||
version "3.3.5"
|
||||
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.3.5.tgz#b927214d106122240e79f4d65e49bfec0e86e189"
|
||||
integrity sha512-vkfPgPmKfSJa+jq6Ov+CTg7L1t2jLPKa7Slef9OrVHcLqg+gLuIj0z4PJE6E9HjFTUbgZShShOGxps52REJRIA==
|
||||
vuetify@3.5.7:
|
||||
version "3.5.7"
|
||||
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.5.7.tgz#9dfa027a582aa7d2211c8019c33ef7cadd66c5c0"
|
||||
integrity sha512-BFj/puY8odRwY50pRfE1gpawnxreY8PtPb/tDw3oumxSLXhoXw8q6YLA6QUvqZrYEzcYpojxZIYhNWUky2KN1w==
|
||||
|
||||
vuex-persistedstate@^4.1.0:
|
||||
version "4.1.0"
|
||||
|
||||