Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a825093a | ||
|
|
d70f477bc1 | ||
|
|
c9c897ffb5 | ||
|
|
462dea3e05 | ||
|
|
4e7a0084dd | ||
|
|
0268df0e24 | ||
|
|
f926ca66c0 | ||
|
|
16b5898928 | ||
|
|
c1bb66cc9d | ||
|
|
f7502d0d18 | ||
|
|
b4975f649c | ||
|
|
89353c1f7e | ||
|
|
fce10b6dca | ||
|
|
2cf95c6706 | ||
|
|
58ab1599db | ||
|
|
9745c2ea1a | ||
|
|
9db46e2949 | ||
|
|
7949505104 | ||
|
|
db0d5133e8 | ||
|
|
54415377ee | ||
|
|
d7f55477da | ||
|
|
faca586fa7 | ||
|
|
5f3ba7b9c7 | ||
|
|
abace4a58d | ||
|
|
5895cea587 | ||
|
|
cdbcef5232 | ||
|
|
d5d6bfdc56 | ||
|
|
75ae7f0c15 | ||
|
|
6931451f18 | ||
|
|
f5625e1354 | ||
|
|
d1be4a30b6 | ||
|
|
5c13362db2 | ||
|
|
6c71dce80c | ||
|
|
790c397951 | ||
|
|
e28e74b874 | ||
|
|
b99ea22d89 | ||
|
|
8938195c5d | ||
|
|
887b4a7862 | ||
|
|
7c9c39fa0e | ||
|
|
3b800753ec | ||
|
|
647119052c | ||
|
|
e9ce6bbd4e | ||
|
|
1fee27f78e | ||
|
|
e7a334861d | ||
|
|
267ae3436d | ||
|
|
60ff9f1891 | ||
|
|
f83efd23df | ||
|
|
db60f02745 | ||
|
|
3e109bd27c | ||
|
|
c4ccf6e3fa | ||
|
|
fb1a246e4a | ||
|
|
a418b03c06 | ||
|
|
e9fee000ca | ||
|
|
71c13e0653 | ||
|
|
32d7f933f8 | ||
|
|
f28dd810ce | ||
|
|
aaedd88ca7 | ||
|
|
00dee40917 | ||
|
|
019248b605 | ||
|
|
826f37bcc4 | ||
|
|
fa02a23e4c | ||
|
|
7143fb6f67 | ||
|
|
e1524c26cd | ||
|
|
72088dff2e | ||
|
|
8e6d3cf30e | ||
|
|
144992ccec | ||
|
|
673e883ae6 | ||
|
|
f197ed7972 | ||
|
|
ce642aceed | ||
|
|
d5411489c0 | ||
|
|
26c66627f8 | ||
|
|
c654986042 | ||
|
|
c5b5c15f99 | ||
|
|
7727b0f1c3 | ||
|
|
3d551ac45b | ||
|
|
555a00b731 | ||
|
|
9f9091b23e | ||
|
|
14c343142f | ||
|
|
890920775a | ||
|
|
7b38d2d74f | ||
|
|
e85c2870e2 | ||
|
|
cfbc5802e4 | ||
|
|
40cdb820fb | ||
|
|
f63beb776e | ||
|
|
20f031b2e2 | ||
|
|
b0f28b7e7c | ||
|
|
62bb6de80d | ||
|
|
3db4d883af | ||
|
|
8cb514d70e | ||
|
|
2d7880351b | ||
|
|
e1ee3ef2db | ||
|
|
aff30c48a0 | ||
|
|
55eea50a6e | ||
|
|
9ff212c94d | ||
|
|
6350c7e9e6 | ||
|
|
d097c1c17c | ||
|
|
b9ee6b4039 | ||
|
|
f1238a03b3 | ||
|
|
e90cf3ee77 | ||
|
|
468607c8e8 | ||
|
|
5bd9283177 | ||
|
|
117b12348c | ||
|
|
0d325b6eb8 | ||
|
|
86d5903f32 | ||
|
|
3b518d6f33 | ||
|
|
78f57e7d4b | ||
|
|
f710f1bfc0 | ||
|
|
c5d4fc62e6 | ||
|
|
60606d5eb9 | ||
|
|
8751236380 | ||
|
|
2291ce3680 | ||
|
|
16ed589857 | ||
|
|
b59254ca42 | ||
|
|
6e3f9b285d | ||
|
|
8bcff774fa | ||
|
|
9b04b12dec | ||
|
|
b22d81a9e9 | ||
|
|
6c80a3a8cd | ||
|
|
059d836653 | ||
|
|
2c3ecfeb6f | ||
|
|
b07e5eecc3 | ||
|
|
1847bc90cf | ||
|
|
899aaae47c | ||
|
|
bcc05086a4 | ||
|
|
d2cc547875 | ||
|
|
c6127f440e | ||
|
|
c2849ad49f | ||
|
|
9c6ba294f9 | ||
|
|
b2a8707e91 | ||
|
|
193e3085a9 | ||
|
|
2401f38e9f | ||
|
|
4ea65727a1 | ||
|
|
3d6cfe260c | ||
|
|
4c33a09c3c | ||
|
|
8d25743680 | ||
|
|
4bc5b763a2 | ||
|
|
00ea179c90 | ||
|
|
a17d40d2d0 | ||
|
|
62ddd703f1 | ||
|
|
77ab0ccae2 | ||
|
|
f377ac3fcc | ||
|
|
a81becd77b | ||
|
|
a004f1c758 | ||
|
|
b19b015986 | ||
|
|
3f0c1213ad | ||
|
|
f3a781d857 | ||
|
|
a07a32f648 | ||
|
|
1e1117b187 | ||
|
|
0e161b1735 | ||
|
|
98cbb8dc29 | ||
|
|
9c17d2d335 | ||
|
|
d4ea7f48c0 | ||
|
|
3ecfe3ba94 | ||
|
|
a959594348 | ||
|
|
faa1027c04 | ||
|
|
6f68732ef9 | ||
|
|
3a4e936938 | ||
|
|
8b4b79fa10 | ||
|
|
ce0dda0455 | ||
|
|
fd1ee398c4 | ||
|
|
1488017bf2 | ||
|
|
367f4236ad | ||
|
|
ec202f22e8 | ||
|
|
08081da29e | ||
|
|
5511424bd6 | ||
|
|
9f2c848413 | ||
|
|
d5efe2b499 | ||
|
|
9b989fc40f | ||
|
|
567e85d1f8 | ||
|
|
af00036e7f | ||
|
|
e58e6e2a3e | ||
|
|
370039664f | ||
|
|
d244363321 | ||
|
|
984d502259 | ||
|
|
34b418af96 | ||
|
|
eee0c0c878 | ||
|
|
952ef368ab | ||
|
|
dfaa789f7c | ||
|
|
74dd549ffb | ||
|
|
4d8f369ba0 | ||
|
|
824e2d72c7 | ||
|
|
143aa79797 | ||
|
|
0e1120f407 | ||
|
|
c8dbb9672a | ||
|
|
372b74776f | ||
|
|
abcc3c6411 | ||
|
|
ec4ab8762c | ||
|
|
bc93de8ff2 | ||
|
|
b426f3c6f2 | ||
|
|
99d9bb29ce | ||
|
|
5e109c666b | ||
|
|
0bed216735 | ||
|
|
d55bb8d336 | ||
|
|
7c32b3edf0 | ||
|
|
121cb7e442 | ||
|
|
dec3e1ea92 | ||
|
|
664b6610f3 | ||
|
|
44163f0fb2 | ||
|
|
d43865fcad | ||
|
|
fed92f3853 | ||
|
|
823d2a816e | ||
|
|
046c21edf6 |
3
.github/workflows/build.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Download Icons
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
run: |
|
||||
yarn
|
||||
yarn build
|
||||
echo "$frontend_version" > dist/version.txt
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Generate Release
|
||||
|
||||
@@ -21,14 +21,9 @@
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"max-line-length": [
|
||||
120,
|
||||
{
|
||||
"ignore": "comments"
|
||||
}
|
||||
],
|
||||
"liberty/use-logical-spec": true,
|
||||
"selector-class-pattern": null,
|
||||
"color-function-notation": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"fix": true
|
||||
}
|
||||
12
.vscode/settings.json
vendored
@@ -6,9 +6,6 @@
|
||||
"[javascript]": {
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
|
||||
},
|
||||
@@ -25,7 +22,7 @@
|
||||
},
|
||||
// Vue
|
||||
"[vue]": {
|
||||
"editor.formatOnSave": false
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
// Extension: Volar
|
||||
"volar.preview.port": 3000,
|
||||
@@ -34,6 +31,10 @@
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.fixAll.stylelint": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"eslint.alwaysShowStatus": true,
|
||||
"eslint.format.enable": true,
|
||||
// Extension: Stylelint
|
||||
@@ -53,6 +54,7 @@
|
||||
"stylelint",
|
||||
"touchless",
|
||||
"triggerer",
|
||||
"unref",
|
||||
"vuetify"
|
||||
],
|
||||
// Extension: Comment Anchors
|
||||
@@ -104,4 +106,4 @@
|
||||
]
|
||||
},
|
||||
"vue3snippets.enable-compile-vue-file-on-did-save-code": false
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# MoviePilot-Frontend
|
||||
|
||||
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目。
|
||||
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,NodeJS版本:>= `v20.12.1`。
|
||||
|
||||
## 推荐的IDE设置
|
||||
|
||||
|
||||
322
auto-imports.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
@@ -41,6 +42,7 @@ declare global {
|
||||
const h: typeof import('vue')['h']
|
||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
@@ -77,6 +79,7 @@ declare global {
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
@@ -105,6 +108,7 @@ declare global {
|
||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
||||
@@ -143,6 +147,7 @@ declare global {
|
||||
const useCeil: typeof import('@vueuse/math')['useCeil']
|
||||
const useClamp: typeof import('@vueuse/math')['useClamp']
|
||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
||||
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
||||
@@ -308,11 +313,13 @@ declare global {
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, InjectionKey, PropType, Ref, VNode } from 'vue'
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
// for vue template auto import
|
||||
import { UnwrapRef } from 'vue'
|
||||
declare module 'vue' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
@@ -351,6 +358,7 @@ declare module 'vue' {
|
||||
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']>
|
||||
@@ -387,6 +395,7 @@ declare module 'vue' {
|
||||
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']>
|
||||
@@ -415,6 +424,7 @@ declare module 'vue' {
|
||||
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']>
|
||||
@@ -453,6 +463,316 @@ declare module 'vue' {
|
||||
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']>
|
||||
}
|
||||
}
|
||||
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']>
|
||||
|
||||
6
components.d.ts
vendored
@@ -3,18 +3,18 @@
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
|
||||
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
|
||||
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
|
||||
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.vue')['default']
|
||||
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
StatIcon: typeof import('./src/@core/components/StatIcon.vue')['default']
|
||||
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
64
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "1.7.2",
|
||||
"version": "1.8.5",
|
||||
"private": true,
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
@@ -21,45 +21,47 @@
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.2.0",
|
||||
"@casl/vue": "^2.2.0",
|
||||
"@floating-ui/dom": "1.2.8",
|
||||
"@floating-ui/dom": "1.6.3",
|
||||
"@iconify/utils": "^2.1.22",
|
||||
"@vueuse/core": "^10.1.2",
|
||||
"@vueuse/math": "^10.1.2",
|
||||
"ace-builds": "^1.32.6",
|
||||
"apexcharts-clevision": "^3.28.5",
|
||||
"axios": "1.4.0",
|
||||
"axios": "1.6.8",
|
||||
"axios-mock-adapter": "^1.21.4",
|
||||
"chart.js": "^4.1.2",
|
||||
"colorthief": "^2.4.0",
|
||||
"express": "^4.18.2",
|
||||
"express-http-proxy": "^2.0.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"postcss-purgecss": "^5.0.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"pull-refresh-vue3": "^0.3.1",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"roboto-fontface": "^0.10.0",
|
||||
"sass": "^1.59.3",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"unplugin-vue-define-options": "^1.3.5",
|
||||
"vite-plugin-pwa": "^0.16.4",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"vue": "^3.3.2",
|
||||
"vue-chartjs": "^5.2.0",
|
||||
"vue-flatpickr-component": "11.0.3",
|
||||
"vue-flatpickr-component": "11.0.5",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-prism-component": "^2.0.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"vue-toast-notification": "^3",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vue3-apexcharts": "^1.4.1",
|
||||
"vue3-perfect-scrollbar": "^1.6.0",
|
||||
"vuetify": "3.3.5",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuetify": "3.5.14",
|
||||
"vuetify-use-dialog": "^0.6.0",
|
||||
"vuex": "^4.1.0",
|
||||
"vuex-persistedstate": "^4.1.0",
|
||||
"webfontloader": "^1.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config-vue": "^0.38.6",
|
||||
"@antfu/eslint-config-vue": "^0.43.1",
|
||||
"@fullcalendar/core": "^6.1.8",
|
||||
"@fullcalendar/daygrid": "^6.1.8",
|
||||
"@fullcalendar/interaction": "^6.1.7",
|
||||
@@ -67,43 +69,45 @@
|
||||
"@fullcalendar/timegrid": "^6.1.7",
|
||||
"@fullcalendar/vue3": "^6.1.8",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify/tools": "^2.2.0",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "4.1.1",
|
||||
"@intlify/unplugin-vue-i18n": "^0.10.0",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.5",
|
||||
"@typescript-eslint/parser": "^5.59.5",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^3.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.40.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-promise": "^6.0.1",
|
||||
"eslint-plugin-regex": "^1.10.0",
|
||||
"eslint-plugin-sonarjs": "^0.19.0",
|
||||
"eslint-plugin-unicorn": "^47.0.0",
|
||||
"eslint-plugin-sonarjs": "^0.25.1",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"eslint-plugin-vue": "^9.12.0",
|
||||
"postcss": "^8.4.24",
|
||||
"lodash": "^4.17.21",
|
||||
"postcss": "8",
|
||||
"postcss-html": "^1.5.0",
|
||||
"stylelint": "14.15.0",
|
||||
"stylelint-config-idiomatic-order": "9.0.0",
|
||||
"stylelint-config-standard-scss": "6.1.0",
|
||||
"stylelint-use-logical-spec": "4.1.0",
|
||||
"type-fest": "^3.10.0",
|
||||
"stylelint": "16.3.1",
|
||||
"stylelint-config-idiomatic-order": "10.0.0",
|
||||
"stylelint-config-standard-scss": "13.1.0",
|
||||
"stylelint-use-logical-spec": "5.0.1",
|
||||
"type-fest": "^4.15.0",
|
||||
"typescript": "^5.0.4",
|
||||
"unplugin-auto-import": "^0.15.1",
|
||||
"unplugin-vue-components": "^0.24.1",
|
||||
"vite": "^4.3.5",
|
||||
"vite-plugin-pages": "^0.29.0",
|
||||
"vite-plugin-vue-layouts": "^0.8.0",
|
||||
"vite-plugin-vuetify": "1.0.2",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-pages": "^0.32.1",
|
||||
"vite-plugin-vue-layouts": "^0.11.0",
|
||||
"vite-plugin-vuetify": "2.0.3",
|
||||
"vue-shepherd": "^3.0.0",
|
||||
"vue-tsc": "^1.6.5"
|
||||
"vue-tsc": "^2.0.10"
|
||||
},
|
||||
"packageManager": "yarn@1.22.18",
|
||||
"resolutions": {
|
||||
|
||||
@@ -75,6 +75,26 @@ http {
|
||||
# 超时设置
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /cookiecloud {
|
||||
# 后端cookiecloud地址
|
||||
proxy_pass http://backend_api;
|
||||
rewrite ^.+mock-server/?(.*)$ /$1 break;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Nginx-Proxy true;
|
||||
|
||||
# 超时设置
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
upstream backend_api {
|
||||
|
||||
@@ -25,6 +25,16 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// 配置代理中间件将CookieCloud请求转发给后端API
|
||||
app.use(
|
||||
'/cookiecloud',
|
||||
proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
|
||||
// 路径加上 /cookiecloud 前缀
|
||||
proxyReqPathResolver: (req) => {
|
||||
return `/cookiecloud${req.url}`
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 处理根路径的请求
|
||||
app.get('/', (req, res) => {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
// 是否显示
|
||||
innerClass: String,
|
||||
})
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const emit = defineEmits(['click', 'update:modelValue'])
|
||||
// 按钮点击
|
||||
function onClick() {
|
||||
emit('update:modelValue', false)
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IconBtn
|
||||
class="absolute right-3 top-3"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<IconBtn :class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'" @click.stop="onClick">
|
||||
<VIcon icon="mdi-close" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
|
||||
28
src/@core/components/LoadingBanner.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
progress: Number,
|
||||
text: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
v-if="!props.text"
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<VProgressCircular
|
||||
v-if="props.progress"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
:model-value="props.progress"
|
||||
size="64"
|
||||
/>
|
||||
<span>{{ props.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
18
src/@core/components/StatIcon.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
color?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute top-2 right-2 flex items-center justify-between p-2 shadow">
|
||||
<VBadge :color="props.color" bordered>
|
||||
<template #badge>
|
||||
<VIcon icon="mdi-pulse"></VIcon>
|
||||
</template>
|
||||
</VBadge>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import type { ThemeSwitcherTheme } from '@layouts/types'
|
||||
import api from '@/api'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
themes: ThemeSwitcherTheme[]
|
||||
@@ -20,54 +22,17 @@ const {
|
||||
{ initialValue: savedTheme.value },
|
||||
)
|
||||
|
||||
function updateTheme() {
|
||||
const autoTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
|
||||
globalTheme.name.value = theme
|
||||
savedTheme.value = theme
|
||||
// 修改载入时背景色
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
themeTransition()
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
|
||||
|
||||
watch(
|
||||
() => currentThemeName.value,
|
||||
() => updateTheme(),
|
||||
)
|
||||
|
||||
function changeTheme() {
|
||||
const nextTheme = getNextThemeName()
|
||||
currentThemeName.value = nextTheme
|
||||
localStorage.setItem('theme', nextTheme)
|
||||
}
|
||||
|
||||
// Apply saved theme on page load
|
||||
// onMounted(() => {
|
||||
// globalTheme.name.value = savedTheme.value
|
||||
// })
|
||||
|
||||
function hasScrollbar(el?: Element | null) {
|
||||
if (!el || el.nodeType !== Node.ELEMENT_NODE)
|
||||
return false
|
||||
|
||||
const style = window.getComputedStyle(el)
|
||||
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
|
||||
}
|
||||
|
||||
// 主题切换动画
|
||||
function themeTransition() {
|
||||
const x = performance.now()
|
||||
for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9));
|
||||
const cost = performance.now() - x
|
||||
if (cost > 10)
|
||||
return
|
||||
if (cost > 10) return
|
||||
|
||||
const el: HTMLElement = document.querySelector('[data-v-app]')!
|
||||
const children = el.querySelectorAll('*') as NodeListOf<HTMLElement>
|
||||
|
||||
children.forEach((el) => {
|
||||
children.forEach(el => {
|
||||
if (hasScrollbar(el)) {
|
||||
el.dataset.scrollX = String(el.scrollLeft)
|
||||
el.dataset.scrollY = String(el.scrollTop)
|
||||
@@ -99,7 +64,7 @@ function themeTransition() {
|
||||
})
|
||||
|
||||
document.body.append(copy)
|
||||
; (copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach((el) => {
|
||||
;(copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach(el => {
|
||||
el.scrollLeft = +el.dataset.scrollX!
|
||||
el.scrollTop = +el.dataset.scrollY!
|
||||
})
|
||||
@@ -117,6 +82,55 @@ function themeTransition() {
|
||||
el.addEventListener('transitionend', onTransitionend)
|
||||
el.addEventListener('transitioncancel', onTransitionend)
|
||||
}
|
||||
|
||||
// 更新主题
|
||||
function updateTheme() {
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
|
||||
globalTheme.name.value = theme
|
||||
savedTheme.value = theme
|
||||
themeTransition()
|
||||
}
|
||||
|
||||
// 切换主题
|
||||
function changeTheme() {
|
||||
const nextTheme = getNextThemeName()
|
||||
currentThemeName.value = nextTheme
|
||||
// 保存主题到本地
|
||||
localStorage.setItem('theme', nextTheme)
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
// 保存主题到服务端
|
||||
try {
|
||||
api.post('/user/config/theme', nextTheme, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('保存主题到服务端失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 是否有滚动条
|
||||
function hasScrollbar(el?: Element | null) {
|
||||
if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
|
||||
|
||||
const style = window.getComputedStyle(el)
|
||||
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
try {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
|
||||
} catch (e) {
|
||||
console.error('当前设备不支持监听系统主题变化')
|
||||
}
|
||||
|
||||
// 监听设置主题变化
|
||||
watch(
|
||||
() => currentThemeName.value,
|
||||
() => updateTheme(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
18
src/@core/utils/compatibility.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 浏览器兼容性处理
|
||||
*/
|
||||
|
||||
/**
|
||||
* 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||
*/
|
||||
export function fixArrayAt() {
|
||||
if (!Array.prototype.at) {
|
||||
Array.prototype.at = function(index: number) {
|
||||
if (index >= 0) {
|
||||
return this[index]
|
||||
} else {
|
||||
return this[this.length + index]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import ZH_CN from 'dayjs/locale/zh-cn'
|
||||
|
||||
import { isToday } from './index'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale(ZH_CN)
|
||||
|
||||
export function avatarText(value: string) {
|
||||
if (!value)
|
||||
return ''
|
||||
@@ -19,7 +26,7 @@ export function kFormatter(num: number) {
|
||||
* Format and return date in Humanize format
|
||||
* Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format
|
||||
* Intl Constructor: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
|
||||
* @param {String} value date to format
|
||||
* @param {string} value date to format
|
||||
* @param {Intl.DateTimeFormatOptions} formatting Intl object to format with
|
||||
*/
|
||||
export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }) {
|
||||
@@ -32,8 +39,8 @@ export function formatDate(value: string, formatting: Intl.DateTimeFormatOptions
|
||||
/**
|
||||
* Return short human friendly month representation of date
|
||||
* Can also convert date to only time if date is of today (Better UX)
|
||||
* @param {String} value date to format
|
||||
* @param {Boolean} toTimeForCurrentDay Shall convert to time if day is today/current
|
||||
* @param {string} value date to format
|
||||
* @param {boolean} toTimeForCurrentDay Shall convert to time if day is today/current
|
||||
*/
|
||||
export function formatDateToMonthShort(value: string, toTimeForCurrentDay = true) {
|
||||
const date = new Date(value)
|
||||
@@ -107,7 +114,7 @@ export function formatBytes(bytes: number, decimals = 2) {
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
// 格式化剧集列表
|
||||
@@ -147,3 +154,24 @@ export function formatEp(nums: number[]): string {
|
||||
|
||||
return formattedRanges.join('、')
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd hh:mm:ss转换为时间差,如:1小时前,1天前
|
||||
export function formatDateDifference(dateString: string): string {
|
||||
// const timeDifference = dayjs().millisecond() - dayjs(dateString).millisecond()
|
||||
// const secondsDifference = Math.floor(timeDifference / 1000)
|
||||
// const minutesDifference = Math.floor(secondsDifference / 60)
|
||||
// const hoursDifference = Math.floor(minutesDifference / 60)
|
||||
// const daysDifference = Math.floor(hoursDifference / 24)
|
||||
|
||||
// if (daysDifference > 0)
|
||||
// return `${daysDifference}天前`
|
||||
// else if (hoursDifference > 0)
|
||||
// return `${hoursDifference}小时前`
|
||||
// else if (minutesDifference > 0)
|
||||
// return `${minutesDifference}分钟前`
|
||||
// else
|
||||
// return '刚刚'
|
||||
if (!dateString)
|
||||
return ''
|
||||
return dayjs(dateString).fromNow()
|
||||
}
|
||||
|
||||
@@ -33,12 +33,16 @@ export function isToday(date: Date) {
|
||||
)
|
||||
}
|
||||
|
||||
// 计算时间差,返回xx天/xx小时/xx分钟/xx秒
|
||||
/**
|
||||
* 计算时间差,返回xx天/xx小时/xx分钟/xx秒
|
||||
*
|
||||
* @deprecated 建议使用:@core/utils/formatters.ts formatDateDifference
|
||||
*/
|
||||
export function calculateTimeDifference(inputTime: string): string {
|
||||
if (!inputTime)
|
||||
return ''
|
||||
|
||||
const inputDate = new Date(inputTime)
|
||||
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
|
||||
const currentDate = new Date()
|
||||
|
||||
const timeDifference = currentDate.getTime() - inputDate.getTime()
|
||||
@@ -70,7 +74,7 @@ export function calculateTimeDiff(inputTime: string): string {
|
||||
return ''
|
||||
|
||||
// 使用当前时区
|
||||
const inputDate = new Date(inputTime)
|
||||
const inputDate = new Date(inputTime.replaceAll(/-/g, '/'))
|
||||
const currentDate = new Date()
|
||||
|
||||
const timeDifference = currentDate.getTime() - inputDate.getTime()
|
||||
@@ -114,3 +118,12 @@ export function isNullOrEmptyObject(obj: any): boolean {
|
||||
// 然后判断是否为空对象
|
||||
return !!(typeof obj === 'object' && Object.keys(obj).length === 0)
|
||||
}
|
||||
|
||||
// 判断系统配置色是否是黑暗的
|
||||
export function checkPrefersColorSchemeIsDark(): boolean {
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue'
|
||||
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import logo from '@images/logo.svg?raw'
|
||||
|
||||
@@ -85,6 +84,7 @@ function handleNavScroll(evt: Event) {
|
||||
.visible {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
// 👉 Vertical Nav
|
||||
.layout-vertical-nav {
|
||||
position: fixed;
|
||||
@@ -96,8 +96,8 @@ function handleNavScroll(evt: Event) {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
transition: transform 0.25s ease-in-out, inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
|
||||
will-change: transform, inline-size;
|
||||
visibility: hidden;
|
||||
will-change: transform, inline-size;
|
||||
|
||||
&:not(.overlay-nav) {
|
||||
visibility: visible;
|
||||
|
||||
@@ -52,7 +52,7 @@ export default defineComponent({
|
||||
'main',
|
||||
{ class: 'layout-page-content' },
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
() => h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ $layout-horizontal-nav-layout-navbar-z-index: 11 !default;
|
||||
$layout-boxed-content-width: 90rem !default;
|
||||
|
||||
// 👉Footer
|
||||
$layout-vertical-nav-footer-height: 3.5rem !default;
|
||||
$layout-vertical-nav-footer-height: 0rem !default;
|
||||
|
||||
// 👉 Layout overlay
|
||||
$layout-overlay-z-index: 11 !default;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
@use "_global";
|
||||
@use "vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.min.css";
|
||||
@use "_classes";
|
||||
|
||||
46
src/App.vue
@@ -1,33 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
import api from '@/api'
|
||||
import store from './store'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
|
||||
const { global: globalTheme } = useTheme()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 设置主题
|
||||
function setTheme() {
|
||||
const { global: globalTheme } = useTheme()
|
||||
let theme = localStorage.getItem('theme') || 'light'
|
||||
if (theme === 'auto')
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
globalTheme.name.value = theme
|
||||
// 生效主题
|
||||
async function setTheme() {
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
}
|
||||
|
||||
// SSE持续接收消息
|
||||
function startSSEMessager() {
|
||||
const token = store.state.auth.token
|
||||
if (token) {
|
||||
const eventSource = new EventSource(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`,
|
||||
)
|
||||
const eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`)
|
||||
|
||||
eventSource.addEventListener('message', (event) => {
|
||||
eventSource.addEventListener('message', event => {
|
||||
const message = event.data
|
||||
if (message)
|
||||
$toast.info(message)
|
||||
if (message) $toast.info(message)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -36,10 +34,30 @@ function startSSEMessager() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户监控面板配置
|
||||
async function loadDashboardConfig() {
|
||||
const response = await api.get('/user/config/Dashboard')
|
||||
if (response && response.data && response.data.value) {
|
||||
const data = JSON.stringify(response.data.value)
|
||||
if (data != localStorage.getItem('MP_DASHBOARD')) {
|
||||
localStorage.setItem('MP_DASHBOARD', data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试加载用户监控面板配置(本地无配置时才加载)
|
||||
async function tryLoadDashboardConfig() {
|
||||
if (localStorage.getItem('MP_DASHBOARD')) {
|
||||
return
|
||||
}
|
||||
await loadDashboardConfig()
|
||||
}
|
||||
|
||||
// 页面加载时,加载当前用户数据
|
||||
onBeforeMount(async () => {
|
||||
setTheme()
|
||||
startSSEMessager()
|
||||
await tryLoadDashboardConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
434
src/api/types.ts
|
Before Width: | Height: | Size: 27 KiB |
BIN
src/assets/images/logos/bangumi.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/assets/images/logos/douban-black.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/images/misc/teamwork.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 253 KiB |
@@ -1,10 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import axios from 'axios'
|
||||
import List from './filebrowser/List.vue'
|
||||
|
||||
import Toolbar from './filebrowser/Toolbar.vue'
|
||||
import Tree from './filebrowser/Tree.vue'
|
||||
import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import type { EndPoints } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
@@ -70,12 +68,10 @@ const storagesArray = computed(() => {
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading) {
|
||||
if (loading)
|
||||
loading++
|
||||
}
|
||||
else if (loading > 0) {
|
||||
else if (loading > 0)
|
||||
loading--
|
||||
}
|
||||
}
|
||||
|
||||
function storageChanged(storage: string) {
|
||||
@@ -103,7 +99,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<VCard class="mx-auto" :loading="loading > 0 || !path">
|
||||
<div v-if="path">
|
||||
<Toolbar
|
||||
<FileToolbar
|
||||
:path="path"
|
||||
:storages="storagesArray"
|
||||
:storage="activeStorage"
|
||||
@@ -114,38 +110,20 @@ onMounted(() => {
|
||||
@foldercreated="refreshPending = true"
|
||||
@sortchanged="sortChanged"
|
||||
/>
|
||||
<VRow no-gutters>
|
||||
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
|
||||
<Tree
|
||||
:path="path"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:refreshpending="refreshPending"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
/>
|
||||
</VCol>
|
||||
<VDivider v-if="tree" vertical />
|
||||
<VCol>
|
||||
<List
|
||||
:path="path"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<FileList
|
||||
:path="path"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axiosInstance"
|
||||
:refreshpending="refreshPending"
|
||||
:sort="sort"
|
||||
@pathchanged="pathChanged"
|
||||
@loading="loadingChanged"
|
||||
@refreshed="refreshPending = false"
|
||||
@filedeleted="refreshPending = true"
|
||||
@renamed="refreshPending = true"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import miscpose from '@images/pages/pose-fs-9.png'
|
||||
import image from '@images/misc/teamwork.png'
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
@@ -11,25 +11,24 @@ interface Props {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<ErrorHeader
|
||||
:error-code="props.errorCode"
|
||||
:error-title="props.errorTitle"
|
||||
:error-description="props.errorDescription"
|
||||
/>
|
||||
<VEmptyState
|
||||
:image="image"
|
||||
size="250"
|
||||
>
|
||||
<template #title>
|
||||
<div class="mt-8 text-2xl">
|
||||
{{ props.errorTitle }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 👉 Image -->
|
||||
<div class="text-center">
|
||||
<VImg
|
||||
:src="miscpose"
|
||||
class="mx-auto pt-10"
|
||||
max-width="250"
|
||||
cover
|
||||
/>
|
||||
<template #text>
|
||||
<div class="text-subtitle">
|
||||
{{ props.errorDescription }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<slot name="button" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VEmptyState>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,7 @@ function goPlay() {
|
||||
// 计算图片地址
|
||||
const getImgUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { kFormatter } from '@core/utils/formatters'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
color?: string
|
||||
icon: string
|
||||
stats: number
|
||||
change: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'primary',
|
||||
})
|
||||
|
||||
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<VAvatar
|
||||
size="44"
|
||||
rounded
|
||||
:color="props.color"
|
||||
variant="tonal"
|
||||
class="me-4"
|
||||
>
|
||||
<VIcon
|
||||
:icon="props.icon"
|
||||
size="30"
|
||||
/>
|
||||
</VAvatar>
|
||||
|
||||
<div>
|
||||
<span class="text-caption">{{ props.title }}</span>
|
||||
<div class="d-flex align-center flex-wrap">
|
||||
<span class="text-h6 font-weight-semibold">{{ kFormatter(props.stats) }}</span>
|
||||
<div
|
||||
v-if="props.change"
|
||||
:class="`${isPositive ? 'text-success' : 'text-error'} mt-1`"
|
||||
>
|
||||
<VIcon :icon="isPositive ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
|
||||
<span class="text-caption font-weight-semibold">{{ Math.abs(props.change) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
color?: string
|
||||
icon: string
|
||||
stats: string
|
||||
change: number
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'primary',
|
||||
})
|
||||
|
||||
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard>
|
||||
<VCardText class="d-flex align-center">
|
||||
<VAvatar
|
||||
v-if="props.icon"
|
||||
size="38"
|
||||
:color="props.color"
|
||||
>
|
||||
<VIcon
|
||||
:icon="props.icon"
|
||||
size="24"
|
||||
/>
|
||||
</VAvatar>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<MoreBtn class="me-n3 mt-n1" />
|
||||
</VCardText>
|
||||
|
||||
<VCardText>
|
||||
<h6 class="text-sm font-weight-semibold mb-2">
|
||||
{{ props.title }}
|
||||
</h6>
|
||||
<div
|
||||
v-if="props.change"
|
||||
class="d-flex align-center mb-2"
|
||||
>
|
||||
<span class="font-weight-semibold text-h5 me-2">{{ props.stats }}</span>
|
||||
<span
|
||||
:class="isPositive ? 'text-success' : 'text-error'"
|
||||
class="text-caption"
|
||||
>
|
||||
{{ isPositive ? `+${props.change}` : props.change }}%
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-caption">{{ props.subtitle }}</span>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle: string
|
||||
stats: string
|
||||
change: number
|
||||
image: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'primary',
|
||||
})
|
||||
|
||||
const isPositive = controlledComputed(() => props.change, () => Math.sign(props.change) === 1)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="overflow-visible">
|
||||
<div class="d-flex position-relative">
|
||||
<VCardText>
|
||||
<h6 class="text-base font-weight-semibold mb-4">
|
||||
{{ props.title }}
|
||||
</h6>
|
||||
<div class="d-flex align-center flex-wrap mb-4">
|
||||
<h5 class="text-h5 font-weight-semibold me-2">
|
||||
{{ props.stats }}
|
||||
</h5>
|
||||
<span
|
||||
class="text-caption"
|
||||
:class="isPositive ? 'text-success' : 'text-error'"
|
||||
>
|
||||
{{ isPositive ? `+${props.change}` : props.change }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<VChip
|
||||
v-if="props.subtitle"
|
||||
size="small"
|
||||
:color="props.color"
|
||||
>
|
||||
{{ props.subtitle }}
|
||||
</VChip>
|
||||
</VCardText>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<div class="illustrator-img">
|
||||
<VImg
|
||||
v-if="props.image"
|
||||
:src="props.image"
|
||||
:width="110"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.illustrator-img {
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 5%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import personIcon from '@images/misc/person-icon.png'
|
||||
import type { DoubanPerson } from '@/api/types'
|
||||
|
||||
const personProps = defineProps({
|
||||
person: Object as PropType<DoubanPerson>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 当前人物
|
||||
const personInfo = ref(personProps.person)
|
||||
|
||||
// 人物图片是否加载
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 人物图片地址
|
||||
function getPersonImage() {
|
||||
if (!personInfo.value?.avatar)
|
||||
return personIcon
|
||||
return personInfo.value?.avatar?.large
|
||||
}
|
||||
|
||||
// 打开人物详情
|
||||
function goPersonDetail() {
|
||||
if (!personInfo.value?.id)
|
||||
return
|
||||
window.open(`https://movie.douban.com/celebrity/${personInfo.value?.id}/`, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover v-bind="personProps">
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="rounded-lg"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 scale-105': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<div
|
||||
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
|
||||
>
|
||||
<div style="padding-bottom: 150%;">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
||||
<VAvatar
|
||||
size="120"
|
||||
:class="{
|
||||
'ring-1 ring-gray-700': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
v-img
|
||||
:src="getPersonImage()"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="w-full truncate text-center font-bold">
|
||||
{{ personInfo?.name }}
|
||||
</div>
|
||||
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
|
||||
{{ personInfo?.character }}
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.person-card {
|
||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
|
||||
}
|
||||
|
||||
.person-card:hover {
|
||||
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-custom-background)) 60%);
|
||||
}
|
||||
</style>
|
||||
@@ -25,8 +25,8 @@ const isDownloading = ref(props.info?.state === 'downloading')
|
||||
|
||||
// 监听props.info?.state的变化
|
||||
watch(() => props.info?.state, (newValue) => {
|
||||
isDownloading.value = newValue === 'downloading';
|
||||
});
|
||||
isDownloading.value = newValue === 'downloading'
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
@@ -56,7 +56,7 @@ function getImgUrl(url: string) {
|
||||
if (!url)
|
||||
return getDefaultImage()
|
||||
else
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(url)}/0`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
@@ -68,7 +68,7 @@ async function drawImages(imageList: string[]) {
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++)
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(IMAGES[i])}/0`
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
||||
|
||||
// canvas
|
||||
const canvas = canvasRef.value
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType, Ref } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import SubscribeEditForm from '../form/SubscribeEditForm.vue'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import tmdbImage from '@images/logos/tmdb.png'
|
||||
import doubanImage from '@images/logos/douban-black.png'
|
||||
import bangumiImage from '@images/logos/bangumi.png'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -16,11 +19,6 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 订阅规则
|
||||
const subscribeRules = ref({
|
||||
show_edit_dialog: false,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -57,22 +55,35 @@ const seasonInfos = ref<TmdbSeason[]>([])
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<TmdbSeason[]>([])
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict = {
|
||||
themoviedb: tmdbImage,
|
||||
douban: doubanImage,
|
||||
bangumi: bangumiImage,
|
||||
}
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
return props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: props.media?.douban_id
|
||||
? `douban:${props.media?.douban_id}`
|
||||
: `bangumi:${props.media?.bangumi_id}`
|
||||
}
|
||||
|
||||
// 订阅弹窗选择的多季
|
||||
function subscribeSeasons() {
|
||||
subscribeSeasonDialog.value = false
|
||||
seasonsSelected.value.forEach((season) => {
|
||||
seasonsSelected.value.forEach(season => {
|
||||
addSubscribe(season.season_number)
|
||||
})
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影')
|
||||
return 'border-blue-500 bg-blue-600'
|
||||
else if (type === '电视剧')
|
||||
return ' bg-indigo-500 border-indigo-600'
|
||||
else
|
||||
return 'border-purple-600 bg-purple-600'
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'
|
||||
else return 'border-purple-600 bg-purple-600'
|
||||
}
|
||||
|
||||
// 添加订阅处理
|
||||
@@ -89,26 +100,22 @@ async function handleAddSubscribe() {
|
||||
|
||||
// 检查各季的缺失状态
|
||||
await checkSeasonsNotExists()
|
||||
if (!tmdbFlag.value)
|
||||
return
|
||||
if (!tmdbFlag.value) return
|
||||
|
||||
if (seasonInfos.value.length === 1) {
|
||||
// 添加订阅
|
||||
addSubscribe(1)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
subscribeSeasonDialog.value = true
|
||||
}
|
||||
}
|
||||
else if (props.media?.type === '电视剧') {
|
||||
} else if (props.media?.type === '电视剧') {
|
||||
// 豆瓣电视剧,只会有一季
|
||||
const season = props.media?.season ?? 1
|
||||
// 添加订阅
|
||||
addSubscribe(season)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
}
|
||||
@@ -131,6 +138,7 @@ async function addSubscribe(season = 0) {
|
||||
year: props.media?.year,
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
season,
|
||||
best_version,
|
||||
})
|
||||
@@ -142,43 +150,32 @@ async function addSubscribe(season = 0) {
|
||||
}
|
||||
|
||||
// 提示
|
||||
showSubscribeAddToast(
|
||||
result.success,
|
||||
props.media?.title ?? '',
|
||||
season,
|
||||
result.message,
|
||||
best_version,
|
||||
)
|
||||
showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)
|
||||
|
||||
// 弹出订阅编辑弹窗
|
||||
if (result.success && seasonsSelected.value.length <= 1 && subscribeRules.value.show_edit_dialog) {
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
if (result.success && seasonsSelected.value.length <= 1) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean,
|
||||
title: string,
|
||||
season: number,
|
||||
message: string,
|
||||
best_version: number) {
|
||||
if (season)
|
||||
title = `${title} ${formatSeason(season.toString())}`
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
|
||||
if (season) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = '订阅'
|
||||
if (best_version > 0)
|
||||
subname = '洗版订阅'
|
||||
if (best_version > 0) subname = '洗版订阅'
|
||||
|
||||
if (result && seasonsSelected.value.length > 1)
|
||||
$toast.success(`${title} 添加${subname}成功!`)
|
||||
else if (!result)
|
||||
$toast.error(`${title} 添加${subname}失败:${message}!`)
|
||||
if (result && seasonsSelected.value.length > 1) $toast.success(`${title} 添加${subname}成功!`)
|
||||
else if (!result) $toast.error(`${title} 添加${subname}失败:${message}!`)
|
||||
}
|
||||
|
||||
// 调用API取消订阅
|
||||
@@ -186,28 +183,21 @@ async function removeSubscribe() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: `douban:${props.media?.douban_id}`
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(
|
||||
`subscribe/media/${mediaid}`,
|
||||
{
|
||||
params: {
|
||||
season: props.media?.season,
|
||||
},
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season: props.media?.season,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
$toast.success(`${props.media?.title} 已取消订阅!`)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}!`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
@@ -217,10 +207,8 @@ async function removeSubscribe() {
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season)
|
||||
if (result)
|
||||
isSubscribed.value = true
|
||||
}
|
||||
catch (error) {
|
||||
if (result) isSubscribed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -238,10 +226,8 @@ async function handleCheckExists() {
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success)
|
||||
isExists.value = true
|
||||
}
|
||||
catch (error) {
|
||||
if (result.success) isExists.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -249,9 +235,7 @@ async function handleCheckExists() {
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
try {
|
||||
const mediaid = props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: `douban:${props.media?.douban_id}`
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
@@ -261,8 +245,7 @@ async function checkSubscribe(season = 0) {
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
@@ -276,19 +259,16 @@ async function checkSeasonsNotExists() {
|
||||
try {
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
|
||||
if (result) {
|
||||
result.forEach((item) => {
|
||||
result.forEach(item => {
|
||||
// 0-已入库 1-部分缺失 2-全部缺失
|
||||
let state = 0
|
||||
if (item.episodes.length === 0)
|
||||
state = 2
|
||||
else if (item.episodes.length < item.total_episode)
|
||||
state = 1
|
||||
if (item.episodes.length === 0) state = 2
|
||||
else if (item.episodes.length < item.total_episode) state = 1
|
||||
|
||||
seasonsNotExisted.value[item.season] = state
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
$toast.error(`${props.media?.title}无法识别TMDB媒体信息!`)
|
||||
tmdbFlag.value = false
|
||||
}
|
||||
@@ -301,75 +281,64 @@ async function checkSeasonsNotExists() {
|
||||
async function getMediaSeasons() {
|
||||
try {
|
||||
seasonInfos.value = await api.get(`tmdb/seasons/${props.media?.tmdb_id}`)
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function querySubscribeRules() {
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
'system/setting/DefaultFilterRules',
|
||||
)
|
||||
if (result.data?.value)
|
||||
subscribeRules.value = result.data?.value
|
||||
}
|
||||
catch (error) {
|
||||
let subscribe_config_url = ''
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 爱心订阅按钮响应
|
||||
function handleSubscribe() {
|
||||
if (isSubscribed.value)
|
||||
removeSubscribe()
|
||||
else
|
||||
handleAddSubscribe()
|
||||
if (isSubscribed.value) removeSubscribe()
|
||||
else handleAddSubscribe()
|
||||
}
|
||||
|
||||
// 计算存在状态的颜色
|
||||
function getExistColor(season: number) {
|
||||
const state = seasonsNotExisted.value[season]
|
||||
if (!state)
|
||||
return 'success'
|
||||
if (!state) return 'success'
|
||||
|
||||
if (state === 1)
|
||||
return 'warning'
|
||||
else if (state === 2)
|
||||
return 'error'
|
||||
else
|
||||
return 'success'
|
||||
if (state === 1) return 'warning'
|
||||
else if (state === 2) return 'error'
|
||||
else return 'success'
|
||||
}
|
||||
|
||||
// 计算存在状态的文本
|
||||
function getExistText(season: number) {
|
||||
const state = seasonsNotExisted.value[season]
|
||||
if (!state)
|
||||
return '已入库'
|
||||
if (!state) return '已入库'
|
||||
|
||||
if (state === 1)
|
||||
return '部分缺失'
|
||||
else if (state === 2)
|
||||
return '缺失'
|
||||
else
|
||||
return '已入库'
|
||||
if (state === 1) return '部分缺失'
|
||||
else if (state === 2) return '缺失'
|
||||
else return '已入库'
|
||||
}
|
||||
|
||||
// 打开详情页
|
||||
function goMediaDetail() {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: `${
|
||||
props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: `douban:${props.media?.douban_id}`
|
||||
}`,
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
function goMediaDetail(isHovering = false) {
|
||||
if (isHovering) {
|
||||
router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: getMediaId(),
|
||||
type: props.media?.type,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 开始搜索
|
||||
@@ -377,13 +346,10 @@ function handleSearch() {
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: `${
|
||||
props.media?.tmdb_id
|
||||
? `tmdb:${props.media?.tmdb_id}`
|
||||
: `douban:${props.media?.douban_id}`
|
||||
}`,
|
||||
keyword: getMediaId(),
|
||||
type: props.media?.type,
|
||||
area: 'title',
|
||||
season: props.media?.season,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -392,46 +358,41 @@ function handleSearch() {
|
||||
onBeforeMount(() => {
|
||||
handleCheckSubscribe()
|
||||
handleCheckExists()
|
||||
querySubscribeRules()
|
||||
})
|
||||
|
||||
// 计算图片地址
|
||||
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
|
||||
// 如果地址中包含douban则使用中转代理
|
||||
if (url.includes('doubanio.com'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}douban/img?imgurl=${encodeURIComponent(url)}`
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
// 拼装季图片地址
|
||||
function getSeasonPoster(posterPath: string) {
|
||||
if (!posterPath)
|
||||
return ''
|
||||
if (!posterPath) return ''
|
||||
return `https://image.tmdb.org/t/p/w500${posterPath}`
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||
function formatAirDate(airDate: string) {
|
||||
if (!airDate)
|
||||
return ''
|
||||
const date = new Date(airDate)
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
// 从yyyy-mm-dd中提取年份
|
||||
function getYear(airDate: string) {
|
||||
if (!airDate)
|
||||
return ''
|
||||
const date = new Date(airDate)
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return date.getFullYear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover v-bind="props">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
@@ -442,6 +403,7 @@ function getYear(airDate: string) {
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -457,79 +419,66 @@ function getYear(airDate: string) {
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !isExists"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.vote_average }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
@click.stop="goMediaDetail"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.year }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.overview }}
|
||||
</p>
|
||||
<div class="flex align-center justify-between">
|
||||
<IconBtn
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
@click.stop="handleSearch"
|
||||
/>
|
||||
<IconBtn
|
||||
icon="mdi-heart"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
@click.stop="handleSubscribe"
|
||||
/>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VImg>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
|
||||
>
|
||||
{{ props.media?.vote_average }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<span class="font-bold">{{ props.media?.year }}</span>
|
||||
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.overview }}
|
||||
</p>
|
||||
<div class="flex align-center justify-between">
|
||||
<IconBtn icon="mdi-magnify" color="white" @click.stop="handleSearch" />
|
||||
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||
</div>
|
||||
</VCardText>
|
||||
<VAvatar
|
||||
size="24"
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
<!-- 订阅季弹窗 -->
|
||||
<VBottomSheet
|
||||
v-model="subscribeSeasonDialog"
|
||||
inset
|
||||
scrollable
|
||||
>
|
||||
<VBottomSheet v-if="subscribeSeasonDialog" v-model="subscribeSeasonDialog" inset scrollable>
|
||||
<VCard class="rounded-t">
|
||||
<DialogCloseBtn @click="subscribeSeasonDialog = false" />
|
||||
<VCardTitle class="pe-10">
|
||||
订阅 - {{ props.media?.title }}
|
||||
</VCardTitle>
|
||||
<VCardTitle class="pe-10"> 订阅 - {{ props.media?.title }} </VCardTitle>
|
||||
<VCardText>
|
||||
<VList
|
||||
v-model:selected="seasonsSelected"
|
||||
lines="three"
|
||||
select-strategy="classic"
|
||||
>
|
||||
<VListItem
|
||||
v-for="(item, i) in seasonInfos" :key="i"
|
||||
:value="item"
|
||||
>
|
||||
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
|
||||
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="90"
|
||||
@@ -546,16 +495,9 @@ function getYear(airDate: string) {
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
第 {{ item.season_number }} 季
|
||||
</VListItemTitle>
|
||||
<VListItemTitle> 第 {{ item.season_number }} 季 </VListItemTitle>
|
||||
<VListItemSubtitle class="mt-1 me-2">
|
||||
<VChip
|
||||
v-if="item.vote_average"
|
||||
color="primary"
|
||||
size="small"
|
||||
class="mb-1"
|
||||
>
|
||||
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
|
||||
<VIcon icon="mdi-star" /> {{ item.vote_average }}
|
||||
</VChip>
|
||||
{{ getYear(item.air_date || '') }} • {{ item.episode_count }} 集
|
||||
@@ -564,12 +506,7 @@ function getYear(airDate: string) {
|
||||
《{{ media?.title }}》第 {{ item.season_number }} 季于 {{ formatAirDate(item.air_date || '') }} 首播。
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
<VChip
|
||||
v-if="seasonsNotExisted"
|
||||
class="mt-2"
|
||||
size="small"
|
||||
:color="getExistColor(item.season_number || 0)"
|
||||
>
|
||||
<VChip v-if="seasonsNotExisted" class="mt-2" size="small" :color="getExistColor(item.season_number || 0)">
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
@@ -582,23 +519,25 @@ function getYear(airDate: string) {
|
||||
</VList>
|
||||
</VCardText>
|
||||
<div class="my-2 text-center">
|
||||
<VBtn
|
||||
:disabled="seasonsSelected.length === 0"
|
||||
width="30%"
|
||||
@click="subscribeSeasons"
|
||||
>
|
||||
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
{{ seasonsSelected.length === 0 ? '请选择订阅季' : '提交订阅' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditForm
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@save="subscribeEditDialog = false"
|
||||
@remove="() => { subscribeEditDialog = false; handleCheckSubscribe(); }"
|
||||
@remove="
|
||||
() => {
|
||||
subscribeEditDialog = false
|
||||
handleCheckSubscribe()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
112
src/components/cards/MessageCard.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
message: Object as PropType<Message>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
}
|
||||
|
||||
// 链接打开新窗口
|
||||
function openLink() {
|
||||
if (props.message?.link)
|
||||
window.open(props.message.link, '_blank')
|
||||
}
|
||||
|
||||
// 将note转换为json
|
||||
function noteToJson() {
|
||||
if (props.message?.note) {
|
||||
try {
|
||||
return JSON.parse(props.message.note)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 将\n转换为html属性的换行符
|
||||
function replaceNewLine(value: string) {
|
||||
if (!value)
|
||||
return ''
|
||||
return value.replace(/\n/g, '<br/>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
variant="tonal"
|
||||
@click="openLink"
|
||||
>
|
||||
<div
|
||||
v-if="props.message?.image"
|
||||
class="relative text-center card-cover-blurred"
|
||||
>
|
||||
<VImg
|
||||
:src="props.message?.image"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
:class="{ shadow: isImageLoaded }"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</div>
|
||||
<VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
|
||||
{{ props.message?.title }}
|
||||
</VCardTitle>
|
||||
<VAlert
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
variant="tonal"
|
||||
type="success"
|
||||
>
|
||||
<template #prepend />
|
||||
{{ props.message?.text }}
|
||||
</VAlert>
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
v-html="replaceNewLine(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="props.message?.note">
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(value, key) in noteToJson()"
|
||||
:key="key"
|
||||
two-line
|
||||
>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
|
||||
{{ value.description }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<div class="text-end">
|
||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
||||
<span class="text-sm italic me-2">{{ formatDateDifference(props.message?.reg_time || props.message?.date || '') }}</span>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import personIcon from '@images/misc/person-icon.png'
|
||||
import type { TmdbPerson } from '@/api/types'
|
||||
import type { Person } from '@/api/types'
|
||||
import router from '@/router'
|
||||
|
||||
const personProps = defineProps({
|
||||
person: Object as PropType<TmdbPerson>,
|
||||
person: Object as PropType<Person>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
@@ -17,26 +17,54 @@ const isImageLoaded = ref(false)
|
||||
|
||||
// 人物图片地址
|
||||
function getPersonImage() {
|
||||
if (!personInfo.value?.profile_path)
|
||||
if (personProps.person?.source === 'themoviedb') {
|
||||
if (!personInfo.value?.profile_path) return personIcon
|
||||
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
|
||||
} else if (personProps.person?.source === 'douban') {
|
||||
if (!personInfo.value?.avatar) return personIcon
|
||||
if (typeof personInfo.value?.avatar === 'object') {
|
||||
return personInfo.value?.avatar?.normal
|
||||
} else {
|
||||
return personInfo.value?.avatar
|
||||
}
|
||||
} else if (personProps.person?.source === 'bangumi') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
return personInfo.value?.images?.medium
|
||||
} else {
|
||||
return personIcon
|
||||
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
|
||||
}
|
||||
}
|
||||
|
||||
// 人物姓名
|
||||
function getPersonName() {
|
||||
return personInfo.value?.name
|
||||
}
|
||||
|
||||
// 人物角色
|
||||
function getPersonCharacter() {
|
||||
if (personProps.person?.source === 'bangumi') {
|
||||
if (!personInfo.value?.career) return ''
|
||||
return personInfo.value?.career.join('、')
|
||||
} else {
|
||||
return personInfo.value?.character
|
||||
}
|
||||
}
|
||||
|
||||
// 人物详情
|
||||
function goPersonDetail() {
|
||||
if (!personInfo.value?.id)
|
||||
return
|
||||
if (!personInfo.value?.id) return
|
||||
router.push({
|
||||
path: '/person',
|
||||
query: {
|
||||
personid: personInfo.value?.id,
|
||||
source: personInfo.value?.source,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover v-bind="personProps">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
@@ -49,9 +77,9 @@ function goPersonDetail() {
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<div
|
||||
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
|
||||
class="person-card relative transform-gpu cursor-pointer rounded shadow transition duration-150 ease-in-out scale-100 ring-gray-700"
|
||||
>
|
||||
<div style="padding-bottom: 150%;">
|
||||
<div style="padding-block-end: 150%">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
||||
<VAvatar
|
||||
@@ -60,19 +88,17 @@ function goPersonDetail() {
|
||||
'ring-1 ring-gray-700': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
v-img
|
||||
:src="getPersonImage()"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
/>
|
||||
<VImg :src="getPersonImage()" cover @load="isImageLoaded = true" />
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="w-full truncate text-center font-bold">
|
||||
{{ personInfo?.name }}
|
||||
{{ getPersonName() }}
|
||||
</div>
|
||||
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
|
||||
{{ personInfo?.character }}
|
||||
<div
|
||||
class="overflow-hidden whitespace-normal text-center text-sm"
|
||||
style="display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2"
|
||||
>
|
||||
{{ getPersonCharacter() }}
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
|
||||
</div>
|
||||
@@ -1,15 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import VersionHistory from '../misc/VersionHistory.vue'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: Object as PropType<Plugin>,
|
||||
width: String,
|
||||
height: String,
|
||||
count: Number,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -36,6 +40,15 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 更新日志弹窗
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 计算插件标签
|
||||
const pluginLabels = computed(() => {
|
||||
if (!props.plugin?.plugin_label) return []
|
||||
return props.plugin.plugin_label.split(',')
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -51,15 +64,12 @@ async function installPlugin() {
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
`plugin/install/${props.plugin?.id}`,
|
||||
{
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
},
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
@@ -69,23 +79,20 @@ async function installPlugin() {
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('install')
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value)
|
||||
return noImage
|
||||
if (imageLoadError.value) return noImage
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
@@ -96,60 +103,62 @@ function visitPluginPage() {
|
||||
let repoUrl = props.plugin?.repo_url
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/'))
|
||||
repoUrl += '/'
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
if (repoUrl.split('/').length < 6)
|
||||
repoUrl = `${repoUrl}main/`
|
||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||
|
||||
try {
|
||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||
repoUrl = `https://github.com/${user}/${repo}`
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
window.open(repoUrl, '_blank')
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
releaseDialog.value = true
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: '查看详情',
|
||||
title: '项目主页',
|
||||
value: 1,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
prependIcon: 'mdi-github',
|
||||
click: visitPluginPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '更新说明',
|
||||
value: 2,
|
||||
show: !isNullOrEmptyObject(props.plugin?.history || {}),
|
||||
props: {
|
||||
prependIcon: 'mdi-update',
|
||||
click: showUpdateHistory,
|
||||
},
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="installPlugin"
|
||||
>
|
||||
<div
|
||||
class="relative pa-4 text-center card-cover-blurred"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<VCard :width="props.width" :height="props.height" @click="installPlugin">
|
||||
<div class="relative pa-4 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
|
||||
<div class="me-n3 absolute top-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" class="text-white" />
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<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"
|
||||
@@ -163,9 +172,7 @@ const dropdownItems = ref([
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<VAvatar
|
||||
size="8rem"
|
||||
>
|
||||
<VAvatar size="8rem">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
@@ -177,39 +184,39 @@ const dropdownItems = ref([
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<VCardTitle>{{ props.plugin?.plugin_name }}</VCardTitle>
|
||||
|
||||
<VCardText>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
<VCardTitle>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
<VCardText class="pb-2">
|
||||
<div>{{ props.plugin?.plugin_desc }}</div>
|
||||
<div>
|
||||
<VChip v-for="label in pluginLabels" variant="tonal" size="small" class="me-1 my-1" color="info" label>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText>
|
||||
作者:<a
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a><br>
|
||||
版本:{{ props.plugin?.plugin_version }}
|
||||
<VCardText class="flex items-center justify-start pb-2">
|
||||
<span>
|
||||
<VIcon icon="mdi-account" class="me-1" />
|
||||
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="props.count" class="ms-3">
|
||||
<VIcon icon="mdi-download" />
|
||||
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 安装插件进度框 -->
|
||||
<VDialog
|
||||
v-model="progressDialog"
|
||||
:scrim="false"
|
||||
width="25rem"
|
||||
>
|
||||
<VCard
|
||||
color="primary"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
<VProgressLinear
|
||||
indeterminate
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
/>
|
||||
</VCardText>
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="releaseDialog = false" />
|
||||
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle>
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -221,7 +228,7 @@ const dropdownItems = ref([
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
background: rgba(29, 39, 59, 48%);
|
||||
content: "";
|
||||
content: '';
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { VIcon } from 'vuetify/lib/components/index.mjs'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import FormRender from '@/components/render/FormRender.vue'
|
||||
import PageRender from '@/components/render/PageRender.vue'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import { isNullOrEmptyObject } from '@core/utils'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import store from '@/store'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: Object as PropType<Plugin>,
|
||||
count: Number, // 下载次数
|
||||
action: Boolean, // 动作标识
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
const emit = defineEmits(['remove', 'save', 'actionDone'])
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
@@ -54,7 +63,7 @@ const pluginInfoDialog = ref(false)
|
||||
const progressText = ref('正在更新插件...')
|
||||
|
||||
// 插件数据页面配置项
|
||||
let pluginPageItems = reactive([])
|
||||
let pluginPageItems = ref([])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
@@ -62,6 +71,20 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 更新日志弹窗
|
||||
const releaseDialog = ref(false)
|
||||
|
||||
// 监听动作标识,如为true则打开详情
|
||||
watch(
|
||||
() => props.action,
|
||||
(newAction, oldAction) => {
|
||||
if (newAction && !oldAction) {
|
||||
openPluginDetail()
|
||||
emit('actionDone')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -70,6 +93,16 @@ async function imageLoaded() {
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
// 检查当前版本是否有更新日志
|
||||
if (isNullOrEmptyObject(props.plugin?.history)) {
|
||||
updatePlugin()
|
||||
} else {
|
||||
releaseDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API卸载插件
|
||||
async function uninstallPlugin() {
|
||||
const isConfirmed = await createConfirm({
|
||||
@@ -85,11 +118,10 @@ async function uninstallPlugin() {
|
||||
},
|
||||
})
|
||||
|
||||
if (!isConfirmed)
|
||||
return
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在卸载 ${props.plugin?.plugin_name} ...`
|
||||
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
@@ -100,12 +132,10 @@ async function uninstallPlugin() {
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -116,11 +146,9 @@ async function loadPluginForm() {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
||||
if (result) {
|
||||
pluginFormItems = result.conf
|
||||
if (result.model)
|
||||
pluginConfigForm.value = result.model
|
||||
if (result.model) pluginConfigForm.value = result.model
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -129,10 +157,8 @@ async function loadPluginForm() {
|
||||
async function loadPluginPage() {
|
||||
try {
|
||||
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
|
||||
if (result)
|
||||
pluginPageItems = result
|
||||
}
|
||||
catch (error) {
|
||||
if (result) pluginPageItems.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -141,29 +167,30 @@ async function loadPluginPage() {
|
||||
async function loadPluginConf() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
|
||||
if (!isNullOrEmptyObject(result))
|
||||
pluginConfigForm.value = result
|
||||
}
|
||||
catch (error) {
|
||||
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API保存配置数据
|
||||
async function savePluginConf() {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
||||
if (result.success) {
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
progressDialog.value = false
|
||||
pluginConfigDialog.value = false
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
progressDialog.value = false
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -189,11 +216,10 @@ async function showPluginConfig() {
|
||||
|
||||
// 计算图标路径
|
||||
const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value)
|
||||
return noImage
|
||||
if (imageLoadError.value) return noImage
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
@@ -213,8 +239,7 @@ async function resetPlugin() {
|
||||
},
|
||||
})
|
||||
|
||||
if (!isConfirmed)
|
||||
return
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||
@@ -222,12 +247,10 @@ async function resetPlugin() {
|
||||
$toast.success(`插件 ${props.plugin?.plugin_name} 数据已重置`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 重置失败:${result.message}}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -235,19 +258,17 @@ async function resetPlugin() {
|
||||
// 更新插件
|
||||
async function updatePlugin() {
|
||||
try {
|
||||
releaseDialog.value = false
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在更新 ${props.plugin?.plugin_name} ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
`plugin/install/${props.plugin?.id}`,
|
||||
{
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: true,
|
||||
},
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
@@ -257,12 +278,10 @@ async function updatePlugin() {
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$toast.error(`插件 ${props.plugin?.plugin_name} 更新失败:${result.message}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -275,10 +294,18 @@ function visitAuthorPage() {
|
||||
// 查看日志URL
|
||||
function openLoggerWindow() {
|
||||
const token = store.state.auth.token
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
const url = `${
|
||||
import.meta.env.VITE_API_BASE_URL
|
||||
}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 打开插件详情
|
||||
function openPluginDetail() {
|
||||
if (props.plugin?.has_page) showPluginInfo()
|
||||
else showPluginConfig()
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
@@ -304,9 +331,9 @@ const dropdownItems = ref([
|
||||
value: 3,
|
||||
show: props.plugin?.has_update,
|
||||
props: {
|
||||
prependIcon: 'mdi-cancel',
|
||||
prependIcon: 'mdi-arrow-up-circle-outline',
|
||||
color: 'success',
|
||||
click: updatePlugin,
|
||||
click: showUpdateHistory,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -352,46 +379,26 @@ const dropdownItems = ref([
|
||||
])
|
||||
|
||||
// 监听插件状态变化
|
||||
watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
|
||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||
if (updateItemIndex !== -1)
|
||||
dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||
})
|
||||
watch(
|
||||
() => props.plugin?.has_update,
|
||||
(newHasUpdate, oldHasUpdate) => {
|
||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 插件卡片 -->
|
||||
<VCard
|
||||
v-if="isVisible"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="() => {
|
||||
if (props.plugin?.has_page)
|
||||
showPluginInfo()
|
||||
else
|
||||
showPluginConfig()
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative pa-4 text-center card-cover-blurred"
|
||||
:style="{ background: `${backgroundColor}` }"
|
||||
>
|
||||
<div
|
||||
v-if="props.plugin?.has_update"
|
||||
class="me-n3 absolute top-0 left-1"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-new-box"
|
||||
class="text-white"
|
||||
/>
|
||||
<VCard v-if="isVisible" :width="props.width" :height="props.height" @click="openPluginDetail">
|
||||
<div class="relative pa-4 text-center card-cover-blurred" :style="{ background: `${backgroundColor}` }">
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 left-1">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
<div class="me-n3 absolute top-0 right-3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" class="text-white" />
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
@@ -410,9 +417,7 @@ watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<VAvatar
|
||||
size="8rem"
|
||||
>
|
||||
<VAvatar size="8rem">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
@@ -424,100 +429,68 @@ watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<span v-if="props.count" class="absolute bottom-1 right-2 flex items-center">
|
||||
<VIcon icon="mdi-fire" />
|
||||
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
|
||||
</span>
|
||||
<VCardItem class="py-2">
|
||||
<VCardTitle class="flex items-center flex-row">
|
||||
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
|
||||
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">{{ props.plugin?.plugin_version }}</span>
|
||||
{{ props.plugin?.plugin_name
|
||||
}}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 插件配置页面 -->
|
||||
<VDialog
|
||||
v-model="pluginConfigDialog"
|
||||
scrollable
|
||||
max-width="60rem"
|
||||
>
|
||||
<VCard
|
||||
:title="`${props.plugin?.plugin_name} - 配置`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<DialogCloseBtn @click="pluginConfigDialog = false" />
|
||||
<VDialog v-model="pluginConfigDialog" scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
|
||||
<DialogCloseBtn v-model="pluginConfigDialog" />
|
||||
<VCardText>
|
||||
<FormRender
|
||||
v-for="(item, index) in pluginFormItems"
|
||||
:key="index"
|
||||
:config="item"
|
||||
:form="pluginConfigForm"
|
||||
/>
|
||||
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :form="pluginConfigForm" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo">
|
||||
查看数据
|
||||
</VBtn>
|
||||
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo"> 查看数据 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="savePluginConf"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="savePluginConf"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 插件数据页面 -->
|
||||
<VDialog
|
||||
v-model="pluginInfoDialog"
|
||||
scrollable
|
||||
max-width="80rem"
|
||||
>
|
||||
<VCard
|
||||
:title="`${props.plugin?.plugin_name}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<DialogCloseBtn @click="pluginInfoDialog = false" />
|
||||
<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>
|
||||
<PageRender
|
||||
v-for="(item, index) in pluginPageItems"
|
||||
:key="index"
|
||||
:config="item"
|
||||
/>
|
||||
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn
|
||||
@click="showPluginConfig"
|
||||
>
|
||||
配置
|
||||
</VBtn>
|
||||
<VBtn @click="showPluginConfig"> 配置 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="pluginInfoDialog = false"
|
||||
>
|
||||
关闭
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="pluginInfoDialog = false"> 关闭 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 更新插件进度框 -->
|
||||
<VDialog
|
||||
v-model="progressDialog"
|
||||
:scrim="false"
|
||||
width="25rem"
|
||||
>
|
||||
<VCard
|
||||
color="primary"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
<VProgressLinear
|
||||
indeterminate
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
/>
|
||||
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
|
||||
<!-- 更新日志 -->
|
||||
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="releaseDialog = false" />
|
||||
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle>
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
<VCardText>
|
||||
<VBtn @click="updatePlugin" block>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
更新到最新版本
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
@@ -530,7 +503,7 @@ watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
background: rgba(29, 39, 59, 48%);
|
||||
content: "";
|
||||
content: '';
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,12 +31,12 @@ const getImgUrl = computed(() => {
|
||||
if (imageLoadError.value)
|
||||
return noImage
|
||||
const image = props.media?.image || ''
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
function goPlay() {
|
||||
if (props.media?.link)
|
||||
function goPlay(isHovering = false) {
|
||||
if (props.media?.link && isHovering)
|
||||
window.open(props.media?.link, '_blank')
|
||||
}
|
||||
</script>
|
||||
@@ -53,7 +53,7 @@ function goPlay() {
|
||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goPlay"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -69,8 +69,9 @@ function goPlay() {
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
</VImg>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
@@ -89,7 +90,6 @@ function goPlay() {
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VImg>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import SiteAddEditForm from '../form/SiteAddEditForm.vue'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
|
||||
import SiteTorrentTable from '../table/SiteTorrentTable.vue'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { Site, TorrentInfo } from '@/api/types'
|
||||
import ExistIcon from '@core/components/ExistIcon.vue'
|
||||
import type { Site, SiteStatistic } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const cardProps = defineProps({
|
||||
@@ -51,31 +56,6 @@ const progressDialog = ref(false)
|
||||
// 进度文本
|
||||
const progressText = ref('请稍候 ...')
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
{ title: '标题', key: 'title', sortable: false },
|
||||
{ title: '时间', key: 'pubdate', sortable: true },
|
||||
{ title: '大小', key: 'size', sortable: true },
|
||||
{ title: '做种', key: 'seeders', sortable: true },
|
||||
{ title: '下载', key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 用户名密码表单
|
||||
const userPwForm = ref({
|
||||
username: '',
|
||||
@@ -83,22 +63,14 @@ const userPwForm = ref({
|
||||
code: '',
|
||||
})
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
// 站点使用统计
|
||||
const siteStats = ref<SiteStatistic>({})
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -110,15 +82,23 @@ async function testSite() {
|
||||
testButtonDisable.value = true
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)
|
||||
if (result.success)
|
||||
$toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
|
||||
else
|
||||
$toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
|
||||
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
|
||||
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
|
||||
|
||||
testButtonText.value = '测试'
|
||||
testButtonDisable.value = false
|
||||
|
||||
getSiteStats()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
catch (error) {
|
||||
}
|
||||
|
||||
// 查询站点使用统计
|
||||
async function getSiteStats() {
|
||||
try {
|
||||
siteStats.value = await api.get(`site/statistic/${cardProps.site?.domain}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -131,14 +111,12 @@ async function handleSiteUpdate() {
|
||||
// 打开资源浏览弹窗
|
||||
async function handleResourceBrowse() {
|
||||
resourceDialog.value = true
|
||||
getResourceList()
|
||||
}
|
||||
|
||||
// 调用API,更新站点Cookie UA
|
||||
async function updateSiteCookie() {
|
||||
try {
|
||||
if (!userPwForm.value.username || !userPwForm.value.password)
|
||||
return
|
||||
if (!userPwForm.value.username || !userPwForm.value.password) return
|
||||
|
||||
// 更新按钮状态
|
||||
siteCookieDialog.value = false
|
||||
@@ -147,50 +125,20 @@ async function updateSiteCookie() {
|
||||
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,
|
||||
},
|
||||
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}`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0)
|
||||
return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1)
|
||||
return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1)
|
||||
return 'text-white bg-sky-500'
|
||||
else
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${cardProps.site?.id}`)
|
||||
resourceLoading.value = false
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -200,9 +148,29 @@ function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
}
|
||||
|
||||
// 根据站点状态显示不同的状态图标
|
||||
const statColor = computed(() => {
|
||||
if (isNullOrEmptyObject(siteStats.value)) {
|
||||
return 'secondary'
|
||||
}
|
||||
if (siteStats.value?.lst_state == 1) {
|
||||
return 'error'
|
||||
} else if (siteStats.value?.lst_state == 0) {
|
||||
if (!siteStats.value?.seconds) return 'secondary'
|
||||
if (siteStats.value?.seconds >= 5) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
})
|
||||
|
||||
// 监听resourceDialog,如果为false则重新查询站点使用统计
|
||||
watch(resourceDialog, value => {
|
||||
if (!value) getSiteStats()
|
||||
})
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
getSiteStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -210,108 +178,63 @@ onMounted(() => {
|
||||
<VCard
|
||||
:height="cardProps.height"
|
||||
:width="cardProps.width"
|
||||
:flat="!cardProps.site?.is_active"
|
||||
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
|
||||
class="overflow-hidden"
|
||||
@click="siteEditDialog = true"
|
||||
>
|
||||
<template #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" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
|
||||
<VCardItem>
|
||||
<VCardTitle class="font-bold">
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ cardProps.site?.url }}
|
||||
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<ExistIcon v-if="cardProps.site?.is_active" />
|
||||
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
|
||||
|
||||
<VCardText class="py-2">
|
||||
<VTooltip
|
||||
v-if="cardProps.site?.render === 1"
|
||||
text="浏览器仿真"
|
||||
>
|
||||
<VTooltip v-if="cardProps.site?.render === 1" text="浏览器仿真">
|
||||
<template #activator="{ props }">
|
||||
<VIcon
|
||||
color="primary"
|
||||
class="me-2"
|
||||
v-bind="props"
|
||||
icon="mdi-apple-safari"
|
||||
/>
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-apple-safari" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
|
||||
<VTooltip
|
||||
v-if="cardProps.site?.proxy === 1"
|
||||
text="代理"
|
||||
>
|
||||
<VTooltip v-if="cardProps.site?.proxy === 1" text="代理">
|
||||
<template #activator="{ props }">
|
||||
<VIcon
|
||||
color="primary"
|
||||
class="me-2"
|
||||
v-bind="props"
|
||||
icon="mdi-network-outline"
|
||||
/>
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-network-outline" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
|
||||
<VTooltip
|
||||
v-if="cardProps.site?.limit_interval"
|
||||
text="流控"
|
||||
>
|
||||
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
|
||||
<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" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
|
||||
<VTooltip
|
||||
v-if="cardProps.site?.filter"
|
||||
text="过滤"
|
||||
>
|
||||
<VTooltip v-if="cardProps.site?.filter" text="过滤">
|
||||
<template #activator="{ props }">
|
||||
<VIcon
|
||||
color="primary"
|
||||
class="me-2"
|
||||
v-bind="props"
|
||||
icon="mdi-filter-cog-outline"
|
||||
/>
|
||||
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-filter-cog-outline" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
</VCardText>
|
||||
|
||||
<VDivider
|
||||
class="opacity-75"
|
||||
style="border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity));"
|
||||
/>
|
||||
<VDivider class="opacity-75" style="border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity))" />
|
||||
|
||||
<VCardActions>
|
||||
<VBtn
|
||||
v-if="!cardProps.site?.public"
|
||||
:disabled="updateButtonDisable"
|
||||
@click.stop="handleSiteUpdate"
|
||||
>
|
||||
<VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
更新
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:disabled="testButtonDisable"
|
||||
@click.stop="testSite"
|
||||
>
|
||||
<VBtn :disabled="testButtonDisable" @click.stop="testSite">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-link" />
|
||||
</template>
|
||||
@@ -326,49 +249,29 @@ onMounted(() => {
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- 更新站点Cookie & UA弹窗 -->
|
||||
<VDialog
|
||||
v-model="siteCookieDialog"
|
||||
max-width="50rem"
|
||||
>
|
||||
<VDialog v-model="siteCookieDialog" max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Dialog Content -->
|
||||
<VCard title="更新站点Cookie & UA">
|
||||
<DialogCloseBtn @click="siteCookieDialog = false" />
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VTextField
|
||||
v-model="userPwForm.username"
|
||||
label="用户名"
|
||||
:rules="[requiredValidator]"
|
||||
/>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<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'
|
||||
"
|
||||
: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 cols="12" md="4">
|
||||
<VTextField v-model="userPwForm.code" label="两步验证" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
@@ -376,169 +279,42 @@ onMounted(() => {
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="updateSiteCookie"
|
||||
>
|
||||
开始更新
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="updateSiteCookie"> 开始更新 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<SiteAddEditForm
|
||||
<SiteAddEditDialog
|
||||
v-if="siteEditDialog"
|
||||
v-model="siteEditDialog"
|
||||
:siteid="cardProps.site?.id"
|
||||
@save="siteEditDialog = false; emit('update')"
|
||||
@save="
|
||||
() => {
|
||||
siteEditDialog = false
|
||||
emit('update')
|
||||
}
|
||||
"
|
||||
@remove="emit('remove')"
|
||||
@close="siteEditDialog = false"
|
||||
/>
|
||||
<!-- 站点资源弹窗 -->
|
||||
<VDialog
|
||||
v-if="resourceDialog"
|
||||
v-model="resourceDialog"
|
||||
max-width="80rem"
|
||||
scrollable
|
||||
z-index="1010"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<!-- Dialog Content -->
|
||||
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
|
||||
<DialogCloseBtn @click="resourceDialog = false" />
|
||||
<VCardText class="pt-2">
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
{{ item.raw.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
{{ item.raw.description }}
|
||||
</div>
|
||||
<VChip
|
||||
v-if="item.raw?.hit_and_run"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1 text-white bg-black"
|
||||
>
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.raw?.freedate_diff"
|
||||
variant="elevated"
|
||||
color="secondary"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.raw?.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.raw?.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.raw?.downloadvolumefactor !== 1 || item.raw?.uploadvolumefactor !== 1"
|
||||
:class="
|
||||
getVolumeFactorClass(item.raw?.downloadvolumefactor, item.raw?.uploadvolumefactor)
|
||||
"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.raw?.volume_factor }}
|
||||
</VChip>
|
||||
</template>
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.raw.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
{{ item.raw.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.raw.size) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.raw.seeders }}</div>
|
||||
</template>
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.raw.peers }}</div>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
@click="openTorrentDetail(item.raw.page_url)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="item.raw.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile(item.raw.enclosure)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
<template #no-data>
|
||||
没有数据
|
||||
</template>
|
||||
</VDataTable>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<VDialog
|
||||
v-model="progressDialog"
|
||||
:scrim="false"
|
||||
width="25rem"
|
||||
>
|
||||
<VCard
|
||||
color="primary"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
<VProgressLinear
|
||||
indeterminate
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
/>
|
||||
<SiteTorrentTable :site="cardProps.site?.id" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang='ts' setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import SubscribeEditForm from '../form/SubscribeEditForm.vue'
|
||||
import { calculateTimeDifference } from '@/@core/utils'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
@@ -26,11 +26,9 @@ const subscribeEditDialog = ref(false)
|
||||
|
||||
// 上一次更新时间
|
||||
const lastUpdateText = ref(
|
||||
`${
|
||||
props.media?.last_update
|
||||
? `${calculateTimeDifference(props.media?.last_update || '')}前`
|
||||
: ''
|
||||
}`,
|
||||
props.media && props.media.last_update
|
||||
? formatDateDifference(props.media.last_update)
|
||||
: '',
|
||||
)
|
||||
|
||||
// 图片加载完成响应
|
||||
@@ -284,7 +282,8 @@ const dropdownItems = ref([
|
||||
/>
|
||||
</VCard>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditForm
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="props.media?.id"
|
||||
@remove="() => { emit('remove');subscribeEditDialog = false; }"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { Context } from '@/api/types'
|
||||
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -36,6 +36,9 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<String[]>([])
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
try {
|
||||
@@ -76,7 +79,7 @@ async function handleAddDownload(_site: any = undefined,
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_media: any, _torrent: any) {
|
||||
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/', {
|
||||
@@ -87,6 +90,7 @@ async function addDownload(_media: any, _torrent: any) {
|
||||
if (result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
downloaded.value.push(_torrent?.enclosure || '')
|
||||
}
|
||||
else {
|
||||
// 添加下载失败
|
||||
@@ -131,6 +135,7 @@ onMounted(() => {
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<template
|
||||
@@ -288,7 +293,7 @@ onMounted(() => {
|
||||
<VExpandTransition>
|
||||
<div v-show="showMoreTorrents">
|
||||
<VDivider />
|
||||
<VChipGroup class="p-3">
|
||||
<VChipGroup class="p-3" column>
|
||||
<VChip
|
||||
v-for="(item, index) in props.more"
|
||||
:key="index"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useConfirm } from 'vuetify-use-dialog'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { Context } from '@/api/types'
|
||||
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -33,6 +33,9 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<String[]>([])
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
try {
|
||||
@@ -73,7 +76,7 @@ async function handleAddDownload(_site: any = undefined,
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_media: any, _torrent: any) {
|
||||
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/', {
|
||||
@@ -84,6 +87,7 @@ async function addDownload(_media: any, _torrent: any) {
|
||||
if (result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
downloaded.value.push(_torrent?.enclosure || '')
|
||||
}
|
||||
else {
|
||||
// 添加下载失败
|
||||
@@ -125,7 +129,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VListItem @click="handleAddDownload">
|
||||
<VListItem
|
||||
@click="handleAddDownload"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
>
|
||||
<template
|
||||
v-if="!showMoreTorrents"
|
||||
#prepend
|
||||
|
||||
45
src/components/dialog/ImportCodeDialog.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 代码
|
||||
const codeString = ref('')
|
||||
|
||||
// 导入
|
||||
function handleImport() {
|
||||
emit('update:modelValue', codeString.value)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
width="40rem"
|
||||
scrollable
|
||||
max-height="85vh"
|
||||
>
|
||||
<VCard
|
||||
:title="props.title"
|
||||
class="rounded-t"
|
||||
>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea v-model="codeString" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="handleImport"
|
||||
>
|
||||
导入
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
17
src/components/dialog/ProgressDialog.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
value: Number,
|
||||
text: String,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<!-- 手动整理进度框 -->
|
||||
<VDialog :scrim="false" width="25rem">
|
||||
<VCard color="primary">
|
||||
<VCardText class="text-center">
|
||||
{{ props.text }}
|
||||
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -1,9 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import TmdbSelectorCard from '../cards/TmdbSelectorCard.vue'
|
||||
import TmdbSelector from '../misc/TmdbSelector.vue'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -55,7 +60,6 @@ const transferForm = reactive({
|
||||
episode_part: '',
|
||||
episode_offset: null,
|
||||
min_filesize: 0,
|
||||
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -72,7 +76,7 @@ function startLoadingProgress() {
|
||||
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)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
@@ -89,8 +93,7 @@ function stopLoadingProgress() {
|
||||
// 整理文件
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
async function transfer() {
|
||||
if (!props.logids && !props.path)
|
||||
return
|
||||
if (!props.logids && !props.path) return
|
||||
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
@@ -100,32 +103,33 @@ async function transfer() {
|
||||
if (props.path) {
|
||||
// 文件整理
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('transfer/manual', {}, {
|
||||
params: transferForm,
|
||||
})
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
'transfer/manual',
|
||||
{},
|
||||
{
|
||||
params: transferForm,
|
||||
},
|
||||
)
|
||||
// 显示结果
|
||||
if (result.success)
|
||||
$toast.success(`${props.path} 整理完成!`)
|
||||
|
||||
else
|
||||
$toast.error(`${props.path} 整理失败:${result.message}!`)
|
||||
}
|
||||
catch (e) {
|
||||
if (result.success) $toast.success(`${props.path} 整理完成!`)
|
||||
else $toast.error(`${props.path} 整理失败:${result.message}!`)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
else if (props.logids) {
|
||||
} else if (props.logids) {
|
||||
// 日志整理
|
||||
for (const logid of props.logids) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -141,10 +145,7 @@ async function transfer() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
scrollable
|
||||
max-width="60rem"
|
||||
>
|
||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.path ? `整理 - ${props.path}` : `整理 - 共 ${props.logids?.length} 条记录`}`"
|
||||
class="rounded-t"
|
||||
@@ -153,20 +154,15 @@ async function transfer() {
|
||||
<VCardText class="pt-2">
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="8"
|
||||
>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField
|
||||
v-model="transferForm.target"
|
||||
label="目的路径"
|
||||
placeholder="留空自动"
|
||||
hint="留空将自动整理到媒体库目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="transferForm.transfer_type"
|
||||
label="整理方式"
|
||||
@@ -183,20 +179,18 @@ async function transfer() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="transferForm.type_name"
|
||||
label="类型"
|
||||
:items="[{ title: '自动', value: '' }, { title: '电影', value: '电影' }, { title: '电视剧', value: '电视剧' }]"
|
||||
:items="[
|
||||
{ title: '自动', value: '' },
|
||||
{ title: '电影', value: '电影' },
|
||||
{ title: '电视剧', value: '电视剧' },
|
||||
]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="transferForm.tmdbid"
|
||||
:disabled="transferForm.type_name === ''"
|
||||
@@ -204,13 +198,11 @@ async function transfer() {
|
||||
placeholder="留空自动识别"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
hint="点击图标按名称搜索,留空将自动重新识别"
|
||||
@click:append-inner="tmdbSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-show="transferForm.type_name === '电视剧'"
|
||||
v-model.number="transferForm.season"
|
||||
@@ -225,6 +217,7 @@ async function transfer() {
|
||||
v-model="transferForm.episode_format"
|
||||
label="集数定位"
|
||||
placeholder="使用{ep}定位集数"
|
||||
hint="使用{ep}定位文件名中的集数部分,其余相同部分直接填写,不同部分使用{a}进行忽略,例如:{a}葬送的芙莉莲_Sousou no Frieren 第{ep}话{b}"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -232,6 +225,7 @@ async function transfer() {
|
||||
v-model="transferForm.episode_detail"
|
||||
label="指定集数"
|
||||
placeholder="起始集,终止集,如1或1,2"
|
||||
hint="直接指定集数或者范围,格式:起始集,终止集,如1或1,2"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -239,6 +233,7 @@ async function transfer() {
|
||||
v-model="transferForm.episode_part"
|
||||
label="指定Part"
|
||||
placeholder="如part1"
|
||||
hint="指定集数的Part,如part1"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -246,6 +241,7 @@ async function transfer() {
|
||||
v-model.number="transferForm.episode_offset"
|
||||
label="集数偏移"
|
||||
placeholder="如-10"
|
||||
hint="对集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -254,54 +250,23 @@ async function transfer() {
|
||||
label="最小文件大小(MB)"
|
||||
:rules="[numberValidator]"
|
||||
placeholder="0"
|
||||
hint="最小文件大小,小于此大小的文件将被忽略不进行整理"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn depressed @click="emit('close')">
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn depressed @click="emit('close')"> 取消 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="transfer"
|
||||
>
|
||||
开始整理
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="transfer"> 开始整理 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- 手动整理进度框 -->
|
||||
<VDialog
|
||||
v-model="progressDialog"
|
||||
:scrim="false"
|
||||
width="25rem"
|
||||
>
|
||||
<VCard
|
||||
color="primary"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
<VProgressLinear
|
||||
v-if="progressValue"
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
:model-value="progressValue"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||
<!-- TMDB ID搜索框 -->
|
||||
<VDialog
|
||||
v-model="tmdbSelectorDialog"
|
||||
width="40rem"
|
||||
scrollable
|
||||
>
|
||||
<TmdbSelectorCard
|
||||
v-model="transferForm.tmdbid"
|
||||
@close="tmdbSelectorDialog = false"
|
||||
/>
|
||||
<VDialog v-model="tmdbSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<TmdbSelector v-model="transferForm.tmdbid" @close="tmdbSelectorDialog = false" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -4,6 +4,10 @@ import type { Site } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { numberValidator, requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -48,8 +52,7 @@ const priorityItems = ref(
|
||||
|
||||
// 监控输入参数
|
||||
watchEffect(async () => {
|
||||
if (props.siteid)
|
||||
fetchSiteInfo()
|
||||
if (props.siteid) fetchSiteInfo()
|
||||
})
|
||||
|
||||
// 查询站点信息
|
||||
@@ -58,27 +61,24 @@ async function fetchSiteInfo() {
|
||||
siteForm.value = await api.get(`site/${props.siteid}`)
|
||||
siteForm.value.proxy = siteForm.value.proxy === 1
|
||||
siteForm.value.render = siteForm.value.render === 1
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API 新增站点
|
||||
async function addSite() {
|
||||
if (!siteForm.value?.url)
|
||||
return
|
||||
if (!siteForm.value?.url) return
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('site/', siteForm.value)
|
||||
if (result.success) {
|
||||
$toast.success('新增站点成功')
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`新增站点失败:${result.message}`)
|
||||
}
|
||||
|
||||
else { $toast.error(`新增站点失败:${result.message}`) }
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
@@ -88,12 +88,9 @@ async function addSite() {
|
||||
async function deleteSiteInfo() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`site/${siteForm.value?.id}`)
|
||||
if (result.success)
|
||||
emit('remove')
|
||||
|
||||
if (result.success) emit('remove')
|
||||
else $toast.error(`${siteForm.value?.name} 删除失败:${result.message}`)
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
$toast.error(`${siteForm.value?.name} 删除失败!`)
|
||||
console.error(error)
|
||||
}
|
||||
@@ -107,10 +104,10 @@ async function updateSiteInfo() {
|
||||
if (result.success) {
|
||||
$toast.success(`${siteForm.value?.name} 更新成功!`)
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`${siteForm.value?.name} 更新失败:${result.message}`)
|
||||
}
|
||||
else { $toast.error(`${siteForm.value?.name} 更新失败:${result.message}`) }
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
$toast.error(`${siteForm.value?.name} 更新失败!`)
|
||||
console.error(error)
|
||||
}
|
||||
@@ -119,13 +116,7 @@ async function updateSiteInfo() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
scrollable
|
||||
:close-on-back="false"
|
||||
persistent
|
||||
eager
|
||||
max-width="60rem"
|
||||
>
|
||||
<VDialog scrollable :close-on-back="false" persistent eager max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
|
||||
class="rounded-t"
|
||||
@@ -134,36 +125,25 @@ async function updateSiteInfo() {
|
||||
<VCardText class="pt-2">
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="siteForm.url"
|
||||
label="站点地址"
|
||||
:rules="[requiredValidator]"
|
||||
hint="格式:http://www.example.com/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="3"
|
||||
>
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect
|
||||
v-model="siteForm.pri"
|
||||
label="优先级"
|
||||
:items="priorityItems"
|
||||
:rules="[requiredValidator]"
|
||||
hint="站点资源下载优先级,优先级数字越小越优先下载"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="3"
|
||||
>
|
||||
<VSelect
|
||||
v-model="siteForm.is_active"
|
||||
:items="statusItems"
|
||||
label="状态"
|
||||
/>
|
||||
<VCol cols="12" md="3">
|
||||
<VSelect v-model="siteForm.is_active" :items="statusItems" label="状态" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
@@ -171,106 +151,70 @@ async function updateSiteInfo() {
|
||||
<VTextField
|
||||
v-model="siteForm.rss"
|
||||
label="RSS地址"
|
||||
hint="订阅模式为站点RSS时,将会使用此地址获取站点种子资源,该地址一般会自动获取,也可手动补充"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="siteForm.cookie"
|
||||
label="站点Cookie"
|
||||
hint="浏览器打开站点首页,打开开发人员工具,刷新页面后在网络选项中找到首页地址,在请求头中获取Cookie信息"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="siteForm.ua"
|
||||
label="站点User-Agent"
|
||||
hint="在开发人员工具,网络请求头中获取User-Agent信息,需与站点Cookie配套使用"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="siteForm.limit_interval"
|
||||
label="单位周期(秒)"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定站点限流的单位周期,单位为秒,0为不限流"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="siteForm.limit_count"
|
||||
label="访问次数"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定单位周期内站点允许的访问次数,0为不限制"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="siteForm.limit_seconds"
|
||||
label="访问间隔(秒)"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定单位周期内每次站点访问需间隔时间,单位为秒,0为不限制"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VSwitch
|
||||
v-model="siteForm.proxy"
|
||||
label="代理"
|
||||
/>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="siteForm.proxy" label="代理" hint="站点是否需要代理访问,需要设置好代理服务器信息" />
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="siteForm.render"
|
||||
label="仿真"
|
||||
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
@click="emit('close')"
|
||||
>
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else
|
||||
color="error"
|
||||
@click="deleteSiteInfo"
|
||||
>
|
||||
删除
|
||||
</VBtn>
|
||||
<VBtn v-if="props.oper === 'add'" @click="emit('close')"> 取消 </VBtn>
|
||||
<VBtn v-else color="error" @click="deleteSiteInfo"> 删除 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="addSite"
|
||||
>
|
||||
新增
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="updateSiteInfo"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
<VBtn v-if="props.oper === 'add'" color="primary" variant="tonal" @click="addSite"> 新增 </VBtn>
|
||||
<VBtn v-else color="primary" variant="tonal" @click="updateSiteInfo"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
@@ -3,10 +3,16 @@ import { useToast } from 'vue-toast-notification'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { Site, Subscribe } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
subid: Number,
|
||||
default: Boolean,
|
||||
type: String,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -41,6 +47,8 @@ const subscribeForm = ref<Subscribe>({
|
||||
username: '',
|
||||
current_priority: 0,
|
||||
save_path: '',
|
||||
date: '',
|
||||
show_edit_dialog: false,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
@@ -55,14 +63,47 @@ async function updateSubscribeInfo() {
|
||||
$toast.success(`${subscribeForm.value.name} 更新成功!`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`${subscribeForm.value.name} 更新失败:${result.message}!`)
|
||||
}
|
||||
else { $toast.error(`${subscribeForm.value.name} 更新失败:${result.message}!`) }
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置用户设置的默认订阅规则
|
||||
async function saveDefaultSubscribeConfig() {
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
|
||||
if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`)
|
||||
else $toast.error(`${props.type}订阅默认规则保存失败!`)
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询用户设置的默认订阅规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
if (result.data.value) subscribeForm.value = result.data?.value ?? ''
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取站点列表数据
|
||||
async function loadSites() {
|
||||
try {
|
||||
@@ -70,8 +111,7 @@ async function loadSites() {
|
||||
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
siteList.value = data.filter(item => item.is_active)
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -79,10 +119,9 @@ async function loadSites() {
|
||||
// 获取站点列表选择框数据
|
||||
async function getSiteList() {
|
||||
// 加载订阅站点列表
|
||||
if (!siteList.value.length)
|
||||
await loadSites()
|
||||
if (!siteList.value.length) await loadSites()
|
||||
|
||||
const maps = siteList.value.map((item) => {
|
||||
const maps = siteList.value.map(item => {
|
||||
return {
|
||||
title: item.name,
|
||||
value: item.id,
|
||||
@@ -95,14 +134,11 @@ async function getSiteList() {
|
||||
// 获取订阅信息
|
||||
async function getSubscribeInfo() {
|
||||
try {
|
||||
const result: Subscribe = await api.get(
|
||||
`subscribe/${props.subid}`,
|
||||
)
|
||||
const result: Subscribe = await api.get(`subscribe/${props.subid}`)
|
||||
subscribeForm.value = result
|
||||
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
|
||||
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
@@ -110,16 +146,13 @@ async function getSubscribeInfo() {
|
||||
// 删除订阅
|
||||
async function removeSubscribe() {
|
||||
try {
|
||||
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) {
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
@@ -208,150 +241,119 @@ const effectOptions = ref([
|
||||
},
|
||||
])
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.subid) {
|
||||
getSiteList()
|
||||
getSubscribeInfo()
|
||||
}
|
||||
onMounted(() => {
|
||||
getSiteList()
|
||||
if (props.subid) getSubscribeInfo()
|
||||
|
||||
if (props.default) queryDefaultSubscribeConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
scrollable
|
||||
max-width="60rem"
|
||||
>
|
||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard
|
||||
:title="`编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`"
|
||||
:title="`${
|
||||
props.default
|
||||
? `${props.type}默认订阅规则`
|
||||
: `编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`
|
||||
}`"
|
||||
class="rounded-t"
|
||||
>
|
||||
<VCardText class="pt-2">
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="8"
|
||||
>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField
|
||||
v-if="!props.default"
|
||||
v-model="subscribeForm.keyword"
|
||||
label="搜索关键词"
|
||||
hint="设定搜索关键词后,将使用此关键词搜索站点资源,否则自动使用themoviedb中的名称搜索"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="subscribeForm.type === '电视剧'"
|
||||
cols="12"
|
||||
md="2"
|
||||
>
|
||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
||||
<VTextField
|
||||
v-model="subscribeForm.total_episode"
|
||||
label="总集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="设定剧集的总集数,以应对themoviedb中剧集信息未维护完整,导致提前结束订阅的情况"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="subscribeForm.type === '电视剧'"
|
||||
cols="12"
|
||||
md="2"
|
||||
>
|
||||
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
|
||||
<VTextField
|
||||
v-model="subscribeForm.start_episode"
|
||||
label="开始集数"
|
||||
:rules="[numberValidator]"
|
||||
hint="只订阅下载此集数及之后的剧集"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VSelect
|
||||
v-model="subscribeForm.quality"
|
||||
label="质量"
|
||||
:items="qualityOptions"
|
||||
/>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.quality" label="质量" :items="qualityOptions" />
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VSelect
|
||||
v-model="subscribeForm.resolution"
|
||||
label="分辨率"
|
||||
:items="resolutionOptions"
|
||||
/>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.resolution" label="分辨率" :items="resolutionOptions" />
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VSelect
|
||||
v-model="subscribeForm.effect"
|
||||
label="特效"
|
||||
:items="effectOptions"
|
||||
/>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect v-model="subscribeForm.effect" label="特效" :items="effectOptions" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="subscribeForm.include"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="subscribeForm.exclude"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正则表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VSelect
|
||||
v-model="subscribeForm.sites"
|
||||
:items="selectSitesOptions"
|
||||
chips
|
||||
label="订阅站点"
|
||||
multiple
|
||||
hint="只订阅选中的订阅站点,不选则订阅所有可订阅站点"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="subscribeForm.save_path"
|
||||
label="保存路径"
|
||||
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.best_version"
|
||||
label="洗版"
|
||||
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.search_imdbid"
|
||||
label="使用 ImdbID 搜索"
|
||||
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="props.default" cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="subscribeForm.show_edit_dialog"
|
||||
label="订阅时编辑更多规则"
|
||||
hint="开启后将在添加订阅后弹出编辑订阅的对话框,方便用户编辑订阅规则"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -359,14 +361,9 @@ watchEffect(() => {
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="removeSubscribe">
|
||||
取消订阅
|
||||
</VBtn>
|
||||
<VBtn v-if="!props.default" color="error" @click="removeSubscribe"> 取消订阅 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="updateSubscribeInfo"
|
||||
>
|
||||
<VBtn variant="tonal" @click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`">
|
||||
保存
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
210
src/components/dialog/SubscribeHistoryDialog.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { Subscribe } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
type: String,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
// 订阅历史列表
|
||||
const historyList = ref<Subscribe[]>([])
|
||||
|
||||
// 当前加载数据
|
||||
const currData = ref<Subscribe[]>([])
|
||||
|
||||
// 当前页
|
||||
const currentPage = ref(1)
|
||||
|
||||
// 每页数量
|
||||
const pageSize = ref(30)
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度文字
|
||||
const progressText = ref('正在重新订阅...')
|
||||
|
||||
// 调用API查询列表
|
||||
async function loadHistory({ done }: { done: any }) {
|
||||
// 如果正在加载中,直接返回
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用API查询列表
|
||||
try {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
currData.value = await api.get(`subscribe/history/${props.type}`, {
|
||||
params: {
|
||||
page: currentPage.value,
|
||||
count: pageSize.value,
|
||||
},
|
||||
})
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 合并数据
|
||||
historyList.value = [...historyList.value, ...currData.value]
|
||||
// 页码+1
|
||||
currentPage.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// 返回加载失败
|
||||
done('error')
|
||||
}
|
||||
}
|
||||
|
||||
// 重新订阅
|
||||
async function reSubscribe(item: Subscribe) {
|
||||
if (item.type === '电影') progressText.value = `正在重新订阅 ${item.name} ...`
|
||||
else progressText.value = `正在重新订阅 ${item.name} 第 ${item.season} 季 ...`
|
||||
progressDialog.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('subscribe', item)
|
||||
if (result.success) {
|
||||
emit('save')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
progressDialog.value = false
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
async function deleteHistory(item: Subscribe) {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/history/${item.id}`)
|
||||
if (result.success) {
|
||||
historyList.value = historyList.value.filter(i => i.id !== item.id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: '重新订阅',
|
||||
value: 1,
|
||||
color: '',
|
||||
props: {
|
||||
prependIcon: 'mdi-redo',
|
||||
click: reSubscribe,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '删除',
|
||||
value: 2,
|
||||
color: 'error',
|
||||
props: {
|
||||
prependIcon: 'mdi-delete',
|
||||
click: deleteHistory,
|
||||
},
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="mx-auto" width="100%">
|
||||
<VCardItem class="pb-0">
|
||||
<VCardTitle>{{ props.type + '订阅历史' }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<DialogCloseBtn
|
||||
@click="
|
||||
() => {
|
||||
emit('close')
|
||||
}
|
||||
"
|
||||
/>
|
||||
<VList lines="two">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory">
|
||||
<template #loading>
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty />
|
||||
<template v-for="(item, i) in historyList" :key="i">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded shadow ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }} <span class="text-sm">第 {{ item.season }} 季</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VInfiniteScroll>
|
||||
</VList>
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
</VDialog>
|
||||
</template>
|
||||
521
src/components/filebrowser/FileList.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import type { Context, EndPoints, FileItem } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
path: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
refreshpending: Boolean,
|
||||
sort: String,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref(true)
|
||||
|
||||
// 识别进度条
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 识别进度文本
|
||||
const progressText = ref('请稍候 ...')
|
||||
|
||||
// 识别进度
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 存储空间类型
|
||||
const storage = ref(inProps.storage ?? '')
|
||||
|
||||
// axios实例
|
||||
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
|
||||
|
||||
// 内容列表
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
// 过滤条件
|
||||
const filter = ref('')
|
||||
|
||||
// 重命名弹窗
|
||||
const renamePopper = ref(false)
|
||||
|
||||
// 整理弹窗
|
||||
const transferPopper = ref(false)
|
||||
|
||||
// 新名称
|
||||
const newName = ref('')
|
||||
|
||||
// 当前名称
|
||||
const currentItem = ref<FileItem>()
|
||||
|
||||
// 识别结果
|
||||
const nameTestResult = ref<Context>()
|
||||
|
||||
// 识别结果对话框
|
||||
const nameTestDialog = ref(false)
|
||||
|
||||
// 延迟加载
|
||||
const defer = (_: number) => true
|
||||
|
||||
// 目录过滤
|
||||
const dirs = computed(() => items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)))
|
||||
|
||||
// 文件过滤
|
||||
const files = computed(() => items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)))
|
||||
|
||||
// 是否目录
|
||||
const isDir = computed(() => inProps.path?.endsWith('/'))
|
||||
|
||||
// 是否文件
|
||||
const isFile = computed(() => !isDir.value)
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
const ext = inProps.path?.split('.').pop()?.toLowerCase()
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
|
||||
})
|
||||
|
||||
// 调API加载内容
|
||||
async function load() {
|
||||
loading.value = true
|
||||
emit('loading', true)
|
||||
// 参数
|
||||
const url = inProps.endpoints?.list.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
|
||||
.replace(/{sort}/g, inProps.sort || 'name')
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.list.method || 'get',
|
||||
}
|
||||
// 加载数据
|
||||
items.value = (await axiosInstance.value.request(config)) ?? []
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
async function deleteItem(item: FileItem) {
|
||||
const confirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除${item.type === 'dir' ? '目录' : '文件'} ${item.basename}?`,
|
||||
confirmationText: '确认',
|
||||
cancellationText: '取消',
|
||||
dialogProps: {
|
||||
maxWidth: '50rem',
|
||||
},
|
||||
cancellationButtonProps: {
|
||||
variant: 'tonal',
|
||||
},
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.delete.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(item.path))
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.delete.method || 'post',
|
||||
}
|
||||
|
||||
await axiosInstance.value.request(config)
|
||||
emit('filedeleted')
|
||||
emit('loading', false)
|
||||
// 重新加载
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
// 切换路径
|
||||
function changePath(_path: string) {
|
||||
emit('pathchanged', _path)
|
||||
}
|
||||
|
||||
// 新窗口中下载文件
|
||||
function download(path: string) {
|
||||
if (!path) return
|
||||
const token = store.state.auth.token
|
||||
const url_path = inProps.endpoints?.download.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(path))
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
||||
// 下载文件
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 显示图片
|
||||
function getImgLink(path: string) {
|
||||
if (!path) return ''
|
||||
const token = store.state.auth.token
|
||||
const url_path = inProps.endpoints?.image.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(path))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
||||
}
|
||||
|
||||
// 显示重命名弹窗
|
||||
function showRenmae(item: FileItem) {
|
||||
currentItem.value = item
|
||||
newName.value = item.name
|
||||
renamePopper.value = true
|
||||
}
|
||||
|
||||
// 重命名
|
||||
async function rename() {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.rename.url
|
||||
.replace(/{storage}/g, inProps.storage)
|
||||
.replace(/{path}/g, encodeURIComponent(currentItem.value?.path || ''))
|
||||
.replace(/{newname}/g, encodeURIComponent(newName.value))
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.mkdir.method || 'post',
|
||||
}
|
||||
|
||||
// 调API
|
||||
await inProps.axios?.request(config)
|
||||
|
||||
renamePopper.value = false
|
||||
newName.value = ''
|
||||
emit('loading', false)
|
||||
|
||||
// 通知重新加载
|
||||
emit('renamed')
|
||||
}
|
||||
|
||||
// 显示整理对话框
|
||||
function showTransfer(item: FileItem) {
|
||||
currentItem.value = item
|
||||
transferPopper.value = true
|
||||
}
|
||||
|
||||
// 将文件修改时间(timestape)转换为本地时间
|
||||
function formatTime(timestape: number) {
|
||||
return new Date(timestape * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
// 监听path变化
|
||||
watch(
|
||||
() => inProps.path,
|
||||
async () => {
|
||||
items.value = []
|
||||
nameTestResult.value = undefined
|
||||
nameTestDialog.value = false
|
||||
await load()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听refreshPending变化
|
||||
watch(
|
||||
() => inProps.refreshpending,
|
||||
async () => {
|
||||
if (inProps.refreshpending) {
|
||||
await load()
|
||||
emit('refreshed')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 调用API识别
|
||||
async function recognize(path: string) {
|
||||
try {
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在识别 ${path} ...`
|
||||
progressValue.value = 0
|
||||
nameTestResult.value = await api.get('media/recognize_file', {
|
||||
params: {
|
||||
path,
|
||||
},
|
||||
})
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
if (!nameTestResult.value) $toast.error(`${path} 识别失败!`)
|
||||
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API刮削
|
||||
async function scrape(path: string) {
|
||||
try {
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在刮削 ${path} ...`
|
||||
const result: { [key: string]: any } = await api.get('media/scrape', {
|
||||
params: {
|
||||
path,
|
||||
},
|
||||
})
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
if (!result.success) $toast.error(result.message)
|
||||
else $toast.success(`${path}削刮完成!`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: '识别',
|
||||
value: 1,
|
||||
props: {
|
||||
prependIcon: 'mdi-text-recognition',
|
||||
click: (_item: FileItem) => {
|
||||
recognize(_item.path || '')
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '刮削',
|
||||
value: 2,
|
||||
props: {
|
||||
prependIcon: 'mdi-auto-fix',
|
||||
click: (_item: FileItem) => {
|
||||
scrape(_item.path || '')
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重命名',
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-rename',
|
||||
click: showRenmae,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '整理',
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-folder-arrow-right',
|
||||
click: showTransfer,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '删除',
|
||||
value: 5,
|
||||
props: {
|
||||
prependIcon: 'mdi-delete-outline',
|
||||
color: 'error',
|
||||
click: deleteItem,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="d-flex flex-column">
|
||||
<VToolbar v-if="!loading" density="compact" flat color="gray">
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
v-model="filter"
|
||||
hide-details
|
||||
flat
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
placeholder="搜索 ..."
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
class="me-2"
|
||||
rounded="0"
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
|
||||
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
|
||||
<VIcon color="primary"> mdi-download </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="load">
|
||||
<VIcon color="primary"> mdi-refresh </VIcon>
|
||||
</IconBtn>
|
||||
</VToolbar>
|
||||
<VCardText v-if="loading" class="text-center flex flex-col items-center">
|
||||
<VProgressCircular size="48" indeterminate color="primary" />
|
||||
</VCardText>
|
||||
<VCardText v-if="!path" class="grow d-flex justify-center align-center grey--text"> 选择目录或文件 </VCardText>
|
||||
<VCardText v-else-if="isFile && !isImage" class="text-center break-all">
|
||||
<strong>{{ items[0]?.name }}</strong
|
||||
><br />
|
||||
大小:{{ formatBytes(items[0]?.size || 0) }}<br />
|
||||
修改时间:{{ formatTime(items[0]?.modify_time || 0) }}
|
||||
</VCardText>
|
||||
<VCardText v-else-if="isFile && isImage" class="grow d-flex justify-center align-center">
|
||||
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
|
||||
</VCardText>
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList subheader>
|
||||
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="changePath(item.path)">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
v-if="inProps.icons && item.extension"
|
||||
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||
/>
|
||||
<VIcon v-else icon="mdi-folder-outline" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<VListItemSubtitle v-if="item.size">
|
||||
{{ formatBytes(item.size) }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<IconBtn class="d-sm-none">
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-if="hover.isHovering" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
|
||||
没有目录或文件
|
||||
</VCardText>
|
||||
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5"> 空目录 </VCardText>
|
||||
</VCard>
|
||||
<!-- 重命名弹窗 -->
|
||||
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="50rem">
|
||||
<VCard title="重命名">
|
||||
<VCardText>
|
||||
<VTextField v-model="newName" label="名称" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn depressed @click="renamePopper = false"> 取消 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn :disabled="!newName" depressed variant="tonal" @click="rename"> 重命名 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 文件整理弹窗 -->
|
||||
<ReorganizeDialog
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:path="currentItem?.path"
|
||||
@done="
|
||||
() => {
|
||||
transferPopper = false
|
||||
load()
|
||||
}
|
||||
"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||
<!-- 识别结果对话框 -->
|
||||
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" width="50rem">
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="nameTestDialog = false" />
|
||||
<VCardItem>
|
||||
<MediaInfoCard :context="nameTestResult" />
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.v-toolbar {
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
|
||||
.virtual-scroll-div {
|
||||
block-size: calc(100vh - 14rem);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.virtual-scroll-div {
|
||||
block-size: calc(100vh - 17rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,664 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import ReorganizeForm from '../form/ReorganizeForm.vue'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import type { Context, EndPoints, FileItem } from '@/api/types'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
path: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
refreshpending: Boolean,
|
||||
sort: String,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted', 'renamed'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref(true)
|
||||
|
||||
// 识别进度条
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 识别进度文本
|
||||
const progressText = ref('请稍候 ...')
|
||||
|
||||
// 识别进度
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 存储空间类型
|
||||
const storage = ref(inProps.storage ?? '')
|
||||
|
||||
// axios实例
|
||||
const axiosInstance = ref<Axios>(inProps.axios ?? axios)
|
||||
|
||||
// 内容列表
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
// 过滤条件
|
||||
const filter = ref('')
|
||||
|
||||
// 重命名弹窗
|
||||
const renamePopper = ref(false)
|
||||
|
||||
// 整理弹窗
|
||||
const transferPopper = ref(false)
|
||||
|
||||
// 新名称
|
||||
const newName = ref('')
|
||||
|
||||
// 当前名称
|
||||
const currentItem = ref<FileItem>()
|
||||
|
||||
// 识别结果
|
||||
const nameTestResult = ref<Context>()
|
||||
|
||||
// 识别结果对话框
|
||||
const nameTestDialog = ref(false)
|
||||
|
||||
// 目录过滤
|
||||
const dirs = computed(() =>
|
||||
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
|
||||
)
|
||||
|
||||
// 文件过滤
|
||||
const files = computed(() =>
|
||||
items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)),
|
||||
)
|
||||
|
||||
// 是否目录
|
||||
const isDir = computed(() => inProps.path?.endsWith('/'))
|
||||
|
||||
// 是否文件
|
||||
const isFile = computed(() => !isDir.value)
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
const ext = inProps.path?.split('.').pop()?.toLowerCase()
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
|
||||
})
|
||||
|
||||
// 调API加载内容
|
||||
async function load() {
|
||||
loading.value = true
|
||||
emit('loading', true)
|
||||
// 参数
|
||||
const url = inProps.endpoints?.list.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(inProps.path || ''))
|
||||
.replace(/{sort}/g, inProps.sort || 'name')
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.list.method || 'get',
|
||||
}
|
||||
// 加载数据
|
||||
items.value = await axiosInstance.value.request(config) ?? []
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
async function deleteItem(item: FileItem) {
|
||||
const confirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认删除${
|
||||
item.type === 'dir' ? '目录' : '文件'
|
||||
} ${item.basename}?`,
|
||||
confirmationText: '确认',
|
||||
cancellationText: '取消',
|
||||
dialogProps: {
|
||||
maxWidth: '50rem',
|
||||
},
|
||||
cancellationButtonProps: {
|
||||
variant: 'tonal',
|
||||
},
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.delete.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(item.path))
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.delete.method || 'post',
|
||||
}
|
||||
|
||||
await axiosInstance.value.request(config)
|
||||
emit('filedeleted')
|
||||
emit('loading', false)
|
||||
// 重新加载
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
// 切换路径
|
||||
function changePath(_path: string) {
|
||||
emit('pathchanged', _path)
|
||||
}
|
||||
|
||||
// 新窗口中下载文件
|
||||
function download(path: string) {
|
||||
if (!path)
|
||||
return
|
||||
const token = store.state.auth.token
|
||||
const url_path = inProps.endpoints?.download.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(path))
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
||||
// 下载文件
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 显示图片
|
||||
function getImgLink(path: string) {
|
||||
if (!path)
|
||||
return ''
|
||||
const token = store.state.auth.token
|
||||
const url_path = inProps.endpoints?.image.url
|
||||
.replace(/{storage}/g, storage.value)
|
||||
.replace(/{path}/g, encodeURIComponent(path))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}${url_path.slice(1)}&token=${token}`
|
||||
}
|
||||
|
||||
// 显示重命名弹窗
|
||||
function showRenmae(item: FileItem) {
|
||||
currentItem.value = item
|
||||
newName.value = item.name
|
||||
renamePopper.value = true
|
||||
}
|
||||
|
||||
// 重命名
|
||||
async function rename() {
|
||||
emit('loading', true)
|
||||
const url = inProps.endpoints?.rename.url
|
||||
.replace(/{storage}/g, inProps.storage)
|
||||
.replace(/{path}/g, encodeURIComponent(currentItem.value?.path || ''))
|
||||
.replace(/{newname}/g, encodeURIComponent(newName.value))
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: inProps.endpoints?.mkdir.method || 'post',
|
||||
}
|
||||
|
||||
// 调API
|
||||
await inProps.axios?.request(config)
|
||||
|
||||
renamePopper.value = false
|
||||
newName.value = ''
|
||||
emit('loading', false)
|
||||
|
||||
// 通知重新加载
|
||||
emit('renamed')
|
||||
}
|
||||
|
||||
// 显示整理对话框
|
||||
function showTransfer(item: FileItem) {
|
||||
currentItem.value = item
|
||||
transferPopper.value = true
|
||||
}
|
||||
|
||||
// 将文件修改时间(timestape)转换为本地时间
|
||||
function formatTime(timestape: number) {
|
||||
return new Date(timestape * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
// 监听path变化
|
||||
watch(
|
||||
() => inProps.path,
|
||||
async () => {
|
||||
items.value = []
|
||||
nameTestResult.value = undefined
|
||||
nameTestDialog.value = false
|
||||
await load()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听refreshPending变化
|
||||
watch(
|
||||
() => inProps.refreshpending,
|
||||
async () => {
|
||||
if (inProps.refreshpending) {
|
||||
await load()
|
||||
emit('refreshed')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 调用API识别
|
||||
async function recognize(path: string) {
|
||||
try {
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在识别 ${path} ...`
|
||||
progressValue.value = 0
|
||||
nameTestResult.value = await api.get('media/recognize_file', {
|
||||
params: {
|
||||
path,
|
||||
},
|
||||
})
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
if (!nameTestResult.value)
|
||||
$toast.error(`${path} 识别失败!`)
|
||||
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API刮削
|
||||
async function scrape(path: string) {
|
||||
try {
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在刮削 ${path} ...`
|
||||
const result: { [key: string]: any } = await api.get('media/scrape', {
|
||||
params: {
|
||||
path,
|
||||
},
|
||||
})
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
if (!result.success)
|
||||
$toast.error(result.message)
|
||||
else
|
||||
$toast.success(`${path}削刮完成!`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: '识别',
|
||||
value: 1,
|
||||
props: {
|
||||
prependIcon: 'mdi-text-recognition',
|
||||
click: (_item: FileItem) => {
|
||||
recognize(_item.path || '')
|
||||
},
|
||||
},
|
||||
}, {
|
||||
title: '刮削',
|
||||
value: 2,
|
||||
props: {
|
||||
prependIcon: 'mdi-auto-fix',
|
||||
click: (_item: FileItem) => {
|
||||
scrape(_item.path || '')
|
||||
},
|
||||
},
|
||||
}, {
|
||||
title: '重命名',
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-rename',
|
||||
click: showRenmae,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '整理',
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-folder-arrow-right',
|
||||
click: showTransfer,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '删除',
|
||||
value: 5,
|
||||
props: {
|
||||
prependIcon: 'mdi-delete-outline',
|
||||
color: 'error',
|
||||
click: deleteItem,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="d-flex flex-column">
|
||||
<VCardText
|
||||
v-if="loading"
|
||||
class="text-center flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-if="!path"
|
||||
class="grow d-flex justify-center align-center grey--text"
|
||||
>
|
||||
选择目录或文件
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="isFile && !isImage"
|
||||
class="text-center break-all"
|
||||
>
|
||||
<strong>{{ items[0]?.name }}</strong><br>
|
||||
大小:{{ formatBytes(items[0]?.size || 0) }}<br>
|
||||
修改时间:{{ formatTime(items[0]?.modify_time || 0) }}
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="isFile && isImage"
|
||||
class="grow d-flex justify-center align-center"
|
||||
>
|
||||
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
|
||||
</VCardText>
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList v-if="dirs.length" subheader>
|
||||
<VListSubheader>目录</VListSubheader>
|
||||
<VHover
|
||||
v-for="(item, index) in dirs"
|
||||
:key="index"
|
||||
>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
v-bind="hover.props"
|
||||
class="px-3 pe-1"
|
||||
@click="changePath(item.path)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-folder-outline" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<template #append>
|
||||
<IconBtn class="d-sm-none">
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-show="hover.isHovering" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</VList>
|
||||
<VDivider v-if="dirs.length && files.length" />
|
||||
<VList v-if="files.length" subheader>
|
||||
<VListSubheader>文件</VListSubheader>
|
||||
<VHover
|
||||
v-for="(item, index) in files"
|
||||
:key="index"
|
||||
>
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
v-bind="hover.props"
|
||||
class="pl-3 pe-1"
|
||||
@click="changePath(item.path)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
|
||||
</template>
|
||||
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
|
||||
|
||||
<template #append>
|
||||
<IconBtn class="d-sm-none">
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-show="hover.isHovering" class="flex">
|
||||
<VTooltip text="识别">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="刮削">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="重命名">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="整理">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="删除">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="filter"
|
||||
class="grow d-flex justify-center align-center grey--text py-5"
|
||||
>
|
||||
没有目录或文件
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="!loading"
|
||||
class="grow d-flex justify-center align-center grey--text py-5"
|
||||
>
|
||||
空目录
|
||||
</VCardText>
|
||||
<VDivider v-if="path" />
|
||||
<VToolbar v-if="!loading" density="compact" flat color="gray">
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
v-model="filter"
|
||||
hide-details
|
||||
flat
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
placeholder="搜索 ..."
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
class="me-2"
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
|
||||
<VIcon color="primary">
|
||||
mdi-text-recognition
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
|
||||
<VIcon color="primary">
|
||||
mdi-download
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="load">
|
||||
<VIcon color="primary">
|
||||
mdi-refresh
|
||||
</VIcon>
|
||||
</IconBtn>
|
||||
</VToolbar>
|
||||
</VCard>
|
||||
<!-- 重命名弹窗 -->
|
||||
<VDialog
|
||||
v-model="renamePopper"
|
||||
max-width="50rem"
|
||||
>
|
||||
<VCard title="重命名">
|
||||
<VCardText>
|
||||
<VTextField v-model="newName" label="名称" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn depressed @click="renamePopper = false">
|
||||
取消
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
:disabled="!newName"
|
||||
depressed
|
||||
variant="tonal"
|
||||
@click="rename"
|
||||
>
|
||||
重命名
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 文件整理弹窗 -->
|
||||
<ReorganizeForm
|
||||
v-model="transferPopper"
|
||||
:path="currentItem?.path"
|
||||
@done="transferPopper = false; load()"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
<!-- 手动整理进度框 -->
|
||||
<VDialog
|
||||
v-model="progressDialog"
|
||||
:scrim="false"
|
||||
width="25rem"
|
||||
>
|
||||
<VCard
|
||||
color="primary"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
<VProgressLinear
|
||||
v-if="progressValue"
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
:model-value="progressValue"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 识别结果对话框 -->
|
||||
<VDialog
|
||||
v-model="nameTestDialog"
|
||||
width="50rem"
|
||||
>
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="nameTestDialog = false" />
|
||||
<VCardItem>
|
||||
<MediaInfoCard :context="nameTestResult" />
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-card {
|
||||
height: 100%;
|
||||
}
|
||||
.v-toolbar{
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
</style>
|
||||
@@ -1,203 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Axios } from 'axios'
|
||||
import type { EndPoints, FileItem } from '@/api/types'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
path: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: Object as PropType<Axios>,
|
||||
refreshpending: Boolean,
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged', 'loading', 'refreshed'])
|
||||
|
||||
// 变量
|
||||
const open = ref<string[]>([])
|
||||
// 活跃的文件夹
|
||||
const active = ref<string[]>([])
|
||||
// 内容
|
||||
const items = ref<FileItem[]>([])
|
||||
// 过滤
|
||||
const filter = ref('')
|
||||
|
||||
// 方法
|
||||
function init() {
|
||||
open.value = []
|
||||
items.value = [{
|
||||
type: 'dir',
|
||||
path: '/',
|
||||
basename: 'root',
|
||||
extension: '',
|
||||
name: 'root',
|
||||
children: [],
|
||||
size: 0,
|
||||
modify_time: 0,
|
||||
}]
|
||||
}
|
||||
|
||||
// 调用API读取文件夹
|
||||
async function readFolder(item: FileItem) {
|
||||
emit('loading', true)
|
||||
const url = props.endpoints?.list.url
|
||||
.replace(/{storage}/g, props.storage)
|
||||
.replace(/{path}/g, item.path)
|
||||
|
||||
const config = {
|
||||
url,
|
||||
method: props.endpoints?.list.method || 'get',
|
||||
}
|
||||
|
||||
const response: FileItem[] = await props.axios?.request(config) ?? []
|
||||
|
||||
item.children = response.map((item: FileItem) => {
|
||||
if (item.type === 'dir')
|
||||
item.children = []
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
emit('loading', false)
|
||||
}
|
||||
|
||||
// 选中变化
|
||||
function activeChanged(_active: string[]) {
|
||||
let path = ''
|
||||
if (active.value.length)
|
||||
path = active.value[0]
|
||||
|
||||
if (props.path !== path)
|
||||
emit('pathchanged', path)
|
||||
}
|
||||
|
||||
// 查找文件
|
||||
function findItem(path: string) {
|
||||
const stack: FileItem[] = []
|
||||
stack.push(items.value[0])
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()
|
||||
if (node?.path === path) {
|
||||
return node
|
||||
}
|
||||
else if (node?.children && node.children.length) {
|
||||
for (const element of node.children)
|
||||
stack.push(element)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 监听存储空间变量
|
||||
watch(() => props.storage, () => {
|
||||
init()
|
||||
})
|
||||
|
||||
// 监听路径变化
|
||||
watch(
|
||||
() => props.path,
|
||||
() => {
|
||||
if (props.path) {
|
||||
active.value = [props.path]
|
||||
if (!open.value.includes(props.path))
|
||||
open.value.push(props.path)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 refreshPending
|
||||
watch(
|
||||
() => props.refreshpending,
|
||||
async () => {
|
||||
if (props.refreshpending && props.path) {
|
||||
const item = findItem(props.path)
|
||||
if (item) {
|
||||
await readFolder(item)
|
||||
emit('refreshed')
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard flat width="250" min-height="500" class="d-flex flex-column folders-tree-card">
|
||||
<div class="grow scroll-x">
|
||||
<VTreeview
|
||||
:open="open"
|
||||
:active="active"
|
||||
:items="items"
|
||||
:search="filter"
|
||||
:load-children="readFolder"
|
||||
item-key="path"
|
||||
item-text="basename"
|
||||
dense
|
||||
activatable
|
||||
transition
|
||||
class="folders-tree"
|
||||
@update:active="activeChanged"
|
||||
>
|
||||
<template #prepend="{ item, open }">
|
||||
<VIcon
|
||||
v-if="item.type === 'dir'"
|
||||
>
|
||||
{{ open ? 'mdi-folder-open-outline' : 'mdi-folder-outline' }}
|
||||
</VIcon>
|
||||
<VIcon v-else-if="props.icons" :icon="props.icons[item.extension.toLowerCase()] || props.icons.other" />
|
||||
</template>
|
||||
<template #label="{ item }">
|
||||
{{ item.basename }}
|
||||
<VBtn
|
||||
v-if="item.type === 'dir'"
|
||||
icon
|
||||
class="ml-1"
|
||||
@click.stop="readFolder(item)"
|
||||
>
|
||||
<VIcon class="pa-0 mdi-18px" color="grey lighten-1">
|
||||
mdi-refresh
|
||||
</VIcon>
|
||||
</VBtn>
|
||||
</template>
|
||||
</VTreeview>
|
||||
</div>
|
||||
<VDivider />
|
||||
<VToolbar
|
||||
density="compact"
|
||||
>
|
||||
<VBtn icon @click="init">
|
||||
<VIcon icon="mdi-collapse-all-outline" />
|
||||
</VBtn>
|
||||
</VToolbar>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folders-tree-card {
|
||||
height: 100%;
|
||||
|
||||
.scroll-x {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
::v-deep .folders-tree {
|
||||
width: fit-content;
|
||||
min-width: 250px;
|
||||
|
||||
.v-treeview-node {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-toolbar{
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
</style>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 代码
|
||||
const codeString = ref('')
|
||||
|
||||
// 导入
|
||||
function handleImport() {
|
||||
emit('update:modelValue', codeString.value)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
:title="props.title"
|
||||
class="rounded-t"
|
||||
>
|
||||
<DialogCloseBtn @click="emit('close')" />
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea v-model="codeString" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="handleImport"
|
||||
>
|
||||
导入
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -93,7 +93,7 @@ onMounted(() => {
|
||||
single-line
|
||||
placeholder="电影或电视剧名称"
|
||||
variant="solo"
|
||||
append-inner-icon="mdi-magnify"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
flat
|
||||
class="mx-1"
|
||||
:loading="loading"
|
||||
@@ -101,7 +101,7 @@ onMounted(() => {
|
||||
@keydown.enter="searchMedias"
|
||||
/>
|
||||
</VToolbar>
|
||||
|
||||
<DialogCloseBtn @click="() => { emit('close') }" />
|
||||
<VList
|
||||
v-if="items.length > 0"
|
||||
lines="three"
|
||||
@@ -131,7 +131,6 @@ onMounted(() => {
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2" v-html="item.overview" />
|
||||
</VListItem>
|
||||
<VDivider v-if="i < items.length - 1" class="mt-1" inset />
|
||||
</template>
|
||||
</VList>
|
||||
</VCard>
|
||||
24
src/components/misc/VersionHistory.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
history: Object as PropType<{ [key: string]: string }>,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCardItem>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(value, key) in props.history"
|
||||
:key="key"
|
||||
>
|
||||
<VListItemTitle>{{ key }}</VListItemTitle>
|
||||
<div class="text-gray-500">
|
||||
{{ value }}
|
||||
</div>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardItem>
|
||||
</template>
|
||||
@@ -1,5 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { type PropType, ref } from 'vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
import { type PropType } from 'vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
|
||||
// 定议外部事件
|
||||
const emit = defineEmits(['action'])
|
||||
|
||||
// 组件接口
|
||||
interface RenderProps {
|
||||
@@ -7,7 +13,9 @@ interface RenderProps {
|
||||
text: string
|
||||
html: string
|
||||
content?: any
|
||||
slots?: any
|
||||
props?: any
|
||||
events?: any
|
||||
}
|
||||
|
||||
// 输入参数
|
||||
@@ -15,33 +23,78 @@ const elementProps = defineProps({
|
||||
config: Object as PropType<RenderProps>,
|
||||
})
|
||||
|
||||
// 配置元素
|
||||
const formItem = ref<RenderProps>(elementProps.config ?? {
|
||||
component: 'div',
|
||||
text: '',
|
||||
html: '',
|
||||
props: {},
|
||||
content: [],
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('正在处理...')
|
||||
|
||||
// 元素API事件响应
|
||||
async function commonAction(api_path: string, method: string, params = {}) {
|
||||
if (!api_path || !method) return
|
||||
progressDialog.value = true
|
||||
try {
|
||||
if (method.toUpperCase() === 'GET') {
|
||||
await api.get(api_path, {
|
||||
params: params,
|
||||
})
|
||||
} else {
|
||||
await api.post(api_path, params)
|
||||
}
|
||||
emit('action')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
progressDialog.value = false
|
||||
}
|
||||
|
||||
// 组装事件
|
||||
let componentEvents = reactive<{ [key: string]: any }>({})
|
||||
watchEffect(() => {
|
||||
if (!isNullOrEmptyObject(elementProps.config?.events)) {
|
||||
for (const key in elementProps.config?.events) {
|
||||
const attr = elementProps.config?.events[key]
|
||||
const func = async () => {
|
||||
await commonAction(attr['api'], attr['method'], attr['params'])
|
||||
}
|
||||
componentEvents[key] = func
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="formItem.component"
|
||||
v-if="!formItem.html"
|
||||
v-bind="formItem.props"
|
||||
:is="elementProps.config?.component"
|
||||
v-if="!elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
v-on="componentEvents"
|
||||
>
|
||||
{{ formItem.text }}
|
||||
{{ elementProps.config?.text }}
|
||||
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
|
||||
<slot :name="name" v-bind="_props">
|
||||
<PageRender
|
||||
v-for="(slotItem, slotIndex) in content || []"
|
||||
:key="slotIndex"
|
||||
:config="slotItem"
|
||||
@action="emit('action')"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<PageRender
|
||||
v-for="(innerItem, innerIndex) in (formItem.content || [])"
|
||||
v-for="(innerItem, innerIndex) in elementProps.config?.content || []"
|
||||
:key="innerIndex"
|
||||
:config="innerItem"
|
||||
@action="emit('action')"
|
||||
/>
|
||||
</Component>
|
||||
<Component
|
||||
:is="formItem.component"
|
||||
v-if="formItem.html"
|
||||
v-bind="formItem.props"
|
||||
v-html="formItem.html"
|
||||
:is="elementProps.config?.component"
|
||||
v-if="elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
v-html="elementProps.config?.html"
|
||||
v-on="componentEvents"
|
||||
/>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
linkurl: String,
|
||||
title: String,
|
||||
})
|
||||
|
||||
// 元素
|
||||
const slideview_content = ref()
|
||||
// 分页切换状态
|
||||
@@ -95,7 +89,7 @@ onActivated(() => {
|
||||
<template>
|
||||
<div class="flex justify-between mt-3">
|
||||
<slot name="title">
|
||||
<SlideViewTitle v-bind="props" />
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<div v-if="disabled !== 3" class="me-1 d-none d-md-flex">
|
||||
<VBtn
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
linkurl: String,
|
||||
title: String,
|
||||
})
|
||||
const props = inject('rankingPropsKey')
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
245
src/components/table/SiteTorrentTable.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useConfirm } from 'vuetify-use-dialog'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Number,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
{ title: '标题', key: 'title', sortable: false },
|
||||
{ title: '时间', key: 'pubdate', sortable: true },
|
||||
{ title: '大小', key: 'size', sortable: true },
|
||||
{ title: '做种', key: 'seeders', sortable: true },
|
||||
{ title: '下载', key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site}`)
|
||||
resourceLoading.value = false
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0)
|
||||
return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1)
|
||||
return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1)
|
||||
return 'text-white bg-sky-500'
|
||||
else
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_torrent: any) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: '确认',
|
||||
content: `是否确认下载【${_torrent.site_name}】${_torrent?.title} ?`,
|
||||
confirmationText: '确认',
|
||||
cancellationText: '取消',
|
||||
dialogProps: {
|
||||
maxWidth: '50rem',
|
||||
},
|
||||
confirmationButtonProps: {
|
||||
variant: 'tonal',
|
||||
},
|
||||
})
|
||||
|
||||
if (!isConfirmed)
|
||||
return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('download/add', _torrent)
|
||||
|
||||
if (result.success) {
|
||||
// 添加下载成功
|
||||
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
|
||||
}
|
||||
else {
|
||||
// 添加下载失败
|
||||
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDataTable
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
return-object
|
||||
fixed-header
|
||||
hover
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
loading-text="加载中..."
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<VChip
|
||||
v-if="item.hit_and_run"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1 text-white bg-black"
|
||||
>
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.freedate_diff"
|
||||
variant="elevated"
|
||||
color="secondary"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="
|
||||
getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)
|
||||
"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</a>
|
||||
</template>
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
{{ item.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.seeders }}</div>
|
||||
</template>
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.peers }}</div>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon
|
||||
icon="mdi-dots-vertical"
|
||||
/>
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-information" />
|
||||
</template>
|
||||
<VListItemTitle>查看详情</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="plain"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" />
|
||||
</template>
|
||||
<VListItemTitle>下载种子文件</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
<template #no-data>
|
||||
没有数据
|
||||
</template>
|
||||
</VDataTable>
|
||||
</template>
|
||||
@@ -44,7 +44,7 @@ const superUser = store.state.auth.superUser
|
||||
</IconBtn>
|
||||
|
||||
<!-- 👉 Shortcuts -->
|
||||
<ShortcutBar />
|
||||
<ShortcutBar v-if="superUser" />
|
||||
|
||||
<!-- 👉 Theme -->
|
||||
<NavbarThemeSwitcher class="me-2" />
|
||||
|
||||
@@ -11,20 +11,28 @@ const searchDialog = ref(false)
|
||||
// ref
|
||||
const searchWordInput = ref<HTMLElement | null>(null)
|
||||
|
||||
// 当前的搜索类型 media/person
|
||||
const searchType = ref('media')
|
||||
|
||||
// Search
|
||||
function search() {
|
||||
if (!searchWord.value)
|
||||
return
|
||||
if (!searchWord.value) return
|
||||
|
||||
searchDialog.value = false
|
||||
router.push({
|
||||
path: '/browse/media/search',
|
||||
query: {
|
||||
title: searchWord.value,
|
||||
type: searchType.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 切换搜索类型
|
||||
function switchSearchType() {
|
||||
searchType.value = searchType.value === 'media' ? 'person' : 'media'
|
||||
}
|
||||
|
||||
// 打开搜索弹窗
|
||||
function openSearchDialog() {
|
||||
searchDialog.value = true
|
||||
@@ -36,15 +44,8 @@ function openSearchDialog() {
|
||||
|
||||
<template>
|
||||
<!-- 👉 Search Button -->
|
||||
<div
|
||||
class="d-flex align-center cursor-pointer"
|
||||
style="user-select: none;"
|
||||
>
|
||||
<VDialog
|
||||
v-model="searchDialog"
|
||||
max-width="50rem"
|
||||
transition="dialog-top-transition"
|
||||
>
|
||||
<div class="d-flex align-center cursor-pointer" style="user-select: none">
|
||||
<VDialog v-model="searchDialog" max-width="50rem" transition="dialog-top-transition">
|
||||
<!-- Dialog Content -->
|
||||
<VCard title="搜索">
|
||||
<VCardText>
|
||||
@@ -53,8 +54,10 @@ function openSearchDialog() {
|
||||
<VTextField
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
label="电影、电视剧名称"
|
||||
:prepend-inner-icon="searchType == 'person' ? 'mdi-account' : 'mdi-movie'"
|
||||
:label="searchType == 'person' ? '搜索演员' : '搜索电影、电视剧'"
|
||||
@keydown.enter="search"
|
||||
@click:prepend-inner="switchSearchType"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -62,21 +65,13 @@ function openSearchDialog() {
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="search"
|
||||
>
|
||||
搜索
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="search"> 搜索 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
<!-- 👉 Search Icon -->
|
||||
<IconBtn
|
||||
class="d-lg-none"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<IconBtn class="d-lg-none" @click="openSearchDialog">
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Search Textfield -->
|
||||
@@ -87,20 +82,22 @@ function openSearchDialog() {
|
||||
class="d-none d-lg-block text-disabled search-box"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
label="搜索电影、电视剧"
|
||||
:prepend-inner-icon="searchType == 'person' ? 'mdi-account' : 'mdi-movie'"
|
||||
:label="searchType == 'person' ? '搜索演员' : '搜索电影、电视剧'"
|
||||
append-inner-icon="mdi-magnify"
|
||||
single-line
|
||||
hide-details
|
||||
flat
|
||||
rounded
|
||||
@click:append-inner="search"
|
||||
@click:prepend-inner="switchSearchType"
|
||||
@keydown.enter="search"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.search-box div.v-input__control div[role="textbox"] {
|
||||
.search-box div.v-input__control div[role='textbox'] {
|
||||
border: 1px solid rgb(var(--v-theme-background));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,13 @@ import NetTestView from '@/views/system/NetTestView.vue'
|
||||
import LoggingView from '@/views/system/LoggingView.vue'
|
||||
import RuleTestView from '@/views/system/RuleTestView.vue'
|
||||
import ModuleTestView from '@/views/system/ModuleTestView.vue'
|
||||
import MessageView from '@/views/system/MessageView.vue'
|
||||
import store from '@/store'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// App捷径
|
||||
const appsMenu = ref(false)
|
||||
@@ -24,11 +30,52 @@ const ruleTestDialog = ref(false)
|
||||
// 系统健康检查弹窗
|
||||
const systemTestDialog = ref(false)
|
||||
|
||||
// 消息中心弹窗
|
||||
const messageDialog = ref(false)
|
||||
|
||||
// 输入消息
|
||||
const user_message = ref('')
|
||||
|
||||
// 发送按钮是否可用
|
||||
const sendButtonDisabled = ref(false)
|
||||
|
||||
// 聊天容器
|
||||
const chatContainer = ref<HTMLDivElement>()
|
||||
|
||||
// 滚动到底部
|
||||
function scrollMessageToEnd() {
|
||||
nextTick(() => {
|
||||
if (chatContainer.value) {
|
||||
const scrollDiv = chatContainer.value.$el
|
||||
scrollDiv.scrollTop = scrollDiv.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 拼接全部日志url
|
||||
function allLoggingUrl() {
|
||||
const token = store.state.auth.token
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1`
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
async function sendMessage() {
|
||||
if (user_message.value) {
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${user_message.value}`)
|
||||
user_message.value = ''
|
||||
sendButtonDisabled.value = false
|
||||
scrollMessageToEnd()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollMessageToEnd()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,10 +91,7 @@ function allLoggingUrl() {
|
||||
>
|
||||
<!-- Menu Activator -->
|
||||
<template #activator="{ props }">
|
||||
<IconBtn
|
||||
class="me-2"
|
||||
v-bind="props"
|
||||
>
|
||||
<IconBtn class="me-2" v-bind="props">
|
||||
<VIcon icon="mdi-checkbox-multiple-blank-outline" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
@@ -63,112 +107,62 @@ function allLoggingUrl() {
|
||||
</VCardItem>
|
||||
<div class="ps ps--active-y">
|
||||
<VRow class="ma-0 mt-n1">
|
||||
<VCol
|
||||
cols="6"
|
||||
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
|
||||
>
|
||||
<VListItem
|
||||
class="pa-4"
|
||||
@click="nameTestDialog = true"
|
||||
>
|
||||
<VAvatar
|
||||
size="48"
|
||||
variant="tonal"
|
||||
>
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e">
|
||||
<VListItem class="pa-4" @click="nameTestDialog = true">
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
识别
|
||||
</h6>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">识别</h6>
|
||||
<span class="text-sm">名称识别测试</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
|
||||
@click="() => {}"
|
||||
>
|
||||
<VListItem
|
||||
class="pa-4"
|
||||
@click="ruleTestDialog = true"
|
||||
>
|
||||
<VAvatar
|
||||
size="48"
|
||||
variant="tonal"
|
||||
>
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
|
||||
<VListItem class="pa-4" @click="ruleTestDialog = true">
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-filter-cog-outline" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
优先级
|
||||
</h6>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">优先级</h6>
|
||||
<span class="text-sm">优先级规则测试</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="ma-0 mt-n1 border-t">
|
||||
<VCol
|
||||
cols="6"
|
||||
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
|
||||
@click="() => {}"
|
||||
>
|
||||
<VListItem
|
||||
class="pa-4"
|
||||
@click="loggingDialog = true"
|
||||
>
|
||||
<VAvatar
|
||||
size="48"
|
||||
variant="tonal"
|
||||
>
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
|
||||
<VListItem class="pa-4" @click="loggingDialog = true">
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-file-document-outline" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
日志
|
||||
</h6>
|
||||
<span class="text-sm">查看实时日志</span>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">日志</h6>
|
||||
<span class="text-sm">实时日志</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
class="text-center cursor-pointer pa-0 shortcut-icon"
|
||||
@click="() => {}"
|
||||
>
|
||||
<VListItem
|
||||
class="pa-4"
|
||||
@click="netTestDialog = true"
|
||||
>
|
||||
<VAvatar
|
||||
size="48"
|
||||
variant="tonal"
|
||||
>
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon" @click="() => {}">
|
||||
<VListItem class="pa-4" @click="netTestDialog = true">
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-network-outline" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
网络
|
||||
</h6>
|
||||
<span class="text-sm">测试网速连通性</span>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">网络</h6>
|
||||
<span class="text-sm">网速连通性测试</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="ma-0 mt-n1 border-t">
|
||||
<VCol
|
||||
cols="6"
|
||||
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
|
||||
@click="() => {}"
|
||||
>
|
||||
<VListItem
|
||||
class="pa-4"
|
||||
@click="systemTestDialog = true"
|
||||
>
|
||||
<VAvatar
|
||||
size="48"
|
||||
variant="tonal"
|
||||
>
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
|
||||
<VListItem class="pa-4" @click="systemTestDialog = true">
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-cog-outline" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">
|
||||
系统
|
||||
</h6>
|
||||
<span class="text-sm">系统健康检查</span>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">系统</h6>
|
||||
<span class="text-sm">健康检查</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
<VCol cols="6" class="text-center cursor-pointer pa-0 shortcut-icon border-e" @click="() => {}">
|
||||
<VListItem class="pa-4" @click="messageDialog = true">
|
||||
<VAvatar size="48" variant="tonal">
|
||||
<VIcon icon="mdi-message-outline" />
|
||||
</VAvatar>
|
||||
<h6 class="text-base font-weight-medium mt-2 mb-0">消息</h6>
|
||||
<span class="text-sm">消息中心</span>
|
||||
</VListItem>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -176,34 +170,30 @@ function allLoggingUrl() {
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<!-- 名称测试弹窗 -->
|
||||
<VDialog
|
||||
v-model="nameTestDialog"
|
||||
max-width="50rem"
|
||||
>
|
||||
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" max-width="50rem" scrollable>
|
||||
<VCard title="名称识别测试">
|
||||
<DialogCloseBtn @click="nameTestDialog = false" />
|
||||
<VCardItem>
|
||||
<VCardText>
|
||||
<NameTestView />
|
||||
</VCardItem>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 网络测试弹窗 -->
|
||||
<VDialog
|
||||
v-model="netTestDialog"
|
||||
max-width="35rem"
|
||||
>
|
||||
<VDialog v-if="netTestDialog" v-model="netTestDialog" max-width="35rem" max-height="85vh" scrollable>
|
||||
<VCard title="网络测试">
|
||||
<DialogCloseBtn @click="netTestDialog = false" />
|
||||
<VCardItem>
|
||||
<VCardText>
|
||||
<NetTestView />
|
||||
</VCardItem>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
class="w-full lg:w-4/5"
|
||||
scrollable
|
||||
max-width="70rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="loggingDialog = false" />
|
||||
@@ -211,7 +201,9 @@ function allLoggingUrl() {
|
||||
<VCardTitle class="inline-flex">
|
||||
实时日志
|
||||
<a class="mx-2 inline-flex items-center justify-center" :href="allLoggingUrl()" target="_blank">
|
||||
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700">
|
||||
<div
|
||||
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
|
||||
>
|
||||
<VIcon icon="mdi-open-in-new" />
|
||||
<span class="ms-1">在新窗口中打开</span>
|
||||
</div>
|
||||
@@ -224,11 +216,7 @@ function allLoggingUrl() {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 规则测试弹窗 -->
|
||||
<VDialog
|
||||
v-model="ruleTestDialog"
|
||||
max-width="50rem"
|
||||
scrollable
|
||||
>
|
||||
<VDialog v-if="ruleTestDialog" v-model="ruleTestDialog" max-width="50rem" scrollable>
|
||||
<VCard title="优先级测试">
|
||||
<DialogCloseBtn @click="ruleTestDialog = false" />
|
||||
<VCardText>
|
||||
@@ -237,11 +225,7 @@ function allLoggingUrl() {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 系统健康检查弹窗 -->
|
||||
<VDialog
|
||||
v-model="systemTestDialog"
|
||||
max-width="50rem"
|
||||
scrollable
|
||||
>
|
||||
<VDialog v-if="systemTestDialog" v-model="systemTestDialog" max-width="35rem" max-height="85vh" scrollable>
|
||||
<VCard title="系统健康检查">
|
||||
<DialogCloseBtn @click="systemTestDialog = false" />
|
||||
<VCardText>
|
||||
@@ -249,4 +233,37 @@ function allLoggingUrl() {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 消息中心弹窗 -->
|
||||
<VDialog
|
||||
v-if="messageDialog"
|
||||
v-model="messageDialog"
|
||||
max-width="60rem"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard title="消息中心">
|
||||
<DialogCloseBtn @click="messageDialog = false" />
|
||||
<VCardText ref="chatContainer">
|
||||
<MessageView @scroll="scrollMessageToEnd" />
|
||||
</VCardText>
|
||||
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
v-model="user_message"
|
||||
placeholder="输入消息或命令"
|
||||
outlined
|
||||
hide-details
|
||||
single-line
|
||||
clearable
|
||||
density="compact"
|
||||
:disabled="sendButtonDisabled"
|
||||
@keydown.enter="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<VBtn color="primary" :disabled="sendButtonDisabled" @click="sendMessage"> 发送 </VBtn>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToast } from 'vue-toast-notification'
|
||||
import router from '@/router'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import api from '@/api'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
|
||||
// Vuex Store
|
||||
const store = useStore()
|
||||
@@ -56,8 +57,7 @@ async function restart() {
|
||||
$toast.error(result.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
// 注销
|
||||
@@ -72,53 +72,33 @@ const avatar = store.state.auth.avatar
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VAvatar
|
||||
class="cursor-pointer"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
<VAvatar class="cursor-pointer" color="primary" variant="tonal">
|
||||
<VImg :src="avatar ?? avatar1" />
|
||||
|
||||
<!-- SECTION Menu -->
|
||||
<VMenu
|
||||
activator="parent"
|
||||
width="230"
|
||||
location="bottom end"
|
||||
offset="14px"
|
||||
>
|
||||
<VMenu activator="parent" width="230" location="bottom end" offset="14px">
|
||||
<VList>
|
||||
<!-- 👉 User Avatar & Name -->
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VListItemAction start>
|
||||
<VAvatar
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
<VAvatar color="primary" variant="tonal">
|
||||
<VImg :src="avatar ?? avatar1" />
|
||||
</VAvatar>
|
||||
</VListItemAction>
|
||||
</template>
|
||||
|
||||
<VListItemTitle class="font-weight-semibold">
|
||||
{{ superUser ? "管理员" : "普通用户" }}
|
||||
{{ superUser ? '管理员' : '普通用户' }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- 👉 Profile -->
|
||||
<VListItem
|
||||
v-if="superUser"
|
||||
link
|
||||
to="setting"
|
||||
>
|
||||
<VListItem v-if="superUser" link to="setting">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
class="me-2"
|
||||
icon="mdi-account-outline"
|
||||
size="22"
|
||||
/>
|
||||
<VIcon class="me-2" icon="mdi-account-outline" size="22" />
|
||||
</template>
|
||||
|
||||
<VListItemTitle>设定</VListItemTitle>
|
||||
@@ -128,32 +108,18 @@ const avatar = store.state.auth.avatar
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- 👉 restart -->
|
||||
<VListItem
|
||||
v-if="superUser"
|
||||
@click="restart"
|
||||
>
|
||||
<VListItem v-if="superUser" @click="restart">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
class="me-2"
|
||||
icon="mdi-restart"
|
||||
size="22"
|
||||
/>
|
||||
<VIcon class="me-2" icon="mdi-restart" size="22" />
|
||||
</template>
|
||||
|
||||
<VListItemTitle>重启</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<!-- 👉 FAQ -->
|
||||
<VListItem
|
||||
href="https://github.com/jxxghp/MoviePilot/blob/main/README.md"
|
||||
target="_blank"
|
||||
>
|
||||
<VListItem href="https://github.com/jxxghp/MoviePilot/blob/main/README.md" target="_blank">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
class="me-2"
|
||||
icon="mdi-help-circle-outline"
|
||||
size="22"
|
||||
/>
|
||||
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
|
||||
</template>
|
||||
|
||||
<VListItemTitle>帮助</VListItemTitle>
|
||||
@@ -162,11 +128,7 @@ const avatar = store.state.auth.avatar
|
||||
<!-- 👉 Logout -->
|
||||
<VListItem @click="logout">
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
class="me-2"
|
||||
icon="mdi-logout"
|
||||
size="22"
|
||||
/>
|
||||
<VIcon class="me-2" icon="mdi-logout" size="22" />
|
||||
</template>
|
||||
|
||||
<VListItemTitle>注销</VListItemTitle>
|
||||
@@ -176,21 +138,5 @@ const avatar = store.state.auth.avatar
|
||||
<!-- !SECTION -->
|
||||
</VAvatar>
|
||||
<!-- 重启进度框 -->
|
||||
<VDialog
|
||||
v-model="progressDialog"
|
||||
width="25rem"
|
||||
>
|
||||
<VCard
|
||||
color="primary"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
正在重启 ...
|
||||
<VProgressLinear
|
||||
indeterminate
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在重启 ..." />
|
||||
</template>
|
||||
|
||||
10
src/main.ts
@@ -15,7 +15,15 @@ import '@core/scss/template/index.scss'
|
||||
import '@layouts/styles/index.scss'
|
||||
import '@styles/styles.scss'
|
||||
import 'vue-toast-notification/dist/theme-bootstrap.css'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar';
|
||||
import 'vue3-perfect-scrollbar/style.css';
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import { fixArrayAt } from '@/@core/utils/compatibility'
|
||||
|
||||
// 修复低版本Safari等浏览器数组不支持at函数的问题
|
||||
fixArrayAt()
|
||||
|
||||
// 加载字体
|
||||
loadFonts()
|
||||
|
||||
// 创建Vue实例
|
||||
@@ -24,6 +32,7 @@ const app = createApp(App)
|
||||
// 注册全局组件
|
||||
app.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.component('VDialogCloseBtn', DialogCloseBtn)
|
||||
|
||||
// 注册插件
|
||||
app
|
||||
@@ -34,5 +43,6 @@ app
|
||||
position: 'bottom-right',
|
||||
})
|
||||
.use(VuetifyUseDialog)
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.mount('#app')
|
||||
.$nextTick(() => removeEl('#loading-bg'))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import MediaCardListView from '@/views/discover/MediaCardListView.vue'
|
||||
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -11,14 +13,16 @@ const props = defineProps({
|
||||
const route = useRoute()
|
||||
|
||||
// 标题
|
||||
const title = route.query?.title?.toString()
|
||||
let title = route.query?.title?.toString()
|
||||
|
||||
// 类型
|
||||
const type = route.query?.type?.toString()
|
||||
if (type === 'person') title = '演员:' + title
|
||||
|
||||
// 计算API路径
|
||||
function getApiPath(paths: string[] | string) {
|
||||
if (Array.isArray(paths))
|
||||
return paths.join('/')
|
||||
else
|
||||
return paths
|
||||
if (Array.isArray(paths)) return paths.join('/')
|
||||
else return paths
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -26,14 +30,15 @@ function getApiPath(paths: string[] | string) {
|
||||
<div>
|
||||
<div v-if="title" class="mt-3 md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1 mx-0">
|
||||
<h2 class="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0" data-testid="page-header">
|
||||
<h2
|
||||
class="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
|
||||
data-testid="page-header"
|
||||
>
|
||||
<span class="text-moviepilot">{{ title }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MediaCardListView
|
||||
:apipath="getApiPath(props.paths || '')"
|
||||
:params="route.query"
|
||||
/>
|
||||
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" />
|
||||
<MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,12 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
|
||||
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
|
||||
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
||||
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
||||
import api from '@/api'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 仪表盘配置
|
||||
const dashboard_names = {
|
||||
@@ -40,139 +46,86 @@ const default_config = {
|
||||
playing: true,
|
||||
latest: true,
|
||||
}
|
||||
|
||||
// 初始化默认值
|
||||
const config = ref(JSON.parse(localStorage.getItem('MP_DASHBOARD') || '{}'))
|
||||
if (Object.keys(config.value).length === 0) {
|
||||
if (isNullOrEmptyObject(config.value)) {
|
||||
config.value = default_config
|
||||
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value))
|
||||
}
|
||||
|
||||
// 设置项目
|
||||
function setDashboardConfig() {
|
||||
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value))
|
||||
const data = JSON.stringify(config.value)
|
||||
localStorage.setItem('MP_DASHBOARD', data)
|
||||
// 保存到服务端
|
||||
api.post('/user/config/Dashboard', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
dialog.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 底部操作按钮 -->
|
||||
<VFab icon="mdi-view-dashboard-edit" location="bottom end" size="x-large" fixed app appear @click="dialog = true" />
|
||||
<VRow class="match-height">
|
||||
<VCol
|
||||
v-if="config.storage"
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol v-if="config.storage" cols="12" md="4">
|
||||
<AnalyticsStorage />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.mediaStatistic"
|
||||
cols="12"
|
||||
md="8"
|
||||
>
|
||||
<VCol v-if="config.mediaStatistic" cols="12" md="8">
|
||||
<AnalyticsMediaStatistic />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.weeklyOverview"
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol v-if="config.weeklyOverview" cols="12" md="4">
|
||||
<AnalyticsWeeklyOverview />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.speed"
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol v-if="config.speed" cols="12" md="4">
|
||||
<AnalyticsSpeed />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.scheduler"
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCol v-if="config.scheduler" cols="12" md="4">
|
||||
<AnalyticsScheduler />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.cpu"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VCol v-if="config.cpu" cols="12" md="6">
|
||||
<AnalyticsCpu />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.memory"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VCol v-if="config.memory" cols="12" md="6">
|
||||
<AnalyticsMemory />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.library"
|
||||
cols="12"
|
||||
>
|
||||
<VCol v-if="config.library" cols="12">
|
||||
<MediaServerLibrary />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.playing"
|
||||
cols="12"
|
||||
>
|
||||
<VCol v-if="config.playing" cols="12">
|
||||
<MediaServerPlaying />
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="config.latest"
|
||||
cols="12"
|
||||
>
|
||||
<VCol v-if="config.latest" cols="12">
|
||||
<MediaServerLatest />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<!-- 底部操作按钮 -->
|
||||
<span class="fixed right-5 bottom-5">
|
||||
<VBtn icon="mdi-view-dashboard-edit" class="me-2" color="primary" size="x-large" @click="dialog = true" />
|
||||
</span>
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
<VDialog
|
||||
v-model="dialog"
|
||||
max-width="600"
|
||||
scrollable
|
||||
>
|
||||
<VDialog v-model="dialog" max-width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard title="设置仪表板">
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol
|
||||
v-for="(item, key) in dashboard_names"
|
||||
:key="key"
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<VCheckbox
|
||||
v-model="config[key]"
|
||||
:label="dashboard_names[key]"
|
||||
/>
|
||||
<VCol v-for="(item, key) in dashboard_names" :key="key" cols="12" md="4" sm="4">
|
||||
<VCheckbox v-model="config[key]" :label="dashboard_names[key]" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="dialog = false"
|
||||
>
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn color="primary" @click="dialog = false"> 取消 </VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="setDashboardConfig"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
<VBtn color="primary" variant="tonal" @click="setDashboardConfig"> 保存 </VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</vdialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash'
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useStore } from 'vuex'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import router from '@/router'
|
||||
import logo from '@images/logo.png'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
|
||||
const { global: globalTheme } = useTheme()
|
||||
|
||||
// Vuex Store
|
||||
const store = useStore()
|
||||
@@ -13,6 +18,7 @@ const store = useStore()
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
otp_password: '',
|
||||
remember: true,
|
||||
})
|
||||
|
||||
@@ -30,6 +36,12 @@ const backgroundImageUrl = ref('')
|
||||
// 背景图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 是否开启双重验证
|
||||
const isOTP = ref(false)
|
||||
|
||||
// 用户名称输入框
|
||||
const usernameInput = ref()
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImage() {
|
||||
api
|
||||
@@ -41,20 +53,85 @@ async function fetchBackgroundImage() {
|
||||
console.log(error)
|
||||
})
|
||||
}
|
||||
// 查询是否开启双重验证
|
||||
const fetchOTP = debounce(async () => {
|
||||
const userid = usernameInput.value?.value
|
||||
if (!userid) {
|
||||
isOTP.value = false
|
||||
return
|
||||
}
|
||||
api
|
||||
.get(`/user/otp/${userid}`)
|
||||
.then((response: any) => {
|
||||
isOTP.value = response.success
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.log(error)
|
||||
})
|
||||
}, 500)
|
||||
|
||||
// 加载用户监控面板配置
|
||||
async function loadDashboardConfig() {
|
||||
const response = await api.get('/user/config/Dashboard')
|
||||
if (response && response.data && response.data.value) {
|
||||
const data = JSON.stringify(response.data.value)
|
||||
if (data != localStorage.getItem('MP_DASHBOARD')) {
|
||||
localStorage.setItem('MP_DASHBOARD', data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试加载用户监控面板配置(本地无配置时才加载)
|
||||
async function tryLoadDashboardConfig() {
|
||||
if (localStorage.getItem('MP_DASHBOARD')) {
|
||||
return
|
||||
}
|
||||
await loadDashboardConfig()
|
||||
}
|
||||
|
||||
// 获取用户主题配置
|
||||
async function fetchThemeConfig() {
|
||||
const response = await api.get('/user/config/theme')
|
||||
if (response && response.data && response.data.value) {
|
||||
return response.data.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 生效主题
|
||||
async function setTheme() {
|
||||
let themeValue = (await fetchThemeConfig()) || localStorage.getItem('theme') || 'light'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
// 存储主题到本地
|
||||
localStorage.setItem('theme', themeValue)
|
||||
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
|
||||
}
|
||||
|
||||
async function afterLogin() {
|
||||
// 生效主题配置
|
||||
await setTheme()
|
||||
// 尝试加载用户监控面板配置(本地无配置时才加载)
|
||||
await tryLoadDashboardConfig()
|
||||
// 跳转到首页或回原始页面
|
||||
router.push(store.state.auth.originalPath ?? '/')
|
||||
}
|
||||
|
||||
// 登录获取token事件
|
||||
function login() {
|
||||
errorMessage.value = ''
|
||||
|
||||
// 进行表单校验
|
||||
if (!form.value.username || !form.value.password)
|
||||
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
|
||||
errorMessage.value = '请输入完整信息'
|
||||
return
|
||||
|
||||
}
|
||||
// 用户名密码
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('username', form.value.username)
|
||||
formData.append('password', form.value.password)
|
||||
formData.append('otp_password', form.value.otp_password)
|
||||
|
||||
// 请求token
|
||||
api
|
||||
@@ -77,21 +154,16 @@ function login() {
|
||||
store.dispatch('auth/updateUserName', username)
|
||||
store.dispatch('auth/updateAvatar', avatar)
|
||||
|
||||
// 跳转到首页或回原始页面
|
||||
router.push(store.state.auth.originalPath ?? '/')
|
||||
// 登录后处理
|
||||
afterLogin()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// 登录失败,显示错误提示
|
||||
if (!error.response)
|
||||
errorMessage.value = '登录失败,请检查网络连接'
|
||||
else if (error.response.status === 401)
|
||||
errorMessage.value = '登录失败,请检查用户名和密码是否正确'
|
||||
else if (error.response.status === 403)
|
||||
errorMessage.value = '登录失败,您没有权限访问'
|
||||
else if (error.response.status === 500)
|
||||
errorMessage.value = '登录失败,服务器错误'
|
||||
else
|
||||
errorMessage.value = `登录失败 ${error.response.status},请检查用户名和密码是否正确`
|
||||
if (!error.response) errorMessage.value = '登录失败,请检查网络连接'
|
||||
else if (error.response.status === 401) errorMessage.value = '登录失败,请检查用户名、密码或双重验证是否正确'
|
||||
else if (error.response.status === 403) errorMessage.value = '登录失败,您没有权限访问'
|
||||
else if (error.response.status === 500) errorMessage.value = '登录失败,服务器错误'
|
||||
else errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或双重验证码是否正确`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,8 +176,7 @@ onMounted(() => {
|
||||
// 如果token存在,且保持登录状态为true,则跳转到首页
|
||||
if (token && remember) {
|
||||
router.push('/')
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// 获取背景图片
|
||||
fetchBackgroundImage()
|
||||
}
|
||||
@@ -134,64 +205,47 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VCardTitle class="font-weight-semibold text-2xl text-uppercase">
|
||||
MoviePilot
|
||||
</VCardTitle>
|
||||
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VForm
|
||||
ref="refForm"
|
||||
@submit.prevent="() => {}"
|
||||
>
|
||||
<VForm ref="refForm" @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<!-- username -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
ref="usernameInput"
|
||||
v-model="form.username"
|
||||
label="用户名"
|
||||
type="text"
|
||||
:rules="[requiredValidator]"
|
||||
@input="fetchOTP"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- password -->
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="form.password"
|
||||
label="密码"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
||||
"
|
||||
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
:rules="[requiredValidator]"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="text-error mt-1"
|
||||
>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
|
||||
<!-- remember me checkbox -->
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<VCheckbox v-model="form.remember" label="保持登录" required />
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<!-- login button -->
|
||||
<VBtn block type="submit" @click="login"> 登录 </VBtn>
|
||||
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- remember me checkbox -->
|
||||
<div class="d-flex align-center justify-space-between flex-wrap mt-1 mb-4">
|
||||
<VCheckbox
|
||||
v-model="form.remember"
|
||||
label="保持登录"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- login button -->
|
||||
<VBtn
|
||||
block
|
||||
type="submit"
|
||||
@click="login"
|
||||
>
|
||||
登录
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
@@ -202,7 +256,7 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@core/scss/pages/page-auth.scss";
|
||||
@use '@core/scss/pages/page-auth.scss';
|
||||
|
||||
.v-card-item__prepend {
|
||||
padding-inline-end: 0 !important;
|
||||
|
||||
@@ -7,15 +7,15 @@ const route = useRoute()
|
||||
// Person Id
|
||||
const personid = route.query?.personid?.toString()
|
||||
|
||||
// 来源
|
||||
const source = route.query?.source?.toString()
|
||||
|
||||
// 类型
|
||||
const type = route.query?.type?.toString()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PersonDetailView
|
||||
:personid="personid"
|
||||
:type="type"
|
||||
/>
|
||||
<PersonDetailView :personid="personid" :type="type" :source="source" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,79 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||
|
||||
const viewList = reactive<{apipath: string, linkurl: string, title: string}[]>([
|
||||
{
|
||||
apipath: 'tmdb/trending',
|
||||
linkurl: "/browse/tmdb/trending?title=流行趋势",
|
||||
title: "流行趋势",
|
||||
},
|
||||
{
|
||||
apipath: "douban/showing",
|
||||
linkurl: "/browse/douban/showing?title=正在热映",
|
||||
title: "正在热映"
|
||||
},
|
||||
{
|
||||
apipath: "bangumi/calendar",
|
||||
linkurl: "/browse/bangumi/calendar?title=Bangumi每日放送",
|
||||
title: "Bangumi每日放送"
|
||||
},
|
||||
{
|
||||
apipath: "tmdb/movies",
|
||||
linkurl: "/browse/tmdb/movies?title=热门电影",
|
||||
title: "热门电影"
|
||||
},
|
||||
{
|
||||
apipath: "tmdb/tvs?with_original_language=zh|en|ja|ko",
|
||||
linkurl: "/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=热门电视剧",
|
||||
title: "热门电视剧"
|
||||
},
|
||||
{
|
||||
apipath: "douban/movie_hot",
|
||||
linkurl: "/browse/douban/movie_hot?title=热门电影",
|
||||
title: "热门电影"
|
||||
},
|
||||
{
|
||||
apipath: "douban/tv_hot",
|
||||
linkurl: "/browse/douban/tv_hot?title=热门电视剧",
|
||||
title: "热门电视剧"
|
||||
},
|
||||
{
|
||||
apipath: "douban/tv_animation",
|
||||
linkurl: "/browse/douban/tv_animation?title=热门动漫",
|
||||
title: "热门动漫"
|
||||
},
|
||||
{
|
||||
apipath: "douban/movies",
|
||||
linkurl: "/browse/douban/movies?title=最新电影",
|
||||
title: "最新电影"
|
||||
},
|
||||
{
|
||||
apipath: "douban/tvs",
|
||||
linkurl: "/browse/douban/tvs?title=最新电视剧",
|
||||
title: "最新电视剧"
|
||||
},
|
||||
{
|
||||
apipath: "douban/movie_top250",
|
||||
linkurl: "/browse/douban/movie_top250?title=电影TOP250",
|
||||
title: "电影TOP250"
|
||||
},
|
||||
{
|
||||
apipath: "douban/tv_weekly_chinese",
|
||||
linkurl: "/browse/douban/tv_weekly_chinese?title=国产剧集榜",
|
||||
title: "国产剧集榜"
|
||||
},
|
||||
{
|
||||
apipath: "douban/tv_weekly_global",
|
||||
linkurl: "/browse/douban/tv_weekly_global?title=全球剧集榜",
|
||||
title: "全球剧集榜"
|
||||
}
|
||||
])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MediaCardSlideView
|
||||
apipath="tmdb/trending"
|
||||
linkurl="/browse/tmdb/trending?title=流行趋势"
|
||||
title="流行趋势"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/showing"
|
||||
linkurl="/browse/douban/showing?title=正在热映"
|
||||
title="正在热映"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="tmdb/movies"
|
||||
linkurl="/browse/tmdb/movies?title=热门电影"
|
||||
title="热门电影"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="tmdb/tvs?with_original_language=zh|en|ja|ko"
|
||||
linkurl="/browse/tmdb/tvs??with_original_language=zh|en|ja|ko&title=热门电视剧"
|
||||
title="热门电视剧"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/movie_hot"
|
||||
linkurl="/browse/douban/movie_hot?title=热门电影"
|
||||
title="热门电影"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/tv_hot"
|
||||
linkurl="/browse/douban/tv_hot?title=热门电视剧"
|
||||
title="热门电视剧"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/tv_animation"
|
||||
linkurl="/browse/douban/tv_animation?title=热门动漫"
|
||||
title="热门动漫"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/movies"
|
||||
linkurl="/browse/douban/movies?title=最新电影"
|
||||
title="最新电影"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/tvs"
|
||||
linkurl="/browse/douban/tvs?title=最新电视剧"
|
||||
title="最新电视剧"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/movie_top250"
|
||||
linkurl="/browse/douban/movie_top250?title=电影TOP250"
|
||||
title="电影TOP250"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/tv_weekly_chinese"
|
||||
linkurl="/browse/douban/tv_weekly_chinese?title=国产剧集榜"
|
||||
title="国产剧集榜"
|
||||
/>
|
||||
|
||||
<MediaCardSlideView
|
||||
apipath="douban/tv_weekly_global"
|
||||
linkurl="/browse/douban/tv_weekly_global?title=全球剧集榜"
|
||||
title="全球剧集榜"
|
||||
<MediaCardSlideView
|
||||
v-for="item in viewList"
|
||||
:key="item.apipath"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,9 @@ const type = route.query?.type?.toString() ?? ''
|
||||
// 搜索字段
|
||||
const area = route.query?.area?.toString() ?? ''
|
||||
|
||||
// 搜索季
|
||||
const season = route.query?.season?.toString() ?? ''
|
||||
|
||||
// 视图类型,从localStorage中读取
|
||||
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
|
||||
|
||||
@@ -36,6 +39,12 @@ const progressValue = ref(0)
|
||||
// 加载进度SSE
|
||||
const progressEventSource = ref<EventSource>()
|
||||
|
||||
// 错误标题
|
||||
const errorTitle = ref('没有数据')
|
||||
|
||||
// 错误描述
|
||||
const errorDescription = ref('未搜索到任何资源')
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
progressText.value = '正在搜索,请稍候...'
|
||||
@@ -75,13 +84,19 @@ async function fetchData() {
|
||||
else {
|
||||
startLoadingProgress()
|
||||
// 优先按TMDBID精确查询
|
||||
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:')) {
|
||||
dataList.value = await api.get(`search/media/${keyword}`, {
|
||||
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
|
||||
const result: {[key: string]: any} = await api.get(`search/media/${keyword}`, {
|
||||
params: {
|
||||
mtype: type,
|
||||
area,
|
||||
season,
|
||||
},
|
||||
})
|
||||
if (result.success){
|
||||
dataList.value = result.data
|
||||
} else {
|
||||
errorDescription.value = result.message
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 按标题模糊查询
|
||||
@@ -105,16 +120,16 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!isRefreshed" class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center">
|
||||
<VProgressCircular v-if="!keyword" size="48" indeterminate color="primary" />
|
||||
<VProgressCircular v-if="keyword" class="mb-3" color="primary" :model-value="progressValue" size="64" />
|
||||
<span>{{ progressText }}</span>
|
||||
</div>
|
||||
<LoadingBanner
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12"
|
||||
:text="progressText"
|
||||
:progress="progressValue"
|
||||
/>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
error-title="没有资源"
|
||||
error-description="没有搜索到符合条件的资源。"
|
||||
:error-title="errorTitle"
|
||||
:error-description="errorDescription"
|
||||
/>
|
||||
<div v-if="dataList.length > 0">
|
||||
<TorrentRowListView
|
||||
@@ -127,20 +142,24 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<!-- 视图切换 -->
|
||||
<span v-if="dataList.length > 0" class="fixed right-5 bottom-5">
|
||||
<VBtn
|
||||
v-if="viewType === 'list'"
|
||||
size="x-large"
|
||||
icon="mdi-view-grid"
|
||||
color="primary"
|
||||
@click="setViewType('card')"
|
||||
/>
|
||||
<VBtn
|
||||
v-else
|
||||
size="x-large"
|
||||
icon="mdi-view-list"
|
||||
color="primary"
|
||||
@click="setViewType('list')"
|
||||
/>
|
||||
</span>
|
||||
<VFab
|
||||
v-if="viewType === 'list'"
|
||||
icon="mdi-view-grid"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="setViewType('card')"
|
||||
/>
|
||||
<VFab
|
||||
v-else
|
||||
icon="mdi-view-list"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="setViewType('list')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -5,39 +5,37 @@
|
||||
|
||||
#nprogress .bar {
|
||||
background: rgb(var(--v-theme-primary)) !important;
|
||||
top: env(safe-area-inset-top) !important;
|
||||
inset-block-start: env(safe-area-inset-top) !important;
|
||||
}
|
||||
|
||||
#nprogress .peg {
|
||||
box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important;
|
||||
-webkit-transform: rotate(0deg) translate(0px, -1px);
|
||||
-ms-transform: rotate(0deg) translate(0px, -1px);
|
||||
transform: rotate(0deg) translate(0px, -1px);
|
||||
transform: rotate(0deg) translate(0, -1px);
|
||||
}
|
||||
|
||||
.v-toast--bottom {
|
||||
margin-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 2500;
|
||||
margin-block-end: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v-toast--top {
|
||||
margin-top: env(safe-area-inset-top);
|
||||
z-index: 2500;
|
||||
margin-block-start: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.v-dialog > .v-overlay__content {
|
||||
margin-top: calc(env(safe-area-inset-top) + 1rem);
|
||||
max-height: calc(100% - env(safe-area-inset-top) - 1rem);
|
||||
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
|
||||
max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
|
||||
}
|
||||
|
||||
.v-dialog > .v-overlay__content{
|
||||
width: calc(100% - 1rem);
|
||||
inline-size: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.v-dialog--fullscreen > .v-overlay__content{
|
||||
margin-top: env(safe-area-inset-top);
|
||||
max-height: calc(100% - env(safe-area-inset-top));
|
||||
width: 100%;
|
||||
inline-size: 100%;
|
||||
margin-block-start: env(safe-area-inset-top);
|
||||
max-block-size: calc(100% - env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
/* router view transition fade-slide */
|
||||
@@ -62,21 +60,20 @@
|
||||
}
|
||||
|
||||
.text-moviepilot {
|
||||
background-clip: text;
|
||||
background-image: linear-gradient(to bottom right,var(--tw-gradient-stops));
|
||||
color: transparent;
|
||||
|
||||
--tw-gradient-from: #818cf8;
|
||||
--tw-gradient-to: rgba(129,140,248,0);
|
||||
--tw-gradient-to: rgba(129,140,248,0%);
|
||||
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
|
||||
--tw-gradient-to: #c084fc;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
position: relative;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
margin-block: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.slider-title {
|
||||
@@ -87,25 +84,27 @@
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px){
|
||||
@media (width >= 640px){
|
||||
.slider-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2.25rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// 美化滚动条
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
block-size: 8px;
|
||||
inline-size: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
background: rgb(var(--v-theme-perfect-scrollbar-thumb));
|
||||
-webkit-box-shadow: inset 0 0 10px rgba(0,0,0,0.2);
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,20%);
|
||||
|
||||
@media(hover){
|
||||
&:hover{
|
||||
background: #a1a1a1;
|
||||
@@ -120,10 +119,14 @@
|
||||
|
||||
.backdrop-blur {
|
||||
--tw-backdrop-blur: blur(8px)!important;
|
||||
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
|
||||
|
||||
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
|
||||
}
|
||||
|
||||
.v-toolbar{
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
|
||||
.v-toast {
|
||||
z-index: 2500 !important;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,7 @@ const props = defineProps({
|
||||
|
||||
// 判断是否有滚动条
|
||||
function hasScroll() {
|
||||
return (
|
||||
document.body.scrollHeight
|
||||
- (window.innerHeight || document.documentElement.clientHeight)
|
||||
> 2
|
||||
)
|
||||
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||
}
|
||||
|
||||
// 当前页码
|
||||
@@ -37,8 +33,7 @@ function getParams() {
|
||||
let params = {
|
||||
page: page.value,
|
||||
}
|
||||
if (props.params)
|
||||
params = { ...params, ...props.params }
|
||||
if (props.params) params = { ...params, ...props.params }
|
||||
|
||||
return params
|
||||
}
|
||||
@@ -46,76 +41,66 @@ function getParams() {
|
||||
// 获取列表数据
|
||||
async function fetchData({ done }: { done: any }) {
|
||||
try {
|
||||
if (!props.apipath)
|
||||
return
|
||||
if (!props.apipath) return
|
||||
|
||||
// 如果正在加载中,直接返回
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
|
||||
// 加载到满屏或者加载出错
|
||||
if (!hasScroll()) {
|
||||
// 加载多次
|
||||
while (!hasScroll()) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('ok')
|
||||
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// 加载一次
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
|
||||
// 页码+1
|
||||
page.value++
|
||||
}
|
||||
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
// 返回加载失败
|
||||
done('error')
|
||||
}
|
||||
@@ -123,34 +108,12 @@ async function fetchData({ done }: { done: any }) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="dataList"
|
||||
class="overflow-hidden"
|
||||
@load="fetchData"
|
||||
>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-hidden" @load="fetchData">
|
||||
<template #loading />
|
||||
<div
|
||||
v-if="dataList.length > 0"
|
||||
class="grid gap-4 grid-media-card mx-3"
|
||||
tabindex="0"
|
||||
>
|
||||
<MediaCard
|
||||
v-for="data in dataList"
|
||||
:key="data.tmdb_id || data.douban_id"
|
||||
:media="data"
|
||||
/>
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card mx-3" tabindex="0">
|
||||
<MediaCard v-for="data in dataList" :key="data.tmdb_id || data.douban_id" :media="data" />
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
|
||||
@@ -11,6 +11,8 @@ const props = defineProps({
|
||||
title: String,
|
||||
})
|
||||
|
||||
provide('rankingPropsKey', reactive({...props}))
|
||||
|
||||
// 组件加载完成
|
||||
const componentLoaded = ref(false)
|
||||
|
||||
@@ -39,12 +41,11 @@ onMounted(fetchData)
|
||||
<template>
|
||||
<SlideView
|
||||
v-if="componentLoaded"
|
||||
v-bind="props"
|
||||
>
|
||||
<template #content>
|
||||
<template
|
||||
v-for="data in dataList"
|
||||
:key="data.tmdb_id || data.douban_id"
|
||||
:key="data.tmdb_id || data.douban_id || data.bangumi_id"
|
||||
>
|
||||
<MediaCard
|
||||
:media="data"
|
||||
|
||||
@@ -8,7 +8,7 @@ import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import router from '@/router'
|
||||
import SubscribeEditForm from '@/components/form/SubscribeEditForm.vue'
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
|
||||
// 输入参数
|
||||
const mediaProps = defineProps({
|
||||
@@ -46,10 +46,14 @@ const seasonsSubscribed = ref<{ [key: number]: boolean }>({})
|
||||
// 订阅编号
|
||||
const subscribeId = ref<number>()
|
||||
|
||||
// 订阅规则
|
||||
const subscribeRules = ref({
|
||||
show_edit_dialog: false,
|
||||
})
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
return mediaDetail.value?.tmdb_id
|
||||
? `tmdb:${mediaDetail.value?.tmdb_id}`
|
||||
: mediaDetail.value?.douban_id
|
||||
? `douban:${mediaDetail.value?.douban_id}`
|
||||
: `bangumi:${mediaDetail.value?.bangumi_id}`
|
||||
}
|
||||
|
||||
// 调用API查询详情
|
||||
async function getMediaDetail() {
|
||||
@@ -60,7 +64,7 @@ async function getMediaDetail() {
|
||||
},
|
||||
})
|
||||
isRefreshed.value = true
|
||||
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id)
|
||||
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id)
|
||||
return
|
||||
|
||||
// 检查存在状态
|
||||
@@ -113,7 +117,7 @@ async function checkExists() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function checkSubscribe(season = 0) {
|
||||
try {
|
||||
const mediaid = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
@@ -198,6 +202,7 @@ async function addSubscribe(season = 0) {
|
||||
year: mediaDetail.value?.year,
|
||||
tmdbid: mediaDetail.value?.tmdb_id,
|
||||
doubanid: mediaDetail.value?.douban_id,
|
||||
bangumiid: mediaDetail.value?.bangumi_id,
|
||||
season,
|
||||
best_version,
|
||||
})
|
||||
@@ -220,9 +225,12 @@ async function addSubscribe(season = 0) {
|
||||
)
|
||||
|
||||
// 显示编辑弹窗
|
||||
if (result.success && subscribeRules.value.show_edit_dialog) {
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
if (result.success) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
subscribeId.value = result.data.id
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -253,9 +261,7 @@ async function removeSubscribe(season: number) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = mediaDetail.value?.tmdb_id
|
||||
? `tmdb:${mediaDetail.value?.tmdb_id}`
|
||||
: `douban:${mediaDetail.value?.douban_id}`
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(
|
||||
`subscribe/media/${mediaid}`,
|
||||
@@ -282,20 +288,6 @@ async function removeSubscribe(season: number) {
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function querySubscribeRules() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
'system/setting/DefaultFilterRules',
|
||||
)
|
||||
if (result.data?.value)
|
||||
subscribeRules.value = result.data?.value
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅按钮响应
|
||||
function handleSubscribe(season = 0) {
|
||||
if (isSubscribed.value)
|
||||
@@ -330,6 +322,11 @@ function getTvdbLink() {
|
||||
return `https://www.thetvdb.com/series/${mediaDetail.value.tvdb_id}`
|
||||
}
|
||||
|
||||
// 拼装Bangumi地址
|
||||
function getBangumiLink() {
|
||||
return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}`
|
||||
}
|
||||
|
||||
// 拼装集图片地址
|
||||
function getEpisodeImage(stillPath: string) {
|
||||
if (!stillPath)
|
||||
@@ -405,13 +402,14 @@ function joinArray(arr: string[]) {
|
||||
|
||||
// 开始搜索
|
||||
function handleSearch(area: string) {
|
||||
const keyword = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
|
||||
const keyword = getMediaId()
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword,
|
||||
type: mediaDetail.value.type,
|
||||
area,
|
||||
season: mediaDetail.value.season,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -436,24 +434,36 @@ async function handlePlay() {
|
||||
}
|
||||
}
|
||||
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (mediaProps.type === '电影')
|
||||
subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else
|
||||
subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
if (result.data?.value)
|
||||
return result.data.value.show_edit_dialog
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
getMediaDetail()
|
||||
querySubscribeRules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<LoadingBanner
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" class="max-w-8xl mx-auto px-4">
|
||||
class="mt-12"
|
||||
/>
|
||||
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" class="max-w-8xl mx-auto px-4">
|
||||
<template v-if="mediaDetail.backdrop_path || mediaDetail.poster_path">
|
||||
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
|
||||
<VImg class="h-96" :src="mediaDetail.backdrop_path || mediaDetail.poster_path" cover />
|
||||
@@ -492,7 +502,7 @@ onBeforeMount(() => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="media-actions">
|
||||
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" variant="tonal" color="info" class="mb-2">
|
||||
<VBtn v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && mediaDetail.imdb_id" variant="tonal" color="info" class="mb-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</template>
|
||||
@@ -518,7 +528,13 @@ onBeforeMount(() => {
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
|
||||
<VBtn v-if="(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) && !mediaDetail.imdb_id" variant="tonal" color="info" class="mb-2" @click="handleSearch('title')">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</template>
|
||||
搜索资源
|
||||
</VBtn>
|
||||
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
|
||||
<template #prepend>
|
||||
<VIcon :icon="getSubscribeIcon" />
|
||||
</template>
|
||||
@@ -580,6 +596,12 @@ onBeforeMount(() => {
|
||||
<span class="ms-1">TheTvDb</span>
|
||||
</div>
|
||||
</a>
|
||||
<a v-if="mediaDetail.bangumi_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getBangumiLink()" target="_blank">
|
||||
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700">
|
||||
<VIcon icon="mdi-link" />
|
||||
<span class="ms-1">Bangumi</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">
|
||||
季
|
||||
@@ -618,16 +640,10 @@ onBeforeMount(() => {
|
||||
</VExpansionPanelTitle>
|
||||
<VExpansionPanelText>
|
||||
<template #default>
|
||||
<div
|
||||
<LoadingBanner
|
||||
v-if="!seasonEpisodesInfo[season.season_number || 0]"
|
||||
class="mt-3 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
class="mt-3"
|
||||
/>
|
||||
<div class="flex flex-col justify-center divide-y divide-gray-700">
|
||||
<div v-for="episode in seasonEpisodesInfo[season.season_number || 0]" :key="episode.episode_number" class="flex flex-col space-y-4 py-4 xl:flex-row xl:space-y-4 xl:space-x-4">
|
||||
<div class="flex-1">
|
||||
@@ -740,6 +756,33 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.bangumi_id" class="media-overview-right">
|
||||
<div class="media-facts">
|
||||
<div v-if="mediaDetail.vote_average" class="media-ratings">
|
||||
<VRating
|
||||
v-model="mediaDetail.vote_average"
|
||||
density="compact"
|
||||
length="10"
|
||||
class="ma-2"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.bangumi_id" class="media-fact">
|
||||
<span>ID</span>
|
||||
<span class="media-fact-value">{{ mediaDetail.bangumi_id }}</span>
|
||||
</div>
|
||||
<div v-if="mediaDetail.original_title" class="media-fact">
|
||||
<span>原始标题</span>
|
||||
<span class="media-fact-value">{{ mediaDetail.original_title }}</span>
|
||||
</div>
|
||||
<div v-if="mediaDetail.release_date" class="media-fact border-b-0">
|
||||
<span>上映日期</span>
|
||||
<span class="media-fact-value">
|
||||
{{ mediaDetail.release_date }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id">
|
||||
<PersonCardSlideView
|
||||
@@ -757,6 +800,14 @@ onBeforeMount(() => {
|
||||
type="douban"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.bangumi_id">
|
||||
<PersonCardSlideView
|
||||
:apipath="`bangumi/credits/${mediaDetail.bangumi_id}`"
|
||||
:linkurl="`/credits/bangumi/credits/${mediaDetail.bangumi_id}?title=演员阵容&type=bangumi`"
|
||||
title="演员阵容"
|
||||
type="bangumi"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`"
|
||||
@@ -771,6 +822,13 @@ onBeforeMount(() => {
|
||||
title="推荐"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="mediaDetail.bangumi_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`bangumi/recommend/${mediaDetail.bangumi_id}`"
|
||||
:linkurl="`/browse/bangumi/recommend/${mediaDetail.bangumi_id}?title=推荐`"
|
||||
title="推荐"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mediaDetail.tmdb_id">
|
||||
<MediaCardSlideView
|
||||
:apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"
|
||||
@@ -781,13 +839,13 @@ onBeforeMount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && isRefreshed"
|
||||
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
|
||||
error-code="500"
|
||||
error-title="出错啦!"
|
||||
error-description="未识别到媒体信息。"
|
||||
/>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditForm
|
||||
<SubscribeEditDialog
|
||||
v-model="subscribeEditDialog"
|
||||
:subid="subscribeId"
|
||||
@close="subscribeEditDialog = false"
|
||||
@@ -829,7 +887,7 @@ onBeforeMount(() => {
|
||||
padding-block-start: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
@media (width >= 1280px) {
|
||||
.media-header {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
@@ -839,65 +897,66 @@ onBeforeMount(() => {
|
||||
.media-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
padding-block: 2rem 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@media (width >= 1024px) {
|
||||
.media-overview {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.media-poster {
|
||||
width: 8rem;
|
||||
overflow: hidden;
|
||||
border-radius: .25rem;
|
||||
--tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, .1), 0 1px 2px -1px rgba(0, 0, 0, .1);
|
||||
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
|
||||
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 (min-width: 1280px) {
|
||||
@media (width >= 1280px) {
|
||||
.media-poster {
|
||||
margin-right: 1rem;
|
||||
width: 13rem;
|
||||
inline-size: 13rem;
|
||||
margin-inline-end: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@media (width >= 768px) {
|
||||
.media-poster {
|
||||
width: 11rem;
|
||||
border-radius: .5rem;
|
||||
--tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, .25);
|
||||
--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
|
||||
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 {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex: 1 1 0%;
|
||||
flex-direction: column;
|
||||
margin-block-start: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
@media (width >= 1280px) {
|
||||
.media-title {
|
||||
margin-right: 1rem;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
margin-block-start: 0;
|
||||
margin-inline-end: 1rem;
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
.media-title>h1 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
@media (width >= 1280px) {
|
||||
.media-title>h1 {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
@@ -905,23 +964,23 @@ onBeforeMount(() => {
|
||||
}
|
||||
|
||||
ul.media-crew {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2,minmax(0,1fr));
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(2,minmax(0,1fr));
|
||||
margin-block-start: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@media (width >= 640px) {
|
||||
ul.media-crew {
|
||||
grid-template-columns: repeat(3,minmax(0,1fr));
|
||||
}
|
||||
}
|
||||
|
||||
ul.media-crew>li {
|
||||
grid-column: span 1/span 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: 700;
|
||||
grid-column: span 1/span 1;
|
||||
}
|
||||
|
||||
a.crew-name {
|
||||
@@ -929,27 +988,27 @@ a.crew-name {
|
||||
}
|
||||
|
||||
.media-status {
|
||||
margin-bottom: .5rem;
|
||||
margin-block-end: .5rem;
|
||||
}
|
||||
|
||||
.media-attributes {
|
||||
margin-top: .25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-block-start: .25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
@media (width >= 1280px) {
|
||||
.media-attributes {
|
||||
margin-top: 0;
|
||||
justify-content: flex-start;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@media (width >= 640px) {
|
||||
.media-attributes {
|
||||
font-size: .875rem;
|
||||
line-height: 1.25rem;
|
||||
@@ -958,21 +1017,21 @@ a.crew-name {
|
||||
|
||||
.media-actions {
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
@media (width >= 1280px) {
|
||||
.media-actions {
|
||||
margin-top: 0;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@media (width >= 640px) {
|
||||
.media-actions {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
@@ -983,42 +1042,45 @@ a.crew-name {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@media (width >= 1024px) {
|
||||
.media-overview-left {
|
||||
margin-right: 2rem;
|
||||
margin-inline-end: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.media-overview-right {
|
||||
margin-top: 2rem;
|
||||
width: 100%;
|
||||
inline-size: 100%;
|
||||
margin-block-start: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@media (width >= 1024px) {
|
||||
.media-overview-right {
|
||||
margin-top: 0;
|
||||
width: 20rem;
|
||||
inline-size: 20rem;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.media-facts {
|
||||
border-radius: 0.5rem;
|
||||
border-width: 1px;
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(55 65 81/var(--tw-border-opacity));
|
||||
--tw-bg-opacity: 1;
|
||||
border-radius: 0.5rem;
|
||||
font-size: .875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25rem;
|
||||
|
||||
--tw-border-opacity: 1;
|
||||
--tw-bg-opacity: 1;
|
||||
--tw-text-opacity: 1;
|
||||
}
|
||||
|
||||
.media-ratings {
|
||||
border-bottom-width: 1px;
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(55 65 81/var(--tw-border-opacity));
|
||||
padding: 0.5rem 1rem;
|
||||
border-block-end-width: 1px;
|
||||
font-weight: 500;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
--tw-border-opacity: 1;
|
||||
}
|
||||
|
||||
.media-ratings {
|
||||
@@ -1030,19 +1092,21 @@ a.crew-name {
|
||||
.media-fact {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom-width: 1px;
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(55 65 81/var(--tw-border-opacity));
|
||||
padding: 0.5rem 1rem;
|
||||
border-block-end-width: 1px;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
--tw-border-opacity: 1;
|
||||
}
|
||||
|
||||
.media-overview h2 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@media (width >= 640px) {
|
||||
.media-overview h2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
@@ -1050,13 +1114,13 @@ a.crew-name {
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
font-style: italic;
|
||||
line-height: 1.75rem;
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@media (width >= 1024px) {
|
||||
.tagline {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import DoubanPersonCard from '@/components/cards/DoubanPersonCard.vue'
|
||||
import TmdbPersonCard from '@/components/cards/TmdbPersonCard.vue'
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
|
||||
// 输入参数
|
||||
@@ -13,11 +12,7 @@ const props = defineProps({
|
||||
|
||||
// 判断是否有滚动条
|
||||
function hasScroll() {
|
||||
return (
|
||||
document.body.scrollHeight
|
||||
- (window.innerHeight || document.documentElement.clientHeight)
|
||||
> 2
|
||||
)
|
||||
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||
}
|
||||
|
||||
// 当前页码
|
||||
@@ -33,83 +28,80 @@ const isRefreshed = ref(false)
|
||||
const dataList = ref<any>([])
|
||||
const currData = ref<any>([])
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
let params = {
|
||||
page: page.value,
|
||||
}
|
||||
if (props.params) params = { ...params, ...props.params }
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
async function fetchData({ done }: { done: any }) {
|
||||
try {
|
||||
if (!props.apipath)
|
||||
return
|
||||
if (!props.apipath) return
|
||||
|
||||
// 如果正在加载中,直接返回
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
|
||||
// 加载到满屏或者加载出错
|
||||
if (!hasScroll()) {
|
||||
// 加载多次
|
||||
while (!hasScroll()) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: {
|
||||
page: page.value,
|
||||
},
|
||||
params: getParams(),
|
||||
})
|
||||
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('ok')
|
||||
|
||||
done('empty')
|
||||
return
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
|
||||
// 页码+1
|
||||
page.value++
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// 加载一次
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: {
|
||||
page: page.value,
|
||||
},
|
||||
params: getParams(),
|
||||
})
|
||||
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
// 返回加载失败
|
||||
done('error')
|
||||
}
|
||||
@@ -117,45 +109,12 @@ async function fetchData({ done }: { done: any }) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="dataList"
|
||||
class="overflow-hidden"
|
||||
@load="fetchData"
|
||||
>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-hidden" @load="fetchData">
|
||||
<template #loading />
|
||||
<div
|
||||
v-if="dataList.length > 0 && props.type === 'tmdb'"
|
||||
class="grid gap-4 grid-media-card mx-3"
|
||||
tabindex="0"
|
||||
>
|
||||
<TmdbPersonCard
|
||||
v-for="data in dataList"
|
||||
:key="data.id"
|
||||
:person="data"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="dataList.length > 0 && props.type === 'douban'"
|
||||
class="grid gap-4 grid-media-card mx-3"
|
||||
tabindex="0"
|
||||
>
|
||||
<DoubanPersonCard
|
||||
v-for="data in dataList"
|
||||
:key="data.id"
|
||||
:person="data"
|
||||
/>
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card mx-3" tabindex="0">
|
||||
<PersonCard v-for="data in dataList" :key="data.id" :person="data" />
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import TmdbPersonCard from '@/components/cards/TmdbPersonCard.vue'
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import api from '@/api'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import DoubanPersonCard from '@/components/cards/DoubanPersonCard.vue'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -12,6 +11,8 @@ const props = defineProps({
|
||||
type: String,
|
||||
})
|
||||
|
||||
provide('rankingPropsKey', reactive({ ...props }))
|
||||
|
||||
// 组件加载完成
|
||||
const componentLoaded = ref(false)
|
||||
|
||||
@@ -21,14 +22,11 @@ const dataList = ref<any>([])
|
||||
// 获取订阅列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
if (!props.apipath)
|
||||
return
|
||||
if (!props.apipath) return
|
||||
|
||||
dataList.value = await api.get(props.apipath)
|
||||
if (dataList.value.length > 0)
|
||||
componentLoaded.value = true
|
||||
}
|
||||
catch (error) {
|
||||
if (dataList.value.length > 0) componentLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -38,27 +36,10 @@ onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideView
|
||||
v-if="componentLoaded"
|
||||
v-bind="props"
|
||||
>
|
||||
<SlideView v-if="componentLoaded">
|
||||
<template #content>
|
||||
<template
|
||||
v-for="data in dataList"
|
||||
:key="data.id"
|
||||
>
|
||||
<TmdbPersonCard
|
||||
v-if="props.type === 'tmdb'"
|
||||
:person="data"
|
||||
height="15rem"
|
||||
width="10rem"
|
||||
/>
|
||||
<DoubanPersonCard
|
||||
v-if="props.type === 'douban'"
|
||||
:person="data"
|
||||
height="15rem"
|
||||
width="10rem"
|
||||
/>
|
||||
<template v-for="data in dataList" :key="data.id">
|
||||
<PersonCard :person="data" height="15rem" width="10rem" />
|
||||
</template>
|
||||
</template>
|
||||
</SlideView>
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
import MediaCardListView from './MediaCardListView.vue'
|
||||
import api from '@/api'
|
||||
import personIcon from '@images/misc/person.png'
|
||||
import type { TmdbPerson } from '@/api/types'
|
||||
import type { Person } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
|
||||
// 输入参数
|
||||
const personProps = defineProps({
|
||||
personid: String,
|
||||
type: String,
|
||||
source: String,
|
||||
})
|
||||
|
||||
// 媒体详情
|
||||
const personDetail = ref<TmdbPerson>({} as TmdbPerson)
|
||||
const personDetail = ref<Person>({} as Person)
|
||||
|
||||
// 是否已加载完成
|
||||
const isRefreshed = ref(false)
|
||||
@@ -23,23 +24,67 @@ const isImageLoaded = ref(false)
|
||||
// 调用API查询详情
|
||||
async function getPersonDetail() {
|
||||
if (personProps.personid) {
|
||||
personDetail.value = await api.get(`tmdb/person/${personProps.personid}`)
|
||||
if (personProps.source === 'themoviedb') {
|
||||
personDetail.value = await api.get(`tmdb/person/${personProps.personid}`)
|
||||
} else if (personProps.source === 'douban') {
|
||||
personDetail.value = await api.get(`douban/person/${personProps.personid}`)
|
||||
} else if (personProps.source === 'bangumi') {
|
||||
personDetail.value = await api.get(`bangumi/person/${personProps.personid}`)
|
||||
}
|
||||
isRefreshed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 人物图片地址
|
||||
function getPersonImage() {
|
||||
if (!personDetail.value?.profile_path)
|
||||
if (personProps.source === 'themoviedb') {
|
||||
if (!personDetail.value?.profile_path) return personIcon
|
||||
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personDetail.value?.profile_path}`
|
||||
} else if (personProps.source === 'douban') {
|
||||
if (!personDetail.value?.avatar) return personIcon
|
||||
if (typeof personDetail.value?.avatar === 'object') {
|
||||
return personDetail.value?.avatar?.normal
|
||||
} else {
|
||||
return personDetail.value?.avatar
|
||||
}
|
||||
} else if (personProps.source === 'bangumi') {
|
||||
if (!personDetail.value?.images) return personIcon
|
||||
return personDetail.value?.images?.medium
|
||||
} else {
|
||||
return personIcon
|
||||
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personDetail.value?.profile_path}`
|
||||
}
|
||||
}
|
||||
|
||||
// 将别名数组拆分为、分隔的字符串
|
||||
function getAlsoKnownAs() {
|
||||
if (!personDetail.value?.also_known_as)
|
||||
return ''
|
||||
return personDetail.value.also_known_as.join('、')
|
||||
if (!personDetail.value?.also_known_as) return ''
|
||||
if (personProps.source === 'themoviedb') {
|
||||
return '别名:' + personDetail.value.also_known_as.join('、')
|
||||
} else {
|
||||
return personDetail.value.also_known_as.join(',')
|
||||
}
|
||||
}
|
||||
|
||||
// 参演作品路由地址
|
||||
function getPersonCreditsPath() {
|
||||
let apipath = 'tmdb'
|
||||
if (personProps.source === 'douban') {
|
||||
apipath = 'douban'
|
||||
} else if (personProps.source === 'bangumi') {
|
||||
apipath = 'bangumi'
|
||||
}
|
||||
return `/browse/${apipath}/person/credits/${personDetail.value.id}?title=参演作品`
|
||||
}
|
||||
|
||||
// 参演作品API路径
|
||||
function getPersonCreditsApiPath() {
|
||||
let apipath = 'tmdb'
|
||||
if (personProps.source === 'douban') {
|
||||
apipath = 'douban'
|
||||
} else if (personProps.source === 'bangumi') {
|
||||
apipath = 'bangumi'
|
||||
}
|
||||
return `${apipath}/person/credits/${personDetail.value.id}`
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
@@ -48,62 +93,46 @@ onBeforeMount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<div v-if="personDetail.id" class="max-w-8xl mx-auto px-4">
|
||||
<div class="relative z-10 mt-4 mb-8 flex flex-col items-center lg:flex-row ">
|
||||
<div class="relative z-10 mt-4 mb-8 flex flex-col items-center flex-md-row">
|
||||
<VAvatar
|
||||
size="200"
|
||||
:class="{
|
||||
'ring-1 ring-gray-700': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
v-img
|
||||
:src="getPersonImage()"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
/>
|
||||
<VImg v-img :src="getPersonImage()" cover @load="isImageLoaded = true" />
|
||||
</VAvatar>
|
||||
<div class="text-start ms-3 md:text-center">
|
||||
<h1 class="text-3xl lg:text-4xl">
|
||||
<div class="ms-3">
|
||||
<h1 class="text-3xl lg:text-4xl text-center text-lg-left">
|
||||
{{ personDetail.name }}
|
||||
</h1>
|
||||
<div class="mt-1 mb-2 space-y-1 text-xs sm:text-sm lg:text-base">
|
||||
<div class="mt-1 mb-2 space-y-1 text-xs sm:text-sm lg:text-base text-center text-lg-left">
|
||||
<div>
|
||||
<span v-if="personDetail.birthday">{{ personDetail.birthday }}</span>
|
||||
<span v-if="personDetail.place_of_birth"> | </span>
|
||||
<span v-if="personDetail.place_of_birth">{{ personDetail.place_of_birth }}</span>
|
||||
</div>
|
||||
<div v-if="personDetail.also_known_as">
|
||||
别名:{{ getAlsoKnownAs() }}
|
||||
</div>
|
||||
<div v-if="personDetail.also_known_as">{{ getAlsoKnownAs() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative text-left">
|
||||
<div class="group outline-none ring-0" role="button" tabindex="-1">
|
||||
<p class="pt-2 text-sm lg:text-base" style="overflow-wrap: break-word;">
|
||||
<p class="pt-2 text-sm lg:text-base" style="overflow-wrap: break-word">
|
||||
{{ personDetail.biography }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="slider-header">
|
||||
<RouterLink :to="`/browse/tmdb/person/credits/${personDetail.id}?title=参演作品`" class="slider-title">
|
||||
<RouterLink :to="getPersonCreditsPath()" class="slider-title">
|
||||
<span>参演作品</span>
|
||||
<VIcon icon="mdi-arrow-right-circle-outline" class="ms-1" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<MediaCardListView :apipath="`tmdb/person/credits/${personDetail.id}`" />
|
||||
<MediaCardListView :apipath="getPersonCreditsApiPath()" />
|
||||
</div>
|
||||
</div>
|
||||
<NoDataFound
|
||||
|
||||
@@ -104,16 +104,16 @@ onMounted(() => {
|
||||
<template>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VList v-if="dataList.length === 0" lines="three" class="rounded">
|
||||
<VList v-if="dataList.length === 0" lines="three" class="rounded p-0">
|
||||
<VListItem>
|
||||
<VListItemTitle>没有附合当前过滤条件的资源。</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div>
|
||||
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0">
|
||||
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`">
|
||||
<TorrentItem v-if="defer(index)" :torrent="item" />
|
||||
</div>
|
||||
</div>
|
||||
</VList>
|
||||
</VCol>
|
||||
<VCol xl="2" md="3" class="d-none d-md-block">
|
||||
<VList lines="one" class="rounded">
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 已安装插件列表
|
||||
const dataList = ref<Plugin[]>([])
|
||||
@@ -11,6 +18,9 @@ const dataList = ref<Plugin[]>([])
|
||||
// 未安装插件列表
|
||||
const uninstalledList = ref<Plugin[]>([])
|
||||
|
||||
// 插件市场插件列表
|
||||
const marketList = ref<Plugin[]>([])
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
@@ -20,16 +30,150 @@ const isAppMarketLoaded = ref(false)
|
||||
// APP市场窗口
|
||||
const PluginAppDialog = ref(false)
|
||||
|
||||
// 插件安装统计
|
||||
const PluginStatistics = ref<{ [key: string]: number }>({})
|
||||
|
||||
// 搜索窗口
|
||||
const SearchDialog = ref(false)
|
||||
|
||||
// 搜索关键字
|
||||
const keyword = ref('')
|
||||
|
||||
// 每一个插件的图标加载状态
|
||||
const pluginIconLoaded = ref<{ [key: string]: boolean }>({})
|
||||
|
||||
// 每一个插件的动作标识
|
||||
const pluginActions = ref<{ [key: string]: boolean }>({})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 进度框文本
|
||||
const progressText = ref('正在安装插件...')
|
||||
|
||||
// 过滤表单
|
||||
const filterForm = reactive({
|
||||
// 名称
|
||||
name: [] as string[],
|
||||
// 作者
|
||||
author: [] as string[],
|
||||
// 标签
|
||||
label: [] as string[],
|
||||
// 插件库
|
||||
repo: [] as string[],
|
||||
})
|
||||
|
||||
// 名称过滤项
|
||||
const nameFilterOptions = ref<string[]>([])
|
||||
// 作者过滤项
|
||||
const authorFilterOptions = ref<string[]>([])
|
||||
// 标签过滤项
|
||||
const labelFilterOptions = ref<string[]>([])
|
||||
// 插件库过滤项
|
||||
const repoFilterOptions = ref<string[]>([])
|
||||
|
||||
// 初始化过滤选项
|
||||
function initOptions(item: Plugin) {
|
||||
const optionValue = (options: Array<string>, value: string | undefined) => {
|
||||
value && !options.includes(value) && options.push(value)
|
||||
}
|
||||
const optionMutipleValue = (options: Array<string>, value: string | undefined) => {
|
||||
value && value.split(',').forEach(v => !options.includes(v) && options.push(v))
|
||||
}
|
||||
optionValue(nameFilterOptions.value, item.plugin_name)
|
||||
optionValue(authorFilterOptions.value, item.plugin_author)
|
||||
optionMutipleValue(labelFilterOptions.value, item.plugin_label)
|
||||
optionValue(repoFilterOptions.value, item.repo_url)
|
||||
}
|
||||
|
||||
// 关闭插件市场窗口
|
||||
function pluginDialogClose() {
|
||||
PluginAppDialog.value = false
|
||||
}
|
||||
|
||||
// 安装插件
|
||||
async function installPlugin(item: Plugin) {
|
||||
try {
|
||||
// 显示等待提示框
|
||||
progressDialog.value = true
|
||||
progressText.value = `正在安装 ${item?.plugin_name} v${item?.plugin_version} ...`
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${item?.id}`, {
|
||||
params: {
|
||||
repo_url: item?.repo_url,
|
||||
force: item?.has_update,
|
||||
},
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
progressDialog.value = false
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`插件 ${item?.plugin_name} 安装成功!`)
|
||||
|
||||
// 刷新
|
||||
refreshData()
|
||||
} else {
|
||||
$toast.error(`插件 ${item?.plugin_name} 安装失败:${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开插件搜索结果
|
||||
function openPlugin(item: Plugin) {
|
||||
// 如果是已安装插件则打开插件详情
|
||||
if (item.installed === true) {
|
||||
// 标记插件动作
|
||||
pluginActions.value[item.id || '0'] = true
|
||||
} else {
|
||||
// 如果是未安装插件则安装
|
||||
installPlugin(item)
|
||||
}
|
||||
closeSearchDialog()
|
||||
}
|
||||
|
||||
// 关闭插件搜索窗口
|
||||
function closeSearchDialog() {
|
||||
SearchDialog.value = false
|
||||
}
|
||||
|
||||
// 插件图标加载错误
|
||||
function pluginIconError(item: Plugin) {
|
||||
pluginIconLoaded.value[item.id || '0'] = false
|
||||
}
|
||||
|
||||
// 插件图标地址
|
||||
function pluginIcon(item: Plugin) {
|
||||
// 如果图片加载错误
|
||||
if (pluginIconLoaded.value[item.id || '0'] === false) return noImage
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (item?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}`
|
||||
|
||||
return `./plugin_icon/${item?.plugin_icon}`
|
||||
}
|
||||
|
||||
// 过滤插件
|
||||
const filterPlugins = computed(() => {
|
||||
const all_list = [...dataList.value, ...uninstalledList.value]
|
||||
return all_list.filter((item: Plugin) => {
|
||||
// 需要忽略大小写
|
||||
return (
|
||||
item.plugin_name?.toLowerCase().includes(keyword.value.toLowerCase()) ||
|
||||
item.plugin_desc?.toLowerCase().includes(keyword.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// 新安装了插件
|
||||
function pluginInstalled() {
|
||||
fetchInstalledPlugins()
|
||||
pluginDialogClose()
|
||||
fetchUninstalledPlugins()
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// 获取插件列表数据
|
||||
@@ -41,8 +185,7 @@ async function fetchInstalledPlugins() {
|
||||
},
|
||||
})
|
||||
isRefreshed.value = true
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -55,62 +198,101 @@ async function fetchUninstalledPlugins() {
|
||||
state: 'market',
|
||||
},
|
||||
})
|
||||
// 设置APP市场加载完成
|
||||
isAppMarketLoaded.value = true
|
||||
// 设置更新状态
|
||||
for (const uninstalled of uninstalledList.value) {
|
||||
for (const data of dataList.value) {
|
||||
if (uninstalled.id === data.id) {
|
||||
data.has_update = true
|
||||
data.repo_url = uninstalled.repo_url
|
||||
data.history = uninstalled.history
|
||||
data.plugin_label = uninstalled.plugin_label
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新插件市场列表
|
||||
// 排除已安装且有更新的,上面的问题在于“本地存在未安装的旧版本插件且云端有更新时”不会在插件市场展示
|
||||
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
|
||||
// 初始化过滤选项
|
||||
marketList.value.forEach(initOptions)
|
||||
// 设置APP市场加载完成
|
||||
isAppMarketLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
catch (error) {
|
||||
}
|
||||
|
||||
// 加载插件统计数据
|
||||
async function getPluginStatistics() {
|
||||
try {
|
||||
PluginStatistics.value = await api.get('plugin/statistic')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有数据
|
||||
function refreshData() {
|
||||
fetchInstalledPlugins()
|
||||
async function refreshData() {
|
||||
await fetchInstalledPlugins()
|
||||
fetchUninstalledPlugins()
|
||||
}
|
||||
|
||||
// 获取没有更新的插件
|
||||
const getUnupdatedPlugins = computed(() => {
|
||||
return uninstalledList.value.filter(item => !item.has_update)
|
||||
// 对uninstalledList进行排序,按PluginStatistics倒序
|
||||
const sortedUninstalledList = computed(() => {
|
||||
// 匹配过滤函数
|
||||
const match = (filter: Array<string>, value: string | undefined) =>
|
||||
filter.length === 0 || (value && filter.includes(value))
|
||||
const matchMultiple = (filter: Array<string>, value: string | undefined) =>
|
||||
filter.length === 0 || (value && value.split(',').some(v => filter.includes(v)))
|
||||
|
||||
// 过滤后的数据列表
|
||||
const ret_list: Plugin[] = []
|
||||
|
||||
// 过滤
|
||||
marketList.value.forEach(value => {
|
||||
if (value) {
|
||||
if (
|
||||
match(filterForm.name, value.plugin_name) &&
|
||||
match(filterForm.author, value.plugin_author) &&
|
||||
matchMultiple(filterForm.label, value.plugin_label) &&
|
||||
match(filterForm.repo, value.repo_url)
|
||||
) {
|
||||
ret_list.push(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 按照统计数据排序
|
||||
if (isNullOrEmptyObject(PluginStatistics.value)) return ret_list
|
||||
return ret_list.sort((a, b) => {
|
||||
return PluginStatistics.value[b.id || '0'] - PluginStatistics.value[a.id || '0']
|
||||
})
|
||||
})
|
||||
|
||||
// 标签转换
|
||||
function pluginLabels(label: string | undefined) {
|
||||
if (!label) return []
|
||||
return label.split(',')
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onBeforeMount(() => {
|
||||
refreshData()
|
||||
onBeforeMount(async () => {
|
||||
await refreshData()
|
||||
getPluginStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
v-if="!isRefreshed"
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="dataList.length > 0"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-plugin-card">
|
||||
<PluginCard
|
||||
v-for="data in dataList"
|
||||
:key="data.id"
|
||||
:key="`${data.id}_v${data.plugin_version}`"
|
||||
:count="PluginStatistics[data.id || '0']"
|
||||
:plugin="data"
|
||||
:action="pluginActions[data.id || '0']"
|
||||
@remove="refreshData"
|
||||
@save="refreshData"
|
||||
@action-done="pluginActions[data.id || '0'] = false"
|
||||
/>
|
||||
</div>
|
||||
<NoDataFound
|
||||
@@ -120,7 +302,9 @@ onBeforeMount(() => {
|
||||
error-description="点击右下角按钮,前往插件市场安装插件。"
|
||||
/>
|
||||
<!-- App市场 -->
|
||||
<VFab icon="mdi-store-plus" location="bottom end" size="x-large" fixed app appear @click="PluginAppDialog = true" />
|
||||
<VDialog
|
||||
v-if="PluginAppDialog"
|
||||
v-model="PluginAppDialog"
|
||||
fullscreen
|
||||
scrollable
|
||||
@@ -128,55 +312,76 @@ onBeforeMount(() => {
|
||||
:z-index="1010"
|
||||
transition="dialog-bottom-transition"
|
||||
>
|
||||
<!-- Dialog Activator -->
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
icon="mdi-store-plus"
|
||||
v-bind="props"
|
||||
size="x-large"
|
||||
class="fixed right-5 bottom-5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Dialog Content -->
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<!-- 标题栏 -->
|
||||
<div>
|
||||
<VToolbar color="primary">
|
||||
<VToolbarTitle>插件市场</VToolbarTitle>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<VToolbarItems>
|
||||
<VBtn
|
||||
size="x-large"
|
||||
@click="pluginDialogClose"
|
||||
>
|
||||
<VIcon
|
||||
color="white"
|
||||
icon="mdi-close"
|
||||
/>
|
||||
<VBtn size="x-large" @click="pluginDialogClose">
|
||||
<VIcon color="white" icon="mdi-close" />
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<VCardText>
|
||||
<div
|
||||
v-if="!isAppMarketLoaded"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
v-if="!isAppMarketLoaded"
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
<LoadingBanner v-if="!isAppMarketLoaded" class="mt-12" />
|
||||
<!-- 过滤表单 -->
|
||||
<div v-if="isAppMarketLoaded" class="bg-transparent mb-3 shadow-none">
|
||||
<VRow>
|
||||
<VCol v-if="nameFilterOptions.length > 0" cols="6" md="3">
|
||||
<VSelect
|
||||
v-model="filterForm.name"
|
||||
:items="nameFilterOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
label="名称"
|
||||
multiple
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="authorFilterOptions.length > 0" cols="6" md="3">
|
||||
<VSelect
|
||||
v-model="filterForm.author"
|
||||
:items="authorFilterOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
label="作者"
|
||||
multiple
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="labelFilterOptions.length > 0" cols="6" md="3">
|
||||
<VSelect
|
||||
v-model="filterForm.label"
|
||||
:items="labelFilterOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
label="标签"
|
||||
multiple
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="repoFilterOptions.length > 0" cols="6" md="3">
|
||||
<VSelect
|
||||
v-model="filterForm.repo"
|
||||
:items="repoFilterOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
label="插件库"
|
||||
multiple
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card">
|
||||
<PluginAppCard
|
||||
v-for="data in getUnupdatedPlugins"
|
||||
:key="data.id"
|
||||
v-for="data in sortedUninstalledList"
|
||||
:key="`${data.id}_v${data.plugin_version}`"
|
||||
:plugin="data"
|
||||
:count="PluginStatistics[data.id || '0']"
|
||||
@install="pluginInstalled"
|
||||
/>
|
||||
</div>
|
||||
@@ -189,11 +394,94 @@ onBeforeMount(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 插件搜索 -->
|
||||
<VFab
|
||||
icon="mdi-magnify"
|
||||
color="info"
|
||||
location="bottom end"
|
||||
class="mb-2"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="SearchDialog = true"
|
||||
/>
|
||||
<VDialog
|
||||
v-if="SearchDialog"
|
||||
v-model="SearchDialog"
|
||||
scrollable
|
||||
:z-index="1010"
|
||||
max-width="40rem"
|
||||
:max-height="!display.mdAndUp.value ? '' : '85vh'"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="mx-auto" width="100%">
|
||||
<VToolbar flat class="p-0">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
label="搜索插件"
|
||||
single-line
|
||||
placeholder="插件名称或描述"
|
||||
variant="solo"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
flat
|
||||
class="mx-1"
|
||||
/>
|
||||
</VToolbar>
|
||||
<DialogCloseBtn @click="closeSearchDialog" />
|
||||
<VList v-if="filterPlugins.length > 0" lines="two">
|
||||
<template v-for="(item, i) in filterPlugins" :key="i">
|
||||
<VListItem @click="openPlugin(item)">
|
||||
<template #prepend>
|
||||
<VAvatar>
|
||||
<VImg :src="pluginIcon(item)" @error="pluginIconError(item)">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{ item.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ item?.plugin_version }}</span>
|
||||
<VIcon v-if="item.installed" color="success" icon="mdi-check-circle" class="ms-2" size="small" />
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">
|
||||
<VChip
|
||||
v-for="label in pluginLabels(item.plugin_label)"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-1 my-1"
|
||||
color="info"
|
||||
label
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<span>
|
||||
{{ item.plugin_desc }}
|
||||
</span>
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 安装插件进度框 -->
|
||||
<VDialog v-model="progressDialog" :scrim="false" width="25rem">
|
||||
<VCard color="primary">
|
||||
<VCardText class="text-center">
|
||||
{{ progressText }}
|
||||
<VProgressLinear indeterminate color="white" class="mb-0 mt-1" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.grid-plugin-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
padding-block-end: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,17 +67,10 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<LoadingBanner
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
v-if="!isRefreshed"
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
class="mt-12"
|
||||
/>
|
||||
<PullRefresh
|
||||
v-model="loading"
|
||||
@refresh="onRefresh"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { debounce } from 'lodash'
|
||||
import { ref, unref } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import api from '@/api'
|
||||
import type { TransferHistory } from '@/api/types'
|
||||
import ReorganizeForm from '@/components/form/ReorganizeForm.vue'
|
||||
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -28,27 +29,27 @@ const headers = [
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '目录',
|
||||
key: 'src',
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '转移方式',
|
||||
key: 'mode',
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
key: 'date',
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
sortable: false,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
@@ -57,6 +58,15 @@ const headers = [
|
||||
},
|
||||
]
|
||||
|
||||
const pageRange = [
|
||||
{ title: '25', value: 25 },
|
||||
{ title: '50', value: 50 },
|
||||
{ title: '100', value: 100 },
|
||||
{ title: '500', value: 500 },
|
||||
{ title: '1000', value: 1000 },
|
||||
{ title: 'All', value: -1 },
|
||||
]
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<TransferHistory[]>([])
|
||||
|
||||
@@ -93,42 +103,6 @@ const deleteConfirmDialog = ref(false)
|
||||
// 确认框标题
|
||||
const confirmTitle = ref('')
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData({ page, itemsPerPage }: { page: number; itemsPerPage: number }) {
|
||||
loading.value = true
|
||||
try {
|
||||
currentPage.value = page
|
||||
|
||||
const result: { [key: string]: any } = await api.get('history/transfer', {
|
||||
params: {
|
||||
page,
|
||||
count: itemsPerPage,
|
||||
title: search.value,
|
||||
},
|
||||
})
|
||||
|
||||
dataList.value = result.data.list
|
||||
totalItems.value = result.data.total
|
||||
searchHintList.value = ['失败', '成功', ...new Set(dataList.value.map(item => item.title || ''))].filter(
|
||||
title => title !== '',
|
||||
)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 根据 type 返回不同的图标
|
||||
function getIcon(type: string) {
|
||||
if (type === '电影')
|
||||
return 'mdi-movie'
|
||||
else if (type === '电视剧')
|
||||
return 'mdi-television-classic'
|
||||
else
|
||||
return 'mdi-help-circle'
|
||||
}
|
||||
|
||||
// 转移方式字典
|
||||
const TransferDict: { [key: string]: string } = {
|
||||
copy: '复制',
|
||||
@@ -139,6 +113,61 @@ const TransferDict: { [key: string]: string } = {
|
||||
rclone_move: 'Rclone移动',
|
||||
}
|
||||
|
||||
// 分页提示
|
||||
const pageTip = computed(() => {
|
||||
const begin = unref(itemsPerPage) * (unref(currentPage) - 1) + 1
|
||||
const end = unref(itemsPerPage) * unref(currentPage) === -1 ? 'ALL' : unref(itemsPerPage) * unref(currentPage)
|
||||
return {
|
||||
begin,
|
||||
end,
|
||||
}
|
||||
})
|
||||
|
||||
// 分页总数
|
||||
const totalPage = computed(() => {
|
||||
const total = Math.ceil(unref(totalItems) / unref(itemsPerPage))
|
||||
return total
|
||||
})
|
||||
|
||||
// 切换页签和搜索词
|
||||
watch(
|
||||
[() => currentPage.value, () => itemsPerPage.value, () => search.value],
|
||||
debounce(async () => {
|
||||
await fetchData()
|
||||
}, 1000),
|
||||
)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData(page = currentPage.value, count = itemsPerPage.value) {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('history/transfer', {
|
||||
params: {
|
||||
page,
|
||||
count,
|
||||
title: search.value,
|
||||
},
|
||||
})
|
||||
|
||||
dataList.value = result.data?.list
|
||||
totalItems.value = result.data?.total
|
||||
searchHintList.value = ['失败', '成功', ...new Set(dataList.value.map(item => item.title || ''))].filter(
|
||||
title => title !== '',
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 根据 type 返回不同的图标
|
||||
function getIcon(type: string) {
|
||||
if (type === '电影') return 'mdi-movie'
|
||||
else if (type === '电视剧') return 'mdi-television-classic'
|
||||
else return 'mdi-help-circle'
|
||||
}
|
||||
|
||||
// 删除历史记录
|
||||
async function removeHistory(item: TransferHistory) {
|
||||
currentHistory.value = item
|
||||
@@ -156,10 +185,8 @@ async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boo
|
||||
data: item,
|
||||
})
|
||||
|
||||
if (!result.success)
|
||||
$toast.error(`删除失败: ${result.msg}`)
|
||||
}
|
||||
catch (error) {
|
||||
if (!result.success) $toast.error(`删除失败: ${result.msg}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -168,16 +195,12 @@ async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boo
|
||||
async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
|
||||
// 关闭弹窗
|
||||
deleteConfirmDialog.value = false
|
||||
if (!currentHistory.value)
|
||||
return
|
||||
if (!currentHistory.value) return
|
||||
|
||||
// 删除
|
||||
await remove(currentHistory.value, deleteSrc, deleteDest)
|
||||
// 刷新
|
||||
fetchData({
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
})
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 批量删除记录
|
||||
@@ -186,8 +209,7 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
|
||||
deleteConfirmDialog.value = false
|
||||
// 总条数
|
||||
const total = selected.value.length
|
||||
if (total === 0)
|
||||
return
|
||||
if (total === 0) return
|
||||
|
||||
// 已处理条数
|
||||
let handled = 0
|
||||
@@ -207,24 +229,18 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
|
||||
// 隐藏进度条
|
||||
progressDialog.value = false
|
||||
// 重新获取数据
|
||||
fetchData({
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
})
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 响应删除操作
|
||||
async function deleteConfirmHandler(deleteSrc: boolean, deleteDest: boolean) {
|
||||
if (currentHistory.value)
|
||||
await removeSingle(deleteSrc, deleteDest)
|
||||
else
|
||||
await removeBatch(deleteSrc, deleteDest)
|
||||
if (currentHistory.value) await removeSingle(deleteSrc, deleteDest)
|
||||
else await removeBatch(deleteSrc, deleteDest)
|
||||
}
|
||||
|
||||
// 批量删除历史记录
|
||||
async function removeHistoryBatch() {
|
||||
if (selected.value.length === 0)
|
||||
return
|
||||
if (selected.value.length === 0) return
|
||||
|
||||
// 清空当前操作记录
|
||||
currentHistory.value = undefined
|
||||
@@ -235,26 +251,20 @@ async function removeHistoryBatch() {
|
||||
|
||||
// 计算根路径
|
||||
function getRootPath(path: string, type: string, category: string) {
|
||||
if (!path)
|
||||
return ''
|
||||
if (!path) return ''
|
||||
|
||||
let index = -2
|
||||
if (type !== '电影')
|
||||
index = -3
|
||||
if (type !== '电影') index = -3
|
||||
|
||||
if (category)
|
||||
index -= 1
|
||||
if (category) index -= 1
|
||||
|
||||
if (path.includes('/'))
|
||||
return path.split('/').slice(0, index).join('/')
|
||||
else
|
||||
return path.split('\\').slice(0, index).join('\\')
|
||||
if (path.includes('/')) return path.split('/').slice(0, index).join('/')
|
||||
else return path.split('\\').slice(0, index).join('\\')
|
||||
}
|
||||
|
||||
// 批量重新整理
|
||||
async function retransferBatch() {
|
||||
if (selected.value.length === 0)
|
||||
return
|
||||
if (selected.value.length === 0) return
|
||||
|
||||
// 清空当前操作记录
|
||||
currentHistory.value = undefined
|
||||
@@ -270,8 +280,7 @@ async function retransferBatch() {
|
||||
const category = selected.value[0].category ?? ''
|
||||
// 计算根路径
|
||||
redoTarget.value = getRootPath(dest, mediaType, category)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
redoTarget.value = ''
|
||||
}
|
||||
// 打开识别弹窗
|
||||
@@ -304,25 +313,26 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 初始加载数据
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="pb-5">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VRow>
|
||||
<VCol cols="4" md="6">
|
||||
历史记录
|
||||
</VCol>
|
||||
<VCol cols="8" md="6">
|
||||
<VCol cols="4" md="6"> 历史记录 </VCol>
|
||||
<VCol cols="8" md="6" class="flex">
|
||||
<VCombobox
|
||||
key="search_navbar"
|
||||
v-model="search"
|
||||
:items="searchHintList"
|
||||
class="text-disabled"
|
||||
density="compact"
|
||||
label="搜索标题、状态"
|
||||
append-inner-icon="mdi-magnify"
|
||||
label="搜索目录、状态"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled"
|
||||
single-line
|
||||
hide-details
|
||||
@@ -334,58 +344,52 @@ const dropdownItems = ref([
|
||||
</VRow>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDataTableServer
|
||||
<VDataTableVirtual
|
||||
v-model="selected"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
:headers="headers"
|
||||
:items="dataList"
|
||||
:items-length="totalItems"
|
||||
:search="search"
|
||||
:loading="loading"
|
||||
density="compact"
|
||||
item-value="id"
|
||||
return-object
|
||||
fixed-header
|
||||
show-select
|
||||
items-per-page-text="每页条数"
|
||||
page-text="{0}-{1} 共 {2} 条"
|
||||
@update:options="fetchData"
|
||||
loading-text="加载中..."
|
||||
class="data-table-div"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<VAvatar>
|
||||
<VIcon :icon="getIcon(item.value.type || '')" />
|
||||
<VIcon :icon="getIcon(item.type || '')" />
|
||||
</VAvatar>
|
||||
<div class="d-flex flex-column ms-1">
|
||||
<span class="d-block text-high-emphasis">
|
||||
{{ item.value.title }} {{ item.value.seasons }}{{ item.value.episodes }}
|
||||
<span v-if="item.type === '电视剧'" class="d-block text-high-emphasis min-w-20">
|
||||
{{ item?.title }} {{ item?.seasons }}{{ item?.episodes }}
|
||||
</span>
|
||||
<small>{{ item.value.category }}</small>
|
||||
<span v-else class="d-block text-high-emphasis min-w-20">
|
||||
{{ item?.title }}
|
||||
</span>
|
||||
<small>{{ item?.category }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.src="{ item }">
|
||||
<small>{{ item.value.src }} <br>=> {{ item.value.dest }}</small>
|
||||
<small>{{ item?.src }} <br />=> {{ item?.dest }}</small>
|
||||
</template>
|
||||
<template #item.mode="{ item }">
|
||||
<VChip variant="outlined" color="primary" size="small">
|
||||
{{ TransferDict[item.value.mode] }}
|
||||
{{ TransferDict[item?.mode || ''] }}
|
||||
</VChip>
|
||||
</template>
|
||||
<template #item.status="{ item }">
|
||||
<VChip v-if="item.value.status" color="success" size="small">
|
||||
成功
|
||||
</VChip>
|
||||
<v-tooltip v-else :text="item.value.errmsg">
|
||||
<VChip v-if="item?.status" color="success" size="small"> 成功 </VChip>
|
||||
<VTooltip v-else :text="item?.errmsg">
|
||||
<template #activator="{ props }">
|
||||
<VChip v-bind="props" color="error" size="small">
|
||||
失败
|
||||
</VChip>
|
||||
<VChip v-bind="props" color="error" size="small"> 失败 </VChip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</VTooltip>
|
||||
</template>
|
||||
<template #item.date="{ item }">
|
||||
<small>{{ item.value.date }}</small>
|
||||
<small>{{ item?.date }}</small>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<IconBtn>
|
||||
@@ -397,7 +401,7 @@ const dropdownItems = ref([
|
||||
:key="i"
|
||||
variant="plain"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item.value)"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
@@ -408,24 +412,23 @@ const dropdownItems = ref([
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</template>
|
||||
<template #no-data>
|
||||
没有数据
|
||||
</template>
|
||||
</VDataTableServer>
|
||||
<template #no-data> 没有数据 </template>
|
||||
</VDataTableVirtual>
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="w-auto">
|
||||
<VSelect v-model="itemsPerPage" :items="pageRange" density="compact" variant="solo" flat />
|
||||
</div>
|
||||
<div class="w-auto text-sm">{{ pageTip.begin }}-{{ pageTip.end }} / {{ totalItems }}</div>
|
||||
<VPagination
|
||||
v-model="currentPage"
|
||||
show-first-last-page
|
||||
:length="totalPage"
|
||||
@next="currentPage + 1"
|
||||
@prev="currentPage - 1"
|
||||
>
|
||||
</VPagination>
|
||||
</div>
|
||||
</VCard>
|
||||
<!-- 底部操作按钮 -->
|
||||
<span v-if="selected.length > 0" class="fixed right-5 bottom-5">
|
||||
<VTooltip text="批量重新整理">
|
||||
<template #activator="{ props }">
|
||||
<VBtn v-bind="props" icon="mdi-redo-variant" class="me-2" color="primary" size="x-large" @click="retransferBatch" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip text="批量删除">
|
||||
<template #activator="{ props }">
|
||||
<VBtn v-bind="props" icon="mdi-trash-can-outline" color="error" size="x-large" @click="removeHistoryBatch" />
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
<!-- 底部弹窗 -->
|
||||
<VBottomSheet v-model="deleteConfirmDialog" inset>
|
||||
<VCard class="text-center rounded-t">
|
||||
@@ -434,12 +437,8 @@ const dropdownItems = ref([
|
||||
{{ confirmTitle }}
|
||||
</VCardTitle>
|
||||
<div class="d-flex flex-column flex-lg-row justify-center my-3">
|
||||
<VBtn color="primary" class="mb-2 mx-2" @click="deleteConfirmHandler(false, false)">
|
||||
仅删除历史记录
|
||||
</VBtn>
|
||||
<VBtn color="warning" class="mb-2 mx-2" @click="deleteConfirmHandler(true, false)">
|
||||
删除历史记录和源文件
|
||||
</VBtn>
|
||||
<VBtn color="primary" class="mb-2 mx-2" @click="deleteConfirmHandler(false, false)"> 仅删除历史记录 </VBtn>
|
||||
<VBtn color="warning" class="mb-2 mx-2" @click="deleteConfirmHandler(true, false)"> 删除历史记录和源文件 </VBtn>
|
||||
<VBtn color="info" class="mb-2 mx-2" @click="deleteConfirmHandler(false, true)">
|
||||
删除历史记录和媒体库文件
|
||||
</VBtn>
|
||||
@@ -450,7 +449,8 @@ const dropdownItems = ref([
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
<!-- 文件整理弹窗 -->
|
||||
<ReorganizeForm
|
||||
<ReorganizeDialog
|
||||
v-if="redoDialog"
|
||||
v-model="redoDialog"
|
||||
:logids="redoIds"
|
||||
:target="redoTarget"
|
||||
@@ -461,18 +461,50 @@ const dropdownItems = ref([
|
||||
currentHistory = undefined
|
||||
selected = []
|
||||
// 刷新
|
||||
fetchData({
|
||||
page: currentPage,
|
||||
itemsPerPage,
|
||||
})
|
||||
fetchData()
|
||||
}
|
||||
"
|
||||
@close="redoDialog = false"
|
||||
/>
|
||||
<!-- 底部操作按钮 -->
|
||||
<span>
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
icon="mdi-trash-can-outline"
|
||||
color="error"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="removeHistoryBatch"
|
||||
/>
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
class="mb-2"
|
||||
icon="mdi-redo-variant"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="retransferBatch"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table-div {
|
||||
block-size: calc(100vh - 14rem);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.data-table-div {
|
||||
block-size: calc(100vh - 17rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { calculateTimeDifference } from '@/@core/utils'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
|
||||
// 系统环境变量
|
||||
@@ -30,13 +30,10 @@ function showReleaseDialog(title: string, body: string) {
|
||||
// 查询系统环境变量
|
||||
async function querySystemEnv() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
'system/env',
|
||||
)
|
||||
const result: { [key: string]: any } = await api.get('system/env')
|
||||
|
||||
systemEnv.value = result.data
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -44,17 +41,13 @@ async function querySystemEnv() {
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
'system/versions',
|
||||
)
|
||||
const result: { [key: string]: any } = await api.get('system/versions')
|
||||
|
||||
allRelease.value = result.data ?? []
|
||||
|
||||
// 最新版本
|
||||
if (allRelease.value.length > 0)
|
||||
latestRelease.value = allRelease.value[0].tag_name
|
||||
}
|
||||
catch (error) {
|
||||
if (allRelease.value.length > 0) latestRelease.value = allRelease.value[0].tag_name
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +55,7 @@ async function queryAllRelease() {
|
||||
// 计算发布时间
|
||||
function releaseTime(releaseDate: string) {
|
||||
// 上一次更新时间
|
||||
return `${calculateTimeDifference(releaseDate)}前`
|
||||
return formatDateDifference(releaseDate)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -75,22 +68,25 @@ onMounted(() => {
|
||||
<div class="px-3">
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">
|
||||
关于 MoviePilot
|
||||
</h3>
|
||||
<h3 class="heading">关于 MoviePilot</h3>
|
||||
</div>
|
||||
<div class="section border-t border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
软件版本
|
||||
</dt>
|
||||
<dt class="block text-sm font-bold">软件版本</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.VERSION }}</code>
|
||||
<a v-if="latestRelease === systemEnv.VERSION" href="https://github.com/jxxghp/MoviePilot/releases" target="_blank" rel="noopener noreferrer">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400">
|
||||
<a
|
||||
v-if="latestRelease === systemEnv.VERSION"
|
||||
href="https://github.com/jxxghp/MoviePilot/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400"
|
||||
>
|
||||
最新
|
||||
</span>
|
||||
</a>
|
||||
@@ -98,11 +94,19 @@ onMounted(() => {
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="systemEnv.FRONTEND_VERSION">
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">前端版本</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.FRONTEND_VERSION }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
认证资源版本
|
||||
</dt>
|
||||
<dt class="block text-sm font-bold">认证资源版本</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.AUTH_VERSION }}</code>
|
||||
@@ -112,9 +116,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
站点资源版本
|
||||
</dt>
|
||||
<dt class="block text-sm font-bold">站点资源版本</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow flex flex-row items-center truncate">
|
||||
<code class="truncate">{{ systemEnv.INDEXER_VERSION }}</code>
|
||||
@@ -124,9 +126,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
配置目录
|
||||
</dt>
|
||||
<dt class="block text-sm font-bold">配置目录</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<code>{{ systemEnv.CONFIG_DIR }}</code>
|
||||
@@ -134,9 +134,7 @@ onMounted(() => {
|
||||
</dd>
|
||||
</div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
数据目录
|
||||
</dt>
|
||||
<dt class="block text-sm font-bold">数据目录</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined"><code>/moviepilot</code></span>
|
||||
</dd>
|
||||
@@ -144,9 +142,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
时区
|
||||
</dt>
|
||||
<dt class="block text-sm font-bold">时区</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<code>{{ systemEnv.TZ }}</code>
|
||||
@@ -159,44 +155,55 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">
|
||||
支援
|
||||
</h3>
|
||||
<h3 class="heading">支援</h3>
|
||||
</div>
|
||||
<div class="section border-t border-gray-800">
|
||||
<dl>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
文档
|
||||
</dt><dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<dt class="block text-sm font-bold">文档</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<a href="https://github.com/jxxghp/MoviePilot/blob/main/README.md" target="_blank" rel="noreferrer" class="text-indigo-500 transition duration-300 hover:underline">
|
||||
<a
|
||||
href="https://github.com/jxxghp/MoviePilot/blob/main/README.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://github.com/jxxghp/MoviePilot/blob/main/README.md
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div><div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
问题反馈
|
||||
</dt><dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<dt class="block text-sm font-bold">问题反馈</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<a href="https://github.com/jxxghp/MoviePilot/issues/new/choose" target="_blank" rel="noreferrer" class="text-indigo-500 transition duration-300 hover:underline">
|
||||
<a
|
||||
href="https://github.com/jxxghp/MoviePilot/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://github.com/jxxghp/MoviePilot/issues/new/choose
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div><div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">
|
||||
发布频道
|
||||
</dt>
|
||||
<dt class="block text-sm font-bold">发布频道</dt>
|
||||
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="flex-grow undefined">
|
||||
<a href="https://t.me/moviepilot_channel" target="_blank" rel="noreferrer" class="text-indigo-500 transition duration-300 hover:underline">
|
||||
<a
|
||||
href="https://t.me/moviepilot_channel"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-indigo-500 transition duration-300 hover:underline"
|
||||
>
|
||||
https://t.me/moviepilot_channel
|
||||
</a>
|
||||
</span>
|
||||
@@ -208,21 +215,31 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="section">
|
||||
<div>
|
||||
<h3 class="heading">
|
||||
软件版本
|
||||
</h3>
|
||||
<h3 class="heading">软件版本</h3>
|
||||
<div class="section space-y-3">
|
||||
<div>
|
||||
<div v-for="release in allRelease" :key="release.tag_name" class="mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 shadow-md ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3">
|
||||
<div
|
||||
v-for="release in allRelease"
|
||||
:key="release.tag_name"
|
||||
class="mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 shadow-md ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
>
|
||||
<div class="flex w-full flex-grow items-center justify-start space-x-2 truncate sm:justify-start">
|
||||
<span class="truncate text-lg font-bold">
|
||||
<span class="mr-2 whitespace-nowrap text-xs font-normal">{{ releaseTime(release.published_at) }}</span>
|
||||
<span class="mr-2 whitespace-nowrap text-xs font-normal">{{
|
||||
releaseTime(release.published_at)
|
||||
}}</span>
|
||||
{{ release.tag_name }}
|
||||
</span>
|
||||
<span v-if="release.tag_name === latestRelease" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100">
|
||||
<span
|
||||
v-if="release.tag_name === latestRelease"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100"
|
||||
>
|
||||
最新软件版本
|
||||
</span>
|
||||
<span v-if="release.tag_name === systemEnv.VERSION" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100">
|
||||
<span
|
||||
v-if="release.tag_name === systemEnv.VERSION"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100"
|
||||
>
|
||||
当前版本
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { VForm } from 'vuetify/lib/components/index.mjs'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
@@ -19,6 +21,18 @@ const refInputEl = ref<HTMLElement>()
|
||||
// 新增用户窗口
|
||||
const addUserDialog = ref(false)
|
||||
|
||||
// 开启双重验证窗口
|
||||
const otpDialog = ref(false)
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
// 新增用户表单
|
||||
const userForm = reactive({
|
||||
name: '',
|
||||
@@ -35,11 +49,15 @@ const accountInfo = ref<User>({
|
||||
is_active: false,
|
||||
is_superuser: false,
|
||||
avatar: '',
|
||||
is_otp: false,
|
||||
})
|
||||
|
||||
// 所有用户信息
|
||||
const allUsers = ref<User[]>([])
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// changeAvatar function
|
||||
function changeAvatar(file: Event) {
|
||||
const fileReader = new FileReader()
|
||||
@@ -65,7 +83,7 @@ function resetAvatar() {
|
||||
async function loadAccountInfo() {
|
||||
try {
|
||||
const user: User = await api.get('user/current')
|
||||
|
||||
console.log(user)
|
||||
accountInfo.value = user
|
||||
if (!accountInfo.value.avatar)
|
||||
accountInfo.value.avatar = avatar1
|
||||
@@ -167,6 +185,65 @@ async function addUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
async function getOtpUri() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user/otp/generate')
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
otpDialog.value = true
|
||||
}
|
||||
else {
|
||||
$toast.error(`获取otp uri失败:${result.message}!`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
async function disableOtp() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user/otp/disable')
|
||||
if (result.success) {
|
||||
accountInfo.value.is_otp = false
|
||||
$toast.success('关闭登录双重验证成功!')
|
||||
}
|
||||
else {
|
||||
$toast.error(`关闭otp失败:${result.message}!`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error('请填写6位验证码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user/otp/judge', { uri: otpUri.value, otpPassword: otpPassword.value })
|
||||
|
||||
if (result.success) {
|
||||
$toast.success('开启登录双重验证成功!')
|
||||
otpDialog.value = false
|
||||
accountInfo.value.is_otp = true
|
||||
}
|
||||
else {
|
||||
$toast.error(`开启otp失败:${result.message}!`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载当前用户数据
|
||||
onMounted(() => {
|
||||
loadAccountInfo()
|
||||
@@ -175,298 +252,364 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard title="个人信息">
|
||||
<VCardText class="d-flex">
|
||||
<!-- 👉 Avatar -->
|
||||
<VAvatar
|
||||
rounded="lg"
|
||||
size="100"
|
||||
class="me-6"
|
||||
:image="accountInfo.avatar"
|
||||
/>
|
||||
<div>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard title="个人信息">
|
||||
<VCardText class="d-flex">
|
||||
<!-- 👉 Avatar -->
|
||||
<VAvatar
|
||||
rounded="lg"
|
||||
size="100"
|
||||
class="me-6"
|
||||
:image="accountInfo.avatar"
|
||||
/>
|
||||
|
||||
<!-- 👉 Upload Photo -->
|
||||
<form class="d-flex flex-column justify-center gap-5">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="refInputEl?.click()"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-cloud-upload-outline"
|
||||
class="d-sm-none"
|
||||
/>
|
||||
<span class="d-none d-sm-block">上传头像</span>
|
||||
</VBtn>
|
||||
|
||||
<input
|
||||
ref="refInputEl"
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".jpeg,.png,.jpg,GIF"
|
||||
hidden
|
||||
@input="changeAvatar"
|
||||
>
|
||||
|
||||
<VBtn
|
||||
type="reset"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
@click="resetAvatar"
|
||||
>
|
||||
<span class="d-none d-sm-block">重置</span>
|
||||
<VIcon
|
||||
icon="mdi-refresh"
|
||||
class="d-sm-none"
|
||||
/>
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-0">
|
||||
允许 JPG、GIF 或 PNG 格式, 最大尺寸 800K。
|
||||
</p>
|
||||
</form>
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText>
|
||||
<!-- 👉 Form -->
|
||||
<VForm class="mt-6">
|
||||
<VRow>
|
||||
<!-- 👉 Name -->
|
||||
<VCol
|
||||
md="6"
|
||||
cols="12"
|
||||
>
|
||||
<VTextField
|
||||
v-model="accountInfo.name"
|
||||
readonly
|
||||
label="用户名"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- 👉 Email -->
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="accountInfo.email"
|
||||
label="邮箱"
|
||||
type="email"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<!-- 👉 new password -->
|
||||
<VTextField
|
||||
v-model="newPassword"
|
||||
:type="isNewPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
||||
"
|
||||
label="新密码"
|
||||
autocomplete="new-password"
|
||||
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<!-- 👉 confirm password -->
|
||||
<VTextField
|
||||
v-model="confirmPassword"
|
||||
:type="isConfirmPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
||||
"
|
||||
label="确认新密码"
|
||||
@click:append-inner="
|
||||
isConfirmPasswordVisible = !isConfirmPasswordVisible
|
||||
"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- 👉 Form Actions -->
|
||||
<VCol
|
||||
cols="12"
|
||||
class="d-flex flex-wrap gap-4"
|
||||
>
|
||||
<VBtn @click="saveAccountInfo">
|
||||
保存
|
||||
<!-- 👉 Upload Photo -->
|
||||
<form class="d-flex flex-column justify-center gap-5">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="refInputEl?.click()"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-cloud-upload-outline"
|
||||
/>
|
||||
<span class="d-none d-sm-block ms-2">上传头像</span>
|
||||
</VBtn>
|
||||
|
||||
<input
|
||||
ref="refInputEl"
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".jpeg,.png,.jpg,GIF"
|
||||
hidden
|
||||
@input="changeAvatar"
|
||||
>
|
||||
|
||||
<VBtn
|
||||
type="reset"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
@click="resetAvatar"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-refresh"
|
||||
/>
|
||||
<span class="d-none d-sm-block ms-2">重置</span>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
:color="accountInfo.is_otp ? 'error' : 'info'"
|
||||
variant="tonal"
|
||||
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-account-key"
|
||||
/>
|
||||
<span class="d-none d-sm-block ms-2">{{ accountInfo.is_otp ? "关闭验证" : "双重验证" }}</span>
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<p class="text-body-1 mb-0">
|
||||
允许 JPG、GIF 或 PNG 格式, 最大尺寸 800K。
|
||||
</p>
|
||||
</form>
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText>
|
||||
<!-- 👉 Form -->
|
||||
<VForm class="mt-6">
|
||||
<VRow>
|
||||
<!-- 👉 Name -->
|
||||
<VCol
|
||||
md="6"
|
||||
cols="12"
|
||||
>
|
||||
<VTextField
|
||||
v-model="accountInfo.name"
|
||||
readonly
|
||||
label="用户名"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- 👉 Email -->
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="accountInfo.email"
|
||||
label="邮箱"
|
||||
type="email"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<!-- 👉 new password -->
|
||||
<VTextField
|
||||
v-model="newPassword"
|
||||
:type="isNewPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
label="新密码"
|
||||
autocomplete=""
|
||||
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<!-- 👉 confirm password -->
|
||||
<VTextField
|
||||
v-model="confirmPassword"
|
||||
:type="isConfirmPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
||||
"
|
||||
label="确认新密码"
|
||||
@click:append-inner="
|
||||
isConfirmPasswordVisible = !isConfirmPasswordVisible
|
||||
"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<!-- 👉 Form Actions -->
|
||||
<VCol
|
||||
cols="12"
|
||||
class="d-flex flex-wrap gap-4"
|
||||
>
|
||||
<VBtn @click="saveAccountInfo">
|
||||
保存
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<VCol
|
||||
v-if="accountInfo.is_superuser"
|
||||
cols="12"
|
||||
>
|
||||
<!-- 👉 Accounts -->
|
||||
<VCard title="所有用户">
|
||||
<template #append>
|
||||
<IconBtn @click.stop="addUserDialog = true">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VTable class="text-no-wrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
用户名
|
||||
</th>
|
||||
<th scope="col">
|
||||
邮箱
|
||||
</th>
|
||||
<th scope="col">
|
||||
状态
|
||||
</th>
|
||||
<th scope="col">
|
||||
管理员
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-5"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in allUsers"
|
||||
:key="user.name"
|
||||
>
|
||||
<td>
|
||||
{{ user.name }}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
<VChip
|
||||
v-if="user.is_active"
|
||||
color="success"
|
||||
text-color="white"
|
||||
>
|
||||
激活
|
||||
</VChip>
|
||||
<VChip
|
||||
v-else
|
||||
color="error"
|
||||
text-color="white"
|
||||
>
|
||||
冻结
|
||||
</VChip>
|
||||
</td>
|
||||
<td>{{ user.is_superuser ? "是" : "否" }}</td>
|
||||
<td>
|
||||
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name !== user.name">
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
@click="deactivateUser(user)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-lock" />
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{
|
||||
user.is_active ? "冻结" : "解冻"
|
||||
}}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
base-color="error"
|
||||
@click="deleteUser(user)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
<VListItemTitle>删除</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<!-- =弹窗 -->
|
||||
<VDialog
|
||||
v-model="addUserDialog"
|
||||
max-width="50rem"
|
||||
persistent
|
||||
z-index="1010"
|
||||
>
|
||||
<!-- Dialog Content -->
|
||||
<VCard title="新增用户">
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="userForm.name"
|
||||
label="用户名"
|
||||
:rules="[requiredValidator]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="userForm.password"
|
||||
label="密码"
|
||||
:rules="[requiredValidator]"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
||||
"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="userForm.email"
|
||||
:rules="[requiredValidator]"
|
||||
label="邮箱"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn @click="addUserDialog = false">
|
||||
取消
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="addUser">
|
||||
确定
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VDialog>
|
||||
|
||||
<VCol
|
||||
v-if="accountInfo.is_superuser"
|
||||
cols="12"
|
||||
<!-- 双重验证弹窗 -->
|
||||
<VDialog
|
||||
v-model="otpDialog"
|
||||
max-width="45rem"
|
||||
persistent
|
||||
z-index="1010"
|
||||
>
|
||||
<!-- 👉 Accounts -->
|
||||
<VCard title="所有用户">
|
||||
<template #append>
|
||||
<IconBtn @click.stop="addUserDialog = true">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VTable class="text-no-wrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
用户名
|
||||
</th>
|
||||
<th scope="col">
|
||||
邮箱
|
||||
</th>
|
||||
<th scope="col">
|
||||
状态
|
||||
</th>
|
||||
<th scope="col">
|
||||
管理员
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="w-5"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in allUsers"
|
||||
:key="user.name"
|
||||
>
|
||||
<td>
|
||||
{{ user.name }}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
<VChip
|
||||
v-if="user.is_active"
|
||||
color="success"
|
||||
text-color="white"
|
||||
>
|
||||
激活
|
||||
</VChip>
|
||||
<VChip
|
||||
v-else
|
||||
color="error"
|
||||
text-color="white"
|
||||
>
|
||||
冻结
|
||||
</VChip>
|
||||
</td>
|
||||
<td>{{ user.is_superuser ? "是" : "否" }}</td>
|
||||
<td>
|
||||
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name != user.name">
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VList>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
@click="deactivateUser(user)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-lock" />
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{
|
||||
user.is_active ? "冻结" : "解冻"
|
||||
}}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
base-color="error"
|
||||
@click="deleteUser(user)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
<VListItemTitle>删除</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
<!-- 开启双重验证弹窗内容 -->
|
||||
<VCard>
|
||||
<DialogCloseBtn @click="otpDialog = false" />
|
||||
<VCardText>
|
||||
<h4 class="text-h4 text-center mb-6 mt-5">
|
||||
登录双重验证
|
||||
</h4><h5 class="text-h5 font-weight-medium mb-2">
|
||||
身份验证器
|
||||
</h5>
|
||||
<p class="mb-6">
|
||||
使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。
|
||||
</p>
|
||||
<div class="my-6">
|
||||
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
|
||||
</div>
|
||||
<VAlert
|
||||
:title="secret"
|
||||
variant="tonal"
|
||||
type="warning"
|
||||
class="my-4"
|
||||
text="如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。"
|
||||
>
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm>
|
||||
<VTextField
|
||||
v-model="otpPassword"
|
||||
type="text"
|
||||
label="输入验证码以确认开启双重验证"
|
||||
autocomplete=""
|
||||
class="mb-8"
|
||||
variant="outlined"
|
||||
/>
|
||||
<div class="d-flex justify-end flex-wrap gap-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
|
||||
取消
|
||||
</VBtn>
|
||||
<VBtn @click="judgeOtpPassword">
|
||||
确定
|
||||
<template #append>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<!-- 站点编辑弹窗 -->
|
||||
<VDialog
|
||||
v-model="addUserDialog"
|
||||
max-width="50rem"
|
||||
persistent
|
||||
>
|
||||
<!-- Dialog Content -->
|
||||
<VCard title="新增用户">
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="userForm.name"
|
||||
label="用户名"
|
||||
:rules="[requiredValidator]"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="userForm.password"
|
||||
label="密码"
|
||||
:rules="[requiredValidator]"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
||||
"
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="userForm.email"
|
||||
:rules="[requiredValidator]"
|
||||
label="邮箱"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn @click="addUserDialog = false">
|
||||
取消
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="addUser">
|
||||
确定
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,7 +96,7 @@ async function loadNotificationSettings() {
|
||||
try {
|
||||
const result1: { [key: string]: any } = await api.get('system/setting/MESSAGER')
|
||||
if (result1.success)
|
||||
selectedChannels.value = result1.data?.value?.split(',')
|
||||
selectedChannels.value = result1.data && result1.data.value ? result1.data.value.split(',') : []
|
||||
|
||||
const result2: { [key: string]: any } = await api.get('system/env')
|
||||
if (result2.success) {
|
||||
@@ -209,6 +209,7 @@ onMounted(() => {
|
||||
chips
|
||||
:items="NotificationChannels"
|
||||
label="当前使用通知渠道"
|
||||
hint="选中的渠道才会按消息类型的设定发送消息"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -246,36 +247,42 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_CORPID"
|
||||
label="企业ID"
|
||||
hint="登录企业微信后台,在 https://work.weixin.qq.com/wework_admin/frame#profile 中查看"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_APP_SECRET"
|
||||
label="应用密钥"
|
||||
label="应用Secret"
|
||||
hint="在企业微信中创建应用,查看应用的Secret"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_APP_ID"
|
||||
label="应用ID"
|
||||
label="应用 AgentId"
|
||||
hint="在企业微信中创建应用,查看应用的AgentId"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_PROXY"
|
||||
label="代理地址"
|
||||
hint="由于微信官方限制,2022年6月20日后创建的企业微信应用需要有固定的公网IP地址并加入IP白名单后才能接收消息,使用有固定公网IP的代理服务器转发可解决该问题;代理服务器需自行搭建,搭建方法参考项目主页说明,不使用代理需保留默认值"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_TOKEN"
|
||||
label="Token"
|
||||
hint="在微信企业应用管理后台-接收消息设置页面生成"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="notificationSettings.WECHAT_ENCODING_AESKEY"
|
||||
label="EncodingAESKey"
|
||||
hint="在微信企业应用管理后台-接收消息设置页面生成,所有信息填入完成后保存,然后再在企业微信应用消息接收服务中输入回调地址:http(s)://domain:port/api/v1/message/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -283,6 +290,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.WECHAT_ADMINS"
|
||||
label="管理员白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用菜单管理功能,不填写则所有用户都能使用,菜单会自动生成,不需要手动创建"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -295,12 +303,14 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.TELEGRAM_TOKEN"
|
||||
label="Bot Token"
|
||||
hint="Telegram机器人的token,关注BotFather创建机器人并获取token,格式为:123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationSettings.TELEGRAM_CHAT_ID"
|
||||
label="Chat ID"
|
||||
hint="接受消息通知的用户、群组或频道Chat ID,关注@getidsbot获取"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -308,6 +318,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.TELEGRAM_USERS"
|
||||
label="用户白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用Telegram机器人,不填写则所有用户都能使用,多个用户用英文,分隔"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -315,6 +326,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.TELEGRAM_ADMINS"
|
||||
label="管理员白名单"
|
||||
placeholder="多个用,分隔"
|
||||
hint="只有在白名单中的用户才能使用管理功能,不填写则所有用户都能使用,多个用户用英文,分隔。菜单会自动生成,不需要手动创建"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -327,12 +339,16 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.SLACK_OAUTH_TOKEN"
|
||||
label="Slack Bot User OAuth Token"
|
||||
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
hint="在 https://api.slack.com/apps 中创建应用,查看OAuth & Permissions页面中的Bot User OAuth Token"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="5">
|
||||
<VTextField
|
||||
v-model="notificationSettings.SLACK_APP_TOKEN"
|
||||
label="Slack App-Level Token"
|
||||
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
hint="在 https://api.slack.com/apps 中创建应用,查看OAuth & Permissions页面中的App-Level Token"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2">
|
||||
@@ -340,6 +356,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.SLACK_CHANNEL"
|
||||
label="频道名称"
|
||||
placeholder="全体"
|
||||
hint="消息发送到的频道名称,不填写则发送到全体频道"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -351,13 +368,15 @@ onMounted(() => {
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationSettings.SYNOLOGYCHAT_WEBHOOK"
|
||||
label="Webhook"
|
||||
label="机器人传入URL"
|
||||
hint="在Synology Chat中创建机器人,获取机器人传入URL"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationSettings.SYNOLOGYCHAT_TOKEN"
|
||||
label="Token"
|
||||
label="令牌"
|
||||
hint="在Synology Chat中创建机器人,获取机器人令牌"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -376,6 +395,7 @@ onMounted(() => {
|
||||
<VTextField
|
||||
v-model="notificationSettings.VOCECHAT_API_KEY"
|
||||
label="机器人密钥"
|
||||
hint="在VoceChat中创建机器人,获取机器人密钥"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -383,6 +403,7 @@ onMounted(() => {
|
||||
v-model="notificationSettings.VOCECHAT_CHANNEL_ID"
|
||||
label="频道ID"
|
||||
placeholder="不包含#号"
|
||||
hint="在VoceChat中创建频道,获取频道ID,不包含#号"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -4,7 +4,7 @@ import api from '@/api'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import type { Site } from '@/api/types'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import ImportCodeForm from '@/components/form/ImportCodeForm.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
|
||||
// 规则卡片类型
|
||||
interface FilterCard {
|
||||
@@ -30,8 +30,29 @@ const selectedSites = ref<number[]>([])
|
||||
const defaultFilterRules = ref({
|
||||
include: '',
|
||||
exclude: '',
|
||||
min_seeders: 0,
|
||||
min_seeders_time: 0,
|
||||
})
|
||||
|
||||
// 媒体信息数据源字典
|
||||
const mediaSourcesDict = [
|
||||
{
|
||||
title: 'TheMovieDb',
|
||||
value: 'themoviedb',
|
||||
},
|
||||
{
|
||||
title: '豆瓣',
|
||||
value: 'douban',
|
||||
},
|
||||
{
|
||||
title: 'Bangumi',
|
||||
value: 'bangumi',
|
||||
},
|
||||
]
|
||||
|
||||
// 当前选中的媒体信息数据源
|
||||
const selectedMediaSource = ref([])
|
||||
|
||||
// 导入代码弹窗
|
||||
const importCodeDialog = ref(false)
|
||||
|
||||
@@ -54,8 +75,7 @@ async function queryCustomFilters() {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -73,17 +93,11 @@ async function saveCustomFilters() {
|
||||
.join('>')
|
||||
}
|
||||
// 保存
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
'system/setting/SearchFilterRules',
|
||||
value,
|
||||
)
|
||||
const result: { [key: string]: any } = await api.post('system/setting/SearchFilterRules', value)
|
||||
|
||||
if (result.success)
|
||||
$toast.success('搜索优先级保存成功')
|
||||
else
|
||||
$toast.error('搜索优先级保存失败!')
|
||||
}
|
||||
catch (error) {
|
||||
if (result.success) $toast.success('搜索优先级保存成功')
|
||||
else $toast.error('搜索优先级保存失败!')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -91,8 +105,7 @@ async function saveCustomFilters() {
|
||||
// 更新规则卡片的值
|
||||
function updateFilterCardValue(pri: string, rules: string[]) {
|
||||
const card = filterCards.value.find(card => card.pri === pri)
|
||||
if (card)
|
||||
card.rules = rules
|
||||
if (card) card.rules = rules
|
||||
}
|
||||
|
||||
// 移除卡片
|
||||
@@ -128,8 +141,7 @@ async function querySites() {
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
querySelectedSites()
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -140,8 +152,7 @@ async function querySelectedSites() {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -152,12 +163,9 @@ async function saveSelectedSites() {
|
||||
// 用户名密码
|
||||
const result: { [key: string]: any } = await api.post('system/setting/IndexerSites', selectedSites.value)
|
||||
|
||||
if (result.success)
|
||||
$toast.success('搜索站点保存成功')
|
||||
else
|
||||
$toast.error('搜索站点保存失败!')
|
||||
}
|
||||
catch (error) {
|
||||
if (result.success) $toast.success('搜索站点保存成功')
|
||||
else $toast.error('搜索站点保存失败!')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -166,13 +174,11 @@ async function saveSelectedSites() {
|
||||
function onLevelUp(pri: string) {
|
||||
// 找到当前卡片
|
||||
const card = filterCards.value.find(card => card.pri === pri)
|
||||
if (!card)
|
||||
return
|
||||
if (!card) return
|
||||
|
||||
// 找到当前卡片的上一张卡片
|
||||
const prevCard = filterCards.value.find(card => card.pri === (parseInt(pri) - 1).toString())
|
||||
if (!prevCard)
|
||||
return
|
||||
if (!prevCard) return
|
||||
|
||||
// 交换两张卡片的优先级
|
||||
const temp = card.pri
|
||||
@@ -187,13 +193,11 @@ function onLevelUp(pri: string) {
|
||||
function onLevelDown(pri: string) {
|
||||
// 找到当前卡片
|
||||
const card = filterCards.value.find(card => card.pri === pri)
|
||||
if (!card)
|
||||
return
|
||||
if (!card) return
|
||||
|
||||
// 找到当前卡片的下一张卡片
|
||||
const nextCard = filterCards.value.find(card => card.pri === (parseInt(pri) + 1).toString())
|
||||
if (!nextCard)
|
||||
return
|
||||
if (!nextCard) return
|
||||
|
||||
// 交换两张卡片的优先级
|
||||
const temp = card.pri
|
||||
@@ -207,13 +211,9 @@ function onLevelDown(pri: string) {
|
||||
// 查询包含与排除规则
|
||||
async function queryDefaultFilter() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(
|
||||
'system/setting/DefaultSearchFilterRules',
|
||||
)
|
||||
if (result.data?.value)
|
||||
defaultFilterRules.value = result.data?.value
|
||||
}
|
||||
catch (error) {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/DefaultSearchFilterRules')
|
||||
if (result.data?.value) defaultFilterRules.value = result.data?.value
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -225,12 +225,9 @@ async function saveDefaultFilter() {
|
||||
'system/setting/DefaultSearchFilterRules',
|
||||
defaultFilterRules.value,
|
||||
)
|
||||
if (result.success)
|
||||
$toast.success('默认包含/排除规则保存成功')
|
||||
else
|
||||
$toast.error('默认包含/排除规则保存失败!')
|
||||
}
|
||||
catch (error) {
|
||||
if (result.success) $toast.success('默认包含/排除规则保存成功')
|
||||
else $toast.error('默认包含/排除规则保存失败!')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
@@ -238,8 +235,7 @@ async function saveDefaultFilter() {
|
||||
// 分享规则
|
||||
function shareRules() {
|
||||
// 有值才处理
|
||||
if (filterCards.value.length === 0)
|
||||
return
|
||||
if (filterCards.value.length === 0) return
|
||||
|
||||
// 将卡片规则接装为字符串
|
||||
const value = filterCards.value
|
||||
@@ -251,22 +247,18 @@ function shareRules() {
|
||||
try {
|
||||
copyToClipboard(value)
|
||||
$toast.success('优先级规则已复制到剪贴板')
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
$toast.error('优先级规则复制失败!')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听导入代码变化
|
||||
watchEffect(() => {
|
||||
if (!importCodeString.value)
|
||||
return
|
||||
if (!importCodeString.value) return
|
||||
|
||||
// 导入代码需要以空格开头和结束,没有则拼接
|
||||
if (!importCodeString.value.startsWith(' '))
|
||||
importCodeString.value = ` ${importCodeString.value}`
|
||||
if (!importCodeString.value.endsWith(' '))
|
||||
importCodeString.value = `${importCodeString.value} `
|
||||
if (!importCodeString.value.startsWith(' ')) importCodeString.value = ` ${importCodeString.value}`
|
||||
if (!importCodeString.value.endsWith(' ')) importCodeString.value = `${importCodeString.value} `
|
||||
|
||||
// 将导入的代码转换为规则卡片
|
||||
const groups = importCodeString.value.split('>')
|
||||
@@ -278,15 +270,67 @@ watchEffect(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 调用API查询下载器设置
|
||||
async function loadMediaSourceSetting() {
|
||||
try {
|
||||
const result1: { [key: string]: any } = await api.get('system/setting/SEARCH_SOURCE')
|
||||
if (result1.success) selectedMediaSource.value = result1.data?.value?.split(',')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API保存下载器设置
|
||||
async function saveMediaSourceSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
'system/setting/SEARCH_SOURCE',
|
||||
selectedMediaSource.value.join(','),
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success('保存媒体数据源设置成功')
|
||||
} else {
|
||||
$toast.error('保存媒体数据源设置失败!')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryCustomFilters()
|
||||
querySites()
|
||||
queryDefaultFilter()
|
||||
loadMediaSourceSetting()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCard title="媒体数据源">
|
||||
<VCardSubtitle> 设定搜索时展示哪些源的媒体信息。</VCardSubtitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="selectedMediaSource"
|
||||
multiple
|
||||
chips
|
||||
:items="mediaSourcesDict"
|
||||
label="当前使用数据源"
|
||||
hint="选中多项时会同时展示来自不同数据源的搜索结果,选择的数据源顺序将会搜索结果的排序"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
|
||||
<VCardItem>
|
||||
<VBtn type="submit" @click="saveMediaSourceSetting"> 保存 </VBtn>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCard title="搜索站点">
|
||||
<VCardSubtitle> 只有选中的站点才会在搜索中使用。</VCardSubtitle>
|
||||
@@ -307,9 +351,7 @@ onMounted(() => {
|
||||
</VCardItem>
|
||||
|
||||
<VCardItem>
|
||||
<VBtn type="submit" @click="saveSelectedSites">
|
||||
保存
|
||||
</VBtn>
|
||||
<VBtn type="submit" @click="saveSelectedSites"> 保存 </VBtn>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VCol>
|
||||
@@ -318,24 +360,15 @@ onMounted(() => {
|
||||
<template #append>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu
|
||||
activator="parent"
|
||||
close-on-content-click
|
||||
>
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
@click="shareRules"
|
||||
>
|
||||
<VListItem variant="plain" @click="shareRules">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-share" />
|
||||
</template>
|
||||
<VListItemTitle>分享</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
variant="plain"
|
||||
@click="importCodeDialog = true"
|
||||
>
|
||||
<VListItem variant="plain" @click="importCodeDialog = true">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-import" />
|
||||
</template>
|
||||
@@ -362,18 +395,8 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
<VBtn
|
||||
type="submit"
|
||||
class="me-2"
|
||||
@click="saveCustomFilters()"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="success"
|
||||
variant="tonal"
|
||||
@click="addFilterCard()"
|
||||
>
|
||||
<VBtn type="submit" class="me-2" @click="saveCustomFilters()"> 保存 </VBtn>
|
||||
<VBtn color="success" variant="tonal" @click="addFilterCard()">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
</VCardItem>
|
||||
@@ -390,6 +413,7 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.include"
|
||||
type="text"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -397,32 +421,38 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.exclude"
|
||||
type="text"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="defaultFilterRules.min_seeders"
|
||||
type="text"
|
||||
label="最小做种数"
|
||||
placeholder="0"
|
||||
hint="小于该值的资源将被过滤掉,0表示不过滤"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="defaultFilterRules.min_seeders_time"
|
||||
type="text"
|
||||
label="最少做种人数生效发布时间(分钟)"
|
||||
placeholder="0"
|
||||
hint="发布时间距现在大于该值的资源将生效最小做种数规则,0表示不生效"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardItem>
|
||||
<VBtn
|
||||
type="submit"
|
||||
@click="saveDefaultFilter"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
<VBtn type="submit" @click="saveDefaultFilter"> 保存 </VBtn>
|
||||
</VCardItem>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VDialog
|
||||
v-model="importCodeDialog"
|
||||
width="60rem"
|
||||
scrollable
|
||||
>
|
||||
<ImportCodeForm
|
||||
v-model="importCodeString"
|
||||
title="导入优先级规则"
|
||||
@close="importCodeDialog = false"
|
||||
/>
|
||||
<VDialog v-model="importCodeDialog" width="60rem" scrollable>
|
||||
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const cookieCloudSetting = ref({
|
||||
COOKIECLOUD_PASSWORD: '',
|
||||
COOKIECLOUD_INTERVAL: 0,
|
||||
USER_AGENT: '',
|
||||
COOKIECLOUD_ENABLE_LOCAL: '',
|
||||
})
|
||||
|
||||
// 种子优先规则下拉框
|
||||
@@ -108,6 +109,7 @@ async function loadCookieCloudSettings() {
|
||||
COOKIECLOUD_PASSWORD,
|
||||
COOKIECLOUD_INTERVAL,
|
||||
USER_AGENT,
|
||||
COOKIECLOUD_ENABLE_LOCAL,
|
||||
} = result.data
|
||||
cookieCloudSetting.value = {
|
||||
COOKIECLOUD_HOST,
|
||||
@@ -115,6 +117,7 @@ async function loadCookieCloudSettings() {
|
||||
COOKIECLOUD_PASSWORD,
|
||||
COOKIECLOUD_INTERVAL,
|
||||
USER_AGENT,
|
||||
COOKIECLOUD_ENABLE_LOCAL,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,18 +158,30 @@ onMounted(() => {
|
||||
<VCardSubtitle> 从CookieCloud快速同步站点数据。 </VCardSubtitle>
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCheckbox
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
|
||||
label="启用本地CookieCloud服务器"
|
||||
hint="启用后,将使用内建CookieCloud服务同步站点数据,服务地址为:http://localhost:3000/cookiecloud"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_HOST"
|
||||
label="CookieCloud服务器地址"
|
||||
label="远程CookieCloud服务器地址"
|
||||
placeholder="https://movie-pilot.org/cookiecloud"
|
||||
:disabled="!!cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
|
||||
hint="格式:https://movie-pilot.org/cookiecloud"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_KEY"
|
||||
label="用户KEY"
|
||||
hint="在CookieCloud浏览器插件中生成"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -174,6 +189,7 @@ onMounted(() => {
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_PASSWORD"
|
||||
type="password"
|
||||
label="端对端加密密码"
|
||||
hint="在CookieCloud浏览器插件中生成"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -181,12 +197,14 @@ onMounted(() => {
|
||||
v-model="cookieCloudSetting.COOKIECLOUD_INTERVAL"
|
||||
label="自动同步间隔"
|
||||
:items="CookieCloudIntervalItems"
|
||||
hint="设置定时从CookieCloud服务器同步站点Cookie到MoviePilot的时间周期"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="cookieCloudSetting.USER_AGENT"
|
||||
label="浏览器User-Agent"
|
||||
hint="设置为CookieCloud插件所在的浏览器的User-Agent,用于模拟浏览器请求,正确填写后有助于提升站点访问成功率"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -213,6 +231,7 @@ onMounted(() => {
|
||||
v-model="selectedTorrentPriority"
|
||||
:items="TorrentPriorityItems"
|
||||
label="当前使用下载优先规则"
|
||||
hint="站点优先:优先下载站点优先级最高的站点的种子;做种数优先:优先下载做种数量最多的种子。注意下载优先级仍然低于搜索和订阅中设定的优先级规则"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -232,7 +251,11 @@ onMounted(() => {
|
||||
<VCard title="站点重置">
|
||||
<VCardText>
|
||||
<div>
|
||||
<VCheckbox v-model="isConfirmResetSites" label="确认删除所有站点数据并重新同步。" />
|
||||
<VCheckbox
|
||||
v-model="isConfirmResetSites"
|
||||
label="确认删除所有站点数据并重新同步。"
|
||||
hint="删除所有站点数据并重新同步,站点图标短时间内会因数缓存而混乱,重启或者等待2两时自动恢复。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VBtn :disabled="!isConfirmResetSites || resetSitesDisabled" color="error" class="mt-3" @click="resetSites">
|
||||
|
||||
@@ -4,7 +4,7 @@ import api from '@/api'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import type { Site } from '@/api/types'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import ImportCodeForm from '@/components/form/ImportCodeForm.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
|
||||
// 规则卡片类型
|
||||
interface FilterCard {
|
||||
@@ -42,7 +42,7 @@ const defaultFilterRules = ref({
|
||||
movie_size: '',
|
||||
tv_size: '',
|
||||
min_seeders: 0,
|
||||
show_edit_dialog: false,
|
||||
min_seeders_time: 0
|
||||
})
|
||||
|
||||
// 订阅模式选择项
|
||||
@@ -411,6 +411,7 @@ onMounted(() => {
|
||||
v-model="selectedSubscribeMode"
|
||||
:items="subscribeModeItems"
|
||||
label="订阅模式"
|
||||
hint="自动:系统自动爬取站点首页资源;站点RSS:使用站点RSS订阅资源,站点RSS会自动获取,也可手动在站点管理中补全"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -418,6 +419,7 @@ onMounted(() => {
|
||||
v-model="selectedRssInterval"
|
||||
:items="rssIntervalItems"
|
||||
label="站点RSS周期"
|
||||
hint="设置站点RSS运行周期,在订阅模式为站点RSS时生效"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -426,6 +428,7 @@ onMounted(() => {
|
||||
<VSwitch
|
||||
v-model="enableIntervalSearch"
|
||||
label="开启订阅定时搜索"
|
||||
hint="开启后,系统每隔24小时将按名称搜索全站,补全订阅可能漏掉的资源"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -581,6 +584,7 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.include"
|
||||
type="text"
|
||||
label="包含(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -588,36 +592,43 @@ onMounted(() => {
|
||||
v-model="defaultFilterRules.exclude"
|
||||
type="text"
|
||||
label="排除(关键字、正则式)"
|
||||
hint="支持正式表达式,多个关键字用 | 分隔表示或"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="defaultFilterRules.movie_size"
|
||||
type="text"
|
||||
label="电影文件大小(GB)"
|
||||
placeholder="0-30"
|
||||
hint="格式:0-30,表示0到30GB之间的资源"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="defaultFilterRules.tv_size"
|
||||
type="text"
|
||||
label="剧集单集文件大小(GB)"
|
||||
placeholder="0-10"
|
||||
hint="格式:0-10,表示0到10GB之间的资源"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="defaultFilterRules.min_seeders"
|
||||
type="text"
|
||||
label="最小做种数"
|
||||
placeholder="0"
|
||||
hint="小于该值的资源将被过滤掉,0表示不过滤"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="defaultFilterRules.show_edit_dialog"
|
||||
label="订阅时编辑更多规则"
|
||||
<VTextField
|
||||
v-model="defaultFilterRules.min_seeders_time"
|
||||
type="text"
|
||||
label="最少做种人数生效发布时间(分钟)"
|
||||
placeholder="0"
|
||||
hint="发布时间距现在大于该值的资源将生效最小做种数规则,0表示不生效"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -639,7 +650,7 @@ onMounted(() => {
|
||||
width="60rem"
|
||||
scrollable
|
||||
>
|
||||
<ImportCodeForm
|
||||
<ImportCodeDialog
|
||||
v-model="importCodeString"
|
||||
title="导入优先级规则"
|
||||
@close="importCodeDialog = false"
|
||||
|
||||
@@ -184,7 +184,7 @@ async function saveMediaSetting() {
|
||||
}
|
||||
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownladerSetting() {
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
const result1: { [key: string]: any } = await api.get('system/setting/DOWNLOADER')
|
||||
if (result1.success)
|
||||
@@ -330,7 +330,7 @@ async function reloadModule() {
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
loadDownladerSetting()
|
||||
loadDownloaderSetting()
|
||||
loadMediaServerSetting()
|
||||
loadMediaSettings()
|
||||
})
|
||||
@@ -351,12 +351,14 @@ onMounted(() => {
|
||||
chips
|
||||
:items="Downloaders"
|
||||
label="当前使用下载器"
|
||||
hint="MoviePilot自动添加的下载任务将使用选中的第1个下载器"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderSettings.TORRENT_TAG"
|
||||
label="下载器种子标签"
|
||||
hint="设置种子标签用于区分MoviePilot添加的下载任务,默认标签为`MOVIEPILOT`"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -365,6 +367,7 @@ onMounted(() => {
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.DOWNLOADER_MONITOR"
|
||||
label="监控默认下载器"
|
||||
hint="监控选中的第1个下载器,当任务下载完成时自动整理文件到媒体库"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -394,6 +397,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.QB_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT,如启用了HTTPS,请使用https://IP:PORT"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -401,6 +405,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.QB_USER"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="QB的登录用户名"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -408,24 +413,28 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.QB_PASSWORD"
|
||||
type="password"
|
||||
label="密码"
|
||||
hint="QB的登录密码"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_CATEGORY"
|
||||
label="自动分类管理"
|
||||
hint="开启后,下载目录将由QB控制自动下载到分类到目录,此时MoviePilot的下载目录设定无效,需在QB中提前创建分类"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_SEQUENTIAL"
|
||||
label="顺序下载"
|
||||
hint="开启后QB将按照文件顺序依次下载"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="downloaderSettings.QB_FORCE_RESUME"
|
||||
label="强制继续"
|
||||
hint="开启后,QB将设置为强制继续、强制上传模式(带[F]标识)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -439,6 +448,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.TR_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT,如启用了HTTPS,请使用https://IP:PORT"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -446,6 +456,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.TR_USER"
|
||||
label="用户名"
|
||||
placeholder="admin"
|
||||
hint="TR的登录用户名"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -453,6 +464,7 @@ onMounted(() => {
|
||||
v-model="downloaderSettings.TR_PASSWORD"
|
||||
type="password"
|
||||
label="密码"
|
||||
hint="TR的登录密码"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -492,6 +504,7 @@ onMounted(() => {
|
||||
chips
|
||||
:items="MediaServers"
|
||||
label="当前使用媒体服务器"
|
||||
hint="媒体服务器用于搜索下载等判断库中是否已存在,以避免重复下载"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -499,6 +512,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.MEDIASERVER_SYNC_INTERVAL"
|
||||
:items="syncIntervalItems"
|
||||
label="同步周期"
|
||||
hint="设置后数据将定时同步到MoviePilot数据库,以便展示媒体库是否存在标识"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -506,6 +520,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.MEDIASERVER_SYNC_BLACKLIST"
|
||||
label="媒体库同步黑名单"
|
||||
placeholder="使用,分隔"
|
||||
hint="设置不同步数据的媒体库名称,使用,分隔,如:电影,电视剧"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -538,6 +553,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.EMBY_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -545,12 +561,14 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.EMBY_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Emby时将优先使用此地址"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.EMBY_API_KEY"
|
||||
label="API密钥"
|
||||
hint="Emby的API密钥,在 Emby设置->高级->API 密钥 中生成"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -564,6 +582,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.JELLYFIN_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -571,12 +590,14 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.JELLYFIN_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Jellyfin时将优先使用此地址"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.JELLYFIN_API_KEY"
|
||||
label="API密钥"
|
||||
hint="Jellyfin的API密钥,在 Jellyfin设置->高级->API 密钥 中生成"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -590,6 +611,7 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.PLEX_HOST"
|
||||
label="地址"
|
||||
placeholder="IP:PORT"
|
||||
hint="格式:IP:PORT 或 http(s)://IP:PORT/"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
@@ -597,12 +619,14 @@ onMounted(() => {
|
||||
v-model="mediaServerSettings.PLEX_PLAY_HOST"
|
||||
label="外网播放地址"
|
||||
placeholder="http(s)://domain:port"
|
||||
hint="格式:http(s)://domain:port,设置后跳转Plex时将优先使用此地址"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaServerSettings.PLEX_TOKEN"
|
||||
label="API密钥"
|
||||
hint="Plex网页Url中的X-Plex-Token,通过浏览器F12->网络从请求URL中获取"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -640,30 +664,35 @@ onMounted(() => {
|
||||
v-model="mediaSettings.DOWNLOAD_PATH"
|
||||
label="下载目录"
|
||||
:rules="[requiredValidator]"
|
||||
hint="MoviePilot添加的下载任务的默认保存目录,必须设置"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaSettings.DOWNLOAD_MOVIE_PATH"
|
||||
label="电影下载目录"
|
||||
hint="为电影设置单独的下载保存目录,不设置则使用下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaSettings.DOWNLOAD_TV_PATH"
|
||||
label="电视剧下载目录"
|
||||
hint="为电视剧设置单独的下载保存目录,不设置则使用下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaSettings.DOWNLOAD_ANIME_PATH"
|
||||
label="动漫下载目录"
|
||||
hint="为动漫设置单独的下载保存目录,不设置则使用下载目录"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaSettings.DOWNLOAD_CATEGORY"
|
||||
label="下载目录自动分类"
|
||||
hint="开启后,下载任务保存目录将根据二级分类策略自动分类存放到下载目录的二级子目录中,二级分类策略需要编辑配置文件目录下的`category.yml`文件,插件市场有提供文件编辑插件"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -673,6 +702,7 @@ onMounted(() => {
|
||||
v-model="mediaSettings.TRANSFER_TYPE"
|
||||
:items="transferTypeItems"
|
||||
label="整理方式"
|
||||
hint="硬链接需要确保下载目录和媒体库目录不跨盘、不跨共享目录、不分别映射;rclone需要手动在容器中完成配置,且配置名为:`MP`"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -680,12 +710,14 @@ onMounted(() => {
|
||||
v-model="mediaSettings.OVERWRITE_MODE"
|
||||
:items="overwriteModeItems"
|
||||
label="覆盖模式"
|
||||
hint="从不覆盖:不覆盖已存在的文件;按大小覆盖:大文件将覆盖小文件;总是覆盖:总是覆盖已存在的文件;仅保留最新版本:保留最新版本的文件,删除其它版本的文件"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaSettings.SCRAP_METADATA"
|
||||
label="自动刮削媒体信息"
|
||||
hint="开启后,整理完成后将自动刮削媒体信息,如海报、简介等"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -696,6 +728,7 @@ onMounted(() => {
|
||||
label="媒体库目录"
|
||||
placeholder="多个目录使用,分隔"
|
||||
:rules="[requiredValidator]"
|
||||
hint="整理完成后的媒体文件存放的根目录,所有整理场景下未设定目的目录时都将整理到该目录下,必须设置"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -703,6 +736,7 @@ onMounted(() => {
|
||||
v-model="mediaSettings.LIBRARY_MOVIE_NAME"
|
||||
label="电影目录名称"
|
||||
placeholder="电影"
|
||||
hint="设置电影的存放一级目录名称,不设置则使用使用`电影`做为目录名称"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -710,6 +744,7 @@ onMounted(() => {
|
||||
v-model="mediaSettings.LIBRARY_TV_NAME"
|
||||
label="电视剧目录名称"
|
||||
placeholder="电视剧"
|
||||
hint="设置电视剧的存放一级目录名称,不设置则使用使用`电视剧`做为目录名称"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -717,12 +752,14 @@ onMounted(() => {
|
||||
v-model="mediaSettings.LIBRARY_ANIME_NAME"
|
||||
label="动漫目录名称"
|
||||
placeholder="动漫"
|
||||
hint="设置动漫的存放一级目录名称,不设置则使用使用`动漫`做为目录名称"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaSettings.LIBRARY_CATEGORY"
|
||||
label="媒体库目录自动分类"
|
||||
hint="开启后,整理完成后的媒体文件将根据二级分类策略自动分类存放到媒体库一级目录的二级子目录中,二级分类策略需要编辑配置文件目录下的`category.yml`文件,插件市场有提供文件编辑插件"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
@@ -167,6 +167,7 @@ onMounted(() => {
|
||||
v-model="customIdentifiers"
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行为一组"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行为一组"
|
||||
/>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
@@ -204,6 +205,7 @@ onMounted(() => {
|
||||
v-model="customReleaseGroups"
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
|
||||
/>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
@@ -224,6 +226,7 @@ onMounted(() => {
|
||||
v-model="customization"
|
||||
auto-grow
|
||||
placeholder="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
|
||||
hint="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
|
||||
/>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
@@ -244,6 +247,7 @@ onMounted(() => {
|
||||
v-model="transferExcludeWords"
|
||||
auto-grow
|
||||
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
||||
hint="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
|
||||
/>
|
||||
</VCardItem>
|
||||
<VCardItem>
|
||||
|
||||
@@ -3,7 +3,8 @@ import api from '@/api'
|
||||
import type { Site } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SiteAddEditForm from '@/components/form/SiteAddEditForm.vue'
|
||||
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||
import { useDefer } from '@/@core/utils/dom'
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Site[]>([])
|
||||
@@ -14,11 +15,15 @@ const isRefreshed = ref(false)
|
||||
// 新增站点对话框
|
||||
const siteAddDialog = ref(false)
|
||||
|
||||
// 延迟加载
|
||||
let defer = (_: number) => true
|
||||
|
||||
// 获取站点列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
dataList.value = await api.get('site/')
|
||||
isRefreshed.value = true
|
||||
defer = useDefer(dataList.value.length)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
@@ -30,28 +35,26 @@ onBeforeMount(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<LoadingBanner
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
v-if="!isRefreshed"
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
class="mt-12"
|
||||
/>
|
||||
<div
|
||||
v-if="dataList.length > 0"
|
||||
class="grid gap-3 grid-site-card"
|
||||
>
|
||||
<SiteCard
|
||||
v-for="data in dataList"
|
||||
:key="data.id"
|
||||
:site="data"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
/>
|
||||
<div
|
||||
v-for="(data, index) in dataList"
|
||||
:key="index"
|
||||
>
|
||||
<SiteCard
|
||||
v-if="defer(index)"
|
||||
:key="data.id"
|
||||
:site="data"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
@@ -60,14 +63,17 @@ onBeforeMount(fetchData)
|
||||
error-description="已添加并支持的站点将会在这里显示。"
|
||||
/>
|
||||
<!-- 新增站点按钮 -->
|
||||
<VBtn
|
||||
<VFab
|
||||
icon="mdi-plus"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
class="fixed right-5 bottom-5"
|
||||
oper="add"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="siteAddDialog = true"
|
||||
/>
|
||||
<SiteAddEditForm
|
||||
<!-- 新增站点弹窗 -->
|
||||
<SiteAddEditDialog
|
||||
v-if="siteAddDialog"
|
||||
v-model="siteAddDialog"
|
||||
oper="add"
|
||||
|
||||
@@ -155,7 +155,7 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</div>
|
||||
<div class="md:hidden">
|
||||
<VTooltip :text="`${arg.event.title} ${arg.event.extendedProps.subtitle}`">
|
||||
<VTooltip :text="`${arg.event.title} 第 ${arg.event.extendedProps.subtitle} 集`">
|
||||
<template #activator="{ props }">
|
||||
<VImg
|
||||
height="60"
|
||||
@@ -384,8 +384,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.v-application .fc .fc-daygrid-day-number {
|
||||
padding-block: 0rem;
|
||||
padding-inline: 0rem;
|
||||
padding-block: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-list-event-dot {
|
||||
@@ -435,7 +435,7 @@ onMounted(() => {
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1264px) {
|
||||
@media (width <= 1264px) {
|
||||
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-drawerToggler-button {
|
||||
display: block !important;
|
||||
}
|
||||
@@ -481,10 +481,10 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.v-application .fc .fc-button-primary {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
color: var(--v-theme-on-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-button-primary:hover {
|
||||
@@ -492,7 +492,7 @@ onMounted(() => {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@media (max-width: 776px) {
|
||||
@media (width <= 776px) {
|
||||
.fc-daygrid-event-harness {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,8 @@ import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
||||
import store from '@/store'
|
||||
|
||||
// 输入参数
|
||||
@@ -17,6 +19,12 @@ const isRefreshed = ref(false)
|
||||
// 数据列表
|
||||
const dataList = ref<Subscribe[]>([])
|
||||
|
||||
// 弹窗
|
||||
const subscribeEditDialog = ref(false)
|
||||
|
||||
// 历史记录弹窗
|
||||
const historyDialog = ref(false)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -54,17 +62,10 @@ const filteredDataList = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<LoadingBanner
|
||||
v-if="!isRefreshed"
|
||||
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
|
||||
>
|
||||
<VProgressCircular
|
||||
v-if="!isRefreshed"
|
||||
size="48"
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
class="mt-12"
|
||||
/>
|
||||
<PullRefresh
|
||||
v-model="loading"
|
||||
@refresh="onRefresh"
|
||||
@@ -88,6 +89,44 @@ const filteredDataList = computed(() => {
|
||||
error-description="请通过搜索添加电影、电视剧订阅。"
|
||||
/>
|
||||
</PullRefresh>
|
||||
<!-- 底部操作按钮 -->
|
||||
<VFab
|
||||
icon="mdi-file-document-edit"
|
||||
location="bottom end"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="subscribeEditDialog = true"
|
||||
/>
|
||||
<VFab
|
||||
icon="mdi-history"
|
||||
color="info"
|
||||
location="bottom end"
|
||||
class="mb-2"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="historyDialog = true"
|
||||
/>
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
v-model="subscribeEditDialog"
|
||||
:default="true"
|
||||
:type="props.type"
|
||||
@save="subscribeEditDialog = false"
|
||||
@close="subscribeEditDialog = false"
|
||||
/>
|
||||
<!-- 历史记录弹窗 -->
|
||||
<SubscribeHistoryDialog
|
||||
v-if="historyDialog"
|
||||
v-model="historyDialog"
|
||||
:type="props.type"
|
||||
@close="historyDialog = false"
|
||||
@save="() => {historyDialog = false; fetchData()}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||