Compare commits

..

46 Commits

Author SHA1 Message Date
jxxghp
9c17d2d335 release 2024-04-05 23:33:54 +08:00
jxxghp
d4ea7f48c0 fix ui 2024-04-05 23:27:19 +08:00
jxxghp
3ecfe3ba94 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2024-04-05 22:34:36 +08:00
jxxghp
a959594348 feat:插件搜索 2024-04-05 22:34:34 +08:00
jxxghp
faa1027c04 Merge pull request #93 from hotlcc/develop-低版本浏览器兼容性 2024-04-05 12:17:07 +08:00
Allen
6f68732ef9 低版本浏览器数组不支持at函数问题修复 2024-04-05 04:11:50 +00:00
jxxghp
3a4e936938 fix datatable 2024-04-03 11:55:37 +08:00
jxxghp
8b4b79fa10 fix name 2024-04-02 16:36:27 +08:00
jxxghp
ce0dda0455 fix ui 2024-04-02 13:17:40 +08:00
jxxghp
fd1ee398c4 update vuetify => 3.5.7 2024-04-02 11:37:25 +08:00
jxxghp
1488017bf2 fix #1797 2024-04-02 10:26:15 +08:00
jxxghp
367f4236ad fix #92 2024-04-02 08:26:58 +08:00
jxxghp
ec202f22e8 Merge pull request #92 from thsrite/main 2024-04-01 19:10:57 +08:00
thsrite
08081da29e fix 组件复用 2024-04-01 14:27:41 +08:00
thsrite
5511424bd6 feat 设置订阅默认规则 2024-04-01 13:30:51 +08:00
jxxghp
9f2c848413 fix hint 2024-03-31 19:23:11 +08:00
jxxghp
d5efe2b499 feat:增加设置项提示 2024-03-31 09:36:31 +08:00
jxxghp
9b989fc40f feat:插件配置保存动画 2024-03-31 08:24:02 +08:00
jxxghp
567e85d1f8 更新 package.json 2024-03-30 09:50:04 +08:00
jxxghp
af00036e7f fix login 2024-03-29 11:13:46 +08:00
jxxghp
e58e6e2a3e feat:OTP默认不显示 2024-03-29 10:59:26 +08:00
jxxghp
370039664f fix ui 2024-03-28 19:18:37 +08:00
jxxghp
d244363321 fix #91 otp ui 2024-03-28 19:04:15 +08:00
jxxghp
984d502259 Merge pull request #91 from z3shan33/main
feat #1763 用户可在个人设置中自行开启二次验证
2024-03-28 16:58:10 +08:00
zss
34b418af96 feat #1763 2024-03-28 16:35:20 +08:00
jxxghp
eee0c0c878 fix ui 2024-03-26 15:59:37 +08:00
jxxghp
952ef368ab fix plugin card 2024-03-26 12:37:52 +08:00
jxxghp
dfaa789f7c release 2024-03-25 18:32:14 +08:00
jxxghp
74dd549ffb plugin statistic 2024-03-25 18:31:58 +08:00
jxxghp
4d8f369ba0 fix icon 2024-03-21 21:31:23 +08:00
jxxghp
824e2d72c7 fix icon 2024-03-21 16:30:48 +08:00
jxxghp
143aa79797 fix defer 2024-03-19 12:53:23 +08:00
jxxghp
0e1120f407 fix 减少无效查询 2024-03-19 12:34:21 +08:00
jxxghp
c8dbb9672a fix 2024-03-19 11:47:00 +08:00
jxxghp
372b74776f fix bug 2024-03-18 23:42:24 +08:00
jxxghp
abcc3c6411 fix 2024-03-18 23:31:21 +08:00
jxxghp
ec4ab8762c add Bangumi 2024-03-18 19:03:13 +08:00
jxxghp
bc93de8ff2 fix SSE 2024-03-18 11:42:56 +08:00
jxxghp
b426f3c6f2 更新 PluginAppCard.vue 2024-03-16 22:04:51 +08:00
jxxghp
99d9bb29ce 更新 PluginAppCard.vue 2024-03-16 21:55:59 +08:00
jxxghp
5e109c666b fix plugin card 2024-03-16 21:39:05 +08:00
jxxghp
0bed216735 fix 消息控重 2024-03-16 21:07:09 +08:00
jxxghp
d55bb8d336 fix label 2024-03-16 19:44:10 +08:00
jxxghp
7c32b3edf0 Merge pull request #88 from lingjiameng/main 2024-03-16 18:51:56 +08:00
ljmeng
dec3e1ea92 更新注释 2024-03-16 18:26:11 +08:00
ljmeng
664b6610f3 支持本地CookieCloud服务器 2024-03-16 18:23:24 +08:00
47 changed files with 1485 additions and 701 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.7.3",
"version": "1.7.9",
"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",
@@ -109,4 +110,4 @@
"resolutions": {
"postcss": "8"
}
}
}

