Compare commits

..

22 Commits
v2.3.2 ... main

Author SHA1 Message Date
jxxghp
6fdbc8104c v1.9.17 2024-09-18 17:37:57 +08:00
jxxghp
433c14679c 更新 package.json 2024-09-08 13:09:25 +08:00
jxxghp
fcaa4476f0 更新 package.json 2024-09-05 06:59:13 +08:00
jxxghp
85c5c3058c 更新 AccountSettingDirectory.vue 2024-09-05 06:57:32 +08:00
jxxghp
035122a08e Merge pull request #172 from flowclouds/main 2024-08-16 12:07:18 +08:00
jxxghp
0a76875f8e chore: update package.json version to 1.9.14-2 2024-08-16 10:10:41 +08:00
lj
218eac54ce fix: 优化代码移除相关警告 2024-08-15 15:52:32 +08:00
jxxghp
84deeff4f5 chore: update package.json version to 1.9.14-1 2024-08-12 18:13:47 +08:00
jxxghp
0c72d026f6 chore: update package.json version to 1.9.14 2024-08-08 14:37:40 +08:00
jxxghp
aec9ea83c5 更新 package.json 2024-07-30 06:33:53 +08:00
jxxghp
effd13aedd Merge pull request #171 from InfinityPacer/main 2024-07-22 22:12:19 +08:00
InfinityPacer
42b43d65d7 fix(FilterRuleCard): 移除规则中的空白字符并保留前后的空格 2024-07-22 21:47:42 +08:00
jxxghp
c501d824dd Merge pull request #170 from InfinityPacer/main 2024-07-17 06:53:43 +08:00
InfinityPacer
384ac2faf1 feat: ace-editor support python 2024-07-17 01:48:18 +08:00
jxxghp
dd2c4dd24b v1.9.12 2024-07-16 07:56:07 +08:00
jxxghp
356ffddb1c release 2024-07-09 08:00:11 +08:00
jxxghp
de69be7c4e Merge pull request #168 from s0urcelab/main 2024-07-09 06:28:30 +08:00
s0urce
e962f555ae fix: issue #167 2024-07-09 02:56:48 +08:00
jxxghp
1987246585 Merge pull request #166 from JavaZeroo/main 2024-07-08 19:05:23 +08:00
JavaZero
393264f66b 实现根据操作系统动态显示不同的搜索快捷键提示 2024-07-08 13:51:00 +08:00
jxxghp
9b50020b3b Merge pull request #165 from BrettDean/main 2024-07-05 15:26:31 +08:00
Dean
5e5545fe01 优化"最近入库"数量显示 2024-07-05 15:19:50 +08:00
210 changed files with 10582 additions and 20948 deletions

View File

@@ -1,2 +1,2 @@
VITE_API_BASE_URL=/api/v1/
VITE_API_BASE_URL=http://localhost:3001/api/v1/
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM

View File

@@ -1,45 +0,0 @@
name: 功能提案
description: Request for Comments
title: '[RFC]'
labels: ['RFC']
body:
- type: markdown
attributes:
value: |
一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突)
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)
- type: textarea
id: background
attributes:
label: 背景 or 问题
description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。
validations:
required: true
- type: textarea
id: goal
attributes:
label: '目标 & 方案简述'
description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。
validations:
required: true
- type: textarea
id: design
attributes:
label: '方案设计 & 实现步骤'
description: |
详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。
这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。
validations:
required: false
- type: textarea
id: alternative
attributes:
label: '替代方案 & 对比'
description: |
[可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比?
validations:
required: false

View File

@@ -1,12 +1,12 @@
name: Build Moviepilot-Frontend v2
name: Build Moviepilot-Frontend
on:
workflow_dispatch:
push:
branches:
- v2
- main
paths:
- 'package.json'
- package.json
jobs:
build:
@@ -42,21 +42,13 @@ jobs:
echo "$frontend_version" > dist/version.txt
zip -r dist.zip dist
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
with:
tag_name: ${{ env.frontend_version }}
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ env.frontend_version }}
name: ${{ env.frontend_version }}
draft: false
prerelease: false
make_latest: false
files: |
dist.zip
env:

369
auto-imports.d.ts vendored
View File

