Compare commits

..

22 Commits
v2.2.7 ... 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
178 changed files with 10205 additions and 17386 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 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: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- v2 - main
paths: paths:
- 'package.json' - package.json
jobs: jobs:
build: build:
@@ -42,21 +42,13 @@ jobs:
echo "$frontend_version" > dist/version.txt echo "$frontend_version" > dist/version.txt
zip -r dist.zip dist 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 - name: Generate Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v1
with: with:
tag_name: ${{ env.frontend_version }} tag_name: ${{ env.frontend_version }}
name: ${{ env.frontend_version }} name: ${{ env.frontend_version }}
draft: false draft: false
prerelease: false prerelease: false
make_latest: false
files: | files: |
dist.zip dist.zip
env: env:

329
auto-imports.d.ts vendored
View File

@@ -3,7 +3,6 @@
// @ts-nocheck // @ts-nocheck
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import // Generated by unplugin-auto-import
// biome-ignore lint: disable
export {} export {}
declare global { declare global {
const EffectScope: typeof import('vue')['EffectScope'] const EffectScope: typeof import('vue')['EffectScope']
@@ -67,7 +66,6 @@ declare global {
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside'] const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated'] const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured'] const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke'] const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress'] const onLongPress: typeof import('@vueuse/core')['onLongPress']
@@ -79,7 +77,6 @@ declare global {
const onStartTyping: typeof import('@vueuse/core')['onStartTyping'] const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted'] const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated'] const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide'] const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal'] const provideLocal: typeof import('@vueuse/core')['provideLocal']
@@ -193,7 +190,6 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen'] const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad'] const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation'] const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle'] const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage'] const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll'] const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
@@ -213,7 +209,6 @@ declare global {
const useMemoize: typeof import('@vueuse/core')['useMemoize'] const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory'] const useMemory: typeof import('@vueuse/core')['useMemory']
const useMin: typeof import('@vueuse/math')['useMin'] const useMin: typeof import('@vueuse/math')['useMin']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted'] const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse'] const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement'] const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
@@ -239,7 +234,6 @@ declare global {
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark'] const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages'] const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion'] const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious'] const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useProjection: typeof import('@vueuse/math')['useProjection'] const useProjection: typeof import('@vueuse/math')['useProjection']
const useRafFn: typeof import('@vueuse/core')['useRafFn'] const useRafFn: typeof import('@vueuse/core')['useRafFn']
@@ -248,7 +242,6 @@ declare global {
const useRound: typeof import('@vueuse/math')['useRound'] const useRound: typeof import('@vueuse/math')['useRound']
const useRoute: typeof import('vue-router')['useRoute'] const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter'] const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation'] const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea'] const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag'] const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
@@ -268,7 +261,6 @@ declare global {
const useSum: typeof import('@vueuse/math')['useSum'] const useSum: typeof import('@vueuse/math')['useSum']
const useSupported: typeof import('@vueuse/core')['useSupported'] const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe'] const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList'] const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection'] const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection'] const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
@@ -321,10 +313,9 @@ declare global {
// for type re-export // for type re-export
declare global { declare global {
// @ts-ignore // @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') import('vue')
} }
// for vue template auto import // for vue template auto import
import { UnwrapRef } from 'vue' import { UnwrapRef } from 'vue'
declare module 'vue' { declare module 'vue' {
@@ -391,7 +382,6 @@ declare module 'vue' {
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']> readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']> readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']> readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onElementRemoval: UnwrapRef<typeof import('@vueuse/core')['onElementRemoval']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']> readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']> readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']> readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
@@ -403,7 +393,6 @@ declare module 'vue' {
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']> readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']> readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']> readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']> readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
@@ -517,7 +506,6 @@ declare module 'vue' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']> readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']> readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']> readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']> readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']> readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']> readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
@@ -537,7 +525,6 @@ declare module 'vue' {
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']> readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']> readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']> readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']> readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']> readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']> readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
@@ -563,7 +550,6 @@ declare module 'vue' {
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']> readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']> readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']> 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 usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']> readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']> readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
@@ -572,7 +558,6 @@ declare module 'vue' {
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']> readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']> readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']> 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 useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']> readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']> readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
@@ -592,7 +577,6 @@ declare module 'vue' {
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']> readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']> readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']> readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']> readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']> readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']> readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
@@ -642,4 +626,313 @@ declare module 'vue' {
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']> readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']> 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 */ /* eslint-disable */
/* prettier-ignore */
// @ts-nocheck // @ts-nocheck
// Generated by unplugin-vue-components // Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
export {} export {}
/* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default'] DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']

View File

@@ -1,6 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" <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);">
<head> <head>
<meta http-equiv="pragma" content="no-cache"> <meta http-equiv="pragma" content="no-cache">
@@ -30,17 +29,10 @@
<meta name="HandheldFriendly" content="True" /> <meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" /> <meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" /> <link rel="stylesheet" type="text/css" href="/loader.css" />
<script> <link rel="preload" href="index.js" as="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>
</head> </head>
<body style="margin: 0;"> <body>
<div id="loading-bg"> <div id="loading-bg">
<div class="loading-logo"> <div class="loading-logo">
<!-- Logo --> <!-- Logo -->
@@ -155,6 +147,16 @@
</div> </div>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <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> </body>
</html> </html>

View File

@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "2.2.7", "version": "1.9.17",
"private": true, "private": true,
"bin": "dist/service.js", "bin": "dist/service.js",
"scripts": { "scripts": {
@@ -19,86 +19,86 @@
] ]
}, },
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.15", "@fullcalendar/core": "^6.1.8",
"@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.15", "@fullcalendar/interaction": "^6.1.7",
"@fullcalendar/list": "^6.1.15", "@fullcalendar/list": "^6.1.7",
"@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/timegrid": "^6.1.7",
"@fullcalendar/vue3": "^6.1.15", "@fullcalendar/vue3": "^6.1.8",
"@iconify/utils": "^2.2.1", "@iconify/utils": "^2.1.22",
"@vue-js-cron/vuetify": "^5.0.9", "@vueuse/core": "^10.1.2",
"@vueuse/core": "^12.4.0", "@vueuse/math": "^10.1.2",
"@vueuse/math": "^12.4.0", "ace-builds": "^1.32.6",
"ace-builds": "^1.37.4", "apexcharts-clevision": "^3.28.5",
"apexcharts": "^4.0.0", "axios": "1.6.8",
"axios": "^1.7.9", "colorthief": "^2.4.0",
"colorthief": "^2.6.0", "dayjs": "^1.11.10",
"copy-to-clipboard": "^3.3.3", "express": "^4.18.2",
"dayjs": "^1.11.13", "express-http-proxy": "^2.0.0",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.4.1",
"sass": "^1.83.4", "sass": "^1.59.3",
"tailwindcss": "^ 3.4.17", "tailwindcss": "^3.3.2",
"vue": "^3.5.13", "unplugin-vue-define-options": "^1.3.5",
"vue-router": "^4.5.0", "vue": "^3.3.2",
"vue-toast-notification": "^3.1.3", "vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.8.0", "vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^2.0.0", "vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"vuetify": "3.7.3", "vuetify": "3.6.8",
"vuetify-use-dialog": "^0.6.11", "vuetify-use-dialog": "^0.6.11",
"vuex": "^4.1.0", "vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0", "vuex-persistedstate": "^4.1.0",
"webfontloader": "^1.6.28" "webfontloader": "^1.6.28"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config-vue": "^0.43.1",
"@iconify-json/mdi": "^1.1.52", "@iconify-json/mdi": "^1.1.52",
"@iconify/tools": "^4.0.4", "@iconify/tools": "^4.0.4",
"@iconify/vue": "^4.3.0", "@iconify/vue": "4.1.1",
"@intlify/unplugin-vue-i18n": "^6.0.3", "@intlify/unplugin-vue-i18n": "^4.0.0",
"@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/node": "^20.1.4", "@types/node": "^20.1.4",
"@types/webfontloader": "^1.6.34", "@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0", "@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^8.20.0", "@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^4.1.1", "@vitejs/plugin-vue-jsx": "^3.0.0",
"autoprefixer": "^10.4.14", "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-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0", "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-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^3.0.1", "eslint-plugin-sonarjs": "^0.25.1",
"eslint-plugin-unicorn": "^56.0.1", "eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-vue": "^9.12.0", "eslint-plugin-vue": "^9.12.0",
"postcss": "^8.5.1", "postcss": "8",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"stylelint": "^16.13.2", "stylelint": "16.3.1",
"stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-idiomatic-order": "10.0.0",
"stylelint-config-standard-scss": "^14.0.0", "stylelint-config-standard-scss": "13.1.0",
"stylelint-use-logical-spec": "5.0.1", "stylelint-use-logical-spec": "5.0.1",
"terser": "^5.36.0",
"type-fest": "^4.15.0", "type-fest": "^4.15.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"unplugin-auto-import": "^19.0.0", "unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^28.0.0", "unplugin-vue-components": "^0.26.0",
"unplugin-vue-define-options": "^1.5.3", "vite": "^5.2.8",
"vite": "^5.4.11",
"vite-plugin-pages": "^0.32.1", "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-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4", "vite-plugin-vuetify": "2.0.3",
"vue-shepherd": "^4.1.0", "vue-shepherd": "^3.0.0",
"vue-tsc": "^2.0.10", "vue-tsc": "^2.0.10"
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
}, },
"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 { #loading-bg {
position: fixed; position: absolute;
z-index: 9999; z-index: 999;
display: block; display: block;
background: var(--initial-loader-bg, #fff); background: var(--initial-loader-bg, #fff);
block-size: 100vh; block-size: 100vh;

View File

@@ -5,7 +5,7 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
import api from '@/api' import api from '@/api'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils' import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import { saveLocalTheme } from '../utils/theme' import { VAceEditor } from 'vue3-ace-editor'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
@@ -103,7 +103,8 @@ function updateTheme() {
savedTheme.value = theme savedTheme.value = theme
themeTransition() 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 currentThemeName.value = nextTheme
// 保存主题到服务端 // 保存主题到服务端
try { try {
api.post('/user/config/Layout', { api.post('/user/config/theme', nextTheme, {
theme: nextTheme, headers: {
'Content-Type': 'text/plain',
},
}) })
} catch (e) { } catch (e) {
console.error('保存主题到服务端失败') console.error('保存主题到服务端失败')
@@ -175,7 +178,7 @@ async function saveCustomCSS() {
}, },
}) })
if (result.success) $toast.success('自定义CSS保存成功,请刷新页面生效') if (result.success) $toast.success('自定义CSS保存成功')
} catch (e) { } catch (e) {
console.error('保存自定义 CSS 到服务端失败') console.error('保存自定义 CSS 到服务端失败')
} }
@@ -209,7 +212,7 @@ onMounted(() => {
</VList> </VList>
</VMenu> </VMenu>
<!-- 自定义 CSS -- --> <!-- 自定义 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="自定义主题风格"> <VCard title="自定义主题风格">
<DialogCloseBtn @click="cssDialog = false" /> <DialogCloseBtn @click="cssDialog = false" />
<VDivider /> <VDivider />

View File

@@ -1,5 +1,5 @@
.auth-wrapper { .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 { .auth-footer-mask {

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ export function useDefer(maxFrameCount = 1) {
const refreshFrameCount = () => { const refreshFrameCount = () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
frameCount.value++ frameCount.value++
if (frameCount.value < maxFrameCount) refreshFrameCount() if (frameCount.value < maxFrameCount)
refreshFrameCount()
}) })
} }
refreshFrameCount() refreshFrameCount()
@@ -18,9 +19,3 @@ export function useDefer(maxFrameCount = 1) {
return frameCount.value >= showInFrameCount 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')}` : '') export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
// 格式化为xx[TGMK]B // 格式化为xx[TGMK]B
export function formatFileSize(bytes: number, decimals = 2, prefix = false) { export function formatFileSize(bytes: number) {
// 负数标记 if (bytes < 0) throw new Error('字节数不能为负数。')
let negative = false
let size = bytes
if (bytes < 0) {
negative = true
size = Math.abs(bytes)
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'] const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0 let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) { while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024 size /= 1024
unitIndex++ unitIndex++
} }
if (negative) return `-${size.toFixed(decimals)} ${units[unitIndex]}`
else return `${size.toFixed(2)} ${units[unitIndex]}`
return prefix ? `+${size.toFixed(decimals)} ${units[unitIndex]}` : `${size.toFixed(decimals)} ${units[unitIndex]}`
} }
// 将时间秒格式化为时分秒 // 将时间秒格式化为时分秒

View File

@@ -1,10 +1,9 @@
import copy from 'copy-to-clipboard'
// 请求和获取剪贴板内容 // 请求和获取剪贴板内容
export async function getClipboardContent() { export async function getClipboardContent() {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
return await navigator.clipboard.readText() return await navigator.clipboard.readText()
} else { }
else {
const input = document.createElement('textarea') const input = document.createElement('textarea')
document.body.appendChild(input) document.body.appendChild(input)
input.select() input.select()
@@ -15,10 +14,19 @@ export async function getClipboardContent() {
} }
} }
// 将内容复制到剪贴板 // 将内容复制到剪贴板,兼容非安全域场景
export async function copyToClipboard(content: string) { export async function copyToClipboard(content: string) {
const success = copy(content) if (navigator.clipboard && window.isSecureContext) {
return success 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 // VAPID公钥转Uint8Array
@@ -34,12 +42,3 @@ export function urlBase64ToUint8Array(base64String: string) {
} }
return outputArray 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 +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"> <RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
<div class="d-flex" v-html="logo" /> <div class="d-flex" v-html="logo" />
<h1 class="font-weight-bold leading-normal text-xl"> <h1 class="font-weight-bold leading-normal text-2xl">
MOVIEPILOT <span class="text-sm text-gray-500">v2</span> MOVIEPILOT
</h1> </h1>
</RouterLink> </RouterLink>
</slot> </slot>

View File

@@ -5,7 +5,7 @@
@use "@configured-variables" as variables; @use "@configured-variables" as variables;
html { 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)); background: rgb(var(--v-theme-background));
overflow-y: overlay; overflow-y: overlay;
} }

View File

@@ -7,5 +7,5 @@
html { html {
box-sizing: border-box; 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

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

View File

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

@@ -37,13 +37,3 @@ api.interceptors.response.use(
) )
export default api 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 { export interface Subscribe {
// 订阅ID
id: number id: number
// 订阅名称 // 订阅名称
name: string name: string
@@ -14,10 +13,6 @@ export interface Subscribe {
tmdbid: number tmdbid: number
// 豆瓣ID // 豆瓣ID
doubanid?: string doubanid?: string
// Bangumi ID
bangumiid?: string
// 其它媒体ID
mediaid?: string
// 季号 // 季号
season?: number season?: number
// 海报 // 海报
@@ -48,7 +43,7 @@ export interface Subscribe {
lack_episode?: number lack_episode?: number
// 附加信息 // 附加信息
note?: string note?: string
// 状态N-新建 R-订阅中 P-待定 S-暂停 // 状态N-新建 R-订阅中
state: string state: string
// 最后更新时间 // 最后更新时间
last_update: string last_update: string
@@ -70,84 +65,12 @@ export interface Subscribe {
show_edit_dialog: boolean show_edit_dialog: boolean
// 编辑框打开状态 // 编辑框打开状态
page_open?: 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 { export interface TransferHistory {
// ID // ID
id: number id: number
// 源存储
src_storage?: string
// 目标存储
dest_storage?: string
// 源目录 // 源目录
src?: string src?: string
// 目的目录 // 目的目录
@@ -190,7 +113,7 @@ export interface TransferHistory {
export interface MediaInfo { export interface MediaInfo {
// 来源themoviedb、douban、bangumi // 来源themoviedb、douban、bangumi
source?: string source?: string
// 类型 电影、电视剧、合集 // 类型 电影、电视剧
type?: string type?: string
// 媒体标题 // 媒体标题
title?: string title?: string
@@ -210,12 +133,6 @@ export interface MediaInfo {
douban_id?: string douban_id?: string
// Bangumi ID // Bangumi ID
bangumi_id?: string bangumi_id?: string
// 合集ID
collection_id?: number
// 其它媒体ID前缀
mediaid_prefix?: string
// 其它媒体ID值
media_id?: string
// 媒体原语种 // 媒体原语种
original_language?: string original_language?: string
// 媒体原发行标题 // 媒体原发行标题
@@ -401,8 +318,6 @@ export interface Site {
pri?: number pri?: number
// RSS地址 // RSS地址
rss?: string rss?: string
// 下载器
downloader: string
// Cookie // Cookie
cookie?: string cookie?: string
// ApiKey // ApiKey
@@ -451,48 +366,6 @@ export interface SiteStatistic {
note?: string 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 { export interface DownloadingInfo {
// HASH // HASH
@@ -521,8 +394,6 @@ export interface DownloadingInfo {
userid?: string userid?: string
// 下载用户名称 // 下载用户名称
username?: string username?: string
// 剩余时间
left_time?: string
} }
// 缺失剧集信息 // 缺失剧集信息
@@ -621,8 +492,6 @@ export interface TorrentInfo {
site_proxy: boolean site_proxy: boolean
// 站点优先级 // 站点优先级
site_order: number site_order: number
// 站点下载器
site_downloader?: string
// 种子名称 // 种子名称
title?: string title?: string
// 种子副标题 // 种子副标题
@@ -773,10 +642,6 @@ export interface User {
avatar: string avatar: string
// 是否开启双重验证 // 是否开启双重验证
is_otp: boolean is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }
// 用户个性化设置 json
settings: { [key: string]: string | null }
} }
// 存储空间 // 存储空间
@@ -876,8 +741,6 @@ export interface EndPoints {
// 文件浏览项目 // 文件浏览项目
export interface FileItem { export interface FileItem {
// 存储
storage: string
// 类型 dir/file // 类型 dir/file
type: string type: string
// 文件名 // 文件名
@@ -980,249 +843,22 @@ export interface SystemNotification {
date: string date: string
} }
// 下载器配置 // 下载目录/媒体库目录
export interface DownloaderConf { export interface MediaDirectory {
// 名称 // 类型 download/library
name: string type?: string
// 类型 qbittorrent/transmission // 别名
type: string name?: string
// 是否默认 // 路径
default: boolean path?: string
// 配置 // 媒体类型 电影/电视剧
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_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 category?: string
} // 刮削媒体信息
scrape?: boolean
// 订阅下载文件详情 // 自动二级分类,未指定类别时自动分类
export interface SubscribeDownloadFileInfo { auto_category?: boolean
// 种子名称 // 优先级
torrent_title?: string priority?: number
// 站点名称
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[]
} }

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 type { Axios } from 'axios'
import FileList from './filebrowser/FileList.vue' import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue' import FileToolbar from './filebrowser/FileToolbar.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types' import type { EndPoints, FileItem } from '@/api/types'
import { storageOptions } from '@/api/constants' import api from '@/api'
import AliyunAuthDialog from './dialog/AliyunAuthDialog.vue'
import U115AuthDialog from './dialog/U115AuthDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
storages: Array as PropType<StorageConf[]>, storages: String,
tree: Boolean, tree: Boolean,
endpoints: Object as PropType<EndPoints>, endpoints: Object as PropType<EndPoints>,
axios: { axios: {
@@ -19,101 +22,49 @@ const props = defineProps({
type: Object as PropType<FileItem>, type: Object as PropType<FileItem>,
required: true, required: true,
}, },
itemstack: { itemstack: Array as PropType<FileItem[]>,
type: Array as PropType<FileItem[]>,
default: () => [],
},
}) })
// 对外事件 // 对外事件
const emit = defineEmits(['pathchanged']) 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 = { const fileIcons = {
// 压缩包
zip: 'mdi-folder-zip-outline', zip: 'mdi-folder-zip-outline',
rar: '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', htm: 'mdi-language-html5',
html: 'mdi-language-html5', html: 'mdi-language-html5',
vue: 'mdi-vuejs',
js: 'mdi-nodejs', js: 'mdi-nodejs',
ts: 'mdi-language-typescript',
json: 'mdi-file-document-outline', 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', md: 'mdi-language-markdown-outline',
markdown: 'mdi-language-markdown-outline', pdf: 'mdi-file-pdf',
// 图片 png: 'mdi-file-image',
png: 'mdi-file-png-box', jpg: 'mdi-file-image',
jpg: 'mdi-file-jpg-box', jpeg: 'mdi-file-image',
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',
// 视频
mp4: 'mdi-filmstrip', mp4: 'mdi-filmstrip',
mkv: 'mdi-filmstrip', mkv: 'mdi-filmstrip',
avi: 'mdi-filmstrip', avi: 'mdi-filmstrip',
wmv: 'mdi-filmstrip', wmv: 'mdi-filmstrip',
mov: 'mdi-filmstrip', mov: 'mdi-filmstrip',
flv: 'mdi-filmstrip',
rmvb: 'mdi-filmstrip',
// 文档
txt: 'mdi-file-document-outline', 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', 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', other: 'mdi-file-outline',
} }
@@ -125,11 +76,19 @@ const activeStorage = ref('local')
const refreshPending = ref(false) const refreshPending = ref(false)
// 排序 // 排序
const sort = ref('name') 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 storagesArray = computed(() => {
const storageCodes = props.storages?.map(item => item.type) const storageCodes = props.storages?.split(',')
return storageOptions.filter(item => storageCodes?.includes(item.value)) return availableStorages.filter(item => storageCodes?.includes(item.code))
}) })
// 方法 // 方法
@@ -138,10 +97,47 @@ function loadingChanged(loading: number) {
else if (loading > 0) loading-- 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) { 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 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 sort.value = s
refreshPending.value = true refreshPending.value = true
} }
// aliyun认证完成
function aliyunAuthDone() {
aliyunAuthDialog.value = false
activeStorage.value = 'aliyun'
}
// u115认证完成
function u115AuthDone() {
u115AuthDialog.value = false
activeStorage.value = 'u115'
}
</script> </script>
<template> <template>
@@ -187,4 +195,11 @@ function sortChanged(s: string) {
/> />
</div> </div>
</VCard> </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> </template>

View File

@@ -73,3 +73,9 @@ const getImgUrl = computed(() => {
</template> </template>
</VHover> </VHover>
</template> </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'
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> <script lang="ts" setup>
import type { TransferDirectoryConf } from '@/api/types' import type { MediaDirectory } from '@/api/types'
import api from '@/api' import { VTextField } from 'vuetify/lib/components/index.mjs'
import { nextTick } from 'vue'
import { storageOptions } from '@/api/constants'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
type: String, // download/library type: String, // download/library
directory: { directory: {
type: Object as PropType<TransferDirectoryConf>, type: Object as PropType<MediaDirectory>,
required: true, // 必填参数 required: true, // 必填参数
}, },
categories: { categories: {
@@ -19,8 +17,8 @@ const props = defineProps({
height: String, height: String,
}) })
// 卡版是否折叠状态 // 路径
const isCollapsed = ref(true) const path = ref<string>('')
// 类型下拉字典 // 类型下拉字典
const typeItems = [ const typeItems = [
@@ -29,93 +27,6 @@ const typeItems = [
{ title: '电视剧', value: '电视剧' }, { 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']) const emit = defineEmits(['close', 'changed', 'update:modelValue'])
@@ -124,38 +35,18 @@ function onClose() {
emit('close') emit('close')
} }
// 路径更新
function updatePath(value: string) {
path.value = value
emit('update:modelValue', value)
}
// 根据选中的媒体类型,获取对应的媒体类别 // 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => { const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }] const default_value = [{ title: '全部', value: '' }]
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
return default_value.concat(props.categories[props.directory.media_type ?? '']) 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> </script>
<template> <template>
@@ -174,124 +65,40 @@ watch(
</IconBtn> </IconBtn>
</span> </span>
</VCardItem> </VCardItem>
<VCardText v-if="!isCollapsed"> <VCardText>
<VForm> <VForm>
<VRow> <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 <VSelect
v-model="props.directory.media_type" v-model="props.directory.media_type"
variant="underlined" variant="underlined"
:items="typeItems" :items="typeItems"
label="媒体类型" label="媒体类型"
@update:modelValue="props.directory.media_category = ''" @update:modelValue="props.directory.category = ''"
/> />
</VCol> </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> <VCol>
<VSelect <VSelect v-model="props.directory.category" variant="underlined" :items="getCategories" label="媒体类别" />
v-model="props.directory.monitor_type"
variant="underlined"
:items="transferSourceItems"
label="自动整理"
/>
</VCol> </VCol>
</VRow> </VRow>
<VRow v-if="$props.directory.monitor_type"> <VRow>
<VCol cols="12" v-if="$props.directory.monitor_type == 'monitor'"> <VCol v-if="!props.directory.category || props.directory.category === ''">
<VSelect <VSwitch v-model="props.directory.auto_category" label="自动分类"></VSwitch>
v-model="props.directory.monitor_mode"
variant="underlined"
:items="MonitorModeItems"
label="监控模式"
/>
</VCol> </VCol>
<VCol cols="4"> <VCol v-if="type === 'library'">
<VSelect <VSwitch v-model="props.directory.scrape" label="刮削元数据"></VSwitch>
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> </VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions class="text-center py-0">
<VSpacer />
<VBtn :icon="isCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'" @click.stop="isCollapsed = !isCollapsed" />
<VSpacer />
</VCardActions>
</VCard> </VCard>
</template> </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'
// 定义输入
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> <script lang="ts" setup>
import api from '@/api' import api from '@/api'
import type { DownloadingInfo } from '@/api/types' import type { DownloadingInfo } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -18,21 +17,16 @@ function getPercentage() {
// 速度 // 速度
function getSpeedText() { function getSpeedText() {
return `${formatFileSize(props.info?.size || 0)} ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${ return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${props.info?.left_time}`
props.info?.left_time
}`
} }
// 下载状态 // 下载状态
const isDownloading = ref(props.info?.state === 'downloading') const isDownloading = ref(props.info?.state === 'downloading')
// 监听props.info?.state的变化 // 监听props.info?.state的变化
watch( watch(() => props.info?.state, (newValue) => {
() => props.info?.state, isDownloading.value = newValue === 'downloading'
newValue => { })
isDownloading.value = newValue === 'downloading'
},
)
// 图片是否加载完成 // 图片是否加载完成
const imageLoaded = ref(false) const imageLoaded = ref(false)
@@ -51,10 +45,14 @@ function getTextClass() {
async function toggleDownload() { async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start' const operation = isDownloading.value ? 'stop' : 'start'
try { 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 if (result.success)
} catch (error) { isDownloading.value = !isDownloading.value
}
catch (error) {
console.error(error) console.error(error)
} }
} }
@@ -64,42 +62,67 @@ async function deleteDownload() {
try { try {
await api.delete(`download/${props.info?.hash}`) await api.delete(`download/${props.info?.hash}`)
cardState.value = false cardState.value = false
} catch (error) { }
catch (error) {
console.error(error) console.error(error)
} }
} }
</script> </script>
<template> <template>
<VCard v-if="cardState" :key="props.info?.hash"> <VCard
v-if="cardState"
:key="props.info?.hash"
>
<template #image> <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> </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.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> </VCardTitle>
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()"> <VCardSubtitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
{{ props.info?.title }} {{ props.info?.title }}
</VCardSubtitle> </VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()"> <VCardText
class="text-subtitle-1 pt-3 pb-1"
:class="getTextClass()"
>
{{ getSpeedText() }} {{ getSpeedText() }}
</VCardText> </VCardText>
<VCardText v-if="getPercentage() > 0" :class="getTextClass()"> <VCardText
v-if="getPercentage() > 0"
:class="getTextClass()"
>
<VProgressLinear :model-value="getPercentage()" /> <VProgressLinear :model-value="getPercentage()" />
</VCardText> </VCardText>
<VCardActions class="justify-space-between"> <VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" /> <VBtn
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" /> :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`"
@click="toggleDownload"
/>
<VBtn
color="error"
icon="mdi-trash-can-outline"
@click="deleteDownload"
/>
</VCardActions> </VCardActions>
</VCard> </VCard>
</template> </template>

View File

@@ -1,13 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { innerFilterRules } from '@/api/constants'
import { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
pri: String, pri: String,
maxpri: String,
rules: Array as PropType<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) emit('changed', props.pri, value)
} }
// 过滤规则下拉框 // 清洗规则中的换行符和多余空格,并在前后添加空格
const selectFilterOptions = ref<{ [key: string]: string }[]>([]) const cleanedRules = computed(() => {
return props.rules.map(rule => {
onMounted(() => { rule = rule ?? ''
selectFilterOptions.value = cloneDeep(innerFilterRules) return ` ${rule.replace(/[\r\n]/g, '').replace(/\s+/g, '')} `
if (props.custom_rules) { })
console.log(props.custom_rules)
props.custom_rules.map(rule => {
selectFilterOptions.value.push({
title: rule.name,
value: rule.id,
})
})
}
}) })
// 过滤规则下拉框
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> </script>
<template> <template>
<VCard variant="tonal"> <VCard variant="tonal" :width="props.width" :height="props.height">
<span class="absolute top-3 right-12"> <span class="absolute top-3 right-12">
<IconBtn> <IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" /> <VIcon class="cursor-move" icon="mdi-drag" />
@@ -53,13 +85,12 @@ onMounted(() => {
<VRow> <VRow>
<VCol> <VCol>
<VSelect <VSelect
v-model="props.rules" v-model="cleanedRules"
variant="underlined" variant="underlined"
:items="selectFilterOptions" :items="selectFilterOptions"
chips chips
label="" label=""
multiple multiple
clearable
@update:modelValue="filtersChanged" @update:modelValue="filtersChanged"
/> />
</VCol> </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'
// 输入参数
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() const img = new Image()
img.setAttribute('crossorigin', 'anonymous') img.setAttribute('crossorigin', 'anonymous')
img.src = imgSrc img.src = imgSrc
try { await new Promise(resolve => (img.onload = resolve))
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
}
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1) const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT const y = MARGIN_HEIGHT
@@ -181,3 +171,9 @@ onMounted(async () => {
</template> </template>
</VHover> </VHover>
</template> </template>
<style lang="scss">
.text-shadow {
text-shadow: 1px 1px #777;
}
</style>

View File

@@ -6,7 +6,7 @@ import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress' import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types' import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
import router, { registerAbortController } from '@/router' import router from '@/router'
import noImage from '@images/no-image.jpeg' import noImage from '@images/no-image.jpeg'
import tmdbImage from '@images/logos/tmdb.png' import tmdbImage from '@images/logos/tmdb.png'
import doubanImage from '@images/logos/douban-black.png' import doubanImage from '@images/logos/douban-black.png'
@@ -19,9 +19,6 @@ const props = defineProps({
height: String, height: String,
}) })
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
const store = useStore() const store = useStore()
// 提示框 // 提示框
@@ -67,18 +64,11 @@ const sourceIconDict: { [key: string]: any } = {
bangumi: bangumiImage, bangumi: bangumiImage,
} }
// 绑定MediaCard元素
const mediaCardRef = ref<HTMLElement | null>(null)
// 创建Intersection Observer实例
const observer = ref<IntersectionObserver | null>(null)
// 获得mediaid // 获得mediaid
function getMediaId() { function getMediaId() {
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}` 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?.douban_id) return `douban:${props.media?.douban_id}`
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}` else return `bangumi:${props.media?.bangumi_id}`
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
} }
// 订阅弹窗选择的多季 // 订阅弹窗选择的多季
@@ -97,6 +87,7 @@ function getChipColor(type: string) {
} }
// 添加订阅处理 // 添加订阅处理
async function handleAddSubscribe() { async function handleAddSubscribe() {
if (props.media?.type === '电视剧' && props.media?.tmdb_id) { if (props.media?.type === '电视剧' && props.media?.tmdb_id) {
// TMDB电视剧 // TMDB电视剧
@@ -147,7 +138,6 @@ async function addSubscribe(season = 0) {
tmdbid: props.media?.tmdb_id, tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id, doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id, bangumiid: props.media?.bangumi_id,
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
season, season,
best_version, best_version,
}) })
@@ -226,9 +216,6 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库 // 查询当前媒体是否已入库
async function handleCheckExists() { async function handleCheckExists() {
try { try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const result: { [key: string]: any } = await api.get('mediaserver/exists', { const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: { params: {
tmdbid: props.media?.tmdb_id, tmdbid: props.media?.tmdb_id,
@@ -237,7 +224,6 @@ async function handleCheckExists() {
season: props.media?.season, season: props.media?.season,
mtype: props.media?.type, mtype: props.media?.type,
}, },
signal,
}) })
if (result.success) isExists.value = true if (result.success) isExists.value = true
@@ -249,16 +235,13 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季 // 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) { async function checkSubscribe(season = 0) {
try { try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const mediaid = getMediaId() const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, { const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: { params: {
season, season,
title: props.media?.title, title: props.media?.title,
}, },
signal,
}) })
return result.id || null return result.id || null
@@ -281,6 +264,7 @@ async function checkSeasonsNotExists() {
let state = 0 let state = 0
if (item.episodes.length === 0) state = 2 if (item.episodes.length === 0) state = 2
else if (item.episodes.length < item.total_episode) state = 1 else if (item.episodes.length < item.total_episode) state = 1
seasonsNotExisted.value[item.season] = state seasonsNotExisted.value[item.season] = state
}) })
} }
@@ -349,26 +333,13 @@ function getExistText(season: number) {
// 打开详情页 // 打开详情页
function goMediaDetail(isHovering = false) { function goMediaDetail(isHovering = false) {
if (isHovering) { if (isHovering) {
if (props.media?.collection_id) { router.push({
// 跳转到合集列表 path: '/media',
router.push({ query: {
path: `/browse/tmdb/collection/${props.media?.collection_id}`, mediaid: getMediaId(),
query: { type: props.media?.type,
title: props.media?.title, },
}, })
})
} else {
// 跳转到媒体详情页
router.push({
path: '/media',
query: {
mediaid: getMediaId(),
title: props.media?.title,
year: props.media?.year,
type: props.media?.type,
},
})
}
} }
} }
@@ -380,69 +351,32 @@ function handleSearch() {
keyword: getMediaId(), keyword: getMediaId(),
type: props.media?.type, type: props.media?.type,
area: 'title', area: 'title',
title: props.media?.title,
year: props.media?.year,
season: props.media?.season, season: props.media?.season,
}, },
}) })
} }
// 懒加载检查 // 装载时检查是否已订阅
function handleCheckLazy() { onBeforeMount(() => {
if (props.media?.collection_id) {
return
}
handleCheckSubscribe() handleCheckSubscribe()
handleCheckExists() 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(() => { const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? 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则使用中转代理 // 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com')) 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 return url
}) })
// 拼装季图片地址 // 拼装季图片地址
function getSeasonPoster(posterPath: string) { function getSeasonPoster(posterPath: string) {
if (!posterPath) return '' 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日 // 将yyyy-mm-dd转换为yyyy年mm月dd日
@@ -451,102 +385,92 @@ function formatAirDate(airDate: string) {
const date = new Date(airDate.replaceAll(/-/g, '/')) const date = new Date(airDate.replaceAll(/-/g, '/'))
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}` return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
} }
// 从yyyy-mm-dd中提取年份 // 从yyyy-mm-dd中提取年份
function getYear(airDate: string) { function getYear(airDate: string) {
if (!airDate) return '' if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/')) const date = new Date(airDate.replaceAll(/-/g, '/'))
return date.getFullYear() return date.getFullYear()
} }
// 移除订阅
function onRemoveSubscribe() {
subscribeEditDialog.value = false
}
</script> </script>
<template> <template>
<VHover> <VHover>
<template #default="hover"> <template #default="hover">
<div ref="mediaCardRef"> <VCard
<VCard v-bind="hover.props"
v-bind="hover.props" :height="props.height"
:height="props.height" :width="props.width"
:width="props.width" class="outline-none shadow ring-gray-500 rounded-lg"
class="outline-none shadow ring-gray-500 rounded-lg" :class="{
:class="{ 'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering, 'ring-1': isImageLoaded,
'ring-1': isImageLoaded, }"
}" @click.stop="goMediaDetail(hover.isHovering)"
@click.stop="goMediaDetail(hover.isHovering ?? false)" >
<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 <template #placeholder>
aspect-ratio="2/3" <div class="w-full h-full">
:src="getImgUrl" <VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
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"
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">
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
</div> </div>
</VCardText> </template>
<!-- 类型角标 --> </VImg>
<VChip <!-- 类型角标 -->
v-show="isImageLoaded" <VChip
variant="elevated" v-show="isImageLoaded"
size="small" variant="elevated"
:class="getChipColor(props.media?.type || '')" size="small"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold" :class="getChipColor(props.media?.type || '')"
> class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
{{ props.media?.type }} >
</VChip> {{ props.media?.type }}
<!-- 本地存在标识 --> </VChip>
<ExistIcon v-if="isExists && !hover.isHovering" /> <!-- 本地存在标识 -->
<!-- 评分角标 --> <ExistIcon v-if="isExists && !hover.isHovering" />
<VChip <!-- 评分角标 -->
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)" <VChip
variant="elevated" v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
size="small" variant="elevated"
:class="getChipColor('rating')" size="small"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold" :class="getChipColor('rating')"
> class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
{{ props.media?.vote_average }} >
</VChip> {{ props.media?.vote_average }}
<!--来源图标--> </VChip>
<VAvatar <!-- 详情 -->
size="24" <VCardText
density="compact" v-show="hover.isHovering || imageLoadError"
class="absolute bottom-1 right-1" class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
tile >
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError" <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 ...">
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" /> {{ props.media?.title }}
</VAvatar> </h1>
</VCard> <p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
</div> {{ 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> </template>
</VHover> </VHover>
<!-- 订阅季弹窗 --> <!-- 订阅季弹窗 -->
@@ -613,6 +537,17 @@ function onRemoveSubscribe() {
:subid="subscribeId" :subid="subscribeId"
@close="subscribeEditDialog = false" @close="subscribeEditDialog = false"
@save="subscribeEditDialog = false" @save="subscribeEditDialog = false"
@remove="onRemoveSubscribe" @remove="
() => {
subscribeEditDialog = false
handleCheckSubscribe()
}
"
/> />
</template> </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' import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数 // 输入参数
defineProps({ const props = defineProps({
context: Object as PropType<Context>, context: Object as PropType<Context>,
}) })
@@ -45,7 +45,8 @@ function openTmdbPage(type: string, tmdbId: number) {
</template> </template>
</VImg> </VImg>
</div> </div>
<div class="flex-grow">
<div>
<VCardItem class="pb-1"> <VCardItem class="pb-1">
<VCardTitle class="text-center text-md-left"> <VCardTitle class="text-center text-md-left">
{{ context?.media_info?.title || context?.meta_info?.name }} {{ 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'
// 定义输入
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> <script lang="ts" setup>
import { isNullOrEmptyObject } from '@/@core/utils'
import type { Message } from '@/api/types' import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters' import { formatDateDifference } from '@core/utils/formatters'
@@ -23,7 +22,8 @@ async function imageLoaded() {
// 链接打开新窗口 // 链接打开新窗口
function openLink() { function openLink() {
if (props.message?.link) window.open(props.message.link, '_blank') if (props.message?.link)
window.open(props.message.link, '_blank')
} }
// 将note转换为json // 将note转换为json
@@ -31,8 +31,9 @@ function noteToJson() {
if (props.message?.note) { if (props.message?.note) {
try { try {
return JSON.parse(props.message.note) return JSON.parse(props.message.note)
} catch (error) { }
return props.message.note catch (error) {
console.error(error)
} }
} }
return {} return {}
@@ -40,53 +41,58 @@ function noteToJson() {
// 将\n转换为html属性的换行符 // 将\n转换为html属性的换行符
function replaceNewLine(value: string) { function replaceNewLine(value: string) {
if (!value) return '' if (!value)
return ''
return value.replace(/\n/g, '<br/>') return value.replace(/\n/g, '<br/>')
} }
</script> </script>
<template> <template>
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem"> <VCard
<div v-if="props.message?.image" class="relative text-center card-cover-blurred"> :width="props.width"
:height="props.height"
variant="tonal"
@click="openLink"
>
<div
v-if="props.message?.image"
class="relative text-center card-cover-blurred"
>
<VImg <VImg
:src="props.message?.image" :src="props.message?.image"
aspect-ratio="3/2" aspect-ratio="4/3"
cover cover
position="top"
:class="{ shadow: isImageLoaded }" :class="{ shadow: isImageLoaded }"
@load="imageLoaded" @load="imageLoaded"
@error="imageLoadError = true" @error="imageLoadError = true"
/> />
</div> </div>
<div <VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
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">
{{ props.message?.title }} {{ props.message?.title }}
</VCardTitle> </VCardTitle>
<div <VAlert
v-if="props.message?.text && props.message?.action === 0" 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> <template #prepend />
</div> {{ props.message?.text }}
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" /> </VAlert>
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)"> <VCardText
v-if="props.message?.text && props.message?.action === 1"
v-html="replaceNewLine(props.message?.text)"
/>
<VCardText v-if="props.message?.note">
<VList> <VList>
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line> <VListItem
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces"> v-for="(value, key) in noteToJson()"
:key="key"
two-line
>
<VListItemTitle v-if="value.title_year" class="font-bold">
{{ key + 1 }}. {{ value.title_year }} {{ key + 1 }}. {{ value.title_year }}
</VListItemTitle> </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 }} {{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} {{ value.seeders }}
</VListItemTitle> </VListItemTitle>
<VListItemSubtitle v-if="value.type"> <VListItemSubtitle v-if="value.type">
@@ -98,11 +104,9 @@ function replaceNewLine(value: string) {
</VListItem> </VListItem>
</VList> </VList>
</VCardText> </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> </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> </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'
// 定义输入
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, height: String,
}) })
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 当前人物 // 当前人物
const personInfo = ref(personProps.person) const personInfo = ref(personProps.person)
@@ -20,26 +17,22 @@ const isImageLoaded = ref(false)
// 人物图片地址 // 人物图片地址
function getPersonImage() { function getPersonImage() {
let url = ''
if (personProps.person?.source === 'themoviedb') { if (personProps.person?.source === 'themoviedb') {
if (!personInfo.value?.profile_path) return personIcon 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') { } else if (personProps.person?.source === 'douban') {
if (!personInfo.value?.avatar) return personIcon if (!personInfo.value?.avatar) return personIcon
if (typeof personInfo.value?.avatar === 'object') { if (typeof personInfo.value?.avatar === 'object') {
url = personInfo.value?.avatar?.normal return personInfo.value?.avatar?.normal
} else { } else {
url = personInfo.value?.avatar return personInfo.value?.avatar
} }
} else if (personProps.person?.source === 'bangumi') { } else if (personProps.person?.source === 'bangumi') {
if (!personInfo.value?.images) return personIcon if (!personInfo.value?.images) return personIcon
url = personInfo.value?.images?.medium return personInfo.value?.images?.medium
} else { } else {
return personIcon 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 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() { async function imageLoaded() {
@@ -73,7 +76,7 @@ async function installPlugin() {
if (result.success) { if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`) $toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
detailDialog.value = false
// 通知父组件刷新 // 通知父组件刷新
emit('install') emit('install')
} else { } else {
@@ -146,141 +149,80 @@ const dropdownItems = ref([
</script> </script>
<template> <template>
<div> <VCard :width="props.width" :height="props.height" @click="installPlugin" class="flex flex-col">
<VCard :width="props.width" :height="props.height" @click="detailDialog = true" class="flex flex-col h-full"> <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 <div
class="relative flex flex-row items-start pa-3 justify-between grow" class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}` }" :style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
> ></div>
<div <div class="relative flex-1 min-w-0">
class="absolute inset-0 bg-cover bg-center" <VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }" {{ props.plugin?.plugin_name }}
></div> <span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
<div class="relative flex-1 min-w-0"> </VCardTitle>
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."> <VCardText class="text-white px-2 py-1 text-shadow line-clamp-3">
{{ props.plugin?.plugin_name }} {{ props.plugin?.plugin_desc }}
<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>
</VCardText> </VCardText>
</VCard> </div>
</VDialog> <div class="relative flex-shrink-0 self-center">
</div> <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> </template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog' import { useConfirm } from 'vuetify-use-dialog'
import { VIcon } from 'vuetify/lib/components/index.mjs'
import api from '@/api' import api from '@/api'
import type { Plugin } from '@/api/types' import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue' import FormRender from '@/components/render/FormRender.vue'
@@ -9,13 +10,12 @@ import VersionHistory from '@/components/misc/VersionHistory.vue'
import { isNullOrEmptyObject } from '@core/utils' import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png' import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image' import { getDominantColor } from '@/@core/utils/image'
import store from '@/store'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import ProgressDialog from '../dialog/ProgressDialog.vue' import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -50,9 +50,6 @@ const pluginConfigDialog = ref(false)
// 插件配置表单数据 // 插件配置表单数据
const pluginConfigForm = ref({}) const pluginConfigForm = ref({})
// 菜单显示状态
const menuVisible = ref(false)
// 进度框 // 进度框
const progressDialog = ref(false) const progressDialog = ref(false)
@@ -65,9 +62,6 @@ const pluginInfoDialog = ref(false)
// 进度框文本 // 进度框文本
const progressText = ref('正在更新插件...') const progressText = ref('正在更新插件...')
// 用户头像是否加载完成
const isAvatarLoaded = ref(false)
// 插件数据页面配置项 // 插件数据页面配置项
let pluginPageItems = ref([]) let pluginPageItems = ref([])
@@ -222,19 +216,11 @@ const iconPath: Ref<string> = computed(() => {
return `./plugin_icon/${props.plugin?.plugin_icon}` 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() { async function resetPlugin() {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `此操作将恢复插件 ${props.plugin?.plugin_name}默认设置,并清除所有相关数据,确定要继续吗?`, content: `是否确认重置插件 ${props.plugin?.plugin_name}配置数据?`,
}) })
if (!isConfirmed) return if (!isConfirmed) return
@@ -291,9 +277,10 @@ function visitAuthorPage() {
// 查看日志URL // 查看日志URL
function openLoggerWindow() { function openLoggerWindow() {
const token = store.state.auth.token
const url = `${ const url = `${
import.meta.env.VITE_API_BASE_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') window.open(url, '_blank')
} }
@@ -394,167 +381,128 @@ watch(
</script> </script>
<template> <template>
<div> <!-- 插件卡片 -->
<!-- 插件卡片 --> <VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail" class="flex flex-col">
<VHover> <div class="me-n3 absolute bottom-0 right-3">
<template #default="hover"> <IconBtn>
<VCard <VIcon icon="mdi-dots-vertical" />
v-if="isVisible" <VMenu activator="parent" close-on-content-click>
v-bind="hover.props" <VList>
:width="props.width" <VListItem
:height="props.height" v-for="(item, i) in dropdownItems"
@click="openPluginDetail" v-show="item.show"
class="flex flex-col h-full" :key="i"
> variant="plain"
<div :base-color="item.props.color"
class="relative flex flex-row items-start pa-3 justify-between grow" @click="item.props.click"
:style="{ background: `${backgroundColor}` }" >
> <template #prepend>
<div <VIcon :icon="item.props.prependIcon" />
class="absolute inset-0 bg-cover bg-center" </template>
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }" <VListItemTitle v-text="item.title" />
/> </VListItem>
<div class="relative flex-1 min-w-0"> </VList>
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis"> </VMenu>
<VBadge v-if="props.plugin?.state" dot inline color="success" /> </IconBtn>
{{ props.plugin?.plugin_name }} </div>
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span> <div
</VCardTitle> class="relative flex flex-row items-start pa-3 justify-between grow"
<VCardText class="px-2 py-0 text-white text-sm text-shadow overflow-hidden line-clamp-3 ..."> :style="{ background: `${backgroundColor}` }"
{{ 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>
<!-- 插件配置页面 -->
<VDialog
v-if="pluginConfigDialog"
v-model="pluginConfigDialog"
scrollable
max-width="60rem"
:fullscreen="!display.mdAndUp.value"
> >
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t"> <div
<DialogCloseBtn v-model="pluginConfigDialog" /> class="absolute inset-0 bg-cover bg-center"
<VDivider /> :style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
<VCardText> />
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" /> <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> </VCardText>
<VCardActions class="pt-3"> </div>
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info"> <div class="relative flex-shrink-0 self-center">
查看数据 <VAvatar size="64">
</VBtn> <VImg
<VSpacer /> ref="imageRef"
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn> :src="iconPath"
</VCardActions> aspect-ratio="4/3"
</VCard> cover
</VDialog> :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>
<!-- 插件数据页面 --> <!-- 插件配置页面 -->
<VDialog <VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
v-if="pluginInfoDialog" <VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
v-model="pluginInfoDialog" <DialogCloseBtn v-model="pluginConfigDialog" />
scrollable <VDivider />
max-width="80rem" <VCardText>
:fullscreen="!display.mdAndUp.value" <FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
> </VCardText>
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t"> <VCardActions class="pt-3">
<DialogCloseBtn v-model="pluginInfoDialog" /> <VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo" variant="outlined" color="info">
<VCardText class="min-h-40"> 查看数据
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" /> </VBtn>
</VCardText> <VSpacer />
<VFab <VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
icon="mdi-cog" </VCardActions>
location="bottom" </VCard>
size="x-large" </VDialog>
fixed
app
appear
@click="showPluginConfig"
:class="{ 'mb-10': appMode }"
/>
</VCard>
</VDialog>
<!-- 进度框 --> <!-- 插件数据页面 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" /> <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>
<!-- 更新日志 --> <!-- 进度框 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable> <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<DialogCloseBtn @click="releaseDialog = false" /> <!-- 更新日志 -->
<VDivider /> <VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VersionHistory :history="props.plugin?.history" /> <VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<VDivider /> <DialogCloseBtn @click="releaseDialog = false" />
<VCardItem> <VDivider />
<VBtn @click="updatePlugin" block> <VersionHistory :history="props.plugin?.history" />
<template #prepend> <VDivider />
<VIcon icon="mdi-arrow-up-circle-outline" /> <VCardText>
</template> <VBtn @click="updatePlugin" block>
更新到最新版本 <template #prepend>
</VBtn> <VIcon icon="mdi-arrow-up-circle-outline" />
</VCardItem> </template>
</VCard> 更新到最新版本
</VDialog> </VBtn>
</div> </VCardText>
</VCard>
</VDialog>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -567,17 +515,4 @@ watch(
content: ''; content: '';
inset: 0; 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> </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') if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
} }
</script> </script>
@@ -48,11 +48,13 @@ function goPlay(isHovering: boolean | null = false) {
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering, 'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded, 'ring-1': isImageLoaded,
}" }"
@click.stop="goPlay(hover.isHovering)"
> >
<VImg <VImg
aspect-ratio="2/3" aspect-ratio="2/3"
:src="getImgUrl" :src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3" class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover cover
@load="isImageLoaded = true" @load="isImageLoaded = true"
@error="imageLoadError = true" @error="imageLoadError = true"
@@ -76,9 +78,7 @@ function goPlay(isHovering: boolean | null = false) {
<!-- 详情 --> <!-- 详情 -->
<VCardText <VCardText
v-show="hover.isHovering || imageLoadError" 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" class="w-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%)"
@click.stop="goPlay(hover.isHovering)"
> >
<span class="font-bold">{{ props.media?.subtitle }}</span> <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 ..."> <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> </template>
</VHover> </VHover>
</template> </template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

View File

@@ -2,23 +2,30 @@
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue' import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue' import SiteTorrentTable from '../table/SiteTorrentTable.vue'
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue' import { requiredValidator } from '@/@validators'
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
import api from '@/api' 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 { 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({ const cardProps = defineProps({
site: Object as PropType<Site>, site: Object as PropType<Site>,
data: Object as PropType<SiteUserData>, width: String,
height: String,
}) })
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['update', 'remove']) const emit = defineEmits(['update', 'remove'])
// 密码输入
const isPasswordVisible = ref(false)
// 图标 // 图标
const siteIcon = ref<string>('') const siteIcon = ref<string>('')
@@ -26,11 +33,14 @@ const siteIcon = ref<string>('')
const $toast = useToast() const $toast = useToast()
// 测试按钮文字 // 测试按钮文字
const testButtonText = ref('连通性测试') const testButtonText = ref('测试')
// 测试按钮可用性 // 测试按钮可用性
const testButtonDisable = ref(false) const testButtonDisable = ref(false)
// 更新按钮可用性
const updateButtonDisable = ref(false)
// 更新站点Cookie UA弹窗 // 更新站点Cookie UA弹窗
const siteCookieDialog = ref(false) const siteCookieDialog = ref(false)
@@ -40,8 +50,18 @@ const siteEditDialog = ref(false)
// 资源浏览弹窗 // 资源浏览弹窗
const resourceDialog = 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>({}) const siteStats = ref<SiteStatistic>({})
@@ -65,7 +85,7 @@ async function testSite() {
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`) if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`) else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
testButtonText.value = '连通性测试' testButtonText.value = '测试'
testButtonDisable.value = false testButtonDisable.value = false
getSiteStats() getSiteStats()
@@ -93,9 +113,34 @@ async function handleResourceBrowse() {
resourceDialog.value = true resourceDialog.value = true
} }
// 打开站点用户数据弹窗 // 调用API更新站点Cookie UA
async function handleSiteUserData() { async function updateSiteCookie() {
siteUserDataDialog.value = true 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(() => {
} }
}) })
// 计算上传量和下载量的百分比 // 监听resourceDialog如果为false则重新查询站点使用统计
const getPercentage = computed(() => { watch(resourceDialog, value => {
if (cardProps.data?.upload === 0) return 100 if (!value) getSiteStats()
return ((cardProps.data?.download ?? 0) / ((cardProps.data?.download ?? 0) + (cardProps.data?.upload ?? 0))) * 100
}) })
// 保存站点 // 保存站点
@@ -129,18 +173,6 @@ function saveSite() {
emit('update') emit('update')
} }
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
getSiteStats()
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
getSiteStats()
}
// 装载时查询站点图标 // 装载时查询站点图标
onMounted(() => { onMounted(() => {
getSiteIcon() getSiteIcon()
@@ -151,8 +183,10 @@ onMounted(() => {
<template> <template>
<div> <div>
<VCard <VCard
:height="cardProps.height"
:width="cardProps.width"
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'" :variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden h-full flex flex-col" class="overflow-hidden"
@click="siteEditDialog = true" @click="siteEditDialog = true"
> >
<template #image> <template #image>
@@ -160,7 +194,7 @@ onMounted(() => {
<VImg :src="siteIcon" /> <VImg :src="siteIcon" />
</VAvatar> </VAvatar>
</template> </template>
<VCardItem style="padding-block-end: 0"> <VCardItem style="padding-block-end: 0;">
<VCardTitle class="font-bold"> <VCardTitle class="font-bold">
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span> <span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
</VCardTitle> </VCardTitle>
@@ -168,7 +202,7 @@ onMounted(() => {
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span> <span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
</VCardSubtitle> </VCardSubtitle>
</VCardItem> </VCardItem>
<VCardText class="py-1"> <VCardText class="py-2" style="block-size: 36px;">
<VTooltip v-if="cardProps.site?.limit_interval" text="流控"> <VTooltip v-if="cardProps.site?.limit_interval" text="流控">
<template #activator="{ props }"> <template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" /> <VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
@@ -190,59 +224,67 @@ onMounted(() => {
</template> </template>
</VTooltip> </VTooltip>
</VCardText> </VCardText>
<VDivider />
<VCardActions> <VCardActions>
<IconBtn> <VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
<VIcon icon="mdi-chevron-down" color="primary" /> <template #prepend>
<VMenu activator="parent" close-on-content-click> <VIcon icon="mdi-refresh" />
<VList> </template>
<VListItem variant="plain" v-if="!cardProps.site?.public" @click="handleSiteUpdate"> 更新
<template #prepend> </VBtn>
<VIcon icon="mdi-refresh" /> <VBtn :disabled="testButtonDisable" @click.stop="testSite">
</template> <template #prepend>
<VListItemTitle>更新 Cookie & UA</VListItemTitle> <VIcon icon="mdi-link" />
</VListItem> </template>
<VListItem variant="plain" :disabled="testButtonDisable" @click.stop="testSite"> {{ testButtonText }}
<template #prepend> </VBtn>
<VIcon icon="mdi-link" /> <VBtn @click.stop="handleResourceBrowse">
</template> <template #prepend>
<VListItemTitle>{{ testButtonText }}</VListItemTitle> <VIcon icon="mdi-web" />
</VListItem> </template>
<VListItem variant="plain" @click="handleResourceBrowse"> 浏览
<template #prepend> </VBtn>
<VIcon icon="mdi-web" />
</template>
<VListItemTitle>资源预览</VListItemTitle>
</VListItem>
<VListItem variant="plain" @click="handleSiteUserData">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
</template>
<VListItemTitle>站点数据</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span class="text-sm">
{{ formatFileSize(cardProps.data?.upload || 0) }} / {{ formatFileSize(cardProps.data?.download || 0) }}
</span>
<VSpacer />
</VCardActions> </VCardActions>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" /> <StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<span class="absolute top-1 right-8"> <span class="absolute top-1 right-8">
<VIcon class="cursor-move">mdi-drag</VIcon> <VIcon class="cursor-move">mdi-drag</VIcon>
</span> </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> </VCard>
<!-- 更新站点Cookie & UA弹窗 --> <!-- 更新站点Cookie & UA弹窗 -->
<SiteCookieUpdateDialog <VDialog v-model="siteCookieDialog" max-width="50rem">
v-if="siteCookieDialog" <!-- Dialog Content -->
v-model="siteCookieDialog" <VCard title="更新站点Cookie & UA">
:site="cardProps.site" <DialogCloseBtn @click="siteCookieDialog = false" />
@close="siteCookieDialog = false" <VCardText>
@done="onSiteCookieUpdated" <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 <SiteAddEditDialog
v-if="siteEditDialog" v-if="siteEditDialog"
@@ -252,19 +294,30 @@ onMounted(() => {
@remove="emit('remove')" @remove="emit('remove')"
@close="siteEditDialog = false" @close="siteEditDialog = false"
/> />
<!-- 站点数据弹窗 -->
<SiteUserDataDialog
v-if="siteUserDataDialog"
v-model="siteUserDataDialog"
:site="cardProps.site"
@close="siteUserDataDialog = false"
/>
<!-- 站点资源弹窗 --> <!-- 站点资源弹窗 -->
<SiteResourceDialog <VDialog
v-if="resourceDialog" v-if="resourceDialog"
v-model="resourceDialog" v-model="resourceDialog"
:site="cardProps.site" max-width="80rem"
@close="onSiteResourceDone" 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> </div>
</template> </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 { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog' import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue' import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue' import { formatDateDifference } from '@/@core/utils/formatters'
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue' import { formatSeason } from '@/@core/utils/formatters'
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { Subscribe } from '@/api/types' import type { Subscribe } from '@/api/types'
import router from '@/router' import router from '@/router'
@@ -14,9 +13,6 @@ const props = defineProps({
media: Object as PropType<Subscribe>, media: Object as PropType<Subscribe>,
}) })
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['remove', 'save']) const emit = defineEmits(['remove', 'save'])
@@ -32,23 +28,21 @@ const imageLoaded = ref(false)
// 订阅弹窗 // 订阅弹窗
const subscribeEditDialog = 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() { function imageLoadHandler() {
imageLoaded.value = true 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() { function getPercentage() {
if (props.media?.total_episode === 0) return 0 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() { async function resetSubscribe() {
// 确认 // 确认
try { try {
const isConfirmed = await createConfirm({ const isConfirmed = await createConfirm({
title: '确认', title: '确认',
content: `重置后 ${props.media?.name} 将恢复初始状态,已下载记录将被清除,未入库的内容将会重新下载,是否确认?`, content: `重置后 ${props.media?.name} 已下载记录将被清除,未入库的剧集将会重新下载,是否确认?`,
}) })
if (!isConfirmed) return if (!isConfirmed) return
// 重置 // 重置
@@ -124,7 +92,6 @@ async function resetSubscribe() {
// 提示 // 提示
if (result.success) { if (result.success) {
$toast.success(`${props.media?.name} 重置成功!`) $toast.success(`${props.media?.name} 重置成功!`)
subscribeState.value = 'R'
emit('save') emit('save')
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`) } else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
} catch (e) { } catch (e) {
@@ -132,44 +99,24 @@ async function resetSubscribe() {
} }
} }
// 分享订阅
async function shareSubscribe() {
subscribeShareDialog.value = true
}
// 编辑订阅响应 // 编辑订阅响应
async function editSubscribeDialog() { async function editSubscribeDialog() {
subscribeEditDialog.value = true 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() { async function viewMediaDetail() {
router.push({ router.push({
path: '/media', path: '/media',
query: { query: {
mediaid: getMediaId(), mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
title: props.media?.name,
year: props.media?.year,
type: props.media?.type, type: props.media?.type,
}, },
}) })
} }
// 查看文件详情
async function viewSubscribeFiles() {
subscribeFilesDialog.value = true
}
// 弹出菜单 // 弹出菜单
const dropdownItems = computed(() => [ const dropdownItems = ref([
{ {
title: '编辑', title: '编辑',
value: 1, value: 1,
@@ -187,52 +134,26 @@ const dropdownItems = computed(() => [
}, },
}, },
{ {
title: '详情', title: '查看详情',
value: 3, value: 3,
props: { props: {
prependIcon: 'mdi-information-outline', prependIcon: 'mdi-open-in-new',
click: viewMediaDetail, 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: '重置', title: '重置',
value: 6, value: 4,
props: { props: {
prependIcon: 'mdi-restore-alert', prependIcon: 'mdi-restore-alert',
click: resetSubscribe, click: resetSubscribe,
color: 'warning', color: 'warning',
}, },
},
{
title: '分享',
value: 7,
props: {
prependIcon: 'mdi-share',
click: shareSubscribe,
color: 'success',
},
show: props.media?.type === '电视剧', show: props.media?.type === '电视剧',
}, },
{ {
title: '取消订阅', title: '取消订阅',
value: 8, value: 5,
props: { props: {
prependIcon: 'mdi-trash-can-outline', prependIcon: 'mdi-trash-can-outline',
color: 'error', color: 'error',
@@ -248,177 +169,133 @@ watch(
if (newOpenState) editSubscribeDialog() 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> </script>
<template> <template>
<div> <VHover>
<VHover> <template #default="hover">
<template #default="hover"> <VCard
<VCard v-bind="hover.props"
v-bind="hover.props" :key="props.media?.id"
:key="props.media?.id" class="flex flex-col rounded-lg"
class="flex flex-col rounded-lg h-full" :class="{
:class="{ 'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'outline-dashed outline-1': props.media?.best_version && imageLoaded, 'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering, }"
'opacity-70': subscribeState === 'S', min-height="170"
}" @click="editSubscribeDialog"
min-height="170" >
@click="editSubscribeDialog" <div class="me-n3 absolute top-1 right-2">
> <IconBtn>
<div class="me-n3 absolute top-1 right-2"> <VIcon icon="mdi-dots-vertical" color="white" />
<IconBtn> <VMenu activator="parent" close-on-content-click>
<VIcon icon="mdi-dots-vertical" color="white" /> <VList>
<VMenu activator="parent" close-on-content-click> <template v-for="(item, i) in dropdownItems" :key="i">
<VList> <VListItem
<template v-for="(item, i) in dropdownItems" :key="i"> v-if="item.show !== false"
<VListItem variant="plain"
v-if="item.show !== false" :base-color="item.props.color"
variant="plain" @click="item.props.click"
:base-color="item.props.color" >
@click="item.props.click" <template #prepend>
> <VIcon :icon="item.props.prependIcon" />
<template #prepend> </template>
<VIcon :icon="item.props.prependIcon" /> <VListItemTitle v-text="item.title" />
</template> </VListItem>
<VListItemTitle v-text="item.title" /> </template>
</VListItem> </VList>
</template> </VMenu>
</VList> </IconBtn>
</VMenu> </div>
</IconBtn> <template #image>
</div> <VImg
<template #image> :src="props.media?.backdrop || props.media?.poster"
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top"> aspect-ratio="3/2"
<template #placeholder> cover
<div class="w-full h-full"> @load="imageLoadHandler"
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" /> position="top"
</div> >
</template> <template #placeholder>
<div class="absolute inset-0 subscribe-card-background"></div> <div class="w-full h-full">
</VImg> <VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
<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>
</div> </div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4"> </template>
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div> <div class="absolute inset-0 subscribe-card-background"></div>
<div class="mr-2 min-w-0 text-lg font-bold text-white"> </VImg>
{{ props.media?.name }} </template>
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }} <div>
</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> </div>
</VCardText> </div>
<VCardText class="flex justify-space-between align-center flex-wrap"> </VCardText>
<div class="flex align-center"> <VCardText class="flex justify-space-between align-center flex-wrap">
<IconBtn <div class="flex align-center">
v-if="props.media?.total_episode" <IconBtn
v-bind="props" v-if="props.media?.total_episode"
icon="mdi-progress-download" v-bind="props"
color="white" icon="mdi-progress-download"
class="me-1" 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 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>
<div v-if="hover.isHovering" class="me-n3 absolute top-1 right-10"> </VCardText>
<IconBtn><VIcon class="cursor-move text-white">mdi-drag</VIcon></IconBtn> <VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
</div> <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> </div>
</VCard> </div>
</template> </VCard>
</VHover> </template>
<!-- 订阅编辑弹窗 --> </VHover>
<SubscribeEditDialog <!-- 订阅编辑弹窗 -->
v-if="subscribeEditDialog" <SubscribeEditDialog
v-model="subscribeEditDialog" v-if="subscribeEditDialog"
:subid="props.media?.id" v-model="subscribeEditDialog"
@remove="onSubscribeEditRemove" :subid="props.media?.id"
@save="onSubscribeEditSave" @remove="
@close="subscribeEditDialog = false" () => {
/> emit('remove')
subscribeEditDialog = false
<!-- 订阅文件信息弹窗 --> }
<SubscribeFilesDialog "
v-if="subscribeFilesDialog" @save="
v-model="subscribeFilesDialog" () => {
:subid="props.media?.id" emit('save')
@close="subscribeFilesDialog = false" subscribeEditDialog = false
/> }
<!-- 分享订阅弹窗 --> "
<SubscribeShareDialog @close="subscribeEditDialog = false"
v-if="subscribeShareDialog" />
v-model="subscribeShareDialog"
:sub="props.media"
@close="subscribeShareDialog = false"
/>
</div>
</template> </template>
<style lang="scss"> <style lang="scss">
.subscribe-card-background { .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>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
: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>
<VCardText class="flex items-center pb-1">
<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> <script lang="ts" setup>
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters' import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { Context } from '@/api/types' import { doneNProgress, startNProgress } from '@/api/nprogress'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue' import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -14,6 +15,12 @@ const props = defineProps({
height: String, height: String,
}) })
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 更多来源界面 // 更多来源界面
const showMoreTorrents = ref(false) const showMoreTorrents = ref(false)
@@ -26,29 +33,11 @@ const media = ref(props.torrent?.media_info)
// 识别元数据 // 识别元数据
const meta = ref(props.torrent?.meta_info) const meta = ref(props.torrent?.meta_info)
// 当前下载项
const downloadItem = ref(props.torrent)
// 站点图标 // 站点图标
const siteIcon = ref('') const siteIcon = ref('')
// 存储是否已经下载过的记录 // 存储是否已经下载过的记录
const downloaded = ref<string[]>([]) 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
}
// 查询站点图标 // 查询站点图标
async function getSiteIcon() { async function getSiteIcon() {
@@ -60,12 +49,50 @@ async function getSiteIcon() {
} }
// 询问并添加下载 // 询问并添加下载
async function handleAddDownload(item: Context | null = null) { async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
if (item && !isNullOrEmptyObject(item)) { if (!_media || !_torrent || !_site) {
downloadItem.value = item _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> </script>
<template> <template>
<div> <VCard
<VCard :width="props.width"
:width="props.width" :height="props.height"
:height="props.height" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'" @click="handleAddDownload"
@click="handleAddDownload(props.torrent)" >
> <template v-if="!showMoreTorrents" #image>
<template v-if="!showMoreTorrents" #image> <VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0"> <VImg :src="siteIcon" />
<VImg :src="siteIcon" /> </VAvatar>
</VAvatar> </template>
</template> <VCardItem class="py-1">
<VCardItem class="py-1"> <VCardTitle class="break-words overflow-visible whitespace-break-spaces">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces"> {{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }} <span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</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>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span> </VCardTitle>
</VCardTitle> <template #append>
<template #append> <div class="me-n3">
<div class="me-n3"> <IconBtn>
<IconBtn> <VIcon icon="mdi-dots-vertical" />
<VIcon icon="mdi-dots-vertical" /> <VMenu activator="parent" close-on-content-click>
<VMenu activator="parent" close-on-content-click> <VList>
<VList> <VListItem variant="plain" @click="openTorrentDetail()">
<VListItem variant="plain" @click="openTorrentDetail()"> <template #prepend>
<template #prepend> <VIcon icon="mdi-information" />
<VIcon icon="mdi-information" /> </template>
</template> <VListItemTitle>查看详情</VListItemTitle>
<VListItemTitle>查看详情</VListItemTitle> </VListItem>
</VListItem> <VListItem
<VListItem v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')" variant="plain"
variant="plain" @click="downloadTorrentFile()"
@click="downloadTorrentFile()" >
> <template #prepend>
<template #prepend> <VIcon icon="mdi-download" />
<VIcon icon="mdi-download" /> </template>
</template> <VListItemTitle>下载种子文件</VListItemTitle>
<VListItemTitle>下载种子文件</VListItemTitle> </VListItem>
</VListItem> </VList>
</VList> </VMenu>
</VMenu> </IconBtn>
</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>
</div> </div>
</VExpandTransition> </template>
</VCard> </VCardItem>
<AddDownloadDialog <VCardText class="text-subtitle-2">
v-if="addDownloadDialog" {{ torrent?.title }}
v-model="addDownloadDialog" </VCardText>
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${ <VCardText>{{ torrent?.description }}</VCardText>
downloadItem?.meta_info?.season_episode <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">
:media="downloadItem?.media_info" H&R
:torrent="downloadItem?.torrent_info" </VChip>
@done="addDownloadSuccess" <VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
@error="addDownloadError" {{ torrent?.freedate_diff }}
@close="addDownloadDialog = false" </VChip>
/> <VChip
</div> 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> </template>

View File

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

View File

@@ -1,192 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { Subscribe, User } from '@/api/types'
import store from '@/store'
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(() => store.state.auth.userID)
// 当前用户是否是管理员
const currentUserIsSuperuser = computed(() => store.state.auth.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,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 QrcodeVue from 'qrcode.vue'
import api from '@/api' import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件 // 定义事件
const emit = defineEmits(['done', 'close']) const emit = defineEmits(['done', 'close'])
@@ -33,17 +25,13 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成 // 完成
async function handleDone() { async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.refreshToken) {
await savaAliPanConfig()
}
emit('done') emit('done')
} }
// 调用/aliyun/qrcode api生成二维码 // 调用/aliyun/qrcode api生成二维码
async function getQrcode() { async function getQrcode() {
try { 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) { if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent qrCodeContent.value = result.data.codeContent
ck.value = result.data.ck ck.value = result.data.ck
@@ -59,8 +47,11 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码 // 调用/aliyun/check api验证二维码
async function checkQrcode() { async function checkQrcode() {
try { try {
const result: { [key: string]: any } = await api.get('/storage/check/alipan', { const result: { [key: string]: any } = await api.get('/aliyun/check', {
params: { ck: ck.value, t: t.value }, params: {
ck: ck.value,
t: t.value,
},
}) })
if (result.success && result.data) { if (result.success && result.data) {
const qrCodeStatus = result.data.qrCodeStatus 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 () => { onMounted(async () => {
await getQrcode() await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000) timeoutTimer = setTimeout(checkQrcode, 3000)
@@ -118,13 +100,6 @@ onUnmounted(() => {
<template #prepend /> <template #prepend />
</VAlert> </VAlert>
</VCardText> </VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义refreshToken" v-model="props.conf.refreshToken" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions> <VCardActions>
<VSpacer /> <VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn> <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({ const props = defineProps({
title: String, title: String,
dataType: String,
}) })
// 定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 代码 // 代码
const codeString = ref('') const codeString = ref('')
// 定义事件
const emit = defineEmits(['close', 'save'])
// 导入 // 导入
function handleImport() { function handleImport() {
emit('save', props.dataType, codeString) emit('update:modelValue', codeString.value)
emit('close') emit('close')
} }
</script> </script>
<template> <template>
<VDialog width="40rem" scrollable max-height="85vh" persistent> <VDialog width="40rem" scrollable max-height="85vh">
<VCard :title="props.title" class="rounded-t"> <VCard :title="props.title" class="rounded-t">
<DialogCloseBtn @click="emit('close')" /> <DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2"> <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,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> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import MediaIdSelector from '../misc/MediaIdSelector.vue' import MediaIdSelector from '../misc/MediaIdSelector.vue'
import store from '@/store'
import api from '@/api' import api from '@/api'
import { storageOptions, transferTypeOptions } from '@/api/constants'
import { numberValidator } from '@/@validators' import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue' import ProgressDialog from './ProgressDialog.vue'
import { FileItem, TransferDirectoryConf, TransferForm } from '@/api/types' import { FileItem, MediaDirectory } from '@/api/types'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
storage: {
type: String,
default: () => 'local',
},
logids: Array<number>, logids: Array<number>,
items: Array<FileItem>, items: Array<FileItem>,
target_storage: String, target: String,
target_path: String,
}) })
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 当前识别类型
const mediaSource = ref(globalSettings.data?.RECOGNIZE_SOURCE || 'themoviedb')
// 定义事件 // 定义事件
const emit = defineEmits(['done', 'close']) const emit = defineEmits(['done', 'close'])
@@ -36,6 +33,9 @@ const seasonItems = ref(
})), })),
) )
// 当前识别类型
const mediaSource = ref('themoviedb')
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
@@ -49,7 +49,7 @@ const progressEventSource = ref<EventSource>()
const progressDialog = ref(false) const progressDialog = ref(false)
// 整理进度文本 // 整理进度文本
const progressText = ref('正在处理 ...') const progressText = ref('请稍候 ...')
// 整理进度 // 整理进度
const progressValue = ref(0) const progressValue = ref(0)
@@ -65,101 +65,56 @@ const dialogTitle = computed(() => {
return '手动整理' 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>({ const transferForm = reactive({
fileitem: {} as FileItem, storage: props.storage,
logid: 0, 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: '', transfer_type: '',
target_path: '', episode_format: '',
episode_detail: '',
episode_part: '',
episode_offset: null,
min_filesize: 0, min_filesize: 0,
scrape: false, scrape: false,
from_history: false,
}) })
// 所有媒体库目录 // 所有媒体库目录
const directories = ref<TransferDirectoryConf[]>([]) const libraryDirectories = ref<MediaDirectory[]>([])
// 查询目录
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 targetDirectories = computed(() => {
const libraryDirectories = directories.value.map(item => item.library_path) const directories = libraryDirectories.value.map(item => item.path)
return [...new Set(libraryDirectories)] return [...new Set(directories)]
}) })
// 监听目的路径变化,配置默认值 // 监听目的路径变化,自动查询目录的刮削配置
watch( watch(transferForm, async () => {
() => transferForm.target_path, if (transferForm.target) {
async newPath => { const directory = libraryDirectories.value.find(item => item.path === transferForm.target)
if (newPath) { if (directory) {
const directory = directories.value.find(item => item.library_path === newPath) transferForm.scrape = directory.scrape ?? false
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
} }
},
)
// 整理文件
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监听加载进度 // 使用SSE监听加载进度
function startLoadingProgress() { function startLoadingProgress() {
progressText.value = '请稍候 ...' 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 => { progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data) const progress = JSON.parse(event.data)
if (progress) { if (progress) {
@@ -175,48 +130,87 @@ function stopLoadingProgress() {
} }
// 整理文件 // 整理文件
async function transfer(background: boolean = false) { async function transfer() {
if (!props.logids && !props.items) return if (!props.logids && !props.items) return
// 显示进度条 // 显示进度条
progressDialog.value = true progressDialog.value = true
// 开始监听进度
if (!background) { startLoadingProgress()
// 开始监听进度
startLoadingProgress()
}
// 文件整理 // 文件整理
if (props.items) { if (props.items) {
for (const item of props.items) { for (const item of props.items) {
await handleTransfer(item, background) await handleTransfer(item)
} }
} }
// 日志整理 // 日志整理
if (props.logids) { if (props.logids) {
for (const logid of props.logids) { for (const logid of props.logids) {
await handleTransferLog(logid, background) await handleTransferLog(logid)
} }
} }
if (!background) { // 停止监听进度
// 停止监听进度 stopLoadingProgress()
stopLoadingProgress()
}
// 关闭进度条 // 关闭进度条
progressDialog.value = false progressDialog.value = false
// 重新加载 // 重新加载
emit('done') 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(() => { try {
stopLoadingProgress() 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> </script>
@@ -228,32 +222,9 @@ onUnmounted(() => {
<VCardText> <VCardText>
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol v-if="props.storage == 'local'" cols="12" md="8">
<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">
<VCombobox <VCombobox
v-model="transferForm.target_path" v-model="transferForm.target"
:items="targetDirectories" :items="targetDirectories"
label="目的路径" label="目的路径"
placeholder="留空自动" placeholder="留空自动"
@@ -261,6 +232,23 @@ onUnmounted(() => {
persistent-hint persistent-hint
/> />
</VCol> </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>
<VRow> <VRow>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
@@ -326,7 +314,6 @@ onUnmounted(() => {
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VTextField <VTextField
v-model="transferForm.episode_detail" v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label="指定集数" label="指定集数"
placeholder="起始集,终止集如1或1,2" placeholder="起始集,终止集如1或1,2"
hint="指定集数或范围如1或1,2" hint="指定集数或范围如1或1,2"
@@ -344,7 +331,7 @@ onUnmounted(() => {
</VCol> </VCol>
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VTextField <VTextField
v-model="transferForm.episode_offset" v-model.number="transferForm.episode_offset"
label="集数偏移" label="集数偏移"
placeholder="如-10" placeholder="如-10"
hint="集数偏移运算,如-10或EP*2" hint="集数偏移运算,如-10或EP*2"
@@ -363,22 +350,6 @@ onUnmounted(() => {
</VCol> </VCol>
</VRow> </VRow>
<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"> <VCol cols="12" md="6">
<VSwitch <VSwitch
v-model="transferForm.scrape" v-model="transferForm.scrape"
@@ -387,25 +358,12 @@ onUnmounted(() => {
persistent-hint persistent-hint
/> />
</VCol> </VCol>
<VCol cols="12" md="6" v-if="props.logids">
<VSwitch
v-model="transferForm.from_history"
label="复用历史识别信息"
hint="使用历史整理记录中已识别的媒体信息"
persistent-hint
/>
</VCol>
</VRow> </VRow>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions class="pt-3"> <VCardActions class="pt-3">
<VSpacer /> <VSpacer />
<VBtn variant="elevated" color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5"> <VBtn variant="elevated" @click="transfer" prepend-icon="mdi-arrow-right-bold" class="px-5"> 开始整理 </VBtn>
加入整理队列
</VBtn>
<VBtn variant="elevated" @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
立即整理
</VBtn>
</VCardActions> </VCardActions>
</VCard> </VCard>
<!-- 手动整理进度框 --> <!-- 手动整理进度框 -->

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useToast } from 'vue-toast-notification' 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 { doneNProgress, startNProgress } from '@/api/nprogress'
import { numberValidator, requiredValidator } from '@/@validators' import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api' import api from '@/api'
@@ -35,18 +35,11 @@ const siteForm = ref<Site>({
limit_seconds: 0, limit_seconds: 0,
name: '', name: '',
domain: '', domain: '',
downloader: '',
}) })
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
// 维护类型
const siteType = ref('cookie')
// 是否限流
const isLimit = ref(false)
// 状态下拉项 // 状态下拉项
const statusItems = [ const statusItems = [
{ title: '启用', value: true }, { title: '启用', value: true },
@@ -61,23 +54,10 @@ const priorityItems = ref(
})), })),
) )
// 下载器选项 // 监控输入参数
const downloaderOptions = ref<{ title: string; value: string }[]>([]) watchEffect(async () => {
if (props.siteid) fetchSiteInfo()
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 fetchSiteInfo() { async function fetchSiteInfo() {
@@ -131,15 +111,6 @@ async function deleteSiteInfo() {
async function updateSiteInfo() { async function updateSiteInfo() {
startNProgress() startNProgress()
try { 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) const result: { [key: string]: any } = await api.put('site/', siteForm.value)
if (result.success) { if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`) $toast.success(`${siteForm.value?.name} 更新成功!`)
@@ -153,16 +124,6 @@ async function updateSiteInfo() {
} }
doneNProgress() 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> </script>
<template> <template>
@@ -206,7 +167,7 @@ onMounted(async () => {
</VCol> </VCol>
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12" md="6"> <VCol cols="12" md="9">
<VTextField <VTextField
v-model="siteForm.rss" v-model="siteForm.rss"
label="RSS地址" label="RSS地址"
@@ -215,85 +176,37 @@ onMounted(async () => {
/> />
</VCol> </VCol>
<VCol cols="12" md="3"> <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 <VTextField
v-model="siteForm.timeout" v-model="siteForm.token"
label="超时时间(秒" label="请求头Authorization"
hint="站点请求超时时间为0时不限制" hint="站点请求头中的Authorization信息特殊站点需要"
persistent-hint persistent-hint
/> />
</VCol> </VCol>
<VCol cols="6" md="3"> <VCol cols="12" md="6">
<VSelect <VTextField
v-model="siteForm.downloader" v-model="siteForm.apikey"
label="下载器" label="令牌API Key"
:items="downloaderOptions" hint="站点的访问API Key特殊站点需要"
hint="此站点使用的下载器" persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint persistent-hint
/> />
</VCol> </VCol>
</VRow> </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> <VRow>
<VCol cols="12" md="4">
<VSwitch v-model="isLimit" label="限制站点访问频率" />
</VCol>
</VRow>
<VRow v-if="isLimit">
<VCol cols="12" md="4"> <VCol cols="12" md="4">
<VTextField <VTextField
v-model="siteForm.limit_interval" v-model="siteForm.limit_interval"
@@ -324,15 +237,10 @@ onMounted(async () => {
</VRow> </VRow>
<VRow> <VRow>
<VCol cols="12" md="6"> <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>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VSwitch <VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
v-model="siteForm.render"
label="浏览器仿真"
hint="使用浏览器模拟真实访问该站点"
persistent-hint
/>
</VCol> </VCol>
</VRow> </VRow>
</VForm> </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,222 +0,0 @@
<script setup lang="ts">
import { Site } from '@/api/types'
import { useDisplay } from 'vuetify'
import api from '@/api'
import type { TorrentInfo } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
})
// 注册事件
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')
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`)
resourceLoading.value = false
} catch (error) {
console.error(error)
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
}
// 添加下载
async function addDownload(_torrent: any) {
torrent.value = _torrent
addDownloadDialog.value = true
}
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 装载时查询站点图标
onMounted(() => {
getResourceList()
})
</script>
<template>
<VDialog max-width="80rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :title="`浏览 - ${props.site?.name}`">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText class="pt-2">
<VDataTable
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
:items="resourceDataList"
:items-length="resourceTotalItems"
:search="resourceSearch"
:loading="resourceLoading"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item)">
<div class="text-high-emphasis pt-1">
{{ item.title }}
</div>
<div class="text-sm my-1">
{{ item.description }}
</div>
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ item.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.volume_factor }}
</VChip>
</a>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.date_elapsed }}</div>
<div class="text-sm">
{{ item.pubdate }}
</div>
</template>
<template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.size) }}
</div>
</template>
<template #item.seeders="{ item }">
<div>{{ item.seeders }}</div>
</template>
<template #item.peers="{ item }">
<div>{{ item.peers }}</div>
</template>
<template #item.actions="{ item }">
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</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 { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators' import { numberValidator } from '@/@validators'
import api from '@/api' 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 { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog' import { useConfirm } from 'vuetify-use-dialog'
import { VTextarea, VTextField } from 'vuetify/lib/components/index.mjs'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
@@ -23,34 +22,38 @@ const props = defineProps({
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'close']) const emit = defineEmits(['remove', 'save', 'close'])
const activeTab = ref('basic')
// 站点数据列表 // 站点数据列表
const siteList = ref<Site[]>([]) const siteList = ref<Site[]>([])
// 下载目录列表 // 下载目录列表
const downloadDirectories = ref<TransferDirectoryConf[]>([]) const downloadDirectories = ref<MediaDirectory[]>([])
// 站点选择下载框 // 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([]) const selectSitesOptions = ref<{ [key: number]: string }[]>([])
// 所有规则组列表
const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 订阅编辑表单 // 订阅编辑表单
const subscribeForm = ref<Subscribe>({ const subscribeForm = ref<Subscribe>({
id: props.subid ?? 0, 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: '', name: '',
year: '', year: '',
type: '',
tmdbid: 0, tmdbid: 0,
state: '', state: '',
last_update: '', last_update: '',
username: '', username: '',
sites: [],
best_version: undefined,
current_priority: 0, current_priority: 0,
downloader: '', save_path: undefined,
date: '', date: '',
show_edit_dialog: false, show_edit_dialog: false,
}) })
@@ -58,42 +61,6 @@ const subscribeForm = ref<Subscribe>({
// 提示框 // 提示框
const $toast = useToast() 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修改订阅 // 调用API修改订阅
async function updateSubscribeInfo() { async function updateSubscribeInfo() {
try { try {
@@ -195,7 +162,6 @@ async function removeSubscribe() {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`) const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
if (result.success) { if (result.success) {
$toast.success(`订阅 ${subscribeForm.value.name} 已取消!`)
// 通知父组件刷新 // 通知父组件刷新
emit('remove') emit('remove')
} }
@@ -207,7 +173,7 @@ async function removeSubscribe() {
// 查询下载目录 // 查询下载目录
async function loadDownloadDirectories() { async function loadDownloadDirectories() {
try { 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) { if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value downloadDirectories.value = result.data.value
} }
@@ -219,7 +185,8 @@ async function loadDownloadDirectories() {
// 保存目录下拉框 // 保存目录下拉框
const targetDirectories = computed(() => { 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(() => { onMounted(() => {
queryFilterRuleGroups()
loadDownloadDirectories() loadDownloadDirectories()
getSiteList() getSiteList()
loadDownloaderSetting()
if (props.subid) getSubscribeInfo() if (props.subid) getSubscribeInfo()
if (props.default) queryDefaultSubscribeConfig() if (props.default) queryDefaultSubscribeConfig()
}) })
@@ -326,199 +291,134 @@ onMounted(() => {
}`" }`"
class="rounded-t" class="rounded-t"
> >
<VDivider />
<VCardText> <VCardText>
<DialogCloseBtn @click="emit('close')" /> <DialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}"> <VForm @submit.prevent="() => {}">
<VTabs v-model="activeTab" show-arrows> <VRow>
<VTab value="basic"> <VCol cols="12" md="8">
<div>基础</div> <VTextField
</VTab> v-if="!props.default"
<VTab value="advance"> v-model="subscribeForm.keyword"
<div>进阶</div> label="搜索关键词"
</VTab> hint="指定搜索站点时使用的关键词"
</VTabs> persistent-hint
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false"> />
<VWindowItem value="basic"> </VCol>
<div> <VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
<VRow v-if="!props.default"> <VTextField
<VCol cols="12" md="4"> v-model="subscribeForm.total_episode"
<VTextField label="总集数"
v-model="subscribeForm.keyword" :rules="[numberValidator]"
label="搜索关键词" hint="剧集总集数"
hint="指定搜索站点时使用的关键词" persistent-hint
persistent-hint />
/> </VCol>
</VCol> <VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4"> <VTextField
<VTextField v-model="subscribeForm.start_episode"
v-model="subscribeForm.total_episode" label="开始集数"
label="总集数" :rules="[numberValidator]"
:rules="[numberValidator]" hint="开始订阅集数"
hint="剧集总集数" persistent-hint
persistent-hint />
/> </VCol>
</VCol> </VRow>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4"> <VRow>
<VTextField <VCol cols="12" md="4">
v-model="subscribeForm.start_episode" <VSelect
label="开始集数" v-model="subscribeForm.quality"
:rules="[numberValidator]" label="质量"
hint="开始订阅集数" :items="qualityOptions"
persistent-hint hint="订阅资源质量"
/> persistent-hint
</VCol> />
</VRow> </VCol>
<VRow> <VCol cols="12" md="4">
<VCol cols="12" md="4"> <VSelect
<VSelect v-model="subscribeForm.resolution"
v-model="subscribeForm.quality" label="分辨率"
label="质量" :items="resolutionOptions"
:items="qualityOptions" hint="订阅资源分辨率"
hint="订阅资源质量" persistent-hint
persistent-hint />
/> </VCol>
</VCol> <VCol cols="12" md="4">
<VCol cols="12" md="4"> <VSelect
<VSelect v-model="subscribeForm.effect"
v-model="subscribeForm.resolution" label="特效"
label="分辨率" :items="effectOptions"
:items="resolutionOptions" hint="订阅资源特效"
hint="订阅资源分辨率" persistent-hint
persistent-hint />
/> </VCol>
</VCol> </VRow>
<VCol cols="12" md="4"> <VRow>
<VSelect <VCol cols="12" md="4">
v-model="subscribeForm.effect" <VTextField
label="特效" v-model="subscribeForm.include"
:items="effectOptions" label="包含(关键字、正则式)"
hint="订阅资源特效" hint="包含规则,支持正则表达式"
persistent-hint persistent-hint
/> />
</VCol> </VCol>
</VRow> <VCol cols="12" md="4">
<VRow> <VTextField
<VCol cols="12"> v-model="subscribeForm.exclude"
<VSelect label="排除(关键字、正则式)"
v-model="subscribeForm.sites" hint="排除规则,支持正则表达式"
:items="selectSitesOptions" persistent-hint
chips />
label="订阅站点" </VCol>
multiple <VCol cols="12" md="4">
clearable <VSelect
hint="订阅的站点范围,不选使用系统设置" v-model="subscribeForm.sites"
persistent-hint :items="selectSitesOptions"
/> chips
</VCol> label="订阅站点"
</VRow> multiple
<VRow> hint="订阅的站点范围,不选使用系统设置"
<VCol cols="12" md="6"> persistent-hint
<VSelect />
v-model="subscribeForm.downloader" </VCol>
:items="downloaderOptions" </VRow>
label="下载器" <VRow>
hint="指定该订阅使用的下载器" <VCol cols="12">
persistent-hint <VCombobox
/> v-model="subscribeForm.save_path"
</VCol> :items="targetDirectories"
<VCol cols="12" md="6"> label="保存路径"
<VCombobox hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
v-model="subscribeForm.save_path" persistent-hint
:items="targetDirectories" />
label="保存路径" </VCol>
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录" </VRow>
persistent-hint <VRow>
/> <VCol cols="12" md="4">
</VCol> <VSwitch
</VRow> v-model="subscribeForm.best_version"
<VRow> label="洗版"
<VCol cols="12" md="4"> hint="根据洗版优先级进行洗版订阅"
<VSwitch persistent-hint
v-model="subscribeForm.best_version" />
label="洗版" </VCol>
hint="根据洗版优先级进行洗版订阅" <VCol cols="12" md="4">
persistent-hint <VSwitch
/> v-model="subscribeForm.search_imdbid"
</VCol> label="使用 ImdbID 搜索"
<VCol cols="12" md="4"> hint="开使用 ImdbID 精确搜索资源"
<VSwitch persistent-hint
v-model="subscribeForm.search_imdbid" />
label="使用 ImdbID 搜索" </VCol>
hint="开使用 ImdbID 精确搜索资源" <VCol v-if="props.default" cols="12" md="4">
persistent-hint <VSwitch
/> v-model="subscribeForm.show_edit_dialog"
</VCol> label="订阅时编辑更多规则"
<VCol v-if="props.default" cols="12" md="4"> hint="添加订阅时显示此编辑订阅对话框"
<VSwitch persistent-hint
v-model="subscribeForm.show_edit_dialog" />
label="订阅时编辑更多规则" </VCol>
hint="添加订阅时显示此编辑订阅对话框" </VRow>
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>
</VForm> </VForm>
</VCardText> </VCardText>
<VCardActions class="pt-3"> <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} 季 ...` else progressText.value = `正在重新订阅 ${item.name}${item.season} 季 ...`
progressDialog.value = true progressDialog.value = true
try { try {
const result: { [key: string]: any } = await api.post('subscribe/', item) const result: { [key: string]: any } = await api.post('subscribe', item)
if (result.success) { if (result.success) {
emit('save') emit('save')
} }
@@ -138,69 +138,72 @@ const dropdownItems = ref([
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle> <VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
</VCardItem> </VCardItem>
<VDivider /> <VDivider />
<DialogCloseBtn @click="emit('close')" /> <DialogCloseBtn
@click="
() => {
emit('close')
}
"
/>
<VList lines="two"> <VList lines="two">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory"> <VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory">
<template #loading> <template #loading>
<LoadingBanner /> <LoadingBanner />
</template> </template>
<template #empty /> <template #empty />
<template v-if="historyList.length > 0"> <template v-for="(item, i) in historyList" :key="i">
<template v-for="(item, i) in historyList" :key="i"> <VListItem>
<VListItem> <template #prepend>
<template #prepend> <VImg
<VImg height="75"
height="75" width="50"
width="50" :src="item.poster"
:src="item.poster" aspect-ratio="2/3"
aspect-ratio="2/3" class="object-cover rounded shadow ring-gray-500 me-3"
class="object-cover rounded shadow ring-gray-500 me-3" cover
cover >
> <template #placeholder>
<template #placeholder> <div class="w-full h-full">
<div class="w-full h-full"> <VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" /> </div>
</div> </template>
</template> </VImg>
</VImg> </template>
</template> <VListItemTitle v-if="item.type == '电视剧'">
<VListItemTitle v-if="item.type == '电视剧'"> {{ item.name }} <span class="text-sm"> {{ item.season }} </span>
{{ item.name }} <span class="text-sm"> {{ item.season }} </span> </VListItemTitle>
</VListItemTitle> <VListItemTitle v-else>
<VListItemTitle v-else> {{ item.name }}
{{ item.name }} </VListItemTitle>
</VListItemTitle> <VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle> <VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle> <template #append>
<template #append> <div class="me-n3">
<div class="me-n3"> <IconBtn>
<IconBtn> <VIcon icon="mdi-dots-vertical" />
<VIcon icon="mdi-dots-vertical" /> <VMenu activator="parent" close-on-content-click>
<VMenu activator="parent" close-on-content-click> <VList>
<VList> <VListItem
<VListItem v-for="(menu, i) in dropdownItems"
v-for="(menu, i) in dropdownItems" :key="i"
:key="i" variant="plain"
variant="plain" :base-color="menu.color"
:base-color="menu.color" @click="menu.props.click(item)"
@click="menu.props.click(item)" >
> <template #prepend>
<template #prepend> <VIcon :icon="menu.props.prependIcon" />
<VIcon :icon="menu.props.prependIcon" /> </template>
</template> <VListItemTitle v-text="menu.title" />
<VListItemTitle v-text="menu.title" /> </VListItem>
</VListItem> </VList>
</VList> </VMenu>
</VMenu> </IconBtn>
</IconBtn> </div>
</div> </template>
</template> </VListItem>
</VListItem>
</template>
</template> </template>
</VInfiniteScroll> </VInfiniteScroll>
</VList> </VList>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center"> 没有已完成的订阅 </VCardText>
</VCard> </VCard>
<!-- 进度框 --> <!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" /> <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> <script lang="ts" setup>
import api from '@/api'
import QrcodeVue from 'qrcode.vue' import QrcodeVue from 'qrcode.vue'
import { VCardItem, VTextField } from 'vuetify/lib/components/index.mjs' import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件 // 定义事件
const emit = defineEmits(['done', 'close']) const emit = defineEmits(['done', 'close'])
@@ -18,7 +9,7 @@ const emit = defineEmits(['done', 'close'])
const qrCodeContent = ref('') const qrCodeContent = ref('')
// 下方的提示信息 // 下方的提示信息
const text = ref('请使用微信或115客户端扫码或在下方输入Cookie') const text = ref('请使用微信或115客户端扫码')
// 提醒类型 // 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info') const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
@@ -28,17 +19,13 @@ let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 完成 // 完成
async function handleDone() { async function handleDone() {
clearTimeout(timeoutTimer)
if (props.conf?.cookie) {
await savaU115Config()
}
emit('done') emit('done')
} }
// 调用/aliyun/qrcode api生成二维码 // 调用/aliyun/qrcode api生成二维码
async function getQrcode() { async function getQrcode() {
try { 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) { if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent qrCodeContent.value = result.data.codeContent
} else { } else {
@@ -52,25 +39,19 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码 // 调用/aliyun/check api验证二维码
async function checkQrcode() { async function checkQrcode() {
try { 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) { if (result.success && result.data) {
const status = result.data.status const status = result.data.status
text.value = result.data.tip text.value = result.data.tip
if (status == 0) { if (status == 1) {
// 已确认完成
alertType.value = 'success'
handleDone()
} else if (status == 0) {
alertType.value = 'info' alertType.value = 'info'
// 新建、待扫码 // 新建、待扫码
clearTimeout(timeoutTimer) clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000) 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 { } else {
// 过期或者已取消 // 过期或者已取消
alertType.value = 'error' 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 () => { onMounted(async () => {
await getQrcode() await getQrcode()
timeoutTimer = setTimeout(checkQrcode, 3000) timeoutTimer = setTimeout(checkQrcode, 3000)
@@ -108,20 +80,15 @@ onUnmounted(() => {
<VCard title="115网盘登录" class="rounded-t"> <VCard title="115网盘登录" class="rounded-t">
<DialogCloseBtn @click="emit('close')" /> <DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center"> <VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 shadow-lg rounded text-center p-3 border"> <div class="my-6 shadow-lg rounded border">
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" /> <VImg class="mx-auto" :src="qrCodeContent" style="block-size: 200px; inline-size: 200px">
<VSkeletonLoader v-if="!qrCodeContent" class="w-full h-full" />
</VImg>
</div> </div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text"> <VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend /> <template #prepend />
</VAlert> </VAlert>
</VCardText> </VCardText>
<VCardText>
<VRow>
<VCol class="mt-2">
<VTextField label="自定义Cookie" v-model="props.conf.cookie" outlined dense />
</VCol>
</VRow>
</VCardText>
<VCardActions> <VCardActions>
<VSpacer /> <VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn> <VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>

View File

@@ -1,433 +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 store from '@/store'
// 显示器宽度
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,
})
// 当前登录用户名称
const currentLoginUser = store.state.auth.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) store.commit('auth/setUserName', currentUserName.value)
} else {
$toast.success(`${userForm.value?.name}】更新成功!`)
}
// 更新本地头像显示
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
store.commit('auth/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,46 +0,0 @@
<script setup lang="ts">
import CronInput from '@/components/input/CronInput.vue'
const attrs = useAttrs()
const props = defineProps({
modelValue: {
type: String,
default: '* * * * *',
},
})
const emit = defineEmits(['update:modelValue'])
const innerValue = ref(props.modelValue)
watch(
() => props.modelValue,
value => {
innerValue.value = value
},
)
const propsWithoutModelValue = computed(() => {
const { modelValue, ...rest } = props
return { ...rest, ...attrs }
})
function updateModelValue(value: string) {
innerValue.value = value
emit('update:modelValue', value)
}
</script>
<template>
<CronInput v-model="innerValue" @update:modelValue="updateModelValue">
<template #activator="{ menuprops }">
<VTextField
:modelValue="innerValue"
@update:modelValue="updateModelValue"
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
clearable
/>
</template>
</CronInput>
</template>

View File

@@ -1,49 +0,0 @@
<script setup lang="ts">
import PathInput from '@/components/input/PathInput.vue'
const attrs = useAttrs()
const props = defineProps({
modelValue: {
type: String,
default: '* * * * *',
},
storage: {
type: String,
default: 'local',
},
})
const emit = defineEmits(['update:modelValue'])
const innerValue = ref(props.modelValue)
watch(
() => props.modelValue,
value => {
innerValue.value = value
},
)
const propsWithoutModelValue = computed(() => {
const { modelValue, ...rest } = props
return { ...rest, ...attrs }
})
function updateModelValue(value: string) {
innerValue.value = value
emit('update:modelValue', value)
}
</script>
<template>
<PathInput v-model="innerValue" :storage="props.storage" @update:modelValue="updateModelValue">
<template #activator="{ menuprops }">
<VTextField
:modelValue="innerValue"
@update:modelValue="updateModelValue"
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
/>
</template>
</PathInput>
</template>

View File

@@ -6,16 +6,19 @@ import { useToast } from 'vue-toast-notification'
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue' import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
import { formatBytes } from '@core/utils/formatters' import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types' import type { Context, EndPoints, FileItem } from '@/api/types'
import store from '@/store'
import api from '@/api' import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue' import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
// 显示器宽度 // 显示器宽度
const display = useDisplay() const display = useDisplay()
// APP // APP
const appMode = inject('pwaMode') && display.mdAndDown.value const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 输入参数 // 输入参数
const inProps = defineProps({ const inProps = defineProps({
@@ -102,19 +105,18 @@ const dirs = computed(() => items.value.filter(item => item.type === 'dir' && it
// 文件过滤 // 文件过滤
const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value))) const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value)))
// 是否目录
const isDir = computed(() => inProps.item.path?.endsWith('/'))
// 是否文件 // 是否文件
const isFile = computed(() => inProps.item.type == 'file') const isFile = computed(() => !isDir.value)
// 需要整理的文件项 // 需要整理的文件项
const transferItems = ref<FileItem[]>([]) const transferItems = ref<FileItem[]>([])
// 当前图片地址
const currentImgLink = ref('')
// 大小控制 // 大小控制
const scrollStyle = computed(() => { const scrollStyle = computed(() => {
return appMode return appMode.value
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)' ? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)' : 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
}) })
@@ -122,7 +124,7 @@ const scrollStyle = computed(() => {
// 是否为图片文件 // 是否为图片文件
const isImage = computed(() => { const isImage = computed(() => {
const ext = inProps.item.path?.split('.').pop()?.toLowerCase() const ext = inProps.item.path?.split('.').pop()?.toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(ext ?? '') return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
}) })
// 调整选择模式 // 调整选择模式
@@ -137,7 +139,9 @@ async function list_files() {
emit('loading', true) emit('loading', true)
// 参数 // 参数
const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name') const url = inProps.endpoints?.list.url
.replace(/{storage}/g, inProps.storage)
.replace(/{sort}/g, inProps.sort || 'name')
const config: AxiosRequestConfig<FileItem> = { const config: AxiosRequestConfig<FileItem> = {
url, url,
@@ -165,7 +169,7 @@ async function deleteItem(item: FileItem, confirm: boolean = true) {
emit('loading', true) emit('loading', true)
// 请求API // 请求API
const url = inProps.endpoints?.delete.url const url = inProps.endpoints?.delete.url.replace(/{storage}/g, inProps.storage)
const config: AxiosRequestConfig<FileItem> = { const config: AxiosRequestConfig<FileItem> = {
url, url,
method: inProps.endpoints?.delete.method || 'post', method: inProps.endpoints?.delete.method || 'post',
@@ -230,51 +234,23 @@ function listItemClick(item: FileItem) {
// 新窗口中下载文件 // 新窗口中下载文件
async function download(item: FileItem) { async function download(item: FileItem) {
const url = inProps.endpoints?.download.url const url = inProps.endpoints?.download.url.replace(/{storage}/g, inProps.storage)
// 下载文件 const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
const config: AxiosRequestConfig<FileItem> = { const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
url, window.open(
method: inProps.endpoints?.download.method || 'post', `${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`,
data: item, '_blank',
responseType: 'blob', )
}
// 加载数据
const result: Blob = await inProps.axios.request(config)
if (result) {
const downloadUrl = URL.createObjectURL(result)
window.open(downloadUrl, '_blank')
}
} }
// 获取图片地址 // 获取图片地址
async function getImgLink(item: FileItem) { function getImgLink(item: FileItem) {
let url = inProps.endpoints?.image.url let url = inProps.endpoints?.image.url.replace(/{storage}/g, inProps.storage)
// 下载文件 const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
const config: AxiosRequestConfig<FileItem> = { const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
url, return `${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`
method: inProps.endpoints?.image.method || 'post',
data: item,
responseType: 'blob',
}
// 加载二进制数据
const result: Blob = await inProps.axios.request(config)
if (result) {
// 创建图片地址
currentImgLink.value = URL.createObjectURL(result)
}
} }
// 如果当前是图片且是文件,则获取图片地址
watch(
() => inProps.item,
async () => {
if (isImage.value && isFile.value) {
await getImgLink(inProps.item)
}
},
{ immediate: true },
)
// 显示重命名弹窗 // 显示重命名弹窗
function showRenmae(item: FileItem) { function showRenmae(item: FileItem) {
currentItem.value = item currentItem.value = item
@@ -324,7 +300,9 @@ async function rename() {
} }
// 调API // 调API
let url = inProps.endpoints?.rename.url.replace(/{newname}/g, encodeURIComponent(newName.value)) let url = inProps.endpoints?.rename.url
.replace(/{storage}/g, inProps.storage)
.replace(/{newname}/g, encodeURIComponent(newName.value))
if (renameAll.value) { if (renameAll.value) {
url += '&recursive=true' url += '&recursive=true'
} }
@@ -518,7 +496,12 @@ async function batchScrape() {
// 使用SSE监听加载进度 // 使用SSE监听加载进度
function startLoadingProgress() { function startLoadingProgress() {
progressText.value = '请稍候 ...' progressText.value = '请稍候 ...'
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`)
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename?token=${token}`,
)
progressEventSource.value.onmessage = event => { progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data) const progress = JSON.parse(event.data)
if (progress) { if (progress) {
@@ -600,7 +583,7 @@ onMounted(() => {
</VCardText> </VCardText>
<!-- 图片 --> <!-- 图片 -->
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center"> <VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
<VImg :src="currentImgLink" max-width="100%" max-height="100%" /> <VImg :src="getImgLink(items[0])" max-width="100%" max-height="100%" />
</VCardText> </VCardText>
<!-- 目录和文件列表 --> <!-- 目录和文件列表 -->
<VCardText v-else-if="dirs.length || files.length" class="p-0"> <VCardText v-else-if="dirs.length || files.length" class="p-0">
@@ -619,8 +602,7 @@ onMounted(() => {
v-if="inProps.icons && item.extension" v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/> />
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder-outline" /> <VIcon v-else icon="mdi-folder-outline" />
<VIcon v-else icon="mdi-file-outline" />
</template> </template>
</template> </template>
<VListItemTitle v-text="item.name" /> <VListItemTitle v-text="item.name" />
@@ -727,20 +709,22 @@ onMounted(() => {
<ReorganizeDialog <ReorganizeDialog
v-if="transferPopper" v-if="transferPopper"
v-model="transferPopper" v-model="transferPopper"
:storage="inProps.storage"
:items="transferItems" :items="transferItems"
:target_storage="inProps.storage"
@done="transferDone" @done="transferDone"
@close="transferPopper = false" @close="transferPopper = false"
/> />
<!-- 进度框 --> <!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" /> <ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 --> <!-- 识别结果对话框 -->
<MediaInfoDialog <VDialog v-if="nameTestDialog" v-model="nameTestDialog" width="50rem">
v-if="nameTestDialog" <VCard>
v-model="nameTestDialog" <DialogCloseBtn @click="nameTestDialog = false" />
:context="nameTestResult" <VCardItem>
@close="nameTestDialog = false" <MediaInfoCard :context="nameTestResult" />
/> </VCardItem>
</VCard>
</VDialog>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -63,7 +63,7 @@ const pathSegments = computed(() => {
// 当前存储 // 当前存储
const storageObject = computed(() => { const storageObject = computed(() => {
return inProps.storages?.find(item => item.value === inProps.storage) return inProps.storages?.find(item => item.code === inProps.storage)
}) })
// 切换存储 // 切换存储
@@ -88,7 +88,9 @@ function goUp() {
// 创建目录 // 创建目录
async function mkdir() { async function mkdir() {
emit('loading', true) emit('loading', true)
const url = inProps.endpoints?.mkdir.url.replace(/{name}/g, newFolderName.value) const url = inProps.endpoints?.mkdir.url
.replace(/{storage}/g, inProps.storage)
.replace(/{name}/g, newFolderName.value)
const config: AxiosRequestConfig<FileItem> = { const config: AxiosRequestConfig<FileItem> = {
url, url,
@@ -127,19 +129,19 @@ const sortIcon = computed(() => {
<VListItem <VListItem
v-for="(item, index) in storages" v-for="(item, index) in storages"
:key="index" :key="index"
:disabled="item.value === storageObject?.value" :disabled="item.code === storageObject?.code"
@click="changeStorage(item.value)" @click="changeStorage(item.code)"
> >
<template #prepend> <template #prepend>
<Icon :icon="item.icon" /> <Icon :icon="item.icon" />
</template> </template>
<VListItemTitle>{{ item.title }}</VListItemTitle> <VListItemTitle>{{ item.name }}</VListItemTitle>
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>
<VBtn variant="text" :input-value="item.path === '/'" class="px-1" @click="changePath(inProps.itemstack[0])"> <VBtn variant="text" :input-value="item.path === '/'" class="px-1" @click="changePath(inProps.itemstack[0])">
<VIcon :icon="storageObject?.icon" class="mr-2" /> <VIcon :icon="storageObject?.icon" class="mr-2" />
{{ storageObject?.title }} {{ storageObject?.name }}
</VBtn> </VBtn>
<template v-for="(segment, index) in pathSegments" :key="index"> <template v-for="(segment, index) in pathSegments" :key="index">
<VBtn <VBtn
@@ -169,7 +171,7 @@ const sortIcon = computed(() => {
</IconBtn> </IconBtn>
</template> </template>
</VTooltip> </VTooltip>
<VDialog v-if="newFolderPopper" v-model="newFolderPopper" max-width="50rem"> <VDialog v-model="newFolderPopper" max-width="50rem">
<template #activator="{ props }"> <template #activator="{ props }">
<IconBtn v-bind="props"> <IconBtn v-bind="props">
<VTooltip text="新建文件夹"> <VTooltip text="新建文件夹">

View File

@@ -1,38 +0,0 @@
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: String,
default: '* * * * *',
},
})
const emit = defineEmits(['update:modelValue'])
const currentCron = ref(props.modelValue)
watch(currentCron, newVal => {
emit('update:modelValue', newVal)
})
watch(
() => props.modelValue,
value => {
currentCron.value = value
},
)
</script>
<template>
<div>
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
<template v-slot:activator="{ props }">
<slot name="activator" :menuprops="props" />
</template>
<VList>
<VListItem>
<VCronVuetify v-model="currentCron" locale="zh-CN" :chip-props="{ color: 'success' }" class="mt-1" />
</VListItem>
</VList>
</VMenu>
</div>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import api from '@/api'
import { FileItem } from '@/api/types'
import { VTreeview } from 'vuetify/labs/VTreeview'
// 输入变量为默认路径
const props = defineProps({
root: {
type: String,
default: '/',
},
})
// update:modelValue 事件
const emit = defineEmits(['update:modelValue'])
// 激活的目录
const activedDirs = ref<string[]>([])
// 打开的目录
const openedDirs = ref<string[]>([])
// 目录列表
const treeItems = ref<FileItem[]>([
{
name: '/',
path: props.root,
children: [],
type: '',
basename: props.root,
extension: '',
size: 0,
modify_time: 0,
fileid: '',
parent_fileid: '',
},
])
// 拉取子目录
async function fetchDirs(item: any) {
return api
.get('/local/listdir?path=' + item.path)
.then((data: any) => {
item.children.push(...data)
})
.catch(err => console.warn(err))
}
// 获取选择的目录路径
const selectedPath = computed(() => {
if (activedDirs.value.length > 0) {
return activedDirs.value[0]
}
return ''
})
// 监听目录变化
watch(activedDirs, newVal => {
if (!newVal.length) return
emit('update:modelValue', selectedPath)
})
onMounted(() => {
fetchDirs(treeItems.value[0])
})
</script>
<template>
<VMenu :close-on-content-click="false" content-class="cursor-default">
<template v-slot:activator="{ props }">
<slot name="activator" :menuprops="props" />
</template>
<VTreeview
v-model:activated="activedDirs"
v-model:opened="openedDirs"
:items="treeItems"
:load-children="fetchDirs"
item-key="path"
item-title="name"
item-value="path"
item-type="unknown"
activatable
return-object
max-height="20rem"
expand-icon="mdi-folder"
collapse-icon="mdi-folder-open"
>
</VTreeview>
</VMenu>
</template>

View File

@@ -1,174 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { FileItem } from '@/api/types'
const props = defineProps({
modelValue: {
type: String,
default: '/',
},
root: {
type: String,
default: '/',
required: true,
},
storage: {
type: String,
default: 'local',
},
})
const emit = defineEmits(['update:modelValue'])
const menuVisible = ref(false)
const treeItems = ref<FileItem[]>([
{
name: '/',
path: props.root,
children: [],
type: 'dir',
basename: props.root,
storage: props.storage,
},
])
const activedDirs = ref<FileItem[]>([])
const openedDirs = ref<FileItem[]>([])
// 调用API查询子目录
async function fetchDirs(item: any) {
return api
.post('/storage/list', item)
.then((data: any) => {
data = data.filter((i: any) => i.type === 'dir')
item.children?.push(...data)
})
.catch(err => console.warn(err))
}
// 递归查询路径
function findPath(item: FileItem, path: string): FileItem | null {
if (item.path === path) {
return item
}
if (item.children) {
for (const child of item.children) {
const res: FileItem | null = findPath(child, path)
if (res) {
return res
}
}
}
return null
}
// 根据路径展开所有子目录
async function expandDirs(path: string) {
// 分割路径
const paths = path.split('/').filter(i => i)
// 展开根目录
const root_item = treeItems.value[0]
await fetchDirs(root_item)
openedDirs.value.push(root_item)
// 逐级展开
let currentPath = '/'
for (const p of paths) {
currentPath += `${p}/`
// 查询当前目录
const item = findPath(root_item, currentPath)
if (!item) {
break
}
// 加载子目录
if (item.children?.length === 0) {
await fetchDirs(item)
}
// 打开当前目录
if (!openedDirs.value.includes(item) && path != currentPath) {
openedDirs.value.push(item)
}
// 选中当前目录
if (path == currentPath) {
activedDirs.value = [item]
}
}
}
// 当前选中项
const selectedPath = computed(() => {
if (activedDirs.value.length > 0) {
return activedDirs.value[0].path
}
return ''
})
watch(activedDirs, newVal => {
if (!newVal.length) return
emit('update:modelValue', selectedPath.value)
})
watch(
() => menuVisible.value,
async visible => {
if (visible) {
treeItems.value = [
{
name: '/',
path: props.root,
children: [],
type: 'dir',
basename: props.root,
storage: props.storage,
},
]
openedDirs.value = []
activedDirs.value = []
await expandDirs(props.modelValue)
}
},
)
watch(
() => props.storage,
async newVal => {
treeItems.value = [
{
name: '/',
path: props.root,
children: [],
type: 'dir',
basename: props.root,
storage: newVal,
},
]
activedDirs.value = []
openedDirs.value = []
},
)
</script>
<template>
<div>
<VMenu v-model="menuVisible" :close-on-content-click="false" content-class="cursor-default">
<template v-slot:activator="{ props }">
<slot name="activator" :menuprops="props" />
</template>
<VTreeview
v-model:activated="activedDirs"
v-model:opened="openedDirs"
:items="treeItems"
:load-children="fetchDirs"
item-key="path"
item-title="name"
item-value="path"
activatable
return-object
max-height="20rem"
expand-icon="mdi-folder"
collapse-icon="mdi-folder-open"
/>
</VMenu>
</div>
</template>

View File

@@ -1,7 +0,0 @@
<script lang="ts" setup>
defineProps<{ title: string }>()
</script>
<template>
<VListSubheader>{{ title }}</VListSubheader>
<VListItem><slot /></VListItem>
</template>

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