View File

@@ -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 {

View File

@@ -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) => {

View 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]
}
}
}
}

View File

@@ -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 {
@@ -822,6 +850,9 @@ export interface User {
// 头像
avatar: string
// 是否开启双重验证
is_otp: boolean
}
// 存储空间

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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,55 +454,54 @@ 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>

View File

@@ -10,6 +10,7 @@ const props = defineProps({
plugin: Object as PropType<Plugin>,
width: String,
height: String,
count: Number,
})
// 定义触发的自定义事件
@@ -177,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>
<!-- 安装插件进度框 -->

View File

@@ -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')
@@ -62,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
@@ -151,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}}`)
}
}
@@ -279,6 +295,14 @@ function openLoggerWindow() {
window.open(url, '_blank')
}
// 打开插件详情
function openPluginDetail() {
if (props.plugin?.has_page)
showPluginInfo()
else
showPluginConfig()
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -304,7 +328,7 @@ const dropdownItems = ref([
value: 3,
show: props.plugin?.has_update,
props: {
prependIcon: 'mdi-cancel',
prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success',
click: updatePlugin,
},
@@ -365,12 +389,7 @@ watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
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"
@@ -424,10 +443,14 @@ watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
/>
</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>

View File

@@ -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')
@@ -397,127 +337,13 @@ onMounted(() => {
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>

View File

@@ -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>

View File

@@ -10,6 +10,7 @@ import type { Context, EndPoints, FileItem } from '@/api/types'
import store from '@/store'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import { useDefer } from '@/@core/utils/dom'
// 输入参数
const inProps = defineProps({
@@ -73,6 +74,9 @@ const nameTestResult = ref<Context>()
// 识别结果对话框
const nameTestDialog = ref(false)
// 延迟加载
let defer = (_: number) => true
// 目录过滤
const dirs = computed(() =>
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
@@ -111,6 +115,7 @@ async function load() {
}
// 加载数据
items.value = await axiosInstance.value.request(config) ?? []
defer = useDefer(items.value.length)
emit('loading', false)
loading.value = false
}
@@ -343,6 +348,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"
@@ -382,6 +417,7 @@ onMounted(() => {
>
<template #default="hover">
<VListItem
v-if="defer(index)"
v-bind="hover.props"
class="px-3 pe-1"
@click="changePath(item.path)"
@@ -466,6 +502,7 @@ onMounted(() => {
>
<template #default="hover">
<VListItem
v-if="defer(index)"
v-bind="hover.props"
class="pl-3 pe-1"
@click="changePath(item.path)"
@@ -557,36 +594,6 @@ 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

View File

@@ -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>

View File

@@ -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="90vh"
>
<TmdbSelectorCard
v-model="transferForm.tmdbid"

View File

@@ -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>

View File

@@ -7,6 +7,8 @@ import type { Site, Subscribe } from '@/api/types'
// 输入参数
const props = defineProps({
subid: Number,
default: Boolean,
type: String,
})
// 定义触发的自定义事件
@@ -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 {
@@ -209,10 +255,12 @@ const effectOptions = ref([
])
watchEffect(() => {
if (props.subid) {
getSiteList()
getSiteList()
if (props.subid)
getSubscribeInfo()
}
if (props.default)
queryDefaultSubscribeConfig()
})
</script>
@@ -222,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">
@@ -234,8 +282,10 @@ watchEffect(() => {
md="8"
>
<VTextField
v-if="!props.default"
v-model="subscribeForm.keyword"
label="搜索关键词"
hint="设定搜索关键词后将使用此关键词搜索站点资源否则自动使用themoviedb中的名称搜索"
/>
</VCol>
<VCol
@@ -247,6 +297,7 @@ watchEffect(() => {
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
hint="设定剧集的总集数以应对themoviedb中剧集信息未维护完整导致提前结束订阅的情况"
/>
</VCol>
<VCol
@@ -258,6 +309,7 @@ watchEffect(() => {
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
hint="只订阅下载此集数及之后的剧集"
/>
</VCol>
</VRow>
@@ -301,6 +353,7 @@ watchEffect(() => {
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或"
/>
</VCol>
<VCol
@@ -310,6 +363,7 @@ watchEffect(() => {
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或"
/>
</VCol>
<VCol
@@ -322,6 +376,7 @@ watchEffect(() => {
chips
label="订阅站点"
multiple
hint="只订阅选中的订阅站点,不选则订阅所有可订阅站点"
/>
</VCol>
</VRow>
@@ -332,6 +387,7 @@ watchEffect(() => {
<VTextField
v-model="subscribeForm.save_path"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
/>
</VCol>
</VRow>
@@ -343,6 +399,7 @@ watchEffect(() => {
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
/>
</VCol>
<VCol
@@ -352,6 +409,7 @@ watchEffect(() => {
<VSwitch
v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索"
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
/>
</VCol>
</VRow>
@@ -359,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>

View 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>

View File

@@ -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"
>
保存

View File

@@ -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>

View File

@@ -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=热门电影"

View File

@@ -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>

View File

@@ -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"

View File

@@ -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="未识别到媒体信息"

View File

@@ -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>

View File

@@ -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()
}
// 获取插件列表数据
@@ -72,20 +176,36 @@ async function fetchUninstalledPlugins() {
}
}
// 加载插件统计数据
async function getPluginStatistics() {
try {
PluginStatistics.value = await api.get('plugin/statistic')
}
catch (error) {
console.error(error)
}
}
// 加载所有数据
function refreshData() {
fetchInstalledPlugins()
fetchUninstalledPlugins()
}
// 获取没有更新的插件
const getUnupdatedPlugins = computed(() => {
return uninstalledList.value.filter(item => !item.has_update)
// 对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>
@@ -107,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"
:action="pluginActions[data.id || '0']"
@remove="refreshData"
@save="refreshData"
@action-done="pluginActions[data.id || '0'] = false"
/>
</div>
<NoDataFound
@@ -130,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>
@@ -174,9 +300,10 @@ onBeforeMount(() => {
</div>
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card">
<PluginAppCard
v-for="data in getUnupdatedPlugins"
: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>
@@ -189,11 +316,102 @@ onBeforeMount(() => {
</VCardText>
</VCard>
</VDialog>
<!-- 插件搜索 -->
<VDialog
v-model="SearchDialog"
scrollable
:z-index="1010"
max-width="40rem"
max-height="90vh"
>
<!-- 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"
append-inner-icon="mdi-magnify"
flat
class="mx-1"
/>
</VToolbar>
<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>
<ExistIcon v-if="item.installed" />
</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>

View File

@@ -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,9 +305,36 @@ const dropdownItems = ref([
},
},
])
// 修复低版本Safari等浏览器数组不支持at函数的问题
fixArrayAt()
</script>
<template>
<!-- 底部操作按钮 -->
<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"
/>
<VCard class="pb-5">
<VCardItem>
<VCardTitle>
@@ -349,34 +377,35 @@ const dropdownItems = ref([
show-select
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
@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 +414,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 +426,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 +442,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">

View File

@@ -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 AuthenticatorMicrosoft AuthenticatorAuthy或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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -184,7 +184,7 @@ async function saveMediaSetting() {
}
// 调用API查询下载器设置
async function loadDownladerSetting() {
async function loadDownloaderSetting() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/DOWNLOADER')
if (result1.success)
@@ -330,7 +330,7 @@ async function reloadModule() {
// 加载数据
onMounted(() => {
loadDownladerSetting()
loadDownloaderSetting()
loadMediaServerSetting()
loadMediaSettings()
})
@@ -351,12 +351,14 @@ onMounted(() => {
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>
@@ -365,6 +367,7 @@ onMounted(() => {
<VSwitch
v-model="downloaderSettings.DOWNLOADER_MONITOR"
label="监控默认下载器"
hint="监控选中的第1个下载器当任务下载完成时自动整理文件到媒体库"
/>
</VCol>
</VRow>
@@ -394,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">
@@ -401,6 +405,7 @@ onMounted(() => {
v-model="downloaderSettings.QB_USER"
label="用户名"
placeholder="admin"
hint="QB的登录用户名"
/>
</VCol>
<VCol cols="12" md="4">
@@ -408,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>
@@ -439,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">
@@ -446,6 +456,7 @@ onMounted(() => {
v-model="downloaderSettings.TR_USER"
label="用户名"
placeholder="admin"
hint="TR的登录用户名"
/>
</VCol>
<VCol cols="12" md="4">
@@ -453,6 +464,7 @@ onMounted(() => {
v-model="downloaderSettings.TR_PASSWORD"
type="password"
label="密码"
hint="TR的登录密码"
/>
</VCol>
</VRow>
@@ -492,6 +504,7 @@ onMounted(() => {
chips
:items="MediaServers"
label="当前使用媒体服务器"
hint="媒体服务器用于搜索下载等判断库中是否已存在,以避免重复下载"
/>
</VCol>
<VCol cols="12" md="4">
@@ -499,6 +512,7 @@ onMounted(() => {
v-model="mediaServerSettings.MEDIASERVER_SYNC_INTERVAL"
:items="syncIntervalItems"
label="同步周期"
hint="设置后数据将定时同步到MoviePilot数据库以便展示媒体库是否存在标识"
/>
</VCol>
<VCol cols="12" md="4">
@@ -506,6 +520,7 @@ onMounted(() => {
v-model="mediaServerSettings.MEDIASERVER_SYNC_BLACKLIST"
label="媒体库同步黑名单"
placeholder="使用,分隔"
hint="设置不同步数据的媒体库名称,使用,分隔,如:电影,电视剧"
/>
</VCol>
</VRow>
@@ -538,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">
@@ -545,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>
@@ -564,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">
@@ -571,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>
@@ -590,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">
@@ -597,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>
@@ -640,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>
@@ -673,6 +702,7 @@ onMounted(() => {
v-model="mediaSettings.TRANSFER_TYPE"
:items="transferTypeItems"
label="整理方式"
hint="硬链接需要确保下载目录和媒体库目录不跨盘、不跨共享目录、不分别映射rclone需要手动在容器中完成配置且配置名为`MP`"
/>
</VCol>
<VCol cols="12" md="6">
@@ -680,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>
@@ -696,6 +728,7 @@ onMounted(() => {
label="媒体库目录"
placeholder="多个目录使用,分隔"
:rules="[requiredValidator]"
hint="整理完成后的媒体文件存放的根目录,所有整理场景下未设定目的目录时都将整理到该目录下,必须设置"
/>
</VCol>
<VCol cols="12" md="6">
@@ -703,6 +736,7 @@ onMounted(() => {
v-model="mediaSettings.LIBRARY_MOVIE_NAME"
label="电影目录名称"
placeholder="电影"
hint="设置电影的存放一级目录名称,不设置则使用使用`电影`做为目录名称"
/>
</VCol>
<VCol cols="12" md="6">
@@ -710,6 +744,7 @@ onMounted(() => {
v-model="mediaSettings.LIBRARY_TV_NAME"
label="电视剧目录名称"
placeholder="电视剧"
hint="设置电视剧的存放一级目录名称,不设置则使用使用`电视剧`做为目录名称"
/>
</VCol>
<VCol cols="12" md="6">
@@ -717,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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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 {
@@ -88,6 +92,24 @@ 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-model="subscribeEditDialog"
:default="true"
:type="props.type"
@save="subscribeEditDialog = false"
@close="subscribeEditDialog = false"
/>
</template>
<style lang="scss">

View File

@@ -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()
})
}
}
@@ -65,6 +64,11 @@ const extractLogDetails = computed(() => {
onMounted(() => {
startSSELogging()
})
onBeforeUnmount(() => {
if (eventSource)
eventSource.close()
})
</script>
<template>

View File

@@ -21,11 +21,17 @@ 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) {
const eventSource = new EventSource(
eventSource = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}&role=user`,
)
@@ -33,14 +39,12 @@ function startSSEMessager() {
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')
}
})
onBeforeUnmount(() => {
eventSource.close()
})
}
}
@@ -61,6 +65,8 @@ async function loadMessages({ done }: { done: any }) {
},
})
if (currData.value.length > 0) {
// 取最后一条时间为存量消息最新时间
lastTime.value = currData.value[currData.value.length - 1].reg_time ?? ''
// 合并数据
messages.value = [...currData.value, ...messages.value]
// 加载完成
@@ -68,12 +74,16 @@ async function loadMessages({ done }: { done: any }) {
if (page.value === 1) {
// 滚动到底部
emit('scroll')
// 监听SSE消息
startSSEMessager()
}
// 页码+1
page.value++
}
else {
done('ok')
// 监听SSE消息
startSSEMessager()
}
loading.value = false
isLoaded.value = true
@@ -83,9 +93,18 @@ async function loadMessages({ done }: { done: any }) {
}
}
onMounted(() => {
// 监听新消息
startSSEMessager()
// 比较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>

View File

@@ -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"