@@ -3,11 +3,9 @@
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
@@ -22,11 +20,13 @@ declare global {
const createGenericProjection: typeof import('@vueuse/math')['createGenericProjection']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createPinia: typeof import('pinia')['createPinia']
const createLogger: typeof import('vuex')['createLogger']
const createNamespacedHelpers: typeof import('vuex')['createNamespacedHelpers']
const createProjection: typeof import('@vueuse/math')['createProjection']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createStore: typeof import('vuex')['createStore']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
@@ -34,11 +34,9 @@ declare global {
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
@@ -54,11 +52,10 @@ declare global {
const logicNot: typeof import('@vueuse/math')['logicNot']
const logicOr: typeof import('@vueuse/math')['logicOr']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const mapActions: typeof import('vuex')['mapActions']
const mapGetters: typeof import('vuex')['mapGetters']
const mapMutations: typeof import('vuex')['mapMutations']
const mapState: typeof import('vuex')['mapState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
@@ -69,7 +66,6 @@ declare global {
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
@@ -81,7 +77,6 @@ declare global {
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
@@ -101,12 +96,9 @@ declare global {
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
@@ -198,7 +190,6 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
@@ -218,7 +209,6 @@ declare global {
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useMin: typeof import('@vueuse/math')['useMin']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
@@ -244,7 +234,6 @@ declare global {
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useProjection: typeof import('@vueuse/math')['useProjection']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
@@ -253,7 +242,6 @@ declare global {
const useRound: typeof import('@vueuse/math')['useRound']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
@@ -268,11 +256,11 @@ declare global {
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStore: typeof import('vuex')['useStore']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSum: typeof import('@vueuse/math')['useSum']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
@@ -325,17 +313,15 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
@@ -350,11 +336,13 @@ declare module 'vue' {
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createStore: UnwrapRef<typeof import('vuex')['createStore']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
@@ -362,11 +350,9 @@ declare module 'vue' {
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
@@ -382,11 +368,10 @@ declare module 'vue' {
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
@@ -397,7 +382,6 @@ declare module 'vue' {
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onElementRemoval: UnwrapRef<typeof import('@vueuse/core')['onElementRemoval']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
@@ -409,7 +393,6 @@ declare module 'vue' {
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
@@ -429,12 +412,9 @@ declare module 'vue' {
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
@@ -526,7 +506,6 @@ declare module 'vue' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
@@ -546,7 +525,6 @@ declare module 'vue' {
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
@@ -572,7 +550,6 @@ declare module 'vue' {
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
@@ -581,7 +558,6 @@ declare module 'vue' {
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
@@ -596,11 +572,11 @@ declare module 'vue' {
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
@@ -650,4 +626,313 @@ declare module 'vue' {
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
}
}
}
declare module '@vue/runtime-core' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createStore: UnwrapRef<typeof import('vuex')['createStore']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly logicAnd: UnwrapRef<typeof import('@vueuse/math')['logicAnd']>
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']>
readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']>
readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']>
readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useAbs: UnwrapRef<typeof import('@vueuse/math')['useAbs']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>
readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>
readonly useArrayFindLast: UnwrapRef<typeof import('@vueuse/core')['useArrayFindLast']>
readonly useArrayIncludes: UnwrapRef<typeof import('@vueuse/core')['useArrayIncludes']>
readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>
readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>
readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>
readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']>
readonly useArrayUnique: UnwrapRef<typeof import('@vueuse/core')['useArrayUnique']>
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAverage: UnwrapRef<typeof import('@vueuse/math')['useAverage']>
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>
readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
readonly useFloor: UnwrapRef<typeof import('@vueuse/math')['useFloor']>
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMath: UnwrapRef<typeof import('@vueuse/math')['useMath']>
readonly useMax: UnwrapRef<typeof import('@vueuse/math')['useMax']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
readonly usePrecision: UnwrapRef<typeof import('@vueuse/math')['usePrecision']>
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>
readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>
readonly watchDeep: UnwrapRef<typeof import('@vueuse/core')['watchDeep']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>
readonly watchImmediate: UnwrapRef<typeof import('@vueuse/core')['watchImmediate']>
readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>
readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']>
readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']>
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
}
}

2
components.d.ts vendored
View File

@@ -1,10 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']

View File

@@ -1,6 +1,5 @@
<!DOCTYPE html>
<html lang="en"
style="overflow: hidden auto; min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));background: var(--initial-loader-bg, #fff);">
<html lang="en">
<head>
<meta http-equiv="pragma" content="no-cache">
@@ -30,17 +29,10 @@
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" />
<script>
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
if (loaderColor)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
if (primaryColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
</script>
<link rel="preload" href="index.js" as="script">
</head>
<body style="margin: 0;">
<body>
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
@@ -155,6 +147,16 @@
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
if (loaderColor)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
if (primaryColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
</script>
</body>
</html>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.3.2",
"version": "1.9.17",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -19,92 +19,86 @@
]
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/list": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@iconify/utils": "^2.2.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
"@vue-flow/minimap": "^1.5.2",
"@vue-flow/node-resizer": "^1.4.0",
"@vue-flow/node-toolbar": "^1.1.0",
"@vue-js-cron/vuetify": "^5.0.9",
"@vueuse/core": "^12.4.0",
"@vueuse/math": "^12.4.0",
"ace-builds": "^1.37.4",
"apexcharts": "^4.0.0",
"axios": "^1.7.9",
"colorthief": "^2.6.0",
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"lodash-es": "^4.17.21",
"@fullcalendar/core": "^6.1.8",
"@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.7",
"@fullcalendar/list": "^6.1.7",
"@fullcalendar/timegrid": "^6.1.7",
"@fullcalendar/vue3": "^6.1.8",
"@iconify/utils": "^2.1.22",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"ace-builds": "^1.32.6",
"apexcharts-clevision": "^3.28.5",
"axios": "1.6.8",
"colorthief": "^2.4.0",
"dayjs": "^1.11.10",
"express": "^4.18.2",
"express-http-proxy": "^2.0.0",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode.vue": "^3.6.0",
"sass": "^1.83.4",
"tailwindcss": "^ 3.4.17",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-toast-notification": "^3.1.3",
"qrcode.vue": "^3.4.1",
"sass": "^1.59.3",
"tailwindcss": "^3.3.2",
"unplugin-vue-define-options": "^1.3.5",
"vue": "^3.3.2",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.8.0",
"vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "3.7.3",
"vuetify": "3.6.8",
"vuetify-use-dialog": "^0.6.11",
"vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@antfu/eslint-config-vue": "^0.43.1",
"@iconify-json/mdi": "^1.1.52",
"@iconify/tools": "^4.0.4",
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@iconify/vue": "4.1.1",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash-es": "^4.17.12",
"@types/lodash": "^4.14.197",
"@types/node": "^20.1.4",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitejs/plugin-vue-jsx": "^3.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^9.18.0",
"eslint": "^9.0.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-promise": "^6.0.1",
"eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-sonarjs": "^0.25.1",
"eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-vue": "^9.12.0",
"postcss": "^8.5.1",
"postcss": "8",
"postcss-html": "^1.5.0",
"stylelint": "^16.13.2",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard-scss": "^14.0.0",
"stylelint": "16.3.1",
"stylelint-config-idiomatic-order": "10.0.0",
"stylelint-config-standard-scss": "13.1.0",
"stylelint-use-logical-spec": "5.0.1",
"terser": "^5.36.0",
"type-fest": "^4.15.0",
"typescript": "^5.0.4",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"unplugin-vue-define-options": "^1.5.3",
"vite": "^5.4.11",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.8",
"vite-plugin-pages": "^0.32.1",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-pwa": "^0.20.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4",
"vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
"vite-plugin-vuetify": "2.0.3",
"vue-shepherd": "^3.0.0",
"vue-tsc": "^2.0.10"
},
"packageManager": "yarn@1.22.18"
}
"packageManager": "yarn@1.22.18",
"resolutions": {
"postcss": "8"
}
}

View File

@@ -1,6 +1,16 @@
body {
margin: 0;
}
html {
overflow: hidden auto;
background: var(--initial-loader-bg, #fff);
min-block-size: calc(100% + env(safe-area-inset-top));
}
#loading-bg {
position: fixed;
z-index: 9999;
position: absolute;
z-index: 999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;

View File

@@ -5,7 +5,7 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
import api from '@/api'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { useToast } from 'vue-toast-notification'
import { saveLocalTheme } from '../utils/theme'
import { VAceEditor } from 'vue3-ace-editor'
// 显示器宽度
const display = useDisplay()
@@ -103,7 +103,8 @@ function updateTheme() {
savedTheme.value = theme
themeTransition()
// 保存主题到本地
saveLocalTheme(theme, globalTheme)
localStorage.setItem('theme', theme)
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
}
// 切换主题
@@ -113,8 +114,10 @@ function changeTheme(theme: string) {
currentThemeName.value = nextTheme
// 保存主题到服务端
try {
api.post('/user/config/Layout', {
theme: nextTheme,
api.post('/user/config/theme', nextTheme, {
headers: {
'Content-Type': 'text/plain',
},
})
} catch (e) {
console.error('保存主题到服务端失败')
@@ -175,7 +178,7 @@ async function saveCustomCSS() {
},
})
if (result.success) $toast.success('自定义CSS保存成功,请刷新页面生效')
if (result.success) $toast.success('自定义CSS保存成功')
} catch (e) {
console.error('保存自定义 CSS 到服务端失败')
}
@@ -209,7 +212,7 @@ onMounted(() => {
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-if="cssDialog" v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-model="cssDialog" persistent max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard title="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" />
<VDivider />

View File

@@ -212,6 +212,10 @@ h6,
}
}
.v-data-table-footer {
margin-block-start: 1rem;
}
// 👉 v-field
.v-field:hover .v-field__outline {
--v-field-border-opacity: var(--v-medium-emphasis-opacity);

View File

@@ -1,5 +1,5 @@
.auth-wrapper {
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top));
}
.auth-footer-mask {

View File

@@ -176,6 +176,10 @@
th {
background: rgb(var(--v-table-header-background)) !important;
}
.v-data-table-footer {
margin-block-start: 1rem;
}
}
// 👉 Pagination

View File

@@ -1,10 +1,9 @@
@use "sass:map";
@use "vuetify/lib/styles/settings" as vuetify_settings;
@mixin avatar-font-sizes($map: $avatar-sizes) {
@each $sizeName, $multiplier in vuetify_settings.$size-scales {
/* stylelint-disable-next-line scss/no-global-function-names */
$size: map.get($map, $sizeName);
$size: map-get($map, $sizeName);
&.v-avatar--size-#{$sizeName} {
font-size: #{$size}px;

View File

@@ -92,7 +92,8 @@
.fc-header-toolbar {
flex-wrap: wrap;
margin: 1.25rem;
gap: 1rem 0.5rem;
column-gap: 0.5rem;
row-gap: 1rem;
}
.fc-toolbar-chunk {
@@ -237,7 +238,7 @@
inline-size: 1.5625rem;
margin-inline-end: 0.25rem;
@media (width <= 1264px) {
@media (max-width: 1264px) {
display: block !important;
}

View File

@@ -10,7 +10,8 @@ export function useDefer(maxFrameCount = 1) {
const refreshFrameCount = () => {
requestAnimationFrame(() => {
frameCount.value++
if (frameCount.value < maxFrameCount) refreshFrameCount()
if (frameCount.value < maxFrameCount)
refreshFrameCount()
})
}
refreshFrameCount()
@@ -18,9 +19,3 @@ export function useDefer(maxFrameCount = 1) {
return frameCount.value >= showInFrameCount
}
}
export function ensureRenderComplete(callback: () => void) {
requestAnimationFrame(() => {
requestAnimationFrame(callback)
})
}

View File

@@ -60,25 +60,19 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
// 格式化为xx[TGMK]B
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
// 负数标记
let negative = false
let size = bytes
if (bytes < 0) {
negative = true
size = Math.abs(bytes)
}
export function formatFileSize(bytes: number) {
if (bytes < 0) throw new Error('字节数不能为负数。')
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
if (negative) return `-${size.toFixed(decimals)} ${units[unitIndex]}`
else
return prefix ? `+${size.toFixed(decimals)} ${units[unitIndex]}` : `${size.toFixed(decimals)} ${units[unitIndex]}`
return `${size.toFixed(2)} ${units[unitIndex]}`
}
// 将时间秒格式化为时分秒
@@ -153,12 +147,3 @@ export function formatDateDifference(dateString: string): string {
if (!dateString) return ''
return dayjs(dateString).fromNow()
}
// 格式化评份如为10及以下的数按原值显示否则格式化为xxM、xxK显示
export function formatRating(rating: number): string {
if (!rating) return ''
if (rating <= 10) return rating.toString()
if (rating < 1000) return rating.toLocaleString()
if (rating < 1000 * 1000) return `${(rating / 1000).toFixed(1)}K`
return `${(rating / 1000 / 1000).toFixed(1)}M`
}

View File

@@ -1,10 +1,9 @@
import copy from 'copy-to-clipboard'
// 请求和获取剪贴板内容
export async function getClipboardContent() {
if (navigator.clipboard && window.isSecureContext) {
return await navigator.clipboard.readText()
} else {
}
else {
const input = document.createElement('textarea')
document.body.appendChild(input)
input.select()
@@ -15,10 +14,19 @@ export async function getClipboardContent() {
}
}
// 将内容复制到剪贴板
// 将内容复制到剪贴板,兼容非安全域场景
export async function copyToClipboard(content: string) {
const success = copy(content)
return success
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content)
}
else {
const input = document.createElement('textarea')
input.value = content
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
}
// VAPID公钥转Uint8Array
@@ -34,12 +42,3 @@ export function urlBase64ToUint8Array(base64String: string) {
}
return outputArray
}
// 判断是否为PWA
export const isPWA = async (): Promise<boolean> => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
return registrations.length > 0
}
return (window.navigator as any).standalone === true
}

View File

@@ -1,6 +0,0 @@
export function saveLocalTheme(name: string, theme: any) {
// 存储主题到本地
localStorage.setItem('theme', name)
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
}

View File

@@ -1,121 +0,0 @@
import { useVueFlow } from '@vue-flow/core'
import { ref, watch } from 'vue'
/**
* @returns {string} - A unique id.
*/
function getId() {
// 生成以act_开头的唯一id
return 'act_' + Math.random().toString(36).substr(2, 9)
}
/**
* In a real world scenario you'd want to avoid creating refs in a global scope like this as they might not be cleaned up properly.
* @type {{draggedData: Ref<any>, isDragOver: Ref<boolean>, isDragging: Ref<boolean>}}
*/
const state = {
/**
* The type of the node being dragged.
*/
draggedData: ref<any | null>({}),
isDragOver: ref(false),
isDragging: ref(false),
}
export default function useDragAndDrop() {
const { draggedData, isDragOver, isDragging } = state
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
watch(isDragging, dragging => {
document.body.style.userSelect = dragging ? 'none' : ''
})
function onDragStart(event: any, data: any) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow', data)
event.dataTransfer.effectAllowed = 'move'
}
draggedData.value = data
isDragging.value = true
document.addEventListener('drop', onDragEnd)
}
/**
* Handles the drag over event.
*
* @param {DragEvent} event
*/
function onDragOver(event: any) {
event.preventDefault()
if (draggedData.value) {
isDragOver.value = true
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
}
function onDragLeave() {
isDragOver.value = false
}
function onDragEnd() {
isDragging.value = false
isDragOver.value = false
draggedData.value = null
document.removeEventListener('drop', onDragEnd)
}
/**
* Handles the drop event.
*
* @param {DragEvent} event
*/
function onDrop(event: any) {
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
})
const nodeId = getId()
const newNode = {
id: nodeId,
type: draggedData.value?.type,
name: draggedData.value?.name,
description: draggedData.value?.description,
position,
data: draggedData.value?.data,
}
/**
* Align node position after drop, so it's centered to the mouse
*
* We can hook into events even in a callback, and we can remove the event listener after it's been called.
*/
const { off } = onNodesInitialized(() => {
updateNode(nodeId, node => ({
position: { x: node.position.x - node.dimensions.width / 2, y: node.position.y - node.dimensions.height / 2 },
}))
off()
})
addNodes(newNode)
}
return {
draggedData,
isDragOver,
isDragging,
onDragStart,
onDragLeave,
onDragOver,
onDrop,
}
}

View File

@@ -1 +0,0 @@
{"root":["./build-icons.ts"],"version":"5.7.3"}

View File

@@ -53,8 +53,8 @@ function handleNavScroll(evt: Event) {
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
<div class="d-flex" v-html="logo" />
<h1 class="font-weight-bold leading-normal text-xl">
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
<h1 class="font-weight-bold leading-normal text-2xl">
MOVIEPILOT
</h1>
</RouterLink>
</slot>

View File

@@ -5,7 +5,7 @@
@use "@configured-variables" as variables;
html {
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
min-height: calc(100% + env(safe-area-inset-top));
background: rgb(var(--v-theme-background));
overflow-y: overlay;
}

View File

@@ -7,5 +7,5 @@
html {
box-sizing: border-box;
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
min-height: calc(100% + env(safe-area-inset-top))
}

View File

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

View File

@@ -122,9 +122,8 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
export interface NavMenu extends NavLink {
header: string
admin: boolean
description?: string
admin?: boolean
footer?: boolean
}
// 👉 Vertical nav group

View File

@@ -1,16 +1,15 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
const { global: globalTheme } = useTheme()
// 生效主题
const { global: globalTheme } = useTheme()
let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 显示状态
const show = ref(false)
async function setTheme() {
let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
}
// ApexCharts 全局配置
declare global {
@@ -42,24 +41,14 @@ if (window.Apex) {
}
}
onMounted(() => {
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
}, 1500)
})
})
// 页面加载时,加载当前用户数据
onBeforeMount(async () => {
setTheme()
})
</script>
<template>
<VApp v-show="show">
<VApp>
<RouterView />
</VApp>
</template>

View File

@@ -10,7 +10,7 @@ import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
import modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'
import modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'
import modePythonUrl from 'ace-builds/src-noconflict/mode-python?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
@@ -40,7 +40,7 @@ import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
import snippertsCssUrl from 'ace-builds/src-noconflict/snippets/css?url'
import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
import snippetsPythonUrl from 'ace-builds/src-noconflict/snippets/python?url'
import 'ace-builds/src-noconflict/ext-language_tools'
@@ -49,7 +49,7 @@ ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
ace.config.setModuleUrl('ace/mode/ini', modeIniUrl)
ace.config.setModuleUrl('ace/mode/python', modePythonUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
@@ -64,6 +64,6 @@ ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
ace.config.setModuleUrl('ace/snippets/python', snippetsPythonUrl)
ace.require('ace/ext/language_tools')

View File

@@ -1,80 +0,0 @@
export const storageOptions = [
{
title: '本地',
value: 'local',
icon: 'mdi-folder-multiple-outline',
},
{
title: '阿里云盘',
value: 'alipan',
icon: 'mdi-cloud-outline',
},
{
title: '115网盘',
value: 'u115',
icon: 'mdi-cloud-outline',
},
{
title: 'RClone',
value: 'rclone',
icon: 'mdi-cloud-outline',
},
{
title: 'AList',
value: 'alist',
icon: 'mdi-cloud-outline',
},
]
export const innerFilterRules = [
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '官种', value: ' GZ ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '粤语配音', value: ' HKVOI ' },
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
{ title: '质量: BLURAY', value: ' BLURAY ' },
{ title: '排除: BLURAY', value: ' !BLURAY ' },
{ title: '质量: UHD', value: ' UHD ' },
{ title: '排除: UHD', value: ' !UHD ' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
]
export const storageDict = storageOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const transferTypeOptions = [
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
]

View File

@@ -1,6 +1,6 @@
import axios from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores'
import store from '@/store'
// 创建axios实例
const api = axios.create({
@@ -9,12 +9,10 @@ const api = axios.create({
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
const authStore = useAuthStore()
// 在请求头中添加token
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
const token = store.state.auth.token
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
@@ -28,10 +26,8 @@ api.interceptors.response.use(
// 请求超时
return Promise.reject(new Error(error))
} else if (error.response.status === 403) {
// 认证 Store
const authStore = useAuthStore()
// 清除登录状态信息
authStore.logout()
store.dispatch('auth/logout')
// token验证失败跳转到登录页面
router.push('/login')
}
@@ -41,13 +37,3 @@ api.interceptors.response.use(
)
export default api
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global')
return result.data || {}
} catch (error) {
console.error('Failed to fetch global settings', error)
throw error
}
}

View File

@@ -1,6 +1,5 @@
// 订阅
export interface Subscribe {
// 订阅ID
id: number
// 订阅名称
name: string
@@ -14,10 +13,6 @@ export interface Subscribe {
tmdbid: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: string
// 其它媒体ID
mediaid?: string
// 季号
season?: number
// 海报
@@ -48,7 +43,7 @@ export interface Subscribe {
lack_episode?: number
// 附加信息
note?: string
// 状态N-新建 R-订阅中 P-待定 S-暂停
// 状态N-新建 R-订阅中
state: string
// 最后更新时间
last_update: string
@@ -70,84 +65,12 @@ export interface Subscribe {
show_edit_dialog: boolean
// 编辑框打开状态
page_open?: boolean
// 自定义识别词
custom_words?: string
// 自定义媒体类别
media_category?: string
// 过滤规则组
filter_groups?: string[]
// 下载器
downloader: string
}
// 订阅分享
export interface SubscribeShare {
// 分享ID
id?: number
// 订阅ID
subscribe_id?: number
// 分享标题
share_title?: string
// 分享说明
share_comment?: string
// 分享人
share_user?: string
// 分享人唯一ID
share_uid?: string
// 订阅名称
name?: string
// 订阅年份
year?: string
// 订阅类型 电影/电视剧
type?: string
// 搜索关键字
keyword?: string
// TMDB ID
tmdbid?: number
// 豆瓣ID
doubanid?: string
// 季号
season?: number
// 海报
poster?: string
// 背景图
backdrop?: string
// 评分
vote?: number
// 描述
description?: string
// 过滤规则
filter?: string
// 包含
include?: string
// 排除
exclude?: string
// 质量
quality?: string
// 分辨率
resolution?: string
// 特效
effect?: string
// 总集数
total_episode?: number
// 时间
date?: string
// 自定义识别词
custom_words?: string
// 自定义媒体类别
media_category?: string
// 复用次数
count?: number
}
// 历史记录
export interface TransferHistory {
// ID
id: number
// 源存储
src_storage?: string
// 目标存储
dest_storage?: string
// 源目录
src?: string
// 目的目录
@@ -190,7 +113,7 @@ export interface TransferHistory {
export interface MediaInfo {
// 来源themoviedb、douban、bangumi
source?: string
// 类型 电影、电视剧、合集
// 类型 电影、电视剧
type?: string
// 媒体标题
title?: string
@@ -210,12 +133,6 @@ export interface MediaInfo {
douban_id?: string
// Bangumi ID
bangumi_id?: string
// 合集ID
collection_id?: number
// 其它媒体ID前缀
mediaid_prefix?: string
// 其它媒体ID值
media_id?: string
// 媒体原语种
original_language?: string
// 媒体原发行标题
@@ -288,24 +205,6 @@ export interface MediaInfo {
names?: string[]
}
// 季信息
export interface MediaSeason {
// 上映日期
air_date?: string
// 总集数
episode_count?: number
// 季名称
name?: string
// 描述
overview?: string
// 海报
poster_path?: string
// 季号
season_number?: number
// 评分
vote_average?: number
}
// TMDB季信息
export interface TmdbSeason {
// 上映日期
@@ -419,8 +318,6 @@ export interface Site {
pri?: number
// RSS地址
rss?: string
// 下载器
downloader: string
// Cookie
cookie?: string
// ApiKey
@@ -469,48 +366,6 @@ export interface SiteStatistic {
note?: string
}
// 站点用户数据
export interface SiteUserData {
// 站点域名
domain?: string
// 用户名
username?: string
// 用户ID
userid?: number
// 用户等级
user_level?: string
// 加入时间
join_at?: string
// 积分
bonus?: number // 默认为 0.0
// 上传量
upload?: number // 默认为 0
// 下载量
download?: number // 默认为 0
// 分享率
ratio?: number // 默认为 0
// 做种数
seeding?: number // 默认为 0
// 下载数
leeching?: number // 默认为 0
// 做种体积
seeding_size?: number // 默认为 0
// 下载体积
leeching_size?: number // 默认为 0
// 做种人数, 种子大小
seeding_info?: any[] // 默认为空数组
// 未读消息
message_unread?: number // 默认为 0
// 未读消息内容
message_unread_contents?: any[] // 默认为空数组
// 错误信息
err_msg?: string | null // 默认为 null
// 更新日期
updated_day?: string
// 更新时间
updated_time?: string
}
// 正在下载
export interface DownloadingInfo {
// HASH
@@ -539,8 +394,6 @@ export interface DownloadingInfo {
userid?: string
// 下载用户名称
username?: string
// 剩余时间
left_time?: string
}
// 缺失剧集信息
@@ -639,8 +492,6 @@ export interface TorrentInfo {
site_proxy: boolean
// 站点优先级
site_order: number
// 站点下载器
site_downloader?: string
// 种子名称
title?: string
// 种子副标题
@@ -791,10 +642,6 @@ export interface User {
avatar: string
// 是否开启双重验证
is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }
// 用户个性化设置 json
settings: { [key: string]: string | null }
}
// 存储空间
@@ -894,8 +741,6 @@ export interface EndPoints {
// 文件浏览项目
export interface FileItem {
// 存储
storage: string
// 类型 dir/file
type: string
// 文件名
@@ -998,294 +843,22 @@ export interface SystemNotification {
date: string
}
// 下载器配置
export interface DownloaderConf {
// 名称
name: string
// 类型 qbittorrent/transmission
type: string
// 是否默认
default: boolean
// 配置
config: { [key: string]: any }
// 是否启用
enabled: boolean
}
// 通知配置
export interface NotificationConf {
// 名称
name: string
// 类型 telegram/wechat/vocechat/synologychat
type: string
// 配置
config: { [key: string]: any }
// 场景开关
switchs?: string[]
// 是否启用
enabled: boolean
}
// 通知场景开关配置
export interface NotificationSwitchConf {
// 场景名称
type: string
// 通知范围 all/user/admin
action: string
}
// 存储配置
export interface StorageConf {
// 名称
name: string
// 类型 local/alipan/u115/rclone
type: string
// 配置
config?: { [key: string]: any }
}
// 媒体服务器配置
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/jellyfin/plex
type: string
// 配置
config: { [key: string]: any }
// 是否启用
enabled: boolean
// 同步媒体体库列表
sync_libraries?: string[]
}
// 文件整理目录配置
export interface TransferDirectoryConf {
// 名称
name: string
// 优先级
priority: number
// 存储
storage: string
// 下载目录
download_path?: string
// 适用媒体类型
media_type?: string
// 适用媒体类别
media_category?: string
// 下载类型子目录
download_type_folder?: boolean
// 下载类别子目录
download_category_folder?: boolean
// 监控方式 downloader/monitorNone为不监控
monitor_type?: string
// 监控模式 fast/compatibility
monitor_mode?: string
// 整理方式 move/copy/link/softlink
transfer_type: string
// 文件覆盖模式 always/size/never/latest
overwrite_mode?: string
// 整理到媒体库目录
library_path?: string
// 媒体库目录存储
library_storage?: string
// 智能重命名
renaming?: boolean
// 刮削
scraping?: boolean
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
// 是否发送通知
notify?: boolean
}
// 自定义规则项
export interface CustomRule {
// 规则ID
id: string
// 名称
name: string
// 包含
include?: string
// 排除
exclude?: string
// 大小范围
size_range?: string
// 最少做种人数
seeders?: string
// 发布时间
publish_time?: string
}
// 过滤规则组
export interface FilterRuleGroup {
// 名称
name: string
// 规则串
rule_string?: string
// 适用类媒体类型 None-全部 电影/电视剧
media_type?: string
// # 适用媒体类别 None-全部 对应二级分类
category?: string
}
// 订阅下载文件详情
export interface SubscribeDownloadFileInfo {
// 种子名称
torrent_title?: string
// 站点名称
site_name?: string
// 下载器
downloader?: string
// hash
hash?: string
// 文件路径
file_path?: string
}
// 订阅媒体库文件详情
export interface SubscribeLibraryFileInfo {
// 存储
storage?: string
// 文件路径
file_path?: string
}
// 订阅集详情
export interface SubscribeEpisodeInfo {
// 标题
title?: string
// 描述
description?: string
// 背景图
backdrop?: string
// 下载文件信息
download?: SubscribeDownloadFileInfo[]
// 媒体库文件信息
library?: SubscribeLibraryFileInfo[]
}
// 订阅详情
export interface SubscrbieInfo {
// 订阅信息
subscribe: Subscribe
// 集信息 {集号: {download: 文件路径library: 文件路径, backdrop: url, title: 标题, description: 描述}}
episodes: Record<number, SubscribeEpisodeInfo>
}
// 整理表单
export interface TransferForm {
// 文件项
fileitem: FileItem
// 历史ID
logid: number
// 目标存储
target_storage: string
// 目标路径
target_path: string
// TMDB ID
tmdbid?: number
// 豆瓣 ID
doubanid?: string
// 季号
season?: number
// 类型
type_name?: string
// 整理方式
transfer_type: string
// 自定义格式
episode_format?: string
// 指定集数
episode_detail?: string
// 指定PART
episode_part?: string
// 集数偏移
episode_offset?: string
// 最小文件大小
min_filesize: number
// 刮削
scrape: boolean
// 复用历史识别信息
from_history: boolean
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
}
// 整理队列
export interface TransferQueue {
// 媒体信息
media: MediaInfo
// 季
season?: number
// 任务列表
tasks: {
// 文件项
fileitem: FileItem
// 元数据
meta: MetaInfo
// 状态
state: string
}[]
}
// 探索的数据源
export interface DiscoverSource {
// 数据源名称
name: string
// 媒体ID的前缀不含:
mediaid_prefix: string
// 媒体数据源API地址
api_path: string
// 过滤参数
filter_params: { [key: string]: any }
// 过滤参数UI配置
filter_ui: RenderProps[]
// UI依赖关系字典
depends?: { [key: string]: string[] }
}
// 推荐的数据源
export interface RecommendSource {
// 数据源名称
name: string
// 媒体数据源API地址
api_path: string
}
// 站点资源分类
export interface SiteCategory {
id: number
cat: string
desc: string
}
// 工作流
export interface Workflow {
// 工作流ID
id?: string
// 工作流名称
// 下载目录/媒体库目录
export interface MediaDirectory {
// 类型 download/library
type?: string
// 别名
name?: string
// 工作流描述
description?: string
// 定时器
timer?: string
// 状态
state?: string
// 当前执行动作
current_action?: string
// 任务执行结果
result?: string
// 已执行次数
run_count?: number
// 动作列表
actions?: any[]
// 动作流
flows?: any[]
// 创建时间
add_time?: string
// 最后执行时间
last_time?: string
// 路径
path?: string
// 媒体类型 电影/电视剧
media_type?: string
// 媒体类别 动画电影/国产剧
category?: string
// 刮削媒体信息
scrape?: boolean
// 自动二级分类,未指定类别时自动分类
auto_category?: boolean
// 优先级
priority?: number
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,10 +0,0 @@
<svg width="1252" height="1252" xmlns="http://www.w3.org/2000/svg" version="1.1">
<g>
<g id="#70c6beff">
<path id="svg_2" d="m634.37,138.38c11.88,-1.36 24.25,1.3 34.18,8.09c14.96,9.66 25.55,24.41 34.49,39.51c40.59,68.03 81.45,135.91 122.02,203.96c54.02,90.99 108.06,181.97 161.94,273.06c37.28,63 74.65,125.96 112.18,188.82c24.72,41.99 50.21,83.54 73.84,126.16c10.18,17.84 15.77,38.44 14.93,59.03c-0.59,15.92 -3.48,32.28 -11.84,46.08c-11.73,19.46 -31.39,33.2 -52.71,40.36c-11.37,4.09 -23.3,6.87 -35.43,6.89c-132.32,-0.05 -264.64,0.04 -396.95,0.03c-11.38,-0.29 -22.95,-1.6 -33.63,-5.72c-7.81,-3.33 -15.5,-7.43 -21.61,-13.42c-10.43,-10.32 -17.19,-24.96 -15.38,-39.83c0.94,-10.39 3.48,-20.64 7.76,-30.16c4.15,-9.77 9.99,-18.67 15.06,-27.97c22.13,-39.47 45.31,-78.35 69.42,-116.65c7.72,-12.05 14.44,-25.07 25.12,-34.87c11.35,-10.39 25.6,-18.54 41.21,-19.6c12.55,-0.52 24.89,3.82 35.35,10.55c11.8,6.92 21.09,18.44 24.2,31.88c4.49,17.01 -0.34,34.88 -7.55,50.42c-8.09,17.65 -19.62,33.67 -25.81,52.18c-1.13,4.21 -2.66,9.52 0.48,13.23c3.19,3 7.62,4.18 11.77,5.22c12,2.67 24.38,1.98 36.59,2.06c45,-0.01 90,0 135,0c8.91,-0.15 17.83,0.3 26.74,-0.22c6.43,-0.74 13.44,-1.79 18.44,-6.28c3.3,-2.92 3.71,-7.85 2.46,-11.85c-2.74,-8.86 -7.46,-16.93 -12.12,-24.89c-119.99,-204.91 -239.31,-410.22 -360.56,-614.4c-3.96,-6.56 -7.36,-13.68 -13.03,-18.98c-2.8,-2.69 -6.95,-4.22 -10.77,-3.11c-3.25,1.17 -5.45,4.03 -7.61,6.57c-5.34,6.81 -10.12,14.06 -14.51,21.52c-20.89,33.95 -40.88,68.44 -61.35,102.64c-117.9,198.43 -235.82,396.85 -353.71,595.29c-7.31,13.46 -15.09,26.67 -23.57,39.43c-7.45,10.96 -16.49,21.23 -28.14,27.83c-13.73,7.94 -30.69,11.09 -46.08,6.54c-11.23,-3.47 -22.09,-9.12 -30.13,-17.84c-10.18,-10.08 -14.69,-24.83 -14.17,-38.94c0.52,-14.86 5.49,-29.34 12.98,-42.1c71.58,-121.59 143.62,-242.92 215.93,-364.09c37.2,-62.8 74.23,-125.69 111.64,-188.36c37.84,-63.5 75.77,-126.94 113.44,-190.54c21.02,-35.82 42.19,-71.56 64.28,-106.74c6.79,-11.15 15.58,-21.15 26.16,-28.85c8.68,-5.92 18.42,-11 29.05,-11.94z" fill="#70c6be"/>
</g>
<g id="#1ba0d8ff">
<path id="svg_3" d="m628.35,608.38c17.83,-2.87 36.72,1.39 51.5,11.78c11.22,8.66 19.01,21.64 21.26,35.65c1.53,10.68 0.49,21.75 -3.44,31.84c-3.02,8.73 -7.35,16.94 -12.17,24.81c-68.76,115.58 -137.5,231.17 -206.27,346.75c-8.8,14.47 -16.82,29.47 -26.96,43.07c-7.37,9.11 -16.58,16.85 -27.21,21.89c-22.47,11.97 -51.79,4.67 -68.88,-13.33c-8.66,-8.69 -13.74,-20.63 -14.4,-32.84c-0.98,-12.64 1.81,-25.42 7.53,-36.69c5.03,-10.96 10.98,-21.45 17.19,-31.77c30.22,-50.84 60.17,-101.84 90.3,-152.73c41.24,-69.98 83.16,-139.55 124.66,-209.37c4.41,-7.94 9.91,-15.26 16.09,-21.9c8.33,-8.46 18.9,-15.3 30.8,-17.16z" fill="#1ba0d8"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="&#1057;&#1083;&#1086;&#1081;_1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
<linearGradient id="SVGID_1__48343" gradientUnits="userSpaceOnUse" x1="39" y1="23.25" x2="39" y2="33.0008" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_1__48343);" cx="39" cy="28" r="4"/>
<linearGradient id="SVGID_2__48343" gradientUnits="userSpaceOnUse" x1="32" y1="6.75" x2="32" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_2__48343);" d="M58,13c0-2.757-2.243-5-5-5H19c-2.757,0-5,2.243-5,5v33H6v5c0,2.757,2.243,5,5,5h34 c2.757,0,5-2.243,5-5V18h8V13z M11,54c-1.654,0-3-1.346-3-3v-3h32v3c0,1.125,0.374,2.164,1.002,3H11z M48,51c0,1.654-1.346,3-3,3 s-3-1.346-3-3v-5H16V13c0-1.654,1.346-3,3-3h30.026C48.391,10.838,48,11.87,48,13V51z M56,16h-6v-3c0-1.654,1.346-3,3-3s3,1.346,3,3 V16z"/>
<linearGradient id="SVGID_3__48343" gradientUnits="userSpaceOnUse" x1="39" y1="6.75" x2="39" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_3__48343);" d="M39,23c-2.757,0-5,2.243-5,5s2.243,5,5,5s5-2.243,5-5S41.757,23,39,23z M39,31 c-1.654,0-3-1.346-3-3s1.346-3,3-3s3,1.346,3,3S40.654,31,39,31z"/>
<linearGradient id="SVGID_4__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="23" style="fill:url(#SVGID_4__48343);" width="10" height="2"/>
<linearGradient id="SVGID_5__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="27" style="fill:url(#SVGID_5__48343);" width="10" height="2"/>
<linearGradient id="SVGID_6__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="31" style="fill:url(#SVGID_6__48343);" width="10" height="2"/>
<linearGradient id="SVGID_7__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="35" style="fill:url(#SVGID_7__48343);" width="10" height="2"/>
<linearGradient id="SVGID_8__48343" gradientUnits="userSpaceOnUse" x1="39" y1="6.75" x2="39" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="34" y="35" style="fill:url(#SVGID_8__48343);" width="10" height="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="&#1057;&#1083;&#1086;&#1081;_1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
<linearGradient id="SVGID_1__52535" gradientUnits="userSpaceOnUse" x1="21.9994" y1="11.6667" x2="21.9994" y2="18.5839" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_1__52535);" cx="21.999" cy="14.998" r="3"/>
<linearGradient id="SVGID_2__52535" gradientUnits="userSpaceOnUse" x1="35.9994" y1="4.1667" x2="35.9994" y2="15.8334" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_2__52535);" cx="35.999" cy="9.998" r="4"/>
<linearGradient id="SVGID_3__52535" gradientUnits="userSpaceOnUse" x1="32" y1="20.7501" x2="32" y2="58.7632" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_3__52535);" d="M47.003,21H16.996C15.344,21,14,22.344,14,23.995V25v0.998v1.261 c0,0.717,0.257,1.41,0.722,1.95l10.556,12.315C25.743,42.068,26,42.763,26,43.479v6.964c0,0.652,0.32,1.264,0.854,1.634l8.016,5.569 c0.341,0.236,0.737,0.356,1.136,0.356c0.316,0,0.634-0.076,0.926-0.229C37.591,57.428,38,56.751,38,56.007V43.479 c0-0.716,0.257-1.409,0.722-1.953L49.277,29.21C49.743,28.668,50,27.975,50,27.259v-1.258V25v-1.005C50,22.344,48.655,21,47.003,21z M37.204,40.225c-0.447,0.521-0.762,1.129-0.963,1.775H33v2h3l0.001,2H33v2h3.003l0.002,2H34v2h2.007l0.003,4.002L28,50.442v-6.964 c0-1.193-0.428-2.35-1.205-3.255L17.176,29h29.648L37.204,40.225z M48,26.001C48,26.552,47.552,27,47,27H17.002 C16.449,27,16,26.551,16,25.998V25v-1.005C16,23.446,16.447,23,16.996,23h30.007C47.553,23,48,23.446,48,23.995V25V26.001z"/>
<linearGradient id="SVGID_4__52535" gradientUnits="userSpaceOnUse" x1="41.9994" y1="17.3333" x2="41.9994" y2="21.3333" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<path style="fill:url(#SVGID_4__52535);" d="M44.999,21c0-2-1.343-3-3-3s-3,1-3,3H44.999z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -2,12 +2,15 @@
import type { Axios } from 'axios'
import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageOptions } from '@/api/constants'
import type { EndPoints, FileItem } from '@/api/types'
import api from '@/api'
import AliyunAuthDialog from './dialog/AliyunAuthDialog.vue'
import U115AuthDialog from './dialog/U115AuthDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
const props = defineProps({
storages: Array as PropType<StorageConf[]>,
storages: String,
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: {
@@ -19,101 +22,49 @@ const props = defineProps({
type: Object as PropType<FileItem>,
required: true,
},
itemstack: {
type: Array as PropType<FileItem[]>,
default: () => [],
},
itemstack: Array as PropType<FileItem[]>,
})
// 对外事件
const emit = defineEmits(['pathchanged'])
const availableStorages = [
{
name: '本地',
code: 'local',
icon: 'mdi-folder-multiple-outline',
},
{
name: '阿里云盘',
code: 'aliyun',
icon: 'mdi-cloud-outline',
},
{
name: '115网盘',
code: 'u115',
icon: 'mdi-cloud-outline',
},
]
const fileIcons = {
// 压缩包
zip: 'mdi-folder-zip-outline',
rar: 'mdi-folder-zip-outline',
bak: 'mdi-folder-zip-outline',
tar: 'mdi-folder-zip-outline',
gz: 'mdi-folder-zip-outline',
bz2: 'mdi-folder-zip-outline',
// 开发
htm: 'mdi-language-html5',
html: 'mdi-language-html5',
vue: 'mdi-vuejs',
js: 'mdi-nodejs',
ts: 'mdi-language-typescript',
json: 'mdi-file-document-outline',
css: 'mdi-language-css3',
scss: 'mdi-language-css3',
less: 'mdi-language-css3',
php: 'mdi-language-php',
py: 'mdi-language-python',
java: 'mdi-language-java',
go: 'mdi-language-go',
c: 'mdi-language-c',
cpp: 'mdi-language-cpp',
h: 'mdi-language-c',
cs: 'mdi-language-csharp',
sql: 'mdi-database',
sh: 'mdi-language-bash',
bat: 'mdi-language-bash',
ps1: 'mdi-language-powershell',
// markdown
md: 'mdi-language-markdown-outline',
markdown: 'mdi-language-markdown-outline',
// 图片
png: 'mdi-file-png-box',
jpg: 'mdi-file-jpg-box',
jpeg: 'mdi-file-jpg-box',
gif: 'mdi-file-gif-box',
bmp: 'mdi-file-image-box',
webp: 'mdi-file-image-box',
ico: 'mdi-file-image-box',
svg: 'mdi-file-image-box',
// 视频
pdf: 'mdi-file-pdf',
png: 'mdi-file-image',
jpg: 'mdi-file-image',
jpeg: 'mdi-file-image',
mp4: 'mdi-filmstrip',
mkv: 'mdi-filmstrip',
avi: 'mdi-filmstrip',
wmv: 'mdi-filmstrip',
mov: 'mdi-filmstrip',
flv: 'mdi-filmstrip',
rmvb: 'mdi-filmstrip',
// 文档
txt: 'mdi-file-document-outline',
env: 'mdi-file-cog-outline',
yml: 'mdi-file-cog-outline',
yaml: 'mdi-file-cog-outline',
conf: 'mdi-file-cog-outline',
log: 'mdi-file-document-outline',
csv: 'mdi-file-delimited',
// office
xls: 'mdi-file-excel',
xlsx: 'mdi-file-excel',
doc: 'mdi-file-word',
docx: 'mdi-file-word',
ppt: 'mdi-file-powerpoint',
pptx: 'mdi-file-powerpoint',
pdf: 'mdi-file-pdf',
// 音频
mp2: 'mdi-music',
mp3: 'mdi-music',
m4a: 'mdi-music',
wma: 'mdi-music',
aac: 'mdi-music',
ogg: 'mdi-music',
flac: 'mdi-music',
wav: 'mdi-music',
// 字体
ttf: 'mdi-format-font',
otf: 'mdi-format-font',
woff: 'mdi-format-font',
woff2: 'mdi-format-font',
eot: 'mdi-format-font',
// 字幕
srt: 'mdi-subtitles-outline',
ass: 'mdi-subtitles-outline',
sub: 'mdi-subtitles-outline',
// 其他
other: 'mdi-file-outline',
}
@@ -125,11 +76,19 @@ const activeStorage = ref('local')
const refreshPending = ref(false)
// 排序
const sort = ref('name')
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 阿里云盘用户信息
const aliyunUserInfo = ref<{ [key: string]: any }>({})
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// 115网盘用户信息
const u115UserInfo = ref<{ [key: string]: any }>({})
// 计算属性
const storagesArray = computed(() => {
const storageCodes = props.storages?.map(item => item.type)
return storageOptions.filter(item => storageCodes?.includes(item.value))
const storageCodes = props.storages?.split(',')
return availableStorages.filter(item => storageCodes?.includes(item.code))
})
// 方法
@@ -138,10 +97,47 @@ function loadingChanged(loading: number) {
else if (loading > 0) loading--
}
// 查询阿里云
async function loadAliyunUserInfo() {
try {
const result: { [key: string]: any } = await api.get('aliyun/userinfo')
if (result.success) {
aliyunUserInfo.value = result
}
} catch (error) {
console.log(error)
}
}
// 查询115
async function loadU115UserInfo() {
try {
const result: { [key: string]: any } = await api.get('u115/storage')
if (result.success) {
u115UserInfo.value = result
}
} catch (error) {
console.log(error)
}
}
// 存储切换
async function storageChanged(storage: string) {
if (storage == 'aliyun') {
await loadAliyunUserInfo()
if (isNullOrEmptyObject(aliyunUserInfo.value)) {
aliyunAuthDialog.value = true
return
}
} else if (storage == 'u115') {
await loadU115UserInfo()
if (isNullOrEmptyObject(u115UserInfo.value)) {
u115AuthDialog.value = true
return
}
}
activeStorage.value = storage
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
emit('pathchanged', { path: '/', fileid: 'root' })
}
// 路径变化
@@ -154,6 +150,18 @@ function sortChanged(s: string) {
sort.value = s
refreshPending.value = true
}
// aliyun认证完成
function aliyunAuthDone() {
aliyunAuthDialog.value = false
activeStorage.value = 'aliyun'
}
// u115认证完成
function u115AuthDone() {
u115AuthDialog.value = false
activeStorage.value = 'u115'
}
</script>
<template>
@@ -187,4 +195,11 @@ function sortChanged(s: string) {
/>
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
@close="aliyunAuthDialog = false"
@done="aliyunAuthDone"
/>
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = false" @done="u115AuthDone" />
</template>

View File

@@ -73,3 +73,9 @@ const getImgUrl = computed(() => {
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow {
text-shadow: 1px 1px #777;
}
</style>

View File

@@ -1,193 +0,0 @@
<script lang="ts" setup>
import { CustomRule } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash-es'
import { innerFilterRules } from '@/api/constants'
// 输入参数
const props = defineProps({
// 单条规则
rule: {
type: Object as PropType<CustomRule>,
required: true,
},
// 所有规则
rules: {
type: Array as PropType<CustomRule[]>,
required: true,
},
})
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const ruleInfoDialog = ref(false)
// 规则详情
const ruleInfo = ref<CustomRule>({
id: '',
name: '',
include: '',
exclude: '',
size_range: '',
seeders: '',
publish_time: '',
})
// 打开详情弹窗
function openRuleInfoDialog() {
// 深复制
ruleInfo.value = cloneDeep(props.rule)
ruleInfoDialog.value = true
}
// 保存详情数据
function saveRuleInfo() {
// 有空值
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error('规则ID和规则名称不能为空')
}
return
}
// 检查ID是否在内置的规则中
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error('当前规则ID已被内置规则占用')
return
}
// 检查规则名称是否在内置的规则中
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error('当前规则名称已被内置规则占用')
return
}
// ID已存在
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(`规则ID【${ruleInfo.value.id}】已存在`)
return
}
// 规则名称已存在
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(`规则名称【${ruleInfo.value.name}】已存在`)
return
}
// 保存数据
ruleInfoDialog.value = false
emit('change', ruleInfo.value, props.rule.id)
emit('done')
}
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openRuleInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
</div>
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
label="规则ID"
placeholder="必填不可与其他规则ID重名"
hint="字符与数字组合,不能含空格"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
label="规则名称"
placeholder="必填;不可与其他规则名称重名"
hint="使用别名便于区分规则"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
placeholder="关键字/正则表达式"
label="包含"
hint="必须包含的关键字或正则表达式,多个值使用|分隔"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
placeholder="关键字/正则表达式"
label="排除"
hint="不能包含的关键字或正则表达式,多个值使用|分隔"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
placeholder="0/1-10"
label="资源体积MB"
hint="最小资源文件体积或体积范围(剧集计算单集平均大小)"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
placeholder="0/1-10"
label="做种人数"
hint="最小做种人数或做种人数范围"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
placeholder="0/1-10"
label="发布时间(分钟)"
hint="距离资源发布的最小时间间隔或时间区间"
persistent-hint
active
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,14 +1,12 @@
<script lang="ts" setup>
import type { TransferDirectoryConf } from '@/api/types'
import api from '@/api'
import { nextTick } from 'vue'
import { storageOptions } from '@/api/constants'
import type { MediaDirectory } from '@/api/types'
import { VTextField } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
type: String, // download/library
directory: {
type: Object as PropType<TransferDirectoryConf>,
type: Object as PropType<MediaDirectory>,
required: true, // 必填参数
},
categories: {
@@ -19,8 +17,8 @@ const props = defineProps({
height: String,
})
// 卡版是否折叠状态
const isCollapsed = ref(true)
// 路径
const path = ref<string>('')
// 类型下拉字典
const typeItems = [
@@ -29,93 +27,6 @@ const typeItems = [
{ title: '电视剧', value: '电视剧' },
]
// 自动整理方式下拉字典
const transferSourceItems = [
{ title: '不整理', value: '' },
{ title: '下载器监控', value: 'downloader' },
{ title: '目录监控', value: 'monitor' },
{ title: '手动整理', value: 'manual' },
]
// 监控模式下拉字典
const MonitorModeItems = [
{ title: '性能模式', value: 'fast' },
{ title: '兼容模式', value: 'compatibility' },
]
// 整理方式下拉字典
const transferTypeItems = ref<{ title: string; value: string }[]>([])
// 调用API查询支持的整理方式
async function loadTransferTypeItems() {
// 参数不全时不查询
if (!props.directory.library_storage || !props.directory.storage) return
try {
// 下载器储存整理方法
const storage_res = await api.get(`storage/transtype/${props.directory.storage}`)
const storage_transtype = (storage_res as any).transtype
// 媒体库储存整理方法
const library_storage_res = await api.get(`storage/transtype/${props.directory.library_storage}`)
const library_storage_transtype = (library_storage_res as any).transtype
// 为空终止
if (!library_storage_transtype || !storage_transtype) return
// 取并集
const transtype: { [key: string]: string } = {}
Object.keys(storage_transtype).forEach(key => {
if (key in library_storage_transtype) {
transtype[key] = storage_transtype[key]
}
})
// 非空时设置整理方式下拉字典
if (transtype && Object.keys(transtype).length > 0) {
transferTypeItems.value = Object.keys(transtype).map(key => ({
title: transtype[key],
value: key,
}))
// 如果整理方式下拉字典不为空且当前值不在新的transferTypeItems里则设置整理方式为第一个
if (
transferTypeItems.value.length > 0 &&
!transferTypeItems.value.find(item => item.value === props.directory.transfer_type)
) {
nextTick(() => {
props.directory.transfer_type = transferTypeItems.value[0].value
})
}
// 如果整理方式下拉字典为空,清空整理方式
if (transferTypeItems.value.length === 0) {
props.directory.transfer_type = ''
}
} else {
// 无可用整理方式,清除已选值
transferTypeItems.value = []
props.directory.transfer_type = ''
}
} catch (e) {
console.log(e)
}
}
// 整理方式无数据提示
const computedNoDataText = computed(() => {
if (!props.directory.library_storage && !props.directory.storage) {
return '请选择储存'
} else if (!props.directory.library_storage) {
return '请选择媒体库储存'
} else if (!props.directory.storage) {
return '请选择下载器储存'
} else {
return '选择的存储类型没有支持的整理方式'
}
})
// 覆盖模式下拉字典
const overwriteModeItems = [
{ title: '从不', value: 'never' },
{ title: '总是', value: 'always' },
{ title: '按文件大小', value: 'size' },
{ title: '仅保留最新版本', value: 'latest' },
]
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed', 'update:modelValue'])
@@ -124,38 +35,18 @@ function onClose() {
emit('close')
}
// 路径更新
function updatePath(value: string) {
path.value = value
emit('update:modelValue', value)
}
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
return default_value.concat(props.categories[props.directory.media_type ?? ''])
})
// 监听 下载储存与媒体库储存 变化,重新加载整理方式下拉字典
watch(
[() => props.directory.library_storage, () => props.directory.storage],
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
if (newLibraryStorage !== oldLibraryStorage || newStorage !== oldStorage) {
loadTransferTypeItems()
}
},
{ immediate: true },
)
// 媒体类别和类型变更非空时将按类型分类和按类别分类置为false
watch(
[() => props.directory.media_type, () => props.directory.media_category],
([newMediaType, newMediaCategory], [oldMediaType, oldMediaCategory]) => {
if (newMediaType && newMediaType !== oldMediaType) {
props.directory.download_type_folder = false
props.directory.library_type_folder = false
}
if (newMediaCategory && newMediaCategory !== oldMediaCategory) {
props.directory.download_category_folder = false
props.directory.library_category_folder = false
}
},
)
</script>
<template>
@@ -174,124 +65,40 @@ watch(
</IconBtn>
</span>
</VCardItem>
<VCardText v-if="!isCollapsed">
<VCardText>
<VForm>
<VRow>
<VCol cols="6">
<VCol>
<VPathField @update:modelValue="updatePath">
<template #activator="{ menuprops }">
<VTextField v-model="props.directory.path" v-bind="menuprops" variant="underlined" label="路径" />
</template>
</VPathField>
</VCol>
</VRow>
<VRow>
<VCol cols="4">
<VSelect
v-model="props.directory.media_type"
variant="underlined"
:items="typeItems"
label="媒体类型"
@update:modelValue="props.directory.media_category = ''"
@update:modelValue="props.directory.category = ''"
/>
</VCol>
<VCol cols="6">
<VSelect
v-model="props.directory.media_category"
variant="underlined"
:items="getCategories"
label="媒体类别"
/>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.storage"
variant="underlined"
:items="storageOptions"
label="下载存储/源存储"
/>
</VCol>
<VCol cols="8">
<VPathField
v-model="props.directory.download_path"
:storage="props.directory.storage"
variant="underlined"
label="下载目录/源目录"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.download_type_folder" label="按类型分类"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.download_category_folder" label="按类别分类"></VSwitch>
</VCol>
</VRow>
<VDivider v-if="$props.directory.monitor_type" class="my-3 bg-primary" />
<VRow>
<VCol>
<VSelect
v-model="props.directory.monitor_type"
variant="underlined"
:items="transferSourceItems"
label="自动整理"
/>
<VSelect v-model="props.directory.category" variant="underlined" :items="getCategories" label="媒体类别" />
</VCol>
</VRow>
<VRow v-if="$props.directory.monitor_type">
<VCol cols="12" v-if="$props.directory.monitor_type == 'monitor'">
<VSelect
v-model="props.directory.monitor_mode"
variant="underlined"
:items="MonitorModeItems"
label="监控模式"
/>
<VRow>
<VCol v-if="!props.directory.category || props.directory.category === ''">
<VSwitch v-model="props.directory.auto_category" label="自动分类"></VSwitch>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.library_storage"
variant="underlined"
:items="storageOptions"
label="媒体库存储"
/>
</VCol>
<VCol cols="8">
<VPathField
v-model="props.directory.library_path"
:storage="props.directory.library_storage"
variant="underlined"
label="媒体库目录"
/>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.transfer_type"
variant="underlined"
:items="transferTypeItems"
label="整理方式"
:no-data-text="computedNoDataText"
/>
</VCol>
<VCol cols="8">
<VSelect
v-model="props.directory.overwrite_mode"
variant="underlined"
:items="overwriteModeItems"
label="覆盖模式"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.library_type_folder" label="按类型分类"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.library_category_folder" label="按类别分类"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.renaming" label="智能重命名"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.scraping" label="刮削元数据"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.notify" label="发送通知"></VSwitch>
<VCol v-if="type === 'library'">
<VSwitch v-model="props.directory.scrape" label="刮削元数据"></VSwitch>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="text-center py-0">
<VSpacer />
<VBtn :icon="isCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'" @click.stop="isCollapsed = !isCollapsed" />
<VSpacer />
</VCardActions>
</VCard>
</template>

View File

@@ -1,317 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { formatFileSize } from '@/@core/utils/formatters'
import { DownloaderConf } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import type { DownloaderInfo } from '@/api/types'
import qbittorrent_image from '@images/logos/qbittorrent.png'
import transmission_image from '@images/logos/transmission.png'
import { cloneDeep } from 'lodash-es'
// 定义输入
const props = defineProps({
// 单个下载器
downloader: {
type: Object as PropType<DownloaderConf>,
required: true,
},
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
// 所有下载器
downloaders: {
type: Array as PropType<DownloaderConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
// 提示框
const $toast = useToast()
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 上传速率
const upload_rate = ref(0)
// 下载速度
const download_rate = ref(0)
// 下载器详情弹窗
const downloaderInfoDialog = ref(false)
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
type: '',
default: false,
enabled: false,
config: {},
})
// 调用API查询下载器数据
async function loadDownloaderInfo() {
if (!props.allowRefresh) {
return
}
try {
const res: DownloaderInfo = await api.get('dashboard/downloader', {
params: {
name: props.downloader.name,
},
})
if (res) {
upload_rate.value = res.upload_speed
download_rate.value = res.download_speed
// 定时查询
clearTimeout(timeoutTimer)
if (props.downloader.enabled) {
timeoutTimer = setTimeout(loadDownloaderInfo, 3000)
}
}
} catch (e) {
console.log(e)
}
}
// 打开详情弹窗
function openDownloaderInfoDialog() {
// 深复制
downloaderInfo.value = cloneDeep(props.downloader)
downloaderInfoDialog.value = true
}
// 保存详情数据
function saveDownloaderInfo() {
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(`${downloaderInfo.value.name}】已存在,请替换为其他名称`)
return
}
// 默认下载器去重
if (downloaderInfo.value.default) {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(`存在默认下载器【${item.name}】,已替换成【${downloaderInfo.value.name}`)
}
})
}
// 执行保存
downloaderInfoDialog.value = false
emit('change', downloaderInfo.value, props.downloader.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.downloader.type) {
case 'qbittorrent':
return qbittorrent_image
case 'transmission':
return transmission_image
default:
return qbittorrent_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
onMounted(async () => {
if (props.downloader.enabled) {
await loadDownloaderInfo()
}
})
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
})
</script>
<template>
<div>
<VCard variant="tonal" @click="openDownloaderInfoDialog">
<DialogCloseBtn @click="onClose" />
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="flex justify-space-between align-center gap-4">
<div class="align-self-start flex-1">
<div class="flex items-center">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="text-h6">{{ downloader.name }}</span>
</div>
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</div>
</VCardText>
</VCard>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" label="启用下载器" />
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.default" label="默认下载器" :disabled="!downloaderInfo.enabled" />
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
hint="登录使用的用户名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
label="自动分类管理"
hint="由下载器自动管理分类和下载目录"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
label="顺序下载"
hint="按顺序依次下载文件"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
label="强制继续"
hint="强制继续、强制上传模式"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
label="优先首尾文件"
hint="优先下载首尾文件块"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
hint="登录使用的用户名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
persistent-hint
active
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
const props = defineProps({
@@ -18,21 +17,16 @@ function getPercentage() {
// 速度
function getSpeedText() {
return `${formatFileSize(props.info?.size || 0)} ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${
props.info?.left_time
}`
return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${props.info?.left_time}`
}
// 下载状态
const isDownloading = ref(props.info?.state === 'downloading')
// 监听props.info?.state的变化
watch(
() => props.info?.state,
newValue => {
isDownloading.value = newValue === 'downloading'
},
)
watch(() => props.info?.state, (newValue) => {
isDownloading.value = newValue === 'downloading'
})
// 图片是否加载完成
const imageLoaded = ref(false)
@@ -51,10 +45,14 @@ function getTextClass() {
async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start'
try {
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`)
const result: { [key: string]: any } = await api.get(
`download/${operation}/${props.info?.hash}`,
)
if (result.success) isDownloading.value = !isDownloading.value
} catch (error) {
if (result.success)
isDownloading.value = !isDownloading.value
}
catch (error) {
console.error(error)
}
}
@@ -64,42 +62,67 @@ async function deleteDownload() {
try {
await api.delete(`download/${props.info?.hash}`)
cardState.value = false
} catch (error) {
}
catch (error) {
console.error(error)
}
}
</script>
<template>
<VCard v-if="cardState" :key="props.info?.hash">
<VCard
v-if="cardState"
:key="props.info?.hash"
>
<template #image>
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover class="brightness-50" @load="imageLoadHandler" />
<VImg
:src="props.info?.media.image"
aspect-ratio="2/3"
cover
class="brightness-50"
@load="imageLoadHandler"
/>
</template>
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
<VCardTitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
{{ props.info?.media.episode ? `${props.info?.media.season} ${props.info?.media.episode}` : props.info?.season_episode }}
</VCardTitle>
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
<VCardSubtitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
{{ props.info?.title }}
</VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()">
<VCardText
class="text-subtitle-1 pt-3 pb-1"
:class="getTextClass()"
>
{{ getSpeedText() }}
</VCardText>
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
<VCardText
v-if="getPercentage() > 0"
:class="getTextClass()"
>
<VProgressLinear :model-value="getPercentage()" />
</VCardText>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
<VBtn
:icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`"
@click="toggleDownload"
/>
<VBtn
color="error"
icon="mdi-trash-can-outline"
@click="deleteDownload"
/>
</VCardActions>
</VCard>
</template>

View File

@@ -1,13 +1,11 @@
<script lang="ts" setup>
import { innerFilterRules } from '@/api/constants'
import { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash-es'
// 输入参数
const props = defineProps({
pri: String,
maxpri: String,
rules: Array as PropType<string[]>,
custom_rules: Array as PropType<CustomRule[]>,
width: String,
height: String,
})
// 定义触发的自定义事件
@@ -23,25 +21,59 @@ function filtersChanged(value: string[]) {
emit('changed', props.pri, value)
}
// 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
onMounted(() => {
selectFilterOptions.value = cloneDeep(innerFilterRules)
if (props.custom_rules) {
console.log(props.custom_rules)
props.custom_rules.map(rule => {
selectFilterOptions.value.push({
title: rule.name,
value: rule.id,
})
})
}
// 清洗规则中的换行符和多余空格,并在前后添加空格
const cleanedRules = computed(() => {
return props.rules.map(rule => {
rule = rule ?? ''
return ` ${rule.replace(/[\r\n]/g, '').replace(/\s+/g, '')} `
})
})
// 过滤规则下拉框
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 ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
{ title: '质量: BLURAY', value: ' BLURAY ' },
{ title: '排除: BLURAY', value: ' !BLURAY ' },
{ title: '质量: UHD', value: ' UHD ' },
{ title: '排除: UHD', value: ' !UHD ' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
])
</script>
<template>
<VCard variant="tonal">
<VCard variant="tonal" :width="props.width" :height="props.height">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -53,13 +85,12 @@ onMounted(() => {
<VRow>
<VCol>
<VSelect
v-model="props.rules"
v-model="cleanedRules"
variant="underlined"
:items="selectFilterOptions"
chips
label=""
multiple
clearable
@update:modelValue="filtersChanged"
/>
</VCol>

View File

@@ -1,307 +0,0 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { useToast } from 'vue-toast-notification'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash-es'
// 输入参数
const props = defineProps({
// 单个规则组
group: {
type: Object as PropType<FilterRuleGroup>,
required: true,
},
// 所有规则组
groups: {
type: Array as PropType<FilterRuleGroup[]>,
required: true,
},
// 媒体类型字典
categories: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
// 自定义规则列表
custom_rules: Array as PropType<CustomRule[]>,
})
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const groupInfoDialog = ref(false)
// 规则详情
const groupInfo = ref<FilterRuleGroup>({
name: props.group?.name ?? '',
rule_string: props.group?.rule_string ?? '',
media_type: props.group?.media_type ?? '',
category: props.group?.category ?? '',
})
// 媒体类型字典
const mediaTypeItems = [
{ title: '通用', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
})
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
// 规则组类型,仅用于导入判断
const filterRuleCardsType = ref<FilterCard>({
pri: '',
rules: [],
})
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入代码类型
const importCodeType = ref('')
// 更新规则卡片的值
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterRuleCards.value.find(card => card.pri === pri)
if (card && Array.isArray(rules)) card.rules = rules
}
// 移除卡片
function filterCardClose(pri: string) {
filterRuleCards.value = filterRuleCards.value
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
// 分享规则
async function shareRules() {
if (filterRuleCards.value.length === 0) return
const value = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
try {
let success
success = copyToClipboard(value)
if (await success) $toast.success('优先级规则已复制到剪贴板!')
else $toast.error('优先级规则复制失败:可能是浏览器不支持或被用户阻止!')
} catch (error) {
$toast.error('优先级规则复制失败!')
console.error(error)
}
}
// 导入规则
async function importRules(ruleType: string) {
importCodeType.value = ruleType
importCodeDialog.value = true
}
// 保存导入的代码,直接覆盖原有值
function saveCodeString(type: string, code: any) {
try {
code = code.value
if (type === 'priority') {
// 解析值
if (!code) return
// 首尾增加空格
if (!code.startsWith(' ')) code = ` ${code}`
if (!code.endsWith(' ')) code = `${code} `
const groups = code.split('>')
filterRuleCards.value = groups.map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
} catch (error) {
$toast.error('导入失败!')
console.error(error)
}
}
// 增加卡片
function addFilterCard() {
const pri = (filterRuleCards.value.length + 1).toString()
const newCard: FilterCard = { pri, rules: [] }
filterRuleCards.value.push(newCard)
}
// 根据列表的拖动顺序更新优先级
function dragOrderEnd() {
filterRuleCards.value.forEach((card, index) => {
card.pri = (index + 1).toString()
})
}
// 打开详情弹窗
function opengroupInfoDialog() {
groupInfo.value = cloneDeep(props.group)
if (props.group.rule_string) {
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
groupInfoDialog.value = true
}
// 保存详情数据
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error('规则组名称不能为空')
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(`规则组名称【${groupInfo.value.name}】已存在,请替换`)
return
}
groupInfoDialog.value = false
groupInfo.value.rule_string = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
emit('change', groupInfo.value, props.group.name)
emit('done')
}
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="opengroupInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
<div class="text-body-1 mb-3">
<span v-if="!props.group.category">{{ props.group.media_type || '通用' }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
</div>
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
label="规则组名称"
placeholder="必填;不可与其他规则组重名"
hint="自定义规则组名称"
persistent-hint
active
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="groupInfo.media_type"
label="适用媒体类型"
:items="mediaTypeItems"
hint="选择规则组适用的媒体类型"
persistent-hint
active
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="groupInfo.category"
:items="getCategories"
label="适用媒体类别"
hint="选择规则组适用的媒体类别"
persistent-hint
active
/>
</VCol>
</VRow>
</VCardItem>
<VCardText>
<draggable
v-model="filterRuleCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterRuleCards.length.toString()"
:rules="element.rules"
:custom_rules="props.custom_rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">请添加或导入规则</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" variant="tonal" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" variant="tonal" @click="importRules('priority')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
title="导入规则优先级"
:dataType="importCodeType"
@close="importCodeDialog = false"
@save="saveCodeString"
/>
</div>
</template>

View File

@@ -91,17 +91,7 @@ async function drawImages(imageList: string[]) {
const img = new Image()
img.setAttribute('crossorigin', 'anonymous')
img.src = imgSrc
try {
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error(`Failed to load image: ${imgSrc}`))
})
} catch (error) {
console.error(error)
ctx.fillStyle = '#e5e7eb'
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), MARGIN_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT)
return
}
await new Promise(resolve => (img.onload = resolve))
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT
@@ -181,3 +171,9 @@ onMounted(async () => {
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow {
text-shadow: 1px 1px #777;
}
</style>

View File

@@ -2,16 +2,15 @@
import type { PropType, Ref } from 'vue'
import { useToast } from 'vue-toast-notification'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, NotExistMediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import router, { registerAbortController } from '@/router'
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
import router from '@/router'
import noImage from '@images/no-image.jpeg'
import tmdbImage from '@images/logos/tmdb.png'
import doubanImage from '@images/logos/douban-black.png'
import bangumiImage from '@images/logos/bangumi.png'
import { useUserStore } from '@/stores'
// 输入参数
const props = defineProps({
@@ -20,11 +19,7 @@ const props = defineProps({
height: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 用户 Store
const userStore = useUserStore()
const store = useStore()
// 提示框
const $toast = useToast()
@@ -57,10 +52,10 @@ const subscribeEditDialog = ref(false)
const subscribeId = ref<number>()
// 季详情
const seasonInfos = ref<MediaSeason[]>([])
const seasonInfos = ref<TmdbSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<MediaSeason[]>([])
const seasonsSelected = ref<TmdbSeason[]>([])
// 来源角标字典
const sourceIconDict: { [key: string]: any } = {
@@ -69,50 +64,11 @@ const sourceIconDict: { [key: string]: any } = {
bangumi: bangumiImage,
}
// 绑定MediaCard元素
const mediaCardRef = ref<HTMLElement | null>(null)
// 创建Intersection Observer实例
const observer = ref<IntersectionObserver | null>(null)
// 所有站点
const allSites = ref<Site[]>([])
// 选中的站点
const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
const searchMenuShow = ref(false)
// 查询所有站点
async function querySites() {
try {
const data: Site[] = await api.get('site/')
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
} catch (error) {
console.log(error)
}
}
// 查询用户选中的站点
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 获得mediaid
function getMediaId() {
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
else return `bangumi:${props.media?.bangumi_id}`
}
// 订阅弹窗选择的多季
@@ -131,11 +87,13 @@ function getChipColor(type: string) {
}
// 添加订阅处理
async function handleAddSubscribe() {
if (props.media?.type === '电视剧') {
// 查询所有季信息
if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
// TMDB电视剧
// 查询TMDB所有季信息
await getMediaSeasons()
if (!seasonInfos.value || seasonInfos.value.length === 0) {
if (!seasonInfos.value) {
$toast.error(`${props.media?.title} 查询剧集信息失败!`)
return
}
@@ -151,6 +109,11 @@ async function handleAddSubscribe() {
seasonsSelected.value = []
subscribeSeasonDialog.value = true
}
} else if (props.media?.type === '电视剧') {
// 豆瓣电视剧,只会有一季
const season = props.media?.season ?? 1
// 添加订阅
addSubscribe(season)
} else {
// 电影
addSubscribe()
@@ -175,7 +138,6 @@ async function addSubscribe(season = 0) {
tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id,
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
season,
best_version,
})
@@ -254,9 +216,6 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
@@ -265,7 +224,6 @@ async function handleCheckExists() {
season: props.media?.season,
mtype: props.media?.type,
},
signal,
})
if (result.success) isExists.value = true
@@ -277,16 +235,13 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: props.media?.title,
},
signal,
})
return result.id || null
@@ -309,6 +264,7 @@ async function checkSeasonsNotExists() {
let state = 0
if (item.episodes.length === 0) state = 2
else if (item.episodes.length < item.total_episode) state = 1
seasonsNotExisted.value[item.season] = state
})
}
@@ -323,26 +279,17 @@ async function checkSeasonsNotExists() {
// 查询TMDB的所有季信息
async function getMediaSeasons() {
startNProgress()
try {
seasonInfos.value = await api.get('media/seasons', {
params: {
mediaid: getMediaId(),
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
},
})
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 查询订阅弹窗规则
async function queryDefaultSubscribeConfig() {
// 非管理员不显示
if (!userStore.superUser) return false
if (!store.state.auth.superUser) return false
try {
let subscribe_config_url = ''
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
@@ -386,36 +333,16 @@ function getExistText(season: number) {
// 打开详情页
function goMediaDetail(isHovering = false) {
if (isHovering) {
if (props.media?.collection_id) {
// 跳转到合集列表
router.push({
path: `/browse/tmdb/collection/${props.media?.collection_id}`,
query: {
title: props.media?.title,
},
})
} else {
// 跳转到媒体详情页
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.title,
year: props.media?.year,
type: props.media?.type,
},
})
}
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
type: props.media?.type,
},
})
}
}
// 点击搜索
async function clickSearch() {
if (allSites.value?.length > 0) return
querySites()
querySelectedSites()
}
// 开始搜索
function handleSearch() {
router.push({
@@ -424,70 +351,32 @@ function handleSearch() {
keyword: getMediaId(),
type: props.media?.type,
area: 'title',
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
sites: selectedSites.value.join(','),
},
})
}
// 懒加载检查
function handleCheckLazy() {
if (props.media?.collection_id) {
return
}
// 装载时检查是否已订阅
onBeforeMount(() => {
handleCheckSubscribe()
handleCheckExists()
}
// 在元素进入视窗时触发懒加载函数
function setupIntersectionObserver() {
if (mediaCardRef.value) {
observer.value = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 只要MediaCard进入视窗就调用懒加载的操作
handleCheckLazy()
// 加载后销毁观察者实例
observer.value?.disconnect()
observer.value = null
}
})
},
{ threshold: 0.1 },
)
observer.value.observe(mediaCardRef.value)
}
}
onMounted(() => {
setupIntersectionObserver()
})
onBeforeUnmount(() => {
observer.value?.disconnect()
observer.value = null
})
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return `${import.meta.env.VITE_API_BASE_URL}douban/img?imgurl=${encodeURIComponent(url)}`
return url
})
// 拼装季图片地址
function getSeasonPoster(posterPath: string) {
if (!posterPath) return ''
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
return `https://image.tmdb.org/t/p/w500${posterPath}`
}
// 将yyyy-mm-dd转换为yyyy年mm月dd日
@@ -496,126 +385,92 @@ function formatAirDate(airDate: string) {
const date = new Date(airDate.replaceAll(/-/g, '/'))
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
// 从yyyy-mm-dd中提取年份
function getYear(airDate: string) {
if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/'))
return date.getFullYear()
}
// 移除订阅
function onRemoveSubscribe() {
subscribeEditDialog.value = false
}
</script>
<template>
<VHover>
<template #default="hover">
<div ref="mediaCardRef">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering)"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError || searchMenuShow"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
>
<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 v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
<div v-else class="flex align-center justify-between">
<VMenu close-on-content-click v-model="searchMenuShow" max-width="450">
<template v-slot:activator="{ props }">
<IconBtn v-bind="props" icon="mdi-magnify" color="white" @click.stop="clickSearch" />
</template>
<VList>
<VListItem>
<VChipGroup v-model="selectedSites" column multiple @click.stop>
<VChip
v-for="site in allSites"
:key="site.id"
:color="selectedSites.includes(site.id) ? 'primary' : ''"
filter
variant="outlined"
:value="site.id"
size="small"
>
{{ site.name }}
</VChip>
</VChipGroup>
</VListItem>
<VListItem>
<VBtn @click="handleSearch" block>搜索</VBtn>
</VListItem>
</VList>
</VMenu>
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</VCardText>
<!-- 类型角标 -->
<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 && !hover.isHovering" />
<!-- 评分角标 -->
<VChip
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
variant="elevated"
size="small"
:class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ formatRating(props.media?.vote_average) }}
</VChip>
<!--来源图标-->
<VAvatar
size="24"
density="compact"
class="absolute bottom-1 right-1"
tile
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
>
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard>
</div>
</template>
</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 && !hover.isHovering" />
<!-- 评分角标 -->
<VChip
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
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>
<VAvatar
size="24"
density="compact"
class="absolute bottom-1 right-1"
tile
v-if="!hover.isHovering && isImageLoaded && props.media?.source"
>
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard>
</template>
</VHover>
<!-- 订阅季弹窗 -->
@@ -669,7 +524,7 @@ function onRemoveSubscribe() {
</VList>
</VCardText>
<div class="my-2 text-center">
<VBtn size="large" :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
</VBtn>
</div>
@@ -682,6 +537,17 @@ function onRemoveSubscribe() {
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="onRemoveSubscribe"
@remove="
() => {
subscribeEditDialog = false
handleCheckSubscribe()
}
"
/>
</template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

View File

@@ -4,7 +4,7 @@ import type { Context } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
defineProps({
const props = defineProps({
context: Object as PropType<Context>,
})
@@ -45,7 +45,8 @@ function openTmdbPage(type: string, tmdbId: number) {
</template>
</VImg>
</div>
<div class="flex-grow">
<div>
<VCardItem class="pb-1">
<VCardTitle class="text-center text-md-left">
{{ context?.media_info?.title || context?.meta_info?.name }}

View File

@@ -1,349 +0,0 @@
<script setup lang="ts">
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import emby_image from '@images/logos/emby.png'
import jellyfin_image from '@images/logos/jellyfin.png'
import plex_image from '@images/logos/plex.png'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
// 定义输入
const props = defineProps({
// 单个媒体服务器
mediaserver: {
type: Object as PropType<MediaServerConf>,
required: true,
},
// 所有媒体服务器
mediaservers: {
type: Array as PropType<MediaServerConf[]>,
required: true,
},
})
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
// 媒体统计数据
const infoItems = ref([
{
avatar: 'mdi-movie-roll',
title: '电影',
amount: '0',
},
{
avatar: 'mdi-television-box',
title: '电视剧',
amount: '0',
},
{
avatar: 'mdi-account',
title: '用户',
amount: '0',
},
])
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: '全部',
value: 'all',
},
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
// 媒体服务器详情
const mediaServerInfo = ref<MediaServerConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 打开详情弹窗
function openMediaServerInfoDialog() {
loadLibrary(props.mediaserver.name)
// 深复制
mediaServerInfo.value = cloneDeep(props.mediaserver)
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
}
}
// 保存详情数据
function saveMediaServerInfo() {
// 为空不保存,跳出警告框
if (!mediaServerInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(`${mediaServerInfo.value.name}】已存在,请替换为其他名称`)
return
}
// 执行保存
mediaServerInfoDialog.value = false
emit('change', mediaServerInfo.value, props.mediaserver.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.mediaserver.type) {
case 'emby':
return emby_image
case 'jellyfin':
return jellyfin_image
default:
return plex_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
// 调用API加载媒体统计数据
async function loadMediaStatistic() {
try {
const res: MediaStatistic = await api.get('dashboard/statistic', {
params: {
name: props.mediaserver.name,
},
})
if (res) {
infoItems.value = [
{
avatar: 'mdi-movie-roll',
title: '电影',
amount: res.movie_count.toLocaleString(),
},
{
avatar: 'mdi-television-box',
title: '电视剧',
amount: res.tv_count.toLocaleString(),
},
{
avatar: 'mdi-account',
title: '用户',
amount: res.user_count.toLocaleString(),
},
]
}
} catch (e) {
console.log(e)
}
}
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
if (result && result.length > 0) {
librariesOptions.value = result.map(item => ({
title: item.name,
value: item.id?.toString(),
}))
} else {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: '全部',
value: 'all',
})
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadMediaStatistic()
})
</script>
<template>
<div>
<VCard variant="tonal" @click="openMediaServerInfoDialog">
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
<div class="text-sm mt-5 flex flex-wrap">
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
</span>
</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" label="启用媒体服务器" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Emby设置->高级->API密钥中生成的密钥"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Jellyfin设置->高级->API密钥中生成的密钥"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
label="X-Plex-Token"
hint="浏览器F12->网络从Plex请求URL中获取的X-Plex-Token"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
label="同步媒体库"
:items="librariesOptions"
chips
multiple
clearable
hint="只有选中的媒体库才会被同步"
persistent-hint
active
append-inner-icon="mdi-refresh"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,5 +1,4 @@
<script lang="ts" setup>
import { isNullOrEmptyObject } from '@/@core/utils'
import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
@@ -23,7 +22,8 @@ async function imageLoaded() {
// 链接打开新窗口
function openLink() {
if (props.message?.link) window.open(props.message.link, '_blank')
if (props.message?.link)
window.open(props.message.link, '_blank')
}
// 将note转换为json
@@ -31,8 +31,9 @@ function noteToJson() {
if (props.message?.note) {
try {
return JSON.parse(props.message.note)
} catch (error) {
return props.message.note
}
catch (error) {
console.error(error)
}
}
return {}
@@ -40,53 +41,58 @@ function noteToJson() {
// 将\n转换为html属性的换行符
function replaceNewLine(value: string) {
if (!value) return ''
if (!value)
return ''
return value.replace(/\n/g, '<br/>')
}
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
<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="3/2"
aspect-ratio="4/3"
cover
position="top"
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</div>
<div
v-if="
props.message?.title &&
!props.message?.text &&
!props.message?.image &&
isNullOrEmptyObject(props.message?.note) &&
props.message?.action === 0
"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
>
<p class="mb-0">{{ props.message?.title }}</p>
</div>
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
<VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
{{ props.message?.title }}
</VCardTitle>
<div
<VAlert
v-if="props.message?.text && props.message?.action === 0"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
variant="tonal"
type="success"
>
<p class="mb-0">{{ props.message?.text }}</p>
</div>
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
<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 break-words whitespace-break-spaces">
<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 break-words whitespace-break-spaces">
<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">
@@ -98,11 +104,9 @@ function replaceNewLine(value: string) {
</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>
<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>
</template>

View File

@@ -1,401 +0,0 @@
<script setup lang="ts">
import { NotificationConf } from '@/api/types'
import wechat_image from '@images/logos/wechat.png'
import telegram_image from '@images/logos/telegram.webp'
import vocechat_image from '@images/logos/vocechat.png'
import synologychat_image from '@images/logos/synologychat.png'
import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import { useToast } from 'vue-toast-notification'
import { cloneDeep } from 'lodash-es'
// 定义输入
const props = defineProps({
// 单个通知
notification: {
type: Object as PropType<NotificationConf>,
required: true,
},
// 所有通知
notifications: {
type: Array as PropType<NotificationConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 提示框
const $toast = useToast()
// 通知详情弹窗
const notificationInfoDialog = ref(false)
// 通知详情
const notificationInfo = ref<NotificationConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 各通知类型的名称字典
const notificationTypeNames: { [key: string]: string } = {
wechat: '企业微信',
telegram: 'Telegram',
vocechat: 'VoceChat',
synologychat: 'Synology Chat',
slack: 'Slack',
webpush: 'WebPush',
}
// 消息类型下拉字典
const notificationTypes = [
{ value: '资源下载', title: '资源下载' },
{ value: '整理入库', title: '整理入库' },
{ value: '订阅', title: '订阅' },
{ value: '站点', title: '站点' },
{ value: '媒体服务器', title: '媒体服务器' },
{ value: '手动处理', title: '手动处理' },
{ value: '插件', title: '插件' },
{ value: '其它', title: '其它' },
]
// 打开详情弹窗
function openNotificationInfoDialog() {
// 替换成深复制,避免修改时影响原数据
notificationInfo.value = cloneDeep(props.notification)
console.log(`当前卡片的通知信息:${JSON.stringify(notificationInfo.value)}`)
notificationInfoDialog.value = true
}
// 保存详情数据
function saveNotificationInfo() {
// 为空不保存,跳出警告框
if (!notificationInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
$toast.error(`通知渠道【${notificationInfo.value.name}】已存在,请替换`)
return
}
notificationInfoDialog.value = false
emit('change', notificationInfo.value, props.notification.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.notification.type) {
case 'wechat':
return wechat_image
case 'telegram':
return telegram_image
case 'vocechat':
return vocechat_image
case 'synologychat':
return synologychat_image
case 'slack':
return slack_image
case 'webpush':
return chrome_image
default:
return wechat_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openNotificationInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<div class="flex items-center">
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
<span class="text-h6">{{ props.notification.name }}</span>
</div>
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="notificationInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="notificationInfo.enabled" label="启用通知" />
</VCol>
<VCol cols="12">
<VSelect
v-model="notificationInfo.switchs"
:items="notificationTypes"
label="消息类型"
hint="开启通知的消息类型"
multiple
clearable
chips
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'wechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
label="企业ID"
hint="企业微信后台企业信息中的企业ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
label="应用 AgentId"
hint="企业微信自建应用的AgentId"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
label="应用 Secret"
hint="企业微信自建应用的Secret"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_PROXY"
label="代理地址"
hint="微信消息的转发代理地址2022年6月20日后创建的自建应用才需要不使用代理时需要保留默认值"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_TOKEN"
label="Token"
hint="微信企业自建应用->API接收消息配置中的Token"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
label="EncodingAESKey"
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'telegram'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_TOKEN"
label="Bot Token"
hint="Telegram机器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
label="Chat ID"
hint="接受消息通知的用户、群组或频道Chat ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_USERS"
label="用户白名单"
placeholder="多个用,分隔"
hint="可使用Telegram机器人的用户ID清单多个用户用,分隔,不填写则所有用户都能使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'slack'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
label="Slack Bot User OAuth Token"
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_APP_TOKEN"
label="Slack App-Level Token"
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_CHANNEL"
label="频道名称"
placeholder="全体"
hint="消息发送频道,默认`全体`"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
label="机器人传入URL"
hint="Synology Chat机器人传入URL"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
label="令牌"
hint="Synology Chat机器人令牌"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'vocechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_HOST"
label="地址"
hint="VoceChat服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_API_KEY"
label="机器人密钥"
hint="VoceChat机器人密钥"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
label="频道ID"
placeholder="不包含#号"
hint="VoceChat的频道ID不包含#号"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WEBPUSH_USERNAME"
label="登录用户名"
hint="只有对应的用户登录后才会推送消息"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -9,9 +9,6 @@ const personProps = defineProps({
height: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 当前人物
const personInfo = ref(personProps.person)
@@ -20,26 +17,22 @@ const isImageLoaded = ref(false)
// 人物图片地址
function getPersonImage() {
let url = ''
if (personProps.person?.source === 'themoviedb') {
if (!personInfo.value?.profile_path) return personIcon
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
} else if (personProps.person?.source === 'douban') {
if (!personInfo.value?.avatar) return personIcon
if (typeof personInfo.value?.avatar === 'object') {
url = personInfo.value?.avatar?.normal
return personInfo.value?.avatar?.normal
} else {
url = personInfo.value?.avatar
return personInfo.value?.avatar
}
} else if (personProps.person?.source === 'bangumi') {
if (!personInfo.value?.images) return personIcon
url = personInfo.value?.images?.medium
return personInfo.value?.images?.medium
} else {
return personIcon
}
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}
// 人物姓名

View File

@@ -43,8 +43,11 @@ const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 插件详情弹窗
const detailDialog = ref(false)
// 计算插件标签
const pluginLabels = computed(() => {
if (!props.plugin?.plugin_label) return []
return props.plugin.plugin_label.split(',')
})
// 图片加载完成
async function imageLoaded() {
@@ -73,7 +76,7 @@ async function installPlugin() {
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
detailDialog.value = false
// 通知父组件刷新
emit('install')
} else {
@@ -146,141 +149,80 @@ const dropdownItems = ref([
</script>
<template>
<div>
<VCard :width="props.width" :height="props.height" @click="detailDialog = true" class="flex flex-col h-full">
<VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
<div class="me-n3 absolute bottom-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ...">
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" 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>
<div class="me-n3 absolute bottom-1 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</VCardText>
</VCard>
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
</VDialog>
<!-- 插件详情-->
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VCard>
<DialogCloseBtn @click="detailDialog = false" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.plugin?.plugin_name }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">版本</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">作者</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
</VListItemTitle>
</VListItem>
</VList>
<div class="text-center text-md-left">
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download"> 安装到本地 </VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" /> {{ props.count?.toLocaleString() }} 次下载
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
{{ props.plugin?.plugin_desc }}
</VCardText>
</VCard>
</VDialog>
</div>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" 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>
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
</VDialog>
</template>

View File

@@ -1,15 +1,21 @@
<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'
import PageRender from '@/components/render/PageRender.vue'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import store from '@/store'
import { useDisplay } from 'vuetify'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
@@ -41,20 +47,23 @@ const isVisible = ref(true)
// 插件配置页面
const pluginConfigDialog = ref(false)
// 菜单显示状态
const menuVisible = ref(false)
// 插件配置表单数据
const pluginConfigForm = ref({})
// 进度框
const progressDialog = ref(false)
// 插件表单配置项
let pluginFormItems = reactive([])
// 插件数据页面
const pluginInfoDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
// 用户头像是否加载完成
const isAvatarLoaded = ref(false)
// 插件数据页面配置项
let pluginPageItems = ref([])
// 图片是否加载完成
const isImageLoaded = ref(false)
@@ -123,14 +132,75 @@ async function uninstallPlugin() {
}
}
// 调用API读取表单页面
async function loadPluginForm() {
try {
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (result) {
pluginFormItems = result.conf
if (result.model) pluginConfigForm.value = result.model
}
} catch (error) {
console.error(error)
}
}
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) pluginPageItems.value = result
} catch (error) {
console.error(error)
}
}
// 调用API读取配置数据
async function loadPluginConf() {
try {
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
} catch (error) {
console.error(error)
}
}
// 调用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) {
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}}`)
}
} catch (error) {
console.error(error)
}
}
// 显示插件数据
async function showPluginInfo() {
// 加载数据
await loadPluginPage()
pluginConfigDialog.value = false
pluginInfoDialog.value = true
}
// 显示插件配置
async function showPluginConfig() {
// 加载表单
await loadPluginForm()
// 加载配置
await loadPluginConf()
// 显示对话框
pluginInfoDialog.value = false
pluginConfigDialog.value = true
@@ -146,19 +216,11 @@ const iconPath: Ref<string> = computed(() => {
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 插件作者头像路径
const authorPath: Ref<string> = computed(() => {
// 网络图片则使用代理后返回
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.author_url + '.png',
)}`
})
// 重置插件
async function resetPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `此操作将恢复插件 ${props.plugin?.plugin_name}默认设置,并清除所有相关数据,确定要继续吗?`,
content: `是否确认重置插件 ${props.plugin?.plugin_name}配置数据?`,
})
if (!isConfirmed) return
@@ -215,9 +277,10 @@ function visitAuthorPage() {
// 查看日志URL
function openLoggerWindow() {
const token = store.state.auth.token
const url = `${
import.meta.env.VITE_API_BASE_URL
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
@@ -227,12 +290,6 @@ function openPluginDetail() {
else showPluginConfig()
}
// 配置完成
function configDone() {
pluginConfigDialog.value = false
emit('save')
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -324,136 +381,128 @@ watch(
</script>
<template>
<div>
<!-- 插件卡片 -->
<VHover>
<template #default="hover">
<VCard
v-if="isVisible"
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="openPluginDetail"
class="flex flex-col h-full"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
/>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VBadge v-if="props.plugin?.state" dot inline color="success" />
{{ props.plugin?.plugin_name }}
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
</VCardTitle>
<VCardText class="px-2 py-0 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span class="author-info">
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
</VImg>
<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>
<div class="me-n3 absolute bottom-1 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</VCardText>
<div v-if="hover.isHovering" class="me-n3 absolute top-0 right-5">
<VIcon class="cursor-move text-white">mdi-drag</VIcon>
</div>
<div v-else-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard>
</template>
</VHover>
<!-- 插件卡片 -->
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
<div class="me-n3 absolute bottom-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
/>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VBadge v-if="props.plugin?.state" dot inline color="success" />
{{ props.plugin?.plugin_name }}
<span class="text-sm mt-1 text-gray-200">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="px-2 py-1 text-white text-shadow line-clamp-3">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" 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>
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard>
<!-- 插件配置页面 -->
<PluginConfigDialog
v-if="pluginConfigDialog"
v-model="pluginConfigDialog"
:plugin="props.plugin"
@save="configDone"
@close="pluginConfigDialog = false"
@switch="showPluginInfo"
/>
<!-- 插件配置页面 -->
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="pluginConfigDialog" />
<VDivider />
<VCardText>
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
查看数据
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 插件数据页面 -->
<PluginDataDialog
v-if="pluginInfoDialog"
v-model="pluginInfoDialog"
:plugin="props.plugin"
@close="pluginInfoDialog = false"
@switch="showPluginConfig"
/>
<!-- 插件数据页面 -->
<VDialog v-model="pluginInfoDialog" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<DialogCloseBtn v-model="pluginInfoDialog" />
<VCardText class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
</VCardText>
<VFab icon="mdi-cog" location="bottom" size="x-large" fixed app appear @click="showPluginConfig" />
</VCard>
</VDialog>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<VDivider />
<VCardItem>
<VBtn @click="updatePlugin" block>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
更新到最新版本
</VBtn>
</VCardItem>
</VCard>
</VDialog>
</div>
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<VDivider />
<VCardText>
<VBtn @click="updatePlugin" block>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
更新到最新版本
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
@@ -466,17 +515,4 @@ watch(
content: '';
inset: 0;
}
.author-info {
display: flex;
align-items: center;
}
.author-avatar {
border-radius: 50%;
block-size: 24px;
inline-size: 24px;
margin-inline-end: 8px;
object-fit: cover;
}
</style>

View File

@@ -31,7 +31,7 @@ const getImgUrl = computed(() => {
})
// 跳转播放
function goPlay(isHovering: boolean | null = false) {
function goPlay(isHovering = false) {
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
}
</script>
@@ -48,11 +48,13 @@ function goPlay(isHovering: boolean | null = false) {
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goPlay(hover.isHovering)"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
@@ -76,9 +78,7 @@ function goPlay(isHovering: boolean | null = false) {
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
@click.stop="goPlay(hover.isHovering)"
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?.subtitle }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
@@ -89,3 +89,9 @@ function goPlay(isHovering: boolean | null = false) {
</template>
</VHover>
</template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

View File

@@ -2,23 +2,30 @@
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
import SiteTorrentTable from '../table/SiteTorrentTable.vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import type { Site, SiteStatistic } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatFileSize } from '@/@core/utils/formatters'
import { useDisplay } from 'vuetify'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const cardProps = defineProps({
site: Object as PropType<Site>,
data: Object as PropType<SiteUserData>,
width: String,
height: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['update', 'remove'])
// 密码输入
const isPasswordVisible = ref(false)
// 图标
const siteIcon = ref<string>('')
@@ -26,11 +33,14 @@ const siteIcon = ref<string>('')
const $toast = useToast()
// 测试按钮文字
const testButtonText = ref('测试连通性')
const testButtonText = ref('测试')
// 测试按钮可用性
const testButtonDisable = ref(false)
// 更新按钮可用性
const updateButtonDisable = ref(false)
// 更新站点Cookie UA弹窗
const siteCookieDialog = ref(false)
@@ -40,8 +50,18 @@ const siteEditDialog = ref(false)
// 资源浏览弹窗
const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 进度条
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 用户名密码表单
const userPwForm = ref({
username: '',
password: '',
code: '',
})
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
@@ -65,7 +85,7 @@ async function testSite() {
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
testButtonText.value = '测试连通性'
testButtonText.value = '测试'
testButtonDisable.value = false
getSiteStats()
@@ -93,9 +113,34 @@ async function handleResourceBrowse() {
resourceDialog.value = true
}
// 打开站点用户数据弹窗
async function handleSiteUserData() {
siteUserDataDialog.value = true
// 调用API更新站点Cookie UA
async function updateSiteCookie() {
try {
if (!userPwForm.value.username || !userPwForm.value.password) return
// 更新按钮状态
siteCookieDialog.value = false
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
})
if (result.success) $toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
progressDialog.value = false
updateButtonDisable.value = false
} catch (error) {
console.error(error)
}
}
// 打开站点页面
@@ -117,10 +162,9 @@ const statColor = computed(() => {
}
})
// 计算上传量和下载量的百分比
const getPercentage = computed(() => {
if (cardProps.data?.upload === 0) return 100
return ((cardProps.data?.download ?? 0) / ((cardProps.data?.download ?? 0) + (cardProps.data?.upload ?? 0))) * 100
// 监听resourceDialog如果为false则重新查询站点使用统计
watch(resourceDialog, value => {
if (!value) getSiteStats()
})
// 保存站点
@@ -129,18 +173,6 @@ function saveSite() {
emit('update')
}
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
getSiteStats()
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
getSiteStats()
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
@@ -151,25 +183,26 @@ onMounted(() => {
<template>
<div>
<VCard
:height="cardProps.height"
:width="cardProps.width"
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden h-full flex flex-col"
@click="handleResourceBrowse"
:ripple="false"
class="overflow-hidden"
@click="siteEditDialog = true"
>
<template #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem style="padding-block-end: 0">
<VCardItem style="padding-block-end: 0;">
<VCardTitle class="font-bold">
{{ cardProps.site?.name }}
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
</VCardTitle>
<VCardSubtitle>
{{ cardProps.site?.url }}
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
</VCardSubtitle>
</VCardItem>
<VCardText class="py-1">
<VCardText class="py-2" style="block-size: 36px;">
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
@@ -191,65 +224,67 @@ onMounted(() => {
</template>
</VTooltip>
</VCardText>
<VDivider />
<VCardActions>
<div class="text-sm">
{{ formatFileSize(cardProps.data?.upload || 0) }} / {{ formatFileSize(cardProps.data?.download || 0) }}
</div>
<IconBtn>
<VIcon icon="mdi-chevron-down" color="primary" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="siteEditDialog = true" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" />
</template>
<VListItemTitle>编辑站点</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="handleSiteUserData">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
</template>
<VListItemTitle>查看站点数据</VListItemTitle>
</VListItem>
<VListItem variant="plain" :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
<VListItemTitle>{{ testButtonText }}</VListItemTitle>
</VListItem>
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
<VListItemTitle>更新 Cookie & UA</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="openSitePage">
<template #prepend>
<VIcon icon="mdi-open-in-new" />
</template>
<VListItemTitle>访问站点</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
<VSpacer />
<VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
更新
</VBtn>
<VBtn :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
{{ testButtonText }}
</VBtn>
<VBtn @click.stop="handleResourceBrowse">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
浏览
</VBtn>
</VCardActions>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<span class="absolute top-1 right-8">
<VIcon class="cursor-move">mdi-drag</VIcon>
</span>
<div class="w-full absolute bottom-0" v-if="(cardProps.data?.upload || cardProps.data?.download || 0) > 0">
<VProgressLinear :model-value="getPercentage" bg-color="success" color="warning" bg-opacity="0.5" height="3" />
</div>
</VCard>
<!-- 更新站点Cookie & UA弹窗 -->
<SiteCookieUpdateDialog
v-if="siteCookieDialog"
v-model="siteCookieDialog"
:site="cardProps.site"
@close="siteCookieDialog = false"
@done="onSiteCookieUpdated"
/>
<VDialog v-model="siteCookieDialog" max-width="50rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="siteCookieDialog = false" />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="userPwForm.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="updateSiteCookie" prepend-icon="mdi-refresh" class="px-5"> 开始更新 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 站点编辑弹窗 -->
<SiteAddEditDialog
v-if="siteEditDialog"
@@ -259,19 +294,30 @@ onMounted(() => {
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<!-- 站点数据弹窗 -->
<SiteUserDataDialog
v-if="siteUserDataDialog"
v-model="siteUserDataDialog"
:site="cardProps.site"
@close="siteUserDataDialog = false"
/>
<!-- 站点资源弹窗 -->
<SiteResourceDialog
<VDialog
v-if="resourceDialog"
v-model="resourceDialog"
:site="cardProps.site"
@close="onSiteResourceDone"
/>
max-width="80rem"
scrollable
z-index="1010"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
<DialogCloseBtn @click="resourceDialog = false" />
<VDivider />
<VCardText class="pt-2">
<SiteTorrentTable :site="cardProps.site?.id" />
</VCardText>
</VCard>
</VDialog>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</div>
</template>
<style lang="scss" scoped>
.v-table th {
white-space: nowrap;
}
</style>

View File

@@ -1,172 +0,0 @@
<script setup lang="ts">
import { StorageConf } from '@/api/types'
import { formatBytes } from '@core/utils/formatters'
import storage_png from '@images/misc/storage.png'
import alipan_png from '@images/misc/alipan.webp'
import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/alist.svg'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import { useToast } from 'vue-toast-notification'
import { isNullOrEmptyObject } from '@/@core/utils'
// 定义输入
const props = defineProps({
storage: {
type: Object as PropType<StorageConf>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done'])
// 提示信息
const $toast = useToast()
// 存储总空间
const total = ref(0)
// 存储可用空间
const available = ref(0)
// 储存已用空间
const used = computed(() => {
return total.value - available.value
})
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// Rclone配置对话框
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// 打开存储对话框
function openStorageDialog() {
switch (props.storage.type) {
case 'alipan':
aliyunAuthDialog.value = true
break
case 'u115':
u115AuthDialog.value = true
break
case 'rclone':
rcloneConfigDialog.value = true
break
case 'alist':
aListConfigDialog.value = true
break
default:
$toast.info('此存储类型无需配置参数,请直接配置目录!')
break
}
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.storage.type) {
case 'local':
return storage_png
case 'alipan':
return alipan_png
case 'u115':
return u115_png
case 'rclone':
return rclone_png
case 'alist':
return alist_png
default:
return storage_png
}
})
// 计算进度条颜色
const progressColor = computed(() => {
if (usage.value > 90) {
return 'error'
} else if (usage.value > 70) {
return 'warning'
} else {
return 'success'
}
})
// 计算存储使用率
const usage = computed(() => {
return Math.round((used.value / (total.value || 1)) * 1000) / 10
})
// 查询存储信息
async function queryStorage() {
try {
const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)
total.value = data.total
available.value = data.available
} catch (error) {
console.error(error)
}
}
// 完成配置后的处理
function handleDone() {
aliyunAuthDialog.value = false
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
emit('done')
}
onMounted(() => {
queryStorage()
})
</script>
<template>
<VCard variant="tonal" @click="openStorageDialog">
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
</div>
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
:conf="props.storage.config || {}"
@close="aliyunAuthDialog = false"
@done="handleDone"
/>
<U115AuthDialog
v-if="u115AuthDialog"
v-model="u115AuthDialog"
:conf="props.storage.config || {}"
@close="u115AuthDialog = false"
@done="handleDone"
/>
<RcloneConfigDialog
v-if="rcloneConfigDialog"
v-model="rcloneConfigDialog"
:conf="props.storage.config || {}"
@close="rcloneConfigDialog = false"
@done="handleDone"
/>
<AlistConfigDialog
v-if="aListConfigDialog"
v-model="aListConfigDialog"
:conf="props.storage.config || {}"
@close="aListConfigDialog = false"
@done="handleDone"
/>
</template>

View File

@@ -2,9 +2,8 @@
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
import { formatDateDifference } from '@/@core/utils/formatters'
import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
@@ -14,9 +13,6 @@ const props = defineProps({
media: Object as PropType<Subscribe>,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
@@ -32,23 +28,21 @@ const imageLoaded = ref(false)
// 订阅弹窗
const subscribeEditDialog = ref(false)
// 订阅文件信息弹窗
const subscribeFilesDialog = ref(false)
// 分享订阅弹窗
const subscribeShareDialog = ref(false)
// 当前的订阅状态
const subscribeState = ref<string>(props.media?.state ?? 'P')
// 上一次更新时间
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
const lastUpdateText = ref(props.media && props.media.last_update ? formatDateDifference(props.media.last_update) : '')
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 根据 type 返回不同的图标
function getIcon() {
if (props.media?.type === '电影') return 'mdi-movie-open'
else if (props.media?.type === '电视剧') return 'mdi-television-play'
else return 'mdi-help-circle'
}
// 计算百分比
function getPercentage() {
if (props.media?.total_episode === 0) return 0
@@ -84,39 +78,13 @@ async function searchSubscribe() {
}
}
// 切换订阅状态
async function toggleSubscribeStatus(state: 'R' | 'S') {
try {
// 根据传入的 state 判断对应的操作文字
const action = state === 'S' ? '暂停' : '启用'
// 弹出确认框
const isConfirmed = await createConfirm({
title: `确认${action}`,
content: `是否${action}订阅 ${props.media?.name}`,
})
if (!isConfirmed) return
// 调用 API 更新订阅状态
const result: { [key: string]: any } = await api.put(`subscribe/status/${props.media?.id}?state=${state}`)
// 提示
if (result.success) {
$toast.success(`${props.media?.name}${action}`)
subscribeState.value = state
emit('save')
} else {
$toast.error(`${action}失败:${result.message}`)
}
} catch (e) {
console.log(e)
}
}
// 重置订阅
async function resetSubscribe() {
// 确认
try {
const isConfirmed = await createConfirm({
title: '确认',
content: `重置后 ${props.media?.name} 将恢复初始状态,已下载记录将被清除,未入库的内容将会重新下载,是否确认?`,
content: `重置后 ${props.media?.name} 已下载记录将被清除,未入库的剧集将会重新下载,是否确认?`,
})
if (!isConfirmed) return
// 重置
@@ -124,7 +92,6 @@ async function resetSubscribe() {
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 重置成功!`)
subscribeState.value = 'R'
emit('save')
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
} catch (e) {
@@ -132,44 +99,24 @@ async function resetSubscribe() {
}
}
// 分享订阅
async function shareSubscribe() {
subscribeShareDialog.value = true
}
// 编辑订阅响应
async function editSubscribeDialog() {
subscribeEditDialog.value = true
}
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
else return props.media?.mediaid
}
// 查看媒体详情
// 查看详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
type: props.media?.type,
},
})
}
// 查看文件详情
async function viewSubscribeFiles() {
subscribeFilesDialog.value = true
}
// 弹出菜单
const dropdownItems = computed(() => [
const dropdownItems = ref([
{
title: '编辑',
value: 1,
@@ -187,52 +134,26 @@ const dropdownItems = computed(() => [
},
},
{
title: '详情',
title: '查看详情',
value: 3,
props: {
prependIcon: 'mdi-information-outline',
prependIcon: 'mdi-open-in-new',
click: viewMediaDetail,
},
},
{
title: '文件',
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
},
{
title: subscribeState.value === 'S' ? '启用' : '暂停',
value: 5,
props: {
prependIcon: subscribeState.value === 'S' ? 'mdi-play' : 'mdi-pause',
click: () => toggleSubscribeStatus(subscribeState.value === 'S' ? 'R' : 'S'),
color: subscribeState.value === 'S' ? 'success' : 'info',
},
},
{
title: '重置',
value: 6,
value: 4,
props: {
prependIcon: 'mdi-restore-alert',
click: resetSubscribe,
color: 'warning',
},
},
{
title: '分享',
value: 7,
props: {
prependIcon: 'mdi-share',
click: shareSubscribe,
color: 'success',
},
show: props.media?.type === '电视剧',
},
{
title: '取消订阅',
value: 8,
value: 5,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
@@ -248,178 +169,133 @@ watch(
if (newOpenState) editSubscribeDialog()
},
)
// 监听订阅状态
watch(
() => props.media?.state,
newState => {
subscribeState.value = newState ?? 'P'
},
)
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 订阅编辑保存
function onSubscribeEditSave() {
subscribeEditDialog.value = false
emit('save')
}
// 订阅编辑取消
function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
</script>
<template>
<div>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg h-full"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'opacity-70': subscribeState === 'S',
}"
min-height="170"
@click="editSubscribeDialog"
:ripple="false"
>
<div class="me-n3 absolute top-1 right-2">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
<div v-if="subscribeState === 'P'" class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none" />
</template>
<div>
<VCardText class="flex items-center">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="editSubscribeDialog"
>
<div class="me-n3 absolute top-1 right-2">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
variant="plain"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image>
<VImg
:src="props.media?.backdrop || props.media?.poster"
aspect-ratio="3/2"
cover
@load="imageLoadHandler"
position="top"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div>
<VCardText class="flex items-center">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="props.media?.poster" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-download"
color="white"
class="me-1"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-download"
color="white"
class="me-1"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
{{ props.media?.username }}
</span>
</div>
<div v-if="hover.isHovering" class="me-n3 absolute top-1 right-10">
<IconBtn><VIcon class="cursor-move text-white">mdi-drag</VIcon></IconBtn>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="onSubscribeEditRemove"
@save="onSubscribeEditSave"
@close="subscribeEditDialog = false"
/>
<!-- 订阅文件信息弹窗 -->
<SubscribeFilesDialog
v-if="subscribeFilesDialog"
v-model="subscribeFilesDialog"
:subid="props.media?.id"
@close="subscribeFilesDialog = false"
/>
<!-- 分享订阅弹窗 -->
<SubscribeShareDialog
v-if="subscribeShareDialog"
v-model="subscribeShareDialog"
:sub="props.media"
@close="subscribeShareDialog = false"
/>
</div>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="
() => {
emit('remove')
subscribeEditDialog = false
}
"
@save="
() => {
emit('save')
subscribeEditDialog = false
}
"
@close="subscribeEditDialog = false"
/>
</template>
<style lang="scss">
.subscribe-card-background {

View File

@@ -1,184 +0,0 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
// 输入参数
const props = defineProps({
media: Object as PropType<SubscribeShare>,
})
// 定义删除事件
const emit = defineEmits(['delete'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 图片是否加载完成
const imageLoaded = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 复用订阅弹窗
const forkSubscribeDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 分享时间
const dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
}
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})
}
// 复用订阅
function showForkSubscribe() {
forkSubscribeDialog.value = true
}
// 完成复用订阅
function finishForkSubscribe(subid: number) {
subscribeId.value = subid
forkSubscribeDialog.value = false
subscribeEditDialog.value = true
}
// 删除订阅分享时处理
function doDelete() {
forkSubscribeDialog.value = false
// 通知父组件刷新
emit('delete')
}
</script>
<template>
<div class="h-full">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg h-full"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="showForkSubscribe"
>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pb-1 grow">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center pl-2 xl:pl-4">
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.share_title }}
</div>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
<div class="text-subtitle-2 me-4 text-white">
{{ props.media?.share_user }}
</div>
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
{{ props.media?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="subscribeEditDialog = false"
/>
<!-- 复用订阅弹窗 -->
<ForkSubscribeDialog
v-if="forkSubscribeDialog"
v-model="forkSubscribeDialog"
:media="props.media"
@close="forkSubscribeDialog = false"
@fork="finishForkSubscribe"
@delete="doDelete"
/>
</div>
</template>
<style lang="scss">
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
</style>

View File

@@ -1,10 +1,11 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
// 输入参数
const props = defineProps({
@@ -14,6 +15,12 @@ const props = defineProps({
height: String,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 更多来源界面
const showMoreTorrents = ref(false)
@@ -26,29 +33,11 @@ const media = ref(props.torrent?.media_info)
// 识别元数据
const meta = ref(props.torrent?.meta_info)
// 当前下载项
const downloadItem = ref(props.torrent)
// 站点图标
const siteIcon = ref('')
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
const downloaded = ref<String[]>([])
// 查询站点图标
async function getSiteIcon() {
@@ -60,12 +49,50 @@ async function getSiteIcon() {
}
// 询问并添加下载
async function handleAddDownload(item: Context | null = null) {
if (item && !isNullOrEmptyObject(item)) {
downloadItem.value = item
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name
_media = media.value
_torrent = torrent.value
}
// 打开下载对话框
addDownloadDialog.value = true
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`,
})
if (!isConfirmed) return
addDownload(_media, _torrent)
}
// 添加下载
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
let result: { [key: string]: any }
if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 打开种子详情页面
@@ -93,137 +120,127 @@ onMounted(() => {
</script>
<template>
<div>
<VCard
:width="props.width"
:height="props.height"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
@click="handleAddDownload(props.torrent)"
>
<template v-if="!showMoreTorrents" #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VCardTitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VCardItem>
<VCardText class="text-subtitle-2">
{{ torrent?.title }}
</VCardText>
<VCardText>{{ torrent?.site_name }}{{ torrent?.description }}</VCardText>
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</VCardItem>
<VCardActions>
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
<template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template>
更多来源
</VBtn>
</VCardActions>
<VExpandTransition>
<div v-show="showMoreTorrents">
<VDivider />
<VChipGroup class="p-3" column>
<VChip v-for="(item, index) in props.more" :key="index" @click.stop="handleAddDownload(item)">
<template #append>
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
<VBadge
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:content="item.torrent_info?.volume_factor"
inline
size="small"
/>
</template>
{{ item.torrent_info.site_name }}
</VChip>
</VChipGroup>
<VCard
:width="props.width"
:height="props.height"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
@click="handleAddDownload"
>
<template v-if="!showMoreTorrents" #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VCardTitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</VExpandTransition>
</VCard>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${
downloadItem?.meta_info?.season_episode
}`"
:media="downloadItem?.media_info"
:torrent="downloadItem?.torrent_info"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>
</VCardItem>
<VCardText class="text-subtitle-2">
{{ torrent?.title }}
</VCardText>
<VCardText>{{ torrent?.description }}</VCardText>
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</VCardItem>
<VCardActions>
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
<template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template>
更多来源
</VBtn>
</VCardActions>
<VExpandTransition>
<div v-show="showMoreTorrents">
<VDivider />
<VChipGroup class="p-3" column>
<VChip
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item.torrent_info?.site_name, item.media_info, item.torrent_info)"
>
<template #append>
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
<VBadge
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:content="item.torrent_info?.volume_factor"
inline
size="small"
/>
</template>
{{ item.torrent_info.site_name }}
</VChip>
</VChipGroup>
</div>
</VExpandTransition>
</VCard>
</template>

View File

@@ -1,15 +1,23 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
// 输入参数
const props = defineProps({
torrent: Object as PropType<Context>,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 更多来源界面
const showMoreTorrents = ref(false)
@@ -26,10 +34,7 @@ const meta = ref(props.torrent?.meta_info)
const siteIcon = ref('')
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
// 添加下载对话框
const addDownloadDialog = ref(false)
const downloaded = ref<String[]>([])
// 查询站点图标
async function getSiteIcon() {
@@ -41,21 +46,50 @@ async function getSiteIcon() {
}
// 询问并添加下载
async function handleAddDownload() {
// 打开下载对话框
addDownloadDialog.value = true
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name
_media = media.value
_torrent = torrent.value
}
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`,
})
if (!isConfirmed) return
addDownload(_media, _torrent)
}
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
}
// 添加下载
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
let result: { [key: string]: any }
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 打开种子详情页面
@@ -83,101 +117,88 @@ onMounted(() => {
</script>
<template>
<div>
<VListItem
@click="handleAddDownload"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
>
<template v-if="!showMoreTorrents" #prepend>
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
<VListItemSubtitle> {{ torrent?.site_name }}{{ torrent?.description }} </VListItemSubtitle>
<div v-if="torrent?.labels" class="pt-2">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
<VListItem @click="handleAddDownload" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'">
<template v-if="!showMoreTorrents" #prepend>
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
<VListItemSubtitle>
{{ torrent?.description }}
</VListItemSubtitle>
<div v-if="torrent?.labels" class="pt-2">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</div>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
:media="media"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>
</VListItem>
</template>

View File

@@ -1,185 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { Subscribe, User } from '@/api/types'
import { useUserStore } from '@/stores'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
// 定义输入变量
const props = defineProps({
// 用户信息
user: {
type: Object as PropType<User>,
required: true,
},
// 所有用户
users: {
type: Array as PropType<User[]>,
required: true,
},
})
// 当前用户的ID
const currentLoginUserId = computed(() => useUserStore().userID)
// 当前用户是否是管理员
const currentUserIsSuperuser = computed(() => useUserStore().superUser)
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
// 确认框
const createConfirm = useConfirm()
// 用户信息弹窗
const userEditDialog = ref(false)
// 提示框
const $toast = useToast()
// 用户电影订阅数量
const movieSubscriptions = ref(0)
// 用户电视剧订阅数量
const tvShowSubscriptions = ref(0)
// 按用户查询订阅数量
async function fetchSubscriptions() {
try {
const result: Subscribe[] = await api.get(`subscribe/user/${props.user.name}`)
if (result) {
movieSubscriptions.value = result.filter(item => item.type === '电影').length
tvShowSubscriptions.value = result.filter(item => item.type === '电视剧').length
}
} catch (error) {
console.log(error)
}
}
// 删除用户
async function removeUser() {
if (props.user.id === currentLoginUserId.value) {
$toast.error('不能删除当前登录用户!')
return
}
try {
const isConfirmed = await createConfirm({
title: '注意',
content: `删除用户 ${props.user?.name} 的所有数据,是否确认?`,
})
if (!isConfirmed) return
const result: { [key: string]: any } = await api.delete(`user/id/${props.user.id}`)
if (result.success) {
$toast.success('用户删除成功')
emit('remove')
} else {
$toast.error('用户删除失败!')
}
} catch (error) {
console.log(error)
}
}
// 编辑用户
function editUser() {
userEditDialog.value = true
}
// 用户重新完成时
function onUserUpdate() {
userEditDialog.value = false
emit('save')
}
onMounted(() => {
fetchSubscriptions()
})
</script>
<template>
<VCard>
<VCardText class="text-center pt-10 pb-3">
<VAvatar variant="flat" size="100" rounded>
<VImg :src="user.avatar || avatar1" alt="avatar" />
</VAvatar>
<h5 class="text-h5 mt-3">{{ user.name }}</h5>
<VChip size="small" class="mt-3" :class="{ 'text-error': user.is_superuser }">
{{ user.is_superuser ? '管理员' : '普通用户' }}
</VChip>
</VCardText>
<VCardText class="flex justify-center gap-6 pb-5">
<div class="d-flex align-center">
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
<VIcon size="24" icon="mdi-movie-open-outline"></VIcon>
</VAvatar>
<div>
<div class="text-h6">{{ movieSubscriptions }}</div>
<div class="text-sm text-no-wrap">电影订阅</div>
</div>
</div>
<div class="d-flex align-center">
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
<VIcon size="24" icon="mdi-television"></VIcon>
</VAvatar>
<div>
<div class="text-h6">{{ tvShowSubscriptions }}</div>
<div class="text-sm text-no-wrap">电视剧订阅</div>
</div>
</div>
</VCardText>
<VCardText class="pb-6">
<VDivider class="my-2">
<h5 class="text-h6">详情</h5>
</VDivider>
<VList lines="one">
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">邮箱</span><span class="text-body-1"> {{ user.email }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">状态</span
><span class="text-body-1">
<VChip size="small" :class="{ 'text-success': user.is_active }" variant="tonal">
{{ user.is_active ? '激活' : '已停用' }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">双重认证</span
><span class="text-body-1">
<VChip size="small" :class="{ 'text-success': user.is_otp }" variant="tonal">
{{ user.is_otp ? '已启用' : '未启用' }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
<VCardText class="flex flex-row justify-center">
<VBtn v-if="currentUserIsSuperuser" color="primary" class="me-4" @click="editUser"> 编辑 </VBtn>
<VBtn
v-if="currentUserIsSuperuser && props.user.id != currentLoginUserId"
color="error"
variant="outlined"
@click="removeUser"
>
删除
</VBtn>
</VCardText>
</VCard>
<!-- 用户编辑弹窗 -->
<UserAddEditDialog
v-if="userEditDialog"
v-model="userEditDialog"
:username="props.user?.name"
:usernames="props.users.map(item => item.name)"
oper="edit"
@save="onUserUpdate"
@close="userEditDialog = false"
/>
</template>

View File

@@ -1,266 +0,0 @@
<script lang="ts" setup>
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
import api from '@/api'
// 定义输入参数
const props = defineProps({
workflow: {
required: true,
type: Object as PropType<Workflow>,
},
})
// 定义事件
const emit = defineEmits(['refresh'])
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 编辑对话框
const editDialog = ref(false)
// 流程对话框
const flowDialog = ref(false)
// 加载中
const loading = ref(false)
// 编辑任务
function handleEdit(item: Workflow) {
editDialog.value = true
}
// 编辑流程
function handleFlow(item: Workflow) {
flowDialog.value = true
}
// 计算已完成的动作数
function resolveDoneActions(item: Workflow) {
return item.current_action?.split(',').length || 0
}
// 编辑完成
function editDone() {
editDialog.value = false
flowDialog.value = false
emit('refresh')
}
// 删除任务
async function handleDelete(item: Workflow) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除任务 ${item.name} ?`,
})
if (!isConfirmed) return
try {
const result: { [key: string]: string } = await api.delete(`workflow/${item.id}`)
if (result.success) {
$toast.success('删除任务成功!')
emit('refresh')
} else {
$toast.error(`删除任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
}
// 开始任务
async function handleEnable(item: Workflow) {
loading.value = true
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/start`)
if (result.success) {
$toast.success('启用任务成功!')
emit('refresh')
} else {
$toast.error(`启用任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
loading.value = false
}
// 停用任务
async function handlePause(item: Workflow) {
loading.value = true
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/pause`)
if (result.success) {
$toast.success('停用任务成功!')
emit('refresh')
} else {
$toast.error(`停用任务失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
loading.value = false
}
// 立即执行任务
async function handleRun(item: Workflow) {
loading.value = true
try {
setTimeout(() => {
emit('refresh')
}, 500)
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/run`)
if (result.success) {
$toast.success('任务执行完成!')
emit('refresh')
} else {
$toast.error(`任务执行失败:${result.message}`)
emit('refresh')
}
} catch (error) {
console.error(error)
}
loading.value = false
}
// 计算状态颜色
const resolveStatusVariant = (status: string | undefined) => {
if (status === 'S') return { color: 'success', text: '成功' }
else if (status === 'R') return { color: 'primary', text: '运行中' }
else if (status === 'F') return { color: 'error', text: '失败' }
else if (status === 'P') return { color: 'secondary', text: '暂停' }
else return { color: 'info', text: '等待' }
}
// 计算当前动作占比
const resolveProgress = (item: Workflow) => {
const current_action_length = item.current_action?.split(',').length || 0
return item.actions?.length ? Math.round((current_action_length / (item.actions.length || 1)) * 100) : 0
}
</script>
<template>
<div class="h-full">
<VCard class="mx-auto h-full" @click="handleFlow(workflow)" :ripple="false" :loading="loading">
<VCardItem class="py-3" :class="`bg-${resolveStatusVariant(workflow?.state).color}`">
<template #prepend>
<VAvatar variant="text" class="me-2">
<VIcon
v-if="workflow?.state === 'P'"
color="success"
size="x-large"
icon="mdi-play"
@click.stop="handleEnable(workflow)"
/>
<VIcon v-else color="warning" icon="mdi-pause" size="x-large" @click.stop="handlePause(workflow)" />
</VAvatar>
</template>
<VCardTitle class="text-white">
{{ workflow?.name }}
</VCardTitle>
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-vector-polyline-edit" @click.stop="handleFlow(workflow)" />
</IconBtn>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" base-color="primary" @click="handleEdit(workflow)">
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>编辑任务</VListItemTitle>
</VListItem>
<VListItem variant="plain" base-color="info" @click="handleRun(workflow)">
<template #prepend>
<VIcon icon="mdi-run" />
</template>
<VListItemTitle>立即执行</VListItemTitle>
</VListItem>
<VListItem variant="plain" base-color="error" @click="handleDelete(workflow)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除任务</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
</VCardItem>
<VDivider />
<VCardText>
<div class="d-flex flex-column gap-y-4">
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">定时</div>
<h5 class="text-h6">{{ workflow?.timer }}</h5>
</div>
<div class="flex-1">
<div class="mb-1">状态</div>
<h5 class="text-h6" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">动作数</div>
<div>
<VAvatar size="32" color="primary" variant="tonal">
<span class="text-sm">{{ workflow?.actions?.length }}</span>
</VAvatar>
</div>
</div>
<div class="flex-1">
<div class="mb-1">已执行次数</div>
<h5 class="text-h6">{{ workflow?.run_count }}</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">进度</div>
<div class="d-flex align-center gap-5">
<div class="flex-grow-1">
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
</div>
<span> {{ resolveProgress(workflow) }}% </span>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6" v-if="workflow?.result">
<div class="flex-1">
<div class="mb-1">错误信息</div>
<div class="text-error">{{ workflow?.result }}</div>
</div>
</div>
</div>
</VCardText>
</VCard>
<!-- 流程对话框 -->
<WorkflowActionsDialog
v-if="flowDialog"
v-model="flowDialog"
@close="flowDialog = false"
@save="editDone"
:workflow="workflow"
/>
<!-- 编辑对话框 -->
<WorkflowAddEditDialog
v-if="editDialog"
v-model="editDialog"
@close="editDialog = false"
@save="editDone"
:workflow="workflow"
/>
</div>
</template>

View File

@@ -1,197 +0,0 @@
<script setup lang="ts">
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
title: String,
media: Object as PropType<MediaInfo>,
torrent: Object as PropType<TorrentInfo>,
})
// 定义成功和失败事件
const emit = defineEmits(['done', 'error', 'close'])
// 提示框
const $toast = useToast()
// 选择的下载器
const selectedDownloader = ref<string | null>(null)
// 选择的保存目录
const selectedDirectory = ref<string | null>(null)
// 下载器
const downloaders = ref<DownloaderConf[]>([])
// 所有目录设置
const directories = ref<TransferDirectoryConf[]>([])
// 是否正在加载
const loading = ref(false)
// 计算按钮图标
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
// 计算按钮文字
const buttonText = computed(() => (loading.value ? '下载中...' : '开始下载'))
// 加载目录设置
async function loadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
directories.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 获取保存目录
const targetDirectories = computed(() => {
const downloadDirectories = directories.value.map(item => item.download_path)
return [...new Set(downloadDirectories)]
})
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
downloaders.value = await api.get('download/clients')
} catch (error) {
console.log(error)
}
}
// 下载器可选项
const downloaderOptions = computed(() => {
return downloaders.value.map(item => ({
title: item.name,
value: item.name,
}))
})
// 添加下载
async function addDownload() {
startNProgress()
loading.value = true
try {
let result: { [key: string]: any }
const payload: any = {
torrent_in: props.torrent,
downloader: selectedDownloader.value,
save_path: selectedDirectory.value,
}
if (props.media) {
payload.media_in = props.media
}
const endpoint = props.media ? 'download/' : 'download/add'
result = await api.post(endpoint, payload)
if (result && result.success) {
// 添加下载成功
$toast.success(`${props.torrent?.site_name} ${props.torrent?.title} 下载成功!`)
// 下载成功,返回链接
emit('done', props.torrent?.enclosure)
} else {
// 添加下载失败
$toast.error(`${props.torrent?.site_name} ${props.torrent?.title} 下载失败:${result?.message}`)
// 下载失败,返回错误原因
emit('error', result?.message)
}
} catch (error) {
console.error(error)
}
loading.value = false
doneNProgress()
}
onMounted(() => {
loadDirectories()
loadDownloaderSetting()
})
</script>
<template>
<VDialog max-width="45rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle v-if="title">{{ torrent?.site_name }} - {{ title }}</VCardTitle>
<VCardTitle v-else>确认下载</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText>
<VList lines="one">
<VListItem>
<template #prepend>
<VIcon icon="mdi-web"></VIcon>
</template>
<VListItemTitle>
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-1">
<VChip variant="tonal" label>
{{ formatFileSize(torrent?.size || 0) }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
label="指定下载器"
variant="underlined"
placeholder="留空默认"
/>
</VCol>
<VCol cols="12" md="8">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="指定保存目录"
placeholder="留空自动匹配"
variant="underlined"
/>
</VCol>
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn
variant="elevated"
:disabled="loading"
@click="addDownload"
:prepend-icon="icon"
class="px-5"
size="large"
>
{{ buttonText }}
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,60 +0,0 @@
<script lang="ts" setup>
import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
// 完成
async function handleDone() {
await savaAlistConfig()
emit('done')
}
// 保存rclone设置
async function savaAlistConfig() {
try {
await api.post(`storage/save/alist`, props.conf)
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="AList配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.url" hint="AList服务地址" label="地址" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="props.conf.username" hint="AList登录用户名" label="用户名" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="props.conf.password"
hint="AList登录密码"
label="密码"
persistent-hint
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -2,14 +2,6 @@
import QrcodeVue from 'qrcode.vue'
import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -33,17 +25,13 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.refreshToken) {
await savaAliPanConfig()
}
emit('done')
}
// 调用/aliyun/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
const result: { [key: string]: any } = await api.get('/aliyun/qrcode')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
ck.value = result.data.ck
@@ -59,8 +47,11 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/check/alipan', {
params: { ck: ck.value, t: t.value },
const result: { [key: string]: any } = await api.get('/aliyun/check', {
params: {
ck: ck.value,
t: t.value,
},
})
if (result.success && result.data) {
const qrCodeStatus = result.data.qrCodeStatus
@@ -87,15 +78,6 @@ async function checkQrcode() {
}
}
// 保存cookie设置
async function savaAliPanConfig() {
try {
await api.post(`storage/save/alipan`, props.conf)
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
@@ -118,13 +100,6 @@ onUnmounted(() => {
<template #prepend />
</VAlert>
</VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义refreshToken" v-model="props.conf.refreshToken" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>

View File

@@ -1,277 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { VBtn } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
media: Object as PropType<SubscribeShare>,
})
// 定义事件
const emit = defineEmits(['fork', 'delete', 'close'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 提示框
const $toast = useToast()
// 处理中
const processing = ref(false)
// 删除中
const deleting = ref(false)
// 是否折叠
const isExpanded = ref(false)
// follow用户列表
const followUsers = ref<string[]>([])
// 当前用户是否已follow
const isFollowed = computed(() => followUsers.value.includes(props.media?.share_uid || ''))
// 折叠展开
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
// 加载follow用户列表
async function queryFollowUsers() {
try {
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
followUsers.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// follow用户
async function followUser() {
try {
const result: { [key: string]: any } = await api.post(`subscribe/follow?share_uid=${props.media?.share_uid}`)
if (result.success) {
queryFollowUsers()
}
} catch (error) {
console.log(error)
}
}
// unfollow用户
async function unfollowUser() {
try {
const result: { [key: string]: any } = await api.delete('subscribe/follow', {
params: {
share_uid: props.media?.share_uid,
},
})
if (result.success) {
queryFollowUsers()
}
} catch (error) {
console.log(error)
}
}
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 获得mediaid
function getMediaId() {
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
}
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.name,
year: props.media?.year,
type: props.media?.type,
},
})
}
// 复用订阅
async function doFork() {
// 开始处理
startNProgress()
try {
processing.value = true
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
// 完成
emit('fork', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}`)
}
} catch (error) {
console.error(error)
} finally {
processing.value = false
doneNProgress()
}
}
// 删除订阅分享
async function doDelete() {
// 开始处理
startNProgress()
try {
deleting.value = true
// 请求API
const result: { [key: string]: any } = await api.delete(`subscribe/share/${props.media?.id}`, {
params: {
share_uid: globalSettings.USER_UNIQUE_ID,
},
})
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 取消分享成功!`)
// 完成
emit('delete', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 取消分享失败:${result.message}`)
}
} catch (error) {
console.error(error)
} finally {
deleting.value = false
doneNProgress()
}
}
onMounted(() => {
queryFollowUsers()
})
</script>
<template>
<VDialog max-width="40rem" scrollable>
<VCard>
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="ma-auto">
<VImg
width="10rem"
aspect-ratio="2/3"
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
:src="posterUrl"
@click="viewMediaDetail"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
>
{{ props.media?.share_title }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
>
{{ props.media?.share_comment }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">分享人</span>
<span class="text-body-1"> {{ media?.share_user }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="media?.keyword">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">搜索词</span>
<span class="text-body-1"> {{ media?.keyword }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="media?.custom_words" @click.stop="toggleExpand">
<VListItemTitle
class="text-center text-md-left break-words whitespace-break-spaces"
:class="{
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
}"
>
<span class="font-weight-medium">识别词</span>
<span class="text-body-1"> {{ media?.custom_words }}</span>
</VListItemTitle>
</VListItem>
</VList>
<div class="text-center text-md-left">
<div>
<VBtn
color="primary"
:disabled="processing"
@click="doFork"
prepend-icon="mdi-heart"
:loading="processing"
>
订阅
</VBtn>
<VBtn
v-if="props.media?.share_uid && props.media?.share_uid === globalSettings.USER_UNIQUE_ID"
color="error"
:disabled="deleting"
@click="doDelete"
prepend-icon="mdi-delete"
:loading="deleting"
class="ms-2"
>
取消分享
</VBtn>
<VBtn
v-else-if="isFollowed && props.media?.share_uid"
color="warning"
@click="unfollowUser"
prepend-icon="mdi-account-remove"
class="ms-2"
>
取消关注
</VBtn>
<VBtn
v-else-if="props.media?.share_uid"
@click="followUser"
color="info"
prepend-icon="mdi-account-plus"
class="ms-2"
>
关注
</VBtn>
</div>
<div class="text-xs mt-2" v-if="props.media?.count">
<VIcon icon="mdi-fire" /> {{ props.media?.count?.toLocaleString() }} 次复用
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -2,24 +2,23 @@
// 输入参数
const props = defineProps({
title: String,
dataType: String,
})
// 定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 代码
const codeString = ref('')
// 定义事件
const emit = defineEmits(['close', 'save'])
// 导入
function handleImport() {
emit('save', props.dataType, codeString)
emit('update:modelValue', codeString.value)
emit('close')
}
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh" persistent>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard :title="props.title" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import { Context } from '@/api/types'
import MediaInfoCard from '../cards/MediaInfoCard.vue'
// 输入参数
defineProps({
context: Object as PropType<Context>,
})
// 定义事件
const emit = defineEmits(['close'])
</script>
<template>
<VDialog max-width="50rem">
<VCard>
<DialogCloseBtn @click="emit('close')" />
<VCardItem>
<MediaInfoCard :context="context" />
</VCardItem>
</VCard>
</VDialog>
</template>

View File

@@ -1,109 +0,0 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import FormRender from '../render/FormRender.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 输入参数
const props = defineProps({
plugin: {
type: Object as PropType<Plugin>,
},
})
// 定义事件
const emit = defineEmits(['close', 'save', 'switch'])
// 显示器宽度
const display = useDisplay()
// 插件配置表单数据
const pluginConfigForm = ref({})
// 插件表单配置项
let pluginFormItems = reactive([])
// 进度框
const progressDialog = ref(false)
// 进度文字
const progressText = ref('')
// 提示框
const $toast = useToast()
// 是否刷新
const isRefreshed = ref(false)
// 调用API读取表单页面
async function loadPluginForm() {
try {
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (result) {
pluginFormItems = result.conf
if (result.model) pluginConfigForm.value = result.model
}
} catch (error) {
console.error(error)
}
isRefreshed.value = true
}
// 调用API读取配置数据
async function loadPluginConf() {
try {
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
} catch (error) {
console.error(error)
}
isRefreshed.value = true
}
// 调用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) {
progressDialog.value = false
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
// 通知父组件刷新
emit('save')
} else {
progressDialog.value = false
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
}
} catch (error) {
console.error(error)
}
}
onBeforeMount(async () => {
await loadPluginForm()
await loadPluginConf()
})
</script>
<template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText v-if="isRefreshed">
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info"> 查看数据 </VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
</VCardActions>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>
</template>

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
// 输入参数
const props = defineProps({
plugin: {
type: Object as PropType<Plugin>,
},
})
// 定义事件
const emit = defineEmits(['close', 'save', 'switch'])
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// 是否刷新
const isRefreshed = ref(false)
// 插件数据页面配置项
let pluginPageItems = ref([])
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) pluginPageItems.value = result
} catch (error) {
console.error(error)
}
isRefreshed.value = true
}
onMounted(() => {
loadPluginPage()
})
</script>
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
</VCardText>
<VFab
icon="mdi-cog"
location="bottom"
size="x-large"
fixed
app
appear
@click="emit('switch')"
:class="{ 'mb-10': appMode }"
/>
</VCard>
</VDialog>
</template>

View File

@@ -1,68 +0,0 @@
<script lang="ts" setup>
import api from '@/api'
import { useToast } from 'vue-toast-notification'
// 输入参数
const props = defineProps({
title: String,
})
const $toast = useToast()
// 插件仓库设置字符串
const repoString = ref('')
// 定义事件
const emit = defineEmits(['save', 'close'])
// 查询已设置的插件仓库
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
if (result && result.data && result.data.value) repoString.value = result.data.value
} catch (error) {
console.log(error)
}
}
// 保存设置
async function saveHandle() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoString.value)
if (result.success) {
$toast.success('插件仓库保存成功')
emit('save')
} else $toast.error(`插件仓库保存失败:${result?.message}`)
} catch (error) {
console.log(error)
}
}
onMounted(() => {
queryMarketRepoSetting()
})
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="插件仓库设置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea
v-model="repoString"
placeholder="格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/"
hint="多个地址使用逗号分隔仅支持Github仓库"
persistent-hint
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,66 +0,0 @@
<script lang="ts" setup>
import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
if (!props.conf.filepath) {
props.conf.filepath = '/moviepilot/.config/rclone/rclone.conf'
}
if (!props.conf.content) {
props.conf.content = '# 请在此处填写rclone配置文件内容 \n# 请参考 https://rclone.org/docs/ \n# 存储节点名必须为MP'
}
// 定义事件
const emit = defineEmits(['done', 'close'])
// 完成
async function handleDone() {
await savaRcloneConfig()
emit('done')
}
// 保存rclone设置
async function savaRcloneConfig() {
try {
await api.post(`storage/save/rclone`, props.conf)
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="RClone配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.filepath" label="rclone配置文件路径" />
</VCol>
<VCol cols="12">
<VAceEditor
v-model:value="props.conf.content"
lang="ini"
theme="monokai"
style="block-size: 30rem"
class="rounded"
>
</VAceEditor>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,30 +1,27 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import store from '@/store'
import api from '@/api'
import { storageOptions, transferTypeOptions } from '@/api/constants'
import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { FileItem, TransferDirectoryConf, TransferForm } from '@/api/types'
import { FileItem, MediaDirectory } from '@/api/types'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
storage: {
type: String,
default: () => 'local',
},
logids: Array<number>,
items: Array<FileItem>,
target_storage: String,
target_path: String,
target: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 当前识别类型
const mediaSource = ref(globalSettings.data?.RECOGNIZE_SOURCE || 'themoviedb')
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -36,6 +33,9 @@ const seasonItems = ref(
})),
)
// 当前识别类型
const mediaSource = ref('themoviedb')
// 提示框
const $toast = useToast()
@@ -49,7 +49,7 @@ const progressEventSource = ref<EventSource>()
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('正在处理 ...')
const progressText = ref('请稍候 ...')
// 整理进度
const progressValue = ref(0)
@@ -65,101 +65,56 @@ const dialogTitle = computed(() => {
return '手动整理'
})
// 禁用指定集数
const disableEpisodeDetail = computed(() => {
if (props.items) {
if (transferForm.episode_format) return false
return !(props.items.length === 1 && props.items[0].type !== 'dir')
}
})
// 表单
const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem,
const transferForm = reactive({
storage: props.storage,
logid: 0,
target_storage: props.target_storage ?? 'local',
path: '',
drive_id: '',
fileid: '',
filetype: '',
target: props.target ?? null,
tmdbid: null,
doubanid: null,
season: null,
type_name: '',
transfer_type: '',
target_path: '',
episode_format: '',
episode_detail: '',
episode_part: '',
episode_offset: null,
min_filesize: 0,
scrape: false,
from_history: false,
})
// 所有媒体库目录
const directories = ref<TransferDirectoryConf[]>([])
// 查询目录
async function loadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
directories.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
const libraryDirectories = ref<MediaDirectory[]>([])
// 目的目录下拉框
const targetDirectories = computed(() => {
const libraryDirectories = directories.value.map(item => item.library_path)
return [...new Set(libraryDirectories)]
const directories = libraryDirectories.value.map(item => item.path)
return [...new Set(directories)]
})
// 监听目的路径变化,配置默认值
watch(
() => transferForm.target_path,
async newPath => {
if (newPath) {
const directory = directories.value.find(item => item.library_path === newPath)
if (directory) {
transferForm.target_storage = directory.library_storage ?? 'local'
transferForm.transfer_type = transferForm.transfer_type || directory.transfer_type
transferForm.scrape = directory.scraping ?? false
transferForm.library_category_folder = directory.library_category_folder ?? false
transferForm.library_type_folder = directory.library_type_folder ?? false
} else {
transferForm.transfer_type = transferForm.transfer_type || 'copy'
transferForm.scrape = false
transferForm.library_category_folder = false
transferForm.library_type_folder = false
}
} else {
// 路径为空时, 恢复到`自动`条件
transferForm.transfer_type = ''
transferForm.library_type_folder = undefined
transferForm.library_category_folder = undefined
// 监听目的路径变化,自动查询目录的刮削配置
watch(transferForm, async () => {
if (transferForm.target) {
const directory = libraryDirectories.value.find(item => item.path === transferForm.target)
if (directory) {
transferForm.scrape = directory.scrape ?? false
}
},
)
// 整理文件
async function handleTransfer(item: FileItem, background: boolean = false) {
transferForm.fileitem = item
transferForm.logid = 0
try {
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(`文件 ${item.name} 已加入整理队列!`)
} catch (e) {
console.log(e)
}
}
// 整理日志
async function handleTransferLog(logid: number, background: boolean = false) {
transferForm.logid = logid
transferForm.fileitem = {} as FileItem
try {
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(`历史记录 ${logid} 已加入整理队列!`)
} catch (e) {
console.log(e)
}
}
})
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?token=${token}`,
)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
@@ -175,48 +130,87 @@ function stopLoadingProgress() {
}
// 整理文件
async function transfer(background: boolean = false) {
async function transfer() {
if (!props.logids && !props.items) return
// 显示进度条
progressDialog.value = true
if (!background) {
// 开始监听进度
startLoadingProgress()
}
// 开始监听进度
startLoadingProgress()
// 文件整理
if (props.items) {
for (const item of props.items) {
await handleTransfer(item, background)
await handleTransfer(item)
}
}
// 日志整理
if (props.logids) {
for (const logid of props.logids) {
await handleTransferLog(logid, background)
await handleTransferLog(logid)
}
}
if (!background) {
// 停止监听进度
stopLoadingProgress()
}
// 停止监听进度
stopLoadingProgress()
// 关闭进度条
progressDialog.value = false
// 重新加载
emit('done')
}
onMounted(() => {
loadDirectories()
})
// 整理文件
async function handleTransfer(item: FileItem) {
transferForm.path = item.path
transferForm.fileid = item.fileid || ''
transferForm.drive_id = item.drive_id || ''
transferForm.filetype = item.type || 'dir'
onUnmounted(() => {
stopLoadingProgress()
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 整理日志
async function handleTransferLog(logid: number) {
transferForm.logid = logid
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 调用API加载当前系统环境设置
async function loadSystemSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result) mediaSource.value = result.data?.RECOGNIZE_SOURCE || 'themoviedb'
} catch (e) {
console.error(e)
}
}
// 查询媒体库目录
async function loadLibraryDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/LibraryDirectories')
if (result.success && result.data?.value) {
libraryDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
onMounted(() => {
loadSystemSettings()
loadLibraryDirectories()
})
</script>
@@ -228,32 +222,9 @@ onUnmounted(() => {
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.target_storage"
:items="storageOptions"
label="目的存储"
placeholder="留空自动"
hint="整理目的存储"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:items="transferTypeOptions"
hint="文件操作整理方式"
persistent-hint
>
<template v-slot:selection="{ item }">
{{ transferForm.transfer_type === '' ? '自动' : item.title }}
</template>
</VSelect>
</VCol>
<VCol cols="12">
<VCol v-if="props.storage == 'local'" cols="12" md="8">
<VCombobox
v-model="transferForm.target_path"
v-model="transferForm.target"
:items="targetDirectories"
label="目的路径"
placeholder="留空自动"
@@ -261,6 +232,23 @@ onUnmounted(() => {
persistent-hint
/>
</VCol>
<VCol v-if="props.storage == 'local'" cols="12" md="4">
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:items="[
{ title: '默认', value: '' },
{ title: '移动', value: 'move' },
{ title: '复制', value: 'copy' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
{ title: 'Rclone复制', value: 'rclone_copy' },
{ title: 'Rclone移动', value: 'rclone_move' },
]"
hint="文件操作整理方式"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
@@ -326,7 +314,6 @@ onUnmounted(() => {
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
hint="指定集数或范围如1或1,2"
@@ -344,7 +331,7 @@ onUnmounted(() => {
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.episode_offset"
v-model.number="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="集数偏移运算,如-10或EP*2"
@@ -363,22 +350,6 @@ onUnmounted(() => {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VSwitch
v-model="transferForm.library_type_folder"
label="按类型分类"
hint="整理时目的路径下按媒体类型添加子目录"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VSwitch
v-model="transferForm.library_category_folder"
label="按类别分类"
hint="整理时在目的路径下按媒体类别添加子目录"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="transferForm.scrape"
@@ -387,25 +358,12 @@ onUnmounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="props.logids">
<VSwitch
v-model="transferForm.from_history"
label="复用历史识别信息"
hint="使用历史整理记录中已识别的媒体信息"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="elevated" color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5">
加入整理队列
</VBtn>
<VBtn variant="elevated" @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
立即整理
</VBtn>
<VBtn variant="elevated" @click="transfer" prepend-icon="mdi-arrow-right-bold" class="px-5"> 开始整理 </VBtn>
</VCardActions>
</VCard>
<!-- 手动整理进度框 -->

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import type { DownloaderConf, Site } from '@/api/types'
import type { Site } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api'
@@ -35,18 +35,11 @@ const siteForm = ref<Site>({
limit_seconds: 0,
name: '',
domain: '',
downloader: '',
})
// 提示框
const $toast = useToast()
// 维护类型
const siteType = ref('cookie')
// 是否限流
const isLimit = ref(false)
// 状态下拉项
const statusItems = [
{ title: '启用', value: true },
@@ -61,23 +54,10 @@ const priorityItems = ref(
})),
)
// 下载器选项
const downloaderOptions = ref<{ title: string; value: string }[]>([])
async function loadDownloaderSetting() {
try {
const downloaders: DownloaderConf[] = await api.get('download/clients')
downloaderOptions.value = [
{ title: '默认', value: '' },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
})),
]
} catch (error) {
console.error('加载下载器设置失败:', error)
}
}
// 监控输入参数
watchEffect(async () => {
if (props.siteid) fetchSiteInfo()
})
// 查询站点信息
async function fetchSiteInfo() {
@@ -131,15 +111,6 @@ async function deleteSiteInfo() {
async function updateSiteInfo() {
startNProgress()
try {
if (isLimit.value) {
siteForm.value.limit_interval = siteForm.value.limit_interval || 0
siteForm.value.limit_count = siteForm.value.limit_count || 0
siteForm.value.limit_seconds = siteForm.value.limit_seconds || 0
} else {
siteForm.value.limit_interval = 0
siteForm.value.limit_count = 0
siteForm.value.limit_seconds = 0
}
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`)
@@ -153,16 +124,6 @@ async function updateSiteInfo() {
}
doneNProgress()
}
onMounted(async () => {
if (props.oper !== 'add') {
await fetchSiteInfo()
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
isLimit.value = true
if (siteForm.value.apikey) siteType.value = 'api'
}
await loadDownloaderSetting()
})
</script>
<template>
@@ -206,7 +167,7 @@ onMounted(async () => {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VCol cols="12" md="9">
<VTextField
v-model="siteForm.rss"
label="RSS地址"
@@ -215,85 +176,37 @@ onMounted(async () => {
/>
</VCol>
<VCol cols="12" md="3">
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
</VCol>
<VCol cols="12">
<VTextarea v-model="siteForm.cookie" label="站点Cookie" hint="站点请求头中的Cookie信息" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.timeout"
label="超时时间(秒"
hint="站点请求超时时间为0时不限制"
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="siteForm.downloader"
label="下载器"
:items="downloaderOptions"
hint="此站点使用的下载器"
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint
/>
</VCol>
</VRow>
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
<VTab selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
Cookie
</div>
</VTab>
<VTab selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-api" value="api" />
API
</div>
</VTab>
</VTabs>
<VWindow v-model="siteType" class="my-3 disable-tab-transition" :touch="false">
<VWindowItem value="cookie">
<VRow>
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
hint="站点请求头中的Cookie信息"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint
/>
</VCol>
</VRow>
</VWindowItem>
<VWindowItem value="api">
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
</VRow>
</VWindowItem>
</VWindow>
<VRow>
<VCol cols="12" md="4">
<VSwitch v-model="isLimit" label="限制站点访问频率" />
</VCol>
</VRow>
<VRow v-if="isLimit">
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_interval"
@@ -324,15 +237,10 @@ onMounted(async () => {
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="使用代理访问" hint="使用代理服务器访问该站点" persistent-hint />
<VSwitch v-model="siteForm.proxy" label="代理" hint="使用代理服务器访问该站点" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="siteForm.render"
label="浏览器仿真"
hint="使用浏览器模拟真实访问该站点"
persistent-hint
/>
<VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
</VCol>
</VRow>
</VForm>

View File

@@ -1,115 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { Site } from '@/api/types'
import { requiredValidator } from '@/@validators'
import { useToast } from 'vue-toast-notification'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 输入参数
const cardProps = defineProps({
site: Object as PropType<Site>,
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done'])
// 提示框
const $toast = useToast()
// 用户名密码表单
const userPwForm = ref({
username: '',
password: '',
code: '',
})
// 密码输入
const isPasswordVisible = ref(false)
// 更新按钮可用性
const updateButtonDisable = ref(false)
// 进度条
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 调用API更新站点Cookie UA
async function updateSiteCookie() {
try {
if (!userPwForm.value.username || !userPwForm.value.password) return
// 更新按钮状态
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
})
if (result.success) {
$toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
emit('done')
} else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
progressDialog.value = false
updateButtonDisable.value = false
} catch (error) {
console.error(error)
}
}
</script>
<template>
<VDialog max-width="30rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12">
<VTextField
v-model="userPwForm.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="mx-auto">
<VBtn
size="large"
variant="elevated"
@click="updateSiteCookie"
:disabled="updateButtonDisable"
:loading="updateButtonDisable"
prepend-icon="mdi-refresh"
class="px-5"
>
开始更新
</VBtn>
</VCardActions>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>
</template>

View File

@@ -1,282 +0,0 @@
<script setup lang="ts">
import { Site } from '@/api/types'
import api from '@/api'
import type { TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
})
// 关键字
const keyword = ref<string>()
// 选择分类
const selectCategory = ref<number[]>([])
// 全部分类
const siteCategoryList = ref<SiteCategory[]>()
// 分类选项
const categoryOptions = computed(() => {
return siteCategoryList.value?.map(item => {
return { title: item.desc, value: item.id }
})
})
// 注册事件
const emit = defineEmits(['close'])
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 加载状态
const resourceLoading = ref(false)
// 种子元数据
const torrent = ref<TorrentInfo>()
// 资源浏览表头
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')
}
// 促销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) {
torrent.value = _torrent
addDownloadDialog.value = true
}
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
params: {
keyword: keyword.value,
cat: selectCategory.value?.join(','),
},
})
} catch (error) {
console.error(error)
}
resourceLoading.value = false
}
// 加载站点分类
async function getSiteCategoryList() {
try {
siteCategoryList.value = await api.get(`site/category/${props.site?.id}`)
} catch (error) {
console.error(error)
}
}
// 装载时查询站点图标
onMounted(() => {
getSiteCategoryList()
getResourceList()
})
</script>
<template>
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VCard>
<!-- Toolbar -->
<div>
<VToolbar color="primary">
<VToolbarTitle>{{ `浏览 - ${props.site?.name}` }}</VToolbarTitle>
<VSpacer />
<VToolbarItems>
<VBtn icon variant="plain" @click="emit('close')" class="me-3">
<VIcon size="large" color="white" icon="ri-close-line" />
</VBtn>
</VToolbarItems>
</VToolbar>
</div>
<div class="p-3">
<VRow>
<VCol cols="6" md="5">
<VTextField v-model="keyword" size="small" density="compact" label="搜索关键字" clearable />
</VCol>
<VCol cols="6" md="5">
<VSelect
v-model="selectCategory"
:items="categoryOptions"
size="small"
density="compact"
chips
label="资源分类"
multiple
clearable
/>
</VCol>
<VCol cols="12" md="2" class="text-center">
<VBtn block prepend-icon="mdi-magnify" @click="getResourceList">搜索</VBtn>
</VCol>
</VRow>
</div>
<VCardText class="px-0 py-0 my-0">
<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="加载中..."
class="h-full"
>
<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>
</VCardText>
</VCard>
<!-- 添加下载对话框 -->
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</VDialog>
</template>
<style lang="scss" scoped>
.v-table th {
white-space: nowrap;
}
</style>

View File

@@ -1,461 +0,0 @@
<script lang="ts" setup>
import type { Site, SiteUserData } from '@/api/types'
import api from '@/api'
import { useDisplay, useTheme } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
})
// 注册事件
const emit = defineEmits(['close'])
// 进度框
const progressDialog = ref(false)
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(
() => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.colors,
)
// 站点数据列表
const siteDatas = ref<SiteUserData[]>([])
// 最新一天的数据
const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
// 站点数据列表中的上传量、下载量数据生成图形使用的数据
const historySeries = computed(() => {
return [
{
name: '上传量',
data: siteDatas.value.map(item => Math.round((item.upload ?? 0) / 1024 / 1024 / 1024)),
},
{
name: '下载量',
data: siteDatas.value.map(item => Math.round((item.download ?? 0) / 1024 / 1024 / 1024)),
},
]
})
// 图形选项
const historyChartOptions = computed(() => {
return {
chart: {
type: 'area',
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
dataLabels: {
enabled: true,
},
zoom: {
autoScaleYaxis: true,
},
},
tooltip: {
enabled: true,
tooltip: {
x: {
format: 'dd MMM yyyy',
},
},
},
grid: {
xaxis: {
lines: { show: false },
},
yaxis: {
title: {
text: 'GB',
},
lines: { show: true },
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.success, currentTheme.value.warning],
markers: {
size: 0,
style: 'hollow',
},
xaxis: {
type: 'category',
categories: siteDatas.value.map(item => item.updated_day),
labels: {
show: true,
formatter: function (val: string) {
return new Date(val).toLocaleDateString('zh-CN')
},
},
},
yaxis: {
title: {
text: 'GB',
},
labels: {
formatter: function (val: number) {
return val.toLocaleString()
},
},
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.5,
opacityTo: 0.7,
stops: [0, 100],
},
},
}
})
// 做种分布列seeding_info的格式为[[x, y], [x, y], ...]x为做种数y为做种体积做种体积需要转换为GB
const seedingSeries = computed(() => {
return [
{
name: '体积',
data: siteData.value?.seeding_info?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]),
},
]
})
// 做种分布图形选项
const seedingChartOptions = computed(() => {
return {
chart: {
type: 'scatter',
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
zoom: {
autoScaleYaxis: true,
},
},
tooltip: {
enabled: true,
x: {
formatter: function (val: number) {
return '数量:' + val.toLocaleString()
},
},
},
grid: {
xaxis: {
lines: { show: true },
},
yaxis: {
lines: { show: true },
},
},
colors: [currentTheme.value.primary],
xaxis: {
type: 'numeric',
labels: {
show: true,
formatter: function (val: number) {
return Math.round(val).toLocaleString()
},
},
title: {
text: '数量',
},
tickAmount: 10,
},
yaxis: {
title: {
text: 'GB',
},
labels: {
formatter: function (val: number) {
return val.toLocaleString() + ' GB'
},
},
},
}
})
// 根据传入属性,计算列表数据中第一条与第二条的差值,如果没有第二条则差值为全部
const diffData: { [key: string]: any } = computed(() => {
if (siteDatas.value.length < 2) {
return siteData.value
}
const first = siteDatas.value[siteDatas.value.length - 1]
const second = siteDatas.value[siteDatas.value.length - 2]
return {
bonus: (first.bonus ?? 0) - (second.bonus ?? 0),
ratio: (first.ratio ?? 0) - (second.ratio ?? 0),
upload: (first.upload ?? 0) - (second.upload ?? 0),
download: (first.download ?? 0) - (second.download ?? 0),
seeding: (first.seeding ?? 0) - (second.seeding ?? 0),
seeding_size: (first.seeding_size ?? 0) - (second.seeding_size ?? 0),
}
})
// 格式化差值
function getDiffString(diff: number | undefined, format: boolean = true) {
if (diff === undefined) {
return '0'
}
if (format) {
return diff >= 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()
}
return diff >= 0 ? `+${diff}` : diff
}
// 根据差值的正负,返回不同的样式
function getDiffClass(diff: number | undefined) {
if (diff === undefined) {
return ''
}
if (diff == 0) {
return ''
}
return diff > 0 ? 'text-success' : 'text-error'
}
// 查询站点用户数据
async function fetchSiteUserData() {
try {
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
if (result.success) {
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
(a.updated_day || '').localeCompare(b.updated_day || ''),
)
}
} catch (error) {
console.error(error)
}
}
// 刷新站点数据
async function refreshSiteData() {
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post(`site/userdata/${props.site?.id}`)
if (result.success) {
await fetchSiteUserData()
}
} catch (error) {
console.log(error)
}
progressDialog.value = false
}
onBeforeMount(async () => {
await fetchSiteUserData()
})
</script>
<template>
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCardItem>
<VCardTitle
>{{ `数据 - ${props.site?.name}` }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh" /></IconBtn>
</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="pt-5">
<VRow class="match-height">
<!-- 用户信息 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">用户等级</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.user_level || '无' }}
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-account"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 积分 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">积分</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.bonus?.toLocaleString() }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.bonus)">
({{ getDiffString(diffData?.bonus) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-scoreboard"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 分享率 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">分享率</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.ratio }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.ratio)">
({{ getDiffString(diffData?.ratio) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-percent"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总上传量 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总上传量</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.upload || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.upload)">
({{ formatFileSize(diffData?.upload || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-upload"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总下载量 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总下载量</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.download || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.download)">
({{ formatFileSize(diffData?.download || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-download"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总做种数 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总做种数</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.seeding?.toLocaleString() }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding)">
({{ getDiffString(diffData?.seeding) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-seed"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总做种体积 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总做种体积</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.seeding_size || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding_size)">
({{ formatFileSize(diffData?.seeding_size || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-database"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 加入时间 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">加入时间</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.join_at?.split(' ')[0] }}
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-calendar"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol>
<VCard title="历史流量">
<VCardText>
<VApexChart type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol>
<VCard title="做种分布">
<VCardText>
<VApexChart type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在刷新站点数据..." />
</VDialog>
</template>

View File

@@ -2,10 +2,9 @@
import { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators'
import api from '@/api'
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
import type { MediaDirectory, Site, Subscribe } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog'
import { VTextarea, VTextField } from 'vuetify/lib/components/index.mjs'
// 显示器宽度
const display = useDisplay()
@@ -23,34 +22,38 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'close'])
const activeTab = ref('basic')
// 站点数据列表
const siteList = ref<Site[]>([])
// 下载目录列表
const downloadDirectories = ref<TransferDirectoryConf[]>([])
const downloadDirectories = ref<MediaDirectory[]>([])
// 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
// 所有规则组列表
const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 订阅编辑表单
const subscribeForm = ref<Subscribe>({
id: props.subid ?? 0,
keyword: '',
quality: '',
resolution: '',
effect: '',
include: '',
exclude: '',
total_episode: 0,
start_episode: 0,
best_version: 0,
search_imdbid: 0,
sites: [],
type: '',
name: '',
year: '',
type: '',
tmdbid: 0,
state: '',
last_update: '',
username: '',
sites: [],
best_version: undefined,
current_priority: 0,
downloader: '',
save_path: undefined,
date: '',
show_edit_dialog: false,
})
@@ -58,42 +61,6 @@ const subscribeForm = ref<Subscribe>({
// 提示框
const $toast = useToast()
// 下载器选项
const downloaderOptions = ref<{ title: string; value: string }[]>([])
async function loadDownloaderSetting() {
try {
const downloaders: DownloaderConf[] = await api.get('download/clients')
downloaderOptions.value = [
{ title: '默认', value: '' },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
})),
]
} catch (error) {
console.error('加载下载器设置失败:', error)
}
}
// 加载规则组
async function queryFilterRuleGroups() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 过滤规则组选择项
const filterRuleGroupOptions = computed(() => {
return filterRuleGroups.value.map(item => ({
title: item.name,
value: item.name,
}))
})
// 调用API修改订阅
async function updateSubscribeInfo() {
try {
@@ -195,7 +162,6 @@ async function removeSubscribe() {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
if (result.success) {
$toast.success(`订阅 ${subscribeForm.value.name} 已取消!`)
// 通知父组件刷新
emit('remove')
}
@@ -207,7 +173,7 @@ async function removeSubscribe() {
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
const result: { [key: string]: any } = await api.get('system/setting/DownloadDirectories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
}
@@ -219,7 +185,8 @@ async function loadDownloadDirectories() {
// 保存目录下拉框
const targetDirectories = computed(() => {
// 去重后的下载目录
return downloadDirectories.value.map(item => item.download_path)
const directories = downloadDirectories.value.map(item => item.path)
return [...new Set(directories)]
})
// 质量选择框数据
@@ -307,10 +274,8 @@ const effectOptions = ref([
])
onMounted(() => {
queryFilterRuleGroups()
loadDownloadDirectories()
getSiteList()
loadDownloaderSetting()
if (props.subid) getSubscribeInfo()
if (props.default) queryDefaultSubscribeConfig()
})
@@ -326,199 +291,134 @@ onMounted(() => {
}`"
class="rounded-t"
>
<VDivider />
<VCardText>
<DialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}">
<VTabs v-model="activeTab" show-arrows>
<VTab value="basic">
<div>基础</div>
</VTab>
<VTab value="advance">
<div>进阶</div>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="basic">
<div>
<VRow v-if="!props.default">
<VCol cols="12" md="4">
<VTextField
v-model="subscribeForm.keyword"
label="搜索关键词"
hint="指定搜索站点时使用的关键词"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4">
<VTextField
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
hint="剧集总集数"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4">
<VTextField
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
hint="开始订阅集数"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.quality"
label="质量"
:items="qualityOptions"
hint="订阅资源质量"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:items="resolutionOptions"
hint="订阅资源分辨率"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.effect"
label="特效"
:items="effectOptions"
hint="订阅资源特效"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
v-model="subscribeForm.sites"
:items="selectSitesOptions"
chips
label="订阅站点"
multiple
clearable
hint="订阅的站点范围,不选使用系统设置"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="subscribeForm.downloader"
:items="downloaderOptions"
label="下载器"
hint="指定该订阅使用的下载器"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VCombobox
v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
hint="根据洗版优先级进行洗版订阅"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索"
hint="开使用 ImdbID 精确搜索资源"
persistent-hint
/>
</VCol>
<VCol v-if="props.default" cols="12" md="4">
<VSwitch
v-model="subscribeForm.show_edit_dialog"
label="订阅时编辑更多规则"
hint="添加订阅时显示此编辑订阅对话框"
persistent-hint
/>
</VCol>
</VRow>
</div>
</VWindowItem>
<VWindowItem value="advance">
<div>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
hint="包含规则,支持正则表达式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
hint="排除规则,支持正则表达式"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="subscribeForm.filter_groups"
:items="filterRuleGroupOptions"
chips
multiple
clearable
label="优先级规则组"
hint="按选定的过滤规则组对订阅进行过滤"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="!props.default">
<VTextField
v-model="subscribeForm.media_category"
label="自定义类别"
hint="指定类别名称,留空自动识别"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="!props.default">
<VCol cols="12">
<VTextarea
v-model="subscribeForm.custom_words"
label="自定义识别词"
hint="只对该订阅使用的识别词"
persistent-hint
placeholder="屏蔽词
被替换词 => 替换词
前定位词 <> 后定位词 >> 集偏移量EP
被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量EP
其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别其中s、e为季数和集数可选"
/>
</VCol>
</VRow>
</div>
</VWindowItem>
</VWindow>
<VRow>
<VCol cols="12" md="8">
<VTextField
v-if="!props.default"
v-model="subscribeForm.keyword"
label="搜索关键词"
hint="指定搜索站点时使用的关键词"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
<VTextField
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
hint="剧集总集数"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
<VTextField
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
hint="开始订阅集数"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.quality"
label="质量"
:items="qualityOptions"
hint="订阅资源质量"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:items="resolutionOptions"
hint="订阅资源分辨率"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.effect"
label="特效"
:items="effectOptions"
hint="订阅资源特效"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
hint="包含规则,支持正则表达式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
hint="排除规则,支持正则表达式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.sites"
:items="selectSitesOptions"
chips
label="订阅站点"
multiple
hint="订阅的站点范围,不选使用系统设置"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCombobox
v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
hint="根据洗版优先级进行洗版订阅"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索"
hint="开使用 ImdbID 精确搜索资源"
persistent-hint
/>
</VCol>
<VCol v-if="props.default" cols="12" md="4">
<VSwitch
v-model="subscribeForm.show_edit_dialog"
label="订阅时编辑更多规则"
hint="添加订阅时显示此编辑订阅对话框"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">

View File

@@ -1,305 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { SubscrbieInfo } from '@/api/types'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
//定义输入参数
const props = defineProps({
subid: Number,
})
const activeTab = ref('download')
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 订阅文件信息
const subScribeInfo = ref<SubscrbieInfo>()
// 下载文件表头
const downloadHeaders = [
{ title: '集', key: 'episode_number', sortable: true },
{ title: '种子', key: 'torrent_title', sortable: true },
{ title: '文件', key: 'file_path', sortable: true },
]
// 媒体库文件表头
const libraryHeaders = [
{ title: '集', key: 'episode_number', sortable: true },
{ title: '文件', key: 'file_path', sortable: true },
]
// 调用API查询订阅文件信息
async function loadSubscribeFilesInfo() {
try {
subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)
} catch (e) {
console.log(e)
}
}
// 计算下载文件列表
const downloadInfos = computed(() => {
return Object.keys(subScribeInfo.value?.episodes ?? {}).map((key: any) => {
const item = subScribeInfo.value?.episodes[key]
return {
episode_number: key,
title: item?.title,
download: item?.download ?? [],
}
})
})
// 总集数
const totalCount = computed(() => {
return Object.keys(subScribeInfo.value?.episodes ?? {}).length
})
// 计算媒体库文件列表
const libraryInfos = computed(() => {
return Object.keys(subScribeInfo.value?.episodes ?? {}).map((key: any) => {
const item = subScribeInfo.value?.episodes[key]
return {
episode_number: key,
title: item?.title,
library: item?.library ?? [],
}
})
})
onBeforeMount(() => {
loadSubscribeFilesInfo()
})
</script>
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCardItem class="my-2">
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VCardText>
<div class="media-page">
<div class="media-header">
<div class="media-poster">
<VImg
:src="subScribeInfo?.subscribe?.poster"
cover
class="object-cover aspect-w-2 aspect-h-3 ring-1 ring-gray-500"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="media-title">
<h1 class="d-flex flex-column flex-lg-row align-baseline justify-center justify-lg-start">
<div class="align-self-center align-self-lg-end">
{{ subScribeInfo?.subscribe?.name }}
</div>
<div v-if="subScribeInfo?.subscribe?.season" class="text-lg align-self-center align-self-lg-end ms-3">
{{ subScribeInfo?.subscribe?.season }}
</div>
</h1>
<div>{{ subScribeInfo?.subscribe?.year }}</div>
<div class="media-overview">
<div class="media-overview-left">
<p>{{ subScribeInfo?.subscribe?.description }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="mt-7">
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill">
<VTab value="download" selected-class="v-slide-group-item--active v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-download" />
下载文件
</div>
</VTab>
<VTab value="library" selected-class="v-slide-group-item--active v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-filmstrip-box-multiple" />
媒体库文件
</div>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="download">
<transition name="fade-slide" appear>
<div>
<VDataTable
items-per-page="50"
:headers="downloadHeaders"
:items="downloadInfos"
:items-length="totalCount"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
</template>
<template #item.torrent_title="{ item }">
<div class="text-xs" v-for="file in item.download">
{{ file.site_name }}{{ file.torrent_title }}
</div>
</template>
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.download">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</div>
</transition>
</VWindowItem>
<VWindowItem value="library">
<transition name="fade-slide" appear>
<div>
<VDataTable
items-per-page="50"
:headers="libraryHeaders"
:items="libraryInfos"
:items-length="totalCount"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
</template>
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.library">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</div>
</transition>
</VWindowItem>
</VWindow>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.vue-media-back {
background-image: linear-gradient(
180deg,
rgba(var(--v-theme-background), 0) 50%,
rgba(var(--v-theme-background), 1) 100%
),
linear-gradient(90deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),
linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);
box-shadow: 0 0 0 2px rgb(var(--v-theme-background));
margin-block-start: calc(-70px - env(safe-area-inset-top));
}
.media-page {
position: relative;
background-position: 50%;
background-size: cover;
margin-block-start: calc(-4rem - env(safe-area-inset-top));
padding-block-start: calc(4rem + env(safe-area-inset-top));
padding-inline: 1rem;
}
.media-header {
display: flex;
flex-direction: column;
align-items: center;
padding-block-start: 1rem;
}
@media (width >= 1280px) {
.media-header {
flex-direction: row;
align-items: flex-end;
}
}
.media-overview {
display: flex;
flex-direction: column;
padding-block: 1rem 1rem;
}
@media (width >= 1024px) {
.media-overview {
flex-direction: row;
}
}
.media-poster {
overflow: hidden;
border-radius: 0.25rem;
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
inline-size: 8rem;
--tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 10%), 0 1px 2px -1px rgba(0, 0, 0, 10%);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
}
@media (width >= 1280px) {
.media-poster {
inline-size: 13rem;
margin-inline-end: 1rem;
}
}
@media (width >= 768px) {
.media-poster {
border-radius: 0.5rem;
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
inline-size: 11rem;
--tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 25%);
--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
}
}
.media-title {
display: flex;
flex: 1 1 0%;
flex-direction: column;
margin-block-start: 1rem;
text-align: center;
}
@media (width >= 1280px) {
.media-title {
margin-block-start: 0;
margin-inline-end: 1rem;
text-align: start;
}
}
.media-title > h1 {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
}
@media (width >= 1280px) {
.media-title > h1 {
font-size: 2.25rem;
line-height: 2.5rem;
}
}
</style>

View File

@@ -86,7 +86,7 @@ async function reSubscribe(item: Subscribe) {
else progressText.value = `正在重新订阅 ${item.name}${item.season} 季 ...`
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post('subscribe/', item)
const result: { [key: string]: any } = await api.post('subscribe', item)
if (result.success) {
emit('save')
}
@@ -138,69 +138,72 @@ const dropdownItems = ref([
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
</VCardItem>
<VDivider />
<DialogCloseBtn @click="emit('close')" />
<DialogCloseBtn
@click="
() => {
emit('close')
}
"
/>
<VList lines="two">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory">
<template #loading>
<LoadingBanner />
</template>
<template #empty />
<template v-if="historyList.length > 0">
<template v-for="(item, i) in historyList" :key="i">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded shadow ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }} <span class="text-sm"> {{ item.season }} </span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<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.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
</template>
<template v-for="(item, i) in historyList" :key="i">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded shadow ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }} <span class="text-sm"> {{ item.season }} </span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<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.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
</template>
</VInfiniteScroll>
</VList>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center"> 没有已完成的订阅 </VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />

View File

@@ -1,108 +0,0 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Subscribe, SubscribeShare } from '@/api/types'
import { useDisplay } from 'vuetify'
import { formatSeason } from '@/@core/utils/formatters'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
sub: Object as PropType<Subscribe>,
})
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 分享处理状态
const shareDoing = ref(false)
// 订阅编辑表单
const shareForm = ref<SubscribeShare>({
subscribe_id: props.sub?.id ?? 0,
share_title: `${props.sub?.name} ${formatSeason(props.sub?.season ? props.sub?.season.toString() : '')}`,
})
// 分享订阅
async function doShare() {
if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return
try {
shareDoing.value = true
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
shareDoing.value = false
// 提示
if (result.success) {
$toast.success(`${props.sub?.name} 分享成功!`)
// 通知父组件刷新
emit('close')
} else {
$toast.error(`${props.sub?.name} 分享失败:${result.message}`)
}
} catch (e) {
console.log(e)
}
}
// 提示框
const $toast = useToast()
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`分享订阅 - ${props.sub?.name} ${props.sub?.season ? `第 ${props.sub?.season} 季` : ''}`"
class="rounded-t"
>
<VCardText>
<DialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}" class="pt-2">
<VRow>
<VCol cols="12">
<VTextField
v-model="shareForm.share_title"
readonly
label="标题"
:rules="[requiredValidator]"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="shareForm.share_comment"
label="说明"
:rules="[requiredValidator]"
hint="填写关于该订阅的说明,订阅中的搜索词、识别词等将会默认包含在分享中"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="shareForm.share_user"
label="分享用户"
:rules="[requiredValidator]"
hint="分享人的昵称"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
variant="elevated"
:disabled="shareDoing"
@click="doShare"
prepend-icon="mdi-share"
class="px-5"
:loading="shareDoing"
>
确认分享
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,187 +0,0 @@
<script lang="ts" setup>
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { FileItem, TransferQueue } from '@/api/types'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 数据列表
const dataList = ref<TransferQueue[]>([])
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 整理进度文本
const progressText = ref('请稍候 ...')
// 整理进度
const progressValue = ref(0)
// 数据可刷新标志
const refreshFlag = ref(false)
// 活动标签
const activeTab = ref('')
// 状态标签
const stateDict: { [key: string]: string } = {
'waiting': '等待中',
'running': '正在整理',
'completed': '完成',
'failed': '失败',
}
// 获取状态颜色
function getStateColor(state: string) {
if (state === 'waiting') return 'gray'
else if (state === 'running') return 'primary'
else if (state === 'completed') return 'success'
else return 'error'
}
// 从dataList中提取所有的媒体信息
const mediaList = computed(() => {
return dataList.value.map(item => item.media)
})
// 按media计算总数和完成数返回 x/x
function getMediaCount(title_year: string) {
// 按title_year查询出所有media列表
const medias = dataList.value.filter(item => item.media.title_year === title_year)
// 计算media下任务的总数
const total = medias.reduce((acc, cur) => acc + cur.tasks.length, 0)
// 计算media下任务的完成数
const completed = medias.reduce((acc, cur) => acc + cur.tasks.filter(task => task.state === 'completed').length, 0)
return `${completed} / ${total}`
}
// 根据媒体信息获取对应的整理任务
const activeTasks = computed(() => {
return dataList.value.find(item => item.media.title_year === activeTab.value)?.tasks
})
// 调用API获取队列信息
async function get_transfer_queue() {
try {
dataList.value = await api.get('transfer/queue')
if (dataList.value.length > 0) {
if (!activeTab.value || activeTasks.value?.length == 0) activeTab.value = dataList.value[0].media.title_year || ''
}
} catch (error) {
console.error(error)
}
}
// 移除队列任务
async function remove_queue_task(fileitem: FileItem) {
try {
await api.delete(`transfer/queue`, { data: fileitem })
get_transfer_queue()
} catch (error) {
console.error(error)
}
}
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
if (!progress.enable) {
progressText.value = '请稍候 ...'
progressValue.value = 0
if (refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
}
return
}
progressText.value = progress.text
progressValue.value = progress.value
if (progress.value >= 100 && refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
} else {
if (progress.value > 0 && refreshFlag.value && progress.text?.includes('整理完成')) {
refreshFlag.value = false
get_transfer_queue()
} else {
refreshFlag.value = true
}
}
}
}
}
// 停止监听加载进度
function stopLoadingProgress() {
progressEventSource.value?.close()
}
onMounted(() => {
get_transfer_queue()
startLoadingProgress()
})
onUnmounted(() => {
stopLoadingProgress()
})
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>整理队列</VCardTitle>
</VCardItem>
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VProgressLinear
v-if="dataList.length > 0 && progressValue > 0"
:value="progressValue"
color="primary"
indeterminate
/>
<VCardItem v-if="dataList.length > 0 && progressValue > 0" class="text-center pt-2">
<span class="text-sm">{{ progressText }}</span>
</VCardItem>
<VCardText v-if="dataList.length === 0" class="text-center"> 没有正在整理的任务 </VCardText>
<VCardText>
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill" stacked>
<VTab
v-for="media in mediaList"
:value="media.title_year"
selected-class="v-slide-group-item--active v-tab--selected"
>
<div class="font-bold text-lg">{{ media.title }}</div>
<div>({{ getMediaCount(media.title_year || '') }})</div>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem v-for="media in mediaList" :value="media.title_year">
<VList>
<VListItem v-for="task in activeTasks">
<VListItemTitle>{{ task.fileitem.name }}</VListItemTitle>
<VListItemSubtitle>
大小{{ formatFileSize(task.fileitem.size || 0) }}
<VChip size="small" :color="getStateColor(task.state)" class="ms-2">
{{ stateDict[task.state] }}
</VChip>
</VListItemSubtitle>
<template #append>
<IconBtn size="small" icon="mdi-cancel" @click="remove_queue_task(task.fileitem)" />
</template>
</VListItem>
</VList>
</VWindowItem>
</VWindow>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,15 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import QrcodeVue from 'qrcode.vue'
import { VCardItem, VTextField } from 'vuetify/lib/components/index.mjs'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
import api from '@/api'
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -18,7 +9,7 @@ const emit = defineEmits(['done', 'close'])
const qrCodeContent = ref('')
// 下方的提示信息
const text = ref('请使用微信或115客户端扫码或在下方输入Cookie')
const text = ref('请使用微信或115客户端扫码')
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
@@ -28,17 +19,13 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.cookie) {
await savaU115Config()
}
emit('done')
}
// 调用/aliyun/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
const result: { [key: string]: any } = await api.get('/u115/qrcode')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
} else {
@@ -52,25 +39,19 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/check/u115')
const result: { [key: string]: any } = await api.get('/u115/check')
if (result.success && result.data) {
const status = result.data.status
text.value = result.data.tip
if (status == 0) {
if (status == 1) {
// 已确认完成
alertType.value = 'success'
handleDone()
} else if (status == 0) {
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 1) {
// 已扫码
alertType.value = 'info'
text.value = '已扫码,请确认登录'
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 2) {
// 已确认完成
alertType.value = 'success'
handleDone()
} else {
// 过期或者已取消
alertType.value = 'error'
@@ -84,15 +65,6 @@ async function checkQrcode() {
}
}
// 保存cookie设置
async function savaU115Config() {
try {
await api.post(`storage/save/u115`, props.conf)
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000)
@@ -108,20 +80,15 @@ onUnmounted(() => {
<VCard title="115网盘登录" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 shadow-lg rounded text-center p-3 border">
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
<div class="my-6 shadow-lg rounded border">
<VImg class="mx-auto" :src="qrCodeContent" style="block-size: 200px; inline-size: 200px">
<VSkeletonLoader v-if="!qrCodeContent" class="w-full h-full" />
</VImg>
</div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />
</VAlert>
</VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义Cookie" v-model="props.conf.cookie" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>

View File

@@ -1,438 +0,0 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import type { User } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import api from '@/api'
import { useDisplay } from 'vuetify'
import avatar1 from '@images/avatars/avatar-1.png'
import { useUserStore } from '@/stores'
// 显示器宽度
const display = useDisplay()
const refInputEl = ref<HTMLElement>()
const isNewPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
// 输入参数
const props = defineProps({
username: String,
usernames: Array,
oper: String,
})
// 用户 Store
const userStore = useUserStore()
// 当前登录用户名称
const currentLoginUser = userStore.userName
// 用户名
const userName = ref('')
// 当前头像缓存
const currentAvatar = ref(avatar1)
// 用户名缓存
const currentUserName = ref('')
// 注册事件
const emit = defineEmits(['save', 'close'])
// 创建新用户按钮运行状态
const isAdding = ref(false)
// 更新用户消息按钮运行状态
const isUpdating = ref(false)
// 提示框
const $toast = useToast()
// 状态下拉项
const statusItems = [
{ title: '激活', value: 1 },
{ title: '已停用', value: 0 },
]
// 用户编辑表单数据
const userForm = ref<User>({
id: 0,
name: props.username ?? '',
password: '',
email: '',
is_active: true,
is_superuser: false,
avatar: avatar1,
is_otp: false,
permissions: {},
settings: {
wechat_userid: null,
telegram_userid: null,
slack_userid: null,
vocechat_userid: null,
synologychat_userid: null,
},
})
// 更新头像
function changeAvatar(file: Event) {
const fileReader = new FileReader()
const { files } = file.target as HTMLInputElement
if (files && files.length > 0) {
const selectedFile = files[0]
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const maxSize = 800 * 1024
// 检查文件是否为图片
if (!allowedTypes.includes(selectedFile.type)) {
$toast.error('上传的文件不符合要求,请重新选择头像')
return
}
// 检查文件大小
if (selectedFile.size > maxSize) {
$toast.error('文件大小不得大于800KB')
return
}
fileReader.readAsDataURL(selectedFile)
fileReader.onload = () => {
if (typeof fileReader.result === 'string') {
currentAvatar.value = fileReader.result
$toast.success('新头像上传成功,待保存后生效!')
}
}
}
}
// 重置默认头像
function resetDefaultAvatar() {
currentAvatar.value = avatar1
$toast.success('已重置为默认头像,待保存后生效!')
}
// 还原当前头像
function restoreCurrentAvatar() {
currentAvatar.value = userForm.value.avatar
$toast.success('已还原当前使用头像!')
}
// 查询用户信息
async function fetchUserInfo() {
try {
userForm.value = await api.get(`user/${props.username}`)
if (userForm.value) {
userForm.value.avatar = userForm.value.avatar || avatar1
currentAvatar.value = userForm.value.avatar
currentUserName.value = userForm.value.name
userName.value = userForm.value.name
}
} catch (error) {
console.error(error)
}
}
// 调用API 新增用户
async function addUser() {
if (isAdding.value) {
$toast.error(`正在创建【${userForm.value.name}】用户,请稍后`)
return
}
if (!currentUserName.value) {
$toast.error('用户名不能为空')
return
} else userForm.value.name = currentUserName.value
// 重名检查
if (props.usernames && props.usernames.includes(userForm.value.name)) {
$toast.error('用户名已存在')
return
}
if (!userForm.value?.name || !newPassword.value) return
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error('两次输入的密码不一致')
return
}
userForm.value.password = newPassword.value
}
isAdding.value = true
startNProgress()
try {
const result: { [key: string]: string } = await api.post('user/', userForm.value)
if (result.success) {
$toast.success(`用户【${userForm.value.name}】创建成功`)
emit('save')
} else {
$toast.error(`创建用户失败:${result.message}`)
// 清除用户名
userForm.value.name = ''
}
} catch (error) {
console.error(error)
}
doneNProgress()
isAdding.value = false
}
// 调用API更新用户信息
async function updateUser() {
if (isUpdating.value) {
$toast.error(`正在更新【${userForm.value.name}】用户,请稍后`)
return
}
if (!currentUserName.value) {
$toast.error('用户名不能为空')
return
}
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error('两次输入的密码不一致')
return
}
userForm.value.password = newPassword.value
}
const oldUserName = userForm.value.name
userForm.value.name = currentUserName.value
const oldAvatar = userForm.value.avatar
userForm.value.avatar = currentAvatar.value
isUpdating.value = true
startNProgress()
try {
const result: { [key: string]: any } = await api.put('user/', userForm.value)
if (result.success) {
if (oldUserName !== currentUserName.value) {
$toast.success(`${oldUserName}】更名【${currentUserName.value}】, 更新成功!`)
// 如果是当前登录用户,更新当前用户名称显示
if (isCurrentUser.value) {
userStore.setUserName(currentUserName.value)
}
} else {
$toast.success(`${userForm.value?.name}】更新成功!`)
}
// 更新本地头像显示
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
userStore.setAvatar(currentAvatar.value)
}
emit('save')
} else {
if (oldUserName !== currentUserName.value) {
$toast.error(`${oldUserName}】更名【${currentUserName.value}】, 更新失败:${result.message}`)
currentUserName.value = oldUserName
} else {
$toast.error(`${userForm.value?.name}】更新失败:${result.message}`)
}
}
//失败缓存值还原
currentUserName.value = userForm.value.name
userForm.value.name = oldUserName
currentAvatar.value = userForm.value.avatar
userForm.value.avatar = oldAvatar
userForm.value.password = ''
} catch (error) {
$toast.error(`${userForm.value?.name}】更新失败!`)
console.error(error)
}
doneNProgress()
isUpdating.value = false
}
// 用户状态转换true/false转换为1/0
const userStatus = computed({
get: () => (userForm.value.is_active ? 1 : 0),
set: (value: number) => {
userForm.value.is_active = value === 1
},
})
// 计算是否有用户管理权限
const canControl = computed(() => {
// 新增用户时,有权限
if (props.oper === 'add') {
return true
} else {
// 调用isCurrentUser函数判断是否为当前用户
return !isCurrentUser.value
}
})
// 检查是否为当前用户
const isCurrentUser = computed(() => {
return props.username === currentLoginUser
})
onMounted(() => {
if (props.oper !== 'add') {
fetchUserInfo()
}
})
</script>
<template>
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
class="rounded-t"
>
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardItem>
<!-- 👉 Avatar -->
<div class="flex flex-row">
<VAvatar rounded="lg" size="100" class="me-5" :image="currentAvatar" />
<!-- 👉 Upload Photo -->
<div class="flex flex-col justify-center gap-5">
<div class="flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
</VBtn>
<input
ref="refInputEl"
type="file"
name="file"
accept=".jpeg,.png,.jpg,GIF"
hidden
@input="changeAvatar"
/>
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
<VIcon icon="mdi-refresh" />
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
</VBtn>
<VBtn
type="reset"
:color="props.oper === 'add' ? 'info' : 'error'"
variant="tonal"
@click="resetDefaultAvatar"
>
<VIcon icon="mdi-image-sync-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
</VBtn>
</div>
<p class="text-body-1 mb-0">允许 JPGPNGGIFWEBP 格式 最大尺寸 800KB</p>
</div>
</div>
</VCardItem>
<VCardText>
<VForm @submit.prevent="() => {}">
<VDivider class="my-10">
<span>用户基础设置</span>
</VDivider>
<VRow>
<VCol md="6" cols="12">
<VTextField
v-model="currentUserName"
density="comfortable"
:readonly="props.oper !== 'add'"
label="用户名"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.email" density="comfortable" clearable label="邮箱" type="email" />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="newPassword"
density="comfortable"
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
clearable
label="密码"
autocomplete=""
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6">
<!-- 👉 confirm password -->
<VTextField
v-model="confirmPassword"
density="comfortable"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
clearable
label="确认密码"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6" v-if="canControl">
<VSelect
v-model="userStatus"
:items="statusItems"
item-text="title"
item-value="value"
label="状态"
dense
/>
</VCol>
</VRow>
<VDivider class="my-10">
<span>账号绑定</span>
</VDivider>
<VRow>
<VCol cols="12" md="6">
<VTextField v-model="userForm.settings.wechat_userid" density="comfortable" clearable label="微信用户" />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.telegram_userid"
density="comfortable"
clearable
label="Telegram用户"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.settings.slack_userid" density="comfortable" clearable label="Slack用户" />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.vocechat_userid"
density="comfortable"
clearable
label="VoceChat用户"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.synologychat_userid"
density="comfortable"
clearable
label="SynologyChat用户"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.settings.douban_userid" density="comfortable" clearable label="豆瓣用户" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
v-if="props.oper === 'add'"
:disabled="isAdding"
color="primary"
variant="elevated"
@click="addUser"
prepend-icon="mdi-plus"
class="px-5"
>
<span v-if="isAdding">创建中...</span>
<span v-else>创建</span>
</VBtn>
<VBtn
v-else
:disabled="isUpdating"
color="primary"
variant="elevated"
@click="updateUser"
prepend-icon="mdi-content-save"
class="px-5"
>
<span v-if="isUpdating">更新中...</span>
<span v-else>更新</span>
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,177 +0,0 @@
<script lang="ts" setup>
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
// 定义事件
const emit = defineEmits(['done', 'close'])
// 提示框
const $toast = useToast()
// 是否加载中
const loading = ref(false)
// 用户认证表单
const authForm = ref<any>({
site: null,
params: {},
})
// 所有认证站点
const authSites = ref<{
[key: string]: {
name: string
icon: string
params: { [key: string]: any }
}
}>({})
// 生成站点拉选项
const dropdownItems = computed(() => {
return Object.keys(authSites.value).map(key => {
return {
key,
name: authSites.value[key].name,
prependAvatar: authSites.value[key].icon,
}
})
})
// 读取authSites.params生成表单配置列表
const formFields = computed(() => {
const site = authSites.value[authForm.value.site]
return Object.keys(site?.params || {})
.filter(item => {
return site.params[item].name && site.params[item].type
})
.map(key => {
return {
key,
site: authForm.value.site,
name: site.params[key].name,
type: site.params[key].type,
placeholder: site.params[key].placeholder,
tooltip: site.params[key].tooltip,
}
})
})
// 查询之前使用的认证参数
async function loadLastAuthParams() {
try {
const result: { [key: string]: any } = await api.get(`system/setting/UserSiteAuthParams`)
if (result.success) {
const ret = result.data?.value
if (ret && !isNullOrEmptyObject(ret.params)) {
authForm.value = ret
}
}
} catch (e) {
console.error(e)
}
}
// 加载认证站点配置
async function loadAuthSites() {
try {
authSites.value = (await api.get(`site/auth`)) || {}
} catch (e) {
console.error(e)
}
}
// 完成
async function handleDone() {
await checkUser()
}
// 认证处理
async function checkUser() {
if (!authForm.value.site) {
$toast.error('请选择认证站点!')
return
}
if (!authSites.value[authForm.value.site]) {
$toast.error('站点配置不存在!')
return
}
if (formFields.value.length > 0) {
for (const field of formFields.value) {
if (!authForm.value.params[field.site.toUpperCase() + '_' + field.key.toUpperCase()]) {
$toast.error(`请输入${field.name}`)
return
}
}
}
loading.value = true
try {
const result: { [key: string]: any } = await api.post(`site/auth`, authForm.value)
if (result.success) {
$toast.success('用户认证成功,请重新登录!')
// 1秒后刷新页面
setTimeout(() => {
emit('done')
}, 1000)
} else {
$toast.error(`认证失败:${result.message}`)
}
} catch (e) {
console.error(e)
}
loading.value = false
}
onMounted(async () => {
await loadAuthSites()
loadLastAuthParams()
})
</script>
<template>
<VDialog width="40rem" max-height="85vh">
<VCard title="用户认证" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VSelect
v-model="authForm.site"
:items="dropdownItems"
item-value="key"
item-title="name"
label="选择认证站点"
item-props
>
</VSelect>
</VCol>
</VRow>
<VRow>
<VCol v-for="param in formFields" :key="param.key">
<VTextField
v-model="authForm.params[param.site.toUpperCase() + '_' + param.key.toUpperCase()]"
:type="param.type"
:label="param.name"
:placeholder="param.placeholder"
:hint="param.tooltip"
clearable
persistent-hint
/>
</VCol>
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn
variant="elevated"
@click="handleDone"
prepend-icon="mdi-check"
class="px-5"
size="large"
:disabled="loading"
>
开始认证
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,303 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { MiniMap } from '@vue-flow/minimap'
import useDragAndDrop from '@core/utils/workflow'
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
const { onConnect, addEdges, nodes, edges } = useVueFlow()
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
onConnect(addEdges)
// 自定义节点类型
const nodeTypes: Record<string, any> = ref({})
// 自动扫描目录下所有的 .vue 文件
const components = import.meta.glob('../workflow/*Action.vue')
// 动态加载某个组件
const loadComponent = async (componentName: string) => {
const component = components[`../workflow/${componentName}.vue`]
if (component) {
return ((await component()) as any).default
}
throw new Error(`组件 ${componentName} 未找到`)
}
// 将所有components中的组件加载到nodeTypes中
for (const path in components) {
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
if (!componentName) {
continue
}
loadComponent(componentName).then(component => {
nodeTypes.value[componentName] = markRaw(component)
})
}
// 定义输入参数
const props = defineProps({
workflow: Object as PropType<Workflow>,
})
// 定义事件
const emit = defineEmits(['close', 'save'])
// 站点编辑表单数据
const workflowForm = ref<any>(props.workflow || {})
// 提示框
const $toast = useToast()
// 导入代码对话框
const importCodeDialog = ref(false)
// 调用API 编辑任务
async function updateWorkflow() {
// 更新节点和流程
workflowForm.value.actions = nodes
workflowForm.value.flows = edges
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
if (result.success) {
$toast.success(`保存任务流程成功!`)
emit('save')
} else {
$toast.error(`保存任务流程失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
}
// 保存导入的代码,直接覆盖原有值
function saveCodeString(type: string, code: any) {
try {
if (code) {
const codeObject = JSON.parse(code.value)
if (type === 'workflow') {
nodes.value = codeObject.actions || []
edges.value = codeObject.flows || []
}
importCodeDialog.value = false
$toast.success('导入成功!')
}
} catch (error) {
$toast.error('导入失败!')
console.error(error)
}
}
// 分享工作流程
function shareWorkflow() {
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
navigator.clipboard.writeText(codeString)
$toast.success('任务流程代码已复制到剪贴板!')
}
// 删除选中节点或连接线
const deleteSelectedNodeOrEdge = () => {
// 删除选中的节点
const selectedNode = nodes.value.find((node: { selected: any }) => node.selected)
if (selectedNode) {
// 删除节点
nodes.value = nodes.value.filter((node: { id: any }) => node.id !== selectedNode.id)
// 删除与该节点相关的 edges
edges.value = edges.value.filter(
(edge: { source: any; target: any }) => edge.source !== selectedNode.id && edge.target !== selectedNode.id,
)
}
// 删除选中的连接线
const selectedEdge = edges.value.find((edge: { selected: any }) => edge.selected)
if (selectedEdge) {
// 删除连接线
edges.value = edges.value.filter((edge: { id: any }) => edge.id !== selectedEdge.id)
}
}
// 键盘按键事件处理
const handleKeyDown = (event: { key: string }) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
deleteSelectedNodeOrEdge()
}
}
onMounted(() => {
if (props.workflow) {
nodes.value = props.workflow.actions ?? []
edges.value = props.workflow.flows ?? []
}
})
</script>
<template>
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VCard>
<!-- Toolbar -->
<div>
<VToolbar color="primary">
<VToolbarItems>
<VBtn icon @click="emit('close')" class="ms-3">
<VIcon size="large" color="white" icon="mdi-close" />
</VBtn>
</VToolbarItems>
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
<VToolbarItems>
<VBtn icon @click="importCodeDialog = true">
<VIcon size="large" color="white" icon="mdi-import" />
</VBtn>
<VBtn icon @click="shareWorkflow">
<VIcon size="large" color="white" icon="mdi-share" />
</VBtn>
<VBtn icon @click="updateWorkflow" class="mx-5">
<VIcon size="large" color="white" icon="mdi-content-save" />
</VBtn>
</VToolbarItems>
</VToolbar>
</div>
<VDivider />
<VCardText class="px-0 py-0">
<div class="dnd-flow" @drop="onDrop">
<VueFlow
:nodes="nodes"
:edges="edges"
:nodeTypes="nodeTypes"
:default-edge-options="{ type: 'animation', animated: true }"
:edge-updater-radius="10"
@dragover="onDragOver"
@dragleave="onDragLeave"
@keydown="handleKeyDown"
auto-connect
>
<MiniMap />
<DropzoneBackground
:style="{
backgroundColor: isDragOver ? '#e7f3ff' : 'transparent',
transition: 'background-color 0.2s ease',
}"
>
</DropzoneBackground>
</VueFlow>
<WorkflowSidebar />
</div>
</VCardText>
</VCard>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
title="导入任务流程"
dataType="workflow"
@close="importCodeDialog = false"
@save="saveCodeString"
/>
</VDialog>
</template>
<style>
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/controls/dist/style.css';
@import '@vue-flow/minimap/dist/style.css';
@import '@vue-flow/node-resizer/dist/style.css';
.vue-flow__minimap {
transform: scale(75%);
transform-origin: bottom right;
}
.dnd-flow {
flex-direction: column;
display: flex;
height: 100%;
}
.dnd-flow aside {
color: #fff;
font-weight: 700;
border-right: 1px solid #eee;
padding: 15px 10px;
font-size: 12px;
background: #10b981bf;
-webkit-box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.3);
box-shadow: 0 5px 10px #0000004d;
}
.dnd-flow aside .nodes > * {
margin-bottom: 10px;
cursor: grab;
font-weight: 500;
-webkit-box-shadow: 5px 5px 10px 2px rgba(0, 0, 0, 0.25);
box-shadow: 5px 5px 10px 2px #00000040;
}
.dnd-flow aside .description {
margin-bottom: 10px;
}
.dnd-flow .vue-flow-wrapper {
flex-grow: 1;
height: 100%;
}
@media screen and (min-width: 640px) {
.dnd-flow {
flex-direction: row;
}
.dnd-flow aside {
max-width: 25%;
}
}
@media screen and (max-width: 639px) {
.dnd-flow aside .nodes {
display: flex;
flex-direction: row;
gap: 5px;
}
}
.dropzone-background {
position: relative;
height: 100%;
width: 100%;
}
.dropzone-background .overlay {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
pointer-events: none;
}
.vue-flow__handle {
height: 24px;
width: 8px;
border-radius: 4px;
}
.vue-flow__edge-path,
.vue-flow__connection-path {
stroke-width: 3;
}
.vue-flow__handle-left {
background-color: rgb(var(--v-theme-info));
}
.vue-flow__handle-right {
background-color: rgb(var(--v-theme-error));
}
</style>

Some files were not shown because too many files have changed in this diff Show More